Skip to content

Commit cbee641

Browse files
fix(billing): sentinel backfill + trialing guard + intentId + structured logger
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).
1 parent a83f0b1 commit cbee641

12 files changed

Lines changed: 470 additions & 53 deletions

modules/billing/controllers/billing.controller.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ const getUsage = async (req, res) => {
133133
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js controller, not Qwik
134134
const extrasCheckout = async (req, res) => {
135135
try {
136-
const { packId, successUrl, cancelUrl } = req.body;
137-
const result = await BillingService.createExtrasCheckout(req.organization, packId, successUrl, cancelUrl);
136+
const { packId, successUrl, cancelUrl, intentId } = req.body;
137+
const result = await BillingService.createExtrasCheckout(req.organization, packId, successUrl, cancelUrl, intentId);
138138
responses.success(res, 'extras checkout session created')(result);
139139
} catch (err) {
140140
const status = err.message?.startsWith('Invalid') || err.message?.includes('not found') ? 422 : 502;

modules/billing/lib/billing.constants.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
*/
44
import config from '../../../config/index.js';
55

6+
/**
7+
* Sentinel value written to stripeSessionId during extras checkout session creation.
8+
* Stripe forbids a session from self-referencing its own id at creation time, so this
9+
* placeholder is used in both session.metadata and payment_intent_data.metadata.
10+
* After checkout.session.completed fires, the real cs_* id is patched onto the
11+
* PaymentIntent metadata — but Stripe Charge metadata is a one-time snapshot copied
12+
* at charge creation, so charge.metadata.stripeSessionId may still carry this sentinel
13+
* on refunds. Treat it as "unresolved" and trigger the PI backfill path.
14+
*/
15+
export const SENTINEL_PENDING = '__pending__';
16+
617
export const DEFAULT_METER_RUN_BASE = 1;
718
export const DEFAULT_CRON_JITTER_MAX_MS = 60_000;
819
export const DEFAULT_PLAN_CHANGE_PRESERVE_USAGE = true;

modules/billing/models/billing.subscription.schema.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ const ExtrasCheckoutRequest = z
7373
packId: z.string().trim().min(1, 'packId is required'),
7474
successUrl: z.string().url('successUrl must be a valid URL'),
7575
cancelUrl: z.string().url('cancelUrl must be a valid URL'),
76+
// Caller-provided stable intent ID for idempotency (prevents double-click double-charge).
77+
// When absent, a soft-stable minute-bucketed key is used as fallback.
78+
// Max 180 chars: Stripe idempotency key limit is 255; the prefix
79+
// `extras_checkout_{orgId}_{packId}_` consumes ~64 chars, leaving ~10 chars margin.
80+
intentId: z.string().min(1).max(180).optional(),
7681
})
7782
.strict();
7883

modules/billing/services/billing.extra.service.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
* Module dependencies
33
*/
44
import config from '../../../config/index.js';
5+
import logger from '../../../lib/services/logger.js';
6+
import billingEvents from '../lib/events.js';
57
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
8+
import { SENTINEL_PENDING } from '../lib/billing.constants.js';
69

710
/**
811
* @function creditPack
@@ -78,22 +81,53 @@ const expireOldEntries = (orgId) =>
7881
* original units were already consumed — this is the correct economic reflection
7982
* (the debt persists until the next creditPack replenishes it).
8083
*
84+
* Defensive sentinel guard: if called with stripeSessionId === SENTINEL_PENDING,
85+
* it means the upstream caller failed to resolve the real session ID before calling.
86+
* Immediately return sentinel_unresolved to prevent a ledger lookup against a
87+
* placeholder value that will never match any topup entry.
88+
*
8189
* @param {string} orgId - The organization ObjectId (string).
8290
* @param {string} stripeSessionId - Stripe session ID of the original purchase (to find the pack).
8391
* @param {number} amountRefundedCents - Amount refunded in cents (integer).
8492
* @param {string|undefined} [packId] - Optional pack identifier from Stripe metadata for exact correlation.
8593
* @param {string|undefined} [stripeRefundId] - Stripe refund object ID (e.g. rf_xxx). When present, used as
8694
* primary idempotency key. When absent (legacy callers), falls back to session+amount+topupId composite.
8795
* @returns {Promise<{doc: Object|null, applied: boolean, refundUnits: number, reason?: string}>}
88-
* `reason` is present only when `applied === false`: 'meter_mode_disabled' | 'invalid_org' |
89-
* 'pack_not_found' | 'ambiguous_pack_match'.
96+
* `reason` is present only when `applied === false`: 'meter_mode_disabled' | 'sentinel_unresolved' |
97+
* 'invalid_org' | 'pack_not_found' | 'ambiguous_pack_match'.
9098
*/
9199
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
92100
const refundPartial = async (orgId, stripeSessionId, amountRefundedCents, packId, stripeRefundId) => {
93101
if (!config?.billing?.meterMode) {
94102
return { doc: null, applied: false, reason: 'meter_mode_disabled', refundUnits: 0 };
95103
}
96104

105+
// Defensive sentinel guard — belt-and-suspenders in case the upstream caller missed
106+
// the isUnresolved() check in handleChargeRefunded. A sentinel stripeSessionId can
107+
// never match any topup ledger entry, so skip immediately rather than silently no-op.
108+
if (stripeSessionId === SENTINEL_PENDING) {
109+
logger.error('[billing.extra] refundPartial called with sentinel stripeSessionId — upstream missed isUnresolved() check', {
110+
orgId,
111+
amountRefundedCents,
112+
packId,
113+
stripeRefundId,
114+
});
115+
try {
116+
billingEvents.emit('billing.refund.unresolved', {
117+
reason: 'sentinel_unresolved',
118+
orgId,
119+
amountRefundedCents,
120+
stripeRefundId,
121+
});
122+
} catch (evtErr) {
123+
logger.error('[billing.extra] billing.refund.unresolved listener error (non-fatal)', {
124+
error: evtErr?.message ?? String(evtErr),
125+
stack: evtErr?.stack,
126+
});
127+
}
128+
return { doc: null, applied: false, reason: 'sentinel_unresolved', refundUnits: 0 };
129+
}
130+
97131
// Find the topup ledger entry for this session to identify the pack
98132
const doc = await BillingExtraBalanceRepository.getOrCreate(orgId);
99133
if (!doc) return { doc: null, applied: false, reason: 'invalid_org', refundUnits: 0 };

modules/billing/services/billing.service.js

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/**
22
* Module dependencies
33
*/
4-
import { randomBytes } from 'node:crypto';
54
import config from '../../../config/index.js';
65
import getStripe from '../lib/stripe.js';
76
import BillingPlansService from './billing.plans.service.js';
87
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
98
import { isDuplicateKeyError } from '../lib/billing.errors.js';
9+
import { SENTINEL_PENDING } from '../lib/billing.constants.js';
1010

1111
/**
1212
* Validate that a redirect URL is safe for the current environment.
@@ -147,6 +147,28 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
147147

148148
const subscription = await _ensureStripeCustomer(stripe, organization);
149149

150+
// Server-side active subscription guard — closes race window between local-DB check and
151+
// stripe.checkout.sessions.create (webhook may have arrived mid-flight).
152+
// Fetches status:'all' and filters locally to cover both 'active' AND 'trialing' in one call
153+
// (Stripe's status filter accepts only a single string, not an array).
154+
// +1 Stripe API call cost; acceptable given infrequent checkout entry point.
155+
if (subscription.stripeCustomerId) {
156+
const liveSubs = await stripe.subscriptions.list({
157+
customer: subscription.stripeCustomerId,
158+
status: 'all',
159+
limit: 10,
160+
});
161+
const blockingSub = liveSubs.data.find((s) => ['active', 'trialing'].includes(s.status));
162+
if (blockingSub) {
163+
const portalUrl = await createPortalSession(organization);
164+
const err = new Error('Subscription already active');
165+
err.statusCode = 409;
166+
err.code = 'subscription_already_active';
167+
err.portalUrl = portalUrl;
168+
throw err;
169+
}
170+
}
171+
150172
const checkoutParams = {
151173
customer: subscription.stripeCustomerId,
152174
mode: 'subscription',
@@ -184,10 +206,15 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
184206
* @param {String} packId - Pack identifier (must exist in config.billing.packs)
185207
* @param {String} successUrl - URL to redirect on payment success
186208
* @param {String} cancelUrl - URL to redirect on cancel
209+
* @param {String} [intentId] - Optional stable caller-provided intent ID for idempotency.
210+
* When provided: key = `extras_checkout_{orgId}_{packId}_{intentId}` (fully stable).
211+
* When absent: key bucketed to the minute — prevents instant double-click but not
212+
* cross-minute replay. Callers should pass a UUID generated on button click.
213+
* Max 180 chars (Stripe idempotency key limit is 255; prefix consumes ~64 chars).
187214
* @returns {Promise<{url: String}>} Object with the Checkout session URL
188215
*/
189216
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
190-
const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl) => {
217+
const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl, intentId) => {
191218
const stripe = getStripe();
192219
if (!stripe) throw new Error('Stripe is not configured');
193220

@@ -206,9 +233,14 @@ const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl)
206233

207234
const subscription = await _ensureStripeCustomer(stripe, organization);
208235

209-
// Per-intent idempotency key: timestamp + crypto-random suffix reduces collision risk under concurrent clicks.
210-
// Full deduplication would require a caller-provided stable intent id — deferred to a future improvement.
211-
const idempotencyKey = `extras_checkout_${String(organization._id)}_${packId}_${Date.now()}_${randomBytes(4).toString('hex')}`;
236+
// Idempotency key strategy:
237+
// - If intentId is provided by the caller (e.g. UUID generated on button click): fully stable key.
238+
// - Otherwise: minute-bucketed key — prevents double-click within the same minute window.
239+
// This is strictly better than Date.now()+random (which disabled idempotency entirely).
240+
const orgId = String(organization._id);
241+
const idempotencyKey = intentId
242+
? `extras_checkout_${orgId}_${packId}_${intentId}`
243+
: `extras_checkout_${orgId}_${packId}_${Math.floor(Date.now() / 60000)}`;
212244

213245
const extrasCheckoutParams = {
214246
customer: subscription.stripeCustomerId,
@@ -217,17 +249,17 @@ const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl)
217249
success_url: successUrl,
218250
cancel_url: cancelUrl,
219251
metadata: {
220-
organizationId: String(organization._id),
252+
organizationId: orgId,
221253
packId,
222254
kind: 'extras',
223-
stripeSessionId: '__pending__',
255+
stripeSessionId: SENTINEL_PENDING,
224256
},
225257
payment_intent_data: {
226258
metadata: {
227-
organizationId: String(organization._id),
259+
organizationId: orgId,
228260
packId,
229261
kind: 'extras',
230-
stripeSessionId: '__pending__',
262+
stripeSessionId: SENTINEL_PENDING,
231263
},
232264
},
233265
};

0 commit comments

Comments
 (0)