├── .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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------