From c20c754e4eb5cd1446010a569c50e00c96d32dd1 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Wed, 25 Sep 2024 18:18:37 -0400 Subject: [PATCH] feat(libs/payments): Implement customerChanged --- .circleci/config.yml | 5 +- apps/payments/next/.env.development | 9 + apps/payments/next/.env.production | 9 + .../cart/src/lib/cart.service.spec.ts | 182 +++++++----------- libs/payments/cart/src/lib/cart.service.ts | 2 +- .../cart/src/lib/checkout.service.spec.ts | 82 +++++--- .../payments/cart/src/lib/checkout.service.ts | 34 +++- .../payments/stripe/src/lib/stripe.service.ts | 25 --- .../payments/ui/src/lib/nestapp/app.module.ts | 11 +- libs/payments/ui/src/lib/nestapp/config.ts | 12 ++ .../client/src/lib/profile.client.spec.ts | 21 +- libs/profile/client/src/lib/profile.client.ts | 3 +- libs/profile/client/src/lib/profile.config.ts | 2 +- .../notifier/src/lib/notifier.service.spec.ts | 1 - .../notifier/src/lib/notifier.service.ts | 3 +- .../notifier/src/lib/notifier.sns.config.ts | 2 +- .../src/lib/notifier.sns.provider.spec.ts | 20 +- .../notifier/src/lib/notifier.sns.provider.ts | 14 +- packages/fxa-admin-server/src/app.module.ts | 4 +- .../fxa-admin-server/src/gql/gql.module.ts | 4 +- packages/fxa-graphql-api/src/app.module.ts | 4 +- .../fxa-graphql-api/src/gql/gql.module.ts | 6 +- 22 files changed, 239 insertions(+), 216 deletions(-) delete mode 100644 libs/payments/stripe/src/lib/stripe.service.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index b0e40815b2..21fe58e417 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -409,7 +409,6 @@ commands: paths: - artifacts/blob-report - rename-reports-chromium: steps: - run: @@ -624,7 +623,7 @@ jobs: build: executor: default-executor - resource_class: large + resource_class: xlarge steps: - git-checkout - restore-workspace @@ -908,7 +907,7 @@ jobs: build-and-deploy-storybooks: executor: default-executor - resource_class: large + resource_class: xlarge steps: - git-checkout - restore-workspace diff --git a/apps/payments/next/.env.development b/apps/payments/next/.env.development index d3f9f3715c..5e77094d9e 100644 --- a/apps/payments/next/.env.development +++ b/apps/payments/next/.env.development @@ -93,6 +93,15 @@ CSP__PAYPAL_API='https://www.sandbox.paypal.com' SENTRY__SERVER_NAME=fxa-payments-next-server SENTRY__AUTH_TOKEN= +# NotifierSns Config +NOTIFIER_SNS_CONFIG__SNS_TOPIC_ARN=arn:aws:sns:us-west-2:123456789012:MyTopic +NOTIFIER_SNS_CONFIG__SNS_TOPIC_ENDPOINT=http://localhost:4566 + +# ProfileClient Config +PROFILE_CLIENT_CONFIG__URL=http://localhost:1111 +PROFILE_CLIENT_CONFIG__SECRET_BEARER_TOKEN='YOU MUST CHANGE ME' +PROFILE_CLIENT_CONFIG__SERVICE_NAME='subhub' + # Other CONTENT_SERVER_URL=http://localhost:3030 SUPPORT_URL=https://support.mozilla.org diff --git a/apps/payments/next/.env.production b/apps/payments/next/.env.production index d70fdd8526..4192a18fbf 100644 --- a/apps/payments/next/.env.production +++ b/apps/payments/next/.env.production @@ -89,6 +89,15 @@ CSP__PAYPAL_API='https://www.paypal.com' SENTRY__SERVER_NAME=fxa-payments-next-server SENTRY__AUTH_TOKEN= +# NotifierSns Config +NOTIFIER_SNS_CONFIG__SNS_TOPIC_ARN=arn:aws:sns:us-west-2:123456789012:MyTopic +NOTIFIER_SNS_CONFIG__SNS_TOPIC_ENDPOINT=http://localhost:4566 + +# ProfileClient Config +PROFILE_CLIENT_CONFIG__URL=https://profile.accounts.firefox.com +PROFILE_CLIENT_CONFIG__SECRET_BEARER_TOKEN='YOU MUST CHANGE ME' +PROFILE_CLIENT_CONFIG__SERVICE_NAME='subhub' + # Other CONTENT_SERVER_URL=https://accounts.firefox.com SUPPORT_URL=https://support.mozilla.org diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index cdc9db0bc7..f46b13a281 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { faker } from '@faker-js/faker'; -import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { @@ -39,6 +38,10 @@ import { MockStripeConfigProvider, AccountCustomerManager, } from '@fxa/payments/stripe'; +import { + MockProfileClientConfigProvider, + ProfileClient, +} from '@fxa/profile/client'; import { MockStrapiClientConfigProvider, ProductConfigurationManager, @@ -57,8 +60,14 @@ import { GeoDBManagerConfig, MockGeoDBNestFactory, } from '@fxa/shared/geodb'; +import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { AccountManager } from '@fxa/shared/account/account'; +import { + MockNotifierSnsConfigProvider, + NotifierService, + NotifierSnsProvider, +} from '@fxa/shared/notifier'; import { CheckoutCustomerDataFactory, FinishErrorCartFactory, @@ -89,6 +98,11 @@ describe('CartService', () => { let invoiceManager: InvoiceManager; let productConfigurationManager: ProductConfigurationManager; + const mockLogger = { + error: jest.fn(), + debug: jest.fn(), + }; + beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [ @@ -97,7 +111,6 @@ describe('CartService', () => { CartManager, CartService, CheckoutService, - ConfigService, CustomerManager, EligibilityManager, EligibilityService, @@ -107,10 +120,14 @@ describe('CartService', () => { MockAccountDatabaseNestFactory, MockFirestoreProvider, MockGeoDBNestFactory, + MockNotifierSnsConfigProvider, MockPaypalClientConfigProvider, + MockProfileClientConfigProvider, MockStatsDProvider, MockStrapiClientConfigProvider, MockStripeConfigProvider, + NotifierService, + NotifierSnsProvider, PaymentMethodManager, PaypalBillingAgreementManager, PayPalClient, @@ -118,12 +135,17 @@ describe('CartService', () => { PriceManager, ProductConfigurationManager, ProductManager, + ProfileClient, PromotionCodeManager, StrapiClient, StripeClient, SubscriptionManager, CurrencyManager, MockCurrencyConfigProvider, + { + provide: LOGGER_PROVIDER, + useValue: mockLogger, + }, ], }).compile(); @@ -142,43 +164,49 @@ describe('CartService', () => { }); describe('setupCart', () => { - it('calls createCart with expected parameters', async () => { - const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); - const mockAccountCustomer = ResultAccountCustomerFactory({ - stripeCustomerId: mockCustomer.id, - }); - const mockResultCart = ResultCartFactory(); - const args = { - interval: SubplatInterval.Monthly, - offeringConfigId: faker.string.uuid(), - experiment: faker.string.uuid(), - promoCode: faker.word.noun(), - uid: faker.string.hexadecimal({ - length: 32, - prefix: '', - casing: 'lower', - }), - ip: faker.internet.ipv4(), - }; - const taxAddress = TaxAddressFactory(); - const mockPrice = StripePriceFactory(); - const mockInvoicePreview = InvoicePreviewFactory(); - const mockResolvedCurrency = faker.finance.currencyCode(); + const args = { + interval: SubplatInterval.Monthly, + offeringConfigId: faker.string.uuid(), + experiment: faker.string.uuid(), + promoCode: faker.word.noun(), + uid: faker.string.hexadecimal({ + length: 32, + prefix: '', + casing: 'lower', + }), + ip: faker.internet.ipv4(), + }; - jest - .spyOn(eligibilityService, 'checkEligibility') - .mockResolvedValue(EligibilityStatus.CREATE); - jest.spyOn(geodbManager, 'getTaxAddress').mockReturnValue(taxAddress); + const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); + const mockAccountCustomer = ResultAccountCustomerFactory({ + stripeCustomerId: mockCustomer.id, + }); + const mockInvoicePreview = InvoicePreviewFactory(); + const mockResultCart = ResultCartFactory(); + const mockPrice = StripePriceFactory(); + const taxAddress = TaxAddressFactory(); + + beforeEach(async () => { jest .spyOn(accountCustomerManager, 'getAccountCustomerByUid') .mockResolvedValue(mockAccountCustomer); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); + jest.spyOn(geodbManager, 'getTaxAddress').mockReturnValue(taxAddress); jest .spyOn(productConfigurationManager, 'retrieveStripePrice') .mockResolvedValue(mockPrice); - jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); jest .spyOn(invoiceManager, 'preview') .mockResolvedValue(mockInvoicePreview); + jest + .spyOn(eligibilityService, 'checkEligibility') + .mockResolvedValue(EligibilityStatus.CREATE); + }); + + it('calls createCart with expected parameters', async () => { + const mockResultCart = ResultCartFactory(); + const mockResolvedCurrency = faker.finance.currencyCode(); + jest .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') .mockResolvedValue(undefined); @@ -206,44 +234,10 @@ describe('CartService', () => { it('throws an error when couponCode is invalid', async () => { const mockAccount = AccountFactory(); - const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); - const mockAccountCustomer = ResultAccountCustomerFactory({ - stripeCustomerId: mockCustomer.id, - }); - const mockResultCart = ResultCartFactory(); - const args = { - interval: SubplatInterval.Monthly, - offeringConfigId: faker.string.uuid(), - experiment: faker.string.uuid(), - promoCode: faker.word.noun(), - uid: faker.string.hexadecimal({ - length: 32, - prefix: '', - casing: 'lower', - }), - ip: faker.internet.ipv4(), - }; - const taxAddress = TaxAddressFactory(); - const mockPrice = StripePriceFactory(); - const mockInvoicePreview = InvoicePreviewFactory(); jest .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') .mockRejectedValue(undefined); - jest - .spyOn(eligibilityService, 'checkEligibility') - .mockResolvedValue(EligibilityStatus.CREATE); - jest.spyOn(geodbManager, 'getTaxAddress').mockReturnValue(taxAddress); - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(productConfigurationManager, 'retrieveStripePrice') - .mockResolvedValue(mockPrice); - jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); - jest - .spyOn(invoiceManager, 'preview') - .mockResolvedValue(mockInvoicePreview); jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart); jest .spyOn(accountManager, 'getAccounts') @@ -258,44 +252,7 @@ describe('CartService', () => { it('throws an error when country to currency result is invalid', async () => { const mockAccount = AccountFactory(); - const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); - const mockAccountCustomer = ResultAccountCustomerFactory({ - stripeCustomerId: mockCustomer.id, - }); - const mockResultCart = ResultCartFactory(); - const args = { - interval: SubplatInterval.Monthly, - offeringConfigId: faker.string.uuid(), - experiment: faker.string.uuid(), - promoCode: faker.word.noun(), - uid: faker.string.hexadecimal({ - length: 32, - prefix: '', - casing: 'lower', - }), - ip: faker.internet.ipv4(), - }; - const taxAddress = TaxAddressFactory(); - const mockPrice = StripePriceFactory(); - const mockInvoicePreview = InvoicePreviewFactory(); - jest - .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') - .mockRejectedValue(undefined); - jest - .spyOn(eligibilityService, 'checkEligibility') - .mockResolvedValue(EligibilityStatus.CREATE); - jest.spyOn(geodbManager, 'getTaxAddress').mockReturnValue(taxAddress); - jest - .spyOn(accountCustomerManager, 'getAccountCustomerByUid') - .mockResolvedValue(mockAccountCustomer); - jest - .spyOn(productConfigurationManager, 'retrieveStripePrice') - .mockResolvedValue(mockPrice); - jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer); - jest - .spyOn(invoiceManager, 'preview') - .mockResolvedValue(mockInvoicePreview); jest .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') .mockResolvedValue(undefined); @@ -316,21 +273,24 @@ describe('CartService', () => { }); describe('restartCart', () => { - it('fetches old cart and creates new cart with same details', async () => { - const mockOldCart = ResultCartFactory({ - couponCode: faker.word.noun(), - }); - const mockNewCart = ResultCartFactory(); - const mockPrice = StripePriceFactory(); + const mockOldCart = ResultCartFactory({ + couponCode: faker.word.noun(), + }); + const mockNewCart = ResultCartFactory(); + const mockPrice = StripePriceFactory(); + beforeEach(async () => { jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockOldCart); - jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockNewCart); jest .spyOn(productConfigurationManager, 'retrieveStripePrice') .mockResolvedValue(mockPrice); + }); + + it('fetches old cart and creates new cart with same details', async () => { jest .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') .mockResolvedValue(undefined); + jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockNewCart); const result = await cartService.restartCart(mockOldCart.id); @@ -351,16 +311,10 @@ describe('CartService', () => { }); it('throws an error when couponCode is invalid', async () => { - const mockOldCart = ResultCartFactory({ - couponCode: faker.word.noun(), - }); - const mockNewCart = ResultCartFactory(); - - jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockOldCart); - jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockNewCart); jest .spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice') .mockRejectedValue(undefined); + jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockNewCart); await expect(() => cartService.restartCart(mockOldCart.id) diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index f3c686b1e4..0b765d6d83 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -161,7 +161,7 @@ export class CartService { oldCart.interval as SubplatInterval ); - this.promotionCodeManager.assertValidPromotionCodeNameForPrice( + await this.promotionCodeManager.assertValidPromotionCodeNameForPrice( oldCart.couponCode, price ); diff --git a/libs/payments/cart/src/lib/checkout.service.spec.ts b/libs/payments/cart/src/lib/checkout.service.spec.ts index 41c79e6218..2b68eb6205 100644 --- a/libs/payments/cart/src/lib/checkout.service.spec.ts +++ b/libs/payments/cart/src/lib/checkout.service.spec.ts @@ -51,6 +51,10 @@ import { MockStripeConfigProvider, AccountCustomerManager, } from '@fxa/payments/stripe'; +import { + MockProfileClientConfigProvider, + ProfileClient, +} from '@fxa/profile/client'; import { AccountManager } from '@fxa/shared/account/account'; import { MockStrapiClientConfigProvider, @@ -62,7 +66,13 @@ import { CartEligibilityStatus, MockAccountDatabaseNestFactory, } from '@fxa/shared/db/mysql/account'; +import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { MockStatsDProvider, StatsDService } from '@fxa/shared/metrics/statsd'; +import { + MockNotifierSnsConfigProvider, + NotifierService, + NotifierSnsProvider, +} from '@fxa/shared/notifier'; import { CartEligibilityMismatchError, CartTotalMismatchError, @@ -80,14 +90,21 @@ describe('CheckoutService', () => { let customerManager: CustomerManager; let eligibilityService: EligibilityService; let invoiceManager: InvoiceManager; - let mockStatsd: StatsD; let paymentMethodManager: PaymentMethodManager; let paypalBillingAgreementManager: PaypalBillingAgreementManager; let paypalCustomerManager: PaypalCustomerManager; + let privateMethod: any; let productConfigurationManager: ProductConfigurationManager; + let profileClient: ProfileClient; let promotionCodeManager: PromotionCodeManager; + let statsd: StatsD; let subscriptionManager: SubscriptionManager; + const mockLogger = { + error: jest.fn(), + debug: jest.fn(), + }; + beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [ @@ -101,9 +118,13 @@ describe('CheckoutService', () => { InvoiceManager, MockAccountDatabaseNestFactory, MockFirestoreProvider, + MockNotifierSnsConfigProvider, + MockProfileClientConfigProvider, MockStatsDProvider, MockStrapiClientConfigProvider, MockStripeConfigProvider, + NotifierService, + NotifierSnsProvider, PaymentMethodManager, PaypalBillingAgreementManager, PayPalClient, @@ -112,11 +133,16 @@ describe('CheckoutService', () => { PriceManager, ProductConfigurationManager, ProductManager, + ProfileClient, PromotionCodeManager, StrapiClient, StripeClient, StripeConfig, SubscriptionManager, + { + provide: LOGGER_PROVIDER, + useValue: mockLogger, + }, ], }).compile(); @@ -127,14 +153,18 @@ describe('CheckoutService', () => { customerManager = moduleRef.get(CustomerManager); eligibilityService = moduleRef.get(EligibilityService); invoiceManager = moduleRef.get(InvoiceManager); - mockStatsd = moduleRef.get(StatsDService); paymentMethodManager = moduleRef.get(PaymentMethodManager); paypalBillingAgreementManager = moduleRef.get( PaypalBillingAgreementManager ); paypalCustomerManager = moduleRef.get(PaypalCustomerManager); + privateMethod = jest + .spyOn(checkoutService as any, 'customerChanged') + .mockResolvedValue({}); + profileClient = moduleRef.get(ProfileClient); productConfigurationManager = moduleRef.get(ProductConfigurationManager); promotionCodeManager = moduleRef.get(PromotionCodeManager); + statsd = moduleRef.get(StatsDService); subscriptionManager = moduleRef.get(SubscriptionManager); }); @@ -415,29 +445,31 @@ describe('CheckoutService', () => { }); describe('postPaySteps', () => { + const mockUid = faker.string.uuid(); + const mockSubscription = StripeResponseFactory(StripeSubscriptionFactory()); + + beforeEach(async () => { + jest.spyOn(customerManager, 'setTaxId').mockResolvedValue(); + jest.spyOn(profileClient, 'deleteCache').mockResolvedValue('test'); + }); + it('success', async () => { const mockCart = ResultCartFactory(); - const mockSubscription = StripeResponseFactory( - StripeSubscriptionFactory() - ); - jest.spyOn(customerManager, 'setTaxId').mockResolvedValue(); - - await checkoutService.postPaySteps(mockCart, mockSubscription); + await checkoutService.postPaySteps(mockCart, mockSubscription, mockUid); expect(customerManager.setTaxId).toHaveBeenCalledWith( mockSubscription.customer, mockSubscription.currency ); + + expect(privateMethod).toHaveBeenCalled(); }); it('success - adds coupon code to subscription metadata if it exists', async () => { const mockCart = ResultCartFactory({ couponCode: faker.string.uuid(), }); - const mockSubscription = StripeResponseFactory( - StripeSubscriptionFactory() - ); const mockUpdatedSubscription = StripeResponseFactory( StripeSubscriptionFactory({ metadata: { @@ -447,17 +479,17 @@ describe('CheckoutService', () => { }) ); - jest.spyOn(customerManager, 'setTaxId').mockResolvedValue(); jest .spyOn(subscriptionManager, 'update') .mockResolvedValue(mockUpdatedSubscription); - await checkoutService.postPaySteps(mockCart, mockSubscription); + await checkoutService.postPaySteps(mockCart, mockSubscription, mockUid); expect(customerManager.setTaxId).toHaveBeenCalledWith( mockSubscription.customer, mockSubscription.currency ); + expect(privateMethod).toHaveBeenCalled(); expect(subscriptionManager.update).toHaveBeenCalledWith( mockSubscription.id, { @@ -497,6 +529,7 @@ describe('CheckoutService', () => { jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue({ uid: mockCart.uid as string, customer: mockCustomer, + email: faker.internet.email(), enableAutomaticTax: true, promotionCode: mockPromotionCode, price: mockPrice, @@ -505,7 +538,7 @@ describe('CheckoutService', () => { .spyOn(paymentMethodManager, 'attach') .mockResolvedValue(mockPaymentMethod); jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer); - jest.spyOn(mockStatsd, 'increment'); + jest.spyOn(statsd, 'increment'); jest .spyOn(subscriptionManager, 'create') .mockResolvedValue(mockSubscription); @@ -552,12 +585,9 @@ describe('CheckoutService', () => { }); it('increments the statsd counter', async () => { - expect(mockStatsd.increment).toHaveBeenCalledWith( - 'stripe_subscription', - { - payment_provider: 'stripe', - } - ); + expect(statsd.increment).toHaveBeenCalledWith('stripe_subscription', { + payment_provider: 'stripe', + }); }); it('creates the subscription', async () => { @@ -618,6 +648,7 @@ describe('CheckoutService', () => { jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue({ uid: mockCart.uid as string, customer: mockCustomer, + email: faker.internet.email(), enableAutomaticTax: true, promotionCode: mockPromotionCode, price: mockPrice, @@ -628,7 +659,7 @@ describe('CheckoutService', () => { jest .spyOn(paypalBillingAgreementManager, 'retrieveOrCreateId') .mockResolvedValue(mockBillingAgreementId); - jest.spyOn(mockStatsd, 'increment'); + jest.spyOn(statsd, 'increment'); jest .spyOn(subscriptionManager, 'create') .mockResolvedValue(mockSubscription); @@ -668,12 +699,9 @@ describe('CheckoutService', () => { }); it('increments the statsd counter', async () => { - expect(mockStatsd.increment).toHaveBeenCalledWith( - 'stripe_subscription', - { - payment_provider: 'paypal', - } - ); + expect(statsd.increment).toHaveBeenCalledWith('stripe_subscription', { + payment_provider: 'paypal', + }); }); it('creates the subscription', async () => { diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index a00780de50..9cb443a8c1 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -27,9 +27,11 @@ import { StripeCustomer, StripePromotionCode, } from '@fxa/payments/stripe'; +import { ProfileClient } from '@fxa/profile/client'; import { AccountManager } from '@fxa/shared/account/account'; import { ProductConfigurationManager } from '@fxa/shared/cms'; import { StatsDService } from '@fxa/shared/metrics/statsd'; +import { NotifierService } from '@fxa/shared/notifier'; import { CartTotalMismatchError, CartEligibilityMismatchError, @@ -51,15 +53,32 @@ export class CheckoutService { private customerManager: CustomerManager, private eligibilityService: EligibilityService, private invoiceManager: InvoiceManager, + private notifierService: NotifierService, private paymentMethodManager: PaymentMethodManager, private paypalBillingAgreementManager: PaypalBillingAgreementManager, private paypalCustomerManager: PaypalCustomerManager, private productConfigurationManager: ProductConfigurationManager, + private profileClient: ProfileClient, private promotionCodeManager: PromotionCodeManager, private subscriptionManager: SubscriptionManager, @Inject(StatsDService) private statsd: StatsD ) {} + /** + * Reload the customer data to reflect a change. + */ + private async customerChanged(uid: string) { + await this.profileClient.deleteCache(uid); + + this.notifierService.send({ + event: 'profileDataChange', + data: { + ts: Date.now() / 1000, + uid, + }, + }); + } + async prePaySteps(cart: ResultCart, customerData: CheckoutCustomerData) { const taxAddress = cart.taxAddress as any as TaxAddress; @@ -182,18 +201,23 @@ export class CheckoutService { return { uid: uid, customer, + email: cart.email, enableAutomaticTax, promotionCode, price, }; } - async postPaySteps(cart: ResultCart, subscription: StripeSubscription) { + async postPaySteps( + cart: ResultCart, + subscription: StripeSubscription, + uid: string + ) { const { customer: customerId, currency } = subscription; await this.customerManager.setTaxId(customerId, currency); - // TODO: call customerChanged + await this.customerChanged(uid); if (cart.couponCode) { const subscriptionMetadata = { @@ -214,7 +238,7 @@ export class CheckoutService { paymentMethodId: string, customerData: CheckoutCustomerData ) { - const { customer, enableAutomaticTax, promotionCode, price } = + const { uid, customer, enableAutomaticTax, promotionCode, price } = await this.prePaySteps(cart, customerData); await this.paymentMethodManager.attach(paymentMethodId, { @@ -281,7 +305,7 @@ export class CheckoutService { ); } - await this.postPaySteps(cart, subscription); + await this.postPaySteps(cart, subscription, uid); } async payWithPaypal( @@ -360,6 +384,6 @@ export class CheckoutService { await this.paypalBillingAgreementManager.cancel(billingAgreementId); } - await this.postPaySteps(cart, subscription); + await this.postPaySteps(cart, subscription, uid); } } diff --git a/libs/payments/stripe/src/lib/stripe.service.ts b/libs/payments/stripe/src/lib/stripe.service.ts deleted file mode 100644 index 0fd868e9d9..0000000000 --- a/libs/payments/stripe/src/lib/stripe.service.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class StripeService { - constructor() {} - - // TODO: this method should be moved down to the manager layer - async customerChanged(uid: string, email: string) { - // @todo - Unblocked by FXA-9274 - //const devices = await this.db.devices(uid); - // @todo - Unblocked by FXA-9275 - //await this.profile.deleteCache(uid); - // @todo - Unblocked by FXA-9276 - //await this.push.notifyProfileUpdated(uid, devices); - // @todo - Unblocked by FXA-9277 - //this.log.notifyAttachedServices('profileDataChange', {} as any, { - // uid, - // email, - //}); - } -} diff --git a/libs/payments/ui/src/lib/nestapp/app.module.ts b/libs/payments/ui/src/lib/nestapp/app.module.ts index 88c2a91f2f..8ddf05f245 100644 --- a/libs/payments/ui/src/lib/nestapp/app.module.ts +++ b/libs/payments/ui/src/lib/nestapp/app.module.ts @@ -25,22 +25,25 @@ import { PromotionCodeManager, SubscriptionManager, } from '@fxa/payments/customer'; +import { PaymentsGleanManager } from '@fxa/payments/metrics'; +import { PaymentsGleanFactory } from '@fxa/payments/metrics/provider'; import { AccountCustomerManager, StripeClient } from '@fxa/payments/stripe'; +import { ProfileClient } from '@fxa/profile/client'; import { AccountManager } from '@fxa/shared/account/account'; import { ProductConfigurationManager, StrapiClient } from '@fxa/shared/cms'; import { FirestoreProvider } from '@fxa/shared/db/firestore'; import { AccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { GeoDBManager, GeoDBNestFactory } from '@fxa/shared/geodb'; import { LocalizerRscFactoryProvider } from '@fxa/shared/l10n/server'; +import { logger, LOGGER_PROVIDER } from '@fxa/shared/log'; import { StatsDProvider } from '@fxa/shared/metrics/statsd'; -import { PaymentsGleanManager } from '@fxa/payments/metrics'; +import { NotifierService, NotifierSnsProvider } from '@fxa/shared/notifier'; import { RootConfig } from './config'; import { NextJSActionsService } from './nextjs-actions.service'; import { validate } from '../config.utils'; import { CurrencyManager } from '@fxa/payments/currency'; import { PaymentsEmitterService } from '../emitter/emitter.service'; -import { PaymentsGleanFactory } from '@fxa/payments/metrics/provider'; @Module({ imports: [ @@ -80,6 +83,8 @@ import { PaymentsGleanFactory } from '@fxa/payments/metrics/provider'; InvoiceManager, LocalizerRscFactoryProvider, NextJSActionsService, + NotifierService, + NotifierSnsProvider, PaymentMethodManager, PaypalBillingAgreementManager, PayPalClient, @@ -87,6 +92,7 @@ import { PaymentsGleanFactory } from '@fxa/payments/metrics/provider'; PriceManager, ProductConfigurationManager, ProductManager, + ProfileClient, PromotionCodeManager, StatsDProvider, StrapiClient, @@ -95,6 +101,7 @@ import { PaymentsGleanFactory } from '@fxa/payments/metrics/provider'; PaymentsGleanFactory, PaymentsGleanManager, PaymentsEmitterService, + { provide: LOGGER_PROVIDER, useValue: logger }, ], }) export class AppModule {} diff --git a/libs/payments/ui/src/lib/nestapp/config.ts b/libs/payments/ui/src/lib/nestapp/config.ts index 980c5c89e7..6afeefb13a 100644 --- a/libs/payments/ui/src/lib/nestapp/config.ts +++ b/libs/payments/ui/src/lib/nestapp/config.ts @@ -14,6 +14,8 @@ import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.conf import { StatsDConfig } from 'libs/shared/metrics/statsd/src/lib/statsd.config'; import { PaymentsGleanConfig } from '@fxa/payments/metrics'; import { CurrencyConfig } from 'libs/payments/currency/src/lib/currency.config'; +import { ProfileClientConfig } from '@fxa/profile/client'; +import { NotifierSnsConfig } from '@fxa/shared/notifier'; export class RootConfig { @Type(() => MySQLConfig) @@ -64,4 +66,14 @@ export class RootConfig { @ValidateNested() @IsDefined() public readonly gleanConfig!: Partial; + + @Type(() => ProfileClientConfig) + @ValidateNested() + @IsDefined() + public readonly profileClientConfig!: Partial; + + @Type(() => NotifierSnsConfig) + @ValidateNested() + @IsDefined() + public readonly notifierSnsConfig!: Partial; } diff --git a/libs/profile/client/src/lib/profile.client.spec.ts b/libs/profile/client/src/lib/profile.client.spec.ts index 30c0e36bf6..84e99bba50 100644 --- a/libs/profile/client/src/lib/profile.client.spec.ts +++ b/libs/profile/client/src/lib/profile.client.spec.ts @@ -1,8 +1,8 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Test } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { ProfileClient } from './profile.client'; import { MockProfileClientConfigProvider } from './profile.config'; @@ -47,11 +47,9 @@ describe('ProfileClient', () => { jest .spyOn(profileClient as any, 'makeRequest') .mockRejectedValue(new Error('fail')); - try { - await profileClient.deleteCache('test'); - } catch (e) { - expect(mockLogger.error).toHaveBeenCalled(); - } + + await expect(profileClient.deleteCache('test')).rejects.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); }); }); @@ -65,11 +63,12 @@ describe('ProfileClient', () => { jest .spyOn(profileClient as any, 'makeRequest') .mockRejectedValue(new Error('fail')); - try { - await profileClient.updateDisplayName('test', 'test'); - } catch (e) { - expect(mockLogger.error).toHaveBeenCalled(); - } + + await expect( + profileClient.updateDisplayName('test', 'test') + ).rejects.toThrow(); + + expect(mockLogger.error).toHaveBeenCalled(); }); }); }); diff --git a/libs/profile/client/src/lib/profile.client.ts b/libs/profile/client/src/lib/profile.client.ts index b93fda2d38..68cc8ceeb0 100644 --- a/libs/profile/client/src/lib/profile.client.ts +++ b/libs/profile/client/src/lib/profile.client.ts @@ -3,7 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { LOGGER_PROVIDER } from '@fxa/shared/log'; -import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import type { LoggerService } from '@nestjs/common'; import Agent from 'agentkeepalive'; import axios, { AxiosInstance } from 'axios'; import { ProfileClientConfig } from './profile.config'; diff --git a/libs/profile/client/src/lib/profile.config.ts b/libs/profile/client/src/lib/profile.config.ts index 5a93985afb..1570242f4c 100644 --- a/libs/profile/client/src/lib/profile.config.ts +++ b/libs/profile/client/src/lib/profile.config.ts @@ -7,7 +7,7 @@ import { faker } from '@faker-js/faker'; import { Provider } from '@nestjs/common'; export class ProfileClientConfig { - @IsUrl() + @IsUrl({ require_tld: false }) public readonly url!: string; @IsString() diff --git a/libs/shared/notifier/src/lib/notifier.service.spec.ts b/libs/shared/notifier/src/lib/notifier.service.spec.ts index 4d26d16405..cd1d01e894 100644 --- a/libs/shared/notifier/src/lib/notifier.service.spec.ts +++ b/libs/shared/notifier/src/lib/notifier.service.spec.ts @@ -5,7 +5,6 @@ import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { Test, TestingModule } from '@nestjs/testing'; import { StatsD } from 'hot-shots'; - import { MockStatsDProvider, StatsDService } from '@fxa/shared/metrics/statsd'; import { NotifierService } from './notifier.service'; import { diff --git a/libs/shared/notifier/src/lib/notifier.service.ts b/libs/shared/notifier/src/lib/notifier.service.ts index f47a11c63d..cdf873f53c 100644 --- a/libs/shared/notifier/src/lib/notifier.service.ts +++ b/libs/shared/notifier/src/lib/notifier.service.ts @@ -4,7 +4,8 @@ import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { StatsDService } from '@fxa/shared/metrics/statsd'; -import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import type { LoggerService } from '@nestjs/common'; import { AWSError, SNS } from 'aws-sdk'; import { StatsD } from 'hot-shots'; diff --git a/libs/shared/notifier/src/lib/notifier.sns.config.ts b/libs/shared/notifier/src/lib/notifier.sns.config.ts index a5b8aabf00..e31bfcd58b 100644 --- a/libs/shared/notifier/src/lib/notifier.sns.config.ts +++ b/libs/shared/notifier/src/lib/notifier.sns.config.ts @@ -9,7 +9,7 @@ export class NotifierSnsConfig { @IsString() public readonly snsTopicArn!: string; - @IsUrl() + @IsUrl({ require_tld: false }) public readonly snsTopicEndpoint!: string; } diff --git a/libs/shared/notifier/src/lib/notifier.sns.provider.spec.ts b/libs/shared/notifier/src/lib/notifier.sns.provider.spec.ts index 0cc730bbb6..6de81b1c38 100644 --- a/libs/shared/notifier/src/lib/notifier.sns.provider.spec.ts +++ b/libs/shared/notifier/src/lib/notifier.sns.provider.spec.ts @@ -5,8 +5,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { SNS } from 'aws-sdk'; +import { MockNotifierSnsConfig } from './notifier.sns.config'; import { - NotifierSnsFactory, + LegacyNotifierSnsFactory, NotifierSnsService, } from './notifier.sns.provider'; @@ -22,14 +23,10 @@ jest.mock('aws-sdk', () => { describe('NotifierSnsFactory', () => { let sns: SNS; - const mockConfig = { - snsTopicArn: 'arn:aws:sns:us-east-1:100010001000:fxa-account-change-dev', - snsTopicEndpoint: 'http://localhost:4100/', - }; const mockConfigService = { get: jest.fn().mockImplementation((key: string) => { if (key === 'notifier.sns') { - return mockConfig; + return MockNotifierSnsConfig; } return null; }), @@ -39,11 +36,8 @@ describe('NotifierSnsFactory', () => { jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ - NotifierSnsFactory, - { - provide: ConfigService, - useValue: mockConfigService, - }, + LegacyNotifierSnsFactory, + { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); @@ -53,8 +47,8 @@ describe('NotifierSnsFactory', () => { it('should provide statsd', async () => { expect(sns).toBeDefined(); expect(mockSNS).toBeCalledWith({ - endpoint: mockConfig.snsTopicEndpoint, - region: 'us-east-1', + endpoint: MockNotifierSnsConfig.snsTopicEndpoint, + region: 'us-west-2', }); }); }); diff --git a/libs/shared/notifier/src/lib/notifier.sns.provider.ts b/libs/shared/notifier/src/lib/notifier.sns.provider.ts index e5ad29dee6..38e5c9fdfe 100644 --- a/libs/shared/notifier/src/lib/notifier.sns.provider.ts +++ b/libs/shared/notifier/src/lib/notifier.sns.provider.ts @@ -25,7 +25,19 @@ export function setupSns(config: NotifierSnsConfig) { * Factory for providing access to SNS */ export const NotifierSnsService = Symbol('NOTIFIER_SNS'); -export const NotifierSnsFactory: Provider = { +export const NotifierSnsProvider: Provider = { + provide: NotifierSnsService, + useFactory: (config: NotifierSnsConfig) => { + if (config == null) { + throw new Error('Could not locate notifier.sns config'); + } + const sns = setupSns(config); + return sns; + }, + inject: [NotifierSnsConfig], +}; + +export const LegacyNotifierSnsFactory: Provider = { provide: NotifierSnsService, useFactory: (configService: ConfigService) => { const config = configService.get('notifier.sns'); diff --git a/packages/fxa-admin-server/src/app.module.ts b/packages/fxa-admin-server/src/app.module.ts index 187750eb09..664237d12a 100644 --- a/packages/fxa-admin-server/src/app.module.ts +++ b/packages/fxa-admin-server/src/app.module.ts @@ -17,7 +17,7 @@ import { LegacyStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MozLoggerService } from '@fxa/shared/mozlog'; import { LegacyNotifierServiceProvider, - NotifierSnsFactory, + LegacyNotifierSnsFactory, } from '@fxa/shared/notifier'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { Module } from '@nestjs/common'; @@ -89,8 +89,8 @@ const version = getVersionInfo(__dirname); provide: LOGGER_PROVIDER, useClass: MozLoggerService, }, - NotifierSnsFactory, LegacyNotifierServiceProvider, + LegacyNotifierSnsFactory, LegacyStatsDProvider, ], }) diff --git a/packages/fxa-admin-server/src/gql/gql.module.ts b/packages/fxa-admin-server/src/gql/gql.module.ts index 328652074d..1bc09c8b23 100644 --- a/packages/fxa-admin-server/src/gql/gql.module.ts +++ b/packages/fxa-admin-server/src/gql/gql.module.ts @@ -7,7 +7,7 @@ import { LegacyStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MozLoggerService } from '@fxa/shared/mozlog'; import { LegacyNotifierServiceProvider, - NotifierSnsFactory, + LegacyNotifierSnsFactory, } from '@fxa/shared/notifier'; import { Module } from '@nestjs/common'; import { BackendModule } from '../backend/backend.module'; @@ -31,8 +31,8 @@ import { RelyingPartyResolver } from './relying-party/relying-party.resolver'; AccountResolver, EmailBounceResolver, LegacyStatsDProvider, - NotifierSnsFactory, LegacyNotifierServiceProvider, + LegacyNotifierSnsFactory, { provide: LOGGER_PROVIDER, useClass: MozLoggerService, diff --git a/packages/fxa-graphql-api/src/app.module.ts b/packages/fxa-graphql-api/src/app.module.ts index ba328db575..2ab3018098 100644 --- a/packages/fxa-graphql-api/src/app.module.ts +++ b/packages/fxa-graphql-api/src/app.module.ts @@ -7,7 +7,7 @@ import { LegacyStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MozLoggerService } from '@fxa/shared/mozlog'; import { LegacyNotifierServiceProvider, - NotifierSnsFactory, + LegacyNotifierSnsFactory, } from '@fxa/shared/notifier'; import { HealthModule } from 'fxa-shared/nestjs/health/health.module'; @@ -56,8 +56,8 @@ const version = getVersionInfo(__dirname); providers: [ LegacyStatsDProvider, MozLoggerService, - NotifierSnsFactory, LegacyNotifierServiceProvider, + LegacyNotifierSnsFactory, ComplexityPlugin, { provide: LOGGER_PROVIDER, diff --git a/packages/fxa-graphql-api/src/gql/gql.module.ts b/packages/fxa-graphql-api/src/gql/gql.module.ts index e625b0e9fa..015b0d912b 100644 --- a/packages/fxa-graphql-api/src/gql/gql.module.ts +++ b/packages/fxa-graphql-api/src/gql/gql.module.ts @@ -13,7 +13,7 @@ import { LegacyStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MozLoggerService } from '@fxa/shared/mozlog'; import { LegacyNotifierServiceProvider, - NotifierSnsFactory, + LegacyNotifierSnsFactory, } from '@fxa/shared/notifier'; import { HttpException, @@ -59,11 +59,11 @@ export const GraphQLConfigFactory = async ( AccountResolver, ClientInfoResolver, CustomsService, + LegacyNotifierServiceProvider, + LegacyNotifierSnsFactory, LegacyStatsDProvider, LegalResolver, - LegacyNotifierServiceProvider, MozLoggerService, - NotifierSnsFactory, SentryPlugin, SessionResolver, SubscriptionResolver,