From 7692fb2b6c079cac57f9ed9c722feea097ac4e16 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Tue, 30 Sep 2025 10:01:51 +0200 Subject: [PATCH] chore(billing): add tests + fix meter name for trial (#14701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Félix Malfait Co-authored-by: Righteousness Akinbola Co-authored-by: Charles Bochet Co-authored-by: Charles Bochet Co-authored-by: Weiko Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: martmull Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: Abdullah. <125115953+mabdullahabaid@users.noreply.github.com> Co-authored-by: Thomas Trompette Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> --- .../SettingsBillingCreditsSection.tsx | 34 +- .../scripts/stripe/update-prices-ids.ts | 219 -- .../scripts/stripe/update-stripe-prices.ts | 206 -- ...58117800000-activate-unaccent-extension.ts | 12 +- .../core-modules/billing/billing.exception.ts | 1 + ...lling-update-subscription-price.command.ts | 11 - .../billing-subscription.service.spec.ts | 3106 +++++++++++++---- .../services/billing-subscription.service.ts | 160 +- .../billing/services/billing-usage.service.ts | 21 +- .../services/stripe-billing-alert.service.ts | 13 +- .../stripe-subscription-schedule.service.ts | 40 +- .../services/stripe-subscription.service.ts | 26 - .../workspace/workspace.resolver.ts | 6 +- 13 files changed, 2663 insertions(+), 1192 deletions(-) delete mode 100644 packages/twenty-server/scripts/stripe/update-prices-ids.ts delete mode 100644 packages/twenty-server/scripts/stripe/update-stripe-prices.ts diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx index a24ad1cac3e..dc4b0c413af 100644 --- a/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx +++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx @@ -79,25 +79,23 @@ export const SettingsBillingCreditsSection = ({ withBorderRadius={true} /> - {!isTrialing && ( - - )} - {!isTrialing && ( - - )} - {!isTrialing && ( - + <> + + + + + )} diff --git a/packages/twenty-server/scripts/stripe/update-prices-ids.ts b/packages/twenty-server/scripts/stripe/update-prices-ids.ts deleted file mode 100644 index 2df048cccc9..00000000000 --- a/packages/twenty-server/scripts/stripe/update-prices-ids.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* eslint-disable */ -import Stripe from 'stripe'; - -const stripe = new Stripe(process.env.STRIPE_API_KEY as string, { - apiVersion: '2024-10-28.acacia', -}); - -const PRODUCT_ID = process.env.PRODUCT_ID!; -const DRY_RUN = !!process.env.DRY_RUN; -const SLEEP_MS = parseInt(process.env.SLEEP_MS ?? '50', 10); - -const tiers = [ - // Monthly - [10_000, 5_000_000], - [50_000, 10_000_000], - [100_000, 50_000_000], - [120_000, 110_000_000], - - // Yearly - [120_000, 50_000_000], - [600_000, 130_000_000], - [1_200_000, 540_000_000], -] as Array<[number, number]>; - -if (!process.env.STRIPE_API_KEY || !process.env.PRODUCT_ID) { - console.error('STRIPE_API_KEY manquant ou PRODUCT_ID manquant'); - process.exit(1); -} - -type PriceMap = Map; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -async function loadPriceMapping([TIER_SRC, TIER_DST]: [ - number, - number, -]): Promise { - const { data: prices } = await stripe.prices.list({ - type: 'recurring', - product: PRODUCT_ID, - limit: 100, - expand: ['data.tiers', 'data.recurring'], - }); - - const byProduct = new Map(); - - for (const p of prices) { - if (p.recurring?.usage_type !== 'metered') continue; - if (!Array.isArray(p.tiers)) continue; - const arr = byProduct.get(p.product as string) ?? []; - - arr.push(p); - byProduct.set(p.product as string, arr); - } - - const mapByProduct: PriceMap = new Map(); - - for (const [productId, plist] of byProduct.entries()) { - const src = plist.find((pr) => pr.tiers?.some((t) => t.up_to === TIER_SRC)); - const dst = plist.find((pr) => pr.tiers?.some((t) => t.up_to === TIER_DST)); - - if (src && dst) { - mapByProduct.set(productId, { - sourcePriceId: src.id, - targetPriceId: dst.id, - }); - } - } - - return mapByProduct; -} - -async function getCurrentPeriodTotalUsageMeters(params: { - meterId: string; - customerId: string; - start: number; - end: number; -}): Promise { - const { meterId, customerId, start, end } = params; - const res = await stripe.billing.meters.listEventSummaries(meterId, { - customer: customerId, - start_time: start, - end_time: end, - }); - - // Additionne aggregated_value si le meter regroupe par fenêtres - return (res.data as Array<{ aggregated_value?: number }>).reduce( - (s, x) => s + (x.aggregated_value ?? 0), - 0, - ); -} - -function toCustomerId( - cust: string | Stripe.Customer | Stripe.DeletedCustomer, -): string { - if (typeof cust === 'string') return cust; - - return cust.id as string; -} - -async function main(tier: [number, number]) { - const priceMap = await loadPriceMapping(tier); - - if (priceMap.size === 0) { - console.log(`Aucun mapping price ${tier[0]} -> ${tier[1]} trouvé`); - process.exit(0); - } - - let examined = 0; - let modified = 0; - let copiedTotal = 0; - - for (const status of [ - 'active', - 'trialing', - ] as Array) { - let starting_after: string | undefined; - - while (true) { - const subs = await stripe.subscriptions.list({ - status, - limit: 100, - ...(starting_after ? { starting_after } : {}), - price: priceMap.get(PRODUCT_ID)?.sourcePriceId, - expand: ['data.items.data.price'], - }); - - for (const sub of subs.data) { - examined++; - const periodStart = sub.current_period_start as number; - const nowTs = Math.floor(Date.now() / 1000); - const customerId = toCustomerId(sub.customer); - - const targets = sub.items.data.filter((it) => { - const m = priceMap.get(it.price.product as string); - - return m && it.price.id === m.sourcePriceId; - }); - - if (targets.length === 0) continue; - - const item = targets[0]; - - const meterId = item.price.recurring?.meter; - - if (!meterId) continue; // pas un meter moderne - - const mapping = priceMap.get(item.price.product as string)!; - - const total = await getCurrentPeriodTotalUsageMeters({ - meterId, - customerId, - start: periodStart, - end: nowTs, - }); - - let newItemId: string; - - if (DRY_RUN) { - console.log( - `[DRY_RUN] CREATE subscription_item price=${mapping.targetPriceId} subscription=${sub.id}`, - ); - newItemId = `dry_${Date.now()}`; - } else { - const created = await stripe.subscriptionItems.create({ - subscription: sub.id, - price: mapping.targetPriceId, - ...(item.quantity ? { quantity: item.quantity } : {}), - }); - - newItemId = created.id; - } - await sleep(SLEEP_MS); - - if (total > 0) { - if (DRY_RUN) { - console.log( - `[DRY_RUN] USAGE new_si=${newItemId} usage=${total * 1000}}`, - ); - copiedTotal += total; - } else { - await stripe.subscriptionItems.createUsageRecord(newItemId, { - quantity: total * 1000, - timestamp: nowTs, - action: 'increment', - }); - } - copiedTotal += total; - await sleep(SLEEP_MS); - } - - if (DRY_RUN) { - console.log( - `[DRY_RUN] DELETE ${item.id}?clear_usage=true (total_usage=${total})`, - ); - } else { - await stripe.subscriptionItems.del(item.id, { clear_usage: true }); - } - await sleep(SLEEP_MS); - - modified++; - console.log('\n'); - } - - if (!subs.has_more) break; - starting_after = subs.data[subs.data.length - 1].id; - } - } - console.log( - `Terminé [${tier[0]} -> ${tier[1]}]. Subscriptions examinées: ${examined}. Modifiées: ${modified}.`, - ); -} - -for (const tier of tiers) { - main(tier).catch((err) => { - console.error(err); - process.exit(1); - }); -} diff --git a/packages/twenty-server/scripts/stripe/update-stripe-prices.ts b/packages/twenty-server/scripts/stripe/update-stripe-prices.ts deleted file mode 100644 index 9a02410a17c..00000000000 --- a/packages/twenty-server/scripts/stripe/update-stripe-prices.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* eslint-disable */ -// Usage: STRIPE_API_KEY=sk_live_xxx PRODUCT_ID=prod_xxx node update-stripe-prices.js - -import crypto from 'crypto'; - -import Stripe from 'stripe'; - -if (!process.env.STRIPE_API_KEY || !process.env.PRODUCT_ID) { - console.error('Missing STRIPE_API_KEY or PRODUCT_ID'); - process.exit(1); -} - -const stripe = new Stripe(process.env.STRIPE_API_KEY, { - apiVersion: '2024-10-28.acacia', -}); - -const productId = process.env.PRODUCT_ID; -const currency = 'usd'; - -const prices: Array<{ - interval: 'month' | 'year'; - usd: number; - credits: number; - pricePer1MAbove: number; -}> = [ - // Monthly - { interval: 'month', usd: 0, credits: 5_000_000, pricePer1MAbove: 8.0 }, - { - interval: 'month', - usd: 29, - credits: 10_000_000, - pricePer1MAbove: 4.35, - }, - { - interval: 'month', - usd: 99, - credits: 50_000_000, - pricePer1MAbove: 2.97, - }, - { - interval: 'month', - usd: 199, - credits: 110_000_000, - pricePer1MAbove: 2.71, - }, - { - interval: 'month', - usd: 399, - credits: 240_000_000, - pricePer1MAbove: 2.49, - }, - { - interval: 'month', - usd: 999, - credits: 700_000_000, - pricePer1MAbove: 1.43, - }, - // Yearly - { interval: 'year', usd: 0, credits: 50_000_000, pricePer1MAbove: 7.6 }, - { - interval: 'year', - usd: 290, - credits: 130_000_000, - pricePer1MAbove: 3.35, - }, - { - interval: 'year', - usd: 990, - credits: 540_000_000, - pricePer1MAbove: 2.75, - }, - { - interval: 'year', - usd: 1990, - credits: 1_200_000_000, - pricePer1MAbove: 2.49, - }, - { - interval: 'year', - usd: 3990, - credits: 2_600_000_000, - pricePer1MAbove: 2.3, - }, - { - interval: 'year', - usd: 9990, - credits: 7_500_000_000, - pricePer1MAbove: 1.33, - }, -] as const; - -const toCents = (usd: number) => Math.round(usd * 100); -const perCreditCentsDecimal = (pricePer1kUSD: number) => - ((pricePer1kUSD * 100) / 1000000).toFixed(5); - -const formatCredits = (credits: number) => credits.toLocaleString('de-DE'); // example: 7.500.000 - -const makeNickname = (price: (typeof prices)[0]) => - `${formatCredits(price.credits)} Credits`; - -const makeIdemKey = (price: (typeof prices)[0], meter: Stripe.Billing.Meter) => - 'price_' + - crypto - .createHash('sha256') - .update( - [ - price.interval, - price.usd, - price.credits, - currency, - price.pricePer1MAbove, - productId, - meter.id, - ].join('='), - ) - .digest('hex') - .slice(0, 32); - -const main = async () => { - const createdNicknames = []; - - const meter = ( - await stripe.billing.meters.list({ status: 'active' }) - ).data.find((m) => m.event_name === 'WORKFLOW_NODE_RUN'); - - if (!meter) { - throw new Error('Meter not found'); - } - - for (const price of prices) { - const flatAmountCents = toCents(price.usd); - const idemKey = makeIdemKey(price, meter); - const nickname = makeNickname(price); - - createdNicknames.push(nickname); - - const unitAmountPerCreditCentsDecimal = perCreditCentsDecimal( - price.pricePer1MAbove, - ); - - try { - const priceCreated = await stripe.prices.create( - { - product: productId, - currency, - nickname, - billing_scheme: 'tiered', - tiers_mode: 'graduated', - recurring: { - interval: price.interval, - usage_type: 'metered', - meter: meter.id, - }, - tiers: [ - { - up_to: price.credits, - flat_amount: flatAmountCents, - }, - { - up_to: 'inf', - unit_amount_decimal: unitAmountPerCreditCentsDecimal, - }, - ], - }, - { idempotencyKey: idemKey }, - ); - - console.log(`Created/exists: ${priceCreated.id} -> ${nickname}`); - } catch (e) { - console.error(`Failed: ${nickname}`); - console.error(e); - process.exitCode = 1; - } - } - - console.log('\n--- Archiving old prices ---'); - let hasMore = true; - let startingAfter = undefined; - - while (hasMore) { - const { data, has_more }: { data: Array; has_more: boolean } = - await stripe.prices.list({ - product: productId, - limit: 100, - starting_after: startingAfter, - }); - - for (const price of data) { - if (price.nickname && !createdNicknames.includes(price.nickname)) { - if (price.active) { - await stripe.prices.update(price.id, { active: false }); - console.log(`Archived: ${price.id} (${price.nickname})`); - } - } - } - - if (has_more) { - startingAfter = data[data.length - 1].id; - } - } - - console.log('\nDone.'); - process.exit(0); -}; - -main(); diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1758117800000-activate-unaccent-extension.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1758117800000-activate-unaccent-extension.ts index e7261167e01..8e7239eb3f7 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/common/1758117800000-activate-unaccent-extension.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1758117800000-activate-unaccent-extension.ts @@ -15,12 +15,12 @@ export class ActivateUnaccentExtension1758117800000 await queryRunner.query( `CREATE OR REPLACE FUNCTION public.unaccent_immutable(input text) - RETURNS text - LANGUAGE sql - IMMUTABLE -AS $$ -SELECT public.unaccent('public.unaccent'::regdictionary, input) -$$;`, + RETURNS text + LANGUAGE sql + IMMUTABLE + AS $$ + SELECT public.unaccent('public.unaccent'::regdictionary, input) + $$;`, ); } catch { // Ignore error 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 d8ecf54cbd5..d0ce4822073 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 @@ -28,4 +28,5 @@ export enum BillingExceptionCode { BILLING_PRICE_INVALID_TIERS = 'BILLING_PRICE_INVALID_TIERS', BILLING_PRICE_INVALID = 'BILLING_PRICE_INVALID', BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND', + BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND', } diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts index e972f3c579c..d6655544f5f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts @@ -10,10 +10,6 @@ import { ActiveOrSuspendedWorkspacesMigrationCommandRunner, type RunOnWorkspaceArgs, } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; -import { - BillingException, - BillingExceptionCode, -} from 'src/engine/core-modules/billing/billing.exception'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; @@ -81,13 +77,6 @@ export class BillingUpdateSubscriptionPriceCommand extends ActiveOrSuspendedWork { workspaceId }, ); - if (!isDefined(subscription)) { - throw new BillingException( - `No subscription found for workspace ${workspaceId}`, - BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND, - ); - } - const subscriptionItemToUpdate = subscription.billingSubscriptionItems.find( (item) => item.stripePriceId === this.stripePriceIdToUpdate, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts index bce992a4d7c..2186fa51cae 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.spec.ts @@ -1,5 +1,3 @@ -/* @license Enterprise */ - import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -7,18 +5,16 @@ import { type ObjectLiteral, type Repository } from 'typeorm'; import type Stripe from 'stripe'; -import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; +import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; +import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; 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 { 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 { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; -import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; -import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; -import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service'; @@ -26,116 +22,16 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; +import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; +import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type'; +import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; +import { type BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; +import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type'; +import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; -import { BillingSubscriptionService } from './billing-subscription.service'; - -// Minimal fixtures -function mkWorkspace(): Workspace { - return { id: 'ws_1' } as Workspace; -} - -function mkLicensedPrice(overrides: Partial = {}): BillingPrice { - return { - id: 'price_db_licensed', - stripePriceId: 'price_licensed_m', - interval: SubscriptionInterval.Month, - active: true, - billingProduct: { - metadata: { - productKey: BillingProductKey.BASE_PRODUCT, - planKey: BillingPlanKey.PRO, - priceUsageBased: BillingUsageType.LICENSED, - }, - } as any, - tiers: [] as any, - ...overrides, - } as BillingPrice; -} - -function mkMeteredPrice(overrides: Partial = {}): BillingPrice { - return { - id: 'price_db_metered', - stripePriceId: 'price_metered_m_1000', - interval: SubscriptionInterval.Month, - active: true, - billingProduct: { - metadata: { - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - planKey: BillingPlanKey.PRO, - priceUsageBased: BillingUsageType.METERED, - }, - } as any, - tiers: [ - { up_to: 1000, unit_amount_decimal: '0' }, - { up_to: null, unit_amount_decimal: '5' }, - ] as any, - ...overrides, - } as BillingPrice; -} - -function mkSubscriptionEntity( - overrides: Partial = {}, -): BillingSubscription { - return { - id: 'sub_db_1', - workspaceId: 'ws_1', - stripeSubscriptionId: 'sub_1', - status: SubscriptionStatus.Active, - interval: SubscriptionInterval.Month, - billingSubscriptionItems: [ - { - stripeSubscriptionItemId: 'si_licensed', - stripeProductId: 'prod_base', - stripePriceId: 'price_licensed_m', - quantity: 7, - billingProduct: { - metadata: { - productKey: BillingProductKey.BASE_PRODUCT, - planKey: BillingPlanKey.PRO, - priceUsageBased: BillingUsageType.LICENSED, - }, - } as any, - } as BillingSubscriptionItem, - { - stripeSubscriptionItemId: 'si_metered', - stripeProductId: 'prod_metered', - stripePriceId: 'price_metered_m_1000', - billingProduct: { - metadata: { - productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, - planKey: BillingPlanKey.PRO, - priceUsageBased: BillingUsageType.METERED, - }, - } as any, - } as BillingSubscriptionItem, - ], - ...overrides, - } as BillingSubscription; -} - -function mkStripeSchedulePhase( - items: Array<{ price: string; quantity?: number }>, - start: number, - end?: number, -): Stripe.SubscriptionSchedule.Phase { - return { - start_date: start, - end_date: end, - items: items.map((i) => ({ price: i.price, quantity: i.quantity })), - } as any; -} - -function mkScheduleWithEditable(phases: { - current: Stripe.SubscriptionSchedule.Phase; - next?: Stripe.SubscriptionSchedule.Phase; -}) { - return { - id: 'ssch_1', - phases: [phases.current, ...(phases.next ? [phases.next] : [])], - } as unknown as Stripe.SubscriptionSchedule; -} - -// Mocks factory +// Minimal fixtures (duplicate of service spec) function repoMock() { return { find: jest.fn(), @@ -149,568 +45,2516 @@ function repoMock() { } as unknown as jest.Mocked>; } -describe('BillingSubscriptionService', () => { +const LICENSE_PRICE_ENTERPRISE_MONTH_ID = 'LICENSE_PRICE_ENTERPRISE_MONTH_ID'; +const LICENSE_PRICE_PRO_MONTH_ID = 'LICENCE_PRICE_PRO_MONTH_ID'; +const LICENSE_PRICE_ENTERPRISE_YEAR_ID = 'LICENSE_PRICE_ENTERPRISE_YEAR_ID'; +const LICENSE_PRICE_PRO_YEAR_ID = 'LICENSE_PRICE_PRO_YEAR_ID'; + +const METER_PRICE_ENTERPRISE_MONTH_ID = 'METER_PRICE_ENTERPRISE_MONTH_ID'; +const METER_PRICE_PRO_MONTH_ID = 'METER_PRICE_PRO_MONTH_ID'; +const METER_PRICE_ENTERPRISE_YEAR_ID = 'METER_PRICE_ENTERPRISE_YEAR_ID'; +const METER_PRICE_PRO_YEAR_ID = 'METER_PRICE_PRO_YEAR_ID'; + +const METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID = + 'METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID'; +const METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID = + 'METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID'; +const METER_PRICE_PRO_MONTH_TIER_LOW_ID = 'METER_PRICE_PRO_MONTH_TIER_LOW_ID'; +const METER_PRICE_PRO_MONTH_TIER_HIGH_ID = 'METER_PRICE_PRO_MONTH_TIER_HIGH_ID'; + +describe('BillingManager scenarios (via BillingSubscriptionService)', () => { let module: TestingModule; let service: BillingSubscriptionService; + let billingSubscriptionRepository: Repository; + let billingPriceRepository: Repository; + let billingProductService: BillingProductService; + let stripeSubscriptionScheduleService: StripeSubscriptionScheduleService; + let stripeSubscriptionService: StripeSubscriptionService; + let billingSubscriptionPhaseService: BillingSubscriptionPhaseService; - // DB repos - let entitlementRepo: jest.Mocked>; - let subscriptionRepo: jest.Mocked>; - let priceRepo: jest.Mocked>; - let itemRepo: jest.Mocked>; - let customerRepo: jest.Mocked>; + const currentSubscription = { + id: 'sub_db_1', + workspaceId: 'ws_1', + stripeSubscriptionId: 'sub_1', + status: SubscriptionStatus.Active, + interval: SubscriptionInterval.Month, + billingSubscriptionItems: [ + { + stripeSubscriptionItemId: 'si_licensed', + stripeProductId: 'prod_base', + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }, + { + stripeSubscriptionItemId: 'si_metered', + stripeProductId: 'prod_metered', + stripePriceId: METER_PRICE_PRO_MONTH_ID, + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + }, + ], + } as BillingSubscription; - // External services (Stripe* + plan/product/phase) - let stripeSubSvc: jest.Mocked; - let stripeSchedSvc: jest.Mocked; - let stripeItemSvc: jest.Mocked; - let stripeCustomerSvc: jest.Mocked; - let planSvc: jest.Mocked; - let productSvc: jest.Mocked; - let phaseSvc: jest.Mocked; - let configSvc: jest.Mocked; + const arrangeBillingPriceRepositoryFindOneByOrFail = () => { + const resolvePrice = (criteria: any) => { + const priceId = + criteria?.stripePriceId ?? criteria?.where?.stripePriceId ?? criteria; + const id = String(priceId).toUpperCase(); + const isMetered = id.includes('METER'); + const interval = id.includes('YEAR') + ? SubscriptionInterval.Year + : SubscriptionInterval.Month; + + const base: Partial = { + stripePriceId: priceId, + interval, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: isMetered + ? BillingProductKey.WORKFLOW_NODE_EXECUTION + : BillingProductKey.BASE_PRODUCT, + priceUsageBased: isMetered + ? BillingUsageType.METERED + : BillingUsageType.LICENSED, + }, + } as BillingProduct, + }; + + if (isMetered) { + return { + ...base, + tiers: [ + { + up_to: 12000, + flat_amount: 12000, + unit_amount: null, + flat_amount_decimal: '1200000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '1200', + }, + ], + } as BillingMeterPrice; + } + + return base as BillingPrice; + }; + + jest + .spyOn(billingPriceRepository, 'findOneByOrFail') + .mockImplementation(async (criteria: any) => resolvePrice(criteria)); + + jest + .spyOn(billingPriceRepository, 'findOneOrFail') + .mockImplementation(async (criteria: any) => resolvePrice(criteria)); + }; + + const arrangeBillingSubscriptionRepositoryFind = ( + overrides: { + planKey?: BillingPlanKey; + interval?: SubscriptionInterval; + licensedPriceId?: string; + meteredPriceId?: string; + seats?: number; + workspaceId?: string; + stripeSubscriptionId?: string; + } = {}, + ) => { + const sub: BillingSubscription = { + ...currentSubscription, + workspaceId: overrides.workspaceId ?? currentSubscription.workspaceId, + stripeSubscriptionId: + overrides.stripeSubscriptionId ?? + currentSubscription.stripeSubscriptionId, + interval: overrides.interval ?? currentSubscription.interval, + billingSubscriptionItems: [ + { + ...currentSubscription.billingSubscriptionItems[0], + stripePriceId: + overrides.licensedPriceId ?? + currentSubscription.billingSubscriptionItems[0].stripePriceId, + quantity: + overrides.seats ?? + currentSubscription.billingSubscriptionItems[0].quantity ?? + 7, + billingProduct: { + ...currentSubscription.billingSubscriptionItems[0].billingProduct, + metadata: { + ...currentSubscription.billingSubscriptionItems[0].billingProduct + .metadata, + planKey: + overrides.planKey ?? + currentSubscription.billingSubscriptionItems[0].billingProduct + .metadata.planKey, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }, + { + ...currentSubscription.billingSubscriptionItems[1], + stripePriceId: + overrides.meteredPriceId ?? + currentSubscription.billingSubscriptionItems[1].stripePriceId, + billingProduct: { + ...currentSubscription.billingSubscriptionItems[1].billingProduct, + metadata: { + ...currentSubscription.billingSubscriptionItems[1].billingProduct + .metadata, + planKey: + overrides.planKey ?? + currentSubscription.billingSubscriptionItems[1].billingProduct + .metadata.planKey, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + }, + ], + } as BillingSubscription; + + return jest + .spyOn(billingSubscriptionRepository, 'find') + .mockResolvedValueOnce([sub]); + }; + + const arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule = + (phases: Array> = []) => + jest + .spyOn( + stripeSubscriptionScheduleService, + 'findOrCreateSubscriptionSchedule', + ) + .mockResolvedValue({ + id: 'scheduleId', + phases, + } as unknown as Stripe.SubscriptionSchedule); + + const arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase = ({ + planKey = BillingPlanKey.ENTERPRISE, + interval = SubscriptionInterval.Year, + licensedPriceId = LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId = METER_PRICE_ENTERPRISE_YEAR_ID, + quantity = 7, + meteredTiers = [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ] as MeterBillingPriceTiers, + }: { + planKey?: BillingPlanKey; + interval?: SubscriptionInterval; + licensedPriceId?: string; + meteredPriceId?: string; + quantity?: number; + meteredTiers?: MeterBillingPriceTiers; + } = {}) => + jest + .spyOn(billingSubscriptionPhaseService, 'getDetailsFromPhase') + .mockResolvedValueOnce({ + licensedPrice: { + stripePriceId: licensedPriceId, + quantity, + } as unknown as BillingPrice, + meteredPrice: { + stripePriceId: meteredPriceId, + tiers: meteredTiers, + } as unknown as BillingMeterPrice, + plan: { + planKey, + licensedProducts: [], + meteredProducts: [], + } as BillingGetPlanResult, + quantity, + interval, + }); + + const arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences = ( + sequences: Array<{ + planKey?: BillingPlanKey; + interval?: SubscriptionInterval; + licensedPriceId?: string; + meteredPriceId?: string; + quantity?: number; + meteredTiers?: MeterBillingPriceTiers; + }> = [{}], + ) => { + const spy = jest.spyOn( + billingSubscriptionPhaseService, + 'getDetailsFromPhase', + ); + + sequences.forEach((cfg) => { + const { + planKey = BillingPlanKey.ENTERPRISE, + interval = SubscriptionInterval.Year, + licensedPriceId = LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId = METER_PRICE_ENTERPRISE_YEAR_ID, + quantity = 7, + meteredTiers = [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ] as MeterBillingPriceTiers, + } = cfg ?? {}; + + spy.mockResolvedValueOnce({ + licensedPrice: { + stripePriceId: licensedPriceId, + quantity, + } as unknown as BillingPrice, + meteredPrice: { + stripePriceId: meteredPriceId, + tiers: meteredTiers, + } as unknown as BillingMeterPrice, + plan: { + planKey, + licensedProducts: [], + meteredProducts: [], + } as BillingGetPlanResult, + quantity, + interval, + }); + }); + + return spy; + }; + + const arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences = ( + snapshots: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [ + {} as Stripe.SubscriptionScheduleUpdateParams.Phase, + ], + ) => { + const spy = jest.spyOn(billingSubscriptionPhaseService, 'buildSnapshot'); + + snapshots.forEach((phase) => { + spy.mockReturnValueOnce(phase); + }); + + return spy; + }; + + const arrangeBillingProductServiceGetProductPrices = ( + prices: Array> = [ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + tiers: [ + { + up_to: 12000, + flat_amount: 12000, + unit_amount: null, + flat_amount_decimal: '1200000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '1200', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + ) => + jest + .spyOn(billingProductService, 'getProductPrices') + .mockResolvedValue(prices as BillingPrice[]); + + const arrangeBillingSubscriptionRepositoryFindOneOrFail = ({ + planKey = BillingPlanKey.PRO, + interval = SubscriptionInterval.Month, + licensedPriceId = LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId = METER_PRICE_PRO_MONTH_ID, + seats = 7, + workspaceId = 'ws_1', + stripeSubscriptionId = 'sub_1', + }: { + planKey?: BillingPlanKey; + interval?: SubscriptionInterval; + licensedPriceId?: string; + meteredPriceId?: string; + seats?: number; + workspaceId?: string; + stripeSubscriptionId?: string; + } = {}) => + jest + .spyOn(billingSubscriptionRepository, 'findOneOrFail') + .mockResolvedValue({ + id: 'sub_db_param', + workspaceId, + stripeSubscriptionId, + status: SubscriptionStatus.Active, + interval, + billingSubscriptionItems: [ + { + stripeSubscriptionItemId: 'si_licensed', + stripeProductId: 'prod_base', + stripePriceId: licensedPriceId, + quantity: seats, + billingProduct: { + metadata: { + planKey, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }, + { + stripeSubscriptionItemId: 'si_metered', + stripeProductId: 'prod_metered', + stripePriceId: meteredPriceId, + billingProduct: { + metadata: { + planKey, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + }, + ], + } as BillingSubscription); + + const arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync = () => { + jest + .spyOn(stripeSubscriptionService, 'updateSubscription') + .mockResolvedValueOnce({} as Stripe.Subscription); + jest + .spyOn(service, 'syncSubscriptionToDatabase') + .mockResolvedValueOnce({} as BillingSubscription); + }; + + const arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval = () => + jest + .spyOn(stripeSubscriptionService, 'getBillingThresholdsByInterval') + .mockReturnValue({ + amount_gte: 10000, + reset_billing_cycle_anchor: false, + }); + + const arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule = + () => + jest + .spyOn(stripeSubscriptionScheduleService, 'getSubscriptionWithSchedule') + .mockResolvedValue({ + id: 'sub_id', + schedule: { id: 'scheduleId' }, + status: 'active', + current_period_end: Math.floor(Date.now() / 1000), + items: { data: [] }, + latest_invoice: { + payment_intent: { + charges: { data: [] }, + }, + }, + } as unknown as SubscriptionWithSchedule); + + const arrangeBillingSubscriptionRepositoryFindOneByOrFail = ({ + planKey = BillingPlanKey.PRO, + interval = SubscriptionInterval.Month, + licensedPriceId = LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId = METER_PRICE_PRO_MONTH_ID, + seats = 7, + workspaceId = 'ws_1', + stripeSubscriptionId = 'sub_1', + }: { + planKey?: BillingPlanKey; + interval?: SubscriptionInterval; + licensedPriceId?: string; + meteredPriceId?: string; + seats?: number; + workspaceId?: string; + stripeSubscriptionId?: string; + } = {}) => + jest + .spyOn(billingSubscriptionRepository, 'findOneByOrFail') + .mockResolvedValue({ + id: 'sub_db_param_by', + workspaceId, + stripeSubscriptionId, + status: SubscriptionStatus.Active, + interval, + billingSubscriptionItems: [ + { + stripeSubscriptionItemId: 'si_licensed', + stripeProductId: 'prod_base', + stripePriceId: licensedPriceId, + quantity: seats, + billingProduct: { + metadata: { + planKey, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + }, + { + stripeSubscriptionItemId: 'si_metered', + stripeProductId: 'prod_metered', + stripePriceId: meteredPriceId, + billingProduct: { + metadata: { + planKey, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + }, + ], + } as BillingSubscription); + + const arrangeBillingSubscriptionPhaseServiceToSnapshot = ( + licensedPriceId: string, + meteredPriceId: string, + quantity = 7, + ) => + jest.spyOn(billingSubscriptionPhaseService, 'toSnapshot').mockReturnValue({ + end_date: Date.now() + 1000, + items: [{ price: licensedPriceId, quantity }, { price: meteredPriceId }], + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + const arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences = ( + sequences: Array<{ + currentEditable: { + licensedPriceId: string; + meteredPriceId: string; + quantity?: number; + }; + nextEditable?: { + licensedPriceId?: string; + meteredPriceId?: string; + quantity?: number; + }; + }> = [], + ) => { + const spy = jest.spyOn( + stripeSubscriptionScheduleService, + 'getEditablePhases', + ); + + sequences.forEach((sequence) => { + spy.mockReturnValueOnce({ + currentEditable: { + items: [ + { + price: sequence.currentEditable.licensedPriceId, + quantity: sequence.currentEditable.quantity ?? 7, + }, + { price: sequence.currentEditable.meteredPriceId }, + ], + } as Stripe.SubscriptionSchedule.Phase, + nextEditable: sequence.nextEditable + ? ({ + items: [ + { + price: sequence.nextEditable.licensedPriceId, + quantity: sequence.nextEditable.quantity ?? 7, + }, + { price: sequence.nextEditable.meteredPriceId }, + ], + } as Stripe.SubscriptionSchedule.Phase) + : undefined, + }); + }); + + return spy; + }; + + const arrangeBillingProductServiceGetProductPricesSequence = ( + first: Array>, + second: Array>, + ) => { + const spy = jest.spyOn(billingProductService, 'getProductPrices'); + + spy.mockResolvedValueOnce(first as BillingPrice[]); + spy.mockResolvedValueOnce(second as BillingPrice[]); + + return spy; + }; + + const arrangeBillingSubscriptionPhaseServiceBuildSnapshot = ( + result: Stripe.SubscriptionScheduleUpdateParams.Phase, + ) => + jest + .spyOn(billingSubscriptionPhaseService, 'buildSnapshot') + .mockReturnValueOnce(result); + + const arrangeServiceSyncSubscriptionToDatabase = () => + jest + .spyOn(service, 'syncSubscriptionToDatabase') + .mockResolvedValue({} as BillingSubscription); + /* @license Enterprise */ beforeEach(async () => { - entitlementRepo = repoMock(); - subscriptionRepo = repoMock(); - priceRepo = repoMock(); - itemRepo = repoMock(); - customerRepo = repoMock(); - - stripeSubSvc = { - updateSubscription: jest.fn(), - cancelSubscription: jest.fn(), - collectLastInvoice: jest.fn(), - getBillingThresholdsByInterval: jest.fn().mockResolvedValue({ - amount_gte: 1000, - reset_billing_cycle_anchor: false, - }), - } as any; - - stripeSchedSvc = { - getSubscriptionWithSchedule: jest.fn(), - findOrCreateSubscriptionSchedule: jest.fn(), - getEditablePhases: jest.fn(), - replaceEditablePhases: jest.fn(), - } as any; - - stripeItemSvc = {} as any; - stripeCustomerSvc = { hasPaymentMethod: jest.fn() } as any; - - planSvc = { - getPlanBaseProduct: jest.fn(), - listPlans: jest.fn(), - getPlanByPriceId: jest.fn(), - getPricesPerPlanByInterval: jest.fn(), - } as any; - - productSvc = { - getProductPrices: jest.fn(), - } as any; - - phaseSvc = { - getDetailsFromPhase: jest.fn(), - toSnapshot: jest.fn(), - buildSnapshot: jest.fn(), - getLicensedPriceIdFromSnapshot: jest.fn(), - isSamePhaseSignature: jest.fn(), - } as any; - - configSvc = { get: jest.fn().mockReturnValue(0) } as any; - module = await Test.createTestingModule({ providers: [ BillingSubscriptionService, - { provide: BillingPlanService, useValue: planSvc }, - { provide: BillingProductService, useValue: productSvc }, - { provide: StripeCustomerService, useValue: stripeCustomerSvc }, - { provide: StripeSubscriptionItemService, useValue: stripeItemSvc }, - { provide: StripeSubscriptionService, useValue: stripeSubSvc }, + { + provide: BillingPlanService, + useValue: { + getPlanBaseProduct: jest.fn(), + listPlans: jest.fn(), + getPlanByPriceId: jest.fn(), + getPricesPerPlanByInterval: jest.fn(), + }, + }, + { + provide: BillingProductService, + useValue: { + getProductPrices: jest.fn(), + }, + }, + { + provide: StripeCustomerService, + useValue: { + hasPaymentMethod: jest.fn(), + }, + }, + { provide: StripeSubscriptionItemService, useValue: {} }, { provide: StripeSubscriptionScheduleService, - useValue: stripeSchedSvc, + useValue: { + getSubscriptionWithSchedule: jest.fn(), + findOrCreateSubscriptionSchedule: jest.fn(), + getEditablePhases: jest.fn(), + replaceEditablePhases: jest.fn(), + }, + }, + { + provide: StripeSubscriptionService, + useValue: { + updateSubscription: jest.fn(), + cancelSubscription: jest.fn(), + collectLastInvoice: jest.fn(), + getBillingThresholdsByInterval: jest.fn().mockResolvedValue({ + amount_gte: 1000, + reset_billing_cycle_anchor: false, + }), + }, + }, + { + provide: StripeSubscriptionScheduleService, + useValue: { + getSubscriptionWithSchedule: jest.fn(), + findOrCreateSubscriptionSchedule: jest.fn(), + getEditablePhases: jest.fn(), + replaceEditablePhases: jest.fn(), + }, + }, + { + provide: BillingSubscriptionPhaseService, + useValue: { + getDetailsFromPhase: jest.fn(), + toSnapshot: jest.fn(), + buildSnapshot: jest.fn(), + getLicensedPriceIdFromSnapshot: jest.fn(), + isSamePhaseSignature: jest.fn(), + }, + }, + { + provide: TwentyConfigService, + useValue: { + get: jest.fn().mockReturnValue(0), + }, }, - { provide: BillingSubscriptionPhaseService, useValue: phaseSvc }, - { provide: TwentyConfigService, useValue: configSvc }, { provide: getRepositoryToken(BillingEntitlement), - useValue: entitlementRepo, + useValue: repoMock(), }, { provide: getRepositoryToken(BillingSubscription), - useValue: subscriptionRepo, + useValue: repoMock(), + }, + { + provide: getRepositoryToken(BillingPrice), + useValue: repoMock(), }, - { provide: getRepositoryToken(BillingPrice), useValue: priceRepo }, { provide: getRepositoryToken(BillingSubscriptionItem), - useValue: itemRepo, + useValue: repoMock(), }, { provide: getRepositoryToken(BillingCustomer), - useValue: customerRepo, + useValue: repoMock(), }, ], }).compile(); service = module.get(BillingSubscriptionService); + billingSubscriptionRepository = module.get>( + getRepositoryToken(BillingSubscription), + ); + billingPriceRepository = module.get>( + getRepositoryToken(BillingPrice), + ); + billingProductService = module.get( + BillingProductService, + ); + stripeSubscriptionScheduleService = + module.get( + StripeSubscriptionScheduleService, + ); + stripeSubscriptionService = module.get( + StripeSubscriptionService, + ); + stripeSubscriptionScheduleService = + module.get( + StripeSubscriptionScheduleService, + ); + billingSubscriptionPhaseService = + module.get( + BillingSubscriptionPhaseService, + ); }); - // Common arrangements - function arrangeScheduleWithCurrentAndNext() { - const now = Math.floor(Date.now() / 1000); - const currentPhase = mkStripeSchedulePhase( - [ - { price: 'price_licensed_m', quantity: 7 }, - { price: 'price_metered_m_1000' }, - ], - now - 1000, - now + 1000, - ); - const nextPhase = mkStripeSchedulePhase( - [ - { price: 'price_licensed_m', quantity: 7 }, - { price: 'price_metered_m_1000' }, - ], - now + 1000, - ); - - const schedule = mkScheduleWithEditable({ - current: currentPhase, - next: nextPhase, - }); - - // Always return plain SubscriptionWithSchedule (no event/data wrapper) - stripeSchedSvc.getSubscriptionWithSchedule.mockResolvedValue({ - id: 'sub_1', - status: 'active', - customer: 'cus_1', - currency: 'usd', - collection_method: 'charge_automatically', - automatic_tax: null as any, - cancellation_details: null as any, - cancel_at_period_end: false, - canceled_at: null as any, - cancel_at: null as any, - ended_at: null as any, - trial_start: null as any, - trial_end: null as any, - current_period_start: now - 1000, - current_period_end: now + 1000, - metadata: {}, - items: { - data: [ - { - id: 'si_licensed', - plan: { interval: 'month' } as any, - price: { id: 'price_licensed_m', product: 'prod_base' } as any, - } as any, - { - id: 'si_metered', - plan: { interval: 'month' } as any, - price: { - id: 'price_metered_m_1000', - product: 'prod_metered', - } as any, - } as any, - ], - } as any, - schedule: { - id: schedule.id, - phases: [ - { - start_date: currentPhase.start_date, - end_date: currentPhase.end_date, - items: currentPhase.items, - }, - { - start_date: nextPhase.start_date, - end_date: nextPhase.end_date, - items: nextPhase.items, - }, - ], - } as any, - } as any); - stripeSchedSvc.findOrCreateSubscriptionSchedule.mockResolvedValue(schedule); - stripeSchedSvc.getEditablePhases.mockReturnValue({ - currentEditable: currentPhase, - nextEditable: nextPhase, - }); - - phaseSvc.toSnapshot.mockImplementation((p: any) => ({ - start_date: p.start_date, - end_date: p.end_date, - items: p.items, - proration_behavior: 'none', - })); - phaseSvc.getLicensedPriceIdFromSnapshot.mockImplementation((snap: any) => { - const items: Array<{ price: string; quantity?: number }> = - (snap && snap.items) || []; - const licensed = items.find((it) => typeof it.quantity === 'number'); - - return licensed?.price ?? 'price_licensed_m'; - }); - // Provide a concrete Subscription for paths that call updateSubscription - stripeSubSvc.updateSubscription.mockResolvedValue({ - id: 'sub_1', - status: 'active', - customer: 'cus_1', - currency: 'usd', - collection_method: 'charge_automatically', - automatic_tax: null as any, - cancellation_details: null as any, - cancel_at_period_end: false, - canceled_at: null as any, - cancel_at: null as any, - ended_at: null as any, - trial_start: null as any, - trial_end: null as any, - current_period_start: now - 1000, - current_period_end: now + 1000, - metadata: {}, - items: { - data: [ - { - id: 'si_licensed', - plan: { interval: 'month' } as any, - price: { id: 'price_licensed_m', product: 'prod_base' } as any, - } as any, - { - id: 'si_metered', - plan: { interval: 'month' } as any, - price: { - id: 'price_metered_m_1000', - product: 'prod_metered', - } as any, - } as any, - ], - } as any, - // schedule is an id string here to exercise the re-fetch branch - schedule: schedule.id as any, - } as any); - - return { currentPhase, nextPhase }; - } - - function arrangeCurrentDetails() { - const metered = mkMeteredPrice({ - stripePriceId: 'price_metered_m_1000', - interval: SubscriptionInterval.Month, - }); - const licensed = mkLicensedPrice(); - - phaseSvc.getDetailsFromPhase.mockResolvedValue({ - plan: { - planKey: BillingPlanKey.PRO, - meteredProducts: [], - licensedProducts: [], - }, - meteredPrice: metered as any, - licensedPrice: licensed as any, - quantity: 7, - interval: SubscriptionInterval.Month, - }); - - return { metered, licensed }; - } - - function arrangePlanMapping() { - // Build catalogs per interval - const licensedMonth = mkLicensedPrice({ - stripePriceId: 'price_licensed_m', - interval: SubscriptionInterval.Month, - }); - const licensedYear = mkLicensedPrice({ - stripePriceId: 'price_licensed_y', - interval: SubscriptionInterval.Year, - }); - - const meteredMonth1k = mkMeteredPrice({ - stripePriceId: 'price_metered_m_1000', - interval: SubscriptionInterval.Month, - tiers: [ - { up_to: 1000, unit_amount_decimal: '0' }, - { up_to: null, unit_amount_decimal: '5' }, - ] as any, - }); - const meteredMonth5k = mkMeteredPrice({ - stripePriceId: 'price_metered_m_5000', - interval: SubscriptionInterval.Month, - tiers: [ - { up_to: 5000, unit_amount_decimal: '0' }, - { up_to: null, unit_amount_decimal: '5' }, - ] as any, - }); - - const meteredYear12k = mkMeteredPrice({ - stripePriceId: 'price_metered_y_12000', - interval: SubscriptionInterval.Year, - tiers: [ - { up_to: 12000, unit_amount_decimal: '0' }, - { up_to: null, unit_amount_decimal: '5' }, - ] as any, - }); - const meteredYear24k = mkMeteredPrice({ - stripePriceId: 'price_metered_y_24000', - interval: SubscriptionInterval.Year, - tiers: [ - { up_to: 24000, unit_amount_decimal: '0' }, - { up_to: null, unit_amount_decimal: '5' }, - ] as any, - }); - - const byId: Record = { - [licensedMonth.stripePriceId]: licensedMonth, - [licensedYear.stripePriceId]: licensedYear, - [meteredMonth1k.stripePriceId]: meteredMonth1k, - [meteredMonth5k.stripePriceId]: meteredMonth5k, - [meteredYear12k.stripePriceId]: meteredYear12k, - [meteredYear24k.stripePriceId]: meteredYear24k, - }; - - (productSvc.getProductPrices as jest.Mock).mockImplementation( - ({ interval }: { interval: SubscriptionInterval }) => { - if (interval === SubscriptionInterval.Month) { - return Promise.resolve([ - licensedMonth, - meteredMonth1k, - meteredMonth5k, - ]); - } - if (interval === SubscriptionInterval.Year) { - return Promise.resolve([ - licensedYear, - meteredYear12k, - meteredYear24k, - ]); - } - - return Promise.resolve([]); - }, - ); - - priceRepo.findOneOrFail.mockImplementation(async ({ where }: any) => { - const id = where?.stripePriceId; - const hit = byId[id]; - - if (!hit) throw new Error('unknown price id ' + id); - - return hit; - }); - - priceRepo.findOneByOrFail.mockImplementation( - async ({ stripePriceId }: any) => { - const hit = byId[stripePriceId]; - - if (!hit) throw new Error('unknown price id ' + stripePriceId); - - return hit; - }, - ); - - priceRepo.findOneBy.mockImplementation( - async ({ stripePriceId }: any) => byId[stripePriceId] ?? null, - ); - priceRepo.findOne.mockImplementation( - async ({ where }: any) => byId[where?.stripePriceId] ?? null, - ); - } - - function arrangeGetCurrentSubEntity() { - const entity = mkSubscriptionEntity(); - - subscriptionRepo.find.mockResolvedValue([entity]); - subscriptionRepo.findOneOrFail.mockResolvedValue(entity); - subscriptionRepo.findOne.mockResolvedValue(entity); - - return entity; - } - - describe('changeMeteredPrice', () => { - it('replaces schedule phases and syncs subscription', async () => { - const ws = mkWorkspace(); - - arrangeGetCurrentSubEntity(); - arrangeScheduleWithCurrentAndNext(); - arrangeCurrentDetails(); - arrangePlanMapping(); - - // After replace, schedule is read again and sync writes to DB - stripeSchedSvc.replaceEditablePhases.mockResolvedValue(undefined as any); - subscriptionRepo.find.mockResolvedValue([mkSubscriptionEntity()]); - - await service.changeMeteredPrice(ws, 'price_metered_m_1000'); - - expect(stripeSchedSvc.replaceEditablePhases).toHaveBeenCalledTimes(1); - expect(subscriptionRepo.upsert).toHaveBeenCalled(); - expect(itemRepo.upsert).toHaveBeenCalled(); - }); + afterEach(() => { + jest.clearAllMocks(); }); - describe('cancelSwitchMeteredPrice', () => { - it('maps to current metered price and calls changeMeteredPrice', async () => { - const ws = mkWorkspace(); + describe('changePlan', () => { + describe('upgrade', () => { + it('PRO -> ENTERPRISE without existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind(); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + quantity: 7, + }); - arrangeScheduleWithCurrentAndNext(); - arrangeGetCurrentSubEntity(); - const { metered } = arrangeCurrentDetails(); + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + quantity: 7, + }, + }, + ]); + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); - const spy = jest.spyOn(service as any, 'changeMeteredPrice'); + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); - (service as any).changeMeteredPrice = spy.mockResolvedValue(undefined); + await service.changePlan({ id: 'ws_1' } as Workspace); - await service.cancelSwitchMeteredPrice(ws); + expect( + stripeSubscriptionService.updateSubscription, + ).toHaveBeenCalledWith(currentSubscription.stripeSubscriptionId, { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + { + id: 'si_metered', + price: METER_PRICE_ENTERPRISE_MONTH_ID, + }, + ], + metadata: { + plan: 'ENTERPRISE', + }, + proration_behavior: 'create_prorations', + }); + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).not.toHaveBeenCalled(); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + // TODO: fix this test it's an issue in the business logic. It should pass as is. + // When the update from pro to enterprise with existing phases append the second phase must be updated too. + it.skip('PRO -> ENTERPRISE with existing phases', async () => { + arrangeBillingSubscriptionRepositoryFind(); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); - expect(spy).toHaveBeenCalledWith(ws, metered.stripePriceId); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, + }, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + quantity: 7, + }, + ]); + + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_ID, + quantity: 7, + }, + }, + ]); + + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + + arrangeBillingPriceRepositoryFindOneByOrFail(); + + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + + await service.changePlan({ id: 'ws_1' } as Workspace); + + expect( + stripeSubscriptionService.updateSubscription, + ).toHaveBeenCalledWith(currentSubscription.stripeSubscriptionId, { + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + { id: 'si_metered', price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + metadata: { plan: 'ENTERPRISE' }, + proration_behavior: 'create_prorations', + }); + + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith( + 'scheduleId', + expect.objectContaining({ + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + }), + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); + describe('downgrade', () => { + it('ENTERPRISE -> PRO without existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + }); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }); + + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + }, + ]); + + arrangeBillingProductServiceGetProductPricesSequence( + [ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + [ + { + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + ); + + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_ID, + 7, + ); + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + + arrangeServiceSyncSubscriptionToDatabase(); + await service.changePlan({ id: 'ws_1' } as Workspace); + + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith( + 'scheduleId', + expect.objectContaining({ + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + }), + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + it('ENTERPRISE -> PRO. with existing phases', async () => { + arrangeBillingSubscriptionRepositoryFind({ + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + }); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + }, + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + }, + ]); + + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + ]); + + arrangeBillingProductServiceGetProductPricesSequence( + [ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + [ + { + stripePriceId: LICENSE_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_PRO_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.PRO, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ], + ); + + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_YEAR_ID, + METER_PRICE_ENTERPRISE_YEAR_ID, + ); + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + + arrangeServiceSyncSubscriptionToDatabase(); + + await service.changePlan({ id: 'ws_1' } as Workspace); + + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith('scheduleId', { + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_ID }, + ], + }), + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); }); }); describe('changeInterval', () => { - it('performs immediate Month -> Year upgrade via Stripe update', async () => { - const ws = mkWorkspace(); + describe('upgrade', () => { + it('MONTHLY -> YEARLY without existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind(); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + }); + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + }, + ]); + arrangeBillingProductServiceGetProductPrices(); + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeBillingSubscriptionRepositoryFindOneOrFail(); + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); - arrangeGetCurrentSubEntity(); - arrangeScheduleWithCurrentAndNext(); - arrangeCurrentDetails(); - arrangePlanMapping(); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }); + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_YEAR_ID, + METER_PRICE_ENTERPRISE_YEAR_ID, + 7, + ); + arrangeServiceSyncSubscriptionToDatabase(); - await service.changeInterval(ws); + await service.changeInterval({ id: 'ws_1' } as Workspace); - expect(stripeSubSvc.updateSubscription).toHaveBeenCalled(); - }); - }); - - describe('changePlan', () => { - it('performs PRO -> ENTERPRISE immediate upgrade via Stripe update', async () => { - arrangeGetCurrentSubEntity(); - arrangeScheduleWithCurrentAndNext(); - arrangeCurrentDetails(); - arrangePlanMapping(); - - await service.changePlan(mkWorkspace()); - - expect(stripeSubSvc.updateSubscription).toHaveBeenCalled(); - }); - }); - - describe('cancelSwitchPlan', () => { - it('cancels switch by targeting ENTERPRISE and updates Stripe', async () => { - arrangeGetCurrentSubEntity(); - arrangeScheduleWithCurrentAndNext(); - arrangeCurrentDetails(); - arrangePlanMapping(); - - await service.cancelSwitchPlan(mkWorkspace()); - - expect(stripeSubSvc.updateSubscription).toHaveBeenCalled(); - }); - }); - - describe('cancelSwitchInterval', () => { - it('cancels interval switch by targeting Year and updates Stripe', async () => { - arrangeGetCurrentSubEntity(); - arrangeScheduleWithCurrentAndNext(); - arrangeCurrentDetails(); - arrangePlanMapping(); - - await service.cancelSwitchInterval(mkWorkspace()); - - expect(stripeSubSvc.updateSubscription).toHaveBeenCalled(); - }); - }); - - describe('metered price mapping helpers', () => { - function mkMeteredWith( - id: string, - cap: number | null, - interval: SubscriptionInterval, - ): BillingPrice { - return mkMeteredPrice({ - stripePriceId: id, - interval, - tiers: [ - { up_to: cap === null ? 0 : cap, unit_amount_decimal: '0' }, - { up_to: null, unit_amount_decimal: '5' }, - ] as any, + expect( + stripeSubscriptionService.updateSubscription, + ).toHaveBeenCalledWith( + currentSubscription.stripeSubscriptionId, + expect.objectContaining({ + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + { id: 'si_metered', price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); }); - } + it('MONTHLY -> YEARLY with existing phases', async () => { + arrangeBillingSubscriptionRepositoryFind(); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); - it('findMeteredMatchingPriceForIntervalSwitching picks floor on target interval with cap scaling', async () => { - // reference price: Month cap 1000 → scales to 12000 on Year - const refId = 'price_ref_m_1000'; - - priceRepo.findOneOrFail.mockImplementation(async ({ where }: any) => { - if (where?.stripePriceId === refId) { - return mkMeteredWith(refId, 1000, SubscriptionInterval.Month); - } - throw new Error('unexpected id ' + where?.stripePriceId); - }); - - const catalog: BillingPrice[] = [ - mkMeteredWith('price_year_6000', 6000, SubscriptionInterval.Year), - mkMeteredWith('price_year_12000', 12000, SubscriptionInterval.Year), - mkMeteredWith('price_year_24000', 24000, SubscriptionInterval.Year), - ]; - - const mapped = await service.findMeteredMatchingPriceForIntervalSwitching( - { - billingPricesPerPlanAndIntervalArray: catalog, - meteredPriceId: refId, - targetInterval: SubscriptionInterval.Year, - }, - ); - - expect(mapped.stripePriceId).toBe('price_year_12000'); - }); - - it('findMeteredMatchingPriceForPlanSwitching picks floor on same interval', async () => { - // reference price: Month cap 5000 - const refId = 'price_ref_m_5000'; - - priceRepo.findOneOrFail.mockImplementation(async ({ where }: any) => { - if (where?.stripePriceId === refId) { - return mkMeteredWith(refId, 5000, SubscriptionInterval.Month); - } - throw new Error('unexpected id ' + where?.stripePriceId); - }); - - const catalog: BillingPrice[] = [ - mkMeteredWith('price_month_1000', 1000, SubscriptionInterval.Month), - mkMeteredWith('price_month_5000', 5000, SubscriptionInterval.Month), - mkMeteredWith('price_month_10000', 10000, SubscriptionInterval.Month), - ]; - - const mapped = await service.findMeteredMatchingPriceForPlanSwitching({ - billingPricesPerPlanAndIntervalArray: catalog, - meteredPriceId: refId, - }); - - expect(mapped.stripePriceId).toBe('price_month_5000'); - }); - - it('findMeteredMatchingPriceForMeteredPriceSwitching scales cap then picks floor', async () => { - // reference price: Month cap 2000 → scales to 24000 on Year - const targetId = 'price_target_m_2000'; - - priceRepo.findOneOrFail.mockImplementation(async ({ where }: any) => { - if (where?.stripePriceId === targetId) { - return mkMeteredWith(targetId, 2000, SubscriptionInterval.Month); - } - throw new Error('unexpected id ' + where?.stripePriceId); - }); - - const catalog: BillingPrice[] = [ - mkMeteredWith('price_year_12000', 12000, SubscriptionInterval.Year), - mkMeteredWith('price_year_24000', 24000, SubscriptionInterval.Year), - ]; - - const mapped = - await service.findMeteredMatchingPriceForMeteredPriceSwitching({ - billingPricesPerPlanAndIntervalArray: catalog, - targetMeteredPriceId: targetId, - interval: SubscriptionInterval.Year, + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, }); - expect(mapped.stripePriceId).toBe('price_year_24000'); + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + ]); + + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + interval: SubscriptionInterval.Year, + tiers: [ + { + up_to: 12000, + flat_amount: 12000, + unit_amount: null, + flat_amount_decimal: '1200000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '1200', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_ID, + 7, + ); + + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + arrangeServiceSyncSubscriptionToDatabase(); + arrangeBillingSubscriptionRepositoryFindOneOrFail({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + }); + + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }); + + await service.changeInterval({ id: 'ws_1' } as Workspace); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith( + 'scheduleId', + expect.objectContaining({ + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + }), + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); + describe('downgrade', () => { + it('YEARLY -> MONTHLY without existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind({ + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + }); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }); + + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + }, + ]); + + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_YEAR_ID, + METER_PRICE_ENTERPRISE_YEAR_ID, + 7, + ); + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + arrangeServiceSyncSubscriptionToDatabase(); + + await service.changeInterval({ id: 'ws_1' } as Workspace); + + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith('scheduleId', { + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + }), + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + it('YEARLY -> MONTHLY with existing phases', async () => { + arrangeBillingSubscriptionRepositoryFind({ + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + planKey: BillingPlanKey.ENTERPRISE, + }); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Year, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }); + + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_YEAR_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_YEAR_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_YEAR_ID, + meteredPriceId: METER_PRICE_PRO_YEAR_ID, + quantity: 7, + }, + }, + ]); + + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_YEAR_ID, + METER_PRICE_ENTERPRISE_YEAR_ID, + 7, + ); + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + arrangeServiceSyncSubscriptionToDatabase(); + + await service.changeInterval({ id: 'ws_1' } as Workspace); + + expect( + stripeSubscriptionService.updateSubscription, + ).not.toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith('scheduleId', { + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_YEAR_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_YEAR_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_ID }, + ], + }), + }); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); + }); + + describe('changeMeteredPrice', () => { + describe('upgrade', () => { + it('without existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + }); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhase({ + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + }); + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + }, + ]); + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_ID, + 7, + ); + arrangeServiceSyncSubscriptionToDatabase(); + + await service.changeMeteredPrice( + { id: 'ws_1' } as Workspace, + METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + ); + + expect( + stripeSubscriptionService.updateSubscription, + ).toHaveBeenCalledWith( + currentSubscription.stripeSubscriptionId, + expect.objectContaining({ + billing_thresholds: { + amount_gte: 10000, + reset_billing_cycle_anchor: false, + }, + items: [ + { + id: 'si_licensed', + price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + quantity: 7, + }, + { + id: 'si_metered', + price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + }, + ], + proration_behavior: 'none', + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + it('with existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + }); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + }, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + }, + ]); + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, + }, + }, + ]); + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + + // Snapshot courant (phase actuelle) + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + 7, + ); + + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + arrangeServiceSyncSubscriptionToDatabase(); + + await service.changeMeteredPrice( + { id: 'ws_1' } as Workspace, + METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + ); + + expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalled(); + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith( + 'scheduleId', + expect.objectContaining({ + currentSnapshot: { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, + ], + }, + nextPhase: { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + }, + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + }); + describe('downgrade', () => { + it('without existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + }); + + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule(); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + quantity: 7, + meteredTiers: [ + { + up_to: 120_000_000, + flat_amount: 10_000, + unit_amount: null, + flat_amount_decimal: '1000000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + }, + ]); + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + quantity: 7, + }, + }, + ]); + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 120_000_000, + flat_amount: 10_000, + unit_amount: null, + flat_amount_decimal: '1000000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + 7, + ); + + arrangeServiceSyncSubscriptionToDatabase(); + + await service.changeMeteredPrice( + { id: 'ws_1' } as Workspace, + METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + ); + + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith( + 'scheduleId', + expect.objectContaining({ + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID }, + ], + }), + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); + it('with existing phase', async () => { + arrangeBillingSubscriptionRepositoryFind({ + planKey: BillingPlanKey.ENTERPRISE, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + }); + arrangeStripeSubscriptionScheduleServiceFindOrCreateSubscriptionSchedule( + [{}, {}], + ); + arrangeBillingSubscriptionPhaseServiceGetDetailsFromPhaseSequences([ + { + planKey: BillingPlanKey.ENTERPRISE, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + }, + { + planKey: BillingPlanKey.PRO, + interval: SubscriptionInterval.Month, + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, + meteredTiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + }, + ]); + arrangeStripeSubscriptionScheduleServiceGetEditablePhasesSequences([ + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, + }, + }, + { + currentEditable: { + licensedPriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + meteredPriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + quantity: 7, + }, + nextEditable: { + licensedPriceId: LICENSE_PRICE_PRO_MONTH_ID, + meteredPriceId: METER_PRICE_PRO_MONTH_TIER_LOW_ID, + quantity: 7, + }, + }, + ]); + arrangeBillingProductServiceGetProductPrices([ + { + stripePriceId: LICENSE_PRICE_ENTERPRISE_MONTH_ID, + interval: SubscriptionInterval.Month, + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.BASE_PRODUCT, + priceUsageBased: BillingUsageType.LICENSED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 1000, + flat_amount: 1000, + unit_amount: null, + flat_amount_decimal: '100000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '100', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + { + stripePriceId: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID, + interval: SubscriptionInterval.Month, + tiers: [ + { + up_to: 5000, + flat_amount: 5000, + unit_amount: null, + flat_amount_decimal: '500000', + unit_amount_decimal: null, + }, + { + up_to: null, + flat_amount: null, + unit_amount: null, + flat_amount_decimal: null, + unit_amount_decimal: '500', + }, + ], + billingProduct: { + metadata: { + planKey: BillingPlanKey.ENTERPRISE, + productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION, + priceUsageBased: BillingUsageType.METERED, + }, + }, + } as Partial, + ]); + arrangeBillingPriceRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceGetBillingThresholdsByInterval(); + arrangeStripeSubscriptionScheduleServiceGetSubscriptionWithSchedule(); + arrangeBillingSubscriptionRepositoryFindOneByOrFail(); + arrangeStripeSubscriptionServiceUpdateSubscriptionAndSync(); + + // Snapshot courant (phase actuelle) + arrangeBillingSubscriptionPhaseServiceToSnapshot( + LICENSE_PRICE_ENTERPRISE_MONTH_ID, + METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + 7, + ); + + arrangeBillingSubscriptionPhaseServiceBuildSnapshotSequences([ + { + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + { + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_LOW_ID }, + ], + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + ]); + arrangeBillingSubscriptionPhaseServiceBuildSnapshot({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_HIGH_ID }, + ], + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase); + + arrangeServiceSyncSubscriptionToDatabase(); + + await service.changeMeteredPrice( + { id: 'ws_1' } as Workspace, + METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID, + ); + + expect( + stripeSubscriptionScheduleService.replaceEditablePhases, + ).toHaveBeenCalledWith( + 'scheduleId', + expect.objectContaining({ + currentSnapshot: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_ENTERPRISE_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID }, + ], + }), + nextPhase: expect.objectContaining({ + items: [ + { price: LICENSE_PRICE_PRO_MONTH_ID, quantity: 7 }, + { price: METER_PRICE_PRO_MONTH_TIER_LOW_ID }, + ], + }), + }), + ); + expect(service.syncSubscriptionToDatabase).toHaveBeenCalled(); + }); }); }); }); 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 6473d9c3fdb..2940b4268f2 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 @@ -3,10 +3,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import assert from 'assert'; - import { Not, Repository } from 'typeorm'; -import { findOrThrow, isDefined } from 'twenty-shared/utils'; +import { + assertIsDefinedOrThrow, + findOrThrow, + isDefined, +} from 'twenty-shared/utils'; import { differenceInDays } from 'date-fns'; import type Stripe from 'stripe'; @@ -80,10 +82,10 @@ export class BillingSubscriptionService { }); } - async getCurrentBillingSubscriptionOrThrow(criteria: { + async getCurrentBillingSubscription(criteria: { workspaceId?: string; stripeCustomerId?: string; - }) { + }): Promise { const notCanceledSubscriptions = await this.billingSubscriptionRepository.find({ where: { ...criteria, status: Not(SubscriptionStatus.Canceled) }, @@ -93,12 +95,32 @@ export class BillingSubscriptionService { ], }); - assert( - notCanceledSubscriptions.length <= 1, - `More than one not canceled subscription for workspace ${criteria.workspaceId}`, + if (notCanceledSubscriptions.length > 1) { + throw new BillingException( + `More than one not canceled subscription for workspace ${criteria.workspaceId}`, + BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND, + ); + } + + return notCanceledSubscriptions[0]; + } + + async getCurrentBillingSubscriptionOrThrow(criteria: { + workspaceId?: string; + stripeCustomerId?: string; + }): Promise { + const notCanceledSubscription = + await this.getCurrentBillingSubscription(criteria); + + assertIsDefinedOrThrow( + notCanceledSubscription, + new BillingException( + `No active subscription found for workspace ${criteria.workspaceId}`, + BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND, + ), ); - return notCanceledSubscriptions?.[0]; + return notCanceledSubscription; } async getBaseProductCurrentBillingSubscriptionItemOrThrow( @@ -138,10 +160,9 @@ export class BillingSubscriptionService { } async deleteSubscriptions(workspaceId: string) { - const subscriptionToCancel = - await this.getCurrentBillingSubscriptionOrThrow({ - workspaceId, - }); + const subscriptionToCancel = await this.getCurrentBillingSubscription({ + workspaceId, + }); if (subscriptionToCancel) { await this.stripeSubscriptionService.cancelSubscription( @@ -379,6 +400,8 @@ export class BillingSubscriptionService { } async getMeteredBillingPriceByPriceId(stripePriceId: string) { + assertIsDefinedOrThrow(stripePriceId); + const currentMeteredBillingPrice = await this.billingPriceRepository.findOneOrFail({ where: { @@ -519,6 +542,7 @@ export class BillingSubscriptionService { await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( stripeSubscriptionId, ); + const schedule = await this.stripeSubscriptionScheduleService.findOrCreateSubscriptionSchedule( subscription, @@ -963,7 +987,7 @@ export class BillingSubscriptionService { updateType: 'interval', }); - return this.upgradeIntervalNowWithReanchor( + await this.upgradeIntervalNowWithReanchor( billingSubscription.stripeSubscriptionId, { licensedPriceId: targetLicensedPrice.stripePriceId, @@ -971,6 +995,55 @@ export class BillingSubscriptionService { seats, }, ); + + const { currentEditable, nextEditable, subscription, schedule } = + await this.loadScheduleEditable( + billingSubscription.stripeSubscriptionId, + ); + + if (nextEditable && currentEditable) { + const reloadedNextDetails = + await this.billingSubscriptionPhaseService.getDetailsFromPhase( + nextEditable as BillingSubscriptionSchedulePhase, + ); + + const mappedNext = await this.resolvePrices({ + interval: SubscriptionInterval.Year, + planKey: reloadedNextDetails.plan.planKey, + meteredPriceId: reloadedNextDetails.meteredPrice.stripePriceId, + updateType: 'interval', + }); + + const currentSnap = + this.billingSubscriptionPhaseService.toSnapshot(currentEditable); + + const nextPhaseForYear = + this.billingSubscriptionPhaseService.buildSnapshot( + { + start_date: ensureFutureStartDate( + (currentSnap?.end_date as number | undefined) ?? + subscription.current_period_end, + ), + items: currentSnap.items, + proration_behavior: 'none', + } as Stripe.SubscriptionScheduleUpdateParams.Phase, + mappedNext.targetLicensedPrice.stripePriceId, + reloadedNextDetails.quantity, + mappedNext.targetMeteredPrice.stripePriceId, + await this.getBillingThresholdsByPriceId( + mappedNext.targetLicensedPrice.stripePriceId, + ), + ); + + return await this.scheduleReplaceNext({ + subscription, + scheduleId: schedule.id, + currentSnapshot: currentSnap, + nextPhase: nextPhaseForYear, + }); + } + + return; } // Case C: Year -> Month @@ -1097,7 +1170,7 @@ export class BillingSubscriptionService { const { targetLicensedPrice, targetMeteredPrice } = await this.resolvePrices({ interval, - planKey: BillingPlanKey.ENTERPRISE, + planKey: targetPlanKey, meteredPriceId: currentMeteredPriceId, updateType: 'plan', }); @@ -1106,7 +1179,7 @@ export class BillingSubscriptionService { licensedPriceId: targetLicensedPrice.stripePriceId, meteredPriceId: targetMeteredPrice.stripePriceId, seats, - planMeta: BillingPlanKey.ENTERPRISE, + planMeta: targetPlanKey, }); return; @@ -1138,12 +1211,12 @@ export class BillingSubscriptionService { const nextPrices = await this.resolvePrices({ interval: preservedNextInterval, - planKey: BillingPlanKey.PRO, + planKey: targetPlanKey, meteredPriceId: preservedNextMeteredId, updateType: 'plan', }); - await this.downgradeDeferred(stripeSubscriptionId, { + return await this.downgradeDeferred(stripeSubscriptionId, { current: { licensedPriceId: currentPrices.targetLicensedPrice.stripePriceId, meteredPriceId: currentMeteredPriceId, @@ -1156,8 +1229,6 @@ export class BillingSubscriptionService { planKey: BillingPlanKey.PRO, }, }); - - return; } throw new BillingException( @@ -1217,7 +1288,7 @@ export class BillingSubscriptionService { currentSnapshot: Stripe.SubscriptionScheduleUpdateParams.Phase; nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase; }): Promise { - const { scheduleId, currentSnapshot } = params; + const { scheduleId, currentSnapshot, subscription } = params; let { nextPhase } = params; if ( @@ -1239,8 +1310,7 @@ export class BillingSubscriptionService { ); const refreshed = await this.stripeSubscriptionScheduleService.getSubscriptionWithSchedule( - (params.subscription as Stripe.Subscription).id ?? - (params.subscription as SubscriptionWithSchedule).id, + subscription.id, ); const workspaceId = ( await this.billingSubscriptionRepository.findOneByOrFail({ @@ -1253,38 +1323,46 @@ export class BillingSubscriptionService { private async upgradePlanNow( stripeSubscriptionId: string, - prices: { + newPrices: { licensedPriceId: string; meteredPriceId: string; seats: number; planMeta?: BillingPlanKey; }, ): Promise { - const sub = await this.billingSubscriptionRepository.findOneOrFail({ - where: { stripeSubscriptionId }, - relations: [ - 'billingSubscriptionItems', - 'billingSubscriptionItems.billingProduct', - ], - }); + const currentSubscription = + await this.billingSubscriptionRepository.findOneOrFail({ + where: { stripeSubscriptionId }, + relations: [ + 'billingSubscriptionItems', + 'billingSubscriptionItems.billingProduct', + ], + }); - const licensed = this.getCurrentLicensedBillingSubscriptionItemOrThrow(sub); - const metered = this.getCurrentMeteredBillingSubscriptionItemOrThrow(sub); + const currentLicenseSubsciptionItem = + this.getCurrentLicensedBillingSubscriptionItemOrThrow( + currentSubscription, + ); + const currentMeteredSubsciptionItem = + this.getCurrentMeteredBillingSubscriptionItemOrThrow(currentSubscription); const updatedSubscription = await this.updateSubscription({ stripeSubscriptionId, - licensedItemId: licensed.stripeSubscriptionItemId, - meteredItemId: metered.stripeSubscriptionItemId, - licensedPriceId: prices.licensedPriceId, - meteredPriceId: prices.meteredPriceId, - seats: prices.seats, + licensedItemId: currentLicenseSubsciptionItem.stripeSubscriptionItemId, + meteredItemId: currentMeteredSubsciptionItem.stripeSubscriptionItemId, + licensedPriceId: newPrices.licensedPriceId, + meteredPriceId: newPrices.meteredPriceId, + seats: newPrices.seats, proration: 'create_prorations', - metadata: prices.planMeta - ? { ...(sub?.metadata || {}), plan: prices.planMeta } + metadata: newPrices.planMeta + ? { ...(currentSubscription?.metadata || {}), plan: newPrices.planMeta } : undefined, }); - await this.syncSubscriptionToDatabase(sub.workspaceId, updatedSubscription); + await this.syncSubscriptionToDatabase( + currentSubscription.workspaceId, + updatedSubscription, + ); } private async upgradeIntervalNowWithReanchor( diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts index 6ae64d0efe9..3eca70d64e5 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts @@ -38,17 +38,11 @@ export class BillingUsageService { } const billingSubscription = - await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow( - { - workspaceId, - }, - ); + await this.billingSubscriptionService.getCurrentBillingSubscription({ + workspaceId, + }); - if (!billingSubscription) { - return false; - } - - return true; + return !!billingSubscription; } async billUsage({ @@ -94,13 +88,6 @@ export class BillingUsageService { { workspaceId: workspace.id }, ); - if (!isDefined(subscription)) { - throw new BillingException( - 'Not-canceled subscription not found', - BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND, - ); - } - const meteredSubscriptionItemDetails = await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails( subscription.id, diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts index 3c9bfbcad40..38358e2939b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service.ts @@ -1,10 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; +import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; + import type Stripe from 'stripe'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service'; +import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names'; @Injectable() export class StripeBillingAlertService { @@ -28,14 +31,20 @@ export class StripeBillingAlertService { customerId: string, gte: number, ): Promise { - const meters = await this.stripeBillingMeterService.getAllMeters(); + const meter = (await this.stripeBillingMeterService.getAllMeters()).find( + (meter) => { + return meter.event_name === BillingMeterEventName.WORKFLOW_NODE_RUN; + }, + ); + + assertIsDefinedOrThrow(meter); await this.stripe.billing.alerts.create({ alert_type: 'usage_threshold', title: `Trial usage cap for customer ${customerId}`, usage_threshold: { gte, - meter: meters[0].id, + meter: meter.id, recurrence: 'one_time', filters: [ { diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts index a05a51b1187..3ac62783cbc 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service.ts @@ -2,12 +2,18 @@ import { Injectable, Logger } from '@nestjs/common'; +import { findOrThrow } from 'twenty-shared/utils'; + import type Stripe from 'stripe'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils'; +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; @Injectable() export class StripeSubscriptionScheduleService { @@ -60,18 +66,30 @@ export class StripeSubscriptionScheduleService { getEditablePhases(live: Stripe.SubscriptionSchedule) { const now = Math.floor(Date.now() / 1000); - const currentEditable = (live.phases || []).find((p) => { - const s = p.start_date ?? 0; - const e = p.end_date ?? Infinity; + const currentEditable = findOrThrow( + live.phases, + (p) => { + const s = p.start_date ?? 0; + const e = p.end_date ?? Infinity; - return s <= now && now < e; - }); + return s <= now && now < e; + }, + new BillingException( + `Subscription must have at least 1 phase to be editable`, + BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND, + ), + ); const nextEditable = (live.phases || []) .filter((p) => (p.start_date ?? 0) > now) - .sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0]; + .sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0] as + | Stripe.SubscriptionSchedule.Phase + | undefined; - return { currentEditable, nextEditable }; + return { + currentEditable, + nextEditable, + }; } async getSubscriptionWithSchedule(stripeSubscriptionId: string) { @@ -128,12 +146,10 @@ export class StripeSubscriptionScheduleService { const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = []; - if (currentEditable) { - const currentSnapshot = - desired.currentSnapshot ?? this.snapshotFromLivePhase(currentEditable); + const currentSnapshot = + desired.currentSnapshot ?? this.snapshotFromLivePhase(currentEditable); - phases.push(currentSnapshot); - } + phases.push(currentSnapshot); const hasNextKey = 'nextPhase' in desired; const wantsNext = hasNextKey && !!desired.nextPhase; 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 index 088c5dbc65c..704081662a1 100644 --- 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 @@ -4,7 +4,6 @@ import { Injectable, Logger } from '@nestjs/common'; import type Stripe from 'stripe'; -import { type 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; @@ -60,23 +59,6 @@ export class StripeSubscriptionService { await this.stripe.invoices.pay(latestInvoice.id); } - async updateSubscriptionItems( - stripeSubscriptionId: string, - billingSubscriptionItems: BillingSubscriptionItem[], - ) { - const stripeSubscriptionItemsToUpdate = billingSubscriptionItems.map( - (item) => ({ - id: item.stripeSubscriptionItemId, - price: item.stripePriceId, - quantity: item.quantity === null ? undefined : item.quantity, - }), - ); - - await this.stripe.subscriptions.update(stripeSubscriptionId, { - items: stripeSubscriptionItemsToUpdate, - }); - } - async updateSubscription( stripeSubscriptionId: string, updateData: Stripe.SubscriptionUpdateParams, @@ -92,12 +74,4 @@ export class StripeSubscriptionService { reset_billing_cycle_anchor: false, }; } - - async setYearlyThresholds(stripeSubscriptionId: string) { - return this.stripe.subscriptions.update(stripeSubscriptionId, { - billing_thresholds: this.getBillingThresholdsByInterval( - SubscriptionInterval.Year, - ), - }); - } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 1e2054ccd10..0a89b7439a6 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -253,9 +253,9 @@ export class WorkspaceResolver { return; } - return this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow( - { workspaceId: workspace.id }, - ); + return this.billingSubscriptionService.getCurrentBillingSubscription({ + workspaceId: workspace.id, + }); } @ResolveField(() => Number)