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/25] 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/25] 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/25] 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 266e46cf764cb13f100a1d7532e71c057a8965bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 10:07:16 +0100 Subject: [PATCH 04/25] feat: add webhook listener for customer subscription updated --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index ca645fc736c..8de1f71d40b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit ca645fc736c287ad8ad74ea15ac9fae18afd1e71 +Subproject commit 8de1f71d40bafc80adef152cdc8afc15c118424a From 5f6c96d8293e4b056cffa14b5e5d68f8ab36180f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 10:09:43 +0100 Subject: [PATCH 05/25] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 8de1f71d40b..fda3d0652b7 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 8de1f71d40bafc80adef152cdc8afc15c118424a +Subproject commit fda3d0652b7c7b2f71d19f1df2a363cbd71cef7d From 6eee419c78cdd3ee5cdb7e167e2f966a0c645659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 11:02:26 +0100 Subject: [PATCH 06/25] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index fda3d0652b7..f985ad54300 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit fda3d0652b7c7b2f71d19f1df2a363cbd71cef7d +Subproject commit f985ad543009d7e68bd9e59a57ddb05efad287f6 From c119b4f2af5736ae0a24c901a15deacaba90cb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 22 Mar 2024 11:19:50 +0100 Subject: [PATCH 07/25] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index f985ad54300..7eacb1fa76c 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit f985ad543009d7e68bd9e59a57ddb05efad287f6 +Subproject commit 7eacb1fa76c84e4e718a0806fc53756e9fe63de0 From 2a3ed6cded31f70f9638296525c6740c3d62866a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Sun, 24 Mar 2024 07:06:09 +0100 Subject: [PATCH 08/25] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 7eacb1fa76c..6947a6f8fe1 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 7eacb1fa76c84e4e718a0806fc53756e9fe63de0 +Subproject commit 6947a6f8fe1bfa7ac0823bf0a9ff79025eb6d345 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 09/25] 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 57292c706a5928c5f67f93b8c8e33b88ed484086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Sun, 24 Mar 2024 07:41:06 +0100 Subject: [PATCH 10/25] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 6947a6f8fe1..b64b84657c0 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 6947a6f8fe1bfa7ac0823bf0a9ff79025eb6d345 +Subproject commit b64b84657c0a3adf739b04e72beeaea97f8c2bbd From 9c8cc360dd4decd2836747d6fc813ff27447c959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Mon, 25 Mar 2024 07:28:50 +0100 Subject: [PATCH 11/25] feat: add tests --- .source | 2 +- .../billing/annual-subscription.spec-ee.ts | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts diff --git a/.source b/.source index b64b84657c0..24e838d4745 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit b64b84657c0a3adf739b04e72beeaea97f8c2bbd +Subproject commit 24e838d47452e57280d5788f85169c2eb892f5c4 diff --git a/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts new file mode 100644 index 00000000000..ae680bac5e0 --- /dev/null +++ b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts @@ -0,0 +1,78 @@ +describe('Billing - Annual subscription', function () { + beforeEach(function () { + cy.initializeSession().as('session'); + }); + + it('should display monthly in modal as default', function () { + cy.intercept('POST', '**/billing/checkout', { + data: { + clientSecret: 'seti_1Mm8s8LkdIwHu7ix0OXBfTRG_secret_NXDICkPqPeiBTAFqWmkbff09lRmSVXe', + }, + }).as('checkout'); + + cy.visit('/settings/billing'); + + cy.getByTestId('upgrade-button').click(); + cy.wait(['@checkout']); + + cy.getByTestId('billing-interval-control-monthly') + .last() + .parent() + .should('have.class', 'mantine-SegmentedControl-labelActive'); + + cy.getByTestId('modal-monthly-pricing').should('exist'); + }); + + it('should display annually if it is selected before open modal', function () { + cy.intercept('POST', '**/billing/checkout', { + data: { + clientSecret: 'seti_1Mm8s8LkdIwHu7ix0OXBfTRG_secret_NXDICkPqPeiBTAFqWmkbff09lRmSVXe', + }, + }).as('checkout'); + + cy.visit('/settings/billing'); + + cy.getByTestId('billing-interval-control-annually').click(); + + cy.getByTestId('upgrade-button').click(); + cy.wait(['@checkout']); + + cy.getByTestId('billing-interval-control-annually') + .last() + .parent() + .should('have.class', 'mantine-SegmentedControl-labelActive'); + + cy.getByTestId('modal-anually-pricing').should('exist'); + }); + + it('should display billing page with billing interval control', function () { + cy.intercept('GET', '**/v1/organizations', (request) => { + request.reply((response) => { + if (!response.body.data) { + return response; + } + + response.body['data'] = [ + { + ...response.body.data[0], + apiServiceLevel: 'free', + }, + ]; + return response; + }); + }).as('organizations'); + + cy.visit('/settings/billing'); + + cy.getByTestId('billing-interval-control').should('exist'); + cy.getByTestId('billing-interval-price').should('have.text', '$250 month package / billed monthly'); + cy.getByTestId('billing-interval-control-annually').click(); + cy.getByTestId('billing-interval-control-annually') + .parent() + .should('have.class', 'mantine-SegmentedControl-labelActive'); + cy.getByTestId('billing-interval-price').should( + 'have.text', + `$${(2700).toLocaleString()} year package / billed annually` + ); + }); +}); From efa2e35c3702f2b686588fc356db7d3c91447c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Tue, 2 Apr 2024 10:46:21 +0200 Subject: [PATCH 12/25] fix: cspell error --- .cspell.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.cspell.json b/.cspell.json index f8502a905a6..7877a940759 100644 --- a/.cspell.json +++ b/.cspell.json @@ -611,14 +611,17 @@ "Pyroscope", "PYROSCOPE", "usecases", - "hbspt", + "hbspt", "prepopulating", "Vonage", "fieldtype", "usecase", "zulip", "uuidv", - "Vonage" + "Vonage", + "seti", + "NXDI", + "Wmkbff" ], "flagWords": [], "patterns": [ From 8a5e79667aec8bdc0a2ba345491cb4899066bbd5 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:19:25 +0100 Subject: [PATCH 13/25] revert: comment and whitespace --- apps/api/src/app.module.ts | 2 +- apps/api/src/app/testing/billing/upsert-setup-intent.e2e-ee.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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 { stubGetUser.rejects(new Error('User not found: user_id')); const useCase = createUseCase(); - try { await useCase.execute({ organizationId: 'organization_id', From c44410152f9beffbaad9b3c773a2df2e0b409c6c Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:24:06 +0100 Subject: [PATCH 14/25] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 25d4ce117b4..458beeafd9b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 25d4ce117b439d6c78d5f61f026b7edfd50f1e62 +Subproject commit 458beeafd9b0af4c83bd43cddf7d7f56a93c3190 From fb18d2d2dd6367ee96e5d9657db2ab8a0eeadfb9 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 16:57:07 +0100 Subject: [PATCH 15/25] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 458beeafd9b..2a7331ac950 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 458beeafd9b0af4c83bd43cddf7d7f56a93c3190 +Subproject commit 2a7331ac9503f740b99e8c7544fb1b2bbbbe9c87 From 068668474ef33ccfeccdd4c371d01bbb76e0980f Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 17:50:55 +0100 Subject: [PATCH 16/25] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 2a7331ac950..1909332f4fd 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 2a7331ac9503f740b99e8c7544fb1b2bbbbe9c87 +Subproject commit 1909332f4fdfc703dcb4aac5f8e315a63e2083a0 From 5fd95fa9b770a2ac1acaa4f4655b62b612e2add9 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 17:57:47 +0100 Subject: [PATCH 17/25] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 1909332f4fd..aee55d1c8ad 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 1909332f4fdfc703dcb4aac5f8e315a63e2083a0 +Subproject commit aee55d1c8ad3c001d659ba18a13d41e410307cfd From 82178d9a6b02fa218117ec0fa421ce5ed94ccd25 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 8 Apr 2024 18:03:24 +0100 Subject: [PATCH 18/25] fix: Ignore spelling in annual-subs spec --- .cspell.json | 3 --- apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.cspell.json b/.cspell.json index 0131b5620e3..78e40221ff3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -619,9 +619,6 @@ "zulip", "uuidv", "Vonage", - "seti", - "NXDI", - "Wmkbff", "runtimes", "cafebabe" ], diff --git a/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts index ae680bac5e0..fb20a5ada87 100644 --- a/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts +++ b/apps/web/cypress/tests/billing/annual-subscription.spec-ee.ts @@ -1,3 +1,4 @@ +/** cspell:disable */ describe('Billing - Annual subscription', function () { beforeEach(function () { cy.initializeSession().as('session'); From e6852c95220472ebc6f906275eb0195465cc7fd7 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 9 Apr 2024 18:16:21 +0100 Subject: [PATCH 19/25] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index aee55d1c8ad..0955775cef6 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit aee55d1c8ad3c001d659ba18a13d41e410307cfd +Subproject commit 0955775cef6abfb262e0837dafb9f562566575b4 From 22d0ee31c6a444149a75a4619f46597d8a425136 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Thu, 11 Apr 2024 12:27:23 +0100 Subject: [PATCH 20/25] test(api): Add upsert-subscription free trial test cases --- .../billing/upsert-subscription.e2e-ee.ts | 449 ++++++++++++------ 1 file changed, 297 insertions(+), 152 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 38bb486c7d6..2877cc3faa5 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 @@ -37,6 +37,7 @@ describe('UpsertSubscription', () => { data: [ { id: 'subscription_id', + billing_cycle_anchor: 123456789, items: { data: [ { @@ -93,66 +94,152 @@ describe('UpsertSubscription', () => { subscriptions: { data: [] }, }; - it('should create a single subscription with monthly prices when billingInterval is month', async () => { - const useCase = createUseCase(); - - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerNoSubscriptions as any, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.MONTH, - }) - ); - - expect(createSubscriptionStub.lastCall.args).to.deep.equal([ - { - customer: 'customer_id', - items: [ - { - price: 'price_id_notifications', - }, - { - price: 'price_id_flat', - }, - ], - }, - ]); - }); - - it('should create two subscriptions, one with monthly prices and one with annual prices when billingInterval is year', async () => { - const useCase = createUseCase(); + describe('Monthly Billing Interval', () => { + it('should create a single subscription with monthly prices', async () => { + const useCase = createUseCase(); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerNoSubscriptions as any, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - billingInterval: StripeBillingIntervalEnum.YEAR, - }) - ); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, + }) + ); - expect(createSubscriptionStub.callCount).to.equal(2); - expect(createSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ - [ + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ { customer: 'customer_id', items: [ + { + price: 'price_id_notifications', + }, { price: 'price_id_flat', }, ], }, - ], - [ + ]); + }); + + it('should set the trial configuration for the subscription when trial days are provided', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.MONTH, + trialPeriodDays: 10, + }) + ); + + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ { customer: 'customer_id', + trial_period_days: 10, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, items: [ { price: 'price_id_notifications', }, + { + price: 'price_id_flat', + }, ], }, - ], - ]); + ]); + }); + }); + + describe('Annual Billing Interval', () => { + it('should create two subscriptions, one with monthly prices and one with annual prices', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); + + expect(createSubscriptionStub.callCount).to.equal(2); + 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', + }, + ], + }, + ], + ]); + }); + + it('should set the trial configuration for both subscriptions when trial days are provided', async () => { + const useCase = createUseCase(); + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerNoSubscriptions as any, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.YEAR, + trialPeriodDays: 10, + }) + ); + + expect(createSubscriptionStub.callCount).to.equal(2); + expect(createSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ + [ + { + customer: 'customer_id', + trial_period_days: 10, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ], + [ + { + customer: 'customer_id', + trial_period_days: 10, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, + items: [ + { + price: 'price_id_notifications', + }, + ], + }, + ], + ]); + }); }); }); @@ -177,71 +264,124 @@ describe('UpsertSubscription', () => { }, }; - it('should update the existing subscription if the customer has one subscription and billingInterval is month', async () => { - const useCase = createUseCase(); + describe('Monthly Billing Interval', () => { + it('should update the existing subscription', async () => { + const useCase = createUseCase(); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerOneSubscription as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: StripeBillingIntervalEnum.MONTH, - }) - ); + 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: [ - { - id: 'item_id_usage_notifications', - price: 'price_id_notifications', - }, - { - id: 'item_id_flat', - price: 'price_id_flat', - }, - ], - }, - ]); + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ + 'subscription_id', + { + items: [ + { + id: 'item_id_usage_notifications', + price: 'price_id_notifications', + }, + { + id: 'item_id_flat', + price: 'price_id_flat', + }, + ], + }, + ]); + }); }); - 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(); + describe('Annual Billing Interval', () => { + it('should create a new annual subscription and update the existing subscription', async () => { + const useCase = createUseCase(); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerOneSubscription as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: StripeBillingIntervalEnum.YEAR, - }) - ); + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer: mockCustomerOneSubscription as any, + apiServiceLevel: ApiServiceLevelEnum.FREE, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); - expect(createSubscriptionStub.lastCall.args).to.deep.equal([ - { - customer: 'customer_id', - items: [ - { - price: 'price_id_flat', - }, - ], - }, - ]); + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ + { + customer: 'customer_id', + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ]); - expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ - 'subscription_id', - { - items: [ - { - id: 'item_id_usage_notifications', - price: 'price_id_notifications', - }, - { - id: 'item_id_flat', - deleted: true, + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ + 'subscription_id', + { + items: [ + { + id: 'item_id_usage_notifications', + price: 'price_id_notifications', + }, + { + id: 'item_id_flat', + deleted: true, + }, + ], + }, + ]); + }); + + it('should set the trial configuration for the newly created annual subscription from the existing licensed subscription', async () => { + const useCase = createUseCase(); + const customer = { + ...mockCustomerBase, + subscriptions: { + data: [ + { + id: 'subscription_id', + trial_end: 123456789, + items: { + data: [ + { + id: 'item_id_usage_notifications', + price: { recurring: { usage_type: StripeUsageTypeEnum.METERED } }, + }, + { id: 'item_id_flat', price: { recurring: { usage_type: StripeUsageTypeEnum.LICENSED } } }, + ], + }, + }, + ], + }, + }; + + await useCase.execute( + UpsertSubscriptionCommand.create({ + customer, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + billingInterval: StripeBillingIntervalEnum.YEAR, + }) + ); + + expect(createSubscriptionStub.lastCall.args).to.deep.equal([ + { + customer: 'customer_id', + trial_end: 123456789, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, }, - ], - }, - ]); + items: [ + { + price: 'price_id_flat', + }, + ], + }, + ]); + }); }); it('should throw an error if the licensed subscription is not found', async () => { @@ -293,66 +433,28 @@ describe('UpsertSubscription', () => { ], }, }; - 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(); - 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: [ - { - id: 'item_id_flat', - price: 'price_id_flat', - }, - { - id: 'item_id_usage_notifications', - price: 'price_id_notifications', - }, - ], - }, - ]); - }); + describe('Monthly Billing Interval', () => { + it('should delete the licensed subscription and update the metered subscription', async () => { + const useCase = createUseCase(); - 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.MONTH, + }) + ); - await useCase.execute( - UpsertSubscriptionCommand.create({ - customer: mockCustomerTwoSubscriptions as any, - apiServiceLevel: ApiServiceLevelEnum.FREE, - billingInterval: StripeBillingIntervalEnum.YEAR, - }) - ); + expect(deleteSubscriptionStub.lastCall.args).to.deep.equal(['subscription_id_1', { prorate: true }]); - expect(updateSubscriptionStub.getCalls().map((call) => call.args)).to.deep.equal([ - [ - 'subscription_id_1', - { - items: [ - { - id: 'item_id_flat', - price: 'price_id_flat', - }, - ], - }, - ], - [ + expect(updateSubscriptionStub.lastCall.args).to.deep.equal([ 'subscription_id_2', { items: [ { id: 'item_id_flat', - deleted: true, + price: 'price_id_flat', }, { id: 'item_id_usage_notifications', @@ -360,8 +462,51 @@ describe('UpsertSubscription', () => { }, ], }, - ], - ]); + ]); + }); + }); + + describe('Annual Billing Interval', () => { + it('should update the existing subscriptions', 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: [ + { + id: 'item_id_flat', + price: 'price_id_flat', + }, + ], + }, + ], + [ + 'subscription_id_2', + { + items: [ + { + id: 'item_id_flat', + deleted: true, + }, + { + id: 'item_id_usage_notifications', + price: 'price_id_notifications', + }, + ], + }, + ], + ]); + }); }); it('should throw an error if the licensed subscription is not found', async () => { From 9ad5cd903330d12a091504ad358ba3d94e15ddda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Fri, 12 Apr 2024 07:08:47 +0200 Subject: [PATCH 21/25] fix: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 0955775cef6..d97044cf381 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 0955775cef6abfb262e0837dafb9f562566575b4 +Subproject commit d97044cf38148cd560831e7106d5e2f61d20d60a From bfaf4aa96d9aa3ff882e125170f8bbba793f496c Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 12 Apr 2024 12:35:25 +0100 Subject: [PATCH 22/25] test(api): Fix setup intent webhooks test --- apps/api/src/app/testing/billing/webhook.e2e-ee.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 b67bf4379fe..33fbf2c11b1 100644 --- a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts @@ -63,6 +63,9 @@ describe('Stripe webhooks', () => { let verifyCustomerStub: sinon.SinonStub; let upsertSubscriptionStub: sinon.SinonStub; + const analyticsServiceStub = { + track: sinon.stub(), + }; beforeEach(() => { verifyCustomerStub = sinon.stub(VerifyCustomer.prototype, 'execute').resolves({ @@ -84,7 +87,8 @@ describe('Stripe webhooks', () => { organization: { _id: 'organization_id', apiServiceLevel: ApiServiceLevelEnum.FREE }, } as any); upsertSubscriptionStub = sinon.stub(UpsertSubscription.prototype, 'execute').resolves({ - id: 'subscription_id', + licensed: { id: 'licensed_subscription_id' }, + metered: { id: 'metered_subscription_id' }, } as any); updateCustomerStub = sinon.stub(stripeStub.customers, 'update').resolves({}); }); @@ -93,7 +97,8 @@ describe('Stripe webhooks', () => { const handler = new SetupIntentSucceededHandler( stripeStub as any, { execute: verifyCustomerStub } as any, - { execute: upsertSubscriptionStub } as any + { execute: upsertSubscriptionStub } as any, + analyticsServiceStub as any ); return handler; From 65f5d405e54ca1f6ed679e96796b4d74ac22836c Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Fri, 12 Apr 2024 17:27:01 +0100 Subject: [PATCH 23/25] test(api): Add customer.subscription.created tests --- .source | 2 +- .../src/app/testing/billing/webhook.e2e-ee.ts | 271 +++++++++++++++++- 2 files changed, 265 insertions(+), 8 deletions(-) diff --git a/.source b/.source index d97044cf381..dc7d549783b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit d97044cf38148cd560831e7106d5e2f61d20d60a +Subproject commit dc7d549783b0742ecadbb603419e1140962abf2f 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 33fbf2c11b1..084207b3ab8 100644 --- a/apps/api/src/app/testing/billing/webhook.e2e-ee.ts +++ b/apps/api/src/app/testing/billing/webhook.e2e-ee.ts @@ -49,6 +49,29 @@ describe('Stripe webhooks', () => { customers: { update: () => {}, }, + subscriptions: { + retrieve: () => + Promise.resolve({ + items: { + data: [ + { + id: 'subscription_id', + items: { data: [{ id: 'item_id_usage_notifications' }, { id: 'item_id_flat' }] }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }, + }, + }, + }, + ], + }, + }), + }, }; const eeBilling = require('@novu/ee-billing'); @@ -56,7 +79,8 @@ describe('Stripe webhooks', () => { throw new Error('ee-billing does not exist'); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { SetupIntentSucceededHandler, UpsertSubscription, VerifyCustomer } = eeBilling; + const { SetupIntentSucceededHandler, CustomerSubscriptionCreatedHandler, UpsertSubscription, VerifyCustomer } = + eeBilling; describe('setup_intent.succeeded', () => { let updateCustomerStub: sinon.SinonStub; @@ -148,14 +172,247 @@ describe('Stripe webhooks', () => { }); describe('customer.subscription.created', () => { - it('Should handle customer.subscription.created event with known organization', async () => { - // @TODO: Implement test - expect(true).to.equal(true); + let verifyCustomerStub: sinon.SinonStub; + const organizationRepositoryStub = { + update: sinon.stub().resolves({ matched: 1, modified: 1 }), + }; + const analyticsServiceStub = { + track: sinon.stub(), + }; + + beforeEach(() => { + verifyCustomerStub = sinon.stub(VerifyCustomer.prototype, 'execute').resolves({ + customer: { + id: 'customer_id', + deleted: false, + metadata: { + organizationId: 'organization_id', + }, + }, + organization: { _id: 'organization_id', apiServiceLevel: ApiServiceLevelEnum.FREE }, + } as any); }); - it('Should exit early with unknown organization', async () => { - // @TODO: Implement test - expect(true).to.equal(true); + afterEach(() => { + organizationRepositoryStub.update.reset(); + }); + + const createHandler = () => { + const handler = new CustomerSubscriptionCreatedHandler( + stripeStub as any, + { execute: verifyCustomerStub } as any, + organizationRepositoryStub, + analyticsServiceStub as any + ); + + return handler; + }; + + it('should handle event with known organization', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + const handler = createHandler(); + await handler.handle(event); + + expect( + organizationRepositoryStub.update.calledWith( + { _id: 'organization_id' }, + { apiServiceLevel: ApiServiceLevelEnum.BUSINESS } + ) + ).to.be.true; + }); + + it('should exit early with unknown organization', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + verifyCustomerStub.resolves({ + organization: null, + customer: { id: 'customer_id', metadata: { organizationId: 'org_id' } }, + }); + + const handler = createHandler(); + await handler.handle(event); + + expect(organizationRepositoryStub.update.called).to.be.false; + }); + + it('should handle event with known organization and licensed subscription', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }, + }, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + const handler = createHandler(); + await handler.handle(event); + + expect( + organizationRepositoryStub.update.calledWith( + { _id: 'organization_id' }, + { apiServiceLevel: ApiServiceLevelEnum.BUSINESS } + ) + ).to.be.true; + }); + + it('should exit early with known organization and metered subscription', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + price: { + recurring: { + usage_type: 'metered', + }, + product: { + metadata: { + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }, + }, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + const handler = createHandler(); + await handler.handle(event); + + expect( + organizationRepositoryStub.update.calledWith( + { _id: 'organization_id' }, + { apiServiceLevel: ApiServiceLevelEnum.BUSINESS } + ) + ).to.be.true; + }); + + it('should exit early with known organization and invalid apiServiceLevel', async () => { + const event = { + data: { + object: { + id: 'sub_123', + customer: 'cus_123', + items: [ + { + id: 'si_123', + data: { + plan: { + interval: StripeBillingIntervalEnum.MONTH, + }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: 'invalid', + }, + }, + }, + }, + }, + ], + }, + }, + created: 1234567890, + }; + + stripeStub.subscriptions.retrieve = () => + Promise.resolve({ + items: { + data: [ + { + id: 'subscription_id', + items: { data: [{ id: 'item_id_usage_notifications' }, { id: 'item_id_flat' }] }, + price: { + recurring: { + usage_type: 'licensed', + }, + product: { + metadata: { + apiServiceLevel: 'invalid' as any, + }, + }, + }, + }, + ], + }, + }); + + const handler = createHandler(); + await handler.handle(event); + + expect(organizationRepositoryStub.update.called).to.be.false; }); }); }); From 8c5991dcfb03a9361da40c7688674ca25ce38993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= Date: Mon, 15 Apr 2024 13:08:37 +0200 Subject: [PATCH 24/25] feat: update source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index dc7d549783b..bca162176d5 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit dc7d549783b0742ecadbb603419e1140962abf2f +Subproject commit bca162176d50b988e5a67658f18b92b4f2775185 From 9a6a136817774d7c04c2b150982bd41fbd5d9fb1 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:08:42 +0100 Subject: [PATCH 25/25] chore: Update submodule --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index bca162176d5..f4c7ef42b87 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit bca162176d50b988e5a67658f18b92b4f2775185 +Subproject commit f4c7ef42b873d5c3a67d561f36258bb313414a91