From 6c33f45862395ee075be3dc8e02b28f3c3a3a357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Wed, 20 Mar 2024 15:17:13 +0100 Subject: [PATCH 01/21] feat: add annual toggle for business plan --- .source | 2 +- apps/api/src/app.module.ts | 2 +- ...-intent.e2e-ee.ts => upsert-setup-intent.e2e-ee.ts} | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) rename apps/api/src/app/testing/billing/{create-setup-intent.e2e-ee.ts => upsert-setup-intent.e2e-ee.ts} (94%) diff --git a/.source b/.source index 0d071faf25c..921288715ce 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 0d071faf25cf2e54cd6e60117aabb36fbb5110d9 +Subproject commit 921288715cefc5e86ba5dd78f0b495b5986e8b99 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cc6a90d62fd..aca7f1d7e8a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -45,7 +45,7 @@ const enterpriseImports = (): Array { +describe('Upsert setup intent', () => { const eeBilling = require('@novu/ee-billing'); - if (!eeBilling.CreateSetupIntent) { - throw new Error("CreateSetupIntent doesn't exist"); + if (!eeBilling.UpsertSetupIntent) { + throw new Error("UpsertSetupIntent doesn't exist"); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { CreateSetupIntent } = eeBilling; + const { UpsertSetupIntent } = eeBilling; const stubObject = { setupIntents: { @@ -54,7 +54,7 @@ describe('Create setup intent', () => { }); const createUseCase = () => { - const useCase = new CreateSetupIntent(stubObject, getCustomerUsecase, userRepository); + const useCase = new UpsertSetupIntent(stubObject, getCustomerUsecase, userRepository); return useCase; }; From b20f41d96f1d06c784ead4744b8873c56d4d26d4 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 22 Mar 2024 08:53:38 +0000 Subject: [PATCH 02/21] fix(web): Change billing tab name for simplicity --- apps/web/src/pages/settings/SettingsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/settings/SettingsPage.tsx b/apps/web/src/pages/settings/SettingsPage.tsx index 1291c6d666b..88d84bde12b 100644 --- a/apps/web/src/pages/settings/SettingsPage.tsx +++ b/apps/web/src/pages/settings/SettingsPage.tsx @@ -66,7 +66,7 @@ export function SettingsPage() { API Keys Email Settings - Billing Plans + Billing Permissions SSO From fdebe557626eb2e30817295a88d1fe438827f254 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 22 Mar 2024 08:54:17 +0000 Subject: [PATCH 03/21] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 921288715ce..ca645fc736c 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 921288715cefc5e86ba5dd78f0b495b5986e8b99 +Subproject commit ca645fc736c287ad8ad74ea15ac9fae18afd1e71 From f07fcbe0c68c3a06f6ee2752dd5b359f22f80187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Sun, 24 Mar 2024 07:06:31 +0100 Subject: [PATCH 04/21] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index ca645fc736c..5838b168408 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit ca645fc736c287ad8ad74ea15ac9fae18afd1e71 +Subproject commit 5838b168408d31bc683a00a4a1f95b8bc1134373 From 9b3492077e6cbaeffb8ec4b6455090b1201a4fa2 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 10:11:22 +0000 Subject: [PATCH 05/21] test(billing): Fix get-prices test --- .../app/testing/billing/get-prices.e2e-ee.ts | 113 ++++++++++++------ 1 file changed, 74 insertions(+), 39 deletions(-) diff --git a/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts b/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts index def35474021..baad2ef371a 100644 --- a/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts @@ -12,21 +12,18 @@ describe('GetPrices', () => { const stripeStub = { prices: { - list: () => {}, + list: sinon.stub(), }, }; let listPricesStub: sinon.SinonStub; beforeEach(() => { - listPricesStub = sinon.stub(stripeStub.prices, 'list').resolves({ - data: [ - { - id: 'price_id_1', - }, - { - id: 'price_id_2', - }, - ], + listPricesStub = stripeStub.prices.list; + listPricesStub.onFirstCall().resolves({ + data: [{ id: 'licensed_price_id_1' }], + }); + listPricesStub.onSecondCall().resolves({ + data: [{ id: 'metered_price_id_1' }], }); }); @@ -34,56 +31,94 @@ describe('GetPrices', () => { listPricesStub.reset(); }); - const createUseCase = () => { - const useCase = new GetPrices(stripeStub as any); - - return useCase; - }; + const createUseCase = () => new GetPrices(stripeStub as any); const expectedPrices = [ { apiServiceLevel: ApiServiceLevelEnum.FREE, - prices: ['free_usage_notifications'], + billingInterval: 'month', // Example billing interval + prices: { + licensed: ['free_flat_monthly'], + metered: ['free_usage_notifications'], + }, }, { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - prices: ['business_flat_monthly', 'business_usage_notifications'], + billingInterval: 'month', // Example billing interval + prices: { + licensed: ['business_flat_monthly'], + metered: ['business_usage_notifications'], + }, + }, + { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: 'year', // Example billing interval + prices: { + licensed: ['business_flat_annually'], + metered: ['business_usage_notifications'], + }, + }, + { + apiServiceLevel: ApiServiceLevelEnum.ENTERPRISE, + billingInterval: 'month', // Example billing interval + prices: { + licensed: ['enterprise_flat_monthly'], + metered: ['enterprise_usage_notifications'], + }, + }, + { + apiServiceLevel: ApiServiceLevelEnum.ENTERPRISE, + billingInterval: 'year', // Example billing interval + prices: { + licensed: ['enterprise_flat_annually'], + metered: ['enterprise_usage_notifications'], + }, }, ]; + expectedPrices - .map(({ apiServiceLevel, prices }) => { + .map(({ apiServiceLevel, billingInterval, prices }) => { return () => { - describe(`apiServiceLevel of ${apiServiceLevel}`, () => { - it(`should fetch the prices list with the expected values`, async () => { + describe(`apiServiceLevel of ${apiServiceLevel} and billingInterval of ${billingInterval}`, () => { + it(`should fetch the prices list with the expected lookup keys`, async () => { const useCase = createUseCase(); await useCase.execute( GetPricesCommand.create({ - apiServiceLevel: apiServiceLevel, + apiServiceLevel, + billingInterval, }) ); - expect(listPricesStub.lastCall.args[0].lookup_keys).to.contain.members(prices); - }); - - it(`should throw an error if no prices are found`, async () => { - listPricesStub.resolves({ data: [] }); - const useCase = createUseCase(); - - try { - await useCase.execute( - GetPricesCommand.create({ - apiServiceLevel: apiServiceLevel, - }) - ); - } catch (e) { - expect(e.message).to.equal( - `No price found for apiServiceLevel: '${apiServiceLevel}' and lookup_keys: '${prices}'` - ); - } + const allCallsArgs = listPricesStub.getCalls().map((call) => call.args[0]); + expect(allCallsArgs).to.deep.equal([ + { + lookup_keys: prices.licensed, + }, + { + lookup_keys: prices.metered, + }, + ]); }); }); }; }) .forEach((test) => test()); + + it(`should throw an error if no prices are found`, async () => { + listPricesStub.onFirstCall().resolves({ data: [] }); + listPricesStub.onSecondCall().resolves({ data: [] }); + const useCase = createUseCase(); + + try { + await useCase.execute( + GetPricesCommand.create({ + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: 'month', + }) + ); + } catch (e) { + expect(e.message).to.include(`No prices found for apiServiceLevel: '${ApiServiceLevelEnum.BUSINESS}'`); + } + }); }); From 2489e0380e18653fe719b690164c8b485803d133 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 10:41:08 +0000 Subject: [PATCH 06/21] test(api): Upsert setup intent fixes --- .../billing/upsert-setup-intent.e2e-ee.ts | 101 ++++++++++++++---- 1 file changed, 81 insertions(+), 20 deletions(-) diff --git a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts index 460bcc1e775..05c4b34e089 100644 --- a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts @@ -1,6 +1,7 @@ import * as sinon from 'sinon'; import { expect } from 'chai'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { StripeBillingIntervalEnum } from '@novu/ee-billing/src/stripe/types'; // Adjust the import path as necessary describe('Upsert setup intent', () => { const eeBilling = require('@novu/ee-billing'); @@ -14,6 +15,7 @@ describe('Upsert setup intent', () => { setupIntents: { list: () => {}, create: () => {}, + update: () => {}, // Added for update case }, }; @@ -28,6 +30,7 @@ describe('Upsert setup intent', () => { let spyGetCustomer: sinon.SinonSpy; let stubCreateSetupIntent: sinon.SinonStub; let stubListSetupIntents: sinon.SinonStub; + let stubUpdateSetupIntent: sinon.SinonStub; // Added for update case let stubGetUser: sinon.SinonStub; beforeEach(() => { @@ -40,10 +43,12 @@ describe('Upsert setup intent', () => { status: 'succeeded', metadata: { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, // Adjusted to include billingInterval }, }, ], }); + stubUpdateSetupIntent = sinon.stub(stubObject.setupIntents, 'update').resolves({}); // Added for update case stubGetUser = sinon.stub(userRepository, 'findById').resolves({ email: 'user_email' }); }); @@ -51,6 +56,7 @@ describe('Upsert setup intent', () => { spyGetCustomer.resetHistory(); stubCreateSetupIntent.reset(); stubListSetupIntents.reset(); + stubUpdateSetupIntent.reset(); // Added for update case }); const createUseCase = () => { @@ -64,29 +70,57 @@ describe('Upsert setup intent', () => { const result = await useCase.execute({ organizationId: 'organization_id', userId: 'user_id', + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, }); expect(stubCreateSetupIntent.lastCall.args.at(0)).to.deep.equal({ customer: 'customer_id', metadata: { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, }, }); - expect(spyGetCustomer.lastCall.args.at(0)).to.deep.equal({ - organizationId: 'organization_id', - email: 'user_email', + expect(result).to.deep.equal({ clientSecret: 'client_secret' }); + }); + + it('should setup intent with existing intent requiring update', async () => { + stubListSetupIntents.resolves({ + data: [ + { + id: 'intent_id', + client_secret: 'client_secret', + status: 'requires_payment_method', + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.YEAR, // Different from the command to trigger an update + }, + }, + ], }); - expect(stubListSetupIntents.lastCall.args.at(0)).to.deep.equal({ - customer: 'customer_id', - limit: 1, + const useCase = createUseCase(); + const result = await useCase.execute({ + organizationId: 'organization_id', + userId: 'user_id', + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, // Triggering an update due to different billingInterval }); - expect(result).to.equal('client_secret'); + expect( + stubUpdateSetupIntent.calledWith('intent_id', { + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, + }, + }) + ).to.be.true; + + expect(result).to.deep.equal({ clientSecret: 'client_secret' }); }); - it('should setup intent with existing intent', async () => { + it('should not create or update setup intent if existing intent matches', async () => { stubListSetupIntents.resolves({ data: [ { @@ -94,6 +128,7 @@ describe('Upsert setup intent', () => { status: 'requires_payment_method', metadata: { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, // Matches the command, no update needed }, }, ], @@ -103,31 +138,25 @@ describe('Upsert setup intent', () => { const result = await useCase.execute({ organizationId: 'organization_id', userId: 'user_id', + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, }); expect(stubCreateSetupIntent.callCount).to.equal(0); - expect(spyGetCustomer.lastCall.args.at(0)).to.deep.equal({ - organizationId: 'organization_id', - email: 'user_email', - }); - - expect(stubListSetupIntents.lastCall.args.at(0)).to.deep.equal({ - customer: 'customer_id', - limit: 1, - }); - - expect(result).to.equal('client_secret'); + expect(stubUpdateSetupIntent.callCount).to.equal(0); + expect(result).to.deep.equal({ clientSecret: 'client_secret' }); }); it('should throw an error if user is not found', async () => { stubGetUser.rejects(new Error('User not found: user_id')); const useCase = createUseCase(); - try { await useCase.execute({ organizationId: 'organization_id', userId: 'user_id', + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, }); throw new Error('Should not reach here'); } catch (e) { @@ -141,6 +170,8 @@ describe('Upsert setup intent', () => { await useCase.execute({ organizationId: 'organization_id', userId: 'user_id', + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, }); expect(spyGetCustomer.lastCall.args.at(0)).to.deep.equal({ @@ -148,4 +179,34 @@ describe('Upsert setup intent', () => { email: 'user_email', }); }); + + it('should throw an error when the apiServiceLevel is not BUSINESS', async () => { + const useCase = createUseCase(); + try { + await useCase.execute({ + organizationId: 'organization_id', + userId: 'user_id', + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`API service level not allowed: ${ApiServiceLevelEnum.FREE}`); + } + }); + + it('should throw an error when given an invalid billing interval', async () => { + const useCase = createUseCase(); + try { + await useCase.execute({ + organizationId: 'organization_id', + userId: 'user_id', + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: 'invalid', + }); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`Invalid billing interval: 'invalid'`); + } + }); }); From 6fe67755142c685b4a8c04dc63bddf4967b1e9db Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 10:45:08 +0000 Subject: [PATCH 07/21] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 5838b168408..d2d07cff02a 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 5838b168408d31bc683a00a4a1f95b8bc1134373 +Subproject commit d2d07cff02a75ded023c109d378e65a28aaa426f From aff1dbeb066cc7871d67be84eda6ffe515612630 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 10:52:01 +0000 Subject: [PATCH 08/21] test(api): Fix setup intent webhook tests --- .../billing/upsert-setup-intent.e2e-ee.ts | 8 +- .../billing/upsert-subscription.e2e-ee.ts | 227 ++++++++++++------ .../src/app/testing/billing/webhook.e2e-ee.ts | 2 + 3 files changed, 153 insertions(+), 84 deletions(-) diff --git a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts index 05c4b34e089..78949d2bbec 100644 --- a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts @@ -15,7 +15,7 @@ describe('Upsert setup intent', () => { setupIntents: { list: () => {}, create: () => {}, - update: () => {}, // Added for update case + update: () => {}, }, }; @@ -30,7 +30,7 @@ describe('Upsert setup intent', () => { let spyGetCustomer: sinon.SinonSpy; let stubCreateSetupIntent: sinon.SinonStub; let stubListSetupIntents: sinon.SinonStub; - let stubUpdateSetupIntent: sinon.SinonStub; // Added for update case + let stubUpdateSetupIntent: sinon.SinonStub; let stubGetUser: sinon.SinonStub; beforeEach(() => { @@ -48,7 +48,7 @@ describe('Upsert setup intent', () => { }, ], }); - stubUpdateSetupIntent = sinon.stub(stubObject.setupIntents, 'update').resolves({}); // Added for update case + stubUpdateSetupIntent = sinon.stub(stubObject.setupIntents, 'update').resolves({}); stubGetUser = sinon.stub(userRepository, 'findById').resolves({ email: 'user_email' }); }); @@ -56,7 +56,7 @@ describe('Upsert setup intent', () => { spyGetCustomer.resetHistory(); stubCreateSetupIntent.reset(); stubListSetupIntents.reset(); - stubUpdateSetupIntent.reset(); // Added for update case + stubUpdateSetupIntent.reset(); }); const createUseCase = () => { diff --git a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts index a2d9b91ccb0..49fab8da6a2 100644 --- a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts @@ -34,21 +34,30 @@ describe('UpsertSubscription', () => { data: [ { id: 'subscription_id', - items: { data: [{ id: 'item_id_usage_notifications' }, { id: 'item_id_flat' }] }, + items: { + data: [ + { id: 'item_id_usage_notifications', price: { recurring: { usage_type: 'metered' } } }, + { id: 'item_id_flat', price: { recurring: { usage_type: 'licensed' } } }, + ], + }, }, ], }, }; beforeEach(() => { - getPricesStub = sinon.stub(GetPrices.prototype, 'execute').resolves([ - { - id: 'price_id_1', - }, - { - id: 'price_id_2', - }, - ] as any); + getPricesStub = sinon.stub(GetPrices.prototype, 'execute').resolves({ + metered: [ + { + id: 'price_id_metered_1', + }, + ], + licensed: [ + { + id: 'price_id_licensed_1', + }, + ], + } as any); updateOrgStub = sinon.stub(repo, 'update').resolves({ matched: 1, modified: 1 }); createSubscriptionStub = sinon.stub(stripeStub.subscriptions, 'create'); updateSubscriptionStub = sinon.stub(stripeStub.subscriptions, 'update'); @@ -68,90 +77,148 @@ describe('UpsertSubscription', () => { }; describe('Subscription upserting', () => { - it('should create a new subscription if the customer has no subscriptions', async () => { - const useCase = createUseCase(); - const customer = { ...mockCustomer, subscriptions: { data: [] } }; - - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: customer as any, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - }) - ); - - expect(createSubscriptionStub.lastCall.args).to.deep.equal([ - { - customer: 'customer_id', - items: [ - { - price: 'price_id_1', - }, - { - price: 'price_id_2', - }, - ], - }, - ]); - }); + describe('No subscriptions', () => { + it('should create a single subscription with monthly prices when billingInterval is month', async () => { + const useCase = createUseCase(); + const customer = { ...mockCustomer, subscriptions: { data: [] } }; - it('should update the existing subscription if the customer has a subscription', async () => { - const useCase = createUseCase(); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: 'month', + }) + ); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomer as any, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - }) - ); + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_metered_1', + }, + { + price: 'price_id_licensed_1', + }, + ], + }, + ]); + }); + + it('should create two subscriptions, one with monthly prices and one with annual prices when billingInterval is year', async () => { + const useCase = createUseCase(); + const customer = { ...mockCustomer, subscriptions: { data: [] } }; - expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ - 'subscription_id', - { - items: [ - { - price: 'price_id_1', - }, - { - price: 'price_id_2', - }, - ], - }, - ]); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: 'year', + }) + ); + + expect(createSubscriptionStub.callCount).to.equal(2); + expect(createSubscriptionStub.getCalls().flatMap((call) => call.args)).to.deep.equal([ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_licensed_1', + }, + ], + }, + { + customer: 'customer_id', + items: [ + { + price: 'price_id_metered_1', + }, + ], + }, + ]); + }); }); - it('should throw an error if the customer has more than one subscription', async () => { - const useCase = createUseCase(); - const customer = { ...mockCustomer, subscriptions: { data: [{}, {}] } }; + describe('One subscription', () => { + it('should create a new annual subscription and update the existing subscription if the customer has one subscription and billingInterval is month', async () => { + const useCase = createUseCase(); - try { await useCase.execute( UpsertSubscriptionCommand.create({ - customer: customer as any, + customer: mockCustomer as any, apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: 'month', }) ); - throw new Error('Should not reach here'); - } catch (e) { - expect(e.message).to.equal(`Customer with id: 'customer_id' has more than one subscription`); - } + + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_metered_1', + }, + { + price: 'price_id_licensed_1', + }, + ], + }, + ]); + }); + + it('should throw an error if the billing interval is not supported', async () => { + const useCase = createUseCase(); + const customer = { ...mockCustomer, subscriptions: { data: [{}, {}] } }; + + try { + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: 'invalid', + }) + ); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`Invalid billing interval: 'invalid'`); + } + }); + + it('should throw an error if the customer has more than two subscription', async () => { + const useCase = createUseCase(); + const customer = { ...mockCustomer, subscriptions: { data: [{}, {}, {}] } }; + + try { + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: 'month', + }) + ); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`Customer with id: 'customer_id' has more than two subscriptions`); + } + }); }); - }); - describe('Organization entity update', () => { - it('should update the organization with the new apiServiceLevel', async () => { - const useCase = createUseCase(); - - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomer as any, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - }) - ); - - expect(updateOrgStub.lastCall.args).to.deep.equal([ - { _id: 'organization_id' }, - { apiServiceLevel: ApiServiceLevelEnum.BUSINESS }, - ]); + describe('Organization entity update', () => { + it('should update the organization with the new apiServiceLevel', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomer as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }) + ); + + expect(updateOrgStub.lastCall.args).to.deep.equal([ + { _id: 'organization_id' }, + { apiServiceLevel: ApiServiceLevelEnum.BUSINESS }, + ]); + }); }); }); }); diff --git a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts index 2a1e6a0a4a0..b67bf4379fe 100644 --- a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts @@ -1,6 +1,7 @@ import * as sinon from 'sinon'; import { expect } from 'chai'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { StripeBillingIntervalEnum } from '@novu/ee-billing/src/stripe/types'; const mockSetupIntentSucceededEvent = { type: 'setup_intent.succeeded', @@ -13,6 +14,7 @@ const mockSetupIntentSucceededEvent = { livemode: false, metadata: { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, }, payment_method_options: null, payment_method_types: [] as string[], From 815a9bac0789420e198cbefadc3eb34f3950ea75 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 12:36:19 +0000 Subject: [PATCH 09/21] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index d2d07cff02a..33563b9e760 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit d2d07cff02a75ded023c109d378e65a28aaa426f +Subproject commit 33563b9e7605df401a63b99cb04586d09e668553 From c09f783cea941a35e629550bcbde945a66422d84 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 12:37:17 +0000 Subject: [PATCH 10/21] test(api): Fix create-usage-records test --- .../billing/create-usage-records.e2e-ee.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts b/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts index ab83de0bbc5..22811fbdbde 100644 --- a/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts @@ -3,13 +3,20 @@ import { Logger } from '@nestjs/common'; import * as sinon from 'sinon'; import { expect } from 'chai'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { StripeBillingIntervalEnum, StripeUsageTypeEnum } from '@novu/ee-billing/src/stripe/types'; -const mockBusinessSubscription = { +const mockMonthlyBusinessSubscription = { id: 'subscription_id', items: { data: [ - { id: 'item_id_usage_notifications', price: { lookup_key: 'business_usage_notifications' } }, - { id: 'item_id_flat', price: { lookup_key: 'business_flat_monthly' } }, + { + id: 'item_id_usage_notifications', + price: { lookup_key: 'business_usage_notifications', recurring: { usage_type: StripeUsageTypeEnum.METERED } }, + }, + { + id: 'item_id_flat', + price: { lookup_key: 'business_flat_monthly', recurring: { usage_type: StripeUsageTypeEnum.LICENSED } }, + }, ], }, }; @@ -53,7 +60,10 @@ describe('CreateUsageRecords', () => { }, ] as any); getFeatureFlagStub = sinon.stub(getFeatureFlagUsecase, 'execute').resolves(true); - upsertSubscriptionStub = sinon.stub(upsertSubscriptionUsecase, 'execute').resolves(mockBusinessSubscription as any); + upsertSubscriptionStub = sinon.stub(upsertSubscriptionUsecase, 'execute').resolves({ + licensed: mockMonthlyBusinessSubscription, + metered: mockMonthlyBusinessSubscription, + } as any); getCustomerStub = sinon.stub(getCustomerUsecase, 'execute').resolves({ id: 'customer_id', deleted: false, @@ -61,7 +71,7 @@ describe('CreateUsageRecords', () => { organizationId: 'organization_id', }, subscriptions: { - data: [mockBusinessSubscription], + data: [mockMonthlyBusinessSubscription], }, } as any); }); @@ -141,6 +151,7 @@ describe('CreateUsageRecords', () => { { customer: mockNoSubscriptionsCustomer, apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, }, ]); }); @@ -154,7 +165,7 @@ describe('CreateUsageRecords', () => { data: [ { created: mockSubscriptionCreated, - ...mockBusinessSubscription, + ...mockMonthlyBusinessSubscription, }, ], }, @@ -179,7 +190,7 @@ describe('CreateUsageRecords', () => { data: [ { created: mockSubscriptionCreated, - ...mockBusinessSubscription, + ...mockMonthlyBusinessSubscription, }, ], }, From 991a33dcc35e4ec40f7cf90475a998203ff8f1ee Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 13:52:37 +0000 Subject: [PATCH 11/21] test(api): Fix upsert-subscription tests --- .../billing/upsert-subscription.e2e-ee.ts | 236 +++++++++++++++--- 1 file changed, 197 insertions(+), 39 deletions(-) diff --git a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts index 49fab8da6a2..14c0a0ca05a 100644 --- a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts @@ -2,6 +2,7 @@ import * as sinon from 'sinon'; import { OrganizationRepository } from '@novu/dal'; import { expect } from 'chai'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { StripeBillingIntervalEnum, StripeUsageTypeEnum } from '@novu/ee-billing/src/stripe/types'; describe('UpsertSubscription', () => { const eeBilling = require('@novu/ee-billing'); @@ -15,16 +16,18 @@ describe('UpsertSubscription', () => { subscriptions: { create: () => {}, update: () => {}, + del: () => {}, }, }; let updateSubscriptionStub: sinon.SinonStub; let createSubscriptionStub: sinon.SinonStub; + let deleteSubscriptionStub: sinon.SinonStub; let getPricesStub: sinon.SinonStub; const repo = new OrganizationRepository(); let updateOrgStub: sinon.SinonStub; - const mockCustomer = { + const mockCustomerBase = { id: 'customer_id', deleted: false, metadata: { @@ -36,8 +39,11 @@ describe('UpsertSubscription', () => { id: 'subscription_id', items: { data: [ - { id: 'item_id_usage_notifications', price: { recurring: { usage_type: 'metered' } } }, - { id: 'item_id_flat', price: { recurring: { usage_type: 'licensed' } } }, + { + id: 'item_id_usage_notifications', + price: { recurring: { usage_type: StripeUsageTypeEnum.METERED } }, + }, + { id: 'item_id_flat', price: { recurring: { usage_type: StripeUsageTypeEnum.LICENSED } } }, ], }, }, @@ -49,18 +55,19 @@ describe('UpsertSubscription', () => { getPricesStub = sinon.stub(GetPrices.prototype, 'execute').resolves({ metered: [ { - id: 'price_id_metered_1', + id: 'price_id_metered', }, ], licensed: [ { - id: 'price_id_licensed_1', + id: 'price_id_licensed', }, ], } as any); updateOrgStub = sinon.stub(repo, 'update').resolves({ matched: 1, modified: 1 }); createSubscriptionStub = sinon.stub(stripeStub.subscriptions, 'create'); updateSubscriptionStub = sinon.stub(stripeStub.subscriptions, 'update'); + deleteSubscriptionStub = sinon.stub(stripeStub.subscriptions, 'del'); }); afterEach(() => { @@ -68,6 +75,7 @@ describe('UpsertSubscription', () => { updateOrgStub.reset(); createSubscriptionStub.reset(); updateSubscriptionStub.reset(); + deleteSubscriptionStub.reset(); }); const createUseCase = () => { @@ -77,16 +85,20 @@ describe('UpsertSubscription', () => { }; describe('Subscription upserting', () => { - describe('No subscriptions', () => { + describe('ZERO active subscriptions', () => { + const mockCustomerNoSubscriptions = { + ...mockCustomerBase, + subscriptions: { data: [] }, + }; + it('should create a single subscription with monthly prices when billingInterval is month', async () => { const useCase = createUseCase(); - const customer = { ...mockCustomer, subscriptions: { data: [] } }; await useCase.execute( UpsertSubscriptionCommand.create({ - customer: customer as any, + customer: mockCustomerNoSubscriptions as any, apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: 'month', + billingInterval: StripeBillingIntervalEnum.MONTH, }) ); @@ -95,10 +107,10 @@ describe('UpsertSubscription', () => { customer: 'customer_id', items: [ { - price: 'price_id_metered_1', + price: 'price_id_metered', }, { - price: 'price_id_licensed_1', + price: 'price_id_licensed', }, ], }, @@ -107,13 +119,12 @@ describe('UpsertSubscription', () => { it('should create two subscriptions, one with monthly prices and one with annual prices when billingInterval is year', async () => { const useCase = createUseCase(); - const customer = { ...mockCustomer, subscriptions: { data: [] } }; await useCase.execute( UpsertSubscriptionCommand.create({ - customer: customer as any, + customer: mockCustomerNoSubscriptions as any, apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: 'year', + billingInterval: StripeBillingIntervalEnum.YEAR, }) ); @@ -123,7 +134,7 @@ describe('UpsertSubscription', () => { customer: 'customer_id', items: [ { - price: 'price_id_licensed_1', + price: 'price_id_licensed', }, ], }, @@ -131,7 +142,7 @@ describe('UpsertSubscription', () => { customer: 'customer_id', items: [ { - price: 'price_id_metered_1', + price: 'price_id_metered', }, ], }, @@ -139,15 +150,61 @@ describe('UpsertSubscription', () => { }); }); - describe('One subscription', () => { - it('should create a new annual subscription and update the existing subscription if the customer has one subscription and billingInterval is month', async () => { + describe('ONE active subscription', () => { + const mockCustomerOneSubscription = { + ...mockCustomerBase, + subscriptions: { + data: [ + { + id: 'subscription_id', + items: { + data: [ + { + id: 'item_id_usage_notifications', + price: { recurring: { usage_type: StripeUsageTypeEnum.METERED } }, + }, + { id: 'item_id_flat', price: { recurring: { usage_type: StripeUsageTypeEnum.LICENSED } } }, + ], + }, + }, + ], + }, + }; + + it('should update the existing subscription if the customer has one subscription and billingInterval is month', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerOneSubscription as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); + + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ + 'subscription_id', + { + items: [ + { + price: 'price_id_metered', + }, + { + price: 'price_id_licensed', + }, + ], + }, + ]); + }); + + it('should create a new annual subscription and update the existing subscription if the customer has one subscription and billingInterval is year', async () => { const useCase = createUseCase(); await useCase.execute( UpsertSubscriptionCommand.create({ - customer: mockCustomer as any, + customer: mockCustomerOneSubscription as any, apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: 'month', + billingInterval: StripeBillingIntervalEnum.YEAR, }) ); @@ -156,44 +213,125 @@ describe('UpsertSubscription', () => { customer: 'customer_id', items: [ { - price: 'price_id_metered_1', + price: 'price_id_licensed', }, + ], + }, + ]); + + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ + 'subscription_id', + { + items: [ { - price: 'price_id_licensed_1', + price: 'price_id_metered', }, ], }, ]); }); + }); - it('should throw an error if the billing interval is not supported', async () => { + describe('TWO active subscriptions', () => { + const mockCustomerTwoSubscriptions = { + ...mockCustomerBase, + subscriptions: { + data: [ + { + id: 'subscription_id_1', + items: { + data: [{ id: 'item_id_flat', price: { recurring: { usage_type: StripeUsageTypeEnum.LICENSED } } }], + }, + }, + { + id: 'subscription_id_2', + items: { + data: [ + { + id: 'item_id_usage_notifications', + price: { recurring: { usage_type: StripeUsageTypeEnum.METERED } }, + }, + ], + }, + }, + ], + }, + }; + it('should delete the licensed subscription and update the metered subscription if the customer has two subscriptions and billingInterval is month', async () => { const useCase = createUseCase(); - const customer = { ...mockCustomer, subscriptions: { data: [{}, {}] } }; - try { - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: customer as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: 'invalid', - }) - ); - throw new Error('Should not reach here'); - } catch (e) { - expect(e.message).to.equal(`Invalid billing interval: 'invalid'`); - } + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerTwoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); + + expect(deleteSubscriptionStub.lastCall.args).to.deep.equal(['subscription_id_1', { prorate: true }]); + + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ + 'subscription_id_2', + { + items: [ + { + price: 'price_id_metered', + }, + { + price: 'price_id_licensed', + }, + ], + }, + ]); }); + it('should update the existing subscriptions if the customer has two subscriptions and billingInterval is year', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerTwoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); + + expect(updateSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ + [ + 'subscription_id_1', + { + items: [ + { + price: 'price_id_licensed', + }, + ], + }, + ], + [ + 'subscription_id_2', + { + items: [ + { + price: 'price_id_metered', + }, + ], + }, + ], + ]); + }); + }); + + describe('More than TWO active subscriptions', () => { it('should throw an error if the customer has more than two subscription', async () => { const useCase = createUseCase(); - const customer = { ...mockCustomer, subscriptions: { data: [{}, {}, {}] } }; + const customer = { ...mockCustomerBase, subscriptions: { data: [{}, {}, {}] } }; try { await useCase.execute( UpsertSubscriptionCommand.create({ customer: customer as any, apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: 'month', + billingInterval: StripeBillingIntervalEnum.MONTH, }) ); throw new Error('Should not reach here'); @@ -203,13 +341,33 @@ describe('UpsertSubscription', () => { }); }); + it('should throw an error if the billing interval is not supported', async () => { + const useCase = createUseCase(); + const customer = { ...mockCustomerBase, subscriptions: { data: [{}, {}] } }; + + try { + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: 'invalid', + }) + ); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`Invalid billing interval: 'invalid'`); + } + }); + describe('Organization entity update', () => { it('should update the organization with the new apiServiceLevel', async () => { const useCase = createUseCase(); + const customer = { ...mockCustomerBase, subscriptions: { data: [{}, {}] } }; await useCase.execute( UpsertSubscriptionCommand.create({ - customer: mockCustomer as any, + customer: mockCustomerBase as any, + billingInterval: StripeBillingIntervalEnum.MONTH, apiServiceLevel: ApiServiceLevelEnum.BUSINESS, }) ); From 251c7fd907d2992d9a574efb5c4e208ae7907150 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 13:53:28 +0000 Subject: [PATCH 12/21] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 33563b9e760..4e6ae1b77cb 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 33563b9e7605df401a63b99cb04586d09e668553 +Subproject commit 4e6ae1b77cb2187c26a42e731aa66457c0a83b6e From 90d91dbc049b202c219c26790581287e46e91f5c Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 14:11:48 +0000 Subject: [PATCH 13/21] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 4e6ae1b77cb..18aa03310e8 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 4e6ae1b77cb2187c26a42e731aa66457c0a83b6e +Subproject commit 18aa03310e893c12978010dc1314deb6966d236c From 7c1a9ec72820e3b7d532ef91a0a3470ce096ca76 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 16:13:33 +0000 Subject: [PATCH 14/21] test(api): Add tests for non-existing subscriptions --- .source | 2 +- .../billing/upsert-subscription.e2e-ee.ts | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/.source b/.source index 18aa03310e8..ebf26c4e3c7 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 18aa03310e893c12978010dc1314deb6966d236c +Subproject commit ebf26c4e3c7e58c53d34a608e535c76bce0b95cd diff --git a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts index 14c0a0ca05a..13ae79f823a 100644 --- a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts @@ -230,6 +230,29 @@ describe('UpsertSubscription', () => { }, ]); }); + + it('should throw an error if the licensed subscription is not found', async () => { + const useCase = createUseCase(); + const customer = { + ...mockCustomerBase, + subscriptions: { + data: [{ items: { data: [{ price: { recurring: { usage_type: 'invalid' } } }] } }], + }, + }; + + try { + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`No licensed subscription found for customer with id: 'customer_id'`); + } + }); }); describe('TWO active subscriptions', () => { @@ -319,6 +342,58 @@ describe('UpsertSubscription', () => { ], ]); }); + + it('should throw an error if the licensed subscription is not found', async () => { + const useCase = createUseCase(); + const customer = { + ...mockCustomerBase, + subscriptions: { + data: [ + { items: { data: [{ price: { recurring: { usage_type: StripeUsageTypeEnum.METERED } } }] } }, + { items: { data: [{ price: { recurring: { usage_type: 'invalid' } } }] } }, + ], + }, + }; + + try { + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`No licensed subscription found for customer with id: 'customer_id'`); + } + }); + + it('should throw an error if the metered subscription is not found', async () => { + const useCase = createUseCase(); + const customer = { + ...mockCustomerBase, + subscriptions: { + data: [ + { items: { data: [{ price: { recurring: { usage_type: StripeUsageTypeEnum.LICENSED } } }] } }, + { items: { data: [{ price: { recurring: { usage_type: 'invalid' } } }] } }, + ], + }, + }; + + try { + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: customer as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal(`No metered subscription found for customer with id: 'customer_id'`); + } + }); }); describe('More than TWO active subscriptions', () => { From 535e7ec24251c06839b4194f5c29928e83144329 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 19:13:42 +0000 Subject: [PATCH 15/21] test(api): Update billing tests for annual subscription --- .source | 2 +- .../billing/create-usage-records.e2e-ee.ts | 10 +-- .../billing/upsert-subscription.e2e-ee.ts | 71 +++++++++++-------- 3 files changed, 48 insertions(+), 35 deletions(-) diff --git a/.source b/.source index ebf26c4e3c7..471ea01a442 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit ebf26c4e3c7e58c53d34a608e535c76bce0b95cd +Subproject commit 471ea01a44237d96ad579ecdfce4e1f5a9b762e9 diff --git a/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts b/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts index 22811fbdbde..b1fe9837776 100644 --- a/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/create-usage-records.e2e-ee.ts @@ -156,16 +156,16 @@ describe('CreateUsageRecords', () => { ]); }); - it('should set the usage timestamp to the subscription start date if the subscription is new', async () => { + it('should set the usage timestamp to the subscription current period start if the subscription is new', async () => { const mockSubscriptionStartDate = new Date('2021-02-01T00:00:00Z'); - const mockSubscriptionCreated = mockSubscriptionStartDate.getTime() / 1000; + const mockSubscriptionCurrentPeriodStart = mockSubscriptionStartDate.getTime() / 1000; const mockUsageStartDate = new Date('2021-01-15T00:00:00Z'); getCustomerStub.resolves({ subscriptions: { data: [ { - created: mockSubscriptionCreated, ...mockMonthlyBusinessSubscription, + current_period_start: mockSubscriptionCurrentPeriodStart, }, ], }, @@ -178,7 +178,7 @@ describe('CreateUsageRecords', () => { }) ); - expect(createUsageRecordStub.lastCall.args[1].timestamp).to.equal(mockSubscriptionCreated); + expect(createUsageRecordStub.lastCall.args[1].timestamp).to.equal(mockSubscriptionCurrentPeriodStart); }); it('should set the usage timestamp to the usage start date if the subscription is not new', async () => { @@ -189,8 +189,8 @@ describe('CreateUsageRecords', () => { subscriptions: { data: [ { - created: mockSubscriptionCreated, ...mockMonthlyBusinessSubscription, + current_period_start: mockSubscriptionCreated, }, ], }, diff --git a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts index 13ae79f823a..1d9a56b829f 100644 --- a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts @@ -55,12 +55,14 @@ describe('UpsertSubscription', () => { getPricesStub = sinon.stub(GetPrices.prototype, 'execute').resolves({ metered: [ { - id: 'price_id_metered', + id: 'price_id_notifications', + recurring: { usage_type: StripeUsageTypeEnum.METERED }, }, ], licensed: [ { - id: 'price_id_licensed', + id: 'price_id_flat', + recurring: { usage_type: StripeUsageTypeEnum.LICENSED }, }, ], } as any); @@ -107,10 +109,10 @@ describe('UpsertSubscription', () => { customer: 'customer_id', items: [ { - price: 'price_id_metered', + price: 'price_id_notifications', }, { - price: 'price_id_licensed', + price: 'price_id_flat', }, ], }, @@ -129,23 +131,27 @@ describe('UpsertSubscription', () => { ); expect(createSubscriptionStub.callCount).to.equal(2); - expect(createSubscriptionStub.getCalls().flatMap((call) => call.args)).to.deep.equal([ - { - customer: 'customer_id', - items: [ - { - price: 'price_id_licensed', - }, - ], - }, - { - customer: 'customer_id', - items: [ - { - price: 'price_id_metered', - }, - ], - }, + expect(createSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ + [ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ], + [ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_notifications', + }, + ], + }, + ], ]); }); }); @@ -187,10 +193,12 @@ describe('UpsertSubscription', () => { { items: [ { - price: 'price_id_metered', + id: 'item_id_usage_notifications', + price: 'price_id_notifications', }, { - price: 'price_id_licensed', + id: 'item_id_flat', + price: 'price_id_flat', }, ], }, @@ -213,7 +221,7 @@ describe('UpsertSubscription', () => { customer: 'customer_id', items: [ { - price: 'price_id_licensed', + price: 'price_id_flat', }, ], }, @@ -224,7 +232,8 @@ describe('UpsertSubscription', () => { { items: [ { - price: 'price_id_metered', + id: 'item_id_usage_notifications', + price: 'price_id_notifications', }, ], }, @@ -298,10 +307,12 @@ describe('UpsertSubscription', () => { { items: [ { - price: 'price_id_metered', + id: 'item_id_flat', + price: 'price_id_flat', }, { - price: 'price_id_licensed', + id: 'item_id_usage_notifications', + price: 'price_id_notifications', }, ], }, @@ -325,7 +336,8 @@ describe('UpsertSubscription', () => { { items: [ { - price: 'price_id_licensed', + id: 'item_id_flat', + price: 'price_id_flat', }, ], }, @@ -335,7 +347,8 @@ describe('UpsertSubscription', () => { { items: [ { - price: 'price_id_metered', + id: 'item_id_usage_notifications', + price: 'price_id_notifications', }, ], }, From 9c4f4f57843802580c30bc020a08069b81a4e22b Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 19:16:23 +0000 Subject: [PATCH 16/21] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 471ea01a442..6f13c19da51 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 471ea01a44237d96ad579ecdfce4e1f5a9b762e9 +Subproject commit 6f13c19da5156a0d2997dabc308cc449e97a539f From 87d1f87a688049e991fe19c2bdb26e3f39cc8643 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:09:22 +0000 Subject: [PATCH 17/21] test(billing): Add upsert deletion assertions --- .../src/app/testing/billing/upsert-subscription.e2e-ee.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts index 1d9a56b829f..38bb486c7d6 100644 --- a/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-subscription.e2e-ee.ts @@ -235,6 +235,10 @@ describe('UpsertSubscription', () => { id: 'item_id_usage_notifications', price: 'price_id_notifications', }, + { + id: 'item_id_flat', + deleted: true, + }, ], }, ]); @@ -346,6 +350,10 @@ describe('UpsertSubscription', () => { 'subscription_id_2', { items: [ + { + id: 'item_id_flat', + deleted: true, + }, { id: 'item_id_usage_notifications', price: 'price_id_notifications', From 78e664f0739aff29d1cbb6c446b047f664c9893d Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:46:38 +0000 Subject: [PATCH 18/21] revert(api): Add chimera module --- apps/api/src/app.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index aca7f1d7e8a..cc6a90d62fd 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -45,7 +45,7 @@ const enterpriseImports = (): Array Date: Mon, 25 Mar 2024 21:50:44 +0000 Subject: [PATCH 19/21] chore: Remove comments and fix constants --- .../src/app/testing/billing/get-prices.e2e-ee.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts b/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts index baad2ef371a..900311442e9 100644 --- a/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/get-prices.e2e-ee.ts @@ -1,6 +1,7 @@ import * as sinon from 'sinon'; import { expect } from 'chai'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { StripeBillingIntervalEnum } from '@novu/ee-billing/src/stripe/types'; describe('GetPrices', () => { const eeBilling = require('@novu/ee-billing'); @@ -36,7 +37,7 @@ describe('GetPrices', () => { const expectedPrices = [ { apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: 'month', // Example billing interval + billingInterval: StripeBillingIntervalEnum.MONTH, prices: { licensed: ['free_flat_monthly'], metered: ['free_usage_notifications'], @@ -44,7 +45,7 @@ describe('GetPrices', () => { }, { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: 'month', // Example billing interval + billingInterval: StripeBillingIntervalEnum.MONTH, prices: { licensed: ['business_flat_monthly'], metered: ['business_usage_notifications'], @@ -52,7 +53,7 @@ describe('GetPrices', () => { }, { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: 'year', // Example billing interval + billingInterval: StripeBillingIntervalEnum.YEAR, prices: { licensed: ['business_flat_annually'], metered: ['business_usage_notifications'], @@ -60,7 +61,7 @@ describe('GetPrices', () => { }, { apiServiceLevel: ApiServiceLevelEnum.ENTERPRISE, - billingInterval: 'month', // Example billing interval + billingInterval: StripeBillingIntervalEnum.MONTH, prices: { licensed: ['enterprise_flat_monthly'], metered: ['enterprise_usage_notifications'], @@ -68,7 +69,7 @@ describe('GetPrices', () => { }, { apiServiceLevel: ApiServiceLevelEnum.ENTERPRISE, - billingInterval: 'year', // Example billing interval + billingInterval: StripeBillingIntervalEnum.YEAR, prices: { licensed: ['enterprise_flat_annually'], metered: ['enterprise_usage_notifications'], @@ -114,7 +115,7 @@ describe('GetPrices', () => { await useCase.execute( GetPricesCommand.create({ apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: 'month', + billingInterval: StripeBillingIntervalEnum.MONTH, }) ); } catch (e) { From b2b82dcafeac0baa9c73035e8bd89cc0b3c9b67a Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:52:18 +0000 Subject: [PATCH 20/21] chore: remove comments --- .../src/app/testing/billing/upsert-setup-intent.e2e-ee.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts index 78949d2bbec..0dfe771deaf 100644 --- a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts @@ -43,7 +43,7 @@ describe('Upsert setup intent', () => { status: 'succeeded', metadata: { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.MONTH, // Adjusted to include billingInterval + billingInterval: StripeBillingIntervalEnum.MONTH, }, }, ], @@ -94,7 +94,7 @@ describe('Upsert setup intent', () => { status: 'requires_payment_method', metadata: { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.YEAR, // Different from the command to trigger an update + billingInterval: StripeBillingIntervalEnum.YEAR, }, }, ], @@ -105,7 +105,7 @@ describe('Upsert setup intent', () => { organizationId: 'organization_id', userId: 'user_id', apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.MONTH, // Triggering an update due to different billingInterval + billingInterval: StripeBillingIntervalEnum.MONTH, }); expect( @@ -128,7 +128,7 @@ describe('Upsert setup intent', () => { status: 'requires_payment_method', metadata: { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.MONTH, // Matches the command, no update needed + billingInterval: StripeBillingIntervalEnum.MONTH, }, }, ], From 20f864c5ba03c8af9648b911c02ad48d410e0979 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:53:36 +0000 Subject: [PATCH 21/21] chore: remove comment --- apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts index 0dfe771deaf..6e439e52230 100644 --- a/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts @@ -1,7 +1,7 @@ import * as sinon from 'sinon'; import { expect } from 'chai'; import { ApiServiceLevelEnum } from '@novu/shared'; -import { StripeBillingIntervalEnum } from '@novu/ee-billing/src/stripe/types'; // Adjust the import path as necessary +import { StripeBillingIntervalEnum } from '@novu/ee-billing/src/stripe/types'; describe('Upsert setup intent', () => { const eeBilling = require('@novu/ee-billing');