import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import { DateTime } from 'luxon' import { applySubscriptionToOrg, createTrialCheckoutSession, handleCheckoutCompleted, handlePaymentFailed, handleSubscriptionDeleted, handleSubscriptionUpdate, handleTrialWillEnd, TrialAlreadyConsumedError, __setTrialRecapEnqueueForTests, } from '#services/stripe_billing' import { dispatchWebhookEvent } from '#controllers/billing_controller' import Organization from '#models/organization' import { createTestUser } from '../helpers/auth.js' import { fakeCheckoutSession, fakeInvoice, fakeSubscription, installStripeMock, uninstallStripeMock, } from '../helpers/stripe_mock.js' /** * Tests des handlers webhook + helper trial du service `stripe_billing`. * * Stratégie de mocking : on injecte un client Stripe partiel via * `installStripeMock()` (cf. helpers). Tous les appels SDK (retrieve, * sessions.create, billingPortal.create) sont stubés. * * Toutes les assertions DB tournent dans une transaction `withGlobalTransaction` * (auto-rollback per test) pour isoler les modifs. */ test.group('stripe_billing — applySubscriptionToOrg', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('persiste plan, cycle, status, trial_end depuis une subscription Stripe', async ({ assert, }) => { const { org } = await createTestUser() const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600 const periodEndEpoch = trialEndEpoch + 30 * 24 * 3600 const sub = fakeSubscription({ id: 'sub_test_apply', customerId: 'cus_test_apply', status: 'trialing', lookupKey: 'rubis_pro_monthly', currentPeriodEnd: periodEndEpoch, trialEnd: trialEndEpoch, }) await applySubscriptionToOrg(org.id, sub) await org.refresh() assert.equal(org.plan, 'pro') assert.equal(org.subscriptionStatus, 'trialing') assert.equal(org.billingCycle, 'monthly') assert.equal(org.stripeSubscriptionId, 'sub_test_apply') assert.isNotNull(org.trialEndsAt) assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch) assert.equal(org.currentPeriodEnd?.toUnixInteger(), periodEndEpoch) assert.isFalse(org.cancelAtPeriodEnd) }) test('détecte cancel_at_period_end (true) ET cancel_at (timestamp)', async ({ assert }) => { const { org: orgA } = await createTestUser() const { org: orgB } = await createTestUser() // Cas A : cancel_at_period_end = true (API directe) await applySubscriptionToOrg( orgA.id, fakeSubscription({ id: 'sub_cancel_a', lookupKey: 'rubis_pro_monthly', cancelAtPeriodEnd: true, }) ) await orgA.refresh() assert.isTrue(orgA.cancelAtPeriodEnd) // Cas B : cancel_at = timestamp (Customer Portal) await applySubscriptionToOrg( orgB.id, fakeSubscription({ id: 'sub_cancel_b', lookupKey: 'rubis_pro_monthly', cancelAt: Math.floor(Date.now() / 1000) + 86_400, }) ) await orgB.refresh() assert.isTrue(orgB.cancelAtPeriodEnd) }) test('idempotent : 2 appels successifs → même état final', async ({ assert }) => { const { org } = await createTestUser() const sub = fakeSubscription({ id: 'sub_test_idem', status: 'active', lookupKey: 'rubis_business_yearly', }) await applySubscriptionToOrg(org.id, sub) const firstState = (await Organization.findOrFail(org.id)).$attributes await applySubscriptionToOrg(org.id, sub) const secondState = (await Organization.findOrFail(org.id)).$attributes assert.equal(firstState['plan'], 'business') assert.equal(firstState['billingCycle'], 'yearly') assert.equal(firstState['subscriptionStatus'], secondState['subscriptionStatus']) assert.equal(firstState['stripeSubscriptionId'], secondState['stripeSubscriptionId']) }) test('org introuvable → no-op silencieux (pas de throw)', async ({ assert }) => { // L'UUID non existant ne doit pas casser le webhook (Stripe retry sinon). await assert.doesNotReject(() => applySubscriptionToOrg( '00000000-0000-0000-0000-000000000000', fakeSubscription({ lookupKey: 'rubis_pro_monthly' }) ) ) }) test('lookup_key inconnu → plan retombe sur free', async ({ assert }) => { const { org } = await createTestUser() await applySubscriptionToOrg( org.id, fakeSubscription({ lookupKey: 'unknown_key_xyz' }) ) await org.refresh() assert.equal(org.plan, 'free') assert.isNull(org.billingCycle) }) }) test.group('stripe_billing — handleSubscriptionUpdate (lookup org)', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('lookup via metadata.organization_id', async ({ assert }) => { const { org } = await createTestUser() await handleSubscriptionUpdate( fakeSubscription({ lookupKey: 'rubis_pro_monthly', organizationId: org.id, status: 'active', }) ) await org.refresh() assert.equal(org.plan, 'pro') assert.equal(org.subscriptionStatus, 'active') }) test('fallback : lookup via stripeCustomerId si metadata absente', async ({ assert }) => { const { org } = await createTestUser() org.stripeCustomerId = 'cus_lookup_fallback' await org.save() await handleSubscriptionUpdate( fakeSubscription({ customerId: 'cus_lookup_fallback', lookupKey: 'rubis_pro_monthly', status: 'active', // pas de metadata.organization_id }) ) await org.refresh() assert.equal(org.plan, 'pro') }) test('aucun org trouvé → no-op silencieux', async ({ assert }) => { await assert.doesNotReject(() => handleSubscriptionUpdate( fakeSubscription({ customerId: 'cus_doesnt_exist_anywhere', lookupKey: 'rubis_pro_monthly', }) ) ) }) }) test.group('stripe_billing — handleSubscriptionDeleted', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('Pro avec sub → org passe en free + clear stripe fields', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'pro' org.subscriptionStatus = 'active' org.billingCycle = 'monthly' org.stripeCustomerId = 'cus_to_delete' org.stripeSubscriptionId = 'sub_to_delete' org.currentPeriodEnd = DateTime.utc().plus({ days: 10 }) org.cancelAtPeriodEnd = true await org.save() await handleSubscriptionDeleted( fakeSubscription({ customerId: 'cus_to_delete', id: 'sub_to_delete' }) ) await org.refresh() assert.equal(org.plan, 'free') assert.equal(org.subscriptionStatus, 'canceled') assert.isNull(org.stripeSubscriptionId) assert.isNull(org.billingCycle) assert.isNull(org.currentPeriodEnd) assert.isFalse(org.cancelAtPeriodEnd) }) test('trial_ends_at conservé après cancellation (garde-fou anti-relance trial)', async ({ assert, }) => { const { org } = await createTestUser() const oldTrialEnd = DateTime.utc().minus({ days: 30 }) org.plan = 'pro' org.stripeCustomerId = 'cus_keep_trial' org.trialEndsAt = oldTrialEnd await org.save() await handleSubscriptionDeleted( fakeSubscription({ customerId: 'cus_keep_trial' }) ) await org.refresh() assert.isNotNull(org.trialEndsAt) assert.equal(org.trialEndsAt?.toUnixInteger(), oldTrialEnd.toUnixInteger()) }) test('customer inconnu → no-op', async ({ assert }) => { await assert.doesNotReject(() => handleSubscriptionDeleted(fakeSubscription({ customerId: 'cus_ghost' })) ) }) }) test.group('stripe_billing — handlePaymentFailed', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('invoice.payment_failed → org marquée past_due', async ({ assert }) => { const { org } = await createTestUser() org.plan = 'pro' org.subscriptionStatus = 'active' org.stripeCustomerId = 'cus_payment_failed' await org.save() await handlePaymentFailed(fakeInvoice({ customerId: 'cus_payment_failed' })) await org.refresh() assert.equal(org.subscriptionStatus, 'past_due') // Plan reste pro pendant le grace period Stripe (smart retries). assert.equal(org.plan, 'pro') }) test('customer null → no-op (jamais d\'invoice détachée)', async ({ assert }) => { await assert.doesNotReject(() => handlePaymentFailed(fakeInvoice({ customerId: null }))) }) test('customer inconnu → no-op', async ({ assert }) => { await assert.doesNotReject(() => handlePaymentFailed(fakeInvoice({ customerId: 'cus_ghost' })) ) }) }) test.group('stripe_billing — handleTrialWillEnd (enqueue recap)', (group) => { let enqueueCalls: Array<{ orgId: string; subscriptionId: string }> = [] group.each.setup(() => testUtils.db().withGlobalTransaction()) group.each.setup(() => { enqueueCalls = [] __setTrialRecapEnqueueForTests(async (orgId, subscriptionId) => { enqueueCalls.push({ orgId, subscriptionId }) }) return () => { // Reset entre les tests pour ne pas leak entre suites. __setTrialRecapEnqueueForTests(async () => {}) } }) test('enqueue recap pour l\'org matchée via customerId', async ({ assert }) => { const { org } = await createTestUser() org.stripeCustomerId = 'cus_trial_will_end' await org.save() await handleTrialWillEnd( fakeSubscription({ id: 'sub_trial_99', customerId: 'cus_trial_will_end', status: 'trialing', }) ) assert.lengthOf(enqueueCalls, 1) assert.equal(enqueueCalls[0]?.orgId, org.id) assert.equal(enqueueCalls[0]?.subscriptionId, 'sub_trial_99') }) test('customer inconnu → pas d\'enqueue', async ({ assert }) => { await handleTrialWillEnd( fakeSubscription({ customerId: 'cus_ghost', status: 'trialing' }) ) assert.lengthOf(enqueueCalls, 0) }) }) test.group('stripe_billing — handleCheckoutCompleted (avec Stripe mock)', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) group.each.teardown(() => uninstallStripeMock()) test('retrieve subscription Stripe puis apply → org Pro trialing', async ({ assert }) => { const { org } = await createTestUser() const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600 installStripeMock({ subscriptions: { retrieve: (async () => fakeSubscription({ id: 'sub_completed', status: 'trialing', lookupKey: 'rubis_pro_monthly', trialEnd: trialEndEpoch, organizationId: org.id, })) as unknown as import('stripe').default['subscriptions']['retrieve'], }, }) await handleCheckoutCompleted( fakeCheckoutSession({ id: 'cs_done', organizationId: org.id, subscriptionId: 'sub_completed', }) ) await org.refresh() assert.equal(org.plan, 'pro') assert.equal(org.subscriptionStatus, 'trialing') assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch) }) test('session sans subscription → no-op', async ({ assert }) => { const { org } = await createTestUser() await handleCheckoutCompleted( fakeCheckoutSession({ organizationId: org.id, subscriptionId: null }) ) await org.refresh() assert.equal(org.plan, 'free') // pas touché }) test('session sans organization_id metadata → no-op', async ({ assert }) => { await handleCheckoutCompleted( fakeCheckoutSession({ organizationId: null, subscriptionId: 'sub_should_be_ignored', }) ) // Pas d'assertion DB nécessaire — le no-op réussit silencieusement. assert.isTrue(true) }) }) test.group('stripe_billing — createTrialCheckoutSession (idempotence)', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) group.each.teardown(() => uninstallStripeMock()) test('throw TrialAlreadyConsumedError si trialEndsAt déjà posé', async ({ assert }) => { const { org } = await createTestUser() org.trialEndsAt = DateTime.utc().minus({ days: 30 }) org.stripeCustomerId = 'cus_test_idem' await org.save() await assert.rejects( () => createTrialCheckoutSession({ org, customerId: 'cus_test_idem', plan: 'pro', cycle: 'monthly', }), TrialAlreadyConsumedError ) }) test('throw TrialAlreadyConsumedError si stripeSubscriptionId déjà posé', async ({ assert, }) => { const { org } = await createTestUser() org.stripeSubscriptionId = 'sub_existing' org.stripeCustomerId = 'cus_test_idem2' await org.save() await assert.rejects( () => createTrialCheckoutSession({ org, customerId: 'cus_test_idem2', plan: 'pro', cycle: 'monthly', }), TrialAlreadyConsumedError ) }) test('happy path : crée la session Checkout avec trial_period_days=14', async ({ assert, }) => { const { org } = await createTestUser() org.stripeCustomerId = 'cus_happy' await org.save() const sessionCalls: Array> = [] installStripeMock({ prices: { list: (async () => ({ data: [{ id: 'price_pro_monthly_test' }], })) as unknown as import('stripe').default['prices']['list'], }, checkout: { sessions: { create: (async (params: Record) => { sessionCalls.push(params) return { id: 'cs_new', url: 'https://checkout.stripe.test/cs_new' } }) as unknown as import('stripe').default['checkout']['sessions']['create'], }, }, }) const result = await createTrialCheckoutSession({ org, customerId: 'cus_happy', plan: 'pro', cycle: 'monthly', }) assert.equal(result.url, 'https://checkout.stripe.test/cs_new') assert.lengthOf(sessionCalls, 1) const params = sessionCalls[0] as { subscription_data?: { trial_period_days?: number; metadata?: Record } metadata?: Record } assert.equal(params.subscription_data?.trial_period_days, 14) assert.equal(params.subscription_data?.metadata?.['organization_id'], org.id) assert.equal(params.metadata?.['is_trial'], 'true') }) }) test.group('stripe_billing — dispatchWebhookEvent (router)', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('event inconnu → no-op silencieux (pas de throw)', async ({ assert }) => { const evt = { id: 'evt_unknown', type: 'customer.tax_id.created', data: { object: {} }, } as unknown as import('stripe').default.Event await assert.doesNotReject(() => dispatchWebhookEvent(evt)) }) test('customer.subscription.deleted route bien vers handleSubscriptionDeleted', async ({ assert, }) => { const { org } = await createTestUser() org.plan = 'pro' org.stripeCustomerId = 'cus_routed' org.stripeSubscriptionId = 'sub_routed' await org.save() const evt = { id: 'evt_deleted', type: 'customer.subscription.deleted', data: { object: fakeSubscription({ customerId: 'cus_routed', id: 'sub_routed' }), }, } as unknown as import('stripe').default.Event await dispatchWebhookEvent(evt) await org.refresh() assert.equal(org.plan, 'free') assert.equal(org.subscriptionStatus, 'canceled') }) })