├── .git-blame-ignore-revs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yaml │ ├── handle-scala-steward-prs.yml │ └── sbt-dependency-graph.yaml ├── .gitignore ├── .prout.json ├── .scalafmt.conf ├── .tool-versions ├── README.md ├── build.sbt ├── cloudformation └── membership-attribute-service.yaml ├── membership-attribute-service ├── app │ ├── actions │ │ ├── AuthAndBackendViaAuthLibAction.scala │ │ ├── AuthAndBackendViaIdapiAction.scala │ │ └── CommonActions.scala │ ├── components │ │ ├── TouchpointBackends.scala │ │ └── TouchpointComponents.scala │ ├── configuration │ │ ├── ApplicationName.scala │ │ ├── CreateTestUsernames.scala │ │ ├── OptionalConfig.scala │ │ ├── SentryConfig.scala │ │ └── Stage.scala │ ├── controllers │ │ ├── AccountController.scala │ │ ├── AttributeController.scala │ │ ├── Cached.scala │ │ ├── ContactController.scala │ │ ├── ExistingPaymentOptionsController.scala │ │ ├── HealthCheckController.scala │ │ └── PaymentUpdateController.scala │ ├── filters │ │ ├── AddEC2InstanceHeader.scala │ │ ├── AddGuIdentityHeaders.scala │ │ ├── CheckCacheHeadersFilter.scala │ │ ├── Headers.scala │ │ └── TestUserChecker.scala │ ├── json │ │ ├── PaymentCardUpdateResultWriters.scala │ │ └── package.scala │ ├── loghandling │ │ ├── DeprecatedRequestLogger.scala │ │ └── StopWatch.scala │ ├── models │ │ ├── AccessScope.scala │ │ ├── AccountDetails.scala │ │ ├── ApiError.scala │ │ ├── ApiErrors.scala │ │ ├── Attributes.scala │ │ ├── ContactAndSubscription.scala │ │ ├── ContributionData.scala │ │ ├── DeliveryAddress.scala │ │ ├── DynamoSupporterRatePlanItem.scala │ │ ├── ExistingPaymentOption.scala │ │ ├── FeastApp.scala │ │ ├── Features.scala │ │ ├── Fixtures.scala │ │ ├── GatewayOwner.scala │ │ ├── MobileSubscriptionStatus.scala │ │ ├── ProductsResponse.scala │ │ ├── SelfServiceCancellation.scala │ │ ├── SupportReminders.scala │ │ └── UserFromToken.scala │ ├── monitoring │ │ ├── BatchedMetrics.scala │ │ ├── CloudWatch.scala │ │ ├── CreateMetrics.scala │ │ ├── ErrorHandling.scala │ │ ├── Metrics.scala │ │ └── SentryLogging.scala │ ├── services │ │ ├── AccountDetailsFromZuora.scala │ │ ├── AuthenticationService.scala │ │ ├── ContributionsStoreDatabaseService.scala │ │ ├── DynamoSupporterProductDataService.scala │ │ ├── GuardianPatronService.scala │ │ ├── HealthCheckableService.scala │ │ ├── IdentityAuthService.scala │ │ ├── MobileSubscriptionService.scala │ │ ├── PaymentDetailsForSubscription.scala │ │ ├── PaymentFailureAlerter.scala │ │ ├── SalesforceService.scala │ │ ├── SupporterRatePlanToAttributesMapper.scala │ │ ├── mail │ │ │ ├── AwsSQSSend.scala │ │ │ ├── Emails.scala │ │ │ ├── SendEmail.scala │ │ │ └── SqsAsync.scala │ │ ├── salesforce │ │ │ ├── ContactRepository.scala │ │ │ └── SimpleContactRepository.scala │ │ ├── stripe │ │ │ ├── BasicStripeService.scala │ │ │ ├── ChooseStripe.scala │ │ │ ├── HttpBasicStripeService.scala │ │ │ └── StripeService.scala │ │ ├── subscription │ │ │ ├── CancelSubscription.scala │ │ │ ├── Sequence.scala │ │ │ └── Trace.scala │ │ └── zuora │ │ │ ├── payment │ │ │ ├── PaymentService.scala │ │ │ └── SetPaymentCard.scala │ │ │ └── rest │ │ │ ├── SimpleClientZuoraRestService.scala │ │ │ └── ZuoraRestService.scala │ ├── utils │ │ ├── ListTEither.scala │ │ ├── OptionTEither.scala │ │ └── SimpleEitherT.scala │ └── wiring │ │ └── AppLoader.scala ├── build-tc.sh ├── conf │ ├── CODE.public.conf │ ├── DEV.public.conf │ ├── PROD.public.conf │ ├── TEST.public.conf │ ├── application.conf │ ├── logback.xml │ ├── riff-raff.yaml │ ├── routes │ ├── touchpoint.CODE.conf │ └── touchpoint.PROD.conf └── test │ ├── acceptance │ ├── AcceptanceTest.scala │ ├── AccountControllerAcceptanceTest.scala │ ├── AttributeControllerAcceptanceTest.scala │ ├── HasIdentityMockServer.scala │ ├── HasPlayServer.scala │ ├── PaymentUpdateControllerAcceptanceTest.scala │ ├── data │ │ ├── IdentityResponse.scala │ │ ├── Randoms.scala │ │ ├── TestAccountSummary.scala │ │ ├── TestContact.scala │ │ ├── TestPaidSubscriptionPlan.scala │ │ ├── TestPaymentSummary.scala │ │ ├── TestPreviewInvoiceItem.scala │ │ ├── TestPricingSummary.scala │ │ ├── TestQueriesAccount.scala │ │ ├── TestQueriesContact.scala │ │ ├── TestQueriesPaymentMethod.scala │ │ ├── TestSingleCharge.scala │ │ ├── TestSubscription.scala │ │ └── stripe │ │ │ ├── TestCustomersPaymentMethods.scala │ │ │ ├── TestDynamoSupporterRatePlanItem.scala │ │ │ ├── TestStripeCard.scala │ │ │ ├── TestStripeCustomer.scala │ │ │ └── TestStripeSubscription.scala │ └── util │ │ └── AvailablePort.scala │ ├── actions │ └── AuthAndBackendViaIdapiActionTest.scala │ ├── controllers │ ├── AccountControllerTest.scala │ ├── AttributeControllerTest.scala │ └── CachedTest.scala │ ├── filters │ ├── AddGuIdentityHeadersTest.scala │ └── HeadersTest.scala │ ├── integration │ └── AccountDetailsFromZuoraIntegrationTest.scala │ ├── models │ ├── AnniversaryDateTest.scala │ ├── ApiErrorTest.scala │ ├── AttributesTest.scala │ ├── DeliveryAddressTest.scala │ ├── FilterPlansSpec.scala │ ├── ProductsResponseSpec.scala │ ├── SelfServiceCancellationTest.scala │ └── UserFromTokenTest.scala │ ├── resources │ ├── contacts.csv │ └── logback.xml │ ├── services │ ├── EmailDataTest.scala │ ├── FakeContributionsStoreDatabaseService.scala │ ├── IdentityAuthServiceTest.scala │ ├── PaymentDetailsForSubscriptionTest.scala │ ├── PaymentFailureAlerterTest.scala │ ├── SupporterProductDataIntegrationTest.scala │ └── SupporterRatePlanToAttributesMapperTest.scala │ └── testdata │ ├── AccountObjectTestData.scala │ ├── SubscriptionTestData.scala │ └── TestLogPrefix.scala ├── membership-common ├── .gitignore ├── README.md ├── conf │ ├── reference.conf │ ├── touchpoint.CODE.conf │ └── touchpoint.PROD.conf └── src │ ├── main │ └── scala │ │ └── com │ │ └── gu │ │ ├── aws │ │ ├── AwsS3Client.scala │ │ └── package.scala │ │ ├── config │ │ └── SubsV2ProductIds.scala │ │ ├── identity │ │ ├── IdapiConfig.scala │ │ └── IdapiService.scala │ │ ├── lib │ │ └── DateDSL.scala │ │ ├── memsub │ │ ├── Address.scala │ │ ├── BillingPeriod.scala │ │ ├── BillingSchedule.scala │ │ ├── FullName.scala │ │ ├── NormalisedTelephoneNumber.scala │ │ ├── PaymentCardUpdateResult.scala │ │ ├── PaymentMethod.scala │ │ ├── Price.scala │ │ ├── PriceParser.scala │ │ ├── PricingSummary.scala │ │ ├── ProductFamily.scala │ │ ├── Subscription.scala │ │ ├── SupplierCode.scala │ │ ├── promo │ │ │ ├── LogImplicit.scala │ │ │ └── Promotion.scala │ │ ├── subsv2 │ │ │ ├── Catalog.scala │ │ │ ├── Plan.scala │ │ │ ├── Subscription.scala │ │ │ ├── reads │ │ │ │ ├── CatJsonReads.scala │ │ │ │ ├── CommonReads.scala │ │ │ │ └── SubJsonReads.scala │ │ │ └── services │ │ │ │ ├── CatalogService.scala │ │ │ │ └── SubscriptionService.scala │ │ └── util │ │ │ ├── FutureRetry.scala │ │ │ ├── ScheduledTask.scala │ │ │ ├── Timing.scala │ │ │ └── WebServiceHelper.scala │ │ ├── monitoring │ │ ├── CloudWatch.scala │ │ ├── Metrics.scala │ │ ├── SafeLogger.scala │ │ ├── SalesforceMetrics.scala │ │ └── ZuoraMetrics.scala │ │ ├── okhttp │ │ └── RequestRunners.scala │ │ ├── salesforce │ │ ├── Contact.scala │ │ ├── ContactDeserializer.scala │ │ ├── ContactRecordType.scala │ │ ├── SalesforceConfig.scala │ │ ├── Scalaforce.scala │ │ ├── Tier.scala │ │ └── job │ │ │ ├── Action.scala │ │ │ ├── Reader.scala │ │ │ └── Result.scala │ │ ├── services │ │ └── model │ │ │ └── PaymentDetails.scala │ │ ├── stripe │ │ ├── Stripe.scala │ │ └── StripeService.scala │ │ ├── touchpoint │ │ └── TouchpointBackendConfig.scala │ │ └── zuora │ │ ├── ZuoraApiConfig.scala │ │ ├── ZuoraLookup.scala │ │ ├── ZuoraSoapService.scala │ │ ├── api │ │ └── PaymentGateway.scala │ │ ├── rest │ │ ├── Readers.scala │ │ ├── SimpleClient.scala │ │ └── package.scala │ │ └── soap │ │ ├── Client.scala │ │ ├── Readers.scala │ │ ├── ServiceHelpers.scala │ │ ├── ZuoraFilters.scala │ │ ├── actions │ │ ├── Action.scala │ │ ├── Actions.scala │ │ └── XmlWriterAction.scala │ │ ├── models │ │ ├── Commands.scala │ │ ├── Errors.scala │ │ ├── PaymentSummary.scala │ │ ├── Query.scala │ │ └── Result.scala │ │ ├── package.scala │ │ ├── readers │ │ ├── Query.scala │ │ ├── Reader.scala │ │ └── Result.scala │ │ └── writers │ │ ├── Command.scala │ │ └── XmlWriter.scala │ └── test │ ├── resources │ ├── batch-info-list-completed.xml │ ├── batch-info-list-failed.xml │ ├── batch-info-list-in-progress.xml │ ├── batch-info.xml │ ├── cas │ │ ├── error.json │ │ ├── expired-subscription.json │ │ ├── valid-subscription-optional-fields.json │ │ └── valid-subscription.json │ ├── job-info.xml │ ├── model │ │ └── zuora │ │ │ ├── accounts.xml │ │ │ ├── amend-result-preview.xml │ │ │ ├── amend-result.xml │ │ │ ├── amendments.xml │ │ │ ├── authentication-success.xml │ │ │ ├── create-result.xml │ │ │ ├── fault-error.xml │ │ │ ├── invalid.xml │ │ │ ├── invoice-result.xml │ │ │ ├── payment-gateway-error.xml │ │ │ ├── query-empty.xml │ │ │ ├── query-not-done.xml │ │ │ ├── query-single.xml │ │ │ ├── rateplancharges.xml │ │ │ ├── rateplans.xml │ │ │ ├── result-error-fatal.xml │ │ │ ├── result-error-non-fatal.xml │ │ │ ├── subscribe-result.xml │ │ │ ├── subscriptions.xml │ │ │ └── update-result.xml │ ├── non-member-contact.json │ ├── paid-member.json │ ├── promo │ │ ├── campaign │ │ │ ├── digitalpack.json │ │ │ ├── invalid.json │ │ │ ├── membership.json │ │ │ ├── newspaper.json │ │ │ └── weekly.json │ │ ├── membership │ │ │ ├── discount.json │ │ │ ├── images.json │ │ │ ├── incentive.json │ │ │ ├── invalid-type.json │ │ │ └── tracking.json │ │ └── subscriptions │ │ │ ├── discount.json │ │ │ ├── double-type.json │ │ │ ├── freetrial.json │ │ │ ├── incentive.json │ │ │ ├── invalid-landingPage-sectionColour.json │ │ │ ├── invalid-type.json │ │ │ ├── retention.json │ │ │ └── tracking.json │ ├── query-result.xml │ ├── query-rows-empty.xml │ ├── query-rows-results.xml │ ├── rest │ │ ├── AmendmentResult.json │ │ ├── Cancelled.json │ │ ├── Catalog.json │ │ ├── CatalogProd.json │ │ ├── CatalogUat.json │ │ ├── Downgrade.json │ │ ├── DowngradeNonRecurring.json │ │ ├── GiftSubscriptions.json │ │ ├── Holiday-Deleted.json │ │ ├── Holiday.json │ │ ├── OneTime.json │ │ ├── OneTimeWithDowngrades.json │ │ ├── PatronReaderType.json │ │ ├── Promotion.json │ │ ├── SupporterDiscountUat.json │ │ ├── accounts │ │ │ ├── AccountQueryResponse.json │ │ │ └── Migrated.json │ │ ├── cancellation │ │ │ ├── GW-6for6-lead-time.json │ │ │ ├── GW-6for6-segment-6for6.json │ │ │ ├── GW-before-bill-run.json │ │ │ └── GW-stale-chargeThroughDate.json │ │ ├── paymentmethod │ │ │ └── PaymentMethod.json │ │ └── plans │ │ │ ├── Credits.json │ │ │ ├── Digi.json │ │ │ ├── EchoLegacy.json │ │ │ ├── Promo.json │ │ │ ├── SPlus.json │ │ │ ├── Upgraded.json │ │ │ ├── WeeklyOneYear.json │ │ │ ├── WeeklySixMonthly.json │ │ │ ├── WeeklySixMonths.json │ │ │ ├── WithRecurringFixedDiscount.json │ │ │ └── accounts │ │ │ ├── Digi.json │ │ │ ├── SPlus.json │ │ │ └── adlite-cancelled.json │ ├── salesforce │ │ ├── contact-get.response.ex-member.json │ │ ├── contact-upsert.response.error.json │ │ └── contact-upsert.response.good.json │ ├── stripe │ │ ├── error.json │ │ ├── event.json │ │ ├── failedCharge.json │ │ └── subscription.json │ └── subs-member.json │ └── scala │ ├── com │ └── gu │ │ ├── Diff.scala │ │ ├── config │ │ └── SubsV2ProductIdsTest.scala │ │ ├── i18n │ │ ├── AddressTest.scala │ │ └── CountryGroupTest.scala │ │ ├── memsub │ │ ├── BillingScheduleTest.scala │ │ ├── NormalisedTelephoneNumberTest.scala │ │ ├── PriceTest.scala │ │ ├── SupplierCodeTest.scala │ │ └── subsv2 │ │ │ ├── Fixtures.scala │ │ │ ├── reads │ │ │ ├── CatReadsTest.scala │ │ │ └── SubReadsTest.scala │ │ │ └── services │ │ │ ├── CatalogServiceTest.scala │ │ │ ├── SubscriptionServiceTest.scala │ │ │ └── TestCatalog.scala │ │ ├── salesforce │ │ ├── ContactDeserializerTest.scala │ │ └── job │ │ │ └── ReaderTest.scala │ │ ├── stripe │ │ └── StripeDeserialiserTest.scala │ │ └── zuora │ │ ├── ZuoraSoapServiceTest.scala │ │ ├── rest │ │ └── SimpleClientTest.scala │ │ └── soap │ │ ├── ActionTest.scala │ │ ├── ClientTest.scala │ │ ├── DeserializerTest.scala │ │ ├── ServiceHelpersTest.scala │ │ ├── ZuoraDeserializerSpec.scala │ │ └── subscribe │ │ ├── RatePlanTest.scala │ │ └── SubscribeTest.scala │ └── utils │ ├── Resource.scala │ └── TestLogPrefix.scala ├── nginx ├── members-data-api.conf └── setup.sh ├── project ├── Dependencies.scala ├── MembershipCommonDependencies.scala ├── build.properties └── plugins.sbt ├── start-api-debug.sh └── start-api.sh /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.7.0 2 | 94b4ac056a1531dddaefc27f3c6ac9377b786a1b 3 | # Include scalafmt checks as part of CI #902 4 | 719ff459615e2b7d44026d91e4f7602c8efb592e 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | ### Why do we need this? 6 | 7 | ### The changes 8 | 9 | ### trello card/screenshot/json/related PRs etc 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | workflow_dispatch: 4 | push: 5 | jobs: 6 | CI: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | id-token: write 10 | contents: read 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: aws-actions/configure-aws-credentials@v1 14 | with: 15 | role-to-assume: ${{ secrets.GU_RIFF_RAFF_ROLE_ARN }} 16 | aws-region: eu-west-1 17 | - uses: guardian/setup-scala@v1 18 | - name: CI 19 | run: | 20 | LAST_TEAMCITY_BUILD=11657 21 | export GITHUB_RUN_NUMBER=$(( $GITHUB_RUN_NUMBER + $LAST_TEAMCITY_BUILD )) 22 | sbt "project membership-common" scalafmtCheckAll test "project membership-attribute-service" clean scalafmtCheckAll scalafmtSbtCheck riffRaffUpload 23 | -------------------------------------------------------------------------------- /.github/workflows/handle-scala-steward-prs.yml: -------------------------------------------------------------------------------- 1 | name: Scala Steward PR handling 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | test: 9 | name: Test Scala Steward PR 10 | if: github.actor == 'scala-steward' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - 14 | name: Checkout repo 15 | uses: actions/checkout@v3 16 | - 17 | uses: guardian/setup-scala@v1 18 | - 19 | name: Run tests 20 | run: sbt test 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/sbt-dependency-graph.yaml: -------------------------------------------------------------------------------- 1 | name: Update Dependency Graph for sbt 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | jobs: 8 | dependency-graph: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout branch 12 | id: checkout 13 | uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 14 | - name: Install Java 15 | id: java 16 | uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.2.0 17 | with: 18 | distribution: corretto 19 | java-version: 17 20 | - name: Install sbt 21 | id: sbt 22 | uses: sbt/setup-sbt@8a071aa780c993c7a204c785d04d3e8eb64ef272 # v1.1.0 23 | - name: Submit dependencies 24 | id: submit 25 | uses: scalacenter/sbt-dependency-submission@64084844d2b0a9b6c3765f33acde2fbe3f5ae7d3 # v3.1.0 26 | - name: Log snapshot for user validation 27 | id: validate 28 | run: cat ${{ steps.submit.outputs.snapshot-json-path }} | jq 29 | permissions: 30 | contents: write 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .history 3 | .bundle 4 | .idea 5 | .idea_modules 6 | *.iml 7 | *.sublime-project 8 | *.sublime-workspace 9 | .scala_dependencies 10 | .worksheet 11 | .sc 12 | 13 | dev 14 | dist 15 | logs 16 | out 17 | target 18 | tmp 19 | project/project 20 | project/target 21 | dynamodb-local 22 | 23 | api/conf/keys.conf 24 | api/public/ 25 | .bsp 26 | 27 | .bloop/ 28 | .metals/ 29 | .vscode/ 30 | project/.bloop/ 31 | project/metals.sbt 32 | -------------------------------------------------------------------------------- /.prout.json: -------------------------------------------------------------------------------- 1 | { 2 | "checkpoints": { 3 | "PROD": { "url": "https://members-data-api.theguardian.com/healthcheck", "overdue": "14M" } 4 | }, 5 | "sentry": { 6 | "projects": ["members-data-api"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.7.3 2 | runner.dialect=scala213 3 | maxColumn = 150 4 | align.preset = none 5 | trailingCommas = always 6 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | java corretto-11.0.25.9.1 -------------------------------------------------------------------------------- /membership-attribute-service/app/actions/AuthAndBackendViaAuthLibAction.scala: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import com.gu.identity.auth.AccessScope 4 | import components.TouchpointBackends 5 | import filters.TestUserChecker 6 | import play.api.mvc.{ActionRefiner, Request, Result, Results} 7 | import services.AuthenticationFailure 8 | 9 | import scala.concurrent.{ExecutionContext, Future} 10 | 11 | class AuthAndBackendViaAuthLibAction( 12 | touchpointBackends: TouchpointBackends, 13 | requiredScopes: List[AccessScope], 14 | testUserChecker: TestUserChecker, 15 | )(implicit 16 | ex: ExecutionContext, 17 | ) extends ActionRefiner[Request, AuthenticatedUserAndBackendRequest] { 18 | override val executionContext = ex 19 | 20 | override protected def refine[A](request: Request[A]): Future[Either[Result, AuthenticatedUserAndBackendRequest[A]]] = { 21 | touchpointBackends.normal.identityAuthService.user(requiredScopes)(request) map { 22 | case Left(AuthenticationFailure.Unauthorised) => Left(Results.Unauthorized) 23 | case Left(AuthenticationFailure.Forbidden) => Left(Results.Forbidden) 24 | case Right(authenticatedUser) => 25 | val backendConf = if (testUserChecker.isTestUser(authenticatedUser.primaryEmailAddress)(authenticatedUser.logPrefix)) { 26 | touchpointBackends.test 27 | } else { 28 | touchpointBackends.normal 29 | } 30 | Right(new AuthenticatedUserAndBackendRequest[A](authenticatedUser, backendConf, request)) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /membership-attribute-service/app/configuration/ApplicationName.scala: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | object ApplicationName { 4 | val applicationName = "members-data-api" 5 | } 6 | -------------------------------------------------------------------------------- /membership-attribute-service/app/configuration/CreateTestUsernames.scala: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import com.gu.identity.testing.usernames.{Encoder, TestUsernames} 4 | import com.typesafe.config.Config 5 | 6 | import java.time.Duration 7 | 8 | object CreateTestUsernames { 9 | def from(config: Config) = TestUsernames(Encoder.withSecret(config.getString("identity.test.users.secret")), Duration.ofDays(2)) 10 | } 11 | -------------------------------------------------------------------------------- /membership-attribute-service/app/configuration/OptionalConfig.scala: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import com.typesafe.config.Config 4 | 5 | object OptionalConfig { 6 | implicit class RichConfig(config: Config) { 7 | def optionalValue[T](key: String, f: Config => T, config: Config): Option[T] = 8 | if (config.hasPath(key)) Some(f(config)) else None 9 | 10 | def optionalBoolean(key: String, default: Boolean): Boolean = optionalValue(key, _.getBoolean(key), config).getOrElse(default) 11 | 12 | def optionalString(key: String): Option[String] = optionalValue(key, _.getString(key), config) 13 | 14 | def optionalConfig(key: String): Option[Config] = optionalValue(key, _.getConfig(key), config) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /membership-attribute-service/app/configuration/SentryConfig.scala: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import com.typesafe.config.Config 4 | import configuration.OptionalConfig._ 5 | 6 | class SentryConfig(private val config: Config) { 7 | val stage = config.getString("stage") 8 | val sentryDsn = config.optionalString("sentry.dsn") 9 | } 10 | -------------------------------------------------------------------------------- /membership-attribute-service/app/configuration/Stage.scala: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | case class Stage(value: String) 4 | -------------------------------------------------------------------------------- /membership-attribute-service/app/controllers/Cached.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import com.github.nscala_time.time.Imports._ 4 | import org.joda.time.DateTime 5 | import play.api.mvc.Result 6 | 7 | import scala.math.max 8 | 9 | object Cached { 10 | 11 | private val cacheableStatusCodes = Seq(200, 301, 404) 12 | 13 | private val tenDaysInSeconds = 10.days.standardDuration.seconds 14 | 15 | def apply(result: Result): Result = apply(60)(result) 16 | 17 | def apply(seconds: Int)(result: Result): Result = { 18 | if (suitableForCaching(result)) cacheHeaders(seconds, result) else result 19 | } 20 | 21 | def suitableForCaching(result: Result): Boolean = cacheableStatusCodes.contains(result.header.status) 22 | 23 | private def cacheHeaders(maxAge: Int, result: Result) = { 24 | val now = DateTime.now 25 | val staleWhileRevalidateSeconds = max(maxAge / 10, 1) 26 | result.withHeaders( 27 | "Cache-Control" -> s"public, max-age=$maxAge, stale-while-revalidate=$staleWhileRevalidateSeconds, stale-if-error=$tenDaysInSeconds", 28 | "Expires" -> toHttpDateTimeString(now + maxAge.seconds), 29 | "Date" -> toHttpDateTimeString(now), 30 | ) 31 | } 32 | 33 | // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1 34 | private val HTTPDateFormat = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'").withZone(DateTimeZone.UTC) 35 | def toHttpDateTimeString(dateTime: DateTime): String = dateTime.toString(HTTPDateFormat) 36 | } 37 | 38 | object NoCache { 39 | def apply(result: Result): Result = result.withHeaders("Cache-Control" -> "no-cache, private", "Pragma" -> "no-cache") 40 | } 41 | -------------------------------------------------------------------------------- /membership-attribute-service/app/controllers/HealthCheckController.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import com.gu.monitoring.SafeLogging 4 | import components.TouchpointBackends 5 | import play.api.libs.json.Json 6 | import play.api.mvc.{BaseController, ControllerComponents} 7 | import services.HealthCheckableService 8 | 9 | trait Test { 10 | def ok: Boolean 11 | def messages: Seq[String] = Nil 12 | } 13 | 14 | class BoolTest(name: String, exec: () => Boolean) extends Test { 15 | override def messages = List(s"Test $name failed, health check will fail") 16 | override def ok = exec() 17 | } 18 | 19 | class HealthCheckController(touchPointBackends: TouchpointBackends, override val controllerComponents: ControllerComponents) 20 | extends BaseController 21 | with SafeLogging { 22 | 23 | val touchpointComponents = touchPointBackends.normal 24 | // behaviourService, Stripe and all Zuora services are not critical 25 | private lazy val services: Set[HealthCheckableService] = Set( 26 | touchpointComponents.salesforceService, 27 | touchpointComponents.zuoraSoapService, 28 | ) 29 | 30 | private lazy val tests = services.map(service => new BoolTest(service.serviceName, () => service.checkHealth)) 31 | 32 | def healthCheck() = Action { 33 | Cached(1) { 34 | val failures = tests.filterNot(_.ok) 35 | 36 | if (failures.isEmpty) { 37 | Ok(Json.obj("status" -> "ok", "gitCommitId" -> app.BuildInfo.gitCommitId)) 38 | } else { 39 | failures.flatMap(_.messages).foreach(msg => logger.warnNoPrefix(msg)) 40 | ServiceUnavailable("Service Unavailable") 41 | } 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /membership-attribute-service/app/filters/AddEC2InstanceHeader.scala: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import org.apache.pekko.stream.Materializer 4 | import play.api.libs.ws.WSClient 5 | import play.api.mvc._ 6 | 7 | import scala.concurrent.{ExecutionContext, Future} 8 | 9 | class AddEC2InstanceHeader(wSClient: WSClient)(implicit val mat: Materializer, ex: ExecutionContext) extends Filter { 10 | 11 | // http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html 12 | lazy val instanceIdF = wSClient.url("http://169.254.169.254/latest/meta-data/instance-id").get().map(_.body) 13 | 14 | def apply(nextFilter: RequestHeader => Future[Result])(requestHeader: RequestHeader): Future[Result] = for { 15 | result <- nextFilter(requestHeader) 16 | } yield { 17 | val instanceIdOpt = instanceIdF.value.flatMap(_.toOption) // We don't want to block if the value is not available 18 | instanceIdOpt.fold(result)(instanceId => result.withHeaders("X-EC2-instance-id" -> instanceId)) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /membership-attribute-service/app/filters/CheckCacheHeadersFilter.scala: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import org.apache.pekko.stream.Materializer 4 | import controllers.Cached.suitableForCaching 5 | import play.api.mvc._ 6 | 7 | import scala.concurrent.{ExecutionContext, Future} 8 | 9 | class CheckCacheHeadersFilter(implicit val mat: Materializer, ex: ExecutionContext) extends Filter { 10 | 11 | def apply(nextFilter: RequestHeader => Future[Result])(requestHeader: RequestHeader): Future[Result] = { 12 | nextFilter(requestHeader).map { result => 13 | if (requestHeader.method.toUpperCase != "OPTIONS" && suitableForCaching(result)) { 14 | val hasCacheControl = result.header.headers.contains("Cache-Control") 15 | assert( 16 | hasCacheControl, 17 | s"Cache-Control not set. Ensure controller response has Cache-Control header set for ${requestHeader.path}. Throwing exception... ", 18 | ) 19 | } 20 | result 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /membership-attribute-service/app/filters/Headers.scala: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import play.api.mvc 4 | 5 | object Headers { 6 | implicit class EnrichedHeaders(headers: mvc.Headers) { 7 | def forwardedFor = headers.get("X-Forwarded-For") map { _.split(",\\s+").toList } 8 | } 9 | 10 | implicit class EnrichedRequestHeader(header: mvc.RequestHeader) { 11 | 12 | /** Remote address, taking X-Forwarded-For into consideration */ 13 | def realRemoteAddr = header.headers.forwardedFor.flatMap(_.headOption).getOrElse(header.remoteAddress) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /membership-attribute-service/app/filters/TestUserChecker.scala: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import com.gu.identity.testing.usernames.TestUsernames 4 | import com.gu.monitoring.SafeLogger.LogPrefix 5 | import com.gu.monitoring.SafeLogging 6 | 7 | class TestUserChecker(testUsernames: TestUsernames) extends SafeLogging { 8 | def isTestUser(primaryEmailAddress: String)(implicit logPrefix: LogPrefix): Boolean = { 9 | val isTestUser = testUsernames.isValidEmail(primaryEmailAddress) 10 | if (isTestUser) { 11 | logger.info(primaryEmailAddress + " is a test user") 12 | } 13 | isTestUser 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /membership-attribute-service/app/json/PaymentCardUpdateResultWriters.scala: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import com.gu.memsub.{CardUpdateFailure, CardUpdateSuccess, PaymentCard} 4 | import play.api.libs.json._ 5 | import play.api.libs.functional.syntax._ 6 | 7 | object PaymentCardUpdateResultWriters { 8 | 9 | implicit val paymentCardWrites: Writes[PaymentCard] = Writes[PaymentCard] { paymentCard => 10 | Json.obj("type" -> paymentCard.cardType.getOrElse[String]("unknown").replace(" ", "")) ++ 11 | paymentCard.paymentCardDetails 12 | .map(details => 13 | Json.obj( 14 | "last4" -> details.lastFourDigits, 15 | "expiryMonth" -> details.expiryMonth, 16 | "expiryYear" -> details.expiryYear, 17 | ), 18 | ) 19 | .getOrElse(Json.obj("last4" -> "••••")) // effectively impossible to happen as this is used in a card update context 20 | } 21 | 22 | implicit val cardUpdateSuccessWrites = Writes[CardUpdateSuccess] { cus => 23 | paymentCardWrites.writes(cus.newPaymentCard) 24 | } 25 | 26 | implicit val cardUpdateFailureWrites: Writes[CardUpdateFailure] = ( 27 | (JsPath \ "type").write[String] and 28 | (JsPath \ "message").write[String] and 29 | (JsPath \ "code").write[String] 30 | )(unlift(CardUpdateFailure.unapply)) 31 | } 32 | -------------------------------------------------------------------------------- /membership-attribute-service/app/json/package.scala: -------------------------------------------------------------------------------- 1 | import org.joda.time.LocalDate 2 | import play.api.libs.functional.syntax._ 3 | import play.api.libs.json.{OWrites, Writes, _} 4 | 5 | package object json { 6 | 7 | // Adapted from http://kailuowang.blogspot.co.uk/2013/11/addremove-fields-to-plays-default-case.html 8 | implicit class RichOWrites[A](writes: OWrites[A]) { 9 | def addField[T: Writes](fieldName: String, field: A => T): OWrites[A] = 10 | (writes ~ (__ \ fieldName).write[T])((a: A) => (a, field(a))) 11 | 12 | def addNullableField[T: Writes](fieldName: String, field: A => Option[T]): OWrites[A] = 13 | (writes ~ (__ \ fieldName).writeNullable[T])((a: A) => (a, field(a))) 14 | 15 | def removeField(fieldName: String): OWrites[A] = OWrites { a: A => 16 | val transformer = (__ \ fieldName).json.prune 17 | Json.toJson(a)(writes).validate(transformer).get 18 | } 19 | } 20 | 21 | implicit val localDateWrites = new Writes[LocalDate] { 22 | override def writes(d: LocalDate) = JsString(d.toString("yyyy-MM-dd")) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /membership-attribute-service/app/loghandling/DeprecatedRequestLogger.scala: -------------------------------------------------------------------------------- 1 | package loghandling 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | import com.gu.monitoring.SafeLogging 5 | import play.api.mvc.{AnyContent, WrappedRequest} 6 | 7 | object DeprecatedRequestLogger extends SafeLogging { 8 | 9 | val deprecatedSearchPhrase = "DeprecatedEndpointCalled" 10 | 11 | def logDeprecatedRequest(request: WrappedRequest[AnyContent])(implicit logPrefix: LogPrefix): Unit = { 12 | logger.info(s"$deprecatedSearchPhrase ${request.method} ${request.path} with headers ${request.headers.toMap} with body ${request.body}") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /membership-attribute-service/app/loghandling/StopWatch.scala: -------------------------------------------------------------------------------- 1 | package loghandling 2 | 3 | import com.gu.monitoring.SafeLogging 4 | 5 | object StopWatch { 6 | def apply() = new StopWatch 7 | } 8 | 9 | class StopWatch extends SafeLogging { 10 | private val startTime = System.currentTimeMillis 11 | 12 | def elapsed: Long = System.currentTimeMillis - startTime 13 | 14 | override def toString() = s"${elapsed}ms" 15 | } 16 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/AccessScope.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import com.gu.identity.auth.{AccessScope => IdentityAccessScope} 4 | 5 | /**

Scope that endpoints need from access tokens before they can carry out requests. For background, see Oauth scopes

7 | * 8 | *

To add scopes, the process is described in IdentityAccessScope

10 | * 11 | *

Scope name values have to match the values stored in Okta.

12 | */ 13 | object AccessScope { 14 | 15 | /** Allows the client to read basic non-sensitive data relating to the user's Guardian subscriptions and contributions. 16 | */ 17 | case object readSelf extends IdentityAccessScope { 18 | val name = "guardian.members-data-api.read.self" 19 | } 20 | 21 | /** Allows the client to read the complete data relating to the user's Guardian subscriptions and contributions. 22 | */ 23 | case object completeReadSelf extends IdentityAccessScope { 24 | val name = "guardian.members-data-api.complete.read.self.secure" 25 | } 26 | 27 | /** Allows the client to update data relating to the user's Guardian subscriptions and contributions. 28 | */ 29 | case object updateSelf extends IdentityAccessScope { 30 | val name = "guardian.members-data-api.update.self.secure" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/ApiError.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import controllers.NoCache 4 | import play.api.libs.json._ 5 | import play.api.mvc._ 6 | 7 | import scala.language.implicitConversions 8 | 9 | case class ApiError(message: String, details: String, statusCode: Int) 10 | 11 | object ApiError { 12 | implicit val apiErrorWrites = new Writes[ApiError] { 13 | override def writes(o: ApiError): JsValue = Json.obj( 14 | "message" -> o.message, 15 | "details" -> o.details, 16 | "statusCode" -> o.statusCode, 17 | ) 18 | } 19 | implicit def apiErrorToResult(err: ApiError): Result = { 20 | NoCache(Results.Status(err.statusCode)(Json.toJson(err))) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/ApiErrors.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | object ApiErrors { 4 | def badRequest(msg: String): ApiError = 5 | ApiError( 6 | message = "Bad Request", 7 | details = msg, 8 | statusCode = 400, 9 | ) 10 | 11 | val notFound: ApiError = 12 | ApiError( 13 | message = "Not found", 14 | details = "Not Found", 15 | statusCode = 404, 16 | ) 17 | 18 | val internalError: ApiError = 19 | ApiError( 20 | message = "Internal Server Error", 21 | details = "Internal Server Error", 22 | statusCode = 500, 23 | ) 24 | 25 | val cookiesRequired: ApiError = 26 | ApiError( 27 | message = "Unauthorised", 28 | details = "Valid GU_U and SC_GU_U cookies are required.", 29 | statusCode = 401, 30 | ) 31 | 32 | val unauthorized = ApiError( 33 | message = "Unauthorized", 34 | details = "Failed to authenticate", 35 | statusCode = 401, 36 | ) 37 | 38 | val forbidden = ApiError( 39 | message = "Forbidden", 40 | details = "Insufficient authority to access endpoint", 41 | statusCode = 403, 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/ContactAndSubscription.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import com.gu.memsub.subsv2.Subscription 4 | import com.gu.salesforce.Contact 5 | 6 | case class ContactAndSubscription( 7 | contact: Contact, 8 | subscription: Subscription, 9 | isGiftRedemption: Boolean, 10 | ) 11 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/ContributionData.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import java.util.Date 4 | 5 | import anorm.{RowParser, Macro, ~} 6 | import play.api.libs.json.{JsValue, Json, Writes} 7 | 8 | case class ContributionData( 9 | created: Date, 10 | currency: String, 11 | amount: BigDecimal, 12 | status: String, 13 | ) 14 | 15 | object ContributionData { 16 | implicit val contributionDataWrites = new Writes[ContributionData] { 17 | override def writes(o: ContributionData): JsValue = Json.obj( 18 | "created" -> o.created, 19 | "currency" -> o.currency.toString, 20 | "price" -> o.amount, 21 | "status" -> o.status, 22 | ) 23 | } 24 | 25 | val contributionRowParser: RowParser[ContributionData] = Macro.indexedParser[ContributionData] 26 | } 27 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/DeliveryAddress.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import com.gu.salesforce.Contact 4 | import play.api.libs.json.{Format, Json} 5 | 6 | case class DeliveryAddress( 7 | addressLine1: Option[String], 8 | addressLine2: Option[String], 9 | town: Option[String], 10 | region: Option[String], 11 | postcode: Option[String], 12 | country: Option[String], 13 | addressChangeInformation: Option[String], 14 | instructions: Option[String], 15 | ) 16 | 17 | object DeliveryAddress { 18 | implicit val format: Format[DeliveryAddress] = Json.format[DeliveryAddress] 19 | 20 | def fromContact(contact: Contact): DeliveryAddress = { 21 | val addressLines = splitAddressLines(contact.mailingStreet) 22 | DeliveryAddress( 23 | addressLine1 = addressLines.map(_._1), 24 | addressLine2 = addressLines.map(_._2), 25 | town = contact.mailingCity, 26 | region = contact.mailingState, 27 | postcode = contact.mailingPostcode, 28 | country = contact.mailingCountry, 29 | addressChangeInformation = None, 30 | instructions = contact.deliveryInstructions, 31 | ) 32 | } 33 | 34 | def splitAddressLines(addressLine: Option[String]): Option[(String, String)] = 35 | addressLine map { line => 36 | val n = line.lastIndexOf(',') 37 | if (n == -1) (line, "") 38 | else (line.take(n).trim, line.drop(n + 1).trim) 39 | } 40 | 41 | def mergeAddressLines(address: DeliveryAddress): Option[String] = 42 | (address.addressLine1, address.addressLine2) match { 43 | case (Some(line1), Some(line2)) => Some(s"${line1.trim},${line2.trim}") 44 | case (Some(line1), None) => Some(line1.trim) 45 | case (None, Some(line2)) => Some(line2.trim) 46 | case (None, None) => None 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/DynamoSupporterRatePlanItem.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import com.gu.i18n.Currency 4 | import org.joda.time.LocalDate 5 | import play.api.libs.json.{Writes, __} 6 | 7 | object DynamoSupporterRatePlanItem { 8 | implicit val currencyWrite: Writes[Currency] = __.write[String].contramap(_.iso) 9 | } 10 | 11 | case class DynamoSupporterRatePlanItem( 12 | subscriptionName: String, // Unique identifier for the subscription 13 | identityId: String, // Unique identifier for user 14 | productRatePlanId: String, // Unique identifier for the product in this rate plan 15 | termEndDate: LocalDate, // Date that this subscription term ends 16 | contractEffectiveDate: LocalDate, // Date that this subscription started 17 | cancellationDate: Option[LocalDate], // If this subscription has been cancelled this will be set 18 | contributionAmount: Option[BigDecimal], 19 | contributionCurrency: Option[Currency], 20 | ) 21 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/FeastApp.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import models.FeastApp.IosSubscriptionGroupIds.IntroductoryOffer 4 | import org.joda.time.LocalDate 5 | 6 | object FeastApp { 7 | 8 | object IosSubscriptionGroupIds { 9 | // Subscription group ids are used by the app to tell the app store which subscription option to show to the user 10 | val IntroductoryOffer = "21396030" 11 | } 12 | 13 | object AndroidOfferTags { 14 | // Offer tags are the Android equivalent of iOS subscription groups - used by the app to work out which offer to show to the user 15 | val IntroductoryOffer = "initial_supporter_launch_offer" 16 | } 17 | 18 | def shouldGetFeastAccess(attributes: Attributes): Boolean = 19 | attributes.isPartnerTier || 20 | attributes.isPatronTier || 21 | attributes.isSupporterTier || 22 | attributes.isGuardianPatron || 23 | attributes.digitalSubscriberHasActivePlan || 24 | attributes.isSupporterPlus || 25 | attributes.isPaperSubscriber 26 | 27 | private def shouldShowSubscriptionOptions(attributes: Attributes) = !shouldGetFeastAccess(attributes) 28 | 29 | def getFeastIosSubscriptionGroup(attributes: Attributes): Option[String] = 30 | if (shouldShowSubscriptionOptions(attributes)) 31 | Some(IntroductoryOffer) 32 | else None 33 | 34 | def getFeastAndroidOfferTags(attributes: Attributes): Option[List[String]] = 35 | if (shouldShowSubscriptionOptions(attributes)) 36 | Some(List(AndroidOfferTags.IntroductoryOffer)) 37 | else None 38 | } 39 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/Features.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import org.joda.time.LocalDate 4 | import play.api.libs.json.{Json} 5 | import play.api.mvc.Result 6 | import play.api.mvc.Results.Ok 7 | import json.localDateWrites 8 | import scala.language.implicitConversions 9 | 10 | object Features { 11 | 12 | implicit val jsWrite = Json.writes[Features] 13 | 14 | implicit def toResult(attrs: Features): Result = 15 | Ok(Json.toJson(attrs)) 16 | 17 | def fromAttributes(attributes: Attributes) = { 18 | Features( 19 | userId = Some(attributes.UserId), 20 | adblockMessage = !attributes.isPaidTier, 21 | membershipJoinDate = attributes.MembershipJoinDate, 22 | ) 23 | } 24 | 25 | val unauthenticated = Features(None, adblockMessage = true, None) 26 | } 27 | 28 | case class Features( 29 | userId: Option[String], 30 | adblockMessage: Boolean, 31 | membershipJoinDate: Option[LocalDate], 32 | ) 33 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/Fixtures.scala: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/GatewayOwner.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | sealed abstract class GatewayOwner(val value: Option[String]) 4 | object GatewayOwner { 5 | case object TortoiseMedia extends GatewayOwner(Some("tortoise-media")) 6 | case object Default extends GatewayOwner(None) 7 | 8 | def fromString(value: Option[String]): GatewayOwner = { 9 | value.map(_.toLowerCase) match { 10 | case Some("tortoise-media") => TortoiseMedia 11 | case _ => Default 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/MobileSubscriptionStatus.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import org.joda.time.DateTime 4 | import play.api.libs.json._ 5 | 6 | import scala.util.Try 7 | 8 | case class MobileSubscriptionStatus( 9 | valid: Boolean, 10 | to: DateTime, 11 | ) 12 | 13 | object MobileSubscriptionStatus { 14 | private implicit val dateTimeReads: Reads[DateTime] = new Reads[DateTime] { 15 | override def reads(json: JsValue): JsResult[DateTime] = json match { 16 | case JsString(date) => Try(DateTime.parse(date)).map(res => JsSuccess(res)).getOrElse(JsError(s"Unable to parse Date $date")) 17 | case _ => JsError("Unable to parse date, was expecting a JsString") 18 | } 19 | } 20 | implicit val mobileSubscriptionStatusReads: Reads[MobileSubscriptionStatus] = Json.reads[MobileSubscriptionStatus] 21 | } 22 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/ProductsResponse.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import com.gu.memsub.subsv2.Catalog 4 | import com.gu.monitoring.SafeLogger.LogPrefix 5 | import org.joda.time.LocalDate 6 | import play.api.libs.json._ 7 | 8 | case class UserDetails(firstName: Option[String], lastName: Option[String], email: String) 9 | 10 | case class ProductsResponse(user: UserDetails, products: List[AccountDetails]) 11 | 12 | class ProductsResponseWrites(catalog: Catalog, today: LocalDate) { 13 | implicit val userDetailsWrites: OWrites[UserDetails] = Json.writes[UserDetails] 14 | implicit def accountDetailsWrites(implicit logPrefix: LogPrefix): Writes[AccountDetails] = Writes[AccountDetails](_.toJson(catalog, today)) 15 | implicit def writes(implicit logPrefix: LogPrefix): OWrites[ProductsResponse] = Json.writes[ProductsResponse] 16 | 17 | def from(user: UserFromToken, products: List[AccountDetails]) = 18 | ProductsResponse( 19 | user = UserDetails( 20 | firstName = user.firstName, 21 | lastName = user.lastName, 22 | email = user.primaryEmailAddress, 23 | ), 24 | products = products, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/SelfServiceCancellation.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import com.gu.i18n.Country 4 | import com.gu.i18n.Country._ 5 | import com.gu.memsub.Product 6 | import com.gu.memsub.Product._ 7 | 8 | /* 9 | * this file aims to model https://docs.google.com/spreadsheets/d/1GydjiURBMRk8S_xD4iwbbIBpuXXYI5h3_M87DtRDV8I 10 | * */ 11 | case class SelfServiceCancellation( 12 | isAllowed: Boolean, 13 | shouldDisplayEmail: Boolean, 14 | phoneRegionsToDisplay: List[String], 15 | ) 16 | 17 | object SelfServiceCancellation { 18 | 19 | private val ukRowPhone = "UK & ROW" 20 | private val usaPhone = "US" 21 | private val ausPhone = "AUS" 22 | private val allPhones = List(ukRowPhone, usaPhone, ausPhone) 23 | 24 | def apply(product: Product, billingCountry: Option[Country]): SelfServiceCancellation = { 25 | 26 | if (isOneOf(product, Membership, Contribution, SupporterPlus, Digipack, TierThree, AdLite)) { 27 | SelfServiceCancellation( 28 | isAllowed = true, 29 | shouldDisplayEmail = true, 30 | phoneRegionsToDisplay = allPhones, 31 | ) 32 | } else if (billingCountry.contains(UK)) { 33 | SelfServiceCancellation( 34 | isAllowed = false, 35 | shouldDisplayEmail = false, 36 | phoneRegionsToDisplay = List(ukRowPhone), 37 | ) 38 | } else if (isOneOf(billingCountry, US, Canada)) { 39 | SelfServiceCancellation( 40 | isAllowed = true, 41 | shouldDisplayEmail = true, 42 | phoneRegionsToDisplay = List(usaPhone), 43 | ) 44 | } else { 45 | SelfServiceCancellation( 46 | isAllowed = true, 47 | shouldDisplayEmail = true, 48 | phoneRegionsToDisplay = allPhones, 49 | ) 50 | } 51 | } 52 | 53 | private def isOneOf[T](product: T, products: T*): Boolean = products.toSet.contains(product) 54 | private def isOneOf[T](product: Option[T], products: T*): Boolean = product.isDefined && products.toSet.contains(product.get) 55 | } 56 | -------------------------------------------------------------------------------- /membership-attribute-service/app/models/SupportReminders.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import anorm.{Macro, RowParser} 4 | import play.api.libs.json.{Json, Writes} 5 | 6 | sealed trait RecurringReminderStatus 7 | 8 | object RecurringReminderStatus { 9 | case object NotSet extends RecurringReminderStatus 10 | case object Active extends RecurringReminderStatus 11 | case object Cancelled extends RecurringReminderStatus 12 | 13 | implicit val writes = new Writes[RecurringReminderStatus] { 14 | def writes(status: RecurringReminderStatus) = status match { 15 | case NotSet => Json.toJson("NotSet") 16 | case Active => Json.toJson("Active") 17 | case Cancelled => Json.toJson("Cancelled") 18 | } 19 | } 20 | } 21 | 22 | case class SupportReminderDb( 23 | is_cancelled: Boolean, 24 | reminder_code: java.util.UUID, 25 | ) 26 | 27 | object SupportReminderDb { 28 | val supportReminderDbRowParser: RowParser[SupportReminderDb] = Macro.indexedParser[SupportReminderDb] 29 | } 30 | 31 | case class SupportReminders( 32 | recurringStatus: RecurringReminderStatus, 33 | recurringReminderCode: Option[String], 34 | ) 35 | 36 | object SupportReminders { 37 | implicit val jsWrite = Json.writes[SupportReminders] 38 | } 39 | -------------------------------------------------------------------------------- /membership-attribute-service/app/monitoring/BatchedMetrics.scala: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import org.apache.pekko.actor.ActorSystem 4 | import com.amazonaws.services.cloudwatch.AmazonCloudWatchAsync 5 | import com.amazonaws.services.cloudwatch.model.StandardUnit 6 | import com.typesafe.scalalogging.{LazyLogging, StrictLogging} 7 | import configuration.ApplicationName 8 | 9 | import java.util.concurrent.ConcurrentHashMap 10 | import java.util.concurrent.atomic.AtomicInteger 11 | import scala.concurrent.ExecutionContext 12 | import scala.concurrent.duration._ 13 | 14 | // Designed for high-frequency metrics, for example, 1000 per minute is about $400 per month 15 | final class BatchedMetrics( 16 | cloudwatch: CloudWatch, 17 | )(implicit system: ActorSystem, ec: ExecutionContext) { 18 | import scala.jdk.CollectionConverters._ 19 | private val countMap = new ConcurrentHashMap[String, AtomicInteger]().asScala // keep it first in the constructor 20 | 21 | val application = ApplicationName.applicationName 22 | 23 | system.scheduler.scheduleAtFixedRate(5.seconds, 60.seconds)(() => publishAllMetrics()) 24 | 25 | def incrementCount(key: String): Unit = 26 | countMap.getOrElseUpdate(key, new AtomicInteger(1)).incrementAndGet() 27 | 28 | private def resetCount(key: String): Unit = 29 | countMap.getOrElseUpdate(key, new AtomicInteger(0)).set(0) 30 | 31 | private def publishAllMetrics(): Unit = 32 | countMap.foreach { case (key, value) => 33 | cloudwatch.put(key, value.doubleValue(), StandardUnit.Count) 34 | resetCount(key) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /membership-attribute-service/app/monitoring/CloudWatch.scala: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import com.amazonaws.handlers.AsyncHandler 4 | import com.amazonaws.services.cloudwatch.AmazonCloudWatchAsync 5 | import com.amazonaws.services.cloudwatch.model._ 6 | import com.gu.monitoring.SafeLogging 7 | 8 | class CloudWatch(stage: String, application: String, service: String, cloudwatch: AmazonCloudWatchAsync) extends SafeLogging { 9 | private lazy val stageDimension = new Dimension().withName("Stage").withValue(stage) 10 | private lazy val servicesDimension = new Dimension().withName("Services").withValue(service) 11 | 12 | private val mandatoryDimensions = Seq(stageDimension, servicesDimension) 13 | 14 | protected[monitoring] def put(name: String, count: Double, unit: StandardUnit): Unit = { 15 | val metric = 16 | new MetricDatum() 17 | .withValue(count) 18 | .withMetricName(name) 19 | .withUnit(unit) 20 | .withDimensions(mandatoryDimensions: _*) 21 | 22 | val request = new PutMetricDataRequest().withNamespace(application).withMetricData(metric) 23 | 24 | cloudwatch.putMetricDataAsync(request, LoggingAsyncHandler) 25 | } 26 | } 27 | 28 | object LoggingAsyncHandler extends AsyncHandler[PutMetricDataRequest, PutMetricDataResult] with SafeLogging { 29 | def onError(exception: Exception): Unit = { 30 | logger.errorNoPrefix(scrub"CloudWatch PutMetricDataRequest error: ${exception.getMessage}}") 31 | } 32 | 33 | def onSuccess(request: PutMetricDataRequest, result: PutMetricDataResult): Unit = { 34 | logger.debug("CloudWatch PutMetricDataRequest - success") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /membership-attribute-service/app/monitoring/CreateMetrics.scala: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import org.apache.pekko.actor.ActorSystem 4 | import com.amazonaws.regions.Regions.EU_WEST_1 5 | import com.amazonaws.services.cloudwatch.model.StandardUnit 6 | import com.amazonaws.services.cloudwatch.{AmazonCloudWatchAsync, AmazonCloudWatchAsyncClient} 7 | import com.gu.aws.CredentialsProvider 8 | import configuration.{ApplicationName, Stage} 9 | 10 | import scala.concurrent.ExecutionContext 11 | 12 | trait CreateMetrics { 13 | def forService(service: Class[_]): Metrics 14 | def batchedForService(service: Class[_])(implicit system: ActorSystem, ec: ExecutionContext): BatchedMetrics 15 | } 16 | 17 | class CreateRealMetrics(stage: Stage) extends CreateMetrics { 18 | private val cloudwatch: AmazonCloudWatchAsync = AmazonCloudWatchAsyncClient.asyncBuilder 19 | .withCredentials(CredentialsProvider) 20 | .withRegion(EU_WEST_1) 21 | .build() 22 | 23 | private def cloudWatchWrapper(service: Class[_]) = 24 | new CloudWatch(stage.value, ApplicationName.applicationName, service.getSimpleName, cloudwatch) 25 | 26 | def forService(service: Class[_]): Metrics = Metrics(service.getSimpleName, cloudWatchWrapper(service)) 27 | 28 | def batchedForService(service: Class[_])(implicit system: ActorSystem, ec: ExecutionContext): BatchedMetrics = 29 | new BatchedMetrics(cloudWatchWrapper(service)) 30 | } 31 | 32 | object CreateNoopMetrics extends CreateMetrics { 33 | private object NoopCloudWatch extends CloudWatch("", "", "", null) { 34 | override protected[monitoring] def put(name: String, count: Double, unit: StandardUnit): Unit = () 35 | } 36 | 37 | override def forService(service: Class[_]): Metrics = new Metrics("", NoopCloudWatch) 38 | 39 | override def batchedForService(service: Class[_])(implicit system: ActorSystem, ec: ExecutionContext): BatchedMetrics = new BatchedMetrics( 40 | NoopCloudWatch, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /membership-attribute-service/app/monitoring/Metrics.scala: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import com.amazonaws.services.cloudwatch.model.StandardUnit 4 | import com.gu.monitoring.SafeLogging 5 | import utils.SimpleEitherT 6 | import utils.SimpleEitherT.SimpleEitherT 7 | 8 | import scala.concurrent.{ExecutionContext, Future} 9 | 10 | case class Metrics(service: String, cloudwatch: CloudWatch) extends SafeLogging { 11 | def incrementCount(metricName: String): Unit = cloudwatch.put(metricName + " count", 1, StandardUnit.Count) 12 | 13 | def measureDurationEither[T](metricName: String)(block: => SimpleEitherT[T])(implicit ec: ExecutionContext): SimpleEitherT[T] = 14 | SimpleEitherT(measureDuration(metricName)(block.run)) 15 | 16 | def measureDuration[T](metricName: String)(block: => Future[T])(implicit ec: ExecutionContext): Future[T] = { 17 | logger.debug(s"$metricName started...") 18 | incrementCount(metricName) 19 | val startTime = System.currentTimeMillis() 20 | 21 | def recordEnd[A](name: String)(value: A): A = { 22 | val duration = System.currentTimeMillis() - startTime 23 | cloudwatch.put(name + " duration ms", duration.toDouble, StandardUnit.Milliseconds) 24 | logger.debug(s"$service $name completed in $duration ms") 25 | 26 | value 27 | } 28 | 29 | block.transform(recordEnd(metricName), recordEnd(s"$metricName failed")) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /membership-attribute-service/app/monitoring/SentryLogging.scala: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import ch.qos.logback.classic.spi.ILoggingEvent 4 | import ch.qos.logback.core.filter.Filter 5 | import ch.qos.logback.core.spi.FilterReply 6 | import com.gu.monitoring.{SafeLogger, SafeLogging} 7 | import configuration.SentryConfig 8 | import io.sentry.Sentry 9 | 10 | import scala.util.{Failure, Success, Try} 11 | 12 | object SentryLogging extends SafeLogging { 13 | def init(config: SentryConfig): Unit = { 14 | config.sentryDsn match { 15 | case None => logger.warnNoPrefix("No Sentry logging configured (OK for dev)") 16 | case Some(sentryDSN) => 17 | logger.infoNoPrefix(s"Initialising Sentry logging") 18 | Try { 19 | Sentry.init(sentryDSN) 20 | val buildInfo: Map[String, String] = app.BuildInfo.toMap.view.mapValues(_.toString).toMap 21 | val tags = Map("stage" -> config.stage) ++ buildInfo 22 | tags.foreach { case (key, value) => 23 | Sentry.setTag(key, value) 24 | } 25 | } match { 26 | case Success(_) => logger.debug("Sentry logging configured.") 27 | case Failure(e) => logger.errorNoPrefix(scrub"Something went wrong when setting up Sentry logging ${e.getStackTrace}") 28 | } 29 | } 30 | 31 | } 32 | } 33 | 34 | class PiiFilter extends Filter[ILoggingEvent] { 35 | override def decide(event: ILoggingEvent): FilterReply = if (event.getMarker.contains(SafeLogger.sanitizedLogMessage)) FilterReply.ACCEPT 36 | else FilterReply.DENY 37 | } 38 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/AuthenticationService.scala: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import com.gu.identity.auth.AccessScope 4 | import models.UserFromToken 5 | import play.api.mvc.RequestHeader 6 | 7 | import scala.concurrent.Future 8 | 9 | trait AuthenticationService { 10 | def user(requiredScopes: List[AccessScope])(implicit request: RequestHeader): Future[Either[AuthenticationFailure, UserFromToken]] 11 | } 12 | 13 | /** See [[https://auth0.com/blog/forbidden-unauthorized-http-status-codes/]] for rationale. 14 | */ 15 | sealed trait AuthenticationFailure 16 | 17 | object AuthenticationFailure { 18 | 19 | /** Client has provided no credentials or invalid credentials. Should give a 401 response. 20 | */ 21 | case object Unauthorised extends AuthenticationFailure 22 | 23 | /** Client has valid credentials but not enough privileges to perform the action. Should give a 403 response. 24 | */ 25 | case object Forbidden extends AuthenticationFailure 26 | } 27 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/HealthCheckableService.scala: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | trait HealthCheckableService { 4 | def serviceName: String = this.getClass.getSimpleName 5 | def checkHealth: Boolean 6 | } 7 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/SalesforceService.scala: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import com.gu.salesforce.Scalaforce 4 | 5 | class SalesforceService(salesforce: Scalaforce) extends HealthCheckableService { 6 | override def checkHealth: Boolean = salesforce.isAuthenticated 7 | } 8 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/mail/AwsSQSSend.scala: -------------------------------------------------------------------------------- 1 | package services.mail 2 | 3 | import play.api.libs.json.{JsObject, JsString, JsValue, Json} 4 | import software.amazon.awssdk.auth.credentials.{AwsCredentialsProviderChain, InstanceProfileCredentialsProvider, ProfileCredentialsProvider} 5 | 6 | case class QueueName(value: String) extends AnyVal 7 | 8 | case class EmailData(emailAddress: String, salesforceContactId: String, campaignName: String, dataPoints: Map[String, String]) { 9 | def toJson: JsValue = { 10 | Json.parse( 11 | s""" 12 | |{ 13 | | "To":{ 14 | | "Address": "$emailAddress", 15 | | "ContactAttributes":{ 16 | | "SubscriberAttributes": ${mapToJson(dataPoints)} 17 | | } 18 | | }, 19 | | "DataExtensionName": "$campaignName", 20 | | "SfContactId": "$salesforceContactId" 21 | |}""".stripMargin, 22 | ) 23 | } 24 | 25 | private def mapToJson(map: Map[String, String]): JsObject = { 26 | JsObject(map.map { case (key, value) => 27 | key -> JsString(value) 28 | }.toSeq) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/mail/SendEmail.scala: -------------------------------------------------------------------------------- 1 | package services.mail 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | import play.api.libs.json.Json 5 | 6 | import scala.concurrent.Future 7 | 8 | trait SendEmail { 9 | def send(emailData: EmailData)(implicit logPrefix: LogPrefix): Future[Unit] 10 | } 11 | 12 | class SendEmailToSQS(queueName: QueueName) extends SendEmail { 13 | val sendAsync = new SqsAsync 14 | 15 | override def send(emailData: EmailData)(implicit logPrefix: LogPrefix): Future[Unit] = sendAsync.send(queueName, Json.prettyPrint(emailData.toJson)) 16 | } 17 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/salesforce/ContactRepository.scala: -------------------------------------------------------------------------------- 1 | package services.salesforce 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | import com.gu.salesforce.{Contact, ContactId} 5 | import play.api.libs.json._ 6 | import scalaz.\/ 7 | 8 | import scala.concurrent.Future 9 | 10 | trait ContactRepository { 11 | 12 | def get(identityId: String)(implicit logPrefix: LogPrefix): Future[String \/ Option[Contact]] 13 | 14 | def update(contactId: String, contactFields: Map[String, String])(implicit logPrefix: LogPrefix): Future[Unit] 15 | } 16 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/stripe/BasicStripeService.scala: -------------------------------------------------------------------------------- 1 | package services.stripe 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | import com.gu.stripe.Stripe 5 | import com.gu.stripe.Stripe.{Customer, CustomersPaymentMethods, StripeObject} 6 | 7 | import scala.concurrent.Future 8 | 9 | trait BasicStripeService { 10 | def fetchCustomer(customerId: String)(implicit logPrefix: LogPrefix): Future[Customer] 11 | 12 | @Deprecated 13 | def createCustomer(card: String)(implicit logPrefix: LogPrefix): Future[Customer] 14 | 15 | def createCustomerWithStripePaymentMethod(stripePaymentMethodID: String)(implicit logPrefix: LogPrefix): Future[Customer] 16 | 17 | def fetchPaymentMethod(customerId: String)(implicit logPrefix: LogPrefix): Future[CustomersPaymentMethods] 18 | 19 | def fetchSubscription(id: String)(implicit logPrefix: LogPrefix): Future[Stripe.Subscription] 20 | } 21 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/stripe/StripeService.scala: -------------------------------------------------------------------------------- 1 | package services.stripe 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | import com.gu.stripe.Stripe._ 5 | import com.gu.stripe.StripeServiceConfig 6 | import com.gu.zuora.api.{PaymentGateway, RegionalStripeGateways} 7 | 8 | import scala.concurrent.{ExecutionContext, Future} 9 | import com.gu.zuora.api.StripeTortoiseMediaPaymentIntentsMembershipGateway 10 | import com.gu.zuora.api.StripeAUPaymentIntentsMembershipGateway 11 | import com.gu.zuora.api.StripeUKPaymentIntentsMembershipGateway 12 | 13 | class StripeService( 14 | apiConfig: StripeServiceConfig, 15 | val basicStripeService: BasicStripeService, 16 | ) { 17 | val paymentIntentsGateway: PaymentGateway = 18 | apiConfig.variant match { 19 | case "tortoise-media" => StripeTortoiseMediaPaymentIntentsMembershipGateway 20 | case "au-membership" => StripeAUPaymentIntentsMembershipGateway 21 | case _ => StripeUKPaymentIntentsMembershipGateway 22 | } 23 | 24 | def createCustomer(card: String)(implicit logPrefix: LogPrefix): Future[Customer] = basicStripeService.createCustomer(card) 25 | 26 | def createCustomerWithStripePaymentMethod(stripePaymentMethodID: String)(implicit logPrefix: LogPrefix): Future[Customer] = 27 | basicStripeService.createCustomerWithStripePaymentMethod(stripePaymentMethodID) 28 | } 29 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/subscription/Sequence.scala: -------------------------------------------------------------------------------- 1 | package services.subscription 2 | 3 | import scalaz.{-\/, NonEmptyList, \/, \/-} 4 | 5 | /* 6 | Sequence turns a list of either into an either of list. In this case, it does it by putting all the rights into a list and returning 7 | that as a right. However if there are no rights, it will return a left of any lefts. 8 | This is mostly useful if we want to try a load of things and hopefully one will succeed. It's not too good in case things 9 | go wrong, we don't know which ones should have failed and which shouldn't have. But at least it keeps most of the errors. 10 | */ 11 | object Sequence { 12 | 13 | def apply[A](eitherList: List[String \/ A]): String \/ NonEmptyList[A] = { 14 | val zero = (List[String](), List[A]()) 15 | val product = eitherList.foldRight(zero)({ 16 | case (-\/(left), (accuLeft, accuRight)) => (left :: accuLeft, accuRight) 17 | case (\/-(right), (accuLeft, accuRight)) => (accuLeft, right :: accuRight) 18 | }) 19 | // if any are right, return them all, otherwise return all the left 20 | product match { 21 | case (Nil, Nil) => -\/("no subscriptions found at all, even invalid ones") // no failures or successes 22 | case (errors, Nil) => -\/(errors.mkString("\n")) // no successes 23 | case (_, result :: results) => \/-(NonEmptyList.fromSeq(result, results)) // discard some errors as long as some worked (log it?) 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /membership-attribute-service/app/services/subscription/Trace.scala: -------------------------------------------------------------------------------- 1 | package services.subscription 2 | 3 | import scalaz.{-\/, \/} 4 | 5 | object Trace { 6 | 7 | implicit class Traceable[T](t: String \/ T) { 8 | def withTrace(message: String): String \/ T = t match { 9 | case -\/(e) => -\/(s"$message: {$e}") 10 | case right => right 11 | } 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /membership-attribute-service/app/utils/OptionTEither.scala: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import scalaz.{OptionT, \/} 4 | import utils.SimpleEitherT.SimpleEitherT 5 | 6 | import scala.concurrent.{ExecutionContext, Future} 7 | 8 | object OptionTEither { 9 | type OptionTEither[A] = OptionT[SimpleEitherT, A] 10 | 11 | def apply[A](m: Future[\/[String, Option[A]]]): OptionTEither[A] = 12 | OptionT[SimpleEitherT, A](SimpleEitherT(m)) 13 | 14 | private def liftOptionDisjunction[A](x: Future[\/[String, A]])(implicit ex: ExecutionContext): OptionTEither[A] = 15 | apply(x.map(_.map[Option[A]](Some.apply))) 16 | 17 | def liftOption[A](x: Future[Either[String, A]])(implicit ex: ExecutionContext): OptionTEither[A] = 18 | liftOptionDisjunction(x.map(\/.fromEither)) 19 | 20 | def some[A](value: A)(implicit ex: ExecutionContext): OptionTEither[A] = 21 | apply(SimpleEitherT.right(Option(value)).run) 22 | 23 | def fromOption[A](x: Option[A]): OptionTEither[A] = 24 | apply(Future.successful(\/.right[String, Option[A]](x))) 25 | 26 | def fromFutureOption[A](future: Future[Option[A]])(implicit ex: ExecutionContext): OptionTEither[A] = { 27 | apply(future.map(\/.right[String, Option[A]](_))) 28 | } 29 | def fromFuture[A](future: Future[A])(implicit ex: ExecutionContext): OptionTEither[A] = 30 | fromFutureOption(future.map(Some(_))) 31 | } 32 | -------------------------------------------------------------------------------- /membership-attribute-service/app/utils/SimpleEitherT.scala: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import scalaz.std.scalaFuture._ 4 | import scalaz.{EitherT, \/} 5 | import utils.ListTEither.ListTEither 6 | 7 | import scala.concurrent.{ExecutionContext, Future} 8 | 9 | object SimpleEitherT { 10 | type SimpleEitherT[A] = EitherT[String, Future, A] 11 | 12 | def apply[T](f: Future[\/[String, T]]): SimpleEitherT[T] = EitherT(f) 13 | 14 | def apply[T](f: Future[Either[String, T]])(implicit ec: ExecutionContext): SimpleEitherT[T] = 15 | SimpleEitherT(f.map(\/.fromEither)) 16 | 17 | def rightT[T](x: Future[T])(implicit ec: ExecutionContext): SimpleEitherT[T] = { 18 | SimpleEitherT[T](x.map(\/.right[String, T])) 19 | } 20 | 21 | def leftT[T](x: Future[String])(implicit ec: ExecutionContext): SimpleEitherT[T] = { 22 | SimpleEitherT[T](x.map(\/.left[String, T])) 23 | } 24 | 25 | def right[T](x: T)(implicit ec: ExecutionContext): SimpleEitherT[T] = 26 | rightT(Future.successful(x)) 27 | 28 | def left[T](x: String)(implicit ec: ExecutionContext): SimpleEitherT[T] = 29 | leftT[T](Future.successful(x)) 30 | 31 | def fromFutureOption[T](f: Future[\/[String, Option[T]]], errorMessage: String)(implicit ec: ExecutionContext): SimpleEitherT[T] = 32 | SimpleEitherT(f).flatMap { 33 | case Some(value) => right(value) 34 | case _ => left(errorMessage) 35 | } 36 | 37 | def fromEither[T](either: Either[String, T])(implicit ec: ExecutionContext): SimpleEitherT[T] = 38 | apply(Future.successful(either)) 39 | 40 | def fromListT[T](value: ListTEither[T])(implicit ec: ExecutionContext): SimpleEitherT[List[T]] = SimpleEitherT(value.toList.run) 41 | } 42 | -------------------------------------------------------------------------------- /membership-attribute-service/build-tc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo ${BUILD_NUMBER:-DEV} > conf/build.txt 3 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/CODE.public.conf: -------------------------------------------------------------------------------- 1 | include "DEV.public" 2 | stage=CODE 3 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/DEV.public.conf: -------------------------------------------------------------------------------- 1 | include "touchpoint.CODE.conf" 2 | 3 | identity { 4 | production.keys = false 5 | test.users.secret="a-non-secure-key-for-our-dev-env-only" 6 | } 7 | 8 | touchpoint.backend.default=CODE 9 | touchpoint.backend.test=CODE 10 | 11 | stage=DEV 12 | 13 | play.filters.cors.supportsCredentials = true 14 | 15 | play.filters.cors.allowedOrigins = [ 16 | "https://m.code.dev-theguardian.com", 17 | "https://subscribe.code.dev-theguardian.com", 18 | "https://profile.code.dev-theguardian.com", 19 | "https://profile.thegulocal.com", 20 | "https://membership.thegulocal.com", 21 | "https://mem.thegulocal.com", 22 | "https://m.thegulocal.com", 23 | "https://subscribe.thegulocal.com", 24 | "https://sub.thegulocal.com", 25 | "https://thegulocal.com", 26 | "https://interactive.guimlocal.co.uk", 27 | "https://support.code.dev-theguardian.com", 28 | "https://support.thegulocal.com" 29 | ] 30 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/PROD.public.conf: -------------------------------------------------------------------------------- 1 | include "touchpoint.PROD.conf" 2 | include "touchpoint.CODE.conf" 3 | 4 | stage=PROD 5 | 6 | identity.production.keys=true 7 | 8 | logger.play=ERROR 9 | logger.application=INFO 10 | 11 | touchpoint.backend.default=PROD 12 | touchpoint.backend.test=CODE 13 | 14 | play.filters.cors.supportsCredentials = true 15 | 16 | play.filters.cors.allowedOrigins = [ 17 | "https://www.theguardian.com", 18 | "https://membership.theguardian.com", 19 | "https://profile.theguardian.com", 20 | "https://subscribe.theguardian.com", 21 | "https://support.theguardian.com", 22 | "https://interactive.guim.co.uk" 23 | ] 24 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/TEST.public.conf: -------------------------------------------------------------------------------- 1 | include "DEV.public" 2 | include "application" 3 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/application.conf: -------------------------------------------------------------------------------- 1 | use-fixtures=false 2 | 3 | play.application.loader= wiring.AppLoader 4 | 5 | #### Play Configuration 6 | 7 | # Secret key 8 | # ~~~~~ 9 | application.crypto.secret="" 10 | 11 | # The application languages 12 | # ~~~~~ 13 | application.langs="en" 14 | 15 | # Logger used by the framework: 16 | logger.play=INFO 17 | 18 | # Logger provided to your application: 19 | logger.application=INFO 20 | 21 | # TODO: remove once the adfree feature is generally available to the public 22 | # These users are the only ones who can potentially get a positive adfree response until the system is deemed stable 23 | identity.prerelease-users = [] 24 | 25 | play.filters.csrf.contentType.blackList = ["text/plain"] 26 | 27 | contexts { 28 | jdbc-context { 29 | executor = "thread-pool-executor" 30 | throughput = 1 31 | thread-pool-executor { 32 | fixed-pool-size = 30 33 | } 34 | } 35 | } 36 | 37 | db.oneOffStore { 38 | url=null #This is overloaded by the conf loaded from the bucket below and includes a socketTimeout query string param set to 30 seconds 39 | driver="org.postgresql.Driver" 40 | password=null 41 | username=null 42 | } 43 | 44 | # Wait up to 2 seconds for DB connection 45 | play.db.prototype.hikaricp.connectionTimeout = 2000 46 | 47 | 48 | include file("/etc/gu/members-data-api.private.conf") 49 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | membership-attribute-service 4 | 5 | 6 | 7 | 8 | 9 | SENTRY 10 | 11 | DENY 12 | 13 | 14 | logs/membership-attribute-service.log 15 | 16 | 17 | logs/membership-attribute-service.log.%d{yyyy-MM-dd}.gz 18 | 30 19 | 20 | 21 | 22 | %date %file:%L [%level]: %msg%n%xException{full} 23 | 24 | 25 | 26 | 27 | 28 | ERROR 29 | 30 | 31 | ERROR 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/riff-raff.yaml: -------------------------------------------------------------------------------- 1 | stacks: [membership] 2 | regions: [eu-west-1] 3 | allowedStages: 4 | - CODE 5 | - PROD 6 | deployments: 7 | cloudformation: 8 | type: cloud-formation 9 | app: membership-attribute-service 10 | parameters: 11 | templatePath: membership-attribute-service.yaml 12 | amiTags: 13 | Recipe: jammy-membership-java11 14 | AmigoStage: PROD 15 | amiEncrypted: true 16 | amiParameter: AmiId 17 | membership-attribute-service: 18 | type: autoscaling 19 | dependencies: [cloudformation] 20 | parameters: 21 | bucket: gu-membership-attribute-service-dist 22 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/touchpoint.CODE.conf: -------------------------------------------------------------------------------- 1 | touchpoint.backend.environments { 2 | CODE { 3 | supporter-product-data.table = SupporterProductData-CODE 4 | } 5 | } 6 | 7 | okta.verifier { 8 | issuerUrl: "https://validUrl/" 9 | audience: "https://anotherValidUrl/" 10 | } 11 | 12 | mobile.subscription.apiKey = "api-key-CODE-members-data-api" 13 | -------------------------------------------------------------------------------- /membership-attribute-service/conf/touchpoint.PROD.conf: -------------------------------------------------------------------------------- 1 | touchpoint.backend.environments { 2 | PROD { 3 | supporter-product-data.table = SupporterProductData-PROD 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/AcceptanceTest.scala: -------------------------------------------------------------------------------- 1 | package acceptance 2 | 3 | import org.mockito.IdiomaticMockito 4 | import org.specs2.mutable.Specification 5 | import org.specs2.specification.BeforeAfterEach 6 | import play.api.test.PlaySpecification 7 | 8 | trait AcceptanceTest 9 | extends Specification 10 | with IdiomaticMockito 11 | with PlaySpecification 12 | with HasIdentityMockServer 13 | with HasPlayServer 14 | with BeforeAfterEach { 15 | protected def before: Unit = { 16 | startPlayServer() 17 | startIdentityMockServer() 18 | } 19 | 20 | protected def after: Unit = { 21 | stopIdentityMockServer() 22 | stopPlayServer() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/HasIdentityMockServer.scala: -------------------------------------------------------------------------------- 1 | package acceptance 2 | 3 | import acceptance.util.AvailablePort 4 | import org.mockserver.integration.ClientAndServer 5 | 6 | trait HasIdentityMockServer { 7 | val identityPort = AvailablePort.find() 8 | val identityServerUrl = s"http://localhost:$identityPort" 9 | val identityApiToken = "db5e969d58bf6ad42f904f56191f88a0" 10 | protected var identityMockClientAndServer: ClientAndServer = _ 11 | 12 | def startIdentityMockServer(): Unit = { 13 | identityMockClientAndServer = new ClientAndServer(identityPort) 14 | } 15 | 16 | def stopIdentityMockServer(): Unit = { 17 | if (identityMockClientAndServer != null) { 18 | identityMockClientAndServer.stop() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/Randoms.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import java.util.UUID.randomUUID 4 | import scala.util.Random 5 | 6 | trait Randoms { 7 | def randomId(prefix: String): String = prefix + "_" + randomId() 8 | 9 | def randomId(): String = randomUUID.toString 10 | 11 | def randomInt(): Int = Random.nextInt() 12 | 13 | def randomLong(): Long = Random.nextLong() 14 | } 15 | 16 | object Randoms extends Randoms 17 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestAccountSummary.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.i18n.Currency 5 | import com.gu.memsub.Subscription.{AccountId, AccountNumber} 6 | import services.zuora.rest.ZuoraRestService.{ 7 | AccountSummary, 8 | BillToContact, 9 | DefaultPaymentMethod, 10 | Invoice, 11 | Payment, 12 | SalesforceContactId, 13 | SoldToContact, 14 | } 15 | 16 | object TestAccountSummary { 17 | def apply( 18 | id: AccountId = AccountId(randomId("accountId")), 19 | accountNumber: AccountNumber = AccountNumber(randomId("accountNumber")), 20 | identityId: Option[String] = None, 21 | billToContact: BillToContact = BillToContact(None, None), 22 | soldToContact: SoldToContact = SoldToContact(None, None, "Smith", None, None, None, None, None, None, None), 23 | invoices: List[Invoice] = Nil, 24 | payments: List[Payment] = Nil, 25 | currency: Option[Currency] = None, 26 | balance: Double = 10, 27 | defaultPaymentMethod: Option[DefaultPaymentMethod] = None, 28 | sfContactId: SalesforceContactId = SalesforceContactId(randomId("salesforceContactId")), 29 | ): AccountSummary = AccountSummary( 30 | id, 31 | accountNumber, 32 | identityId, 33 | billToContact, 34 | soldToContact, 35 | invoices, 36 | payments, 37 | currency, 38 | balance, 39 | defaultPaymentMethod, 40 | sfContactId, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestContact.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.salesforce.Contact 5 | import org.joda.time.DateTime 6 | 7 | object TestContact { 8 | def apply( 9 | identityId: String, 10 | salesforceContactId: String = randomId("salesforceContactId"), 11 | salesforceAccountId: String = randomId("salesforceAccountId"), 12 | lastName: String = "Smith", 13 | joinDate: DateTime = DateTime.now().minusDays(7), 14 | regNumber: Option[String] = None, 15 | title: Option[String] = None, 16 | firstName: Option[String] = None, 17 | mailingStreet: Option[String] = None, 18 | mailingCity: Option[String] = None, 19 | mailingState: Option[String] = None, 20 | mailingPostcode: Option[String] = None, 21 | mailingCountry: Option[String] = None, 22 | deliveryInstructions: Option[String] = None, 23 | recordTypeId: Option[String] = None, 24 | ): Contact = 25 | Contact( 26 | Some(identityId), 27 | regNumber, 28 | title, 29 | firstName, 30 | lastName, 31 | joinDate, 32 | salesforceContactId, 33 | salesforceAccountId, 34 | mailingStreet, 35 | mailingCity, 36 | mailingState, 37 | mailingPostcode, 38 | mailingCountry, 39 | deliveryInstructions, 40 | recordTypeId, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestPaidSubscriptionPlan.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.memsub.Subscription.{ProductRatePlanId, RatePlanId} 5 | import com.gu.memsub.subsv2.{RatePlan, RatePlanCharge} 6 | import com.gu.zuora.rest.Feature 7 | import org.joda.time.LocalDate 8 | import scalaz.NonEmptyList 9 | 10 | object TestPaidSubscriptionPlan { 11 | def apply( 12 | id: RatePlanId = RatePlanId(randomId("ratePlan")), 13 | productRatePlanId: ProductRatePlanId = ProductRatePlanId(randomId("productRatePlan")), 14 | productName: String = randomId("paidSubscriptionPlanProductName"), 15 | lastChangeType: Option[String] = None, 16 | features: List[Feature] = Nil, 17 | charges: NonEmptyList[RatePlanCharge] = NonEmptyList(TestSingleCharge()), 18 | ): RatePlan = RatePlan( 19 | id: RatePlanId, 20 | productRatePlanId, 21 | productName, 22 | lastChangeType, 23 | features, 24 | charges, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestPaymentSummary.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.i18n.Currency 5 | import com.gu.zuora.soap.models.PaymentSummary 6 | import com.gu.zuora.soap.models.Queries.InvoiceItem 7 | import org.joda.time.LocalDate 8 | 9 | object TestPaymentSummary { 10 | def apply(current: InvoiceItem = TestInvoiceItem(), previous: Seq[InvoiceItem] = Nil, currency: Currency = Currency.GBP): PaymentSummary = 11 | PaymentSummary( 12 | current, 13 | previous, 14 | currency, 15 | ) 16 | } 17 | 18 | object TestInvoiceItem { 19 | def apply( 20 | id: String = randomId("invoiceItem"), 21 | price: Float = 20, 22 | serviceStartDate: LocalDate = LocalDate.now().minusDays(7), 23 | serviceEndDate: LocalDate = LocalDate.now().minusDays(7).plusMonths(12), 24 | chargeNumber: String = "1", 25 | productName: String = randomId("invoiceItemProductName"), 26 | subscriptionId: String = randomId("invoiceItemSubscriptionId"), 27 | ): InvoiceItem = InvoiceItem( 28 | id, 29 | price, 30 | serviceStartDate, 31 | serviceEndDate, 32 | chargeNumber, 33 | productName, 34 | subscriptionId, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestPreviewInvoiceItem.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import com.gu.zuora.soap.models.Queries.PreviewInvoiceItem 4 | import org.joda.time.LocalDate 5 | 6 | object TestPreviewInvoiceItem { 7 | def apply(): PreviewInvoiceItem = PreviewInvoiceItem( 8 | 1f, 9 | new LocalDate(2024, 5, 14), 10 | new LocalDate(2024, 6, 14), 11 | "testProductId", 12 | "testProductRatePlanChargeId", 13 | "testChangeName", 14 | 1f, 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestPricingSummary.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import com.gu.i18n.Currency 4 | import com.gu.memsub.{Price, PricingSummary} 5 | 6 | object TestPricingSummary { 7 | def apply(summary: (Currency, Price)*): PricingSummary = PricingSummary(summary.toMap) 8 | 9 | def apply(): PricingSummary = gbp(10) 10 | 11 | def gbp(amount: Double): PricingSummary = TestPricingSummary(Currency.GBP -> Price(amount.toFloat, Currency.GBP)) 12 | } 13 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestQueriesAccount.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.i18n.Currency 5 | import com.gu.zuora.api.PaymentGateway 6 | import com.gu.zuora.soap.models.Queries 7 | 8 | object TestQueriesAccount { 9 | def apply( 10 | id: String = randomId("accountId"), 11 | billToId: String = randomId("billToId"), 12 | soldToId: String = randomId("soldToToId"), 13 | billCycleDay: Int = 1, 14 | creditBalance: Float = 66, 15 | currency: Option[Currency] = None, 16 | defaultPaymentMethodId: Option[String] = None, 17 | sfContactId: Option[String] = None, 18 | paymentGateway: Option[PaymentGateway] = None, 19 | ): Queries.Account = Queries.Account( 20 | id, 21 | billToId, 22 | soldToId, 23 | billCycleDay, 24 | creditBalance, 25 | currency, 26 | defaultPaymentMethodId, 27 | sfContactId, 28 | paymentGateway, 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestQueriesContact.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.i18n.Country 5 | import com.gu.zuora.soap.models.Queries.Contact 6 | 7 | object TestQueriesContact { 8 | def apply( 9 | id: String = randomId("contactId"), 10 | firstName: String = randomId("first_name"), 11 | lastName: String = randomId("last_name"), 12 | postalCode: Option[String] = None, 13 | country: Option[Country] = None, 14 | email: Option[String] = None, 15 | ) = Contact( 16 | id = id, 17 | firstName = firstName, 18 | lastName = lastName, 19 | postalCode = postalCode, 20 | country = country, 21 | email = email, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestQueriesPaymentMethod.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.zuora.soap.models.Queries.PaymentMethod 5 | 6 | object TestQueriesPaymentMethod { 7 | def apply( 8 | id: String = randomId("paymentMethod"), 9 | mandateId: Option[String] = None, 10 | tokenId: Option[String] = None, 11 | secondTokenId: Option[String] = None, 12 | payPalEmail: Option[String] = None, 13 | bankTransferType: Option[String] = None, 14 | bankTransferAccountName: Option[String] = None, 15 | bankTransferAccountNumberMask: Option[String] = None, 16 | bankCode: Option[String] = None, 17 | paymentType: String, 18 | creditCardNumber: Option[String] = None, 19 | creditCardExpirationMonth: Option[String] = None, 20 | creditCardExpirationYear: Option[String] = None, 21 | creditCardType: Option[String] = None, 22 | numConsecutiveFailures: Option[Int] = None, 23 | paymentMethodStatus: Option[String] = None, 24 | ) = PaymentMethod( 25 | id: String, 26 | mandateId = mandateId, 27 | tokenId = tokenId, 28 | secondTokenId = secondTokenId, 29 | payPalEmail = payPalEmail, 30 | bankTransferType = bankTransferType, 31 | bankTransferAccountName = bankTransferAccountName, 32 | bankTransferAccountNumberMask = bankTransferAccountNumberMask, 33 | bankCode = bankCode, 34 | `type` = paymentType, 35 | creditCardNumber = creditCardNumber, 36 | creditCardExpirationMonth = creditCardExpirationMonth, 37 | creditCardExpirationYear = creditCardExpirationYear, 38 | creditCardType = creditCardType, 39 | numConsecutiveFailures = numConsecutiveFailures, 40 | paymentMethodStatus = paymentMethodStatus, 41 | ) 42 | 43 | } 44 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestSingleCharge.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.memsub.PricingSummary 5 | import com.gu.memsub.Subscription.{ProductRatePlanChargeId, SubscriptionRatePlanChargeId} 6 | import com.gu.memsub.subsv2.{RatePlanCharge, SubscriptionEnd, ZBillingPeriod, ZYear} 7 | import org.joda.time.LocalDate 8 | 9 | object TestSingleCharge { 10 | def apply( 11 | billingPeriod: ZBillingPeriod = ZYear, 12 | price: PricingSummary = TestPricingSummary(), 13 | chargeId: ProductRatePlanChargeId = randomProductRatePlanChargeId(), 14 | subRatePlanChargeId: SubscriptionRatePlanChargeId = SubscriptionRatePlanChargeId(randomId("subscriptionRatePlanChargeId")), 15 | chargedThroughDate: Option[LocalDate] = None, // this is None if the sub hasn't been billed yet (on a free trial) 16 | effectiveStartDate: LocalDate = LocalDate.now().minusDays(13), 17 | effectiveEndDate: LocalDate = LocalDate.now().minusDays(13).plusYears(1), 18 | ): RatePlanCharge = RatePlanCharge( 19 | subRatePlanChargeId, 20 | chargeId, 21 | price, 22 | Some(billingPeriod), 23 | None, 24 | SubscriptionEnd, 25 | None, 26 | None, 27 | chargedThroughDate, 28 | effectiveStartDate, 29 | effectiveEndDate, 30 | ) 31 | 32 | def randomProductRatePlanChargeId(): ProductRatePlanChargeId = ProductRatePlanChargeId( 33 | randomId("productRatePlanChargeId"), 34 | ) // was contributor by default 35 | } 36 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/TestSubscription.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.memsub 5 | import com.gu.memsub.promo.PromoCode 6 | import com.gu.memsub.subsv2.{ReaderType, Subscription, RatePlan} 7 | import org.joda.time.{DateTime, LocalDate} 8 | 9 | object TestSubscription { 10 | def apply( 11 | id: memsub.Subscription.Id = memsub.Subscription.Id(randomId("subscriptionId")), 12 | subscriptionNumber: memsub.Subscription.SubscriptionNumber = memsub.Subscription.SubscriptionNumber(randomId("subscriptionName")), 13 | accountId: memsub.Subscription.AccountId = memsub.Subscription.AccountId(randomId("accountId")), 14 | startDate: LocalDate = LocalDate.now().minusDays(7), 15 | acceptanceDate: LocalDate = LocalDate.now().plusDays(2), 16 | termEndDate: LocalDate = LocalDate.now().plusDays(12).plusYears(1), 17 | isCancelled: Boolean = false, 18 | plans: List[RatePlan] = List(TestPaidSubscriptionPlan()), 19 | readerType: ReaderType = ReaderType.Direct, 20 | autoRenew: Boolean = false, 21 | ): Subscription = 22 | Subscription( 23 | id: memsub.Subscription.Id, 24 | subscriptionNumber, 25 | accountId, 26 | startDate, 27 | acceptanceDate, 28 | termEndDate, 29 | isCancelled, 30 | plans, 31 | readerType, 32 | autoRenew, 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/stripe/TestCustomersPaymentMethods.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data.stripe 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.stripe.Stripe.{CustomersPaymentMethods, StripePaymentMethod, StripePaymentMethodCard} 5 | 6 | object TestStripePaymentMethod { 7 | def apply( 8 | id: String = randomId("stripePaymentMethodId"), 9 | card: StripePaymentMethodCard = StripePaymentMethodCard( 10 | brand = "Visa", 11 | last4 = "1111", 12 | exp_month = 1, 13 | exp_year = 2024, 14 | country = "UK", 15 | ), 16 | customer: String = randomId("stripeCustomer"), 17 | ): StripePaymentMethod = 18 | StripePaymentMethod( 19 | id = id, 20 | card = card, 21 | customer = customer, 22 | ) 23 | } 24 | 25 | object TestCustomersPaymentMethods { 26 | def apply(data: List[StripePaymentMethod] = List(TestStripePaymentMethod())): CustomersPaymentMethods = CustomersPaymentMethods(data) 27 | } 28 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/stripe/TestDynamoSupporterRatePlanItem.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data.stripe 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.i18n.Currency 5 | import com.gu.memsub.Subscription.ProductRatePlanId 6 | import models.DynamoSupporterRatePlanItem 7 | import org.joda.time.LocalDate 8 | 9 | object TestDynamoSupporterRatePlanItem { 10 | def apply( 11 | identityId: String, 12 | subscriptionName: String = randomId("dynamoSubscriptionName"), 13 | productRatePlanId: ProductRatePlanId = ProductRatePlanId(randomId("productRatePlanId")), 14 | termEndDate: LocalDate = LocalDate.now().minusDays(5).plusYears(1), 15 | contractEffectiveDate: LocalDate = LocalDate.now().minusDays(5), 16 | cancellationDate: Option[LocalDate] = None, 17 | contributionAmount: Option[BigDecimal] = None, 18 | contributionCurrency: Option[Currency] = None, 19 | ): DynamoSupporterRatePlanItem = DynamoSupporterRatePlanItem( 20 | subscriptionName, 21 | identityId, 22 | productRatePlanId.get, 23 | termEndDate, 24 | contractEffectiveDate, 25 | cancellationDate, 26 | contributionAmount, 27 | contributionCurrency, 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/stripe/TestStripeCard.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data.stripe 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.stripe.Stripe.Card 5 | 6 | object TestStripeCard { 7 | def apply( 8 | id: String = randomId("stripeCardId"), 9 | `type`: String = "Visa", 10 | last4: String = "1111", 11 | exp_month: Int = 10, 12 | exp_year: Int = 2055, 13 | country: String = "UK", 14 | ): Card = 15 | Card( 16 | id = id, 17 | `type` = `type`, 18 | last4 = last4, 19 | exp_month = exp_month, 20 | exp_year = exp_year, 21 | country = country, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/stripe/TestStripeCustomer.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data.stripe 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.stripe.Stripe 5 | import com.gu.stripe.Stripe.{Card, Customer, StripeList} 6 | 7 | object TestStripeCustomer { 8 | def apply(id: String = randomId("stripeCustomer"), card: Card = TestStripeCard()): Stripe.Customer = Customer(id, StripeList(1, Seq(card))) 9 | } 10 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/data/stripe/TestStripeSubscription.scala: -------------------------------------------------------------------------------- 1 | package acceptance.data.stripe 2 | 3 | import acceptance.data.Randoms.randomId 4 | import com.gu.i18n.Currency 5 | import com.gu.stripe.Stripe 6 | import com.gu.stripe.Stripe.{SubscriptionCustomer, SubscriptionPlan} 7 | import org.joda.time.LocalDate 8 | 9 | object TestStripeSubscription { 10 | def apply( 11 | id: String = randomId("stripeSubscriptionId"), 12 | created: LocalDate = LocalDate.now().minusDays(10), 13 | currentPeriodStart: LocalDate = LocalDate.now().minusDays(8), 14 | currentPeriodEnd: LocalDate = LocalDate.now().minusDays(8).plusYears(1), 15 | cancelledAt: Option[LocalDate] = None, 16 | cancelAtPeriodEnd: Boolean = true, 17 | customer: SubscriptionCustomer = SubscriptionCustomer( 18 | randomId("stripeCustomerId"), 19 | randomId("email"), 20 | ), 21 | plan: SubscriptionPlan = SubscriptionPlan( 22 | id = randomId("stripePlanId"), 23 | amount = 1000, 24 | interval = "year", 25 | currency = Currency.GBP, 26 | ), 27 | status: String = "find_me_a_valid_status", 28 | ) = Stripe.Subscription( 29 | id, 30 | created, 31 | currentPeriodStart, 32 | currentPeriodEnd, 33 | cancelledAt, 34 | cancelAtPeriodEnd, 35 | customer, 36 | plan, 37 | status, 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /membership-attribute-service/test/acceptance/util/AvailablePort.scala: -------------------------------------------------------------------------------- 1 | package acceptance.util 2 | import java.net.ServerSocket 3 | import scala.util.Try 4 | 5 | object AvailablePort { 6 | def find(): Int = { 7 | var socket: ServerSocket = null 8 | try { 9 | socket = new ServerSocket(0) 10 | socket.setReuseAddress(true) 11 | val port = socket.getLocalPort 12 | Try(socket.close()) 13 | return port 14 | } catch { 15 | case e: Throwable => 16 | } finally { 17 | Try(socket.close()) 18 | } 19 | throw new IllegalStateException("Could not find a free port") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /membership-attribute-service/test/controllers/AccountControllerTest.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import actions.CommonActions 4 | import monitoring.CreateNoopMetrics 5 | import org.mockito.IdiomaticMockito 6 | import org.specs2.mutable.Specification 7 | import play.api.test.Helpers._ 8 | import play.api.test._ 9 | import services.FakePostgresService 10 | import services.mail.SendEmail 11 | 12 | class AccountControllerTest extends Specification with IdiomaticMockito { 13 | 14 | "validateContributionAmountUpdateForm" should { 15 | 16 | val subName = "s1" 17 | val commonActions = mock[CommonActions] 18 | val controller = new AccountController(commonActions, stubControllerComponents(), FakePostgresService("123"), mock[SendEmail], CreateNoopMetrics) 19 | val request = FakeRequest("POST", s"/api/update/amount/contributions/$subName") 20 | 21 | "succeed when given value is valid" in { 22 | val result = controller.validateContributionAmountUpdateForm(request.withFormUrlEncodedBody("newPaymentAmount" -> "1")) 23 | result must beRight(1) 24 | } 25 | 26 | "fail when no given value" in { 27 | val result = controller.validateContributionAmountUpdateForm(request) 28 | result must beLeft("no new payment amount submitted with request") 29 | } 30 | 31 | "fail when given value is zero" in { 32 | val result = controller.validateContributionAmountUpdateForm(request.withFormUrlEncodedBody("newPaymentAmount" -> "0")) 33 | result must beLeft("New payment amount '0.00' is too small") 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /membership-attribute-service/test/controllers/CachedTest.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import org.joda.time.{DateTime, DateTimeZone} 4 | import org.specs2.mutable.Specification 5 | import play.api.mvc.{Result, Results} 6 | 7 | class CachedTest extends Specification with Results { 8 | 9 | "Cached" should { 10 | def cacheControlSectionsOn(result: Result): Seq[String] = 11 | result.header.headers("Cache-Control").split(",").toSeq.map(_.trim) 12 | 13 | "cache live content for the specified number of seconds" in { 14 | cacheControlSectionsOn(Cached(2)(Ok)) must contain("max-age=2") 15 | } 16 | "serve stale content on error for 10 days if necessary" in { 17 | cacheControlSectionsOn(Cached(Ok)) must contain("stale-if-error=864000") 18 | } 19 | } 20 | 21 | "Convert to http date string" in { 22 | val theDate = new DateTime(2001, 5, 20, 12, 3, 4, 555, DateTimeZone.forID("Europe/London")) 23 | Cached.toHttpDateTimeString(theDate) mustEqual "Sun, 20 May 2001 11:03:04 GMT" 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /membership-attribute-service/test/filters/HeadersTest.scala: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import org.specs2.mutable.Specification 4 | import Headers._ 5 | import org.mockito.IdiomaticMockito 6 | import play.api.mvc 7 | import org.specs2.specification.Scope 8 | 9 | class HeadersTest extends Specification with IdiomaticMockito { 10 | trait fixtures extends Scope { 11 | val headersFixture = mock[mvc.Headers] 12 | headersFixture.get("X-Forwarded-For") returns Some("86.142.23.56, 78.24.67.23") 13 | 14 | val requestHeaderFixture = mock[mvc.RequestHeader] 15 | requestHeaderFixture.remoteAddress returns "127.0.0.1" 16 | requestHeaderFixture.headers returns headersFixture 17 | } 18 | 19 | "forwardedFor" should { 20 | "return the X-Forwarded-For as a list of IPs" in new fixtures { 21 | headersFixture.forwardedFor mustEqual Some(List("86.142.23.56", "78.24.67.23")) 22 | } 23 | } 24 | 25 | "realRemoteAddr" should { 26 | "return the first X-Forwarded-For IP if present" in new fixtures { 27 | requestHeaderFixture.realRemoteAddr mustEqual "86.142.23.56" 28 | } 29 | 30 | "return the Remote Addr header if X-Forwarded-For is not present" in new fixtures { 31 | headersFixture.get("X-Forwarded-For") returns None 32 | 33 | requestHeaderFixture.realRemoteAddr mustEqual "127.0.0.1" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /membership-attribute-service/test/integration/AccountDetailsFromZuoraIntegrationTest.scala: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import org.apache.pekko.actor.ActorSystem 4 | import com.typesafe.config.ConfigFactory 5 | import components.TouchpointComponents 6 | import configuration.Stage 7 | import controllers.AccountHelpers 8 | import monitoring.CreateNoopMetrics 9 | import org.joda.time.LocalDate 10 | import org.specs2.mutable.Specification 11 | import play.api.libs.json.Json 12 | 13 | import scala.concurrent.Await 14 | import scala.concurrent.ExecutionContext.Implicits.global 15 | import scala.concurrent.duration.Duration 16 | import testdata.TestLogPrefix.testLogPrefix 17 | 18 | class AccountDetailsFromZuoraIntegrationTest extends Specification { 19 | 20 | // This is an integration test to run code locally, we don't want it running in CI 21 | args(skipAll = true) 22 | 23 | "AccountDetailsFromZuora" should { 24 | "fetch a list of subs" in { 25 | val touchpointComponents = createTouchpointComponents 26 | val eventualResult = for { 27 | catalog <- touchpointComponents.futureCatalog 28 | result <- touchpointComponents.accountDetailsFromZuora 29 | .fetch("200421949", AccountHelpers.NoFilter) 30 | .run 31 | .map { list => 32 | println(s"Fetched this list: $list") 33 | list 34 | } 35 | } yield result.map( 36 | _.foreach(accountDetails => 37 | println( 38 | s"JSON output for ${accountDetails.subscription.subscriptionNumber.getNumber} is:\n" + Json 39 | .prettyPrint(accountDetails.toJson(catalog, LocalDate.now)), 40 | ), 41 | ), 42 | ) 43 | val result = Await.result(eventualResult, Duration.Inf) 44 | result.isRight must_== true 45 | } 46 | } 47 | 48 | def createTouchpointComponents = { 49 | implicit val system = ActorSystem.create() 50 | lazy val conf = ConfigFactory.load() 51 | new TouchpointComponents( 52 | Stage("CODE"), 53 | CreateNoopMetrics, 54 | conf, 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /membership-attribute-service/test/models/AnniversaryDateTest.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import org.joda.time.LocalDate 4 | import org.specs2.mutable.Specification 5 | 6 | class AnniversaryDateTest extends Specification { 7 | "anniversaryDate" should { 8 | "if today is equal to subscription start, then anniversary is exactly in one year" in { 9 | val actual = AccountDetails.anniversary(LocalDate.parse("2019-05-01"), LocalDate.parse("2019-05-01")) 10 | actual should_=== LocalDate.parse("2020-05-01") 11 | } 12 | "if today is before next anniversary, then stop searching and return next anniversary date" in { 13 | val actual = AccountDetails.anniversary(LocalDate.parse("2019-05-01"), LocalDate.parse("2020-04-28")) 14 | actual should_=== LocalDate.parse("2020-05-01") 15 | } 16 | 17 | "if next anniversary is many years from the subscription start, then keep moving year by year until today is just before next anniversary date" in { 18 | val actual = AccountDetails.anniversary(LocalDate.parse("2019-05-01"), LocalDate.parse("2025-05-01")) 19 | actual should_=== LocalDate.parse("2026-05-01") 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /membership-attribute-service/test/models/ApiErrorTest.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import org.specs2.mutable.Specification 4 | import play.api.libs.json.Json 5 | 6 | class ApiErrorTest extends Specification { 7 | 8 | "toResult" should { 9 | "create a result with the specified http status and a json body" in { 10 | 11 | Json.toJson(ApiError("message", "details", 400)) shouldEqual 12 | Json.parse(""" 13 | | { 14 | | "message": "message", 15 | | "details": "details", 16 | | "statusCode": 400 17 | | } 18 | """.stripMargin) 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /membership-attribute-service/test/models/DeliveryAddressTest.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | class DeliveryAddressTest extends Specification { 6 | 7 | "splitAddressLines" should { 8 | "split address line into two at comma" in { 9 | DeliveryAddress.splitAddressLines( 10 | Some("25, Low Road"), 11 | ) should_=== Some(("25", "Low Road")) 12 | } 13 | "split address line into two at last comma" in { 14 | DeliveryAddress.splitAddressLines( 15 | Some("Flat 4, Floor 7, 25, Low Road, Halfmoon Street"), 16 | ) should_=== Some(("Flat 4, Floor 7, 25, Low Road", "Halfmoon Street")) 17 | } 18 | "leave address line alone if has no comma" in { 19 | DeliveryAddress.splitAddressLines( 20 | Some("25 Low Road"), 21 | ) should_=== Some(("25 Low Road", "")) 22 | } 23 | "leave address line alone if empty" in { 24 | DeliveryAddress.splitAddressLines(Some("")) should_=== Some(("", "")) 25 | } 26 | "leave address line alone if not defined" in { 27 | DeliveryAddress.splitAddressLines(None) should_=== None 28 | } 29 | "trim whitespace" in { 30 | DeliveryAddress.splitAddressLines( 31 | Some("Flat 4, Floor 7, 25, Low Road, Halfmoon Street"), 32 | ) should_=== Some(("Flat 4, Floor 7, 25, Low Road", "Halfmoon Street")) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /membership-attribute-service/test/models/FilterPlansSpec.scala: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import com.gu.memsub.subsv2._ 4 | import com.gu.memsub.subsv2.reads.FixedDiscountRecurringTestData 5 | import com.gu.memsub.subsv2.reads.SubJsonReads.subscriptionReads 6 | import com.gu.memsub.subsv2.services.TestCatalog 7 | import com.gu.monitoring.SafeLogging 8 | import org.joda.time.LocalDate 9 | import org.specs2.mutable.Specification 10 | import utils.Resource 11 | import utils.TestLogPrefix.testLogPrefix 12 | 13 | class FilterPlansSpec extends Specification with SafeLogging { 14 | 15 | "subscription response plan filtering" should { 16 | "handle a fixed discount" in { 17 | val actualSubscription = Resource.getJson("rest/plans/WithRecurringFixedDiscount.json").validate[Subscription](subscriptionReads).get 18 | 19 | val expected = FixedDiscountRecurringTestData.mainPlan 20 | 21 | val actual = new FilterPlans(actualSubscription, TestCatalog.catalogProd, LocalDate.parse("2099-01-01")) 22 | 23 | actual.currentPlans must containTheSameElementsAs(List(expected)) 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /membership-attribute-service/test/resources/contacts.csv: -------------------------------------------------------------------------------- 1 | "IdentityID","Membership Number","Membership Tier","Last Modified Date" 2 | "323479263","292451","Partner","19/03/2015" 3 | "323479267","292454","Patron","19/03/2015" 4 | "323479268","","Friend","19/03/2015" 5 | -------------------------------------------------------------------------------- /membership-attribute-service/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %.-6level - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /membership-attribute-service/test/services/EmailDataTest.scala: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import org.mockito.IdiomaticMockito 4 | import org.specs2.mutable.Specification 5 | import play.api.libs.json.Json 6 | import services.mail.EmailData 7 | 8 | class EmailDataTest extends Specification with IdiomaticMockito { 9 | 10 | "EmailData" should { 11 | 12 | "generate correct json" in { 13 | EmailData( 14 | "my.email@email.com", 15 | "mySalesforceId", 16 | "myCampaignName", 17 | Map( 18 | "data_point_1" -> "value 1", 19 | "data_point_2" -> "value 2", 20 | "data_point_3" -> "value 3", 21 | ), 22 | ).toJson shouldEqual Json.parse( 23 | """ 24 | |{ 25 | | "To":{ 26 | | "Address":"my.email@email.com", 27 | | "ContactAttributes":{ 28 | | "SubscriberAttributes":{ 29 | | "data_point_1":"value 1", 30 | | "data_point_2":"value 2", 31 | | "data_point_3":"value 3" 32 | | } 33 | | } 34 | | }, 35 | | "DataExtensionName":"myCampaignName", 36 | | "SfContactId":"mySalesforceId" 37 | |} 38 | |""".stripMargin, 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /membership-attribute-service/test/services/FakeContributionsStoreDatabaseService.scala: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import java.util.{GregorianCalendar, TimeZone} 4 | import scala.concurrent.Future 5 | import services.ContributionsStoreDatabaseService.DatabaseGetResult 6 | import models.{ContributionData, RecurringReminderStatus, SupportReminders} 7 | 8 | case class FakePostgresService(validId: String) extends ContributionsStoreDatabaseService { 9 | private val calendar = new GregorianCalendar(2021, 10, 28) 10 | calendar.setTimeZone(TimeZone.getTimeZone("UTC")) 11 | val testContributionData = ContributionData( 12 | created = calendar.getTime, 13 | currency = "GBP", 14 | amount = 11.0, 15 | status = "statusValue", 16 | ) 17 | def getAllContributions(identityId: String): DatabaseGetResult[List[ContributionData]] = 18 | if (identityId == validId) 19 | Future.successful(Right(List(testContributionData))) 20 | else 21 | Future.successful(Right(Nil)) 22 | 23 | def getLatestContribution(identityId: String): DatabaseGetResult[Option[ContributionData]] = 24 | Future.successful(Right(None)) 25 | 26 | def getSupportReminders(identityId: String): DatabaseGetResult[SupportReminders] = 27 | Future.successful(Right(SupportReminders(RecurringReminderStatus.NotSet, None))) 28 | } 29 | -------------------------------------------------------------------------------- /membership-attribute-service/test/testdata/TestLogPrefix.scala: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | 5 | object TestLogPrefix { 6 | 7 | implicit val testLogPrefix: LogPrefix = LogPrefix("TestLogPrefix") 8 | 9 | } 10 | -------------------------------------------------------------------------------- /membership-common/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | logs 4 | project/project 5 | project/target 6 | project/metals.sbt 7 | target 8 | tmp 9 | .history 10 | dist 11 | .idea 12 | *.iml 13 | out 14 | .idea_modules 15 | dynamodb-local/ 16 | 17 | # Scala-IDE specific 18 | .scala_dependencies 19 | .worksheet 20 | 21 | .ensime_cache/ 22 | .ensime 23 | 24 | local.* 25 | .bloop 26 | .metals 27 | .vscode 28 | .bsp 29 | -------------------------------------------------------------------------------- /membership-common/README.md: -------------------------------------------------------------------------------- 1 | Membership-common 2 | ================= 3 | 4 | The latest version of membership-common is: 5 | 6 | ![](https://maven-badges.herokuapp.com/maven-central/com.gu/membership-common_2.13/badge.svg) 7 | 8 | Playframework library shared between: 9 | 10 | + [membership-frontend](https://github.com/guardian/membership-frontend) 11 | + ~[subscriptions-frontend](https://github.com/guardian/subscriptions-frontend)~ 12 | + [membership-workflow](https://github.com/guardian/membership-workflow) 13 | + [members-data-api](https://github.com/guardian/members-data-api) 14 | + [memsub-promotions](https://github.com/guardian/memsub-promotions) 15 | 16 | This library helps establish the contract between Salesforce, Stripe, Zuora, CAS and CloudWatch metrics. 17 | 18 | Releasing to local repo 19 | ================== 20 | 21 | Run `sbt publishLocal`. 22 | 23 | 24 | Releasing to maven 25 | ================== 26 | 27 | We use teamcity to release to Maven. Follow these steps to release a new version. 28 | 29 | 1. push/merge your changes to the default branch 30 | 1. wait for the build to finish successfully. 31 | 1. use the version listed by the build to import into dependent projects 32 | -------------------------------------------------------------------------------- /membership-common/conf/reference.conf: -------------------------------------------------------------------------------- 1 | # Touchpoint-backend environment-specific config - 2 | # ***NO PRIVATE CREDENTIALS in these files*** 3 | include classpath("touchpoint.CODE.conf") 4 | include classpath("touchpoint.PROD.conf") -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/aws/AwsS3Client.scala: -------------------------------------------------------------------------------- 1 | package com.gu.aws 2 | 3 | import com.amazonaws.services.s3.model.{GetObjectRequest, S3ObjectInputStream} 4 | import com.amazonaws.services.s3.{AmazonS3, AmazonS3Client} 5 | import com.gu.monitoring.SafeLogger.LogPrefix 6 | import com.gu.monitoring.SafeLogging 7 | import play.api.libs.json.{JsValue, Json} 8 | import scalaz.{-\/, \/, \/-} 9 | 10 | import scala.io.Source 11 | import scala.util.{Failure, Success, Try} 12 | 13 | object AwsS3 extends SafeLogging { 14 | 15 | lazy val client = AmazonS3Client.builder.withCredentials(CredentialsProvider).build() 16 | 17 | def fetchObject(s3Client: AmazonS3, request: GetObjectRequest): Try[S3ObjectInputStream] = Try(s3Client.getObject(request).getObjectContent) 18 | 19 | def fetchJson(s3Client: AmazonS3, request: GetObjectRequest)(implicit logPrefix: LogPrefix): String \/ JsValue = { 20 | logger.info(s"Getting file from S3. Bucket: ${request.getBucketName} | Key: ${request.getKey}") 21 | val attempt = for { 22 | s3Stream <- fetchObject(s3Client, request) 23 | json <- Try(Json.parse(Source.fromInputStream(s3Stream).mkString)) 24 | _ <- Try(s3Stream.close()) 25 | } yield json 26 | attempt match { 27 | case Success(json) => 28 | logger.info(s"Successfully loaded ${request.getKey} from ${request.getBucketName}") 29 | \/-(json) 30 | case Failure(ex) => 31 | logger.error(scrub"Failed to load JSON from S3 bucket ${request.getBucketName}", ex) 32 | -\/(s"Failed to load JSON due to $ex") 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/aws/package.scala: -------------------------------------------------------------------------------- 1 | package com.gu 2 | 3 | import com.amazonaws.auth.{InstanceProfileCredentialsProvider, AWSCredentialsProviderChain} 4 | import com.amazonaws.auth.profile.ProfileCredentialsProvider 5 | 6 | package object aws { 7 | val ProfileName = "membership" 8 | 9 | lazy val CredentialsProvider = new AWSCredentialsProviderChain( 10 | new ProfileCredentialsProvider(ProfileName), 11 | new InstanceProfileCredentialsProvider(false), 12 | ) 13 | 14 | } 15 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/config/SubsV2ProductIds.scala: -------------------------------------------------------------------------------- 1 | package com.gu.config 2 | import com.gu.memsub.Product 3 | import com.gu.memsub.Product._ 4 | import com.gu.memsub.Subscription.ProductId 5 | 6 | object SubsV2ProductIds { 7 | 8 | val guardianPatronProductId: ProductId = ProductId("guardian_patron") 9 | 10 | type ProductMap = Map[ProductId, Product] 11 | 12 | def load(config: com.typesafe.config.Config): ProductMap = Map[ProductId, Product]( 13 | ProductId(config.getString("subscriptions.voucher")) -> Voucher, 14 | ProductId(config.getString("subscriptions.digitalVoucher")) -> DigitalVoucher, 15 | ProductId(config.getString("subscriptions.delivery")) -> Delivery, 16 | ProductId(config.getString("subscriptions.nationalDelivery")) -> NationalDelivery, 17 | ProductId(config.getString("subscriptions.weeklyZoneA")) -> WeeklyZoneA, 18 | ProductId(config.getString("subscriptions.weeklyZoneB")) -> WeeklyZoneB, 19 | ProductId(config.getString("subscriptions.weeklyZoneC")) -> WeeklyZoneC, 20 | ProductId(config.getString("subscriptions.weeklyDomestic")) -> WeeklyDomestic, 21 | ProductId(config.getString("subscriptions.weeklyRestOfWorld")) -> WeeklyRestOfWorld, 22 | ProductId(config.getString("subscriptions.digipack")) -> Digipack, 23 | ProductId(config.getString("subscriptions.supporterPlus")) -> SupporterPlus, 24 | ProductId(config.getString("subscriptions.tierThree")) -> TierThree, 25 | ProductId(config.getString("subscriptions.guardianAdLite")) -> AdLite, 26 | ProductId(config.getString("membership.supporter")) -> Membership, 27 | ProductId(config.getString("membership.partner")) -> Membership, 28 | ProductId(config.getString("membership.patron")) -> Membership, 29 | ProductId(config.getString("contributions.contributor")) -> Contribution, 30 | ProductId(config.getString("discounts")) -> Discounts, 31 | guardianPatronProductId -> GuardianPatron, 32 | ) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/identity/IdapiConfig.scala: -------------------------------------------------------------------------------- 1 | package com.gu.identity 2 | 3 | import com.typesafe.config.Config 4 | 5 | case class IdapiConfig( 6 | url: String, 7 | token: String, 8 | ) 9 | 10 | object IdapiConfig { 11 | def from(config: Config, environmentName: String) = IdapiConfig( 12 | url = config.getString("identity.apiUrl"), 13 | token = config.getString("identity.apiToken"), 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/lib/DateDSL.scala: -------------------------------------------------------------------------------- 1 | package com.gu.lib 2 | import org.joda.time.LocalDate 3 | 4 | /** This is entirely optional! Its only really useful in unit tests 5 | */ 6 | object DateDSL { 7 | implicit class IntOps(in: Int) { 8 | val yearMonth = new LocalDate(_: Int, _: Int, in) 9 | def Jan(yr: Int) = yearMonth(yr, 1) 10 | def Feb(yr: Int) = yearMonth(yr, 2) 11 | def Mar(yr: Int) = yearMonth(yr, 3) 12 | def Apr(yr: Int) = yearMonth(yr, 4) 13 | def May(yr: Int) = yearMonth(yr, 5) 14 | def Jun(yr: Int) = yearMonth(yr, 6) 15 | def Jul(yr: Int) = yearMonth(yr, 7) 16 | def Aug(yr: Int) = yearMonth(yr, 8) 17 | def Sep(yr: Int) = yearMonth(yr, 9) 18 | def Oct(yr: Int) = yearMonth(yr, 10) 19 | def Nov(yr: Int) = yearMonth(yr, 11) 20 | def Dec(yr: Int) = yearMonth(yr, 12) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/Address.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import com.gu.i18n.Country._ 4 | import com.gu.i18n.CountryGroup 5 | 6 | case class Address(lineOne: String, lineTwo: String, town: String, countyOrState: String, postCode: String, countryName: String) { 7 | // Salesforce only has one address line field, so merge our two together 8 | val line = Seq(lineOne, lineTwo).filter(_.nonEmpty).mkString(", ") 9 | 10 | lazy val valid = country.fold(false) { 11 | case c if List(US, Canada).contains(c) => postCode.nonEmpty && c.states.contains(countyOrState) 12 | case c if c == Ireland => lineOne.nonEmpty && town.nonEmpty 13 | case _ => postCode.nonEmpty 14 | } 15 | 16 | lazy val country = CountryGroup.countryByNameOrCode(countryName) 17 | } 18 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/FullName.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import com.gu.i18n.Title 4 | 5 | trait FullName { 6 | def first: String 7 | def last: String 8 | def title: Option[Title] 9 | } 10 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/NormalisedTelephoneNumber.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import com.google.i18n.phonenumbers.{NumberParseException, PhoneNumberUtil} 4 | import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber 5 | import com.gu.i18n.Country 6 | import play.api.libs.json.Json 7 | 8 | import scala.util.control.Exception._ 9 | 10 | case class NormalisedTelephoneNumber(countryCode: String, localNumber: String) { 11 | def format: String = s"+$countryCode$localNumber" 12 | } 13 | 14 | object NormalisedTelephoneNumber { 15 | def fromStringAndCountry(phone: Option[String], country: Option[Country]): Option[NormalisedTelephoneNumber] = { 16 | for { 17 | number <- phone 18 | c <- country 19 | parsed <- parseToOption(number, c.alpha2) 20 | } yield { 21 | NormalisedTelephoneNumber(parsed.getCountryCode.toString, parsed.getNationalNumber.toString) 22 | } 23 | } 24 | 25 | private def parseToOption(phone: String, countryCode: String): Option[PhoneNumber] = { 26 | val phoneNumberUtil = PhoneNumberUtil.getInstance() 27 | catching(classOf[NumberParseException]).opt(phoneNumberUtil.parse(phone, countryCode)).filter(phoneNumberUtil.isValidNumber) 28 | } 29 | implicit val writesTelephoneNumber = Json.writes[NormalisedTelephoneNumber] 30 | 31 | } 32 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/PaymentCardUpdateResult.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | sealed trait PaymentCardUpdateResult 4 | case class CardUpdateSuccess(newPaymentCard: PaymentCard) extends PaymentCardUpdateResult 5 | case class CardUpdateFailure(`type`: String, message: String, code: String) extends PaymentCardUpdateResult 6 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/PaymentMethod.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | case class PaymentCardDetails(lastFourDigits: String, expiryMonth: Int, expiryYear: Int) 4 | 5 | sealed trait PaymentMethod { 6 | val numConsecutiveFailures: Option[Int] 7 | val paymentMethodStatus: Option[String] 8 | } 9 | case class PaymentCard( 10 | isReferenceTransaction: Boolean, 11 | cardType: Option[String], 12 | paymentCardDetails: Option[PaymentCardDetails], 13 | numConsecutiveFailures: Option[Int] = None, 14 | paymentMethodStatus: Option[String] = None, 15 | ) extends PaymentMethod 16 | 17 | case class PayPalMethod( 18 | email: String, 19 | numConsecutiveFailures: Option[Int] = None, 20 | paymentMethodStatus: Option[String] = None, 21 | ) extends PaymentMethod 22 | 23 | case class GoCardless( 24 | mandateId: String, 25 | accountName: String, 26 | accountNumber: String, 27 | sortCode: String, 28 | numConsecutiveFailures: Option[Int] = None, 29 | paymentMethodStatus: Option[String] = None, 30 | ) extends PaymentMethod 31 | 32 | case class Sepa( 33 | mandateId: String, 34 | accountName: String, 35 | accountNumber: String, 36 | numConsecutiveFailures: Option[Int] = None, 37 | paymentMethodStatus: Option[String] = None, 38 | ) extends PaymentMethod 39 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/Price.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import com.gu.i18n.Currency 4 | 5 | import scala.math.BigDecimal.RoundingMode.HALF_UP 6 | 7 | case class Price(amount: Float, currency: Currency) { 8 | val prettyAmount = { 9 | val rounded2dp = BigDecimal(amount.toString).setScale(2, HALF_UP) 10 | rounded2dp.toBigIntExact.fold(rounded2dp.formatted("%.2f"))(_.toString) 11 | } 12 | 13 | val pretty = currency.identifier + prettyAmount 14 | val prettyWithoutCurrencyPrefix = currency.glyph + prettyAmount 15 | def +(n: Float) = Price(amount + n, currency) 16 | def -(n: Float) = Price(amount - n, currency) 17 | def *(n: Float) = Price(amount * n, currency) 18 | def /(n: Float) = Price(amount / n, currency) 19 | 20 | private def checkingCurrency(that: Price, op: (Float, Float) => Float): Price = { 21 | assert(that.currency == currency, s"Trying to compute a price from prices with different currencies: $currency and ${that.currency}") 22 | Price(op(amount, that.amount), currency) 23 | } 24 | 25 | def +(that: Price) = checkingCurrency(that, _ + _) 26 | def -(that: Price) = checkingCurrency(that, _ - _) 27 | def *(that: Price) = checkingCurrency(that, _ * _) 28 | def /(that: Price) = checkingCurrency(that, _ / _) 29 | } 30 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/PriceParser.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import com.gu.i18n.Currency 4 | 5 | import scala.util.Try 6 | 7 | object PriceParser { 8 | def parse(s: String): Option[Price] = 9 | s.replace("/Each", "").splitAt(3) match { 10 | case (code, p) => 11 | for { 12 | currency <- Currency.fromString(code) 13 | price <- Try { p.toFloat }.toOption 14 | } yield Price(price, currency) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/PricingSummary.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import com.gu.i18n.Currency 4 | 5 | case class PricingSummary(underlying: Map[Currency, Price]) { 6 | val prices = underlying.values 7 | val currencies = underlying.keySet 8 | val isFree = prices.map(_.amount).sum == 0 9 | } 10 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/Subscription.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | object Subscription { 4 | case class SubscriptionNumber(getNumber: String) extends AnyVal 5 | case class Id(get: String) extends AnyVal 6 | case class AccountId(get: String) extends AnyVal 7 | case class AccountNumber(get: String) extends AnyVal 8 | case class ProductRatePlanId(get: String) extends AnyVal 9 | case class RatePlanId(get: String) extends AnyVal 10 | case class ProductId(get: String) extends AnyVal 11 | case class ProductRatePlanChargeId(get: String) extends AnyVal 12 | case class SubscriptionRatePlanChargeId(get: String) extends AnyVal 13 | 14 | object Feature { 15 | case class Id(get: String) extends AnyVal 16 | case class Code(get: String) extends AnyVal 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/SupplierCode.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | case class SupplierCode(get: String) 4 | 5 | object SupplierCodeBuilder { 6 | 7 | /** As a SupplierCode may get stored in the session etc, this method generates a safe-to-store SupplierCode from an unsafe String, by stripping out 8 | * any characters that are not alpha-numeric and trimming to max 255 characters. If the resulting String is non-empty then Some(SupplierCode) will 9 | * be returned with all characters capitalised, else it will return None. 10 | * @param code 11 | * any String 12 | * @return 13 | * Option[SupplierCode] 14 | */ 15 | def buildSupplierCode(code: String): Option[SupplierCode] = { 16 | val nonNull = if (code != null) code else "" 17 | val sanitised = nonNull.filter(_.isLetterOrDigit).toUpperCase 18 | val trimmed = sanitised.take(255) 19 | if (trimmed.isEmpty) None else Some(SupplierCode(trimmed)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/promo/LogImplicit.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub.promo 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | import com.gu.monitoring.SafeLogging 5 | 6 | import scala.concurrent.{ExecutionContext, Future} 7 | import scala.util.{Failure, Success} 8 | 9 | object LogImplicit { 10 | 11 | implicit class LoggableFuture[T](eventualT: Future[T]) extends SafeLogging { 12 | def withLogging(message: String)(implicit logPrefix: LogPrefix, ec: ExecutionContext): Future[T] = { 13 | eventualT.onComplete { 14 | case Failure(exception) => logger.warn(s"Failed: $message", exception) 15 | case Success(_) => logger.info(s"Success: $message") 16 | } 17 | eventualT 18 | } 19 | } 20 | 21 | implicit class Loggable[T](t: T) extends SafeLogging { 22 | def withLogging(message: String)(implicit logPrefix: LogPrefix): T = { 23 | logger.info(s"$message {$t}") 24 | t 25 | } 26 | 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/promo/Promotion.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub.promo 2 | 3 | case class PromoCode(get: String) { 4 | override def toString: String = get 5 | } 6 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/subsv2/Catalog.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub.subsv2 2 | 3 | import com.gu.config.SubsV2ProductIds.ProductMap 4 | import com.gu.memsub.Subscription.{ProductRatePlanChargeId, ProductRatePlanId} 5 | import com.gu.memsub.subsv2.Catalog.ProductRatePlanMap 6 | 7 | object Catalog { 8 | 9 | type ProductRatePlanMap = Map[ProductRatePlanId, ProductRatePlan] 10 | 11 | // dummy ids for stripe (non zuora) products 12 | val guardianPatronProductRatePlanId: ProductRatePlanId = ProductRatePlanId("guardian_patron") 13 | val guardianPatronProductRatePlanChargeId: ProductRatePlanChargeId = ProductRatePlanChargeId("guardian_patron") 14 | 15 | } 16 | 17 | case class Catalog(productRatePlans: ProductRatePlanMap, products: ProductMap) 18 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/util/FutureRetry.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub.util 2 | 3 | import scala.concurrent.duration._ 4 | import scala.concurrent.ExecutionContext 5 | import scala.concurrent.Future 6 | import org.apache.pekko.pattern.after 7 | import org.apache.pekko.actor.Scheduler 8 | 9 | /** retry implementation from Scala Future contributor https://gist.github.com/viktorklang/9414163 10 | */ 11 | object FutureRetry { 12 | 13 | /** Given an operation that produces a T, returns a Future containing the result of T, unless an exception is thrown, in which case the operation 14 | * will be retried after _delay_ time, if there are more possible retries, which is configured through the _retries_ parameter. If the operation 15 | * does not succeed and there is no retries left, the resulting Future will contain the last failure. 16 | */ 17 | def retry[T](op: => Future[T], delay: FiniteDuration, retries: Int)(implicit ec: ExecutionContext, s: Scheduler): Future[T] = 18 | op recoverWith { case _ if retries > 0 => after(delay, s)(retry(op, delay, retries - 1)) } 19 | 20 | def retry[T](op: => Future[T])(implicit ec: ExecutionContext, s: Scheduler): Future[T] = 21 | retry(op, delay = 200.milliseconds, retries = 2) 22 | } 23 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/util/ScheduledTask.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub.util 2 | 3 | import com.gu.monitoring.SafeLogging 4 | import org.apache.pekko.actor.ActorSystem 5 | 6 | import java.util.concurrent.atomic.AtomicReference 7 | import scala.concurrent.duration._ 8 | import scala.concurrent.{ExecutionContext, Future} 9 | import scala.util.{Failure, Success} 10 | 11 | /** Use ScheduledTask only when initial value is well defined. 12 | */ 13 | trait ScheduledTask[T] extends SafeLogging { 14 | val initialValue: T 15 | 16 | val initialDelay: FiniteDuration 17 | val interval: FiniteDuration 18 | 19 | val name = getClass.getSimpleName 20 | 21 | implicit def system: ActorSystem 22 | implicit val executionContext: ExecutionContext 23 | 24 | private lazy val atomicReference = new AtomicReference[T](initialValue) 25 | 26 | def task(): Future[T] 27 | 28 | def start(): Unit = { 29 | logger.infoNoPrefix(s"Starting $name scheduled task with an initial delay of: $initialDelay. This task will refresh every: $interval") 30 | system.scheduler.schedule(initialDelay, interval) { 31 | task.onComplete { 32 | case Success(t) => atomicReference.set(t) 33 | case Failure(e) => logger.errorNoPrefix(scrub"Scheduled task $name failed due to: $e. This task will retry in: $interval") 34 | } 35 | } 36 | } 37 | 38 | def get(): T = atomicReference.get() 39 | } 40 | 41 | object ScheduledTask { 42 | def apply[T](taskName: String, initValue: T, initDelay: FiniteDuration, intervalPeriod: FiniteDuration)( 43 | f: => Future[T], 44 | )(implicit actorSys: ActorSystem, ec: ExecutionContext) = 45 | new ScheduledTask[T] { 46 | val system = actorSys 47 | val executionContext = ec 48 | val initialValue = initValue 49 | val initialDelay = initDelay 50 | val interval = intervalPeriod 51 | override val name = taskName 52 | def task(): Future[T] = f 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/memsub/util/Timing.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub.util 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | import com.gu.monitoring.{CloudWatch, SafeLogging} 5 | 6 | import scala.concurrent.{ExecutionContext, Future} 7 | 8 | object Timing extends SafeLogging { 9 | 10 | def record[T](cloudWatch: CloudWatch, metricName: String)(block: => Future[T])(implicit ec: ExecutionContext, logPrefix: LogPrefix): Future[T] = { 11 | logger.debug(s"$metricName started...") 12 | cloudWatch.put(metricName, 1) 13 | val startTime = System.currentTimeMillis() 14 | 15 | def recordEnd[A](name: String)(a: A): A = { 16 | val duration = System.currentTimeMillis() - startTime 17 | cloudWatch.put(name + " duration ms", duration) 18 | logger.debug(s"${cloudWatch.service} $name completed in $duration ms") 19 | 20 | a 21 | } 22 | 23 | block.transform(recordEnd(metricName), recordEnd(s"$metricName failed")) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/monitoring/Metrics.scala: -------------------------------------------------------------------------------- 1 | package com.gu.monitoring 2 | 3 | import com.amazonaws.regions.{Region, Regions} 4 | import com.gu.monitoring.SafeLogger.LogPrefix 5 | 6 | trait ApplicationMetrics extends CloudWatch { 7 | val region = Region.getRegion(Regions.EU_WEST_1) 8 | val application: String 9 | val stage: String 10 | } 11 | 12 | trait StatusMetrics extends CloudWatch { 13 | def putResponseCode(status: Int, responseMethod: String)(implicit logPrefix: LogPrefix) { 14 | val statusClass = status / 100 15 | put(s"${statusClass}XX-response-code", 1, responseMethod) 16 | } 17 | } 18 | 19 | trait RequestMetrics extends CloudWatch { 20 | def putRequest()(implicit logPrefix: LogPrefix) { 21 | put("request-count", 1) 22 | } 23 | } 24 | 25 | trait AuthenticationMetrics extends CloudWatch { 26 | def putAuthenticationError { 27 | put("auth-error", 1)(LogPrefix.noLogPrefix) 28 | } 29 | } 30 | 31 | object CloudWatchHealth { 32 | var hasPushedMetricSuccessfully = false 33 | } 34 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/monitoring/SalesforceMetrics.scala: -------------------------------------------------------------------------------- 1 | package com.gu.monitoring 2 | 3 | import com.amazonaws.regions.{Region, Regions} 4 | import com.gu.monitoring.SafeLogger.LogPrefix 5 | 6 | class SalesforceMetrics(val stage: String, val application: String) 7 | extends CloudWatch 8 | with StatusMetrics 9 | with RequestMetrics 10 | with AuthenticationMetrics { 11 | 12 | val region = Region.getRegion(Regions.EU_WEST_1) 13 | val service = "Salesforce" 14 | 15 | def recordRequest()(implicit logPrefix: LogPrefix) { 16 | putRequest 17 | } 18 | 19 | def recordResponse(status: Int, responseMethod: String)(implicit logPrefix: LogPrefix) { 20 | putResponseCode(status, responseMethod) 21 | } 22 | 23 | def recordAuthenticationError() { 24 | putAuthenticationError 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/monitoring/ZuoraMetrics.scala: -------------------------------------------------------------------------------- 1 | package com.gu.monitoring 2 | 3 | import java.util.concurrent.{CompletableFuture, Future} 4 | import com.amazonaws.services.cloudwatch.model.{Dimension, PutMetricDataResult} 5 | import com.gu.monitoring.SafeLogger.LogPrefix 6 | 7 | class ZuoraMetrics(val stage: String, val application: String, val service: String = "Zuora") 8 | extends CloudWatch 9 | with StatusMetrics 10 | with RequestMetrics 11 | with AuthenticationMetrics { 12 | 13 | def countRequest()(implicit logPrefix: LogPrefix): Unit = putRequest // just a nicer name 14 | } 15 | 16 | // Dummy for tests, and as default argument so API clients do not have to change 17 | object NoOpZuoraMetrics extends ZuoraMetrics("dummy", "dummy") { 18 | override def put(name: String, count: Double, extraDimensions: Dimension*)(implicit logPrefix: LogPrefix): Future[PutMetricDataResult] = 19 | CompletableFuture.completedFuture(null.asInstanceOf[PutMetricDataResult]) 20 | } 21 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/salesforce/Contact.scala: -------------------------------------------------------------------------------- 1 | package com.gu.salesforce 2 | import com.github.nscala_time.time.Imports._ 3 | import com.gu.i18n.{Country, CountryGroup} 4 | 5 | import scala.language.implicitConversions 6 | 7 | trait ContactId { 8 | def salesforceContactId: String 9 | def salesforceAccountId: String 10 | } 11 | 12 | case class Contact( 13 | identityId: Option[String], 14 | regNumber: Option[String], 15 | title: Option[String], 16 | firstName: Option[String], 17 | lastName: String, 18 | joinDate: DateTime, 19 | salesforceContactId: String, 20 | salesforceAccountId: String, 21 | mailingStreet: Option[String], // used for fulfilment 22 | mailingCity: Option[String], 23 | mailingState: Option[String], 24 | mailingPostcode: Option[String], 25 | mailingCountry: Option[String], 26 | deliveryInstructions: Option[String], 27 | recordTypeId: Option[String], 28 | ) extends ContactId { 29 | lazy val mailingCountryParsed: Option[Country] = mailingCountry.flatMap(CountryGroup.countryByName) 30 | } 31 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/salesforce/ContactRecordType.scala: -------------------------------------------------------------------------------- 1 | package com.gu.salesforce 2 | 3 | import com.typesafe.config.Config 4 | 5 | trait ContactRecordType { 6 | def name: String 7 | } 8 | case object StandardCustomer extends ContactRecordType { 9 | val name = "Standard Customer" 10 | } 11 | case object DeliveryRecipientContact extends ContactRecordType { 12 | val name = "Delivery / Recipient Contact" 13 | 14 | } 15 | class ContactRecordTypes(config: Config) { 16 | 17 | /* Does a boot time check to ensure getIdForContactRecordType is safe in-ife */ 18 | private val standardCustomerId = config.getString("standard-customer") 19 | private val deliveryRecipientId = config.getString("delivery-recipient") 20 | 21 | def getIdForContactRecordType(recordType: ContactRecordType): String = { 22 | recordType match { 23 | case StandardCustomer => standardCustomerId 24 | case DeliveryRecipientContact => deliveryRecipientId 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/salesforce/SalesforceConfig.scala: -------------------------------------------------------------------------------- 1 | package com.gu.salesforce 2 | 3 | import com.typesafe.config.Config 4 | 5 | case class SalesforceConfig( 6 | envName: String, 7 | url: String, 8 | key: String, 9 | secret: String, 10 | username: String, 11 | password: String, 12 | token: String, 13 | recordTypeIds: Config, 14 | ) 15 | 16 | object SalesforceConfig { 17 | def from(config: Config, environmentName: String) = SalesforceConfig( 18 | environmentName, 19 | url = config.getString("salesforce.url"), 20 | key = config.getString("salesforce.consumer.key"), 21 | secret = config.getString("salesforce.consumer.secret"), 22 | username = config.getString("salesforce.api.username"), 23 | password = config.getString("salesforce.api.password"), 24 | token = config.getString("salesforce.api.token"), 25 | recordTypeIds = config.getConfig("salesforce.record-type-ids"), 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/salesforce/Tier.scala: -------------------------------------------------------------------------------- 1 | package com.gu.salesforce 2 | 3 | sealed trait Tier { 4 | def name: String 5 | def isPublic: Boolean 6 | def isPaid: Boolean 7 | } 8 | 9 | sealed trait PaidTier extends Tier { 10 | def isPaid = true 11 | } 12 | 13 | object PaidTier { 14 | def all: Seq[PaidTier] = Seq[PaidTier](Tier.Supporter(), Tier.Partner(), Tier.Patron()) 15 | } 16 | 17 | object Tier { 18 | 19 | case class Supporter() extends PaidTier { 20 | override val name = "Supporter" 21 | override def isPublic = true 22 | } 23 | 24 | case class Partner() extends PaidTier { 25 | override val name = "Partner" 26 | override def isPublic = true 27 | } 28 | 29 | case class Patron() extends PaidTier { 30 | override val name = "Patron" 31 | override def isPublic = true 32 | } 33 | 34 | // The order of this list is used in Ordered[Tier] above 35 | lazy val all: Seq[PaidTier] = PaidTier.all 36 | 37 | val supporter = Supporter() 38 | val partner = Partner() 39 | val patron = Patron() 40 | } 41 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/salesforce/job/Action.scala: -------------------------------------------------------------------------------- 1 | package com.gu.salesforce.job 2 | 3 | /** Defines an HTTP request which should return an object of type T 4 | * 5 | * @tparam T 6 | * The expected resultant object 7 | */ 8 | sealed trait Action[T <: Result] { 9 | 10 | /** The URL to send the request to 11 | */ 12 | val url: String 13 | 14 | def name = getClass.getSimpleName 15 | } 16 | 17 | /** A GET request 18 | */ 19 | sealed trait ReadAction[T <: Result] extends Action[T] 20 | 21 | /** A POST request 22 | */ 23 | sealed trait WriteAction[T <: Result] extends Action[T] { 24 | // Can't make this Elem because queries have to be plain text (despite Content-Type 25 | // having to be application/xml 26 | val body: String 27 | } 28 | 29 | case class JobCreate(op: String, objType: String) extends WriteAction[JobInfo] { 30 | val url = "job" 31 | 32 | val body = 33 | 34 | {op} 35 | {objType} 36 | XML 37 | .toString() 38 | } 39 | 40 | case class JobClose(job: JobInfo) extends WriteAction[JobInfo] { 41 | val url = s"job/${job.id}" 42 | val body = 43 | 44 | Closed 45 | .toString() 46 | } 47 | 48 | case class JobGetBatchList(job: JobInfo) extends ReadAction[BatchInfoList] { 49 | val url = s"job/${job.id}/batch" 50 | } 51 | 52 | case class QueryCreate(job: JobInfo, query: String) extends WriteAction[BatchInfo] { 53 | val url = s"job/${job.id}/batch" 54 | val body = query 55 | } 56 | 57 | case class QueryGetResult(batch: BatchInfo) extends ReadAction[QueryResult] { 58 | val url = s"job/${batch.jobId}/batch/${batch.id}/result" 59 | } 60 | 61 | case class QueryGetRows(batch: BatchInfo, query: QueryResult) extends ReadAction[QueryRows] { 62 | val url = s"job/${batch.jobId}/batch/${batch.id}/result/${query.id}" 63 | } 64 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/salesforce/job/Result.scala: -------------------------------------------------------------------------------- 1 | package com.gu.salesforce.job 2 | 3 | sealed trait Result 4 | 5 | case class Error(msg: String) extends Throwable with Result { 6 | override def getMessage: String = msg 7 | } 8 | 9 | // https://www.salesforce.com/us/developer/docs/api_asynch/Content/asynch_api_reference_jobinfo.htm 10 | case class JobInfo(id: String) extends Result 11 | 12 | // https://www.salesforce.com/us/developer/docs/api_asynch/Content/asynch_api_reference_batchinfo.htm 13 | case class BatchInfo(id: String, jobId: String, state: String, stateMessage: String) extends Result { 14 | val completed = state == "Completed" 15 | val failed = state == "Failed" 16 | } 17 | 18 | // https://www.salesforce.com/us/developer/docs/api_asynch/Content/asynch_api_batches_get_info_all.htm 19 | sealed trait BatchInfoList extends Result 20 | case class InProcessBatchList() extends BatchInfoList 21 | case class CompletedBatchList(batches: Seq[BatchInfo]) extends BatchInfoList 22 | case class FailedBatchList(batch: BatchInfo) extends BatchInfoList 23 | 24 | // https://www.salesforce.com/us/developer/docs/api_asynch/Content/asynch_api_bulk_query.htm 25 | case class QueryResult(id: String) extends Result 26 | case class QueryRows(records: Seq[Map[String, String]]) extends Result 27 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/touchpoint/TouchpointBackendConfig.scala: -------------------------------------------------------------------------------- 1 | package com.gu.touchpoint 2 | 3 | import com.gu.i18n.Country 4 | import com.gu.identity.IdapiConfig 5 | import com.gu.monitoring.SafeLogging 6 | import com.gu.salesforce.SalesforceConfig 7 | import com.gu.stripe.{BasicStripeServiceConfig, StripeServiceConfig} 8 | import com.gu.zuora.{ZuoraApiConfig, ZuoraRestConfig, ZuoraSoapConfig} 9 | 10 | case class TouchpointBackendConfig( 11 | environmentName: String, 12 | salesforce: SalesforceConfig, 13 | stripePatrons: BasicStripeServiceConfig, 14 | stripeUKMembership: StripeServiceConfig, 15 | stripeAUMembership: StripeServiceConfig, 16 | stripeTortoiseMedia: StripeServiceConfig, 17 | zuoraSoap: ZuoraSoapConfig, 18 | zuoraRest: ZuoraRestConfig, 19 | idapi: IdapiConfig, 20 | ) 21 | 22 | object TouchpointBackendConfig extends SafeLogging { 23 | 24 | def byEnv(environmentName: String, backendsConfig: com.typesafe.config.Config) = { 25 | val envBackendConf = backendsConfig.getConfig(s"environments.$environmentName") 26 | 27 | TouchpointBackendConfig( 28 | environmentName, 29 | SalesforceConfig.from(envBackendConf, environmentName), 30 | BasicStripeServiceConfig.from(envBackendConf, "patrons"), 31 | StripeServiceConfig.from(envBackendConf, environmentName, Country.UK), // uk-membership 32 | StripeServiceConfig.from(envBackendConf, environmentName, Country.Australia, variant = "au-membership"), 33 | StripeServiceConfig.from(envBackendConf, environmentName, Country.UK, variant = "tortoise-media"), // tortoise-media 34 | ZuoraApiConfig.soap(envBackendConf, environmentName), 35 | ZuoraApiConfig.rest(envBackendConf, environmentName), 36 | IdapiConfig.from(envBackendConf, environmentName), 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/ZuoraApiConfig.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora 2 | 3 | import io.lemonlabs.uri.Uri 4 | import io.lemonlabs.uri.typesafe.dsl._ 5 | 6 | object ZuoraApiConfig { 7 | 8 | private def username(c: com.typesafe.config.Config) = c.getString("zuora.api.username") 9 | private def password(c: com.typesafe.config.Config) = c.getString("zuora.api.password") 10 | 11 | def soap(c: com.typesafe.config.Config, environmentName: String) = 12 | ZuoraSoapConfig(environmentName, c.getString("zuora.api.url"), username(c), password(c)) 13 | 14 | def rest(c: com.typesafe.config.Config, environmentName: String) = 15 | ZuoraRestConfig(environmentName, c.getString("zuora.api.restUrl"), username(c), password(c)) 16 | } 17 | 18 | case class ZuoraRestConfig(envName: String, url: Uri, username: String, password: String) 19 | case class ZuoraSoapConfig(envName: String, url: Uri, username: String, password: String) 20 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/rest/Readers.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.rest 2 | 3 | import okhttp3.{Response => OkHTTPResponse} 4 | import play.api.libs.functional.syntax._ 5 | import play.api.libs.json._ 6 | import scalaz.\/ 7 | 8 | object Readers { 9 | import Reads._ 10 | def parseResponse[T: Reads](resp: OkHTTPResponse): Response[T] = 11 | parseResponse[T](Json.parse(resp.body().string())) 12 | 13 | private def checkSuccess(j: JsValue): JsResult[Boolean] = 14 | (j \ "success").validate[Boolean].filter(JsError("success was false"))(_ == true) 15 | 16 | def parseResponse[T: Reads](json: JsValue): Response[T] = 17 | checkSuccess(json) 18 | .flatMap(_ => json.validate[T]) 19 | .map(\/.r[Failure].apply) 20 | .recoverTotal(errs => 21 | json 22 | .validate[Failure] 23 | .map(\/.l[T].apply) 24 | .recoverTotal(_ => \/.l[T](Failure("None", errs.errors.toSeq.map { case (e, es) => Error(0, s"$e: ${es.mkString(", ")}") }))), 25 | ) 26 | 27 | implicit val readUnit: Reads[Unit] = Reads.pure(()) 28 | 29 | implicit val subscriptionStatus: Reads[SubscriptionStatus] = new Reads[SubscriptionStatus] { 30 | override def reads(v: JsValue): JsResult[SubscriptionStatus] = v match { 31 | case JsString("Draft") => JsSuccess(Draft) 32 | case JsString("PendingActivation") => JsSuccess(PendingActivation) 33 | case JsString("PendingAcceptance") => JsSuccess(PendingAcceptance) 34 | case JsString("Active") => JsSuccess(Active) 35 | case JsString("Cancelled") => JsSuccess(Cancelled) 36 | case JsString("Expired") => JsSuccess(Expired) 37 | case other => JsError(s"Cannot parse a SubscriptionStatus from object $other") 38 | } 39 | } 40 | 41 | implicit val errorMsgReads: Reads[Error] = Json.reads[Error] 42 | implicit val failureReads: Reads[Failure] = ( 43 | (JsPath \ "processId").read[String] and 44 | (JsPath \ "reasons").read[List[Error]] 45 | )(Failure.apply _) 46 | 47 | implicit val featureReads: Reads[Feature] = Json.reads[Feature] 48 | } 49 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/rest/package.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora 2 | import play.api.libs.json.{JsPath, Json, Reads} 3 | import play.api.libs.functional.syntax._ 4 | import scalaz.\/ 5 | 6 | package object rest { 7 | 8 | type Response[T] = Failure \/ T 9 | case class Error(code: Int, message: String) 10 | case class Failure(processId: String, reasons: Seq[Error]) 11 | 12 | sealed trait SubscriptionStatus 13 | case object Draft extends SubscriptionStatus 14 | case object PendingActivation extends SubscriptionStatus 15 | case object PendingAcceptance extends SubscriptionStatus 16 | case object Active extends SubscriptionStatus 17 | case object Cancelled extends SubscriptionStatus 18 | case object Expired extends SubscriptionStatus 19 | 20 | case class Feature(id: String, featureCode: String) 21 | case class ZuoraResponse(success: Boolean, error: Option[String] = None) 22 | implicit val zuoraResponseReads: Reads[ZuoraResponse] = ( 23 | (JsPath \ "success").read[Boolean] and 24 | (JsPath \\ "message").readNullable[String] 25 | )(ZuoraResponse.apply _) 26 | 27 | case class ZuoraError(Code: String, Message: String) 28 | 29 | case class ZuoraCrudResponse(success: Boolean, errors: List[ZuoraError], createdId: Option[String] = None) 30 | 31 | implicit val ZuoraErrorReads = Json.reads[ZuoraError] 32 | implicit val ZuoraCreateResponseReads: Reads[ZuoraCrudResponse] = ( 33 | (JsPath \ "Success").read[Boolean] and 34 | (JsPath \\ "Errors").read[List[ZuoraError]].orElse(Reads.pure(List.empty)) and 35 | (JsPath \ "Id").readNullable[String] 36 | )(ZuoraCrudResponse.apply _) 37 | 38 | } 39 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/ServiceHelpers.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap 2 | 3 | import org.joda.time.format.ISODateTimeFormat 4 | import org.joda.time.{LocalDate, DateTime, DateTimeZone} 5 | 6 | object DateTimeHelpers { 7 | def formatDateTime(dt: DateTime): String = { 8 | val str = ISODateTimeFormat.dateTime().print(dt.withZone(DateTimeZone.UTC)) 9 | str.replace("Z", "+00:00") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/ZuoraFilters.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap 2 | import scala.language.implicitConversions 3 | 4 | sealed trait ZuoraFilter { 5 | def toFilterString: String 6 | } 7 | 8 | case class SimpleFilter(key: String, value: String, operator: String = "=") extends ZuoraFilter { 9 | override def toFilterString = s"$key$operator'$value'" 10 | } 11 | 12 | case class AndFilter(clauses: SimpleFilter*) extends ZuoraFilter { 13 | override def toFilterString = clauses.map(_.toFilterString).mkString(" AND ") 14 | } 15 | 16 | case class OrFilter(clauses: SimpleFilter*) extends ZuoraFilter { 17 | override def toFilterString = clauses.map(_.toFilterString).mkString(" OR ") 18 | } 19 | 20 | object ZuoraFilter { 21 | implicit def tupleToFilter(t: (String, String)): SimpleFilter = SimpleFilter(t._1, t._2) 22 | } 23 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/actions/Action.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.actions 2 | 3 | import com.gu.zuora.soap.models.Results.Authentication 4 | import com.gu.zuora.soap.models.Result 5 | 6 | import scala.xml.{Elem, NodeSeq} 7 | 8 | trait Action[T <: Result] { self => 9 | 10 | protected val body: Elem 11 | 12 | val authRequired = true 13 | val singleTransaction = false 14 | val enableLogging: Boolean = true 15 | 16 | def logInfo: Map[String, String] = Map("Action" -> self.getClass.getSimpleName) 17 | def additionalLogInfo: Map[String, String] = Map.empty 18 | 19 | def prettyLogInfo = (logInfo ++ additionalLogInfo).map { case (k, v) => s" - $k: $v" }.mkString("\n") 20 | 21 | def xml(authentication: Option[Authentication]) = { 22 | 25 | 26 | {sessionHeader(authentication)} 27 | { 28 | if (singleTransaction) { 29 | 30 | true 31 | 32 | } 33 | } 34 | 35 | {body} 36 | 37 | } 38 | 39 | def sanitized = body.toString() 40 | 41 | private def sessionHeader(authOpt: Option[Authentication]): NodeSeq = 42 | authOpt.fold(NodeSeq.Empty) { auth => 43 | 44 | {auth.token} 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/actions/XmlWriterAction.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.actions 2 | 3 | import com.gu.zuora.soap.models.Result 4 | import com.gu.zuora.soap.writers.XmlWriter 5 | 6 | import scala.xml.Elem 7 | 8 | /** This bridges the more comprehensive "Action" abstraction and the newer, simpler "XmlWriter" abstraction. "Actions" also handle authentication and 9 | * the result type of the action, "XmlWriters" only handle going from an object to XML This allows XMLWriters to write very simple struct like 10 | * objects 11 | */ 12 | class XmlWriterAction[I, T <: Result](i: I)(implicit xmlWriter: XmlWriter[I]) extends Action[T] { 13 | lazy val result = XmlWriter.write(i) 14 | override protected val body: Elem = result.value 15 | override def additionalLogInfo = result.written 16 | } 17 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/models/PaymentSummary.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.models 2 | 3 | import com.gu.i18n.Currency 4 | import com.gu.memsub.Price 5 | import com.gu.stripe.Stripe 6 | import com.gu.zuora.soap.models.Queries.{InvoiceItem, PreviewInvoiceItem, Subscription} 7 | 8 | case class PaymentSummary(current: InvoiceItem, previous: Seq[InvoiceItem], currency: Currency) { 9 | val totalPrice = current.price + previous.map(_.price).sum 10 | } 11 | 12 | object PaymentSummary { 13 | def apply(items: Seq[InvoiceItem], currency: Currency): PaymentSummary = { 14 | val sortedInvoiceItems = items.sortBy(_.chargeNumber) 15 | PaymentSummary(sortedInvoiceItems.last, sortedInvoiceItems.dropRight(1), currency) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/models/Result.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.models 2 | 3 | import com.gu.zuora.soap.models.Queries.PreviewInvoiceItem 4 | 5 | trait Result 6 | object Results { 7 | case class Authentication(token: String, url: String) extends Result 8 | case class QueryResult(results: Seq[Map[String, String]]) extends Result 9 | case class UpdateResult(id: String) extends Result 10 | case class SubscribeResult(subscriptionId: String, subscriptionName: String, accountId: String) extends Result 11 | case class AmendResult(ids: Seq[String], invoiceItems: Seq[PreviewInvoiceItem]) extends Result 12 | case class CreateResult(id: String) extends Result 13 | } 14 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/package.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora 2 | 3 | package object soap { 4 | 5 | trait ZuoraException extends Throwable 6 | 7 | case class ZuoraServiceError(s: String) extends ZuoraException { 8 | override def getMessage: String = s 9 | } 10 | 11 | case class ZuoraQueryException(s: String) extends ZuoraException { 12 | override def getMessage: String = s 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/readers/Query.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.readers 2 | import com.gu.zuora.soap.models 3 | 4 | trait Query[T <: models.Query] { 5 | val table: String 6 | val fields: Seq[String] 7 | 8 | def format(where: String) = 9 | s"SELECT ${fields.mkString(",")} FROM ${table} WHERE $where" 10 | 11 | def read(results: Seq[Map[String, String]]): Seq[T] = results.map(extract) 12 | 13 | def extract(result: Map[String, String]): T 14 | } 15 | 16 | object Query { 17 | def apply[T <: models.Query](tableName: String, fieldSeq: Seq[String])(extractFn: Map[String, String] => T) = 18 | new Query[T] { 19 | val table = tableName 20 | val fields = fieldSeq 21 | def extract(results: Map[String, String]) = extractFn(results) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/readers/Reader.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.readers 2 | 3 | import com.gu.zuora.soap.models 4 | import com.gu.zuora.soap.models.errors._ 5 | 6 | import scala.util.{Failure, Success, Try} 7 | import scala.xml.Node 8 | 9 | trait Reader[T <: models.Result] { 10 | val responseTag: String 11 | val multiResults = false 12 | 13 | def read(body: String): Either[Error, T] = { 14 | Try(scala.xml.XML.loadString(body)) match { 15 | case Failure(ex) => Left(XmlParseError(ex.getMessage)) 16 | 17 | case Success(node) => 18 | val body = scala.xml.Utility.trim((scala.xml.Utility.trim(node) \ "Body").head) 19 | 20 | (body \ "Fault").headOption.fold { 21 | val resultNode = if (multiResults) "results" else "result" 22 | val result = body \ responseTag \ resultNode 23 | 24 | extractEither(result.head) 25 | } { fault => Left(ErrorHandler(fault)) } 26 | } 27 | } 28 | 29 | protected def extractEither(result: Node): Either[Error, T] 30 | } 31 | 32 | object Reader { 33 | def apply[T <: models.Result](tag: String)(extractFn: Node => Either[Error, T]) = new Reader[T] { 34 | val responseTag = tag 35 | protected def extractEither(result: Node): Either[Error, T] = extractFn(result) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/readers/Result.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.readers 2 | 3 | import com.gu.zuora.soap.models 4 | import com.gu.zuora.soap.models.errors._ 5 | 6 | import scala.xml.Node 7 | 8 | trait Result[T <: models.Result] extends Reader[T] { 9 | protected def extractEither(result: Node): Either[Error, T] = { 10 | if ((result \ "Success").text == "true") { 11 | Right(extract(result)) 12 | } else { 13 | Left(ErrorHandler(result)) 14 | } 15 | } 16 | 17 | protected def extract(result: Node): T 18 | } 19 | 20 | object Result { 21 | def create[T <: models.Result](tag: String, multi: Boolean, extractFn: Node => T) = new Result[T] { 22 | val responseTag = tag 23 | override val multiResults: Boolean = multi 24 | protected def extract(result: Node) = extractFn(result) 25 | } 26 | 27 | def apply[T <: models.Result](tag: String)(extractFn: Node => T) = create(tag, multi = false, extractFn) 28 | def multi[T <: models.Result](tag: String)(extractFn: Node => T) = create(tag, multi = true, extractFn) 29 | } 30 | -------------------------------------------------------------------------------- /membership-common/src/main/scala/com/gu/zuora/soap/writers/XmlWriter.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap.writers 2 | import scala.xml.Elem 3 | import scalaz.Writer 4 | 5 | trait XmlWriter[T] { 6 | def write(t: T): Writer[Map[String, String], Elem] 7 | } 8 | 9 | object XmlWriter { 10 | def write[T](t: T)(implicit w: XmlWriter[T]): Writer[Map[String, String], Elem] = w.write(t) 11 | } 12 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/batch-info-list-completed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 75111000000gUOCAA2 5 | 75011000000XgXSAA0 6 | Completed 7 | 8 | 2014-10-17T13:25:32.000Z 9 | 2014-10-17T13:25:33.000Z 10 | 0 11 | 0 12 | 0 13 | 0 14 | 0 15 | 16 | 17 | 75111000000gUOCAA3 18 | 75011000000XgXSAA1 19 | Completed 20 | 21 | 2014-10-17T13:25:32.000Z 22 | 2014-10-17T13:25:33.000Z 23 | 0 24 | 0 25 | 0 26 | 0 27 | 0 28 | 29 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/batch-info-list-failed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 75111000000gUOCAA2 5 | 75011000000XgXSAA0 6 | Failed 7 | Failed message 8 | 2014-10-17T13:25:32.000Z 9 | 2014-10-17T13:25:33.000Z 10 | 0 11 | 0 12 | 0 13 | 0 14 | 0 15 | 16 | 17 | 75111000000gUOCAA3 18 | 75011000000XgXSAA1 19 | Completed 20 | 21 | 2014-10-17T13:25:32.000Z 22 | 2014-10-17T13:25:33.000Z 23 | 0 24 | 0 25 | 0 26 | 0 27 | 0 28 | 29 | 30 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/batch-info-list-in-progress.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 75111000000gUOCAA2 5 | 75011000000XgXSAA0 6 | InProgress 7 | 8 | 2014-10-17T13:25:32.000Z 9 | 2014-10-17T13:25:33.000Z 10 | 0 11 | 0 12 | 0 13 | 0 14 | 0 15 | 16 | 17 | 75111000000gUOCAA3 18 | 75011000000XgXSAA1 19 | Completed 20 | 21 | 2014-10-17T13:25:32.000Z 22 | 2014-10-17T13:25:33.000Z 23 | 0 24 | 0 25 | 0 26 | 0 27 | 0 28 | 29 | 30 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/batch-info.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 75111000000gUOCAA2 4 | 75011000000XgXSAA0 5 | Queued 6 | 2014-10-17T13:25:32.000Z 7 | 2014-10-17T13:25:32.000Z 8 | 0 9 | 0 10 | 0 11 | 0 12 | 0 13 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/cas/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "message": "Unknown subscriber", 4 | "code": -90 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/cas/expired-subscription.json: -------------------------------------------------------------------------------- 1 | { 2 | "expiry": { 3 | "expiryType": "sub", 4 | "provider": "provider", 5 | "expiryDate": "2015-02-01", 6 | "subscriptionCode": "XXX", 7 | "content": "CONTENT" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/cas/valid-subscription-optional-fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "expiry": { 3 | "expiryType": "sub", 4 | "expiryDate": "2030-02-24", 5 | "content": "CONTENT" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/cas/valid-subscription.json: -------------------------------------------------------------------------------- 1 | { 2 | "expiry": { 3 | "expiryType": "sub", 4 | "provider": "provider", 5 | "expiryDate": "2030-02-24", 6 | "subscriptionCode": "XXX", 7 | "content": "CONTENT" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/job-info.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 75011000000XgXSAA0 4 | query 5 | Contact 6 | 00520000003DLCDAA4 7 | 2014-10-17T13:25:32.000Z 8 | 2014-10-17T13:25:32.000Z 9 | Open 10 | Parallel 11 | XML 12 | 0 13 | 0 14 | 0 15 | 0 16 | 0 17 | 0 18 | 0 19 | 31.0 20 | 0 21 | 0 22 | 0 23 | 0 24 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/amend-result.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 2c92c0f847f1dcf00147fe0a50520707 6 | 2c92c0f847f1dcf00147fe0a50f3071f 7 | 8 | 2c92c0f847f1dcf00147fe0a51a10729 9 | 10 | true 11 | -11.250000000 12 | 0.0 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/amendments.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 8 | 11 | 2c92c0f847cdc31e0147cf2439b76ae6 12 | 2015-08-13 13 | 2c92c0f847cdc31e0147cf24396f6ae1 14 | RemoveProduct 15 | 16 | 19 | 2c92c0f847cdc31e0147cf24390d6ad7 20 | 2015-08-13 21 | 2c92c0f847cdc31e0147cf2111ba6173 22 | NewProduct 23 | 24 | 2 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/authentication-success.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | yiZutsU55oKFQDR28lv210d7iWh8FVW9K45xFeFWMov7OSlRRI0soZ40DHmdLokscEjaUOo7Jt4sFxm_QZPyAhJdpR9yIxi_ 7 | 8 | 9 | https://apisandbox.zuora.com/apps/services/a/58.0 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/create-result.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 2c92c0f847ae39ba0147c580319a7208 6 | true 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/fault-error.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fns:INVALID_VALUE 5 | 6 | Invalid login. User name and password do not match. 7 | 8 | 9 | 10 | INVALID_VALUE 11 | 12 | Invalid login. User name and password do not match. 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/invalid.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8a80812a4733a5bb0147a1a483994104 6 | A00000011 7 | 8 | Approved 9 | 8a80812a4733a5bb0147a1a489144118 10 | INV00000011 11 | 12 | 13 | 8a80812a4733a5bb0147a1a489144118 14 | INV00000011 15 | 16 | 17 | 8a80812a4733a5bb0147a1a489664122 18 | ch_4Wr8CMYwAb01zm 19 | 8a80812a4733a5bb0147a1a4887f410a 20 | A-S00000011 21 | true 22 | 15.000000000 23 | 180.000000000 24 | 26 | 27 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/payment-gateway-error.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TRANSACTION_FAILED 8 | Transaction declined.generic_decline - Your card was declined. 9 | 10 | Your card was declined. 11 | Declined 12 | false 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/query-empty.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 7 | 8 | 9 | 10 | 0 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/query-not-done.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | false 6 | 8 | 11 | 2c92c0f847cdc31e0147cf2439b76ae6 12 | 2015-08-13 13 | 2c92c0f847cdc31e0147cf24396f6ae1 14 | 15 | 18 | 2c92c0f847cdc31e0147cf24390d6ad7 19 | 2015-08-13 20 | 2c92c0f847cdc31e0147cf2111ba6173 21 | 22 | 2 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/query-single.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 7 | 8 | 9 | 2c92c0f947cddc220147d3c765d0433e 10 | 1 11 | 12 | 1 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/rateplancharges.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 7 | 8 | 9 | 2c92c0f94878e82801487917701931c5 10 | 11 | 2015-09-15 12 | 13 | 14 | 2014-09-15 15 | 16 | 135 17 | 18 | 1 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/rateplans.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 7 | 8 | 9 | 2c92c0f94878e828014879176ff831c4 10 | Partner - annual 11 | ProductRatePlanId 12 | 13 | 1 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/result-error-fatal.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | API_DISABLED 11 | 12 | The API was disabled. 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | false 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/result-error-non-fatal.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | TRANSACTION_FAILED 11 | 12 | Transaction declined.generic_decline - Your card was declined. 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | false 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/subscribe-result.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8a80812a4733a5bb0147a1a483994104 6 | A00000011 7 | 8 | Approved 9 | 8a80812a4733a5bb0147a1a489144118 10 | INV00000011 11 | 12 | 13 | 8a80812a4733a5bb0147a1a489144118 14 | INV00000011 15 | 16 | 17 | 8a80812a4733a5bb0147a1a489664122 18 | ch_4Wr8CMYwAb01zm 19 | 8a80812a4733a5bb0147a1a4887f410a 20 | A-S00000011 21 | true 22 | 15.000000000 23 | 180.000000000 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/model/zuora/update-result.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 2c92c0f847ae39b80147c584947b7ea3 6 | true 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/campaign/digitalpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "SUB123", 3 | "name": "Digipack campaign", 4 | "productFamily": "digitalpack" 5 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/campaign/invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "TOM123", 3 | "name": "Tom campaign", 4 | "productFamily": "toms dodgy products" 5 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/campaign/membership.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "MEM123", 3 | "name": "Membership campaign", 4 | "productFamily": "membership" 5 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/campaign/newspaper.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "SUB456", 3 | "name": "Daily Newspaper campaign", 4 | "group": "newspaper" 5 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/campaign/weekly.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "SUB789", 3 | "name": "Guardian Weekly campaign", 4 | "group": "weekly", 5 | "sortDate": "2018-12-31T00:00:00.000+00:00" 6 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/membership/discount.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "e6c5c361-9b03-459e-9ec0-9eb26922a96b", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "subtitle": "Subtitle", 23 | "productFamily": "membership", 24 | "description": "Desc", 25 | "roundelHtml": "Roundel" 26 | }, 27 | "starts": "2016-05-04T13:58:20.113+01:00", 28 | "expires": "2016-05-06T13:58:20.113+01:00", 29 | "promotionType": { 30 | "amount": 50, 31 | "name": "percent_discount" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/membership/images.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "e6c5c361-9b03-459e-9ec0-9eb26922a96b", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "subtitle": "Subtitle", 23 | "productFamily": "membership", 24 | "description": "Desc", 25 | "roundelHtml": "Roundel", 26 | "heroImage": { 27 | "alignment": "bottom", 28 | "image": { 29 | "availableImages": [ 30 | {"path": "http://example.com", "width": 50}, 31 | {"path": "http://example.com", "width": 20}, 32 | {"path": "http://example.com", "width": 30} 33 | ] 34 | } 35 | }, 36 | "image": { 37 | "availableImages": [ 38 | {"path": "http://example.com", "width": 5}, 39 | {"path": "http://example.com", "width": 2}, 40 | {"path": "http://example.com", "width": 3} 41 | ] 42 | } 43 | }, 44 | "starts": "2016-05-04T13:58:20.113+01:00", 45 | "expires": "2016-05-06T13:58:20.113+01:00", 46 | "promotionType": { 47 | "amount": 50, 48 | "name": "percent_discount" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/membership/incentive.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "60ef61e2-de7c-4472-a386-93ca54d9259f", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "subtitle": "Subtitle", 23 | "productFamily": "membership", 24 | "description": "Desc", 25 | "roundelHtml": "Roundel", 26 | "imageUrl": "http://example.com", 27 | "sectionColour": "grey" 28 | }, 29 | "starts": "2016-05-04T13:59:38.163+01:00", 30 | "expires": "2016-05-06T13:59:38.163+01:00", 31 | "promotionType": { 32 | "redemptionInstructions": "this", 33 | "termsAndConditions": "thing", 34 | "name": "incentive", 35 | "legalTerms": "legalese" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/membership/invalid-type.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "60ef61e2-de7c-4472-a386-93ca54d9259f", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "subtitle": "Subtitle", 23 | "productFamily": "membership", 24 | "description": "Desc", 25 | "roundelHtml": "Roundel", 26 | "imageUrl": "http://example.com" 27 | }, 28 | "starts": "2016-05-04T13:59:38.163+01:00", 29 | "expires": "2016-05-06T13:59:38.163+01:00", 30 | "promotionType": { 31 | "name": "not_tracking" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/membership/tracking.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "fb1fe5dd-1b9f-4a23-adf7-3e9386fd7404", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "subtitle": "Subtitle", 23 | "productFamily": "membership", 24 | "description": "Desc", 25 | "roundelHtml": "Roundel", 26 | "imageUrl": "http://example.com" 27 | }, 28 | "starts": "2016-05-04T13:56:04.126+01:00", 29 | "expires": "2016-05-06T13:56:04.126+01:00", 30 | "promotionType": { 31 | "name": "tracking" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/discount.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "e6c5c361-9b03-459e-9ec0-9eb26922a96b", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "description": "Desc", 23 | "roundelHtml": "Roundel", 24 | "imageUrl": "http://example.com", 25 | "sectionColour": "blue", 26 | "productFamily": "digitalpack" 27 | }, 28 | "starts": "2016-05-04T13:58:20.113+01:00", 29 | "expires": "2016-05-06T13:58:20.113+01:00", 30 | "promotionType": { 31 | "amount": 50, 32 | "name": "percent_discount" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/double-type.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "b4209f28-4a13-4e4c-99e8-cd4c68825973", 3 | "name": "Test promotion", 4 | "description": "PARTNER99 description", 5 | "appliesTo": { 6 | "productRatePlanIds": ["test"], 7 | "countries": ["GB"] 8 | }, 9 | "campaignCode": "C", 10 | "codes": { 11 | "testChannel": ["PARTNER99"] 12 | }, 13 | "landingPage": { 14 | "title": "Page", 15 | "description": "Desc", 16 | "roundelHtml": "Roundel", 17 | "image": { 18 | "availableImages": [{ 19 | "path": "http://example.com", 20 | "width": 0 21 | }] 22 | }, 23 | "sectionColour": "blue", 24 | "productFamily": "digitalpack" 25 | }, 26 | "starts": "2016-09-01T14:30:27.599+01:00", 27 | "expires": "2016-09-03T14:30:27.836+01:00", 28 | "promotionType": { 29 | "a": { 30 | "name": "tracking" 31 | }, 32 | "b": { 33 | "durationMonths": 3, 34 | "amount": 20, 35 | "name": "percent_discount" 36 | }, 37 | "name": "double" 38 | } 39 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/freetrial.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "7e843427-0621-42e4-b0c8-d2d7902d5862", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "description": "Desc", 23 | "roundelHtml": "Roundel", 24 | "imageUrl": "http://example.com", 25 | "productFamily": "digitalpack" 26 | }, 27 | "starts": "2016-05-04T13:57:05.878+01:00", 28 | "expires": "2016-05-06T13:57:05.878+01:00", 29 | "promotionType": { 30 | "duration": 30, 31 | "name": "free_trial" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/incentive.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "60ef61e2-de7c-4472-a386-93ca54d9259f", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "description": "Desc", 23 | "roundelHtml": "Roundel", 24 | "imageUrl": "http://example.com", 25 | "sectionColour": "grey", 26 | "productFamily": "digitalpack" 27 | }, 28 | "starts": "2016-05-04T13:59:38.163+01:00", 29 | "expires": "2016-05-06T13:59:38.163+01:00", 30 | "promotionType": { 31 | "redemptionInstructions": "this", 32 | "termsAndConditions": "thing", 33 | "name": "incentive", 34 | "legalTerms": "legalese" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/invalid-landingPage-sectionColour.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "60ef61e2-de7c-4472-a386-93ca54d9259f", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "description": "Desc", 23 | "roundelHtml": "Roundel", 24 | "imageUrl": "http://example.com", 25 | "sectionColour": "yellow", 26 | "productFamily": "digitalpack" 27 | }, 28 | "starts": "2016-05-04T13:59:38.163+01:00", 29 | "expires": "2016-05-06T13:59:38.163+01:00", 30 | "promotionType": { 31 | "redemptionInstructions": "this", 32 | "termsAndConditions": "thing", 33 | "name": "incentive" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/invalid-type.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "60ef61e2-de7c-4472-a386-93ca54d9259f", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "description": "Desc", 23 | "roundelHtml": "Roundel", 24 | "imageUrl": "http://example.com", 25 | "productFamily": "digitalpack" 26 | }, 27 | "starts": "2016-05-04T13:59:38.163+01:00", 28 | "expires": "2016-05-06T13:59:38.163+01:00", 29 | "promotionType": { 30 | "name": "not_tracking" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/retention.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "fb1fe5dd-1b9f-4a23-adf7-3e9386fd7404", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "description": "Desc", 23 | "roundelHtml": "Roundel", 24 | "imageUrl": "http://example.com", 25 | "productFamily": "digitalpack" 26 | }, 27 | "starts": "2016-05-04T13:56:04.126+01:00", 28 | "expires": "2016-05-06T13:56:04.126+01:00", 29 | "promotionType": { 30 | "name": "retention" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/promo/subscriptions/tracking.json: -------------------------------------------------------------------------------- 1 | { 2 | "uuid": "fb1fe5dd-1b9f-4a23-adf7-3e9386fd7404", 3 | "name": "Test promotion", 4 | "description": "description", 5 | "appliesTo": { 6 | "productRatePlanIds": [ 7 | "123" 8 | ], 9 | "countries": [ 10 | "GB" 11 | ] 12 | }, 13 | "campaignName": "1234 promotion", 14 | "campaignCode": "C", 15 | "codes": { 16 | "testChannel": [ 17 | "1234" 18 | ] 19 | }, 20 | "landingPage": { 21 | "title": "Page", 22 | "description": "Desc", 23 | "roundelHtml": "Roundel", 24 | "imageUrl": "http://example.com", 25 | "productFamily": "digitalpack" 26 | }, 27 | "starts": "2016-05-04T13:56:04.126+01:00", 28 | "expires": "2016-05-06T13:56:04.126+01:00", 29 | "promotionType": { 30 | "name": "tracking" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/query-result.xml: -------------------------------------------------------------------------------- 1 | 75211000000EyhI -------------------------------------------------------------------------------- /membership-common/src/test/resources/query-rows-empty.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/query-rows-results.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Contact 5 | 0031100000ad2NpAAI 6 | 0031100000ad2NpAAI 7 | 10000140 8 | 9 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/rest/AmendmentResult.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [ 3 | { 4 | "SubscriptionId": "1", 5 | "TotalDeltaTcv": 2, 6 | "AmendmentIds": [ 7 | "3" 8 | ], 9 | "TotalDeltaMrr": 0, 10 | "Success": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/rest/GiftSubscriptions.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "Name": "subname", 5 | "Id": "2c92c0fa74e96420017501ba59171832", 6 | "TermEndDate": "2021-10-08" 7 | }, 8 | { 9 | "Name": "subname", 10 | "Id": "2c92c0fa74e964300175029626ef1f63", 11 | "TermEndDate": "2021-10-08" 12 | } 13 | ], 14 | "done": true, 15 | "size": 2 16 | } 17 | -------------------------------------------------------------------------------- /membership-common/src/test/resources/rest/accounts/AccountQueryResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "Balance": 0, 5 | "Currency": "GBP", 6 | "Id": "2c92c0f85cee08f3015cf32fa5df14a1" 7 | }, 8 | { 9 | "Balance": 12.34, 10 | "Currency": "USD", 11 | "Id": "2c92c0f95cee23f3015cf37ef9f24b6a", 12 | "DefaultPaymentMethodId": "2c92a0fd58339435015844cd964c75d2", 13 | "PaymentGateway": "Stripe Gateway GNM Membership AUS" 14 | }, 15 | { 16 | "Balance": 56.78, 17 | "Currency": "GBP", 18 | "Id": "2c92c0f8610ddce501613228973713a8", 19 | "DefaultPaymentMethodId": "2c92c0f8610ddce501613228977313ac", 20 | "PaymentGateway": "Stripe Gateway 1", 21 | "LastInvoiceDate": "2018-01-26" 22 | } 23 | ], 24 | "size": 2, 25 | "done": true 26 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/rest/paymentmethod/PaymentMethod.json: -------------------------------------------------------------------------------- 1 | { 2 | "CreditCardExpirationMonth": 12, 3 | "Active": false, 4 | "Id": "2c92c0f86179f24301617b174a1b112e", 5 | "CreditCardCountry": "United States", 6 | "UpdatedDate": "2018-02-09T15:03:00.000+00:00", 7 | "AccountId": "2c92c0f86179f24301617b174a01112b", 8 | "SecondTokenId": "cus_CIDqPEHv6XGDHS", 9 | "NumConsecutiveFailures": 0, 10 | "CreditCardType": "Visa", 11 | "LastTransactionStatus": "Approved", 12 | "CreditCardExpirationYear": 2021, 13 | "Type": "CreditCardReferenceTransaction", 14 | "CreatedDate": "2018-02-09T15:02:59.000+00:00", 15 | "TotalNumberOfProcessedPayments": 1, 16 | "PaymentMethodStatus": "Active", 17 | "BankIdentificationNumber": "4242", 18 | "TotalNumberOfErrorPayments": 0, 19 | "CreditCardMaskNumber": "4242", 20 | "TokenId": "card_CIDq5xEZd8Fpan", 21 | "LastTransactionDateTime": "2018-02-09T15:03:00.000+00:00", 22 | "UseDefaultRetryRule": true, 23 | "UpdatedById": "2c92c0f9471e145f01471fc9142a1a61", 24 | "CreatedById": "2c92c0f9471e145f01471fc9142a1a61" 25 | } -------------------------------------------------------------------------------- /membership-common/src/test/resources/salesforce/contact-upsert.response.error.json: -------------------------------------------------------------------------------- 1 | {"Success":false,"ErrorString":"Failed Upsert of new Contact: Upsert failed. First exception on row 0; first error: REQUIRED_FIELD_MISSING, Required fields are missing: [LastName]: [LastName]","ContactRecord":null} -------------------------------------------------------------------------------- /membership-common/src/test/resources/salesforce/contact-upsert.response.good.json: -------------------------------------------------------------------------------- 1 | {"Success":true,"ErrorString":null,"ContactRecord":{"attributes":{"type":"Contact","url":"/services/data/v31.0/sobjects/Contact/0031100000csfTPAAY"},"AccountId":"0011100000fRBQ8AAO","FirstName":"Bobby","IdentityID__c":"176650019","Id":"0031100000csfTPAAY","LastName":"Tables"}} -------------------------------------------------------------------------------- /membership-common/src/test/resources/stripe/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "message": "Your card was declined.", 4 | "type": "card_error", 5 | "code": "card_declined", 6 | "decline_code": "do_not_honor", 7 | "charge": "ch_111111111111111111111111" 8 | } 9 | } -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/Diff.scala: -------------------------------------------------------------------------------- 1 | package com.gu 2 | 3 | import com.gu.memsub.ProductRatePlanChargeProductType 4 | import com.softwaremill.diffx.scalatest.DiffShouldMatcher._ 5 | import com.softwaremill.diffx.{Diff => Diffx} 6 | import org.scalatest.Assertion 7 | import scalaz.{Validation, \/} 8 | 9 | object Diff { 10 | 11 | def assertEquals[T: Diffx](expected: T, actual: T): Assertion = 12 | actual shouldMatchTo (expected) 13 | 14 | implicit def eitherDiff[L: Diffx, R: Diffx]: Diffx[L \/ R] = Diffx.derived[L \/ R] 15 | implicit def validationDiff[E: Diffx, A: Diffx]: Diffx[Validation[E, A]] = Diffx.derived[Validation[E, A]] 16 | 17 | implicit val benefitDiff: Diffx[ProductRatePlanChargeProductType] = Diffx.diffForString.contramap(_.id) 18 | 19 | } 20 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/config/SubsV2ProductIdsTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.config 2 | import com.typesafe.config.ConfigFactory 3 | import org.specs2.mutable.Specification 4 | 5 | class SubsV2ProductIdsTest extends Specification { 6 | 7 | "Subs V2 product IDs" should { 8 | 9 | "Work with the configs in here" in { 10 | 11 | val dev = ConfigFactory.parseResources("touchpoint.CODE.conf") 12 | val prod = ConfigFactory.parseResources("touchpoint.PROD.conf") 13 | 14 | SubsV2ProductIds.load(dev.getConfig("touchpoint.backend.environments.CODE.zuora.productIds")) 15 | SubsV2ProductIds.load(prod.getConfig("touchpoint.backend.environments.PROD.zuora.productIds")) 16 | done 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/i18n/AddressTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.i18n 2 | 3 | import com.gu.memsub.Address 4 | import org.specs2.mutable.Specification 5 | 6 | class AddressTest extends Specification { 7 | val IE = CountryGroup.countryByCode("IE") 8 | 9 | "Address" should { 10 | "require only a postcode" in { 11 | Address("lineOne", "lineTwo", "town", "county", "", "GB").valid must beFalse 12 | 13 | Address("", "", "", "", "postCode", "GB").valid must beTrue 14 | } 15 | 16 | "require only lineOne and town in Ireland" in { 17 | Address("", "", "", "", "", "IE").valid must beFalse 18 | Address("lineOne", "", "", "", "", "IE").valid must beFalse 19 | Address("", "", "town", "", "", "IE").valid must beFalse 20 | 21 | Address("lineOne", "", "town", "", "", "IE").valid must beTrue 22 | } 23 | 24 | "require only postcode and valid state in the US" in { 25 | Address("", "", "", "", "", "US").valid must beFalse 26 | Address("", "", "", "", "postCode", "US").valid must beFalse 27 | Address("", "", "", "New York", "", "US").valid must beFalse 28 | Address("", "", "", "Greater York", "postCode", "US").valid must beFalse 29 | 30 | Address("", "", "", "New York", "postCode", "US").valid must beTrue 31 | } 32 | 33 | "require only postcode and valid province in CA" in { 34 | Address("", "", "", "", "", "CA").valid must beFalse 35 | Address("", "", "", "", "postCode", "CA").valid must beFalse 36 | Address("", "", "", "Quebec", "", "CA").valid must beFalse 37 | Address("", "", "", "Old Quebec", "postCode", "CA").valid must beFalse 38 | 39 | Address("", "", "", "Quebec", "postCode", "CA").valid must beTrue 40 | } 41 | 42 | "concatenate lineOne and lineTwo" in { 43 | Address("one", "two", "town", "county", "postCode", "GB").line mustEqual "one, two" 44 | Address("one", "", "town", "county", "postCode", "GB").line mustEqual "one" 45 | Address("", "two", "town", "county", "postCode", "GB").line mustEqual "two" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/i18n/CountryGroupTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.i18n 2 | 3 | import org.specs2.mutable.Specification 4 | import Currency._ 5 | 6 | class CountryGroupTest extends Specification { 7 | "CountryGroupTest" should { 8 | 9 | "byId" in { 10 | CountryGroup.byId("ie") should_=== None 11 | CountryGroup.byId("eu") should_=== Some(CountryGroup.Europe) 12 | CountryGroup.byId("int") should_=== Some(CountryGroup.RestOfTheWorld) 13 | } 14 | 15 | "byCountryNameOrCode" in { 16 | CountryGroup.byCountryNameOrCode(Country.Australia.alpha2) should_=== Some(CountryGroup.Australia) 17 | CountryGroup.byCountryNameOrCode(Country.Australia.name) should_=== Some(CountryGroup.Australia) 18 | CountryGroup.byCountryNameOrCode(Country.US.alpha2) should_=== Some(CountryGroup.US) 19 | CountryGroup.byCountryNameOrCode(Country.US.name) should_=== Some(CountryGroup.US) 20 | CountryGroup.byCountryNameOrCode("Italy") should_=== Some(CountryGroup.Europe) 21 | CountryGroup.byCountryNameOrCode("IT") should_=== Some(CountryGroup.Europe) 22 | CountryGroup.byCountryNameOrCode("AF") should_=== Some(CountryGroup.RestOfTheWorld) 23 | CountryGroup.byCountryNameOrCode("Afghanistan") should_=== Some(CountryGroup.RestOfTheWorld) 24 | CountryGroup.byCountryNameOrCode("IE") should_=== Some(CountryGroup.Europe) 25 | } 26 | 27 | "availableCurrency" in { 28 | CountryGroup.availableCurrency(Set.empty)(Country.UK) should_=== None 29 | CountryGroup.availableCurrency(Set(GBP, AUD))(Country.US) should_=== None 30 | CountryGroup.availableCurrency(Set(GBP, AUD))(Country.UK) should_=== Some(GBP) 31 | CountryGroup.availableCurrency(Set(GBP, AUD))(Country.Australia) should_=== Some(AUD) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/memsub/PriceTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import com.gu.i18n.Currency._ 4 | import org.specs2.mutable.Specification 5 | 6 | class PriceTest extends Specification { 7 | "A price" should { 8 | "be prettified" in { 9 | Price(4.99f, USD).pretty shouldEqual "US$4.99" 10 | Price(4.9949f, USD).pretty shouldEqual "US$4.99" 11 | Price(4.995f, USD).pretty shouldEqual "US$5" 12 | Price(5f, GBP).pretty shouldEqual "£5" 13 | } 14 | 15 | "support basic binary operations with other prices" in { 16 | Price(5f, GBP) + 5f shouldEqual Price(10f, GBP) 17 | Price(5f, GBP) - 5f shouldEqual Price(0f, GBP) 18 | Price(5f, GBP) * 5f shouldEqual Price(25f, GBP) 19 | Price(5f, GBP) / 5f shouldEqual Price(1f, GBP) 20 | 21 | Price(5f, GBP) + Price(5f, GBP) shouldEqual Price(10f, GBP) 22 | Price(5f, GBP) - Price(5f, GBP) shouldEqual Price(0f, GBP) 23 | Price(5f, GBP) * Price(5f, GBP) shouldEqual Price(25f, GBP) 24 | Price(5f, GBP) / Price(5f, GBP) shouldEqual Price(1f, GBP) 25 | 26 | Price(5f, GBP) + Price(5f, USD) should throwAn[AssertionError] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/memsub/SupplierCodeTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | class SupplierCodeTest extends Specification { 6 | "buildSupplierCode" should { 7 | "returnNoneWhenStringIsWhollyInappropriate" in { 8 | SupplierCodeBuilder.buildSupplierCode(null) mustEqual None 9 | SupplierCodeBuilder.buildSupplierCode("") mustEqual None 10 | SupplierCodeBuilder.buildSupplierCode(" ") mustEqual None 11 | SupplierCodeBuilder.buildSupplierCode("_") mustEqual None 12 | SupplierCodeBuilder.buildSupplierCode("<%>$!") mustEqual None 13 | } 14 | "stripOutNonAlphaNumericCharacters" in { 15 | SupplierCodeBuilder.buildSupplierCode("FOO ") mustEqual Some(SupplierCode("FOO")) 16 | SupplierCodeBuilder.buildSupplierCode("FOO BAR") mustEqual Some(SupplierCode("FOOBAR")) 17 | SupplierCodeBuilder.buildSupplierCode("FOO@BAR") mustEqual Some(SupplierCode("FOOBAR")) 18 | SupplierCodeBuilder.buildSupplierCode("1@A") mustEqual Some(SupplierCode("1A")) 19 | } 20 | "trimWhenLongerThan255Chars" in { 21 | SupplierCodeBuilder.buildSupplierCode("F" * 256) mustEqual Some(SupplierCode("F" * 255)) 22 | SupplierCodeBuilder.buildSupplierCode("F" * 255) mustEqual Some(SupplierCode("F" * 255)) 23 | } 24 | "autoCapitaliseAllCharacters" in { 25 | SupplierCodeBuilder.buildSupplierCode("FooBar123") mustEqual Some(SupplierCode("FOOBAR123")) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/memsub/subsv2/Fixtures.scala: -------------------------------------------------------------------------------- 1 | package com.gu.memsub.subsv2 2 | 3 | import com.gu.config.SubsV2ProductIds 4 | import com.typesafe.config.ConfigFactory 5 | 6 | object Fixtures { 7 | lazy val config = ConfigFactory.parseResources("touchpoint.CODE.conf") 8 | lazy val productIds = SubsV2ProductIds.load(config.getConfig("touchpoint.backend.environments.CODE.zuora.productIds")) 9 | } 10 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/salesforce/ContactDeserializerTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.salesforce 2 | import com.gu.salesforce.ContactDeserializer._ 3 | import org.specs2.mutable._ 4 | import utils.Resource 5 | 6 | class ContactDeserializerTest extends Specification { 7 | 8 | "MemberDeserializer" should { 9 | "deserialize PaidMember" in { 10 | val c = Resource.getJson("paid-member.json").as[Contact] 11 | c.salesforceAccountId mustEqual "0011100000XjDmQAAV" 12 | c.salesforceContactId mustEqual "0031100000WDp1LAAT" 13 | c.identityId must beSome("10000007") 14 | c.regNumber must beSome("1234") 15 | } 16 | 17 | "deserialize digipack subscriber" in { 18 | val c = Resource.getJson("subs-member.json").as[Contact] 19 | c.salesforceAccountId mustEqual "123" 20 | c.salesforceContactId mustEqual "123" 21 | c.identityId must beSome("ff") 22 | c.regNumber must beNone 23 | } 24 | 25 | "deserialize subscriber with no identity id" in { 26 | val c = Resource.getJson("non-member-contact.json").as[Contact] 27 | c.salesforceAccountId mustEqual "123" 28 | c.salesforceContactId mustEqual "321" 29 | c.identityId must beNone 30 | c.regNumber must beNone 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/stripe/StripeDeserialiserTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.stripe 2 | import com.gu.stripe.Stripe._ 3 | import org.specs2.mutable.Specification 4 | import com.gu.stripe.Stripe.Deserializer._ 5 | import org.joda.time.{Instant, LocalDate} 6 | import utils.Resource 7 | 8 | class StripeDeserialiserTest extends Specification { 9 | 10 | "Stripe deserialiser" should { 11 | "deserialise a charge event okay" in { 12 | val event = Resource.getJson("stripe/event.json").as[Event[StripeObject]] 13 | event.`object`.asInstanceOf[Charge].id mustEqual "chargeid" 14 | event.`object`.asInstanceOf[Charge].metadata("marketing-opt-in") mustEqual "true" 15 | } 16 | "deserialise a failed charge event okay" in { 17 | val event = Resource.getJson("stripe/failedCharge.json").as[Event[StripeObject]] 18 | event.`object`.asInstanceOf[Charge].id mustEqual "ch_18zUytRbpG0cjdye76ytdj" 19 | event.`object`.asInstanceOf[Charge].metadata("marketing-opt-in") mustEqual "false" 20 | event.`object`.asInstanceOf[Charge].balance_transaction must beNone 21 | } 22 | "deserialise a failed charge event okay" in { 23 | val error = Resource.getJson("stripe/error.json").validate[Error].get 24 | error mustEqual Error( 25 | `type` = "card_error", 26 | charge = Some("ch_111111111111111111111111"), 27 | message = Some("Your card was declined."), 28 | code = Some("card_declined"), 29 | decline_code = Some("do_not_honor"), 30 | param = None, 31 | ) 32 | } 33 | "deserialise a Stripe subscription (eg. guardian patrons) okay" in { 34 | val subscription = Resource.getJson("stripe/subscription.json").as[Subscription] 35 | subscription.id mustEqual "sub_1L8mv1JETvkRwpwqhowvEOlL" 36 | subscription.created mustEqual LocalDate.parse("2022-06-09") 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/zuora/ZuoraSoapServiceTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora 2 | 3 | import com.github.nscala_time.time.Imports._ 4 | import com.gu.zuora.soap.models.Queries.{Amendment, InvoiceItem, Subscription} 5 | import org.joda.time.{DateTime, DurationFieldType} 6 | import org.specs2.mutable.Specification 7 | 8 | class ZuoraSoapServiceTest extends Specification { 9 | import ZuoraSoapService._ 10 | 11 | "latestInvoiceItems" should { 12 | def invoiceItem(subscriptionId: String, chargeNumber: String = "1") = { 13 | val start = LocalDate.today() 14 | InvoiceItem("item-id", 1.2f, start, start.withFieldAdded(DurationFieldType.months(), 1), chargeNumber, "item", subscriptionId) 15 | } 16 | 17 | "return an empty list when given an empty list" in { 18 | latestInvoiceItems(Seq()) mustEqual Seq() 19 | } 20 | 21 | "return all those items when given many items with the same subscriptionId" in { 22 | val items = Seq(invoiceItem("a"), invoiceItem("a"), invoiceItem("a")) 23 | latestInvoiceItems(items) mustEqual items 24 | } 25 | 26 | "return items with the same subscriptionId as the newest item when given items with differing subscription ids" in { 27 | "items in date order" in { 28 | val items = Seq(invoiceItem("a", "1"), invoiceItem("b", "2")) 29 | latestInvoiceItems(items) mustEqual Seq(invoiceItem("b", "2")) 30 | } 31 | 32 | "items out of order" in { 33 | val items = Seq(invoiceItem("b", "1"), invoiceItem("a", "2"), invoiceItem("a", "3"), invoiceItem("c", "2")) 34 | latestInvoiceItems(items) mustEqual Seq(invoiceItem("a", "2"), invoiceItem("a", "3")) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/zuora/soap/ClientTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap 2 | 3 | import com.gu.zuora.soap.models.Queries.{ProductRatePlan, RatePlan} 4 | import org.joda.time.LocalDate 5 | import org.specs2.mutable.Specification 6 | 7 | class ClientTest extends Specification { 8 | "childFilter" should { 9 | "return a filter used to query children of an object" in { 10 | val productRatePlan = ProductRatePlan("prpId", "prpName", "productId", LocalDate.now(), LocalDate.now()) 11 | Client.childFilter[RatePlan, ProductRatePlan](productRatePlan) must_=== SimpleFilter("ProductRatePlanId", "prpId") 12 | } 13 | } 14 | 15 | "parentFilter" should { 16 | "return a filter used to query the parent of an object" in { 17 | val ratePlan = RatePlan("rpId", "rpName", "prpId") 18 | Client.parentFilter[RatePlan, ProductRatePlan](ratePlan, _.productRatePlanId) must_=== SimpleFilter("Id", "prpId") 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/zuora/soap/ServiceHelpersTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap 2 | 3 | import com.gu.zuora.soap.DateTimeHelpers.formatDateTime 4 | import com.gu.zuora.soap.readers.Query 5 | import org.joda.time.DateTime 6 | import org.specs2.mutable.Specification 7 | 8 | class DateTimeHelpersTest extends Specification { 9 | "formatDateTime" should { 10 | "never use Z for UTC offset" in { 11 | val d = new DateTime("2012-01-01T10:00:00Z") 12 | formatDateTime(d) mustEqual "2012-01-01T10:00:00.000+00:00" 13 | } 14 | 15 | "convert any timezone to UTC" in { 16 | val d = new DateTime("2012-01-01T10:00:00+08:00") 17 | formatDateTime(d) mustEqual "2012-01-01T02:00:00.000+00:00" 18 | } 19 | } 20 | 21 | "formatQuery" should { 22 | case class ZuoraQueryTest() extends models.Query 23 | 24 | "format a query with one field" in { 25 | val query = Query("TestTable", Seq("Field1")) { result => 26 | ZuoraQueryTest() 27 | } 28 | 29 | val q = query.format("Field1='something'") 30 | q mustEqual "SELECT Field1 FROM TestTable WHERE Field1='something'" 31 | } 32 | 33 | "format a query with multiple fields" in { 34 | val query = Query("TestTable", Seq("Field1", "Field2", "Field3")) { result => 35 | ZuoraQueryTest() 36 | } 37 | 38 | val q = query.format("Field2='blah'") 39 | q mustEqual "SELECT Field1,Field2,Field3 FROM TestTable WHERE Field2='blah'" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/com/gu/zuora/soap/ZuoraDeserializerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.gu.zuora.soap 2 | 3 | import Readers._ 4 | import com.gu.zuora.soap.models.Results.UpdateResult 5 | import com.gu.zuora.soap.models.errors.{ZuoraPartialError, InvalidValue} 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.freespec.AnyFreeSpec 8 | 9 | class ZuoraDeserializerSpec extends AnyFreeSpec with Matchers { 10 | "An Update can be deserialized" - { 11 | "into a valid UpdateResult" in { 12 | val validResponse = 13 | 14 | 15 | 16 | 17 | 2c92c0f94ed8d0d7014ef90424654cfc 18 | true 19 | 20 | 21 | 22 | 23 | 24 | updateResultReader.read(validResponse.toString()) should be(Right(UpdateResult("2c92c0f94ed8d0d7014ef90424654cfc"))) 25 | } 26 | 27 | "into a Failure" in { 28 | val invalidResponse = 29 | 30 | 31 | 32 | 33 | 34 | INVALID_VALUE 35 | The length of field value is too big. 36 | 37 | false 38 | 39 | 40 | 41 | 42 | 43 | val error = ZuoraPartialError("INVALID_VALUE", "The length of field value is too big.", InvalidValue) 44 | 45 | updateResultReader.read(invalidResponse.toString()) should be(Left(error)) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/utils/Resource.scala: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import play.api.libs.json.JsValue 4 | import play.api.libs.json.Json.parse 5 | 6 | import scala.io.Source 7 | import scala.xml.{Elem, XML} 8 | 9 | object Resource { 10 | def get(name: String): String = 11 | Source.fromInputStream(getClass.getClassLoader.getResourceAsStream(name)).mkString 12 | 13 | def getJson(name: String): JsValue = parse(get(name)) 14 | 15 | def getXML(name: String): Elem = XML.loadString(get(name)) 16 | } 17 | -------------------------------------------------------------------------------- /membership-common/src/test/scala/utils/TestLogPrefix.scala: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import com.gu.monitoring.SafeLogger.LogPrefix 4 | 5 | object TestLogPrefix { 6 | 7 | implicit val testLogPrefix: LogPrefix = LogPrefix("TestLogPrefix") 8 | 9 | } 10 | -------------------------------------------------------------------------------- /nginx/members-data-api.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | server_name members-data-api.thegulocal.com; 4 | proxy_http_version 1.1; # this is essential for chunked responses to work 5 | 6 | ssl_certificate members-data-api.thegulocal.com.crt; 7 | ssl_certificate_key members-data-api.thegulocal.com.key; 8 | ssl_session_timeout 5m; 9 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 10 | ssl_ciphers HIGH:!aNULL:!MD5; 11 | ssl_prefer_server_ciphers on; 12 | 13 | location / { 14 | proxy_pass http://localhost:9400/; 15 | proxy_set_header Host $http_host; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /nginx/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #Clean up legacy config 3 | DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 4 | NGINX_HOME=$(nginx -V 2>&1 | grep 'configure arguments:' | sed 's#.*conf-path=\([^ ]*\)/nginx\.conf.*#\1#g') 5 | sudo rm -f $NGINX_HOME/sites-enabled/members-data-api.conf 6 | 7 | # Setup Nginx proxies for local development with valid SSL 8 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 9 | 10 | SITE_CONF=${SCRIPT_DIR}/members-data-api.conf 11 | 12 | dev-nginx setup-cert members-data-api.thegulocal.com 13 | 14 | dev-nginx link-config ${SITE_CONF} 15 | dev-nginx restart-nginx -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.8 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // Comment to get more information during initialization 2 | logLevel := Level.Warn 3 | 4 | addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.0") 5 | 6 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") 7 | 8 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16") 9 | 10 | addSbtPlugin("com.gu" % "sbt-riffraff-artifact" % "1.1.18") 11 | 12 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") 13 | 14 | addSbtPlugin("org.jmotor.sbt" % "sbt-dependency-updates" % "1.2.7") 15 | 16 | 17 | libraryDependencies += "org.vafer" % "jdeb" % "1.10" 18 | -------------------------------------------------------------------------------- /start-api-debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(dirname $0) 4 | sbt -Djava.awt.headless=true -jvm-debug 9997 "project membership-attribute-service" "devrun" 5 | -------------------------------------------------------------------------------- /start-api.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(dirname $0) 4 | sbt -mem 2048 -Djava.awt.headless=true "project membership-attribute-service" "devrun" 5 | --------------------------------------------------------------------------------