From b150df3737b4bcad15431e7123313e260987c90b Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 5 May 2026 08:12:23 +0200 Subject: [PATCH 1/3] fix(billing): sentinel backfill + trialing guard + intentId + structured logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 — add SENTINEL_PENDING constant + isUnresolved() helper; PI backfill resolver in handleChargeRefunded recovers real cs_* session id from PaymentIntent metadata when charge.metadata has __pending__; defensive guard in refundPartial rejects residual sentinels. H1 — createCheckout live-sub check uses status:'all' + find for ['active','trialing'] (was status:'active', silently missed trialing subscriptions). H2 — ExtrasCheckoutRequest Zod schema adds intentId z.string().max(180); createExtrasCheckout accepts intentId; idempotency key is stable intentId-derived or minute-bucketed fallback. H3 — all 7 console.error/warn sites in billing.webhook.service replaced with structured logger.error/warn calls including { error, stack } context objects. Tests: 44 billing suites, 657 tests all green. billing.usage.endpoint mock extended to cover billing.extra.service import side-effect (logger instantiation in test context). --- modules/billing/lib/billing.constants.js | 11 ++ .../models/billing.subscription.schema.js | 4 +- .../billing/services/billing.extra.service.js | 36 +++- modules/billing/services/billing.service.js | 21 ++- .../services/billing.webhook.service.js | 96 ++++++++--- .../tests/billing.checkout.unit.tests.js | 2 +- .../tests/billing.extra.service.unit.tests.js | 48 +++++- .../billing.service.extras.unit.tests.js | 73 +++++++- .../billing.usage.endpoint.unit.tests.js | 4 + ...illing.webhook.refund.integration.tests.js | 156 +++++++++++++++--- 10 files changed, 391 insertions(+), 60 deletions(-) diff --git a/modules/billing/lib/billing.constants.js b/modules/billing/lib/billing.constants.js index 552637280..97d931370 100644 --- a/modules/billing/lib/billing.constants.js +++ b/modules/billing/lib/billing.constants.js @@ -3,6 +3,17 @@ */ import config from '../../../config/index.js'; +/** + * Sentinel value written to stripeSessionId during extras checkout session creation. + * Stripe forbids a session from self-referencing its own id at creation time, so this + * placeholder is used in both session.metadata and payment_intent_data.metadata. + * After checkout.session.completed fires, the real cs_* id is patched onto the + * PaymentIntent metadata — but Stripe Charge metadata is a one-time snapshot copied + * at charge creation, so charge.metadata.stripeSessionId may still carry this sentinel + * on refunds. Treat it as "unresolved" and trigger the PI backfill path. + */ +export const SENTINEL_PENDING = '__pending__'; + export const DEFAULT_METER_RUN_BASE = 1; export const DEFAULT_CRON_JITTER_MAX_MS = 60_000; export const DEFAULT_PLAN_CHANGE_PRESERVE_USAGE = true; diff --git a/modules/billing/models/billing.subscription.schema.js b/modules/billing/models/billing.subscription.schema.js index 1e3b862d5..c0d45d192 100644 --- a/modules/billing/models/billing.subscription.schema.js +++ b/modules/billing/models/billing.subscription.schema.js @@ -80,7 +80,9 @@ const ExtrasCheckoutRequest = z cancelUrl: z.string().url('cancelUrl must be a valid URL'), // Caller-provided stable intent ID for idempotency (prevents double-click double-charge). // When absent, a soft-stable minute-bucketed key is used as fallback. - intentId: z.string().min(1).optional(), + // Max 180 chars: Stripe idempotency key limit is 255; the prefix + // `extras_checkout_{orgId}_{packId}_` consumes ~64 chars, leaving ~10 chars margin. + intentId: z.string().min(1).max(180).optional(), }) .strict(); diff --git a/modules/billing/services/billing.extra.service.js b/modules/billing/services/billing.extra.service.js index 0bef8869a..e4f39ea6c 100644 --- a/modules/billing/services/billing.extra.service.js +++ b/modules/billing/services/billing.extra.service.js @@ -5,6 +5,7 @@ import config from '../../../config/index.js'; import logger from '../../../lib/services/logger.js'; import billingEvents from '../lib/events.js'; import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; +import { SENTINEL_PENDING } from '../lib/billing.constants.js'; /** * @function creditPack @@ -80,6 +81,11 @@ const expireOldEntries = (orgId) => * original units were already consumed — this is the correct economic reflection * (the debt persists until the next creditPack replenishes it). * + * Defensive sentinel guard: if called with stripeSessionId === SENTINEL_PENDING, + * it means the upstream caller failed to resolve the real session ID before calling. + * Immediately return sentinel_unresolved to prevent a ledger lookup against a + * placeholder value that will never match any topup entry. + * * @param {string} orgId - The organization ObjectId (string). * @param {string} stripeSessionId - Stripe session ID of the original purchase (to find the pack). * @param {number} amountRefundedCents - Amount refunded in cents (integer). @@ -87,8 +93,8 @@ const expireOldEntries = (orgId) => * @param {string|undefined} [stripeRefundId] - Stripe refund object ID (e.g. rf_xxx). When present, used as * primary idempotency key. When absent (legacy callers), falls back to session+amount+topupId composite. * @returns {Promise<{doc: Object|null, applied: boolean, refundUnits: number, reason?: string}>} - * `reason` is present only when `applied === false`: 'meter_mode_disabled' | 'invalid_org' | - * 'pack_not_found' | 'ambiguous_pack_match'. + * `reason` is present only when `applied === false`: 'meter_mode_disabled' | 'sentinel_unresolved' | + * 'invalid_org' | 'pack_not_found' | 'ambiguous_pack_match'. */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik const refundPartial = async (orgId, stripeSessionId, amountRefundedCents, packId, stripeRefundId) => { @@ -96,6 +102,32 @@ const refundPartial = async (orgId, stripeSessionId, amountRefundedCents, packId return { doc: null, applied: false, reason: 'meter_mode_disabled', refundUnits: 0 }; } + // Defensive sentinel guard — belt-and-suspenders in case the upstream caller missed + // the isUnresolved() check in handleChargeRefunded. A sentinel stripeSessionId can + // never match any topup ledger entry, so skip immediately rather than silently no-op. + if (stripeSessionId === SENTINEL_PENDING) { + logger.error('[billing.extra] refundPartial called with sentinel stripeSessionId — upstream missed isUnresolved() check', { + orgId, + amountRefundedCents, + packId, + stripeRefundId, + }); + try { + billingEvents.emit('billing.refund.unresolved', { + reason: 'sentinel_unresolved', + orgId, + amountRefundedCents, + stripeRefundId, + }); + } catch (evtErr) { + logger.error('[billing.extra] billing.refund.unresolved listener error (non-fatal)', { + error: evtErr?.message ?? String(evtErr), + stack: evtErr?.stack, + }); + } + return { doc: null, applied: false, reason: 'sentinel_unresolved', refundUnits: 0 }; + } + // Find the topup ledger entry for this session to identify the pack const doc = await BillingExtraBalanceRepository.getOrCreate(orgId); if (!doc) return { doc: null, applied: false, reason: 'invalid_org', refundUnits: 0 }; diff --git a/modules/billing/services/billing.service.js b/modules/billing/services/billing.service.js index b2dd4098b..31f84793f 100644 --- a/modules/billing/services/billing.service.js +++ b/modules/billing/services/billing.service.js @@ -6,6 +6,7 @@ import getStripe from '../lib/stripe.js'; import BillingPlansService from './billing.plans.service.js'; import SubscriptionRepository from '../repositories/billing.subscription.repository.js'; import { isDuplicateKeyError } from '../lib/billing.errors.js'; +import { SENTINEL_PENDING } from '../lib/billing.constants.js'; /** * Validate that a redirect URL is safe for the current environment. @@ -148,14 +149,17 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => { // Server-side active subscription guard — closes race window between local-DB check and // stripe.checkout.sessions.create (webhook may have arrived mid-flight). + // Fetches status:'all' and filters locally to cover both 'active' AND 'trialing' in one call + // (Stripe's status filter accepts only a single string, not an array). // +1 Stripe API call cost; acceptable given infrequent checkout entry point. if (subscription.stripeCustomerId) { - const liveActiveSubs = await stripe.subscriptions.list({ + const liveSubs = await stripe.subscriptions.list({ customer: subscription.stripeCustomerId, - status: 'active', - limit: 1, + status: 'all', + limit: 10, }); - if (liveActiveSubs.data.length > 0) { + const blockingSub = liveSubs.data.find((s) => ['active', 'trialing'].includes(s.status)); + if (blockingSub) { const portalUrl = await createPortalSession(organization); const err = new Error('Subscription already active'); err.statusCode = 409; @@ -206,6 +210,7 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => { * When provided: key = `extras_checkout_{orgId}_{packId}_{intentId}` (fully stable). * When absent: key bucketed to the minute — prevents instant double-click but not * cross-minute replay. Callers should pass a UUID generated on button click. + * Max 180 chars (Stripe idempotency key limit is 255; prefix consumes ~64 chars). * @returns {Promise<{url: String}>} Object with the Checkout session URL */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik @@ -244,17 +249,17 @@ const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl, success_url: successUrl, cancel_url: cancelUrl, metadata: { - organizationId: String(organization._id), + organizationId: orgId, packId, kind: 'extras', - stripeSessionId: '__pending__', + stripeSessionId: SENTINEL_PENDING, }, payment_intent_data: { metadata: { - organizationId: String(organization._id), + organizationId: orgId, packId, kind: 'extras', - stripeSessionId: '__pending__', + stripeSessionId: SENTINEL_PENDING, }, }, }; diff --git a/modules/billing/services/billing.webhook.service.js b/modules/billing/services/billing.webhook.service.js index 04ebc2dcc..5241b16aa 100644 --- a/modules/billing/services/billing.webhook.service.js +++ b/modules/billing/services/billing.webhook.service.js @@ -12,6 +12,19 @@ import OrganizationRepository from '../../organizations/repositories/organizatio import BillingExtraService from './billing.extra.service.js'; import BillingResetService from './billing.reset.service.js'; import billingEvents from '../lib/events.js'; +import { SENTINEL_PENDING } from '../lib/billing.constants.js'; + +/** + * Treats a stripeSessionId as "unresolved" when absent, empty, or still the + * creation-time sentinel placeholder written before Stripe assigned a real cs_* id. + * Stripe Charge metadata is a one-time snapshot copied at charge creation — later + * PaymentIntent metadata patches do NOT propagate back, so charge.metadata.stripeSessionId + * may permanently carry SENTINEL_PENDING for any charge created during the brief window + * between session.create and checkout.session.completed. + * @param {string|undefined} id + * @returns {boolean} + */ +const isUnresolved = (id) => !id || id === SENTINEL_PENDING; /** * Maximum number of handler execution attempts before an event is dead-lettered. @@ -112,14 +125,14 @@ const withIdempotency = async (event, handler) => { } // Rollback claim so Stripe can retry on a fresh delivery. - try { - await ProcessedStripeEventRepository.deleteByEventId(eventId); - } catch (rollbackErr) { + // Swallow rollback errors so the original handler error is always propagated. + await ProcessedStripeEventRepository.deleteByEventId(event.id).catch((rollbackErr) => { logger.error('[billing.webhook] rollback deleteByEventId failed — event may be stuck', { - eventId, - error: rollbackErr?.message ?? rollbackErr, + eventId: event.id, + error: rollbackErr?.message ?? String(rollbackErr), + stack: rollbackErr?.stack, }); - } + }); throw err; } }; @@ -185,6 +198,11 @@ const handleCheckoutCompleted = async (session) => { * @description Handle checkout.session.completed for mode='payment' — credit extras pack. * Extracts organizationId, packId, kind from session metadata. * Skips silently if payment_status !== 'paid', kind !== 'extras', or metadata is incomplete. + * Backfills PaymentIntent metadata with the real cs_* session ID so that + * charge.refunded events can correlate the charge back to the correct ledger entry. + * At session creation time stripeSessionId is set to SENTINEL_PENDING (Stripe forbids + * self-reference). Charge.metadata is a one-time snapshot, so the PI patch is best-effort; + * handleChargeRefunded has a backfill resolver as a secondary defence. * @param {Object} session - Stripe checkout session object (mode='payment') * @returns {Promise} */ @@ -203,7 +221,7 @@ const handleCheckoutPaymentCompleted = async (session) => { // Backfill PaymentIntent metadata with the real session ID so that charge.refunded // events can correlate the charge back to this ledger entry. - // At session.create time stripeSessionId was set to '__pending__' (Stripe forbids + // At session.create time stripeSessionId was set to SENTINEL_PENDING (Stripe forbids // self-reference). Propagating the real cs_* ID here ensures charge.metadata carries // it when a refund is issued later. if (paymentIntentId) { @@ -215,12 +233,16 @@ const handleCheckoutPaymentCompleted = async (session) => { organizationId, packId, kind: 'extras', - stripeSessionId, // real cs_* ID + stripeSessionId, // real cs_* ID (replaces SENTINEL_PENDING) }, }); } catch (err) { - // Log but don't fail — refund correlation may need fallback path - console.warn('[billing.webhook] PaymentIntent metadata update failed:', err.message); + // Log but don't fail — refund correlation may use the backfill resolver path + logger.warn('[billing.webhook] PaymentIntent metadata update failed', { + paymentIntentId, + error: err?.message ?? String(err), + stack: err?.stack, + }); } } } @@ -289,7 +311,10 @@ const handleSubscriptionUpdated = async (subscription, event) => { }); } catch (evtErr) { // Listener errors must not disrupt webhook processing — log for traceability - console.error('[billing.webhook] plan.changed listener error (non-fatal):', evtErr?.message ?? evtErr); + logger.error('[billing.webhook] plan.changed listener error (non-fatal)', { + error: evtErr?.message ?? String(evtErr), + stack: evtErr?.stack, + }); } // Plan switch mid-cycle = refresh the active week snapshot to the new plan. @@ -307,10 +332,10 @@ const handleSubscriptionUpdated = async (subscription, event) => { await BillingResetService.forceRotateForPlanChange(organizationId, { preserveUsage: true }); } catch (err) { planChangeResetTriggered = false; - console.error( - '[billing.webhook] forceRotateForPlanChange failed, falling back to resetWeek:', - err?.message ?? err, - ); + logger.error('[billing.webhook] forceRotateForPlanChange failed, falling back to resetWeek', { + error: err?.message ?? String(err), + stack: err?.stack, + }); } } } @@ -328,7 +353,10 @@ const handleSubscriptionUpdated = async (subscription, event) => { await BillingResetService.resetWeek(organizationId, newPeriodStart); } catch (err) { // Log for monitoring — not thrown so webhook processing continues - console.error('[billing.webhook] resetWeek failed (non-fatal):', err?.message ?? err); + logger.error('[billing.webhook] resetWeek failed (non-fatal)', { + error: err?.message ?? String(err), + stack: err?.stack, + }); } } }; @@ -360,7 +388,10 @@ const handleSubscriptionDeleted = async (subscription, event) => { await BillingResetService.forceRotateForPlanChange(organizationId, { preserveUsage: false }); } catch (err) { // Log for monitoring — not thrown so webhook processing continues - console.error('[billing.webhook] forceRotateForPlanChange on cancel failed (non-fatal):', err?.message ?? err); + logger.error('[billing.webhook] forceRotateForPlanChange on cancel failed (non-fatal)', { + error: err?.message ?? String(err), + stack: err?.stack, + }); } }; @@ -398,7 +429,10 @@ const handleInvoicePaymentFailed = async (invoice, event) => { billingEvents.emit('payment.failed', { organizationId }); } catch (evtErr) { // Listener errors must not disrupt webhook processing — log for traceability - console.error('[billing.webhook] payment.failed listener error (non-fatal):', evtErr?.message ?? evtErr); + logger.error('[billing.webhook] payment.failed listener error (non-fatal)', { + error: evtErr?.message ?? String(evtErr), + stack: evtErr?.stack, + }); } }; @@ -452,6 +486,12 @@ const handleInvoicePaymentSucceeded = async (invoice, event) => { * Each refund's rf_ id is used as the idempotency key, making webhook replay safe. * Individual entries are silently skipped when: metadata is incomplete, refund amount * is absent/zero, or the refund object has no id. + * + * SENTINEL handling: at session.create time stripeSessionId is set to SENTINEL_PENDING + * ('__pending__'). Stripe Charge metadata is a one-time snapshot — even though + * checkout.session.completed patches the PaymentIntent with the real cs_* id, + * charge.metadata.stripeSessionId may permanently carry SENTINEL_PENDING. + * Both absent AND sentinel values trigger the PI backfill resolver path. * @param {Object} charge - Stripe charge object * @returns {Promise} */ @@ -466,8 +506,10 @@ const handleChargeRefunded = async (charge) => { if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) return; - // Backfill resolver: if stripeSessionId is missing, fetch the PaymentIntent to find it. - if (!stripeSessionId && paymentIntentId) { + // Backfill resolver: if stripeSessionId is missing OR carries SENTINEL_PENDING, + // fetch the PaymentIntent to find the real session ID patched by checkout.session.completed. + // isUnresolved() treats both falsy values and the sentinel string as "unresolved". + if (isUnresolved(stripeSessionId) && paymentIntentId) { const stripe = getStripe(); if (stripe) { try { @@ -482,12 +524,17 @@ const handleChargeRefunded = async (charge) => { } } } catch (err) { - logger.error('[billing] refund PI fetch failed', { chargeId: charge.id, paymentIntentId, error: err?.message }); + logger.error('[billing] refund PI fetch failed', { + chargeId: charge.id, + paymentIntentId, + error: err?.message ?? String(err), + stack: err?.stack, + }); } } } - if (!stripeSessionId) { + if (isUnresolved(stripeSessionId)) { // Could not resolve stripeSessionId from charge or PaymentIntent metadata. // Emit alert event and log for manual reconciliation. const refundAmount = charge.refunds?.data?.reduce((sum, r) => sum + (r.amount ?? 0), 0) ?? 0; @@ -499,7 +546,10 @@ const handleChargeRefunded = async (charge) => { try { billingEvents.emit('billing.refund.unresolved', { chargeId: charge.id, paymentIntentId, refundAmount }); } catch (evtErr) { - console.error('[billing.webhook] billing.refund.unresolved listener error (non-fatal):', evtErr?.message ?? evtErr); + logger.error('[billing.webhook] billing.refund.unresolved listener error (non-fatal)', { + error: evtErr?.message ?? String(evtErr), + stack: evtErr?.stack, + }); } return; } diff --git a/modules/billing/tests/billing.checkout.unit.tests.js b/modules/billing/tests/billing.checkout.unit.tests.js index 20aeafafa..62ecf40ee 100644 --- a/modules/billing/tests/billing.checkout.unit.tests.js +++ b/modules/billing/tests/billing.checkout.unit.tests.js @@ -37,7 +37,7 @@ describe('Billing service unit tests:', () => { }, }, subscriptions: { - // Server-side active-sub guard (Item 9): returns no active subs by default + // Default: no live active/trialing subs — checkout proceeds list: jest.fn().mockResolvedValue({ data: [] }), }, }; diff --git a/modules/billing/tests/billing.extra.service.unit.tests.js b/modules/billing/tests/billing.extra.service.unit.tests.js index 57a5617d3..8c96ce61d 100644 --- a/modules/billing/tests/billing.extra.service.unit.tests.js +++ b/modules/billing/tests/billing.extra.service.unit.tests.js @@ -56,11 +56,11 @@ describe('BillingExtraService unit tests:', () => { default: mockRepository, })); - // billing.extra.service.js imports logger and billingEvents at module load time — - // mock them to prevent logger from trying to read config files during test setup. jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, })); + + jest.unstable_mockModule('../lib/events.js', () => ({ default: { emit: jest.fn() }, })); @@ -429,5 +429,49 @@ describe('BillingExtraService unit tests:', () => { expect(key2).toBe('refund-cs_retry-4900-507f1f77bcf86cd799439fff'); expect(key1).toBe(key2); // same key → idempotent }); + + // ───────────────────────────────────────────────────────────────────────────── + // C1 — Defensive sentinel guard in refundPartial + // ───────────────────────────────────────────────────────────────────────────── + test('C1: refundPartial called with __pending__ sentinel → returns sentinel_unresolved without ledger lookup', async () => { + // Belt-and-suspenders: even if handleChargeRefunded missed the isUnresolved() check, + // refundPartial protects itself from running a ledger lookup against the sentinel placeholder. + const result = await BillingExtraService.refundPartial(orgId, '__pending__', 4900, 'pack_500k', 'rf_sentinel_test'); + + expect(result.applied).toBe(false); + expect(result.reason).toBe('sentinel_unresolved'); + expect(result.refundUnits).toBe(0); + expect(result.doc).toBeNull(); + // Must NOT reach the repository (no ledger lookup) + expect(mockRepository.getOrCreate).not.toHaveBeenCalled(); + }); + + test('C1: refundPartial returns sentinel_unresolved even when meterMode is enabled', async () => { + // Confirm meterMode check is NOT what triggers the early return — sentinel check comes first + mockConfig.billing.meterMode = true; + + const result = await BillingExtraService.refundPartial(orgId, '__pending__', 4900, 'pack_500k'); + + expect(result.reason).toBe('sentinel_unresolved'); + expect(mockRepository.getOrCreate).not.toHaveBeenCalled(); + }); + + test('C1: refundPartial with real session id proceeds normally (sentinel guard is opt-in path only)', async () => { + const topupEntry = { + _id: '507f1f77bcf86cd799439ccc', + kind: 'topup', + amount: 500000, + stripeSessionId: 'cs_real_session', + }; + const doc = makeDoc({ ledger: [topupEntry], cachedBalance: 500000 }); + mockRepository.getOrCreate.mockResolvedValue(doc); + mockRepository.refundPartial.mockResolvedValue({ doc: makeDoc({ cachedBalance: 0 }), applied: true }); + + const result = await BillingExtraService.refundPartial(orgId, 'cs_real_session', 4900, 'pack_500k', 'rf_real'); + + expect(result.reason).toBeUndefined(); + expect(result.applied).toBe(true); + expect(mockRepository.getOrCreate).toHaveBeenCalled(); + }); }); }); diff --git a/modules/billing/tests/billing.service.extras.unit.tests.js b/modules/billing/tests/billing.service.extras.unit.tests.js index 4b2235314..a295ba532 100644 --- a/modules/billing/tests/billing.service.extras.unit.tests.js +++ b/modules/billing/tests/billing.service.extras.unit.tests.js @@ -172,7 +172,7 @@ describe('BillingService.createExtrasCheckout unit tests:', () => { ); }); - test('should include idempotencyKey in Stripe call', async () => { + test('should include idempotencyKey in Stripe call (minute-bucketed when no intentId)', async () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: makeConfig(), })); @@ -187,7 +187,7 @@ describe('BillingService.createExtrasCheckout unit tests:', () => { ); const [, options] = mockStripeInstance.checkout.sessions.create.mock.calls[0]; - // No intentId passed → minute-bucketed key (no random hex suffix) + // Minute-bucketed key: extras_checkout_{orgId}_{packId}_{minuteBucket} expect(options.idempotencyKey).toMatch(/^extras_checkout_.*pack_2m_\d+$/); }); @@ -279,6 +279,75 @@ describe('BillingService.createExtrasCheckout unit tests:', () => { expect(params.automatic_tax).toEqual({ enabled: true }); }); + // ───────────────────────────────────────────────────────────────────────────── + // H2 — intentId stable idempotency key + // ───────────────────────────────────────────────────────────────────────────── + test('H2: intentId provided → stable idempotency key (same intentId = same key)', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + await BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://success', 'http://cancel', 'my-uuid-001'); + await BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://success', 'http://cancel', 'my-uuid-001'); + + const key1 = mockStripeInstance.checkout.sessions.create.mock.calls[0][1].idempotencyKey; + const key2 = mockStripeInstance.checkout.sessions.create.mock.calls[1][1].idempotencyKey; + + expect(key1).toBe(key2); + expect(key1).toMatch(/^extras_checkout_507f1f77bcf86cd799439011_pack_500k_my-uuid-001$/); + }); + + test('H2: different intentIds produce different keys', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + await BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://success', 'http://cancel', 'intent-A'); + await BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://success', 'http://cancel', 'intent-B'); + + const keyA = mockStripeInstance.checkout.sessions.create.mock.calls[0][1].idempotencyKey; + const keyB = mockStripeInstance.checkout.sessions.create.mock.calls[1][1].idempotencyKey; + + expect(keyA).not.toBe(keyB); + expect(keyA).toContain('intent-A'); + expect(keyB).toContain('intent-B'); + }); + + test('H2: no intentId → minute-bucketed key (not random — double-click safe within same minute)', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: makeConfig(), + })); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ stripeCustomerId: 'cus_existing' }); + + const minuteBefore = Math.floor(Date.now() / 60000); + await BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://success', 'http://cancel'); + await BillingService.createExtrasCheckout(mockOrganization, 'pack_500k', 'http://success', 'http://cancel'); + const minuteAfter = Math.floor(Date.now() / 60000); + + const key1 = mockStripeInstance.checkout.sessions.create.mock.calls[0][1].idempotencyKey; + const key2 = mockStripeInstance.checkout.sessions.create.mock.calls[1][1].idempotencyKey; + + // Key is a numeric minute bucket (not random hex) + expect(key1).toMatch(/^extras_checkout_.*pack_500k_\d+$/); + if (minuteBefore === minuteAfter) { + expect(key1).toBe(key2); + } + }); + test('should handle 11000 duplicate key on subscription create gracefully', async () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: makeConfig(), diff --git a/modules/billing/tests/billing.usage.endpoint.unit.tests.js b/modules/billing/tests/billing.usage.endpoint.unit.tests.js index dfae1ed9e..4dd22bd94 100644 --- a/modules/billing/tests/billing.usage.endpoint.unit.tests.js +++ b/modules/billing/tests/billing.usage.endpoint.unit.tests.js @@ -45,6 +45,10 @@ describe('Billing usage endpoint unit tests:', () => { default: mockBillingUsageService, })); + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ + default: { getOrgBalanceContext: jest.fn().mockResolvedValue(0) }, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig, })); diff --git a/modules/billing/tests/billing.webhook.refund.integration.tests.js b/modules/billing/tests/billing.webhook.refund.integration.tests.js index 673159cfe..93adb7972 100644 --- a/modules/billing/tests/billing.webhook.refund.integration.tests.js +++ b/modules/billing/tests/billing.webhook.refund.integration.tests.js @@ -5,11 +5,23 @@ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globa /** * Integration tests for handleChargeRefunded webhook handler + * Covers: + * - Normal path: stripeSessionId present in charge metadata + * - C1: SENTINEL_PENDING ('__pending__') in charge metadata triggers PI backfill + * - C1: Missing stripeSessionId triggers PI backfill + * - C1: PI backfill succeeds → refundPartial called with real session ID + * - C1: PI backfill returns sentinel too → unresolved alert, refundPartial NOT called + * - C1: PI fetch fails → unresolved alert, refundPartial NOT called + * - No paymentIntent → unresolved alert immediately */ describe('Billing webhook refund integration tests:', () => { let BillingWebhookService; let mockExtraService; let mockSubscriptionRepository; + let mockStripeInstance; + let mockGetStripe; + let mockLogger; + let mockEvents; const orgId = '507f1f77bcf86cd799439011'; const stripeSessionId = 'cs_test_session_abc'; @@ -23,6 +35,7 @@ describe('Billing webhook refund integration tests:', () => { id: 'ch_test_001', amount: 4900, amount_refunded: 4900, + payment_intent: 'pi_test_001', refunds: { data: [{ id: 'rf_test_001', amount: 4900, created: Math.floor(Date.now() / 1000) }] }, metadata: { organizationId: orgId, @@ -35,11 +48,24 @@ describe('Billing webhook refund integration tests:', () => { beforeEach(async () => { jest.resetModules(); + mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + mockEvents = { emit: jest.fn() }; + mockExtraService = { creditPack: jest.fn(), refundPartial: jest.fn().mockResolvedValue({ doc: {}, applied: true, refundUnits: 500000 }), }; + mockStripeInstance = { + paymentIntents: { + retrieve: jest.fn().mockResolvedValue({ + id: 'pi_test_001', + metadata: { stripeSessionId, organizationId: orgId, packId: 'pack_500k', kind: 'extras' }, + }), + }, + }; + mockGetStripe = jest.fn().mockReturnValue(mockStripeInstance); + mockSubscriptionRepository = { findByOrganization: jest.fn(), findByStripeCustomerId: jest.fn(), @@ -49,6 +75,8 @@ describe('Billing webhook refund integration tests:', () => { updateIfEventNewer: jest.fn().mockResolvedValue(null), }; + jest.unstable_mockModule('../lib/stripe.js', () => ({ default: mockGetStripe })); + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ default: mockExtraService, })); @@ -69,7 +97,7 @@ describe('Billing webhook refund integration tests:', () => { })); jest.unstable_mockModule('../lib/events.js', () => ({ - default: { emit: jest.fn() }, + default: mockEvents, })); jest.unstable_mockModule('../../../config/index.js', () => ({ @@ -86,7 +114,7 @@ describe('Billing webhook refund integration tests:', () => { })); jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ - default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + default: mockLogger, })); const mod = await import('../services/billing.webhook.service.js'); @@ -97,7 +125,7 @@ describe('Billing webhook refund integration tests:', () => { jest.restoreAllMocks(); }); - describe('handleChargeRefunded', () => { + describe('handleChargeRefunded — normal path', () => { test('full refund — calls refundPartial with correct orgId, sessionId, amount, packId and refundId', async () => { await BillingWebhookService.handleChargeRefunded( makeCharge({ refunds: { data: [{ id: 'rf_001', amount: 4900, created: 1000 }] } }), @@ -159,50 +187,136 @@ describe('Billing webhook refund integration tests:', () => { expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); }); - test('should skip when stripeSessionId is missing', async () => { + test('should skip when refunds list is empty', async () => { + await BillingWebhookService.handleChargeRefunded( + makeCharge({ refunds: { data: [] } }), + ); + + expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); + }); + + test('should skip when refunds list is absent', async () => { + await BillingWebhookService.handleChargeRefunded( + makeCharge({ refunds: undefined }), + ); + + expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); + }); + + test('should skip when metadata is absent', async () => { + await BillingWebhookService.handleChargeRefunded(makeCharge({ metadata: undefined })); + + expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // C1 — Sentinel '__pending__' handling + // ───────────────────────────────────────────────────────────────────────────── + describe('handleChargeRefunded — C1 sentinel + PI backfill resolver', () => { + test('C1: stripeSessionId === __pending__ triggers PI backfill, finds real session id, calls refundPartial', async () => { + // Charge metadata has sentinel — this is the production failure path + await BillingWebhookService.handleChargeRefunded( + makeCharge({ + metadata: { organizationId: orgId, stripeSessionId: '__pending__', packId: 'pack_500k' }, + refunds: { data: [{ id: 'rf_sentinel_001', amount: 4900, created: 1000 }] }, + }), + ); + + // PI fetch must have been triggered (sentinel is unresolved) + expect(mockStripeInstance.paymentIntents.retrieve).toHaveBeenCalledWith('pi_test_001'); + // After backfill, refundPartial must be called with the REAL session id + expect(mockExtraService.refundPartial).toHaveBeenCalledWith( + orgId, stripeSessionId, 4900, 'pack_500k', 'rf_sentinel_001', + ); + }); + + test('C1: stripeSessionId absent triggers PI backfill (absent ≡ unresolved)', async () => { const charge = makeCharge(); delete charge.metadata.stripeSessionId; await BillingWebhookService.handleChargeRefunded(charge); - expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); + expect(mockStripeInstance.paymentIntents.retrieve).toHaveBeenCalledWith('pi_test_001'); + expect(mockExtraService.refundPartial).toHaveBeenCalledWith( + orgId, stripeSessionId, 4900, 'pack_500k', 'rf_test_001', + ); }); - /** - * MEDIUM 4: explicit verification of the silent skip on missing stripeSessionId. - * Documents the upstream contract: charge.metadata.stripeSessionId is only present - * when the upstream session creation sets payment_intent_data.metadata explicitly. - * Without it, refunds silently skip — no service call, no error logged. - */ - test('skips silently when charge.metadata lacks stripeSessionId (upstream contract)', async () => { - // Simulate a charge where payment_intent_data.metadata was NOT set at session creation + test('C1: PI backfill returns __pending__ too → emits billing.refund.unresolved, refundPartial NOT called', async () => { + // Simulates case where the PI metadata patch never ran (checkout.session.completed not processed yet) + mockStripeInstance.paymentIntents.retrieve.mockResolvedValue({ + id: 'pi_test_001', + metadata: { stripeSessionId: '__pending__', organizationId: orgId, packId: 'pack_500k' }, + }); + await BillingWebhookService.handleChargeRefunded( - makeCharge({ metadata: { organizationId: orgId } }), // stripeSessionId absent + makeCharge({ + metadata: { organizationId: orgId, stripeSessionId: '__pending__', packId: 'pack_500k' }, + refunds: { data: [{ id: 'rf_still_sentinel', amount: 4900, created: 1000 }] }, + }), ); + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing] refund unresolved — manual reconciliation required', + expect.objectContaining({ chargeId: 'ch_test_001' }), + ); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'billing.refund.unresolved', + expect.objectContaining({ chargeId: 'ch_test_001' }), + ); expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); }); - test('should skip when refunds list is empty', async () => { + test('C1: PI fetch failure → unresolved alert emitted, refundPartial NOT called', async () => { + mockStripeInstance.paymentIntents.retrieve.mockRejectedValue(new Error('Stripe API down')); + await BillingWebhookService.handleChargeRefunded( - makeCharge({ refunds: { data: [] } }), + makeCharge({ + metadata: { organizationId: orgId, stripeSessionId: '__pending__', packId: 'pack_500k' }, + refunds: { data: [{ id: 'rf_pi_fail', amount: 4900, created: 1000 }] }, + }), ); + // PI fetch error logged + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing] refund PI fetch failed', + expect.objectContaining({ chargeId: 'ch_test_001' }), + ); + // After fetch failure → still unresolved → unresolved alert + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing] refund unresolved — manual reconciliation required', + expect.objectContaining({ chargeId: 'ch_test_001' }), + ); expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); }); - test('should skip when refunds list is absent', async () => { + test('C1: no payment_intent on charge AND __pending__ sentinel → unresolved alert without PI fetch', async () => { await BillingWebhookService.handleChargeRefunded( - makeCharge({ refunds: undefined }), + makeCharge({ + payment_intent: null, + metadata: { organizationId: orgId, stripeSessionId: '__pending__', packId: 'pack_500k' }, + refunds: { data: [{ id: 'rf_no_pi', amount: 4900, created: 1000 }] }, + }), ); + expect(mockStripeInstance.paymentIntents.retrieve).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing] refund unresolved — manual reconciliation required', + expect.objectContaining({ chargeId: 'ch_test_001' }), + ); expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); }); - test('should skip when metadata is absent', async () => { - await BillingWebhookService.handleChargeRefunded(makeCharge({ metadata: undefined })); + test('C1: real session id present (not sentinel) → no PI fetch, refundPartial called directly', async () => { + await BillingWebhookService.handleChargeRefunded( + makeCharge({ refunds: { data: [{ id: 'rf_normal', amount: 4900, created: 1000 }] } }), + ); - expect(mockExtraService.refundPartial).not.toHaveBeenCalled(); + expect(mockStripeInstance.paymentIntents.retrieve).not.toHaveBeenCalled(); + expect(mockExtraService.refundPartial).toHaveBeenCalledWith( + orgId, stripeSessionId, 4900, 'pack_500k', 'rf_normal', + ); }); }); }); From 849aca513e24bf9e2994ffaa37bb9aaad9d1fbe2 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 5 May 2026 08:34:10 +0200 Subject: [PATCH 2/3] test(billing): add trialing race guard tests + fix stale hardening assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 3 new unit tests in billing.checkout.unit.tests.js covering the Stripe-side live guard: - throws 409 when Stripe returns a trialing sub but DB shows none - throws 409 when Stripe returns an active sub but DB shows none - does NOT block when Stripe returns only an incomplete sub (deliberate asymmetry) Also update the stale assertion in billing.webhook.hardening.unit.tests.js that still expected the old status:'active'/limit:1 params — now correctly expects status:'all'/limit:10 to match the upgraded guard. --- .../tests/billing.checkout.unit.tests.js | 95 +++++++++++++++++++ .../billing.webhook.hardening.unit.tests.js | 5 +- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/modules/billing/tests/billing.checkout.unit.tests.js b/modules/billing/tests/billing.checkout.unit.tests.js index 62ecf40ee..c1e2cdf6e 100644 --- a/modules/billing/tests/billing.checkout.unit.tests.js +++ b/modules/billing/tests/billing.checkout.unit.tests.js @@ -391,6 +391,101 @@ describe('Billing service unit tests:', () => { expect(url).toBe('https://checkout.stripe.com/session_123'); }); + + // ── Stripe-side live guard (trialing race window) ────────────────────────────────────────── + + test('should throw 409 subscription_already_active when Stripe lists a trialing sub but DB shows none', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_live_trialing' } }, + })); + + // DB shows no active sub locally — only the Stripe live check should catch this + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + organization: orgId, + stripeCustomerId: 'cus_x', + stripeSubscriptionId: null, + plan: 'free', + status: 'canceled', + }); + + // Stripe live check returns a trialing sub + mockStripeInstance.subscriptions.list.mockResolvedValue({ + data: [{ id: 'sub_trial_x', status: 'trialing' }], + }); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + await expect( + BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'), + ).rejects.toMatchObject({ code: 'subscription_already_active', statusCode: 409 }); + + // Verify the live Stripe check was called with the correct params + expect(mockStripeInstance.subscriptions.list).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_x', status: 'all', limit: 10 }), + ); + expect(mockStripeInstance.checkout.sessions.create).not.toHaveBeenCalled(); + }); + + test('should throw 409 subscription_already_active when Stripe lists an active sub but DB shows none', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_live_active' } }, + })); + + // DB shows no active sub locally — only the Stripe live check should catch this + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + organization: orgId, + stripeCustomerId: 'cus_x', + stripeSubscriptionId: null, + plan: 'free', + status: 'canceled', + }); + + // Stripe live check returns an active sub + mockStripeInstance.subscriptions.list.mockResolvedValue({ + data: [{ id: 'sub_active_x', status: 'active' }], + }); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + await expect( + BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'), + ).rejects.toMatchObject({ code: 'subscription_already_active', statusCode: 409 }); + + expect(mockStripeInstance.subscriptions.list).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_x', status: 'all', limit: 10 }), + ); + expect(mockStripeInstance.checkout.sessions.create).not.toHaveBeenCalled(); + }); + + test('should NOT block checkout when Stripe lists an incomplete sub (incomplete is not in block list)', async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { stripe: { secretKey: 'sk_test_live_incomplete' } }, + })); + + // DB shows no active sub locally + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ + organization: orgId, + stripeCustomerId: 'cus_x', + stripeSubscriptionId: null, + plan: 'free', + status: 'canceled', + }); + + // Stripe returns only an incomplete sub — should NOT block (deliberate asymmetry vs DB guard) + mockStripeInstance.subscriptions.list.mockResolvedValue({ + data: [{ id: 'sub_incomplete_x', status: 'incomplete' }], + }); + + const mod = await import('../services/billing.service.js'); + BillingService = mod.default; + + const url = await BillingService.createCheckout(mockOrganization, 'price_starter_m', 'http://ok', 'http://cancel'); + + expect(url).toBe('https://checkout.stripe.com/session_123'); + expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalled(); + }); }); describe('createPortalSession', () => { diff --git a/modules/billing/tests/billing.webhook.hardening.unit.tests.js b/modules/billing/tests/billing.webhook.hardening.unit.tests.js index 874841f20..30adf46aa 100644 --- a/modules/billing/tests/billing.webhook.hardening.unit.tests.js +++ b/modules/billing/tests/billing.webhook.hardening.unit.tests.js @@ -777,7 +777,7 @@ describe('BillingService.createCheckout — server-side active-sub guard:', () = jest.restoreAllMocks(); }); - test('no live active sub → stripe.subscriptions.list called with active status, checkout proceeds', async () => { + test('no live active/trialing sub → stripe.subscriptions.list called with status:all, checkout proceeds', async () => { mockStripeInstance.subscriptions.list.mockResolvedValue({ data: [] }); await BillingService.createCheckout( @@ -787,8 +787,9 @@ describe('BillingService.createCheckout — server-side active-sub guard:', () = 'https://test.example.com/cancel', ); + // Guard upgraded to status:'all' + local filter to catch both 'active' and 'trialing' in one call expect(mockStripeInstance.subscriptions.list).toHaveBeenCalledWith( - expect.objectContaining({ status: 'active', limit: 1 }), + expect.objectContaining({ status: 'all', limit: 10 }), ); expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalled(); }); From 49634708f75441b08e2e396c5840a2988566c69c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 5 May 2026 09:25:50 +0200 Subject: [PATCH 3/3] test(billing): cover listener-error swallow paths for codecov/patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 3 unit tests covering the non-fatal listener error catches that were the only uncovered patch lines: - billing.extra.service.js L123 — billing.refund.unresolved listener error swallow on sentinel guard path - billing.webhook.service.js L314 — plan.changed listener error swallow in handleSubscriptionUpdated - billing.webhook.service.js L549 — billing.refund.unresolved listener error swallow in handleChargeRefunded Tests-only — no production code changes. --- .../tests/billing.extra.service.unit.tests.js | 56 ++++++ .../billing.webhook.hardening.unit.tests.js | 172 ++++++++++++++++++ 2 files changed, 228 insertions(+) diff --git a/modules/billing/tests/billing.extra.service.unit.tests.js b/modules/billing/tests/billing.extra.service.unit.tests.js index 8c96ce61d..4f90bbc3a 100644 --- a/modules/billing/tests/billing.extra.service.unit.tests.js +++ b/modules/billing/tests/billing.extra.service.unit.tests.js @@ -475,3 +475,59 @@ describe('BillingExtraService unit tests:', () => { }); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Sentinel guard — listener-error catch (covers logger.error swallow path) +// ───────────────────────────────────────────────────────────────────────────── +describe('BillingExtraService refundPartial — sentinel listener-error swallow:', () => { + let BillingExtraService; + let mockLogger; + let mockEvents; + + const orgId = '507f1f77bcf86cd799439011'; + + beforeEach(async () => { + jest.resetModules(); + + mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + mockEvents = { + emit: jest.fn(() => { + throw new Error('listener blew'); + }), + }; + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { billing: { meterMode: true, plans: ['pro'], packs: [] } }, + })); + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ + default: { + creditPack: jest.fn(), + debit: jest.fn(), + addExpirationEntries: jest.fn(), + getOrCreate: jest.fn(), + getBalance: jest.fn(), + refundPartial: jest.fn(), + }, + })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger })); + jest.unstable_mockModule('../lib/events.js', () => ({ default: mockEvents })); + + const mod = await import('../services/billing.extra.service.js'); + BillingExtraService = mod.default; + }); + + afterEach(() => jest.restoreAllMocks()); + + test('sentinel session id + emit throws → logger.error is called and result still returned', async () => { + const result = await BillingExtraService.refundPartial(orgId, '__pending__', 4900, 'pack_500k', 'rf_x'); + + // Listener error must NOT propagate — sentinel return shape preserved + expect(result).toEqual({ doc: null, applied: false, reason: 'sentinel_unresolved', refundUnits: 0 }); + + // Inner catch must have logged the listener error (non-fatal) + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing.extra] billing.refund.unresolved listener error (non-fatal)', + expect.objectContaining({ error: 'listener blew' }), + ); + }); +}); diff --git a/modules/billing/tests/billing.webhook.hardening.unit.tests.js b/modules/billing/tests/billing.webhook.hardening.unit.tests.js index 30adf46aa..718aa7c28 100644 --- a/modules/billing/tests/billing.webhook.hardening.unit.tests.js +++ b/modules/billing/tests/billing.webhook.hardening.unit.tests.js @@ -841,3 +841,175 @@ describe('BillingService.createCheckout — server-side active-sub guard:', () = } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Listener-error swallow paths — logger.error called but no propagation +// (covers webhook lines 314 plan.changed listener catch + 549 refund.unresolved listener catch) +// ───────────────────────────────────────────────────────────────────────────── +describe('handleSubscriptionUpdated — plan.changed listener error swallow:', () => { + let BillingWebhookService; + let mockLogger; + let mockEvents; + + const orgId = '507f1f77bcf86cd799439011'; + const subId = '607f1f77bcf86cd799439022'; + + beforeEach(async () => { + jest.resetModules(); + + mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + mockEvents = { + emit: jest.fn((evtName) => { + if (evtName === 'plan.changed') throw new Error('plan listener blew'); + }), + }; + + jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ + default: { + findByOrganization: jest.fn(), + findByStripeCustomerId: jest.fn(), + findByStripeSubscriptionId: jest.fn().mockResolvedValue({ _id: subId, organization: orgId }), + create: jest.fn(), + update: jest.fn(), + updateIfEventNewer: jest.fn().mockResolvedValue({ _id: subId }), + }, + })); + jest.unstable_mockModule('../repositories/billing.processedStripeEvent.repository.js', () => ({ + default: { tryRecord: jest.fn().mockResolvedValue({ recorded: true }), incrementAttempts: jest.fn(), markDeadLetter: jest.fn(), deleteByEventId: jest.fn() }, + })); + jest.unstable_mockModule('../../organizations/repositories/organizations.repository.js', () => ({ + default: { setPlan: jest.fn().mockResolvedValue({}) }, + })); + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ default: { creditPack: jest.fn(), refundPartial: jest.fn() } })); + jest.unstable_mockModule('../services/billing.reset.service.js', () => ({ + default: { resetWeek: jest.fn().mockResolvedValue({}), forceRotateForPlanChange: jest.fn().mockResolvedValue({}) }, + })); + jest.unstable_mockModule('../lib/events.js', () => ({ default: mockEvents })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger })); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { billing: { plans: ['free', 'starter', 'pro', 'enterprise'], meterMode: true } }, + })); + jest.unstable_mockModule('mongoose', () => ({ + default: { + Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, + model: () => ({}), + }, + })); + + const mod = await import('../services/billing.webhook.service.js'); + BillingWebhookService = mod.default; + }); + + afterEach(() => jest.restoreAllMocks()); + + test('plan.changed listener throws → logger.error called, webhook does NOT propagate', async () => { + const newPeriodStart = 1700604800; + + await expect( + BillingWebhookService.handleSubscriptionUpdated( + { + id: 'sub_listener_err', + status: 'active', + items: { + data: [{ + price: { metadata: { planId: 'pro' } }, + current_period_start: newPeriodStart, + current_period_end: newPeriodStart + 2592000, + }], + }, + }, + { + id: 'evt_plan_listener_err', created: 1700000100, + data: { + previous_attributes: { + items: { data: [{ price: { metadata: { planId: 'starter' } }, current_period_start: 1700000000 }] }, + }, + }, + }, + ), + ).resolves.not.toThrow(); + + expect(mockEvents.emit).toHaveBeenCalledWith('plan.changed', expect.any(Object)); + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing.webhook] plan.changed listener error (non-fatal)', + expect.objectContaining({ error: 'plan listener blew' }), + ); + }); +}); + +describe('handleChargeRefunded — billing.refund.unresolved listener error swallow:', () => { + let BillingWebhookService; + let mockLogger; + let mockEvents; + + const orgId = '507f1f77bcf86cd799439011'; + + beforeEach(async () => { + jest.resetModules(); + + mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + mockEvents = { + emit: jest.fn((evtName) => { + if (evtName === 'billing.refund.unresolved') throw new Error('refund listener blew'); + }), + }; + + jest.unstable_mockModule('../lib/stripe.js', () => ({ default: jest.fn().mockReturnValue(null) })); + jest.unstable_mockModule('../services/billing.extra.service.js', () => ({ + default: { creditPack: jest.fn(), refundPartial: jest.fn() }, + })); + jest.unstable_mockModule('../services/billing.reset.service.js', () => ({ default: { resetWeek: jest.fn() } })); + jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ + default: { + findByOrganization: jest.fn(), findByStripeCustomerId: jest.fn(), + findByStripeSubscriptionId: jest.fn(), create: jest.fn(), update: jest.fn(), + updateIfEventNewer: jest.fn().mockResolvedValue(null), + }, + })); + jest.unstable_mockModule('../repositories/billing.processedStripeEvent.repository.js', () => ({ + default: { + tryRecord: jest.fn().mockResolvedValue({ recorded: true }), + incrementAttempts: jest.fn().mockResolvedValue({ attempts: 1 }), + deleteByEventId: jest.fn().mockResolvedValue({ deleted: true }), + markDeadLetter: jest.fn(), + }, + })); + jest.unstable_mockModule('../lib/events.js', () => ({ default: mockEvents })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger })); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { billing: { plans: ['free', 'starter', 'pro', 'enterprise'] } }, + })); + jest.unstable_mockModule('mongoose', () => ({ + default: { + Types: { ObjectId: { isValid: (id) => /^[a-f\d]{24}$/i.test(id) } }, + model: () => ({}), + }, + })); + + const mod = await import('../services/billing.webhook.service.js'); + BillingWebhookService = mod.default; + }); + + afterEach(() => jest.restoreAllMocks()); + + test('emit throws on unresolved branch → logger.error called, no propagation', async () => { + // No payment_intent → skips PI fetch → falls into isUnresolved() final branch + await expect( + BillingWebhookService.handleChargeRefunded({ + id: 'ch_listener_err', + payment_intent: null, + metadata: { organizationId: orgId }, + refunds: { data: [{ id: 'rf_listener_err', amount: 4900 }] }, + }), + ).resolves.not.toThrow(); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'billing.refund.unresolved', + expect.objectContaining({ chargeId: 'ch_listener_err' }), + ); + expect(mockLogger.error).toHaveBeenCalledWith( + '[billing.webhook] billing.refund.unresolved listener error (non-fatal)', + expect.objectContaining({ error: 'refund listener blew' }), + ); + }); +});