From 4e46a8a01bf4d85cd54eb27df05898cf4a835626 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Thu, 25 Apr 2024 12:18:47 -0400 Subject: [PATCH] feat(payments-stripe): Add applyPromoCodeToSubscription to StripeService --- libs/payments/stripe/src/index.ts | 5 +- .../src/lib/factories/coupon.factory.ts | 27 ++ .../src/lib/factories/discount.factory.ts | 39 +++ .../stripe/src/lib/stripe.client.spec.ts | 71 ++++ libs/payments/stripe/src/lib/stripe.client.ts | 36 +- libs/payments/stripe/src/lib/stripe.error.ts | 44 +++ .../stripe/src/lib/stripe.manager.spec.ts | 64 +++- .../payments/stripe/src/lib/stripe.manager.ts | 17 +- .../stripe/src/lib/stripe.service.spec.ts | 310 +++++++++++++++++- .../payments/stripe/src/lib/stripe.service.ts | 52 ++- libs/payments/stripe/src/lib/stripe.types.ts | 13 + .../stripe/src/lib/stripe.util.spec.ts | 205 +++++++++++- libs/payments/stripe/src/lib/stripe.util.ts | 57 ++++ 13 files changed, 917 insertions(+), 23 deletions(-) create mode 100644 libs/payments/stripe/src/lib/factories/coupon.factory.ts create mode 100644 libs/payments/stripe/src/lib/factories/discount.factory.ts create mode 100644 libs/payments/stripe/src/lib/stripe.types.ts diff --git a/libs/payments/stripe/src/index.ts b/libs/payments/stripe/src/index.ts index 2371f4c9114..082f91b578b 100644 --- a/libs/payments/stripe/src/index.ts +++ b/libs/payments/stripe/src/index.ts @@ -2,12 +2,14 @@ * 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/. */ +export * from './lib/accountCustomer/accountCustomer.factories'; export * from './lib/accountCustomer/accountCustomer.manager'; export { StripeApiListFactory, StripeResponseFactory, } from './lib/factories/api-list.factory'; export { StripeCardFactory } from './lib/factories/card.factory'; +export { StripeCouponFactory } from './lib/factories/coupon.factory'; export { StripeCustomerFactory } from './lib/factories/customer.factory'; export { StripeInvoiceLineItemFactory } from './lib/factories/invoice-line-item.factory'; export { StripeInvoiceFactory } from './lib/factories/invoice.factory'; @@ -27,6 +29,5 @@ export * from './lib/stripe.config'; export * from './lib/stripe.constants'; export * from './lib/stripe.error'; export * from './lib/stripe.manager'; +export * from './lib/stripe.service'; export * from './lib/stripe.util'; -export * from './lib/accountCustomer/accountCustomer.manager'; -export * from './lib/accountCustomer/accountCustomer.factories'; diff --git a/libs/payments/stripe/src/lib/factories/coupon.factory.ts b/libs/payments/stripe/src/lib/factories/coupon.factory.ts new file mode 100644 index 00000000000..c57c3a7c756 --- /dev/null +++ b/libs/payments/stripe/src/lib/factories/coupon.factory.ts @@ -0,0 +1,27 @@ +/* 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 { faker } from '@faker-js/faker'; +import { StripeCoupon } from '../stripe.client.types'; + +export const StripeCouponFactory = ( + override?: Partial +): StripeCoupon => ({ + id: faker.string.alphanumeric({ length: 8 }), + object: 'coupon', + amount_off: null, + created: faker.number.int(), + currency: null, + duration: 'repeating', + duration_in_months: faker.number.int({ min: 1, max: 6 }), + livemode: false, + max_redemptions: null, + metadata: {}, + name: null, + percent_off: faker.number.float({ min: 1, max: 100 }), + redeem_by: null, + times_redeemed: faker.number.int(), + valid: true, + ...override, +}); diff --git a/libs/payments/stripe/src/lib/factories/discount.factory.ts b/libs/payments/stripe/src/lib/factories/discount.factory.ts new file mode 100644 index 00000000000..53067bfac3c --- /dev/null +++ b/libs/payments/stripe/src/lib/factories/discount.factory.ts @@ -0,0 +1,39 @@ +/* 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 { faker } from '@faker-js/faker'; +import { StripeDiscount } from '../stripe.client.types'; + +export const StripeDiscountFactory = ( + override?: Partial +): StripeDiscount => ({ + id: `di_${faker.string.alphanumeric({ length: 14 })}`, + object: 'discount', + checkout_session: `cs_test_${faker.string.alphanumeric({ length: 60 })}`, + coupon: { + id: 'wsd', + object: 'coupon', + amount_off: null, + created: faker.number.int(), + currency: null, + duration: 'forever', + duration_in_months: null, + livemode: false, + max_redemptions: null, + metadata: {}, + name: null, + percent_off: faker.number.int({ min: 1, max: 100 }), + redeem_by: null, + times_redeemed: faker.number.int(), + valid: true, + }, + customer: `cus_${faker.string.alphanumeric({ length: 14 })}`, + end: null, + invoice: null, + invoice_item: null, + promotion_code: null, + start: faker.number.int(), + subscription: null, + ...override, +}); diff --git a/libs/payments/stripe/src/lib/stripe.client.spec.ts b/libs/payments/stripe/src/lib/stripe.client.spec.ts index a160921c519..ea28a7367ae 100644 --- a/libs/payments/stripe/src/lib/stripe.client.spec.ts +++ b/libs/payments/stripe/src/lib/stripe.client.spec.ts @@ -12,6 +12,8 @@ import { import { StripeCustomerFactory } from './factories/customer.factory'; import { StripeInvoiceFactory } from './factories/invoice.factory'; import { StripePlanFactory } from './factories/plan.factory'; +import { StripeProductFactory } from './factories/product.factory'; +import { StripePromotionCodeFactory } from './factories/promotion-code.factory'; import { StripeSubscriptionFactory } from './factories/subscription.factory'; import { StripeUpcomingInvoiceFactory } from './factories/upcoming-invoice.factory'; import { StripeClient } from './stripe.client'; @@ -33,12 +35,20 @@ const mockStripeInvoicesRetrieve = mockJestFnGenerator(); const mockStripePlansRetrieve = mockJestFnGenerator(); +const mockStripeProductsRetrieve = + mockJestFnGenerator(); +const mockStripePromotionCodesList = + mockJestFnGenerator(); +const mockStripePromotionCodesRetrieve = + mockJestFnGenerator(); const mockStripeSubscriptionsList = mockJestFnGenerator(); const mockStripeSubscriptionsCreate = mockJestFnGenerator(); const mockStripeSubscriptionsCancel = mockJestFnGenerator(); +const mockStripeSubscriptionsRetrieve = + mockJestFnGenerator(); const mockStripeSubscriptionsUpdate = mockJestFnGenerator(); @@ -58,10 +68,18 @@ jest.mock('stripe', () => ({ plans: { retrieve: mockStripePlansRetrieve, }, + products: { + retrieve: mockStripeProductsRetrieve, + }, + promotionCodes: { + list: mockStripePromotionCodesList, + retrieve: mockStripePromotionCodesRetrieve, + }, subscriptions: { create: mockStripeSubscriptionsCreate, cancel: mockStripeSubscriptionsCancel, list: mockStripeSubscriptionsList, + retrieve: mockStripeSubscriptionsRetrieve, update: mockStripeSubscriptionsUpdate, }, }; @@ -166,6 +184,20 @@ describe('StripeClient', () => { }); }); + describe('subscriptionsRetrieve', () => { + it('retrieves a subscription within Stripe', async () => { + const mockSubscription = StripeSubscriptionFactory(); + const mockResponse = StripeResponseFactory(mockSubscription); + + mockStripeSubscriptionsRetrieve.mockResolvedValue(mockResponse); + + const result = await mockClient.subscriptionsRetrieve( + mockSubscription.id + ); + expect(result).toEqual(mockResponse); + }); + }); + describe('subscriptionsUpdate', () => { it('updates a subscription within Stripe', async () => { const mockSubscription = StripeSubscriptionFactory(); @@ -238,4 +270,43 @@ describe('StripeClient', () => { expect(result).toEqual(mockResponse); }); }); + + describe('productsRetrieve', () => { + it('retrieves product successfully', async () => { + const mockProduct = StripeProductFactory(); + const mockResponse = StripeResponseFactory(mockProduct); + + mockStripeProductsRetrieve.mockResolvedValue(mockResponse); + + const result = await mockClient.productsRetrieve(mockProduct.id); + expect(result).toEqual(mockResponse); + }); + }); + + describe('promotionCodesList', () => { + it('returns promotion codes from Stripe', async () => { + const mockPromoCode = StripePromotionCodeFactory(); + const mockPromoCodeList = StripeApiListFactory([mockPromoCode]); + const mockResponse = StripeResponseFactory(mockPromoCodeList); + + mockStripePromotionCodesList.mockResolvedValue(mockResponse); + + const result = await mockClient.promotionCodesList({ + code: mockPromoCode.code, + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('promotionCodesRetrieve', () => { + it('retrieves promotion code successfully', async () => { + const mockPromoCode = StripePromotionCodeFactory(); + const mockResponse = StripeResponseFactory(mockPromoCode); + + mockStripePromotionCodesRetrieve.mockResolvedValue(mockResponse); + + const result = await mockClient.promotionCodesRetrieve(mockPromoCode.id); + expect(result).toEqual(mockResponse); + }); + }); }); diff --git a/libs/payments/stripe/src/lib/stripe.client.ts b/libs/payments/stripe/src/lib/stripe.client.ts index 68054eeb7aa..392ba7287b5 100644 --- a/libs/payments/stripe/src/lib/stripe.client.ts +++ b/libs/payments/stripe/src/lib/stripe.client.ts @@ -13,6 +13,7 @@ import { StripePaymentIntent, StripePaymentMethod, StripePlan, + StripeProduct, StripePromotionCode, StripeResponse, StripeSubscription, @@ -103,6 +104,18 @@ export class StripeClient { return result as StripeResponse; } + async subscriptionsRetrieve( + id: string, + params?: Stripe.SubscriptionRetrieveParams + ) { + const result = await this.stripe.subscriptions.retrieve(id, { + ...params, + expand: undefined, + }); + + return result as StripeResponse; + } + async subscriptionsUpdate( id: string, params?: Stripe.SubscriptionUpdateParams @@ -177,11 +190,30 @@ export class StripeClient { return result as StripeResponse; } - async promotionCodeList(params: Stripe.PromotionCodeListParams) { + async productsRetrieve(id: string, params?: Stripe.ProductRetrieveParams) { + const result = await this.stripe.products.retrieve(id, { + ...params, + expand: undefined, + }); + return result as StripeResponse; + } + + async promotionCodesList(params: Stripe.PromotionCodeListParams) { const result = await this.stripe.promotionCodes.list({ ...params, expand: undefined, }); - return result as StripeApiList; + return result as StripeResponse>; + } + + async promotionCodesRetrieve( + id: string, + params?: Stripe.PromotionCodeRetrieveParams + ) { + const result = await this.stripe.promotionCodes.retrieve(id, { + ...params, + expand: undefined, + }); + return result as StripeResponse; } } diff --git a/libs/payments/stripe/src/lib/stripe.error.ts b/libs/payments/stripe/src/lib/stripe.error.ts index e28564527f7..c254bd58807 100644 --- a/libs/payments/stripe/src/lib/stripe.error.ts +++ b/libs/payments/stripe/src/lib/stripe.error.ts @@ -36,6 +36,50 @@ export class PlanNotFoundError extends StripeError { } } +export class ProductNotFoundError extends StripeError { + constructor() { + super('Product not found'); + } +} + +export class PromotionCodeCouldNotBeAttachedError extends StripeError { + constructor(cause: Error) { + super('Promotion code could not be attached to subscription', cause); + } +} + +export class PromotionCodeInvalidError extends StripeError { + constructor() { + super('Invalid promotion code'); + } +} + +export class PromotionCodeNotForSubscriptionError extends StripeError { + constructor() { + super( + "Promotion code restricted to a product that doesn't match the product on this subscription" + ); + } +} + +export class SubscriptionCustomerIdDoesNotMatchCustomerIdError extends StripeError { + constructor() { + super('subscription.customerId does not match passed in customerId'); + } +} + +export class SubscriptionNotActiveError extends StripeError { + constructor() { + super('Subscription is not active'); + } +} + +export class SubscriptionPriceUnknownError extends StripeError { + constructor() { + super('Unknown subscription price'); + } +} + export class StripeNoMinimumChargeAmountAvailableError extends StripeError { constructor() { super('Currency does not have a minimum charge amount available.'); diff --git a/libs/payments/stripe/src/lib/stripe.manager.spec.ts b/libs/payments/stripe/src/lib/stripe.manager.spec.ts index 124b6d6b5a9..8b49eacb6da 100644 --- a/libs/payments/stripe/src/lib/stripe.manager.spec.ts +++ b/libs/payments/stripe/src/lib/stripe.manager.spec.ts @@ -11,15 +11,17 @@ import { } from './factories/api-list.factory'; import { StripeCustomerFactory } from './factories/customer.factory'; import { StripeInvoiceFactory } from './factories/invoice.factory'; +import { StripePaymentIntentFactory } from './factories/payment-intent.factory'; import { StripePlanFactory } from './factories/plan.factory'; -import { StripeSubscriptionFactory } from './factories/subscription.factory'; +import { StripeProductFactory } from './factories/product.factory'; import { StripePromotionCodeFactory } from './factories/promotion-code.factory'; -import { StripePaymentIntentFactory } from './factories/payment-intent.factory'; +import { StripeSubscriptionFactory } from './factories/subscription.factory'; import { StripeClient } from './stripe.client'; import { StripeConfig } from './stripe.config'; import { PlanIntervalMultiplePlansError, PlanNotFoundError, + ProductNotFoundError, } from './stripe.error'; import { StripeManager } from './stripe.manager'; @@ -185,16 +187,34 @@ describe('StripeManager', () => { }); }); + describe('retrieveSubscription', () => { + it('calls stripeclient', async () => { + const mockSubscription = StripeSubscriptionFactory(); + const mockResponse = StripeResponseFactory(mockSubscription); + + mockClient.subscriptionsRetrieve = jest + .fn() + .mockResolvedValueOnce(mockResponse); + + await manager.retrieveSubscription(mockSubscription.id); + + expect(mockClient.subscriptionsRetrieve).toBeCalledWith( + mockSubscription.id + ); + }); + }); + describe('updateSubscription', () => { it('calls stripeclient', async () => { const mockParams = { description: 'This is an updated subscription', }; const mockSubscription = StripeSubscriptionFactory(mockParams); + const mockResponse = StripeResponseFactory(mockSubscription); mockClient.subscriptionsUpdate = jest .fn() - .mockResolvedValueOnce(mockSubscription); + .mockResolvedValueOnce(mockResponse); await manager.updateSubscription(mockSubscription.id, mockParams); @@ -242,7 +262,7 @@ describe('StripeManager', () => { mockPromotionCode2, ]); - mockClient.promotionCodeList = jest + mockClient.promotionCodesList = jest .fn() .mockResolvedValue(mockPromotionCodesResponse); @@ -253,6 +273,20 @@ describe('StripeManager', () => { }); }); + describe('retrievePromotionCode', () => { + it('retrieves promotion code', async () => { + const mockPromotionCode = StripePromotionCodeFactory(); + const mockResponse = StripeResponseFactory([mockPromotionCode]); + + mockClient.promotionCodesRetrieve = jest + .fn() + .mockResolvedValue(mockResponse); + + const result = await manager.retrievePromotionCode(mockPromotionCode.id); + expect(result).toEqual(mockResponse); + }); + }); + describe('getCustomerTaxId', () => { it('returns customer tax id if found', async () => { const mockCustomer = StripeCustomerFactory({ @@ -388,6 +422,28 @@ describe('StripeManager', () => { }); }); + describe('retrieveProduct', () => { + it('returns product', async () => { + const mockProduct = StripeProductFactory(); + + mockClient.productsRetrieve = jest.fn().mockResolvedValue(mockProduct); + + const result = await manager.retrieveProduct(mockProduct.id); + expect(result).toEqual(mockProduct); + }); + + it('should throw error if no product exists', async () => { + const mockProduct = StripeProductFactory(); + + mockClient.productsRetrieve = jest.fn().mockResolvedValue(undefined); + + expect.assertions(1); + expect(manager.retrieveProduct(mockProduct.id)).rejects.toBeInstanceOf( + ProductNotFoundError + ); + }); + }); + describe('getLatestPaymentIntent', () => { it('fetches the latest payment intent for the subscription', async () => { const mockSubscription = StripeSubscriptionFactory(); diff --git a/libs/payments/stripe/src/lib/stripe.manager.ts b/libs/payments/stripe/src/lib/stripe.manager.ts index ff39a6d9826..2c8ac3bb645 100644 --- a/libs/payments/stripe/src/lib/stripe.manager.ts +++ b/libs/payments/stripe/src/lib/stripe.manager.ts @@ -17,6 +17,7 @@ import { CustomerNotFoundError, PlanIntervalMultiplePlansError, PlanNotFoundError, + ProductNotFoundError, StripeNoMinimumChargeAmountAvailableError, } from './stripe.error'; @@ -96,6 +97,10 @@ export class StripeManager { return this.client.subscriptionsCancel(subscriptionId); } + async retrieveSubscription(subscriptionId: string) { + return this.client.subscriptionsRetrieve(subscriptionId); + } + async updateSubscription( subscriptionId: string, params?: Stripe.SubscriptionUpdateParams @@ -116,7 +121,7 @@ export class StripeManager { } async getPromotionCodeByName(code: string, active?: boolean) { - const promotionCodes = await this.client.promotionCodeList({ + const promotionCodes = await this.client.promotionCodesList({ active, code, }); @@ -124,6 +129,10 @@ export class StripeManager { return promotionCodes.data.at(0); } + async retrievePromotionCode(id: string) { + return this.client.promotionCodesRetrieve(id); + } + /** * Updates customer object with incoming tax ID if existing tax ID does not match * @@ -184,6 +193,12 @@ export class StripeManager { return plans.at(0); } + async retrieveProduct(productId: string) { + const product = await this.client.productsRetrieve(productId); + if (!product) throw new ProductNotFoundError(); + return product; + } + async getLatestPaymentIntent(subscription: StripeSubscription) { if (!subscription.latest_invoice) { return; diff --git a/libs/payments/stripe/src/lib/stripe.service.spec.ts b/libs/payments/stripe/src/lib/stripe.service.spec.ts index c25ae6ae8e3..fc7eb928885 100644 --- a/libs/payments/stripe/src/lib/stripe.service.spec.ts +++ b/libs/payments/stripe/src/lib/stripe.service.spec.ts @@ -1,24 +1,314 @@ /* 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 { faker } from '@faker-js/faker'; import { Test, TestingModule } from '@nestjs/testing'; + +import { StripeResponseFactory } from './factories/api-list.factory'; +import { StripeCustomerFactory } from './factories/customer.factory'; +import { StripePriceFactory } from './factories/price.factory'; +import { StripePromotionCodeFactory } from './factories/promotion-code.factory'; +import { + StripeSubscriptionFactory, + StripeSubscriptionItemFactory, +} from './factories/subscription.factory'; + +import { + PromotionCodeCouldNotBeAttachedError, + PromotionCodeInvalidError, + PromotionCodeNotForSubscriptionError, + SubscriptionPriceUnknownError, +} from './stripe.error'; +import { StripeManager } from './stripe.manager'; import { StripeService } from './stripe.service'; +import { STRIPE_PRICE_METADATA } from './stripe.types'; + +const mockIsValidPromotionCode = jest.fn(); +const mockPromotionCodeIncluded = jest.fn(); +const mockSubscribedPrice = jest.fn(); + +jest.mock('../lib/stripe.util.ts', () => { + return { + checkSubscriptionPromotionCodes: function () { + return mockPromotionCodeIncluded(); + }, + checkValidPromotionCode: function () { + return mockIsValidPromotionCode(); + }, + getSubscribedPrice: function () { + return mockSubscribedPrice(); + }, + }; +}); describe('StripeService', () => { - describe('customerChanged', () => { - let service: StripeService; + let service: StripeService; + let stripeManager: StripeManager; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [StripeManager, StripeService], + }) + .overrideProvider(StripeManager) + .useValue({ + retrieveProduct: jest.fn(), + retrievePromotionCode: jest.fn(), + retrieveSubscription: jest.fn(), + updateSubscription: jest.fn(), + }) + .compile(); + + stripeManager = module.get(StripeManager); + service = module.get(StripeService); + }); + + it('should be defined', async () => { + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(StripeService); + }); + + describe('applyPromoCodeToSubscription', () => { + it('throws an error if the subscription is not active', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockPromoId = faker.string.sample(); + const mockSubscription = StripeSubscriptionFactory({ + status: 'canceled', + }); + const mockResponse = StripeResponseFactory(mockSubscription); + + jest + .spyOn(stripeManager, 'retrieveSubscription') + .mockResolvedValue(mockResponse); + + try { + await service.applyPromoCodeToSubscription( + mockCustomer.id, + mockSubscription.id, + mockPromoId + ); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeCouldNotBeAttachedError); + expect(error.message).toEqual( + 'Promotion code could not be attached to subscription: Subscription is not active' + ); + } + }); - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [StripeService], - }).compile(); + it('throws an error if the customer of the subscription does not match customerId', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockPromoId = faker.string.sample(); + const mockSubscription = StripeSubscriptionFactory({ + status: 'active', + }); + const mockResponse = StripeResponseFactory(mockSubscription); - service = module.get(StripeService); + jest + .spyOn(stripeManager, 'retrieveSubscription') + .mockResolvedValue(mockResponse); + + try { + await service.applyPromoCodeToSubscription( + mockCustomer.id, + mockSubscription.id, + mockPromoId + ); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeCouldNotBeAttachedError); + expect(error.message).toEqual( + 'Promotion code could not be attached to subscription: subscription.customerId does not match passed in customerId' + ); + } }); - it('should be defined', async () => { - expect(service).toBeDefined(); - expect(service).toBeInstanceOf(StripeService); + it('throws an error if promotion code is invalid', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockPromotionCode = StripePromotionCodeFactory(); + const mockPromoResponse = StripeResponseFactory(undefined); + const mockSubscription = StripeSubscriptionFactory({ + customer: mockCustomer.id, + status: 'active', + }); + const mockSubResponse = StripeResponseFactory(mockSubscription); + + jest + .spyOn(stripeManager, 'retrieveSubscription') + .mockResolvedValue(mockSubResponse); + + jest + .spyOn(stripeManager, 'retrievePromotionCode') + .mockResolvedValue(mockPromoResponse); + + mockIsValidPromotionCode.mockImplementation(() => { + throw new PromotionCodeInvalidError(); + }); + + try { + await service.applyPromoCodeToSubscription( + mockCustomer.id, + mockSubscription.id, + mockPromotionCode.id + ); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeCouldNotBeAttachedError); + expect(error.message).toEqual( + 'Promotion code could not be attached to subscription: Invalid promotion code' + ); + } + }); + + it('throws an error if no subscription price exists', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockPromotionCode = StripePromotionCodeFactory(); + const mockPromoResponse = StripeResponseFactory(mockPromotionCode); + const mockSubscription = StripeSubscriptionFactory({ + customer: mockCustomer.id, + status: 'active', + }); + const mockSubResponse = StripeResponseFactory(mockSubscription); + + jest + .spyOn(stripeManager, 'retrieveSubscription') + .mockResolvedValue(mockSubResponse); + + jest + .spyOn(stripeManager, 'retrievePromotionCode') + .mockResolvedValue(mockPromoResponse); + + mockIsValidPromotionCode.mockReturnValue(true); + mockSubscribedPrice.mockImplementation(() => { + throw new SubscriptionPriceUnknownError(); + }); + + try { + await service.applyPromoCodeToSubscription( + mockCustomer.id, + mockSubscription.id, + mockPromotionCode.id + ); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeCouldNotBeAttachedError); + expect(error.message).toEqual( + 'Promotion code could not be attached to subscription: Unknown subscription price' + ); + } + }); + + it('throws an error if the promotion code is not one from the product', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory(); + const mockPromotionCode = StripePromotionCodeFactory({ + active: true, + }); + const mockSubscription = StripeSubscriptionFactory({ + customer: mockCustomer.id, + status: 'active', + }); + const mockSubResponse = StripeResponseFactory(mockSubscription); + const mockPromoResponse = StripeResponseFactory(mockPromotionCode); + + jest + .spyOn(stripeManager, 'retrieveSubscription') + .mockResolvedValue(mockSubResponse); + + jest + .spyOn(stripeManager, 'retrievePromotionCode') + .mockResolvedValue(mockPromoResponse); + + mockIsValidPromotionCode.mockReturnValue(true); + mockSubscribedPrice.mockReturnValue(mockPrice); + mockPromotionCodeIncluded.mockImplementation(() => { + throw new PromotionCodeNotForSubscriptionError(); + }); + + try { + await service.applyPromoCodeToSubscription( + mockCustomer.id, + mockSubscription.id, + mockPromotionCode.id + ); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeCouldNotBeAttachedError); + expect(error.message).toEqual( + "Promotion code could not be attached to subscription: Promotion code restricted to a product that doesn't match the product on this subscription" + ); + } + }); + + it('returns the updated subscription with the promotion code successfully', async () => { + const mockCustomer = StripeCustomerFactory(); + const mockPrice = StripePriceFactory(); + const mockPromotionCode = StripePromotionCodeFactory({ + id: 'promo_code2', + active: true, + }); + const mockPromoCodeResponse = StripeResponseFactory(mockPromotionCode); + const mockSubscription = StripeSubscriptionFactory({ + customer: mockCustomer.id, + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: StripePriceFactory({ + metadata: { + [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo_code1', + }, + }), + }), + ], + has_more: false, + url: `/v1/subscription_items?subscription=sub_${faker.string.alphanumeric( + { length: 24 } + )}`, + }, + status: 'active', + }); + const mockUpdatedSubscription = StripeSubscriptionFactory({ + customer: mockCustomer.id, + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: StripePriceFactory({ + metadata: { + [STRIPE_PRICE_METADATA.PROMOTION_CODES]: + 'promo_code1,promo_code2', + }, + }), + }), + ], + has_more: false, + url: `/v1/subscription_items?subscription=sub_${faker.string.alphanumeric( + { length: 24 } + )}`, + }, + status: 'active', + }); + const mockSubResponse1 = StripeResponseFactory(mockSubscription); + const mockSubResponse2 = StripeResponseFactory(mockUpdatedSubscription); + + jest + .spyOn(stripeManager, 'retrieveSubscription') + .mockResolvedValue(mockSubResponse1); + + jest + .spyOn(stripeManager, 'retrievePromotionCode') + .mockResolvedValue(mockPromoCodeResponse); + + mockIsValidPromotionCode.mockReturnValue(true); + mockSubscribedPrice.mockReturnValue(mockPrice); + mockPromotionCodeIncluded.mockReturnValue(true); + + jest + .spyOn(stripeManager, 'updateSubscription') + .mockResolvedValue(mockSubResponse2); + + const result = await service.applyPromoCodeToSubscription( + mockCustomer.id, + mockSubscription.id, + mockPromotionCode.id + ); + expect(result).toEqual(mockSubResponse2); }); }); }); diff --git a/libs/payments/stripe/src/lib/stripe.service.ts b/libs/payments/stripe/src/lib/stripe.service.ts index 0fd868e9d9c..6d69695c708 100644 --- a/libs/payments/stripe/src/lib/stripe.service.ts +++ b/libs/payments/stripe/src/lib/stripe.service.ts @@ -3,10 +3,60 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Injectable } from '@nestjs/common'; +import { + PromotionCodeCouldNotBeAttachedError, + SubscriptionCustomerIdDoesNotMatchCustomerIdError, + SubscriptionNotActiveError, +} from './stripe.error'; +import { StripeManager } from './stripe.manager'; +import { + checkSubscriptionPromotionCodes, + checkValidPromotionCode, + getSubscribedPrice, +} from './stripe.util'; @Injectable() export class StripeService { - constructor() {} + constructor(private stripeManager: StripeManager) {} + + async applyPromoCodeToSubscription( + customerId: string, + subscriptionId: string, + promotionId: string + ) { + try { + const subscription = await this.stripeManager.retrieveSubscription( + subscriptionId + ); + if (subscription?.status !== 'active') + throw new SubscriptionNotActiveError(); + if (subscription.customer !== customerId) + throw new SubscriptionCustomerIdDoesNotMatchCustomerIdError(); + + const promotionCode = await this.stripeManager.retrievePromotionCode( + promotionId + ); + + checkValidPromotionCode(promotionCode); + + const price = getSubscribedPrice(subscription); + const productId = price.product; + const product = await this.stripeManager.retrieveProduct(productId); + + checkSubscriptionPromotionCodes(promotionCode.code, price, product); + + const updatedSubscription = await this.stripeManager.updateSubscription( + subscriptionId, + { + promotion_code: promotionCode.code, + } + ); + + return updatedSubscription; + } catch (error) { + throw new PromotionCodeCouldNotBeAttachedError(error); + } + } // TODO: this method should be moved down to the manager layer async customerChanged(uid: string, email: string) { diff --git a/libs/payments/stripe/src/lib/stripe.types.ts b/libs/payments/stripe/src/lib/stripe.types.ts new file mode 100644 index 00000000000..b060d8a03f0 --- /dev/null +++ b/libs/payments/stripe/src/lib/stripe.types.ts @@ -0,0 +1,13 @@ +/* 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/. */ + +export enum STRIPE_PRICE_METADATA { + APP_STORE_PRODUCT_IDS = 'appStoreProductIds', + PLAY_SKU_IDS = 'playSkuIds', + PROMOTION_CODES = 'promotionCodes', +} + +export enum STRIPE_PRODUCT_METADATA { + PROMOTION_CODES = 'promotionCodes', +} diff --git a/libs/payments/stripe/src/lib/stripe.util.spec.ts b/libs/payments/stripe/src/lib/stripe.util.spec.ts index 32a60037464..011bfd720b9 100644 --- a/libs/payments/stripe/src/lib/stripe.util.spec.ts +++ b/libs/payments/stripe/src/lib/stripe.util.spec.ts @@ -3,16 +3,215 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { faker } from '@faker-js/faker'; -import { StripeApiListFactory } from './factories/api-list.factory'; +import { + StripeApiListFactory, + StripeResponseFactory, +} from './factories/api-list.factory'; +import { StripeCouponFactory } from './factories/coupon.factory'; import { StripePlanFactory } from './factories/plan.factory'; +import { StripePriceFactory } from './factories/price.factory'; +import { StripeProductFactory } from './factories/product.factory'; +import { StripePromotionCodeFactory } from './factories/promotion-code.factory'; import { StripeSubscriptionFactory, StripeSubscriptionItemFactory, } from './factories/subscription.factory'; - -import { getSubscribedPlans, getSubscribedProductIds } from './stripe.util'; +import { STRIPE_PRICE_METADATA, STRIPE_PRODUCT_METADATA } from './stripe.types'; +import { + checkSubscriptionPromotionCodes, + checkValidPromotionCode, + getSubscribedPlans, + getSubscribedPrice, + getSubscribedProductIds, +} from './stripe.util'; +import { + PromotionCodeInvalidError, + PromotionCodeNotForSubscriptionError, + SubscriptionPriceUnknownError, +} from './stripe.error'; describe('util', () => { + describe('checkSubscriptionPromotionCodes', () => { + it('throws error if promotion code is not within subscription price', async () => { + const mockPrice = StripePriceFactory(); + const mockPromoCode = 'promo_code1'; + + try { + checkSubscriptionPromotionCodes(mockPromoCode, mockPrice, undefined); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeNotForSubscriptionError); + } + }); + + it('returns true if only subscription price provided', async () => { + const mockPrice = StripePriceFactory({ + metadata: { + [STRIPE_PRICE_METADATA.PROMOTION_CODES]: + 'promo_code1,promo_code2,promo_code3', + }, + }); + const mockPromoCode = 'promo_code1'; + + const result = checkSubscriptionPromotionCodes( + mockPromoCode, + mockPrice, + undefined + ); + expect(result).toEqual(true); + }); + + it('returns true if promotion code is included in promotion codes for product', async () => { + const mockPrice = StripePriceFactory({ + metadata: { + [STRIPE_PRICE_METADATA.PROMOTION_CODES]: + 'promo_code1,promo_code2,promo_code3', + }, + }); + const mockProduct = StripeProductFactory({ + metadata: { + [STRIPE_PRODUCT_METADATA.PROMOTION_CODES]: + 'promo_code1,promo_code2,promo_code3', + }, + }); + const mockPromoCode = 'promo_code1'; + + const result = checkSubscriptionPromotionCodes( + mockPromoCode, + mockPrice, + mockProduct + ); + expect(result).toEqual(true); + }); + }); + + describe('checkValidPromotion', () => { + it('throws error if there is no promotion code', async () => { + const mockPromotionCode = StripeResponseFactory(undefined); + + try { + checkValidPromotionCode(mockPromotionCode); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeInvalidError); + } + }); + + it('throws error if the promotion code is not active', async () => { + const mockPromotionCode = StripePromotionCodeFactory({ + active: false, + }); + + try { + checkValidPromotionCode(mockPromotionCode); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeInvalidError); + } + }); + + it('throws error if the promotion code coupon is not valid', async () => { + const mockPromotionCode = StripePromotionCodeFactory({ + coupon: StripeCouponFactory({ + valid: false, + }), + }); + + try { + checkValidPromotionCode(mockPromotionCode); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeInvalidError); + } + }); + + it('throws error if the promotion code is expired', async () => { + const expiredTime = Date.now() / 1000 - 50; + const mockPromotionCode = StripePromotionCodeFactory({ + expires_at: expiredTime, + }); + + try { + checkValidPromotionCode(mockPromotionCode); + } catch (error) { + expect(error).toBeInstanceOf(PromotionCodeInvalidError); + } + }); + + it('returns true if the promotion code is valid', async () => { + const mockPromotionCode = StripePromotionCodeFactory({ + active: true, + }); + + const result = checkValidPromotionCode(mockPromotionCode); + expect(result).toEqual(true); + }); + }); + + describe('getSubscribedPrice', () => { + it('returns subscription price successfully', async () => { + const mockPrice = StripePriceFactory(); + const mockSubItem = StripeSubscriptionItemFactory({ + price: mockPrice, + }); + const mockSubscription = StripeSubscriptionFactory({ + items: { + object: 'list', + data: [mockSubItem], + has_more: false, + url: `/v1/subscription_items?subscription=sub_${faker.string.alphanumeric( + { + length: 24, + } + )}`, + }, + }); + + const result = getSubscribedPrice(mockSubscription); + expect(result).toEqual(mockPrice); + }); + + it('throws error if no subscription price exists', async () => { + const mockSubscription = StripeSubscriptionFactory({ + items: { + object: 'list', + data: [], + has_more: false, + url: `/v1/subscription_items?subscription=sub_${faker.string.alphanumeric( + { + length: 24, + } + )}`, + }, + }); + + try { + getSubscribedPrice(mockSubscription); + } catch (error) { + expect(error).toBeInstanceOf(SubscriptionPriceUnknownError); + } + }); + + it('throws error if multiple subscription prices exists', async () => { + const mockSubItem1 = StripeSubscriptionItemFactory(); + const mockSubItem2 = StripeSubscriptionItemFactory(); + const mockSubscription = StripeSubscriptionFactory({ + items: { + object: 'list', + data: [mockSubItem1, mockSubItem2], + has_more: false, + url: `/v1/subscription_items?subscription=sub_${faker.string.alphanumeric( + { + length: 24, + } + )}`, + }, + }); + + try { + getSubscribedPrice(mockSubscription); + } catch (error) { + expect(error).toBeInstanceOf(SubscriptionPriceUnknownError); + } + }); + }); + describe('getSubscribedPlans', () => { it('returns plans successfully', async () => { const mockPlan = StripePlanFactory(); diff --git a/libs/payments/stripe/src/lib/stripe.util.ts b/libs/payments/stripe/src/lib/stripe.util.ts index 7fecadc73ea..f0367fd6b79 100644 --- a/libs/payments/stripe/src/lib/stripe.util.ts +++ b/libs/payments/stripe/src/lib/stripe.util.ts @@ -5,8 +5,65 @@ import { StripeApiList, StripePlan, + StripePrice, + StripeProduct, + StripePromotionCode, StripeSubscription, } from './stripe.client.types'; +import { + PromotionCodeInvalidError, + PromotionCodeNotForSubscriptionError, + SubscriptionPriceUnknownError, +} from './stripe.error'; +import { STRIPE_PRICE_METADATA, STRIPE_PRODUCT_METADATA } from './stripe.types'; + +export const checkSubscriptionPromotionCodes = ( + code: string, + price: StripePrice, + product?: StripeProduct +) => { + const validPromotionCodes: string[] = []; + if (price.metadata && price.metadata[STRIPE_PRICE_METADATA.PROMOTION_CODES]) { + validPromotionCodes.push( + ...price.metadata[STRIPE_PRICE_METADATA.PROMOTION_CODES] + .split(',') + .map((c) => c.trim()) + ); + } + if ( + product?.metadata && + product.metadata[STRIPE_PRODUCT_METADATA.PROMOTION_CODES] + ) { + validPromotionCodes.push( + ...product.metadata[STRIPE_PRODUCT_METADATA.PROMOTION_CODES] + .split(',') + .map((c) => c.trim()) + ); + } + if (!validPromotionCodes.includes(code)) { + throw new PromotionCodeNotForSubscriptionError(); + } + return true; +}; + +export const checkValidPromotionCode = (code: StripePromotionCode) => { + const nowSecs = Date.now() / 1000; + if ( + !code || + !code.active || + !code.coupon.valid || + (code.expires_at && code.expires_at < nowSecs) + ) + throw new PromotionCodeInvalidError(); + return true; +}; + +export const getSubscribedPrice = (subscription: StripeSubscription) => { + const item = subscription.items.data.at(0); + if (!item || subscription.items.data.length > 1) + throw new SubscriptionPriceUnknownError(); + return item.price; +}; /** * Returns array of customer subscription plans