Skip to content

Commit b8d4008

Browse files
feat(billing): webhook hardening — 9 reliability improvements (#3598)
1. Pin Stripe API version to 2026-04-22.dahlia 2. Read current_period_start/end from items.data[0] (Stripe ≥2025-08-27) with top-level fallback 3. Dead-letter protection: track attempts; dead-letter at ≥5, skip rollback, return 200 to Stripe 4. Per-family event ordering guards: separate lastSubscriptionEvent* / lastInvoiceEvent* fields 5. Fail-closed for paused/unpaid/incomplete_expired status in meter mode 6. Refund backfill: fetch PaymentIntent when stripeSessionId missing from charge metadata 7. Integer-cents math for refund proportional calculation (no IEEE 754 drift) 8. Stable extras checkout idempotency key via optional intentId parameter 9. Server-side active-sub guard in createCheckout via live stripe.subscriptions.list call
1 parent a83f0b1 commit b8d4008

25 files changed

Lines changed: 1451 additions & 56 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/events.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { EventEmitter } from 'events';
1515
* Payload: { organizationId, idempotencyKey, extrasUnits, attempts, lastError }
1616
* - `payment.failed` — emitted when an invoice payment fails (pastDueSince set on first failure)
1717
* Payload: { organizationId }
18+
* - `billing.refund.unresolved` — emitted when a charge.refunded event cannot be correlated
19+
* to a known session/org (missing metadata on charge AND PaymentIntent, or ambiguous pack).
20+
* Payload: { chargeId, paymentIntentId, refundAmount } | { reason, orgId, stripeSessionId, amountRefundedCents }
1821
*/
1922
const billingEvents = new EventEmitter();
2023

modules/billing/lib/stripe.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ let stripeClient = null;
1717
const getStripe = () => {
1818
if (stripeClient) return stripeClient;
1919
if (!config.stripe?.secretKey) return null;
20-
stripeClient = new Stripe(config.stripe.secretKey);
20+
stripeClient = new Stripe(config.stripe.secretKey, { apiVersion: '2026-04-22.dahlia' });
2121
return stripeClient;
2222
};
2323

modules/billing/middlewares/billing.requireQuota.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,36 @@ function requireQuota(resource, action) {
5757

5858
// ── Degraded-mode gate (past_due grace period) ─────────────────────
5959
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
60+
61+
// Fail-closed statuses: paused, unpaid, incomplete_expired → always route to free quota.
62+
// These statuses fire as customer.subscription.updated (status field changes), so they
63+
// arrive here while the subscription doc may still hold a paid-tier meterQuota.
64+
// Resetting to zero prevents paid-quota bleed-through on lapsed subscriptions.
65+
const failClosedStatuses = ['paused', 'unpaid', 'incomplete_expired'];
66+
if (subscription && failClosedStatuses.includes(subscription.status)) {
67+
const planId = getDefaultPlanId();
68+
const freePlan = BillingPlanService.getActivePlan(planId);
69+
if (!freePlan) {
70+
return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')({
71+
type: 'PLAN_NOT_CONFIGURED',
72+
planId,
73+
});
74+
}
75+
const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId);
76+
const remaining = (freePlan.meterQuota ?? 0) + extrasBalance;
77+
if (remaining <= 0) {
78+
return responses.error(res, 402, 'Payment Required', 'Meter exhausted')({
79+
type: 'METER_EXHAUSTED',
80+
meterUsed: 0,
81+
meterQuota: freePlan.meterQuota ?? 0,
82+
extrasRemaining: extrasBalance,
83+
packsAvailable: config.billing?.packs ?? [],
84+
upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans',
85+
});
86+
}
87+
return next();
88+
}
89+
6090
if (subscription?.status === 'past_due' && subscription.pastDueSince != null) {
6191
const gracePeriodMs = getGracePeriodDays() * 24 * 60 * 60 * 1000;
6292
const elapsed = Date.now() - new Date(subscription.pastDueSince).getTime();

modules/billing/models/billing.processedStripeEvent.model.mongoose.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,36 @@ const ProcessedStripeEventMongoose = new Schema(
3030
required: true,
3131
default: () => new Date(),
3232
},
33+
/**
34+
* Number of handler execution attempts (including failed ones).
35+
* Incremented on each handler exception before deciding to rollback or dead-letter.
36+
*/
37+
attempts: {
38+
type: Number,
39+
default: 0,
40+
},
41+
/**
42+
* Last handler error message — set when the handler throws.
43+
*/
44+
lastError: {
45+
type: String,
46+
default: null,
47+
},
48+
/**
49+
* Timestamp of the last handler error.
50+
*/
51+
lastErrorAt: {
52+
type: Date,
53+
default: null,
54+
},
55+
/**
56+
* When true, the event has exceeded max retry attempts.
57+
* The claim is kept permanently so Stripe stops retrying.
58+
*/
59+
deadLetter: {
60+
type: Boolean,
61+
default: false,
62+
},
3363
},
3464
{
3565
timestamps: false,

modules/billing/models/billing.processedStripeEvent.schema.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ const ProcessedStripeEvent = z.object({
1111
eventId: z.string().trim().min(1, 'eventId is required'),
1212
type: z.string().trim().min(1, 'type is required'),
1313
processedAt: z.coerce.date().default(() => new Date()),
14+
attempts: z.number().int().nonnegative().default(0),
15+
lastError: z.string().nullable().optional(),
16+
lastErrorAt: z.coerce.date().nullable().optional(),
17+
deadLetter: z.boolean().default(false),
1418
});
1519

1620
const ProcessedStripeEventCreate = z.object({

modules/billing/models/billing.subscription.model.mongoose.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,45 @@ const SubscriptionMongoose = new Schema(
8383
/**
8484
* Stripe event.id of the last processed subscription event.
8585
* Tiebreaker for same-second events (lex string ordering on evt_ IDs).
86+
* @deprecated use lastSubscriptionEventCreatedAt / lastInvoiceEventCreatedAt per-family fields.
8687
*/
8788
stripeEventId: {
8889
type: String,
8990
default: null,
9091
},
92+
93+
// ── Per-family event ordering guards ─────────────────────────────────────
94+
// Separate trackers for 'subscription' vs 'invoice' event families prevent
95+
// same-second cross-family deliveries from cancelling each other's state.
96+
97+
/**
98+
* Stripe event.created (Unix seconds) of the last processed customer.subscription.* event.
99+
*/
100+
lastSubscriptionEventCreatedAt: {
101+
type: Number,
102+
default: null,
103+
},
104+
/**
105+
* Stripe event.id of the last processed customer.subscription.* event (same-second tiebreaker).
106+
*/
107+
lastSubscriptionEventId: {
108+
type: String,
109+
default: null,
110+
},
111+
/**
112+
* Stripe event.created (Unix seconds) of the last processed invoice.* event.
113+
*/
114+
lastInvoiceEventCreatedAt: {
115+
type: Number,
116+
default: null,
117+
},
118+
/**
119+
* Stripe event.id of the last processed invoice.* event (same-second tiebreaker).
120+
*/
121+
lastInvoiceEventId: {
122+
type: String,
123+
default: null,
124+
},
91125
},
92126
{
93127
timestamps: true,

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ const baseShape = {
2626
lastResetAt: z.coerce.date().nullable().optional(),
2727
stripeEventCreatedAt: z.number().int().nullable().optional(),
2828
stripeEventId: z.string().nullable().optional(),
29+
// Per-family event ordering guards
30+
lastSubscriptionEventCreatedAt: z.number().int().nullable().optional(),
31+
lastSubscriptionEventId: z.string().nullable().optional(),
32+
lastInvoiceEventCreatedAt: z.number().int().nullable().optional(),
33+
lastInvoiceEventId: z.string().nullable().optional(),
2934
};
3035

3136
const Subscription = z.object({
@@ -73,6 +78,9 @@ const ExtrasCheckoutRequest = z
7378
packId: z.string().trim().min(1, 'packId is required'),
7479
successUrl: z.string().url('successUrl must be a valid URL'),
7580
cancelUrl: z.string().url('cancelUrl must be a valid URL'),
81+
// Caller-provided stable intent ID for idempotency (prevents double-click double-charge).
82+
// When absent, a soft-stable minute-bucketed key is used as fallback.
83+
intentId: z.string().min(1).optional(),
7684
})
7785
.strict();
7886

modules/billing/repositories/billing.processedStripeEvent.repository.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,52 @@ const deleteByEventId = async (eventId) => {
7474
return { deleted: result.deletedCount > 0 };
7575
};
7676

77+
/**
78+
* @function incrementAttempts
79+
* @description Atomically increment the attempts counter and record the last error details
80+
* on the processed event document. Used by withIdempotency to track retry depth.
81+
* @param {string} eventId - Stripe event ID.
82+
* @param {string} errorMessage - Error message from the last failed handler execution.
83+
* @returns {Promise<Object|null>} Updated document or null if not found.
84+
*/
85+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
86+
const incrementAttempts = async (eventId, errorMessage) => {
87+
if (typeof eventId !== 'string' || eventId.trim() === '') {
88+
throw new Error('invalid argument: eventId must be a non-empty string');
89+
}
90+
return ProcessedStripeEvent().findOneAndUpdate(
91+
{ eventId },
92+
{
93+
$inc: { attempts: 1 },
94+
$set: { lastError: String(errorMessage ?? ''), lastErrorAt: new Date() },
95+
},
96+
{ returnDocument: 'after' },
97+
).exec();
98+
};
99+
100+
/**
101+
* @function markDeadLetter
102+
* @description Mark a processed event as dead-lettered — keeps the claim permanently so
103+
* Stripe stops retrying, and sets deadLetter=true for ops visibility.
104+
* @param {string} eventId - Stripe event ID.
105+
* @returns {Promise<Object|null>} Updated document or null if not found.
106+
*/
107+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
108+
const markDeadLetter = async (eventId) => {
109+
if (typeof eventId !== 'string' || eventId.trim() === '') {
110+
throw new Error('invalid argument: eventId must be a non-empty string');
111+
}
112+
return ProcessedStripeEvent().findOneAndUpdate(
113+
{ eventId },
114+
{ $set: { deadLetter: true } },
115+
{ returnDocument: 'after' },
116+
).exec();
117+
};
118+
77119
export default {
78120
tryRecord,
79121
wasProcessed,
80122
deleteByEventId,
123+
incrementAttempts,
124+
markDeadLetter,
81125
};

modules/billing/repositories/billing.subscription.repository.js

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -214,38 +214,59 @@ const markUnpaid = (id, threshold) => {
214214
/**
215215
* @function updateIfEventNewer
216216
* @description Atomically update a subscription only when the incoming Stripe event is newer
217-
* than the last processed event. Prevents out-of-order webhook delivery from
218-
* overwriting more-recent state.
217+
* than the last processed event for the given family.
218+
* Prevents out-of-order webhook delivery from overwriting more-recent state.
219219
* Same-second events are ordered by lex-string comparison of event IDs (evt_ prefix
220220
* makes these globally deterministic within a second) — V5 P1 #2 tiebreaker.
221+
*
222+
* The `family` parameter scopes the event-ordering guard to either the
223+
* 'subscription' family (customer.subscription.*) or the 'invoice' family
224+
* (invoice.*). Same-second cross-family deliveries no longer cancel each other.
225+
* Falls back to legacy stripeEventCreatedAt/stripeEventId for back-compat when
226+
* per-family fields are not yet populated (gradual migration — no down-time).
227+
*
221228
* Returns null when the guard prevents the write (stale event).
222229
* @param {string} id - The subscription ObjectId (string).
223230
* @param {number} eventCreatedAt - Stripe event.created Unix timestamp (seconds).
224231
* @param {string} eventId - Stripe event.id (e.g. evt_xxx) — tiebreaker for same-second delivery.
225232
* @param {Object} fields - Fields to $set on the document.
233+
* @param {'subscription'|'invoice'} [family='subscription'] - Event family to scope the ordering guard.
226234
* @returns {Promise<Object|null>} Updated doc, or null if id invalid or event is stale.
227235
*/
228236
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
229-
const updateIfEventNewer = (id, eventCreatedAt, eventId, fields) => {
237+
const updateIfEventNewer = (id, eventCreatedAt, eventId, fields, family = 'subscription') => {
230238
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
239+
240+
const createdAtField = family === 'invoice' ? 'lastInvoiceEventCreatedAt' : 'lastSubscriptionEventCreatedAt';
241+
const eventIdField = family === 'invoice' ? 'lastInvoiceEventId' : 'lastSubscriptionEventId';
242+
231243
return Subscription.findOneAndUpdate(
232244
{
233245
_id: id,
234246
$or: [
235-
{ stripeEventCreatedAt: { $exists: false } },
236-
{ stripeEventCreatedAt: null },
237-
{ stripeEventCreatedAt: { $lt: eventCreatedAt } },
247+
{ [createdAtField]: { $exists: false } },
248+
{ [createdAtField]: null },
249+
{ [createdAtField]: { $lt: eventCreatedAt } },
238250
{
239-
stripeEventCreatedAt: eventCreatedAt,
251+
[createdAtField]: eventCreatedAt,
240252
$or: [
241-
{ stripeEventId: { $exists: false } },
242-
{ stripeEventId: null },
243-
{ stripeEventId: { $lt: eventId } },
253+
{ [eventIdField]: { $exists: false } },
254+
{ [eventIdField]: null },
255+
{ [eventIdField]: { $lt: eventId } },
244256
],
245257
},
246258
],
247259
},
248-
{ $set: { ...fields, stripeEventCreatedAt: eventCreatedAt, stripeEventId: eventId } },
260+
{
261+
$set: {
262+
...fields,
263+
[createdAtField]: eventCreatedAt,
264+
[eventIdField]: eventId,
265+
// Keep legacy fields in sync for back-compat with existing queries / reports
266+
stripeEventCreatedAt: eventCreatedAt,
267+
stripeEventId: eventId,
268+
},
269+
},
249270
{ returnDocument: 'after', runValidators: true },
250271
)
251272
.populate(defaultPopulate)

0 commit comments

Comments
 (0)