From 8f0847a086d34d6c44ad06d95948d59bb3eeb090 Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre Date: Thu, 2 Jan 2025 13:41:27 +0100 Subject: [PATCH 1/4] add some tests in billing --- .eslintrc.cjs | 6 +- .vscode/settings.json | 4 + nx.json | 1 + .../filters/billing-api-exception.filter.ts | 6 + ...stripe-valid-product-metadata.util.spec.ts | 56 ++++ ...o-entitlement-repository-data.util.spec.ts | 84 ++++++ ...data-to-meter-repository-data.util.spec.ts | 94 ++++++ ...data-to-price-repository-data.util.spec.ts | 219 ++++++++++++++ ...vent-to-price-repository-data.util.spec.ts | 234 +++++++++++++++ ...ta-to-product-repository-data.util.spec.ts | 86 ++++++ ...nt-to-product-repository-data.util.spec.ts | 89 ++++++ ...t-to-customer-repository-data.util.spec.ts | 85 ++++++ ...-subscription-repository-data.util.spec.ts | 197 ++++++++++++ .../billing-controller.integration-spec.ts | 280 ++++++++++++++++++ .../test/integration/utils/create-app.ts | 27 +- 15 files changed, 1466 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-data-to-meter-repository-data.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-data-to-price-repository-data.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-data-to-product-repository-data.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts create mode 100644 packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 95f91b2a78be..632eac421525 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -96,7 +96,11 @@ module.exports = { rules: {}, }, { - files: ['*.spec.@(ts|tsx|js|jsx)', '*.test.@(ts|tsx|js|jsx)'], + files: [ + '*.spec.@(ts|tsx|js|jsx)', + '*.integration-spec.@(ts|tsx|js|jsx)', + '*.test.@(ts|tsx|js|jsx)', + ], env: { jest: true, }, diff --git a/.vscode/settings.json b/.vscode/settings.json index 54fb6fe8a338..6e547541cd6b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,7 +46,11 @@ "**/.yarn": true, }, "eslint.debug": true, +<<<<<<< Updated upstream "files.associations": { ".cursorrules": "markdown" } +======= + "jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}" +>>>>>>> Stashed changes } diff --git a/nx.json b/nx.json index d73a57ed16e1..6e477b4fcd60 100644 --- a/nx.json +++ b/nx.json @@ -14,6 +14,7 @@ "!{projectRoot}/**/tsconfig.spec.json", "!{projectRoot}/**/*.test.(ts|tsx)", "!{projectRoot}/**/*.spec.(ts|tsx)", + "!{projectRoot}/**/*.integration-spec.ts", "!{projectRoot}/**/__tests__/*" ], "production": [ diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts index ace8707ee9ca..55bb32ffac58 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts @@ -26,6 +26,12 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter { response, 404, ); + case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 404, + ); default: return this.httpExceptionHandlerService.handleError( exception, diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts new file mode 100644 index 000000000000..0cbc5f5d87a4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/is-stripe-valid-product-metadata.util.spec.ts @@ -0,0 +1,56 @@ +import Stripe from 'stripe'; + +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util'; +describe('isStripeValidProductMetadata', () => { + it('should return true if metadata is empty', () => { + const metadata: Stripe.Metadata = {}; + + expect(isStripeValidProductMetadata(metadata)).toBe(true); + }); + it('should return true if metadata has the correct keys with correct values', () => { + const metadata: Stripe.Metadata = { + planKey: BillingPlanKey.PRO, + priceUsageBased: BillingUsageType.METERED, + }; + + expect(isStripeValidProductMetadata(metadata)).toBe(true); + }); + + it('should return true if metadata has extra keys', () => { + const metadata: Stripe.Metadata = { + planKey: BillingPlanKey.ENTERPRISE, + priceUsageBased: BillingUsageType.METERED, + randomKey: 'randomValue', + }; + + expect(isStripeValidProductMetadata(metadata)).toBe(true); + }); + + it('should return false if metadata has invalid keys', () => { + const metadata: Stripe.Metadata = { + planKey: 'invalid', + priceUsageBased: BillingUsageType.METERED, + }; + + expect(isStripeValidProductMetadata(metadata)).toBe(false); + }); + + it('should return false if metadata has invalid values', () => { + const metadata: Stripe.Metadata = { + planKey: BillingPlanKey.PRO, + priceUsageBased: 'invalid', + }; + + expect(isStripeValidProductMetadata(metadata)).toBe(false); + }); + + it('should return false if the metadata does not have the required keys', () => { + const metadata: Stripe.Metadata = { + randomKey: 'randomValue', + }; + + expect(isStripeValidProductMetadata(metadata)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts new file mode 100644 index 000000000000..36b87bdd51ea --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util.spec.ts @@ -0,0 +1,84 @@ +import Stripe from 'stripe'; + +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; +import { transformStripeEntitlementUpdatedEventToEntitlementRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-entitlement-updated-event-to-entitlement-repository-data.util'; + +describe('transformStripeEntitlementUpdatedEventToEntitlementRepositoryData', () => { + it('should return the SSO key with true value', () => { + const data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data = { + object: { + customer: 'cus_123', + entitlements: { + data: [ + { + lookup_key: 'SSO', + feature: 'SSO', + livemode: false, + id: 'ent_123', + object: 'entitlements.active_entitlement', + }, + ], + object: 'list', + has_more: false, + url: '', + }, + livemode: false, + object: 'entitlements.active_entitlement_summary', + }, + }; + + const result = + transformStripeEntitlementUpdatedEventToEntitlementRepositoryData( + 'workspaceId', + data, + ); + + expect(result).toEqual([ + { + workspaceId: 'workspaceId', + key: BillingEntitlementKey.SSO, + value: true, + stripeCustomerId: 'cus_123', + }, + ]); + }); + + it('should return the SSO key with false value,should only render the values that are listed in BillingEntitlementKeys', () => { + const data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data = { + object: { + customer: 'cus_123', + entitlements: { + data: [ + { + id: 'ent_123', + object: 'entitlements.active_entitlement', + lookup_key: 'DIFFERENT_KEY', + feature: 'DIFFERENT_FEATURE', + livemode: false, + }, + ], + object: 'list', + has_more: false, + url: '', + }, + livemode: false, + object: 'entitlements.active_entitlement_summary', + }, + }; + + const result = + transformStripeEntitlementUpdatedEventToEntitlementRepositoryData( + 'workspaceId', + data, + ); + + expect(result).toEqual([ + { + workspaceId: 'workspaceId', + key: BillingEntitlementKey.SSO, + value: false, + stripeCustomerId: 'cus_123', + }, + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-data-to-meter-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-data-to-meter-repository-data.util.spec.ts new file mode 100644 index 000000000000..912bc557c511 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-meter-data-to-meter-repository-data.util.spec.ts @@ -0,0 +1,94 @@ +import Stripe from 'stripe'; + +import { BillingMeterEventTimeWindow } from 'src/engine/core-modules/billing/enums/billing-meter-event-time-window.enum'; +import { BillingMeterStatus } from 'src/engine/core-modules/billing/enums/billing-meter-status.enum'; +import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util'; + +describe('transformStripeMeterDataToMeterRepositoryData', () => { + it('should return the correct data with customer mapping', () => { + const data: Stripe.Billing.Meter = { + id: 'met_123', + object: 'billing.meter', + created: 1719859200, + display_name: 'Meter 1', + event_name: 'event_1', + status: 'active', + customer_mapping: { + event_payload_key: 'event_payload_key_1', + type: 'by_id', + }, + default_aggregation: { + formula: 'count', + }, + event_time_window: 'day', + livemode: false, + status_transitions: { + deactivated_at: null, + }, + updated: 1719859200, + value_settings: { + event_payload_key: 'event_payload_key_1', + }, + }; + + const result = transformStripeMeterDataToMeterRepositoryData(data); + + expect(result).toEqual({ + stripeMeterId: 'met_123', + displayName: 'Meter 1', + eventName: 'event_1', + status: BillingMeterStatus.ACTIVE, + customerMapping: { + event_payload_key: 'event_payload_key_1', + type: 'by_id', + }, + eventTimeWindow: BillingMeterEventTimeWindow.DAY, + valueSettings: { + event_payload_key: 'event_payload_key_1', + }, + }); + }); + it('should return the correct data with null values', () => { + const data: Stripe.Billing.Meter = { + id: 'met_1234', + object: 'billing.meter', + created: 1719859200, + display_name: 'Meter 2', + event_name: 'event_2', + status: 'inactive', + customer_mapping: { + event_payload_key: 'event_payload_key_2', + type: 'by_id', + }, + default_aggregation: { + formula: 'sum', + }, + event_time_window: null, + livemode: false, + status_transitions: { + deactivated_at: 1719859200, + }, + updated: 1719859200, + value_settings: { + event_payload_key: 'event_payload_key_2', + }, + }; + + const result = transformStripeMeterDataToMeterRepositoryData(data); + + expect(result).toEqual({ + stripeMeterId: 'met_1234', + displayName: 'Meter 2', + eventName: 'event_2', + status: BillingMeterStatus.INACTIVE, + customerMapping: { + event_payload_key: 'event_payload_key_2', + type: 'by_id', + }, + eventTimeWindow: undefined, + valueSettings: { + event_payload_key: 'event_payload_key_2', + }, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-data-to-price-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-data-to-price-repository-data.util.spec.ts new file mode 100644 index 000000000000..81e8e24df4fd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-data-to-price-repository-data.util.spec.ts @@ -0,0 +1,219 @@ +import Stripe from 'stripe'; + +import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum'; +import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum'; +import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum'; +import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { transformStripePriceDataToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util'; +describe('transformStripePriceDataToPriceRepositoryData', () => { + const createMockPrice = (overrides = {}): Stripe.Price => + ({ + id: 'price_123', + active: true, + product: 'prod_123', + currency: 'usd', + nickname: null, + tax_behavior: null, + type: 'recurring', + billing_scheme: 'per_unit', + unit_amount_decimal: '1000', + unit_amount: 1000, + transform_quantity: null, + recurring: { + usage_type: 'licensed', + interval: 'month', + meter: null, + }, + currency_options: null, + tiers: null, + tiers_mode: null, + ...overrides, + }) as unknown as Stripe.Price; + + it('should transform basic price data correctly', () => { + const mockPrice = createMockPrice(); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result).toEqual({ + stripePriceId: 'price_123', + active: true, + stripeProductId: 'prod_123', + stripeMeterId: null, + currency: 'USD', + nickname: undefined, + taxBehavior: undefined, + type: BillingPriceType.RECURRING, + billingScheme: BillingPriceBillingScheme.PER_UNIT, + unitAmountDecimal: '1000', + unitAmount: 1000, + transformQuantity: undefined, + usageType: BillingUsageType.LICENSED, + interval: SubscriptionInterval.Month, + currencyOptions: undefined, + tiers: undefined, + tiersMode: undefined, + recurring: { + usage_type: 'licensed', + interval: 'month', + meter: null, + }, + }); + }); + + describe('tax behavior transformations', () => { + it.each([ + ['exclusive', BillingPriceTaxBehavior.EXCLUSIVE], + ['inclusive', BillingPriceTaxBehavior.INCLUSIVE], + ['unspecified', BillingPriceTaxBehavior.UNSPECIFIED], + ])( + 'should transform tax behavior %s correctly', + (stripeTaxBehavior, expected) => { + const mockPrice = createMockPrice({ + tax_behavior: stripeTaxBehavior as Stripe.Price.TaxBehavior, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.taxBehavior).toBe(expected); + }, + ); + }); + + describe('price type transformations', () => { + it.each([ + ['one_time', BillingPriceType.ONE_TIME], + ['recurring', BillingPriceType.RECURRING], + ])('should transform price type %s correctly', (stripeType, expected) => { + const mockPrice = createMockPrice({ + type: stripeType as Stripe.Price.Type, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.type).toBe(expected); + }); + }); + + describe('billing scheme transformations', () => { + it.each([ + ['per_unit', BillingPriceBillingScheme.PER_UNIT], + ['tiered', BillingPriceBillingScheme.TIERED], + ])( + 'should transform billing scheme %s correctly', + (stripeScheme, expected) => { + const mockPrice = createMockPrice({ + billing_scheme: stripeScheme as Stripe.Price.BillingScheme, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.billingScheme).toBe(expected); + }, + ); + }); + + describe('recurring price configurations', () => { + it('should handle metered pricing with meter ID', () => { + const mockPrice = createMockPrice({ + recurring: { + usage_type: 'metered', + interval: 'month', + meter: 'meter_123', + }, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.stripeMeterId).toBe('meter_123'); + expect(result.usageType).toBe(BillingUsageType.METERED); + }); + + it.each([ + ['month', SubscriptionInterval.Month], + ['day', SubscriptionInterval.Day], + ['week', SubscriptionInterval.Week], + ['year', SubscriptionInterval.Year], + ])('should transform interval %s correctly', (stripeInterval, expected) => { + const mockPrice = createMockPrice({ + recurring: { + usage_type: 'licensed', + interval: stripeInterval as Stripe.Price.Recurring.Interval, + meter: null, + }, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.interval).toBe(expected); + }); + }); + + describe('tiered pricing configurations', () => { + const mockTiers = [ + { up_to: 10, unit_amount: 1000 }, + { up_to: 20, unit_amount: 800 }, + ]; + + it.each([ + ['graduated', BillingPriceTiersMode.GRADUATED], + ['volume', BillingPriceTiersMode.VOLUME], + ])( + 'should transform tiers mode %s correctly', + (stripeTiersMode, expected) => { + const mockPrice = createMockPrice({ + billing_scheme: 'tiered', + tiers: mockTiers, + tiers_mode: stripeTiersMode as Stripe.Price.TiersMode, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.tiersMode).toBe(expected); + expect(result.tiers).toEqual(mockTiers); + }, + ); + }); + + describe('optional fields handling', () => { + it('should handle transform quantity configuration', () => { + const transformQuantity = { + divide_by: 100, + round: 'up', + }; + const mockPrice = createMockPrice({ + transform_quantity: transformQuantity, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.transformQuantity).toEqual(transformQuantity); + }); + + it('should handle currency options', () => { + const currencyOptions = { + eur: { + unit_amount: 850, + unit_amount_decimal: '850', + }, + }; + const mockPrice = createMockPrice({ currency_options: currencyOptions }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.currencyOptions).toEqual(currencyOptions); + }); + + it('should handle null and undefined fields correctly', () => { + const mockPrice = createMockPrice({ + nickname: null, + unit_amount: null, + unit_amount_decimal: null, + transform_quantity: null, + tiers: null, + currency_options: null, + }); + const result = transformStripePriceDataToPriceRepositoryData(mockPrice); + + expect(result.nickname).toBeUndefined(); + expect(result.unitAmount).toBeUndefined(); + expect(result.unitAmountDecimal).toBeUndefined(); + expect(result.transformQuantity).toBeUndefined(); + expect(result.tiers).toBeUndefined(); + expect(result.currencyOptions).toBeUndefined(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts new file mode 100644 index 000000000000..5faa1385b36b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-price-event-to-price-repository-data.util.spec.ts @@ -0,0 +1,234 @@ +import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum'; +import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum'; +import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum'; +import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum'; +import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util'; + +describe('transformStripePriceEventToPriceRepositoryData', () => { + const createMockPriceData = (overrides = {}) => ({ + object: { + id: 'price_123', + active: true, + product: 'prod_123', + meter: null, + currency: 'usd', + nickname: null, + tax_behavior: null, + type: 'recurring', + billing_scheme: 'per_unit', + unit_amount_decimal: '1000', + unit_amount: 1000, + transform_quantity: null, + recurring: { + usage_type: 'licensed', + interval: 'month', + }, + currency_options: null, + tiers: null, + tiers_mode: null, + ...overrides, + }, + }); + + it('should transform basic price data correctly', () => { + const mockData = createMockPriceData(); + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result).toEqual({ + stripePriceId: 'price_123', + active: true, + stripeProductId: 'prod_123', + stripeMeterId: undefined, + currency: 'USD', + nickname: undefined, + taxBehavior: undefined, + type: BillingPriceType.RECURRING, + billingScheme: BillingPriceBillingScheme.PER_UNIT, + unitAmountDecimal: '1000', + unitAmount: 1000, + transformQuantity: undefined, + usageType: BillingUsageType.LICENSED, + interval: SubscriptionInterval.Month, + currencyOptions: undefined, + tiers: undefined, + tiersMode: undefined, + recurring: { + usage_type: 'licensed', + interval: 'month', + }, + }); + }); + + it('should handle all tax behaviors correctly', () => { + const taxBehaviors = [ + ['exclusive', BillingPriceTaxBehavior.EXCLUSIVE], + ['inclusive', BillingPriceTaxBehavior.INCLUSIVE], + ['unspecified', BillingPriceTaxBehavior.UNSPECIFIED], + ]; + + taxBehaviors.forEach(([stripeTaxBehavior, expectedTaxBehavior]) => { + const mockData = createMockPriceData({ + tax_behavior: stripeTaxBehavior, + }); + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.taxBehavior).toBe(expectedTaxBehavior); + }); + }); + + it('should handle all price types correctly', () => { + const priceTypes = [ + ['one_time', BillingPriceType.ONE_TIME], + ['recurring', BillingPriceType.RECURRING], + ]; + + priceTypes.forEach(([stripeType, expectedType]) => { + const mockData = createMockPriceData({ type: stripeType }); + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.type).toBe(expectedType); + }); + }); + + it('should handle all billing schemes correctly', () => { + const billingSchemes = [ + ['per_unit', BillingPriceBillingScheme.PER_UNIT], + ['tiered', BillingPriceBillingScheme.TIERED], + ]; + + billingSchemes.forEach(([stripeScheme, expectedScheme]) => { + const mockData = createMockPriceData({ billing_scheme: stripeScheme }); + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.billingScheme).toBe(expectedScheme); + }); + }); + + it('should handle all usage types correctly', () => { + const usageTypes = [ + ['licensed', BillingUsageType.LICENSED], + ['metered', BillingUsageType.METERED], + ]; + + usageTypes.forEach(([stripeUsageType, expectedUsageType]) => { + const mockData = createMockPriceData({ + recurring: { usage_type: stripeUsageType, interval: 'month' }, + }); + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.usageType).toBe(expectedUsageType); + }); + }); + + it('should handle all tiers modes correctly', () => { + const tiersModes = [ + ['graduated', BillingPriceTiersMode.GRADUATED], + ['volume', BillingPriceTiersMode.VOLUME], + ]; + + tiersModes.forEach(([stripeTiersMode, expectedTiersMode]) => { + const mockData = createMockPriceData({ tiers_mode: stripeTiersMode }); + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.tiersMode).toBe(expectedTiersMode); + }); + }); + + it('should handle all intervals correctly', () => { + const intervals = [ + ['month', SubscriptionInterval.Month], + ['day', SubscriptionInterval.Day], + ['week', SubscriptionInterval.Week], + ['year', SubscriptionInterval.Year], + ]; + + intervals.forEach(([stripeInterval, expectedInterval]) => { + const mockData = createMockPriceData({ + recurring: { usage_type: 'licensed', interval: stripeInterval }, + }); + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.interval).toBe(expectedInterval); + }); + }); + + it('should handle tiered pricing configuration', () => { + const mockTiers = [ + { up_to: 10, unit_amount: 1000 }, + { up_to: 20, unit_amount: 800 }, + ]; + + const mockData = createMockPriceData({ + billing_scheme: 'tiered', + tiers: mockTiers, + tiers_mode: 'graduated', + }); + + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.billingScheme).toBe(BillingPriceBillingScheme.TIERED); + expect(result.tiers).toEqual(mockTiers); + expect(result.tiersMode).toBe(BillingPriceTiersMode.GRADUATED); + }); + + it('should handle metered pricing with transform quantity', () => { + const mockTransformQuantity = { + divide_by: 100, + round: 'up', + }; + + const mockData = createMockPriceData({ + recurring: { + usage_type: 'metered', + interval: 'month', + meter: 'meter_123', + }, + transform_quantity: mockTransformQuantity, + }); + + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.stripeMeterId).toBe('meter_123'); + expect(result.usageType).toBe(BillingUsageType.METERED); + expect(result.transformQuantity).toEqual(mockTransformQuantity); + }); + + it('should handle currency options', () => { + const mockCurrencyOptions = { + eur: { + unit_amount: 850, + unit_amount_decimal: '850', + }, + }; + + const mockData = createMockPriceData({ + currency_options: mockCurrencyOptions, + }); + + const result = transformStripePriceEventToPriceRepositoryData( + mockData as any, + ); + + expect(result.currencyOptions).toEqual(mockCurrencyOptions); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-data-to-product-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-data-to-product-repository-data.util.spec.ts new file mode 100644 index 000000000000..875a19de032e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-data-to-product-repository-data.util.spec.ts @@ -0,0 +1,86 @@ +import Stripe from 'stripe'; + +import { transformStripeProductDataToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-data-to-product-repository-data.util'; +describe('transformStripeProductDataToProductRepositoryData', () => { + it('should return the correct data', () => { + const data: Stripe.Product = { + id: 'prod_123', + name: 'Product 1', + active: true, + description: 'Description 1', + images: ['image1.jpg', 'image2.jpg'], + marketing_features: [ + { + name: 'feature1', + }, + ], + created: 1719859200, + updated: 1719859200, + type: 'service', + livemode: false, + package_dimensions: null, + shippable: false, + object: 'product', + default_price: 'price_123', + unit_label: 'Unit', + url: 'https://example.com', + tax_code: 'tax_code_1', + metadata: { key: 'value' }, + }; + + const result = transformStripeProductDataToProductRepositoryData(data); + + expect(result).toEqual({ + stripeProductId: 'prod_123', + name: 'Product 1', + active: true, + description: 'Description 1', + images: ['image1.jpg', 'image2.jpg'], + marketingFeatures: [{ name: 'feature1' }], + defaultStripePriceId: 'price_123', + unitLabel: 'Unit', + url: 'https://example.com', + taxCode: 'tax_code_1', + metadata: { key: 'value' }, + }); + }); + + it('should return the correct data with null values', () => { + const data: Stripe.Product = { + id: 'prod_456', + name: 'Product 2', + active: false, + description: '', + images: [], + created: 1719859200, + updated: 1719859200, + type: 'service', + livemode: false, + package_dimensions: null, + shippable: false, + object: 'product', + marketing_features: [], + default_price: null, + unit_label: null, + url: null, + tax_code: null, + metadata: {}, + }; + + const result = transformStripeProductDataToProductRepositoryData(data); + + expect(result).toEqual({ + stripeProductId: 'prod_456', + name: 'Product 2', + active: false, + description: '', + images: [], + marketingFeatures: [], + defaultStripePriceId: undefined, + unitLabel: undefined, + url: undefined, + taxCode: undefined, + metadata: {}, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts new file mode 100644 index 000000000000..2a858a781bdd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-product-event-to-product-repository-data.util.spec.ts @@ -0,0 +1,89 @@ +import Stripe from 'stripe'; + +import { transformStripeProductEventToProductRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-product-event-to-product-repository-data.util'; + +describe('transformStripeProductEventToProductRepositoryData', () => { + it('should return the correct data', () => { + const data: Stripe.ProductCreatedEvent.Data = { + object: { + id: 'prod_123', + name: 'Product 1', + active: true, + description: 'Description 1', + images: ['image1.jpg', 'image2.jpg'], + marketing_features: [ + { + name: 'feature1', + }, + ], + created: 1719859200, + updated: 1719859200, + type: 'service', + livemode: false, + package_dimensions: null, + shippable: false, + object: 'product', + default_price: 'price_123', + unit_label: 'Unit', + url: 'https://example.com', + tax_code: 'tax_code_1', + metadata: { key: 'value' }, + }, + }; + + const result = transformStripeProductEventToProductRepositoryData(data); + + expect(result).toEqual({ + stripeProductId: 'prod_123', + name: 'Product 1', + active: true, + description: 'Description 1', + images: ['image1.jpg', 'image2.jpg'], + marketingFeatures: [{ name: 'feature1' }], + defaultStripePriceId: 'price_123', + unitLabel: 'Unit', + url: 'https://example.com', + taxCode: 'tax_code_1', + }); + }); + + it('should return the correct data with null values', () => { + const data: Stripe.ProductUpdatedEvent.Data = { + object: { + id: 'prod_456', + name: 'Product 2', + object: 'product', + active: false, + description: '', + images: [], + created: 1719859200, + updated: 1719859200, + type: 'service', + livemode: false, + package_dimensions: null, + shippable: false, + marketing_features: [], + default_price: null, + unit_label: null, + url: null, + tax_code: null, + metadata: {}, + }, + }; + + const result = transformStripeProductEventToProductRepositoryData(data); + + expect(result).toEqual({ + stripeProductId: 'prod_456', + name: 'Product 2', + active: false, + description: '', + images: [], + marketingFeatures: [], + defaultStripePriceId: undefined, + unitLabel: undefined, + url: undefined, + taxCode: undefined, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts new file mode 100644 index 000000000000..be841607b25a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-customer-repository-data.util.spec.ts @@ -0,0 +1,85 @@ +import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util'; + +describe('transformStripeSubscriptionEventToCustomerRepositoryData', () => { + const mockWorkspaceId = 'workspace_123'; + const mockTimestamp = 1672531200; // 2023-01-01 00:00:00 UTC + + const createMockSubscriptionData = (overrides = {}) => ({ + object: { + id: 'sub_123', + customer: 'cus_123', + status: 'active', + items: { + data: [ + { + plan: { + interval: 'month', + }, + }, + ], + }, + cancel_at_period_end: false, + currency: 'usd', + current_period_end: mockTimestamp, + current_period_start: mockTimestamp - 2592000, // 30 days before end + metadata: {}, + collection_method: 'charge_automatically', + automatic_tax: null, + cancellation_details: null, + ended_at: null, + trial_start: null, + trial_end: null, + cancel_at: null, + canceled_at: null, + ...overrides, + }, + }); + + it('should transform basic customer data correctly', () => { + const mockData = createMockSubscriptionData('cus_123'); + + const result = transformStripeSubscriptionEventToCustomerRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result).toEqual({ + workspaceId: 'workspace_123', + stripeCustomerId: 'cus_123', + }); + }); + + it('should work with different subscription event types', () => { + const mockData = createMockSubscriptionData('cus_123'); + + // Test with different event types (they should all transform the same way) + ['updated', 'created', 'deleted'].forEach(() => { + const result = transformStripeSubscriptionEventToCustomerRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result).toEqual({ + workspaceId: 'workspace_123', + stripeCustomerId: 'cus_123', + }); + }); + }); + + it('should handle different workspace IDs', () => { + const mockData = createMockSubscriptionData('cus_123'); + const testWorkspaces = ['workspace_1', 'workspace_2', 'workspace_abc']; + + testWorkspaces.forEach((testWorkspaceId) => { + const result = transformStripeSubscriptionEventToCustomerRepositoryData( + testWorkspaceId, + mockData as any, + ); + + expect(result).toEqual({ + workspaceId: testWorkspaceId, + stripeCustomerId: 'cus_123', + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts new file mode 100644 index 000000000000..6c4a6cdb0eb1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/transform-stripe-subscription-event-to-subscription-repository-data.util.spec.ts @@ -0,0 +1,197 @@ +import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; +import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util'; + +describe('transformStripeSubscriptionEventToSubscriptionRepositoryData', () => { + const mockWorkspaceId = 'workspace-123'; + const mockTimestamp = 1672531200; // 2023-01-01 00:00:00 UTC + + const createMockSubscriptionData = (overrides = {}) => ({ + object: { + id: 'sub_123', + customer: 'cus_123', + status: 'active', + items: { + data: [ + { + plan: { + interval: 'month', + }, + }, + ], + }, + cancel_at_period_end: false, + currency: 'usd', + current_period_end: mockTimestamp, + current_period_start: mockTimestamp - 2592000, // 30 days before end + metadata: {}, + collection_method: 'charge_automatically', + automatic_tax: null, + cancellation_details: null, + ended_at: null, + trial_start: null, + trial_end: null, + cancel_at: null, + canceled_at: null, + ...overrides, + }, + }); + + it('should transform basic subscription data correctly', () => { + const mockData = createMockSubscriptionData(); + const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result).toEqual({ + workspaceId: mockWorkspaceId, + stripeCustomerId: 'cus_123', + stripeSubscriptionId: 'sub_123', + status: SubscriptionStatus.Active, + interval: 'month', + cancelAtPeriodEnd: false, + currency: 'USD', + currentPeriodEnd: new Date(mockTimestamp * 1000), + currentPeriodStart: new Date((mockTimestamp - 2592000) * 1000), + metadata: {}, + collectionMethod: + BillingSubscriptionCollectionMethod.CHARGE_AUTOMATICALLY, + automaticTax: undefined, + cancellationDetails: undefined, + endedAt: undefined, + trialStart: undefined, + trialEnd: undefined, + cancelAt: undefined, + canceledAt: undefined, + }); + }); + + it('should handle all subscription statuses correctly', () => { + const statuses = [ + ['active', SubscriptionStatus.Active], + ['canceled', SubscriptionStatus.Canceled], + ['incomplete', SubscriptionStatus.Incomplete], + ['incomplete_expired', SubscriptionStatus.IncompleteExpired], + ['past_due', SubscriptionStatus.PastDue], + ['paused', SubscriptionStatus.Paused], + ['trialing', SubscriptionStatus.Trialing], + ['unpaid', SubscriptionStatus.Unpaid], + ]; + + statuses.forEach(([stripeStatus, expectedStatus]) => { + const mockData = createMockSubscriptionData({ + status: stripeStatus, + }); + const result = + transformStripeSubscriptionEventToSubscriptionRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result.status).toBe(expectedStatus); + }); + }); + + it('should handle subscription with trial periods', () => { + const trialStart = mockTimestamp - 604800; // 7 days before + const trialEnd = mockTimestamp + 604800; // 7 days after + + const mockData = createMockSubscriptionData({ + trial_start: trialStart, + trial_end: trialEnd, + }); + + const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result.trialStart).toEqual(new Date(trialStart * 1000)); + expect(result.trialEnd).toEqual(new Date(trialEnd * 1000)); + }); + + it('should handle subscription cancellation details', () => { + const cancelAt = mockTimestamp + 2592000; // 30 days after + const canceledAt = mockTimestamp; + const mockData = createMockSubscriptionData({ + cancel_at: cancelAt, + canceled_at: canceledAt, + cancel_at_period_end: true, + cancellation_details: { + comment: 'Customer requested cancellation', + feedback: 'too_expensive', + reason: 'customer_request', + }, + }); + + const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result.cancelAt).toEqual(new Date(cancelAt * 1000)); + expect(result.canceledAt).toEqual(new Date(canceledAt * 1000)); + expect(result.cancelAtPeriodEnd).toBe(true); + expect(result.cancellationDetails).toEqual({ + comment: 'Customer requested cancellation', + feedback: 'too_expensive', + reason: 'customer_request', + }); + }); + + it('should handle automatic tax information', () => { + const mockData = createMockSubscriptionData({ + automatic_tax: { + enabled: true, + status: 'calculated', + }, + }); + + const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result.automaticTax).toEqual({ + enabled: true, + status: 'calculated', + }); + }); + + it('should handle different collection methods', () => { + const methods = [ + [ + 'charge_automatically', + BillingSubscriptionCollectionMethod.CHARGE_AUTOMATICALLY, + ], + ['send_invoice', BillingSubscriptionCollectionMethod.SEND_INVOICE], + ]; + + methods.forEach(([stripeMethod, expectedMethod]) => { + const mockData = createMockSubscriptionData({ + collection_method: stripeMethod, + }); + const result = + transformStripeSubscriptionEventToSubscriptionRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result.collectionMethod).toBe(expectedMethod); + }); + }); + + it('should handle different currencies', () => { + const mockData = createMockSubscriptionData({ + currency: 'eur', + }); + + const result = transformStripeSubscriptionEventToSubscriptionRepositoryData( + mockWorkspaceId, + mockData as any, + ); + + expect(result.currency).toBe('EUR'); + }); +}); diff --git a/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts b/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts new file mode 100644 index 000000000000..fe2b18964628 --- /dev/null +++ b/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts @@ -0,0 +1,280 @@ +import { Stripe } from 'stripe'; +import request from 'supertest'; + +const client = request(`http://localhost:${APP_PORT}`); + +// TODO: Add more tests +// THIS TEST ONLY WORKS IF THE BILLING TABLES ARE CREATED, VERIFY IS_BILLING_ENABLED IS TRUE +describe('BillingController (integration)', () => { + const mockTimestamp = 1672531200; + + //put this in utils? + const createMockSubscriptionData = ( + overrides = {}, + ): Stripe.CustomerSubscriptionCreatedEvent.Data => ({ + object: { + object: 'subscription', + id: 'sub_default', + customer: 'cus_default', + status: 'active', + items: { + data: [ + { + plan: { + id: 'plan_default', + object: 'plan', + active: true, + aggregate_usage: null, + amount_decimal: '0', + billing_scheme: 'per_unit', + interval_count: 1, + livemode: false, + nickname: null, + tiers_mode: null, + transform_usage: null, + trial_period_days: null, + interval: 'month', + currency: 'usd', + amount: 0, + created: mockTimestamp, + product: 'prod_default', + usage_type: 'licensed', + metadata: {}, + meter: null, + }, + id: '', + object: 'subscription_item', + billing_thresholds: null, + created: 0, + discounts: [], + metadata: {}, + price: { + id: 'price_default', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: mockTimestamp, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_default', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: null, + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 1000, + unit_amount_decimal: '1000', + }, + subscription: '', + tax_rates: null, + }, + ], + object: 'list', + has_more: false, + url: '', + }, + cancel_at_period_end: false, + currency: 'usd', + current_period_end: mockTimestamp, + current_period_start: mockTimestamp, + metadata: { workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419' }, + trial_end: null, + trial_start: null, + canceled_at: null, + ...overrides, + application: null, + application_fee_percent: null, + automatic_tax: { + enabled: true, + liability: { + type: 'self', + }, + }, + billing_cycle_anchor: 0, + billing_cycle_anchor_config: null, + billing_thresholds: null, + cancel_at: null, + cancellation_details: null, + collection_method: 'charge_automatically', + created: 0, + days_until_due: null, + default_payment_method: null, + default_source: null, + description: null, + discount: null, + discounts: [], + ended_at: null, + invoice_settings: { + account_tax_ids: null, + issuer: { + type: 'self', + }, + }, + latest_invoice: null, + livemode: false, + next_pending_invoice_item_invoice: null, + on_behalf_of: null, + pause_collection: null, + payment_settings: null, + pending_invoice_item_interval: null, + pending_setup_intent: null, + pending_update: null, + schedule: null, + start_date: 0, + test_clock: null, + transfer_data: null, + trial_settings: null, + }, + }); + + const createMockPriceData = ( + overrides = {}, + ): Stripe.PriceCreatedEvent.Data => ({ + object: { + id: 'price_1Q', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1733734326, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_RLN', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 0, + unit_amount_decimal: '0', + ...overrides, + }, + }); + + it('should handle product.updated and price.created webhook events', async () => { + const productUpdatedData: Stripe.ProductUpdatedEvent.Data = { + object: { + id: 'prod_RLN', + object: 'product', + active: true, + created: 1733410584, + default_price: null, + description: null, + images: [], + livemode: false, + marketing_features: [], + metadata: {}, + name: 'kjnnjkjknkjnjkn', + package_dimensions: null, + shippable: null, + statement_descriptor: null, + tax_code: 'txcd_10103001', + type: 'service', + unit_label: null, + updated: 1734694649, + url: null, + }, + previous_attributes: { + default_price: 'price_1Q', + updated: 1733410585, + }, + }; + const payload = { + type: 'product.updated', + data: productUpdatedData, + }; + + await client + .post('/billing/webhooks') + .set('Authorization', `Bearer ${ACCESS_TOKEN}`) + .set('stripe-signature', 'correct-signature') + .set('Content-Type', 'application/json') + .send(JSON.stringify(payload)) + .expect(200); + + await client + .post('/billing/webhooks') + .set('Authorization', `Bearer ${ACCESS_TOKEN}`) + .set('stripe-signature', 'correct-signature') + .set('Content-Type', 'application/json') + .send( + JSON.stringify({ + type: 'price.created', + data: createMockPriceData(), + }), + ) + .expect(200); + }); + it('should handle subscription.created webhook event', async () => { + const payload = { + type: 'subscription.created', + data: createMockSubscriptionData(), + }; + + await client + .post('/billing/webhooks') + .set('Authorization', `Bearer ${ACCESS_TOKEN}`) + .set('stripe-signature', 'correct-signature') + .set('Content-Type', 'application/json') + .send(JSON.stringify(payload)) + .expect(200); + }); + // it('should handle subscription followed by customer.entitlement.created', async () => { + // // First create subscription + + // // Then create entitlement + + // await client + // .post('/billing/webhooks') + // .set('Authorization', `Bearer ${ACCESS_TOKEN}`) + // .set('stripe-signature', 'correct-signature') + // .set('Content-Type', 'application/json') + // .send( + // JSON.stringify({ + // type: 'customer.entitlement.created', + // data: createMockCustomerEntitlementData(), + // }), + // ) + // .expect(200); + // }); + + it('should reject webhook with invalid signature', async () => { + const payload = { + type: 'customer.entitlement.created', + data: { + object: { + id: 'ent_test123', + }, + }, + }; + + await client + .post('/billing/webhooks') + .set('Authorization', `Bearer ${ACCESS_TOKEN}`) + .set('stripe-signature', 'invalid-signature') + .set('Content-Type', 'application/json') + .send(JSON.stringify(payload)) + .expect(500); + }); +}); diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index dfa136e4d4b7..6b049256c2db 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -1,7 +1,10 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; +import express from 'express'; + import { AppModule } from 'src/app.module'; +import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; interface TestingModuleCreatePreHook { (moduleBuilder: TestingModuleBuilder): TestingModuleBuilder; @@ -25,7 +28,21 @@ export const createApp = async ( ): Promise => { let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({ imports: [AppModule], - }); + }) + .overrideProvider(StripeService) + .useValue({ + constructEventFromPayload: (signature: string, payload: Buffer) => { + if (signature === 'correct-signature') { + const body = JSON.parse(payload.toString()); + + return { + type: body.type, + data: body.data, + }; + } + throw new Error('Invalid signature'); + }, + }); // or perhaps create an alternative stripe service for testing? if (config.moduleBuilderHook) { moduleBuilder = config.moduleBuilderHook(moduleBuilder); @@ -35,6 +52,14 @@ export const createApp = async ( const app = moduleFixture.createNestApplication(); + app.use( + express.json({ + verify: (req: any, res, buf) => { + req.rawBody = buf; + }, + }), + ); + if (config.appInitHook) { await config.appInitHook(app); } From 6bed5fad3e04146b7d506e3d620d1f14a66a4d3e Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre Date: Thu, 2 Jan 2025 17:47:00 +0100 Subject: [PATCH 2/4] lint and upgrade tests --- .github/workflows/ci-server.yaml | 1 + .../billing/billing.controller.ts | 32 ++- .../services/billing-subscription.service.ts | 5 + .../billing-webhook-entitlement.service.ts | 4 + .../services/billing-webhook-price.service.ts | 5 + .../billing-webhook-product.service.ts | 4 + .../billing-webhook-subscription.service.ts | 7 +- .../billing-controller.integration-spec.ts | 242 ++---------------- ...ate-mock-stripe-price-created-data.util.ts | 34 +++ ...e-mock-stripe-product-updated-data.util.ts | 32 +++ ...k-stripe-subscription-created-data.util.ts | 130 ++++++++++ .../test/integration/utils/create-app.ts | 23 +- 12 files changed, 274 insertions(+), 245 deletions(-) create mode 100644 packages/twenty-server/test/integration/billing/utils/create-mock-stripe-price-created-data.util.ts create mode 100644 packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts create mode 100644 packages/twenty-server/test/integration/billing/utils/create-mock-stripe-subscription-created-data.util.ts diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index 5e71284fdc01..421d59f98de5 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -153,6 +153,7 @@ jobs: PGPASSWORD_SUPERUSER: postgres ALLOW_NOSSL: "true" SPILO_PROVIDER: "local" + IS_BILLING_ENABLED: "true" ports: - 5432:5432 options: >- diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts index b5113fe6ce6a..cf593074510e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts @@ -43,6 +43,8 @@ export class BillingController { @Req() req: RawBodyRequest, @Res() res: Response, ) { + let resultBody = {}; + if (!req.rawBody) { res.status(400).end(); @@ -54,7 +56,9 @@ export class BillingController { ); if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) { - await this.billingSubscriptionService.handleUnpaidInvoices(event.data); + resultBody = await this.billingSubscriptionService.handleUnpaidInvoices( + event.data, + ); } if ( @@ -70,18 +74,20 @@ export class BillingController { return; } - await this.billingWebhookSubscriptionService.processStripeEvent( - workspaceId, - event.data, - ); + resultBody = + await this.billingWebhookSubscriptionService.processStripeEvent( + workspaceId, + event.data, + ); } if ( event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED ) { try { - await this.billingWebhookEntitlementService.processStripeEvent( - event.data, - ); + resultBody = + await this.billingWebhookEntitlementService.processStripeEvent( + event.data, + ); } catch (error) { if ( error instanceof BillingException && @@ -96,14 +102,18 @@ export class BillingController { event.type === WebhookEvent.PRODUCT_CREATED || event.type === WebhookEvent.PRODUCT_UPDATED ) { - await this.billingWebhookProductService.processStripeEvent(event.data); + resultBody = await this.billingWebhookProductService.processStripeEvent( + event.data, + ); } if ( event.type === WebhookEvent.PRICE_CREATED || event.type === WebhookEvent.PRICE_UPDATED ) { try { - await this.billingWebhookPriceService.processStripeEvent(event.data); + resultBody = await this.billingWebhookPriceService.processStripeEvent( + event.data, + ); } catch (error) { if ( error instanceof BillingException && @@ -114,6 +124,6 @@ export class BillingController { } } - res.status(200).end(); + res.status(200).send(resultBody).end(); } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index f2ebf2d07a85..ba28828f2a39 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -95,6 +95,11 @@ export class BillingSubscriptionService { billingSubscription.stripeSubscriptionId, ); } + + return { + handleUnpaidInvoiceStripeSubscriptionId: + billingSubscription.stripeSubscriptionId, + }; } async getWorkspaceEntitlementByKey( diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts index 1f34877b7ec2..2c3e285030b9 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-entitlement.service.ts @@ -48,5 +48,9 @@ export class BillingWebhookEntitlementService { skipUpdateIfNoValuesChanged: true, }, ); + + return { + stripeEntitlementCustomerId: data.object.customer, + }; } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts index 8e87fc095979..b6b5e25213c2 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts @@ -63,5 +63,10 @@ export class BillingWebhookPriceService { skipUpdateIfNoValuesChanged: true, }, ); + + return { + stripePriceId: data.object.id, + stripeMeterId: meterId, + }; } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts index 11e2238bf87d..21d09abb4e48 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts @@ -33,6 +33,10 @@ export class BillingWebhookProductService { conflictPaths: ['stripeProductId'], skipUpdateIfNoValuesChanged: true, }); + + return { + stripeProductId: data.object.id, + }; } isStripeValidProductMetadata( diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts index 9cd4c62551e8..d70bf7c03dce 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts @@ -45,7 +45,7 @@ export class BillingWebhookSubscriptionService { }); if (!workspace) { - return; + return { noWorkspace: true }; } await this.billingCustomerRepository.upsert( @@ -110,5 +110,10 @@ export class BillingWebhookSubscriptionService { String(data.object.customer), workspaceId, ); + + return { + stripeSubscriptionId: data.object.id, + stripeCustomerId: data.object.customer, + }; } } diff --git a/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts b/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts index fe2b18964628..e86fa68f7fac 100644 --- a/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts +++ b/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts @@ -1,208 +1,15 @@ -import { Stripe } from 'stripe'; import request from 'supertest'; +import { createMockStripePriceCreatedData } from 'test/integration/billing/utils/create-mock-stripe-price-created-data.util'; +import { createMockStripeProductUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-product-updated-data.util'; +import { createMockStripeSubscriptionCreatedData } from 'test/integration/billing/utils/create-mock-stripe-subscription-created-data.util'; const client = request(`http://localhost:${APP_PORT}`); -// TODO: Add more tests -// THIS TEST ONLY WORKS IF THE BILLING TABLES ARE CREATED, VERIFY IS_BILLING_ENABLED IS TRUE describe('BillingController (integration)', () => { - const mockTimestamp = 1672531200; - - //put this in utils? - const createMockSubscriptionData = ( - overrides = {}, - ): Stripe.CustomerSubscriptionCreatedEvent.Data => ({ - object: { - object: 'subscription', - id: 'sub_default', - customer: 'cus_default', - status: 'active', - items: { - data: [ - { - plan: { - id: 'plan_default', - object: 'plan', - active: true, - aggregate_usage: null, - amount_decimal: '0', - billing_scheme: 'per_unit', - interval_count: 1, - livemode: false, - nickname: null, - tiers_mode: null, - transform_usage: null, - trial_period_days: null, - interval: 'month', - currency: 'usd', - amount: 0, - created: mockTimestamp, - product: 'prod_default', - usage_type: 'licensed', - metadata: {}, - meter: null, - }, - id: '', - object: 'subscription_item', - billing_thresholds: null, - created: 0, - discounts: [], - metadata: {}, - price: { - id: 'price_default', - object: 'price', - active: true, - billing_scheme: 'per_unit', - created: mockTimestamp, - currency: 'usd', - custom_unit_amount: null, - livemode: false, - lookup_key: null, - metadata: {}, - nickname: null, - product: 'prod_default', - recurring: { - aggregate_usage: null, - interval: 'month', - interval_count: 1, - meter: null, - trial_period_days: null, - usage_type: 'licensed', - }, - tax_behavior: null, - tiers_mode: null, - transform_quantity: null, - type: 'recurring', - unit_amount: 1000, - unit_amount_decimal: '1000', - }, - subscription: '', - tax_rates: null, - }, - ], - object: 'list', - has_more: false, - url: '', - }, - cancel_at_period_end: false, - currency: 'usd', - current_period_end: mockTimestamp, - current_period_start: mockTimestamp, - metadata: { workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419' }, - trial_end: null, - trial_start: null, - canceled_at: null, - ...overrides, - application: null, - application_fee_percent: null, - automatic_tax: { - enabled: true, - liability: { - type: 'self', - }, - }, - billing_cycle_anchor: 0, - billing_cycle_anchor_config: null, - billing_thresholds: null, - cancel_at: null, - cancellation_details: null, - collection_method: 'charge_automatically', - created: 0, - days_until_due: null, - default_payment_method: null, - default_source: null, - description: null, - discount: null, - discounts: [], - ended_at: null, - invoice_settings: { - account_tax_ids: null, - issuer: { - type: 'self', - }, - }, - latest_invoice: null, - livemode: false, - next_pending_invoice_item_invoice: null, - on_behalf_of: null, - pause_collection: null, - payment_settings: null, - pending_invoice_item_interval: null, - pending_setup_intent: null, - pending_update: null, - schedule: null, - start_date: 0, - test_clock: null, - transfer_data: null, - trial_settings: null, - }, - }); - - const createMockPriceData = ( - overrides = {}, - ): Stripe.PriceCreatedEvent.Data => ({ - object: { - id: 'price_1Q', - object: 'price', - active: true, - billing_scheme: 'per_unit', - created: 1733734326, - currency: 'usd', - custom_unit_amount: null, - livemode: false, - lookup_key: null, - metadata: {}, - nickname: null, - product: 'prod_RLN', - recurring: { - aggregate_usage: null, - interval: 'month', - interval_count: 1, - meter: null, - trial_period_days: null, - usage_type: 'licensed', - }, - tax_behavior: 'unspecified', - tiers_mode: null, - transform_quantity: null, - type: 'recurring', - unit_amount: 0, - unit_amount_decimal: '0', - ...overrides, - }, - }); - it('should handle product.updated and price.created webhook events', async () => { - const productUpdatedData: Stripe.ProductUpdatedEvent.Data = { - object: { - id: 'prod_RLN', - object: 'product', - active: true, - created: 1733410584, - default_price: null, - description: null, - images: [], - livemode: false, - marketing_features: [], - metadata: {}, - name: 'kjnnjkjknkjnjkn', - package_dimensions: null, - shippable: null, - statement_descriptor: null, - tax_code: 'txcd_10103001', - type: 'service', - unit_label: null, - updated: 1734694649, - url: null, - }, - previous_attributes: { - default_price: 'price_1Q', - updated: 1733410585, - }, - }; const payload = { type: 'product.updated', - data: productUpdatedData, + data: createMockStripeProductUpdatedData(), }; await client @@ -211,7 +18,10 @@ describe('BillingController (integration)', () => { .set('stripe-signature', 'correct-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(payload)) - .expect(200); + .expect(200) + .then((res) => { + expect(res.body.stripeProductId).toBeDefined(); + }); await client .post('/billing/webhooks') @@ -221,15 +31,19 @@ describe('BillingController (integration)', () => { .send( JSON.stringify({ type: 'price.created', - data: createMockPriceData(), + data: createMockStripePriceCreatedData(), }), ) - .expect(200); + .expect(200) + .then((res) => { + expect(res.body.stripePriceId).toBeDefined(); + expect(res.body.stripeMeterId).toBeDefined(); + }); }); it('should handle subscription.created webhook event', async () => { const payload = { - type: 'subscription.created', - data: createMockSubscriptionData(), + type: 'customer.subscription.created', + data: createMockStripeSubscriptionCreatedData(), }; await client @@ -238,26 +52,12 @@ describe('BillingController (integration)', () => { .set('stripe-signature', 'correct-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(payload)) - .expect(200); + .expect(200) + .then((res) => { + expect(res.body.stripeSubscriptionId).toBeDefined(); + expect(res.body.stripeCustomerId).toBeDefined(); + }); }); - // it('should handle subscription followed by customer.entitlement.created', async () => { - // // First create subscription - - // // Then create entitlement - - // await client - // .post('/billing/webhooks') - // .set('Authorization', `Bearer ${ACCESS_TOKEN}`) - // .set('stripe-signature', 'correct-signature') - // .set('Content-Type', 'application/json') - // .send( - // JSON.stringify({ - // type: 'customer.entitlement.created', - // data: createMockCustomerEntitlementData(), - // }), - // ) - // .expect(200); - // }); it('should reject webhook with invalid signature', async () => { const payload = { diff --git a/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-price-created-data.util.ts b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-price-created-data.util.ts new file mode 100644 index 000000000000..65890e8c8dd4 --- /dev/null +++ b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-price-created-data.util.ts @@ -0,0 +1,34 @@ +import Stripe from 'stripe'; +export const createMockStripePriceCreatedData = ( + overrides = {}, +): Stripe.PriceCreatedEvent.Data => ({ + object: { + id: 'price_1Q', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1733734326, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_RLN', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 0, + unit_amount_decimal: '0', + ...overrides, + }, +}); diff --git a/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts new file mode 100644 index 000000000000..b65f70da15b8 --- /dev/null +++ b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-product-updated-data.util.ts @@ -0,0 +1,32 @@ +import Stripe from 'stripe'; + +export const createMockStripeProductUpdatedData = ( + overrides = {}, +): Stripe.ProductUpdatedEvent.Data => ({ + object: { + id: 'prod_RLN', + object: 'product', + active: true, + created: 1733410584, + default_price: null, + description: null, + images: [], + livemode: false, + marketing_features: [], + metadata: {}, + name: 'kjnnjkjknkjnjkn', + package_dimensions: null, + shippable: null, + statement_descriptor: null, + tax_code: 'txcd_10103001', + type: 'service', + unit_label: null, + updated: 1734694649, + url: null, + }, + previous_attributes: { + default_price: 'price_1Q', + updated: 1733410585, + }, + ...overrides, +}); diff --git a/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-subscription-created-data.util.ts b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-subscription-created-data.util.ts new file mode 100644 index 000000000000..e95a0a542494 --- /dev/null +++ b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-subscription-created-data.util.ts @@ -0,0 +1,130 @@ +import Stripe from 'stripe'; + +export const createMockStripeSubscriptionCreatedData = ( + overrides = {}, +): Stripe.CustomerSubscriptionCreatedEvent.Data => ({ + object: { + object: 'subscription', + id: 'sub_default', + customer: 'cus_default1', + status: 'active', + items: { + data: [ + { + plan: { + id: 'plan_default', + object: 'plan', + active: true, + aggregate_usage: null, + amount_decimal: '0', + billing_scheme: 'per_unit', + interval_count: 1, + livemode: false, + nickname: null, + tiers_mode: null, + transform_usage: null, + trial_period_days: null, + interval: 'month', + currency: 'usd', + amount: 0, + created: 1672531200, + product: 'prod_default', + usage_type: 'licensed', + metadata: {}, + meter: null, + }, + id: '', + object: 'subscription_item', + billing_thresholds: null, + created: 0, + discounts: [], + metadata: {}, + price: { + id: 'price_default', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1672531200, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_default', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: null, + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 1000, + unit_amount_decimal: '1000', + }, + subscription: '', + tax_rates: null, + }, + ], + object: 'list', + has_more: false, + url: '', + }, + cancel_at_period_end: false, + currency: 'usd', + current_period_end: 1672531200, + current_period_start: 1672531200, + metadata: { workspaceId: '3b8e6458-5fc1-4e63-8563-008ccddaa6db' }, + trial_end: null, + trial_start: null, + canceled_at: null, + ...overrides, + application: null, + application_fee_percent: null, + automatic_tax: { + enabled: true, + liability: { + type: 'self', + }, + }, + billing_cycle_anchor: 0, + billing_cycle_anchor_config: null, + billing_thresholds: null, + cancel_at: null, + cancellation_details: null, + collection_method: 'charge_automatically', + created: 0, + days_until_due: null, + default_payment_method: null, + default_source: null, + description: null, + discount: null, + discounts: [], + ended_at: null, + invoice_settings: { + account_tax_ids: null, + issuer: { + type: 'self', + }, + }, + latest_invoice: null, + livemode: false, + next_pending_invoice_item_invoice: null, + on_behalf_of: null, + pause_collection: null, + payment_settings: null, + pending_invoice_item_interval: null, + pending_setup_intent: null, + pending_update: null, + schedule: null, + start_date: 0, + test_clock: null, + transfer_data: null, + trial_settings: null, + }, +}); diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index 6b049256c2db..c1cfde5156f3 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -1,8 +1,6 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; -import express from 'express'; - import { AppModule } from 'src/app.module'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; @@ -42,7 +40,13 @@ export const createApp = async ( } throw new Error('Invalid signature'); }, - }); // or perhaps create an alternative stripe service for testing? + updateCustomerMetadataWorkspaceId: ( + _customerId: string, + _workspaceId: string, + ) => { + return; + }, + }); if (config.moduleBuilderHook) { moduleBuilder = config.moduleBuilderHook(moduleBuilder); @@ -50,15 +54,10 @@ export const createApp = async ( const moduleFixture: TestingModule = await moduleBuilder.compile(); - const app = moduleFixture.createNestApplication(); - - app.use( - express.json({ - verify: (req: any, res, buf) => { - req.rawBody = buf; - }, - }), - ); + const app = moduleFixture.createNestApplication({ + rawBody: true, + cors: true, + }); if (config.appInitHook) { await config.appInitHook(app); From 80d0844cebce96348a9c2ae403f07e974bdf96e7 Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre Date: Mon, 6 Jan 2025 11:08:35 +0100 Subject: [PATCH 3/4] update billing controller code style --- .../billing/billing.controller.ts | 102 ++++++++---------- .../core-modules/billing/billing.exception.ts | 1 + .../enums/billing-webhook-events.enum.ts | 2 +- 3 files changed, 44 insertions(+), 61 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts index cf593074510e..860d8bd71c1e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts @@ -10,12 +10,13 @@ import { } from '@nestjs/common'; import { Response } from 'express'; +import Stripe from 'stripe'; import { BillingException, BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; -import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; +import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billing/services/billing-webhook-entitlement.service'; @@ -43,8 +44,6 @@ export class BillingController { @Req() req: RawBodyRequest, @Res() res: Response, ) { - let resultBody = {}; - if (!req.rawBody) { res.status(400).end(); @@ -55,75 +54,58 @@ export class BillingController { req.rawBody, ); - if (event.type === WebhookEvent.SETUP_INTENT_SUCCEEDED) { - resultBody = await this.billingSubscriptionService.handleUnpaidInvoices( - event.data, - ); - } - - if ( - event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED || - event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED || - event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED - ) { - const workspaceId = event.data.object.metadata?.workspaceId; + try { + const result = await this.handleStripeEvent(event); - if (!workspaceId) { + res.status(200).send(result).end(); + } catch (error) { + if (error instanceof BillingException) { res.status(404).end(); - - return; } + } + } - resultBody = - await this.billingWebhookSubscriptionService.processStripeEvent( - workspaceId, + private async handleStripeEvent(event: Stripe.Event) { + switch (event.type) { + case BillingWebhookEvent.SETUP_INTENT_SUCCEEDED: + return await this.billingSubscriptionService.handleUnpaidInvoices( event.data, ); - } - if ( - event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED - ) { - try { - resultBody = - await this.billingWebhookEntitlementService.processStripeEvent( - event.data, + case BillingWebhookEvent.PRICE_UPDATED: + case BillingWebhookEvent.PRICE_CREATED: + return await this.billingWebhookPriceService.processStripeEvent( + event.data, + ); + + case BillingWebhookEvent.PRODUCT_UPDATED: + case BillingWebhookEvent.PRODUCT_CREATED: + return await this.billingWebhookProductService.processStripeEvent( + event.data, + ); + case BillingWebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED: + return await this.billingWebhookEntitlementService.processStripeEvent( + event.data, + ); + + case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED: + case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED: + case BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_DELETED: { + const workspaceId = event.data.object.metadata?.workspaceId; + + if (!workspaceId) { + throw new BillingException( + 'Workspace ID is required for subscription events', + BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND, ); - } catch (error) { - if ( - error instanceof BillingException && - error.code === BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND - ) { - res.status(404).end(); } - } - } - if ( - event.type === WebhookEvent.PRODUCT_CREATED || - event.type === WebhookEvent.PRODUCT_UPDATED - ) { - resultBody = await this.billingWebhookProductService.processStripeEvent( - event.data, - ); - } - if ( - event.type === WebhookEvent.PRICE_CREATED || - event.type === WebhookEvent.PRICE_UPDATED - ) { - try { - resultBody = await this.billingWebhookPriceService.processStripeEvent( + return await this.billingWebhookSubscriptionService.processStripeEvent( + workspaceId, event.data, ); - } catch (error) { - if ( - error instanceof BillingException && - error.code === BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND - ) { - res.status(404).end(); - } } + default: + return {}; } - - res.status(200).send(resultBody).end(); } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts index 59ef9502749b..093b0c481028 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -12,4 +12,5 @@ export class BillingException extends CustomException { export enum BillingExceptionCode { BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND', BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND', + BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND', } diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts index d275746add4c..220b55a5b55f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts @@ -1,4 +1,4 @@ -export enum WebhookEvent { +export enum BillingWebhookEvent { CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created', CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', From 789234ef5c92f8fc1db9af863f7bd180c11ec047 Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre Date: Thu, 9 Jan 2025 14:09:08 +0100 Subject: [PATCH 4/4] separate into multiple stripe service refactor billing integration tests --- .github/workflows/ci-server.yaml | 10 +- .../twenty-server/jest-integration.config.ts | 5 +- .../billing/billing.controller.ts | 6 +- .../core-modules/billing/billing.resolver.ts | 9 +- .../billing-sync-customer-data.command.ts | 6 +- .../billing-sync-plans-data.command.ts | 14 +- .../jobs/update-subscription-quantity.job.ts | 6 +- .../billing-portal.workspace-service.ts | 17 +- .../services/billing-subscription.service.ts | 16 +- .../services/billing-webhook-price.service.ts | 6 +- .../billing-webhook-subscription.service.ts | 6 +- .../services/stripe-billing-meter.service.ts | 34 +++ .../services/stripe-billing-portal.service.ts | 37 +++ .../services/stripe-checkout.service.ts | 67 +++++ .../services/stripe-customer.service.ts | 33 +++ .../stripe/services/stripe-price.service.ts | 85 +++++++ .../stripe/services/stripe-product.service.ts | 30 +++ .../stripe-subscription-item.service.ts | 45 ++++ .../services/stripe-subscription.service.ts | 59 +++++ .../stripe/services/stripe-webhook.service.ts | 37 +++ .../mocks/stripe-sdk-mock.service.ts | 13 + .../stripe-sdk/mocks/stripe-sdk.mock.ts | 29 +++ .../stripe-sdk/services/stripe-sdk.service.ts | 10 + .../stripe/stripe-sdk/stripe-sdk.module.ts | 9 + .../billing/stripe/stripe.module.ts | 37 ++- .../billing/stripe/stripe.service.ts | 233 ------------------ .../billing-controller.integration-spec.ts | 56 ++++- ...ck-stripe-entitlement-updated-data.util.ts | 25 ++ .../test/integration/utils/create-app.ts | 26 +- 29 files changed, 657 insertions(+), 309 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-webhook.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk.mock.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts create mode 100644 packages/twenty-server/test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util.ts diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index 421d59f98de5..806bfe725f75 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -153,7 +153,6 @@ jobs: PGPASSWORD_SUPERUSER: postgres ALLOW_NOSSL: "true" SPILO_PROVIDER: "local" - IS_BILLING_ENABLED: "true" ports: - 5432:5432 options: >- @@ -185,6 +184,15 @@ jobs: - name: Install dependencies if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install + - name: Update .env.test for billing + if: steps.changed-files.outputs.any_changed == 'true' + run: | + sed -i '$ a\ +IS_BILLING_ENABLED=true\ +BILLING_STRIPE_API_KEY=test-api-key\ +BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id\ +BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret' .env.test + - name: Server / Restore Task Cache if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts index cbb53051d1fd..d8d0d975edf0 100644 --- a/packages/twenty-server/jest-integration.config.ts +++ b/packages/twenty-server/jest-integration.config.ts @@ -1,5 +1,6 @@ import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest'; +const isBillingEnabled = process.env.IS_BILLING_ENABLED === 'true'; // eslint-disable-next-line @typescript-eslint/no-var-requires const tsConfig = require('./tsconfig.json'); @@ -9,7 +10,9 @@ const jestConfig: JestConfigWithTsJest = { moduleFileExtensions: ['js', 'json', 'ts'], rootDir: '.', testEnvironment: 'node', - testRegex: '.integration-spec.ts$', + testRegex: isBillingEnabled + ? 'integration-spec.ts' + : '^(?!.*billing).*\\.integration-spec\\.ts$', modulePathIgnorePatterns: ['/dist'], globalSetup: '/test/integration/utils/setup-test.ts', globalTeardown: '/test/integration/utils/teardown-test.ts', diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts index 860d8bd71c1e..19c95edefeb8 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts @@ -23,14 +23,14 @@ import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billin import { BillingWebhookPriceService } from 'src/engine/core-modules/billing/services/billing-webhook-price.service'; import { BillingWebhookProductService } from 'src/engine/core-modules/billing/services/billing-webhook-product.service'; import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing/services/billing-webhook-subscription.service'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service'; @Controller('billing') @UseFilters(BillingRestApiExceptionFilter) export class BillingController { protected readonly logger = new Logger(BillingController.name); constructor( - private readonly stripeService: StripeService, + private readonly stripeWebhookService: StripeWebhookService, private readonly billingWebhookSubscriptionService: BillingWebhookSubscriptionService, private readonly billingWebhookEntitlementService: BillingWebhookEntitlementService, private readonly billingSubscriptionService: BillingSubscriptionService, @@ -49,7 +49,7 @@ export class BillingController { return; } - const event = this.stripeService.constructEventFromPayload( + const event = this.stripeWebhookService.constructEventFromPayload( signature, req.rawBody, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index df2d4469de0d..f04258bf4882 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -10,7 +10,7 @@ import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update- import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; @@ -23,12 +23,13 @@ export class BillingResolver { constructor( private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService, - private readonly stripeService: StripeService, + private readonly stripePriceService: StripePriceService, ) {} @Query(() => ProductPricesEntity) async getProductPrices(@Args() { product }: ProductInput) { - const productPrices = await this.stripeService.getStripePrices(product); + const productPrices = + await this.stripePriceService.getStripePrices(product); return { totalNumberOfPrices: productPrices.length, @@ -64,7 +65,7 @@ export class BillingResolver { requirePaymentMethod, }: CheckoutSessionInput, ) { - const productPrice = await this.stripeService.getStripePrice( + const productPrice = await this.stripePriceService.getStripePrice( AvailableProduct.BasePlan, recurringInterval, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts index 70fcb981a080..9733c4bc6756 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts @@ -9,7 +9,7 @@ import { ActiveWorkspacesCommandRunner, } from 'src/database/commands/active-workspaces.command'; import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; interface SyncCustomerDataCommandOptions @@ -23,7 +23,7 @@ export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunne constructor( @InjectRepository(Workspace, 'core') protected readonly workspaceRepository: Repository, - private readonly stripeService: StripeService, + private readonly stripeSubscriptionService: StripeSubscriptionService, @InjectRepository(BillingCustomer, 'core') protected readonly billingCustomerRepository: Repository, ) { @@ -71,7 +71,7 @@ export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunne if (!options.dryRun && !billingCustomer) { const stripeCustomerId = - await this.stripeService.getStripeCustomerIdFromWorkspaceId( + await this.stripeSubscriptionService.getStripeCustomerIdFromWorkspaceId( workspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts index a75809a3cefe..a220c6a5954b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-plans-data.command.ts @@ -11,7 +11,9 @@ import { import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity'; import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; +import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; +import { StripeProductService } from 'src/engine/core-modules/billing/stripe/services/stripe-product.service'; import { isStripeValidProductMetadata } from 'src/engine/core-modules/billing/utils/is-stripe-valid-product-metadata.util'; import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util'; import { transformStripePriceDataToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-data-to-price-repository-data.util'; @@ -30,7 +32,9 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner { private readonly billingProductRepository: Repository, @InjectRepository(BillingMeter, 'core') private readonly billingMeterRepository: Repository, - private readonly stripeService: StripeService, + private readonly stripeBillingMeterService: StripeBillingMeterService, + private readonly stripeProductService: StripeProductService, + private readonly stripePriceService: StripePriceService, ) { super(); } @@ -92,7 +96,7 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner { } await this.upsertProductRepositoryData(product, options); - const prices = await this.stripeService.getPricesByProductId( + const prices = await this.stripePriceService.getPricesByProductId( product.id, ); @@ -133,11 +137,11 @@ export class BillingSyncPlansDataCommand extends BaseCommandRunner { passedParams: string[], options: BaseCommandOptions, ): Promise { - const billingMeters = await this.stripeService.getAllMeters(); + const billingMeters = await this.stripeBillingMeterService.getAllMeters(); await this.upsertMetersRepositoryData(billingMeters, options); - const billingProducts = await this.stripeService.getAllProducts(); + const billingProducts = await this.stripeProductService.getAllProducts(); const billingPrices = await this.processBillingPricesByProductBatches( billingProducts, diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts index 301322448f3e..b81d67b11fcb 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts @@ -1,7 +1,7 @@ import { Logger, Scope } from '@nestjs/common'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -18,7 +18,7 @@ export class UpdateSubscriptionQuantityJob { constructor( private readonly billingSubscriptionService: BillingSubscriptionService, - private readonly stripeService: StripeService, + private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, private readonly twentyORMManager: TwentyORMManager, ) {} @@ -41,7 +41,7 @@ export class UpdateSubscriptionQuantityJob { data.workspaceId, ); - await this.stripeService.updateSubscriptionItem( + await this.stripeSubscriptionItemService.updateSubscriptionItem( billingSubscriptionItem.stripeSubscriptionItemId, workspaceMembersCount, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index f422b07dc895..788f2bf6e9ce 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -6,7 +6,8 @@ import { Repository } from 'typeorm'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; +import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -17,7 +18,8 @@ import { assert } from 'src/utils/assert'; export class BillingPortalWorkspaceService { protected readonly logger = new Logger(BillingPortalWorkspaceService.name); constructor( - private readonly stripeService: StripeService, + private readonly stripeCheckoutService: StripeCheckoutService, + private readonly stripeBillingPortalService: StripeBillingPortalService, private readonly domainManagerService: DomainManagerService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, @@ -52,7 +54,7 @@ export class BillingPortalWorkspaceService { }) )?.stripeCustomerId; - const session = await this.stripeService.createCheckoutSession( + const session = await this.stripeCheckoutService.createCheckoutSession( user, workspace.id, priceId, @@ -97,10 +99,11 @@ export class BillingPortalWorkspaceService { } const returnUrl = frontBaseUrl.toString(); - const session = await this.stripeService.createBillingPortalSession( - stripeCustomerId, - returnUrl, - ); + const session = + await this.stripeBillingPortalService.createBillingPortalSession( + stripeCustomerId, + returnUrl, + ); assert(session.url, 'Error: missing billingPortal.session.url'); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index ba28828f2a39..c3eeea0a94d7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -13,14 +13,18 @@ import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing- import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; +import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; +import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @Injectable() export class BillingSubscriptionService { protected readonly logger = new Logger(BillingSubscriptionService.name); constructor( - private readonly stripeService: StripeService, + private readonly stripeSubscriptionService: StripeSubscriptionService, + private readonly stripePriceService: StripePriceService, + private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, private readonly environmentService: EnvironmentService, @InjectRepository(BillingEntitlement, 'core') private readonly billingEntitlementRepository: Repository, @@ -78,7 +82,7 @@ export class BillingSubscriptionService { }); if (subscriptionToCancel) { - await this.stripeService.cancelSubscription( + await this.stripeSubscriptionService.cancelSubscription( subscriptionToCancel.stripeSubscriptionId, ); await this.billingSubscriptionRepository.delete(subscriptionToCancel.id); @@ -91,7 +95,7 @@ export class BillingSubscriptionService { ); if (billingSubscription?.status === 'unpaid') { - await this.stripeService.collectLastInvoice( + await this.stripeSubscriptionService.collectLastInvoice( billingSubscription.stripeSubscriptionId, ); } @@ -134,7 +138,7 @@ export class BillingSubscriptionService { user.defaultWorkspaceId, ); - const productPrice = await this.stripeService.getStripePrice( + const productPrice = await this.stripePriceService.getStripePrice( AvailableProduct.BasePlan, newInterval, ); @@ -145,7 +149,7 @@ export class BillingSubscriptionService { ); } - await this.stripeService.updateBillingSubscriptionItem( + await this.stripeSubscriptionItemService.updateBillingSubscriptionItem( billingSubscriptionItem, productPrice.stripePriceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts index b6b5e25213c2..660b1d95d8e3 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-price.service.ts @@ -11,14 +11,14 @@ import { import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity'; import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; import { transformStripeMeterDataToMeterRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-meter-data-to-meter-repository-data.util'; import { transformStripePriceEventToPriceRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-price-event-to-price-repository-data.util'; @Injectable() export class BillingWebhookPriceService { protected readonly logger = new Logger(BillingWebhookPriceService.name); constructor( - private readonly stripeService: StripeService, + private readonly stripeBillingMeterService: StripeBillingMeterService, @InjectRepository(BillingPrice, 'core') private readonly billingPriceRepository: Repository, @InjectRepository(BillingMeter, 'core') @@ -45,7 +45,7 @@ export class BillingWebhookPriceService { const meterId = data.object.recurring?.meter; if (meterId) { - const meterData = await this.stripeService.getMeter(meterId); + const meterData = await this.stripeBillingMeterService.getMeter(meterId); await this.billingMeterRepository.upsert( transformStripeMeterDataToMeterRepositoryData(meterData), diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts index d70bf7c03dce..970137160b9c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-subscription.service.ts @@ -8,7 +8,7 @@ import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billin import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; import { transformStripeSubscriptionEventToCustomerRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-customer-repository-data.util'; import { transformStripeSubscriptionEventToSubscriptionItemRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-item-repository-data.util'; import { transformStripeSubscriptionEventToSubscriptionRepositoryData } from 'src/engine/core-modules/billing/utils/transform-stripe-subscription-event-to-subscription-repository-data.util'; @@ -22,7 +22,7 @@ export class BillingWebhookSubscriptionService { BillingWebhookSubscriptionService.name, ); constructor( - private readonly stripeService: StripeService, + private readonly stripeCustomerService: StripeCustomerService, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, @InjectRepository(BillingSubscriptionItem, 'core') @@ -106,7 +106,7 @@ export class BillingWebhookSubscriptionService { }); } - await this.stripeService.updateCustomerMetadataWorkspaceId( + await this.stripeCustomerService.updateCustomerMetadataWorkspaceId( String(data.object.customer), workspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service.ts new file mode 100644 index 000000000000..e2b156e879be --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service.ts @@ -0,0 +1,34 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class StripeBillingMeterService { + protected readonly logger = new Logger(StripeBillingMeterService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async getMeter(stripeMeterId: string) { + return await this.stripe.billing.meters.retrieve(stripeMeterId); + } + + async getAllMeters() { + const meters = await this.stripe.billing.meters.list(); + + return meters.data; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts new file mode 100644 index 000000000000..f899d8457db2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class StripeBillingPortalService { + protected readonly logger = new Logger(StripeBillingPortalService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly domainManagerService: DomainManagerService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async createBillingPortalSession( + stripeCustomerId: string, + returnUrl?: string, + ): Promise { + return await this.stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: + returnUrl ?? this.domainManagerService.getBaseUrl().toString(), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts new file mode 100644 index 000000000000..fd7715275671 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-checkout.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +@Injectable() +export class StripeCheckoutService { + protected readonly logger = new Logger(StripeCheckoutService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async createCheckoutSession( + user: User, + workspaceId: string, + priceId: string, + quantity: number, + successUrl?: string, + cancelUrl?: string, + stripeCustomerId?: string, + plan: BillingPlanKey = BillingPlanKey.PRO, + requirePaymentMethod = true, + ): Promise { + return await this.stripe.checkout.sessions.create({ + line_items: [ + { + price: priceId, + quantity, + }, + ], + mode: 'subscription', + subscription_data: { + metadata: { + workspaceId, + plan, + }, + trial_period_days: this.environmentService.get( + 'BILLING_FREE_TRIAL_DURATION_IN_DAYS', + ), + }, + automatic_tax: { enabled: !!requirePaymentMethod }, + tax_id_collection: { enabled: !!requirePaymentMethod }, + customer: stripeCustomerId, + customer_update: stripeCustomerId ? { name: 'auto' } : undefined, + customer_email: stripeCustomerId ? undefined : user.email, + success_url: successUrl, + cancel_url: cancelUrl, + payment_method_collection: requirePaymentMethod + ? 'always' + : 'if_required', + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts new file mode 100644 index 000000000000..e8ab972d011e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts @@ -0,0 +1,33 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class StripeCustomerService { + protected readonly logger = new Logger(StripeCustomerService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async updateCustomerMetadataWorkspaceId( + stripeCustomerId: string, + workspaceId: string, + ) { + await this.stripe.customers.update(stripeCustomerId, { + metadata: { workspaceId: workspaceId }, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts new file mode 100644 index 000000000000..83e4058f41e1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-price.service.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; +import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class StripePriceService { + protected readonly logger = new Logger(StripePriceService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async getStripePrices(product: AvailableProduct) { + const stripeProductId = this.getStripeProductId(product); + + const prices = await this.stripe.prices.search({ + query: `product: '${stripeProductId}'`, + }); + + return this.formatProductPrices(prices.data); + } + + async getStripePrice(product: AvailableProduct, recurringInterval: string) { + const productPrices = await this.getStripePrices(product); + + return productPrices.find( + (price) => price.recurringInterval === recurringInterval, + ); + } + + getStripeProductId(product: AvailableProduct) { + if (product === AvailableProduct.BasePlan) { + return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID'); + } + } // PD:,will be eliminated after refactoring + + formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] { + const productPrices: ProductPriceEntity[] = Object.values( + prices + .filter((item) => item.recurring?.interval && item.unit_amount) + .reduce((acc, item: Stripe.Price) => { + const interval = item.recurring?.interval; + + if (!interval || !item.unit_amount) { + return acc; + } + + if (!acc[interval] || item.created > acc[interval].created) { + acc[interval] = { + unitAmount: item.unit_amount, + recurringInterval: interval, + created: item.created, + stripePriceId: item.id, + }; + } + + return acc satisfies Record; + }, {}), + ); + + return productPrices.sort((a, b) => a.unitAmount - b.unitAmount); + } + + async getPricesByProductId(productId: string) { + const prices = await this.stripe.prices.search({ + query: `product:'${productId}'`, + }); + + return prices.data; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts new file mode 100644 index 000000000000..51aa3f9c47dd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-product.service.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class StripeProductService { + protected readonly logger = new Logger(StripeProductService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async getAllProducts() { + const products = await this.stripe.products.list(); + + return products.data; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts new file mode 100644 index 000000000000..3b0c4029e827 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service.ts @@ -0,0 +1,45 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class StripeSubscriptionItemService { + protected readonly logger = new Logger(StripeSubscriptionItemService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async updateSubscriptionItem(stripeItemId: string, quantity: number) { + await this.stripe.subscriptionItems.update(stripeItemId, { quantity }); + } + + async updateBillingSubscriptionItem( + stripeSubscriptionItem: BillingSubscriptionItem, + stripePriceId: string, + ) { + await this.stripe.subscriptionItems.update( + stripeSubscriptionItem.stripeSubscriptionItemId, + { + price: stripePriceId, + quantity: + stripeSubscriptionItem.quantity === null + ? undefined + : stripeSubscriptionItem.quantity, + }, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts new file mode 100644 index 000000000000..686aafe479cc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + +@Injectable() +export class StripeSubscriptionService { + protected readonly logger = new Logger(StripeSubscriptionService.name); + private readonly stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + async cancelSubscription(stripeSubscriptionId: string) { + await this.stripe.subscriptions.cancel(stripeSubscriptionId); + } + + async getStripeCustomerIdFromWorkspaceId(workspaceId: string) { + const subscription = await this.stripe.subscriptions.search({ + query: `metadata['workspaceId']:'${workspaceId}'`, + limit: 1, + }); + const stripeCustomerId = subscription.data[0].customer + ? String(subscription.data[0].customer) + : undefined; + + return stripeCustomerId; + } + + async collectLastInvoice(stripeSubscriptionId: string) { + const subscription = await this.stripe.subscriptions.retrieve( + stripeSubscriptionId, + { expand: ['latest_invoice'] }, + ); + const latestInvoice = subscription.latest_invoice; + + if ( + !( + latestInvoice && + typeof latestInvoice !== 'string' && + latestInvoice.status === 'draft' + ) + ) { + return; + } + await this.stripe.invoices.pay(latestInvoice.id); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-webhook.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-webhook.service.ts new file mode 100644 index 000000000000..bc218a704996 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-webhook.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +@Injectable() +export class StripeWebhookService { + protected readonly logger = new Logger(StripeWebhookService.name); + private stripe: Stripe; + + constructor( + private readonly environmentService: EnvironmentService, + private readonly stripeSDKService: StripeSDKService, + ) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = this.stripeSDKService.getStripe( + this.environmentService.get('BILLING_STRIPE_API_KEY'), + ); + } + + constructEventFromPayload(signature: string, payload: Buffer) { + const webhookSecret = this.environmentService.get( + 'BILLING_STRIPE_WEBHOOK_SECRET', + ); + + const returnValue = this.stripe.webhooks.constructEvent( + payload, + signature, + webhookSecret, + ); + + return returnValue; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service.ts new file mode 100644 index 000000000000..e26bb3cd952b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; + +import Stripe from 'stripe'; + +import { StripeSDKMock } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk.mock'; +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; + +@Injectable() +export class StripeSDKMockService implements StripeSDKService { + getStripe(stripeApiKey: string) { + return new StripeSDKMock(stripeApiKey) as unknown as Stripe; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk.mock.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk.mock.ts new file mode 100644 index 000000000000..719cb87fa49e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk.mock.ts @@ -0,0 +1,29 @@ +import Stripe from 'stripe'; + +export class StripeSDKMock { + constructor(private readonly apiKey: string) {} + + customers = { + update: (_id: string, _params?: Stripe.CustomerUpdateParams) => { + return; + }, + }; + + webhooks = { + constructEvent: ( + payload: Buffer, + signature: string, + _webhookSecret: string, + ) => { + if (signature === 'correct-signature') { + const body = JSON.parse(payload.toString()); + + return { + type: body.type, + data: body.data, + }; + } + throw new Error('Invalid signature'); + }, + }; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service.ts new file mode 100644 index 000000000000..a8b95dc5196d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; + +import Stripe from 'stripe'; + +@Injectable() +export class StripeSDKService { + getStripe(stripeApiKey: string) { + return new Stripe(stripeApiKey, {}); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module.ts new file mode 100644 index 000000000000..5256a28cf27c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; + +@Module({ + providers: [StripeSDKService], + exports: [StripeSDKService], +}) +export class StripeSDKModule {} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts index f85a8930fe64..c7e2dfca4df3 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts @@ -1,11 +1,40 @@ import { Module } from '@nestjs/common'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; +import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service'; +import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service'; +import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; +import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; +import { StripeProductService } from 'src/engine/core-modules/billing/stripe/services/stripe-product.service'; +import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; +import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; +import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service'; +import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; @Module({ - imports: [DomainManagerModule], - providers: [StripeService], - exports: [StripeService], + imports: [DomainManagerModule, StripeSDKModule], + providers: [ + StripeSubscriptionItemService, + StripeWebhookService, + StripeCheckoutService, + StripeSubscriptionService, + StripeBillingPortalService, + StripeBillingMeterService, + StripeCustomerService, + StripePriceService, + StripeProductService, + ], + exports: [ + StripeWebhookService, + StripeBillingPortalService, + StripeBillingMeterService, + StripeCustomerService, + StripePriceService, + StripeCheckoutService, + StripeSubscriptionItemService, + StripeSubscriptionService, + StripeProductService, + ], }) export class StripeModule {} diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts deleted file mode 100644 index ae44628c22fc..000000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import Stripe from 'stripe'; - -import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; -import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; -import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; -import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; -import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; - -@Injectable() -export class StripeService { - protected readonly logger = new Logger(StripeService.name); - private readonly stripe: Stripe; - - constructor( - private readonly environmentService: EnvironmentService, - private readonly domainManagerService: DomainManagerService, - ) { - if (!this.environmentService.get('IS_BILLING_ENABLED')) { - return; - } - this.stripe = new Stripe( - this.environmentService.get('BILLING_STRIPE_API_KEY'), - {}, - ); - } - - constructEventFromPayload(signature: string, payload: Buffer) { - const webhookSecret = this.environmentService.get( - 'BILLING_STRIPE_WEBHOOK_SECRET', - ); - - return this.stripe.webhooks.constructEvent( - payload, - signature, - webhookSecret, - ); - } - - async getStripePrices(product: AvailableProduct) { - const stripeProductId = this.getStripeProductId(product); - - const prices = await this.stripe.prices.search({ - query: `product: '${stripeProductId}'`, - }); - - return this.formatProductPrices(prices.data); - } - - async getStripePrice(product: AvailableProduct, recurringInterval: string) { - const productPrices = await this.getStripePrices(product); - - return productPrices.find( - (price) => price.recurringInterval === recurringInterval, - ); - } - - getStripeProductId(product: AvailableProduct) { - if (product === AvailableProduct.BasePlan) { - return this.environmentService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID'); - } - } - - async updateSubscriptionItem(stripeItemId: string, quantity: number) { - await this.stripe.subscriptionItems.update(stripeItemId, { quantity }); - } - - async cancelSubscription(stripeSubscriptionId: string) { - await this.stripe.subscriptions.cancel(stripeSubscriptionId); - } - - async createBillingPortalSession( - stripeCustomerId: string, - returnUrl?: string, - ): Promise { - return await this.stripe.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: - returnUrl ?? this.domainManagerService.getBaseUrl().toString(), - }); - } - - async createCheckoutSession( - user: User, - workspaceId: string, - priceId: string, - quantity: number, - successUrl?: string, - cancelUrl?: string, - stripeCustomerId?: string, - plan: BillingPlanKey = BillingPlanKey.PRO, - requirePaymentMethod = true, - ): Promise { - return await this.stripe.checkout.sessions.create({ - line_items: [ - { - price: priceId, - quantity, - }, - ], - mode: 'subscription', - subscription_data: { - metadata: { - workspaceId, - plan, - }, - trial_period_days: this.environmentService.get( - 'BILLING_FREE_TRIAL_DURATION_IN_DAYS', - ), - }, - automatic_tax: { enabled: !!requirePaymentMethod }, - tax_id_collection: { enabled: !!requirePaymentMethod }, - customer: stripeCustomerId, - customer_update: stripeCustomerId ? { name: 'auto' } : undefined, - customer_email: stripeCustomerId ? undefined : user.email, - success_url: successUrl, - cancel_url: cancelUrl, - payment_method_collection: requirePaymentMethod - ? 'always' - : 'if_required', - }); - } - - async collectLastInvoice(stripeSubscriptionId: string) { - const subscription = await this.stripe.subscriptions.retrieve( - stripeSubscriptionId, - { expand: ['latest_invoice'] }, - ); - const latestInvoice = subscription.latest_invoice; - - if ( - !( - latestInvoice && - typeof latestInvoice !== 'string' && - latestInvoice.status === 'draft' - ) - ) { - return; - } - await this.stripe.invoices.pay(latestInvoice.id); - } - - async updateBillingSubscriptionItem( - stripeSubscriptionItem: BillingSubscriptionItem, - stripePriceId: string, - ) { - await this.stripe.subscriptionItems.update( - stripeSubscriptionItem.stripeSubscriptionItemId, - { - price: stripePriceId, - quantity: - stripeSubscriptionItem.quantity === null - ? undefined - : stripeSubscriptionItem.quantity, - }, - ); - } - - async updateCustomerMetadataWorkspaceId( - stripeCustomerId: string, - workspaceId: string, - ) { - await this.stripe.customers.update(stripeCustomerId, { - metadata: { workspaceId: workspaceId }, - }); - } - - async getMeter(stripeMeterId: string) { - return await this.stripe.billing.meters.retrieve(stripeMeterId); - } - - formatProductPrices(prices: Stripe.Price[]): ProductPriceEntity[] { - const productPrices: ProductPriceEntity[] = Object.values( - prices - .filter((item) => item.recurring?.interval && item.unit_amount) - .reduce((acc, item: Stripe.Price) => { - const interval = item.recurring?.interval; - - if (!interval || !item.unit_amount) { - return acc; - } - - if (!acc[interval] || item.created > acc[interval].created) { - acc[interval] = { - unitAmount: item.unit_amount, - recurringInterval: interval, - created: item.created, - stripePriceId: item.id, - }; - } - - return acc satisfies Record; - }, {}), - ); - - return productPrices.sort((a, b) => a.unitAmount - b.unitAmount); - } - - async getStripeCustomerIdFromWorkspaceId(workspaceId: string) { - const subscription = await this.stripe.subscriptions.search({ - query: `metadata['workspaceId']:'${workspaceId}'`, - limit: 1, - }); - const stripeCustomerId = subscription.data[0].customer - ? String(subscription.data[0].customer) - : undefined; - - return stripeCustomerId; - } - - async getAllProducts() { - const products = await this.stripe.products.list(); - - return products.data; - } - - async getPricesByProductId(productId: string) { - const prices = await this.stripe.prices.search({ - query: `product:'${productId}'`, - }); - - return prices.data; - } - - async getAllMeters() { - const meters = await this.stripe.billing.meters.list(); - - return meters.data; - } -} diff --git a/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts b/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts index e86fa68f7fac..b5b74fb90ea2 100644 --- a/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts +++ b/packages/twenty-server/test/integration/billing/suites/billing-controller.integration-spec.ts @@ -1,4 +1,5 @@ import request from 'supertest'; +import { createMockStripeEntitlementUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util'; import { createMockStripePriceCreatedData } from 'test/integration/billing/utils/create-mock-stripe-price-created-data.util'; import { createMockStripeProductUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-product-updated-data.util'; import { createMockStripeSubscriptionCreatedData } from 'test/integration/billing/utils/create-mock-stripe-subscription-created-data.util'; @@ -7,17 +8,21 @@ const client = request(`http://localhost:${APP_PORT}`); describe('BillingController (integration)', () => { it('should handle product.updated and price.created webhook events', async () => { - const payload = { + const productUpdatedPayload = { type: 'product.updated', data: createMockStripeProductUpdatedData(), }; + const priceCreatedPayload = { + type: 'price.created', + data: createMockStripePriceCreatedData(), + }; await client .post('/billing/webhooks') .set('Authorization', `Bearer ${ACCESS_TOKEN}`) .set('stripe-signature', 'correct-signature') .set('Content-Type', 'application/json') - .send(JSON.stringify(payload)) + .send(JSON.stringify(productUpdatedPayload)) .expect(200) .then((res) => { expect(res.body.stripeProductId).toBeDefined(); @@ -28,12 +33,7 @@ describe('BillingController (integration)', () => { .set('Authorization', `Bearer ${ACCESS_TOKEN}`) .set('stripe-signature', 'correct-signature') .set('Content-Type', 'application/json') - .send( - JSON.stringify({ - type: 'price.created', - data: createMockStripePriceCreatedData(), - }), - ) + .send(JSON.stringify(priceCreatedPayload)) .expect(200) .then((res) => { expect(res.body.stripePriceId).toBeDefined(); @@ -41,26 +41,58 @@ describe('BillingController (integration)', () => { }); }); it('should handle subscription.created webhook event', async () => { - const payload = { + const subscriptionCreatedPayload = { type: 'customer.subscription.created', data: createMockStripeSubscriptionCreatedData(), }; + const entitlementUpdatedPayload = { + type: 'entitlements.active_entitlement_summary.updated', + data: createMockStripeEntitlementUpdatedData(), + }; await client .post('/billing/webhooks') .set('Authorization', `Bearer ${ACCESS_TOKEN}`) .set('stripe-signature', 'correct-signature') .set('Content-Type', 'application/json') - .send(JSON.stringify(payload)) + .send(JSON.stringify(subscriptionCreatedPayload)) .expect(200) .then((res) => { expect(res.body.stripeSubscriptionId).toBeDefined(); expect(res.body.stripeCustomerId).toBeDefined(); }); + + await client + .post('/billing/webhooks') + .set('Authorization', `Bearer ${ACCESS_TOKEN}`) + .set('stripe-signature', 'correct-signature') + .set('Content-Type', 'application/json') + .send(JSON.stringify(entitlementUpdatedPayload)) + .expect(200) + .then((res) => { + expect(res.body.stripeEntitlementCustomerId).toBeDefined(); + }); + }); + + it('should handle entitlements.active_entitlement_summary.updated when the subscription is not found', async () => { + const entitlementUpdatedPayload = { + type: 'entitlements.active_entitlement_summary.updated', + data: createMockStripeEntitlementUpdatedData({ + customer: 'new_customer', + }), + }; + + await client + .post('/billing/webhooks') + .set('Authorization', `Bearer ${ACCESS_TOKEN}`) + .set('stripe-signature', 'correct-signature') + .set('Content-Type', 'application/json') + .send(JSON.stringify(entitlementUpdatedPayload)) + .expect(404); }); it('should reject webhook with invalid signature', async () => { - const payload = { + const entitlementUpdatedPayload = { type: 'customer.entitlement.created', data: { object: { @@ -74,7 +106,7 @@ describe('BillingController (integration)', () => { .set('Authorization', `Bearer ${ACCESS_TOKEN}`) .set('stripe-signature', 'invalid-signature') .set('Content-Type', 'application/json') - .send(JSON.stringify(payload)) + .send(JSON.stringify(entitlementUpdatedPayload)) .expect(500); }); }); diff --git a/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util.ts b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util.ts new file mode 100644 index 000000000000..1a7e8b7f6f0b --- /dev/null +++ b/packages/twenty-server/test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util.ts @@ -0,0 +1,25 @@ +import Stripe from 'stripe'; +export const createMockStripeEntitlementUpdatedData = ( + overrides = {}, +): Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data => ({ + object: { + object: 'entitlements.active_entitlement_summary', + customer: 'cus_default1', + livemode: false, + entitlements: { + object: 'list', + data: [ + { + id: 'ent_test_61', + object: 'entitlements.active_entitlement', + feature: 'feat_test_61', + livemode: false, + lookup_key: 'SSO', + }, + ], + has_more: false, + url: '/v1/customer/cus_Q/entitlements', + }, + ...overrides, + }, +}); diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index c1cfde5156f3..b5eeabf5f3df 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -2,7 +2,8 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; import { AppModule } from 'src/app.module'; -import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service'; +import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; interface TestingModuleCreatePreHook { (moduleBuilder: TestingModuleBuilder): TestingModuleBuilder; @@ -24,29 +25,12 @@ export const createApp = async ( appInitHook?: TestingAppCreatePreHook; } = {}, ): Promise => { + const stripeSDKMockService = new StripeSDKMockService(); let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({ imports: [AppModule], }) - .overrideProvider(StripeService) - .useValue({ - constructEventFromPayload: (signature: string, payload: Buffer) => { - if (signature === 'correct-signature') { - const body = JSON.parse(payload.toString()); - - return { - type: body.type, - data: body.data, - }; - } - throw new Error('Invalid signature'); - }, - updateCustomerMetadataWorkspaceId: ( - _customerId: string, - _workspaceId: string, - ) => { - return; - }, - }); + .overrideProvider(StripeSDKService) + .useValue(stripeSDKMockService); if (config.moduleBuilderHook) { moduleBuilder = config.moduleBuilderHook(moduleBuilder);