Skip to content

Commit 7e81f47

Browse files
refactor(billing): slim Subscription + delegate Stripe API + V5 P1 fixes (#1 #2 #3) (#3588)
* refactor(billing): slim BillingSubscription schema (delegate periodEnd to Stripe API) Drop currentPeriodEnd and cancelAtPeriodEnd from Mongoose model and Zod schema. Add stripeEventId field (tiebreaker for same-second event ordering, V5 P1 #2). Migration 20260503000200 unsets the removed fields from existing documents. * refactor(billing): fetchSubscriptionDetails helper + getSubscription merge Stripe details New fetchSubscriptionDetails() fetches currentPeriodEnd, cancelAtPeriodEnd, status, nextRenewalDate from Stripe API on-demand (+50ms on GET /subscription, 0ms on quota gate). getSubscription() merges cached local fields with live Stripe details for UI consumers. Falls back gracefully when Stripe is not configured or subscription is free. * refactor(billing): unified webhook handlers — drop period fields, pass eventId handleSubscriptionUpdated: drop currentPeriodEnd/cancelAtPeriodEnd from $set fields. All 4 handlers now call updateIfEventNewer(id, created, eventId, fields). handleInvoicePaymentSucceeded: add event param + use updateIfEventNewer (V5 P1 #1). handleSubscriptionDeleted: force meter rotate to free on cancel (V5 P1 #3). * fix(billing): event tiebreaker via eventId for same-second deliveries (V5 P1 #2) updateIfEventNewer now takes eventId as 3rd param. Same-second events are ordered by lex string comparison of evt_ IDs, preventing the 2nd event from being silently skipped when both events have the same Unix second timestamp. Stores stripeEventId alongside stripeEventCreatedAt in the document. * refactor(billing): drop billing.refund.service — inline Stripe refund in admin controller billing.refund.service.js had a single function (refundCharge) used only by the admin controller. Inline it directly to eliminate an unnecessary service indirection layer. Idempotency key pattern and argument validation are preserved unchanged. * test(billing): update tests for PR2 — new signatures, V5 P1 coverage, inline refund - subscription.schema: drop cancelAtPeriodEnd/currentPeriodEnd tests, add stripeEventId - subscription.repository: updateIfEventNewer now 4-arg; add same-second tiebreaker test - webhook.integration: add eventId arg to all updateIfEventNewer assertions; V5 P1 #3 tests - webhook.subscription: updateIfEventNewer 4-arg; handleInvoicePaymentSucceeded with event - billing.service: updateIfEventNewer 4-arg; add forceRotateForPlanChange mock - billing.checkout: fetchSubscriptionDetails tests + updated getSubscription (Stripe merge) - billing.admin.integration: rewritten for inline Stripe (no refund service mock) - billing.refund.service: rewritten as admin controller refund tests * fix(billing): payment_succeeded markers + admin refund key + reason guard - webhook: always advance stripeEventCreatedAt/stripeEventId on invoice.payment_succeeded, even when pastDueSince is already null; prevents stale out-of-order replays from being applied against a markers state that was never advanced on routine invoices - admin: replace fixed idempotency key with randomUUID suffix so multiple partial refunds on the same charge are not collapsed within Stripe's 24 h idempotency window - reason validation already enforced upstream via AdminRefundRequest Zod schema (z.enum); no controller change required
1 parent 3df3104 commit 7e81f47

17 files changed

Lines changed: 690 additions & 238 deletions

modules/billing/controllers/billing.admin.controller.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,49 @@
11
/**
22
* Module dependencies
33
*/
4+
import { randomUUID } from 'node:crypto';
5+
46
import responses from '../../../lib/helpers/responses.js';
5-
import BillingRefundService from '../services/billing.refund.service.js';
7+
import getStripe from '../lib/stripe.js';
68

79
/**
8-
* @desc Admin endpoint to trigger a Stripe refund for a charge
10+
* @desc Admin endpoint to trigger a Stripe refund for a charge.
11+
* Initiates the Stripe refund; actual ledger debit happens via the
12+
* `charge.refunded` webhook (single source of truth).
913
* @param {Object} req - Express request object
1014
* @param {Object} res - Express response object
1115
* @returns {Promise<void>}
1216
*/
17+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js controller, not Qwik
1318
const adminRefundCharge = async (req, res) => {
1419
try {
1520
const { chargeId, amountCents, reason } = req.body;
16-
const refund = await BillingRefundService.refundCharge(chargeId, amountCents, { reason });
21+
22+
if (typeof chargeId !== 'string' || chargeId.trim() === '') {
23+
return responses.error(res, 422, 'Unprocessable Entity', 'Failed to refund charge')(
24+
new Error('invalid argument: chargeId must be a non-empty string'),
25+
);
26+
}
27+
if (amountCents !== undefined) {
28+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
29+
return responses.error(res, 422, 'Unprocessable Entity', 'Failed to refund charge')(
30+
new Error('invalid argument: amountCents must be a positive integer'),
31+
);
32+
}
33+
}
34+
35+
const stripe = getStripe();
36+
if (!stripe) {
37+
return responses.error(res, 502, 'Bad Gateway', 'Failed to refund charge')(
38+
new Error('Stripe is not configured'),
39+
);
40+
}
41+
42+
const idempotencyKey = `refund_${chargeId}_${amountCents ?? 'full'}_${randomUUID()}`;
43+
const params = { charge: chargeId, reason: reason || 'requested_by_customer' };
44+
if (amountCents !== undefined) params.amount = amountCents;
45+
46+
const refund = await stripe.refunds.create(params, { idempotencyKey });
1747
responses.success(res, 'billing refund created')(refund);
1848
} catch (err) {
1949
const status = err.message?.startsWith('invalid argument') ? 422 : 502;

modules/billing/controllers/billing.webhook.controller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const handleWebhook = async (req, res) => {
5454
break;
5555
case 'invoice.payment_succeeded':
5656
await BillingWebhookService.withIdempotency(event, (e) =>
57-
BillingWebhookService.handleInvoicePaymentSucceeded(e.data.object),
57+
BillingWebhookService.handleInvoicePaymentSucceeded(e.data.object, e),
5858
);
5959
break;
6060
case 'charge.refunded':
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Migration: Slim BillingSubscription — drop currentPeriodEnd and cancelAtPeriodEnd.
3+
* These fields are now fetched on-demand from the Stripe API via fetchSubscriptionDetails().
4+
* stripeEventId is added for same-second event tiebreaker ordering (V5 P1 #2) — no migration
5+
* needed as it is optional and populated at the next webhook delivery.
6+
*
7+
* Safe to run multiple times (idempotent).
8+
*/
9+
export async function up() {
10+
const { default: mongoose } = await import('mongoose');
11+
const db = mongoose.connection.db;
12+
13+
await db.collection('subscriptions').updateMany(
14+
{},
15+
{
16+
$unset: {
17+
currentPeriodEnd: '',
18+
cancelAtPeriodEnd: '',
19+
},
20+
},
21+
);
22+
23+
console.info('[migration] slim-subscription-drop-period-fields: dropped currentPeriodEnd + cancelAtPeriodEnd');
24+
}
25+
26+
/**
27+
* Down: no-op — dropped fields are re-populated by incoming webhooks.
28+
*/
29+
export function down() {
30+
console.warn('[migration] slim-subscription-drop-period-fields DOWN: no-op; fields will be re-populated by webhooks');
31+
}

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,6 @@ const SubscriptionMongoose = new Schema(
3737
enum: config.billing.statuses,
3838
default: 'active',
3939
},
40-
currentPeriodEnd: {
41-
type: Date,
42-
},
43-
cancelAtPeriodEnd: {
44-
type: Boolean,
45-
default: false,
46-
},
4740

4841
// ── Meter fields (sparse — backward-compatible additions) ────────────────
4942

@@ -87,6 +80,14 @@ const SubscriptionMongoose = new Schema(
8780
type: Number,
8881
default: null,
8982
},
83+
/**
84+
* Stripe event.id of the last processed subscription event.
85+
* Tiebreaker for same-second events (lex string ordering on evt_ IDs).
86+
*/
87+
stripeEventId: {
88+
type: String,
89+
default: null,
90+
},
9091
},
9192
{
9293
timestamps: true,

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,19 @@ const baseShape = {
1919
organization: z.string().trim().regex(objectIdRegex, 'organization must be a valid ObjectId'),
2020
stripeCustomerId: optionalStripeId,
2121
stripeSubscriptionId: optionalStripeId,
22-
currentPeriodEnd: z.coerce.date().nullable().optional(),
2322
// ── Meter fields (optional — backward-compatible) ────────────────────────
2423
planVersion: z.string().trim().optional(),
2524
currentPeriodStart: z.coerce.date().nullable().optional(),
2625
pastDueSince: z.coerce.date().nullable().optional(),
2726
lastResetAt: z.coerce.date().nullable().optional(),
2827
stripeEventCreatedAt: z.number().int().nullable().optional(),
28+
stripeEventId: z.string().nullable().optional(),
2929
};
3030

3131
const Subscription = z.object({
3232
...baseShape,
3333
plan: z.enum(config.billing.plans).default('free'),
3434
status: z.enum(config.billing.statuses).default('active'),
35-
cancelAtPeriodEnd: z.boolean().default(false),
3635
});
3736

3837
/**
@@ -42,7 +41,6 @@ const SubscriptionCore = z.object({
4241
...baseShape,
4342
plan: z.enum(config.billing.plans),
4443
status: z.enum(config.billing.statuses),
45-
cancelAtPeriodEnd: z.boolean(),
4644
});
4745

4846
const SubscriptionUpdate = SubscriptionCore.partial();

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,21 +216,36 @@ const markUnpaid = (id, threshold) => {
216216
* @description Atomically update a subscription only when the incoming Stripe event is newer
217217
* than the last processed event. Prevents out-of-order webhook delivery from
218218
* overwriting more-recent state.
219+
* Same-second events are ordered by lex-string comparison of event IDs (evt_ prefix
220+
* makes these globally monotonic within a second) — V5 P1 #2 tiebreaker.
219221
* Returns null when the guard prevents the write (stale event).
220222
* @param {string} id - The subscription ObjectId (string).
221-
* @param {number} eventCreatedAt - Stripe event.created Unix timestamp.
223+
* @param {number} eventCreatedAt - Stripe event.created Unix timestamp (seconds).
224+
* @param {string} eventId - Stripe event.id (e.g. evt_xxx) — tiebreaker for same-second delivery.
222225
* @param {Object} fields - Fields to $set on the document.
223226
* @returns {Promise<Object|null>} Updated doc, or null if id invalid or event is stale.
224227
*/
225228
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
226-
const updateIfEventNewer = (id, eventCreatedAt, fields) => {
229+
const updateIfEventNewer = (id, eventCreatedAt, eventId, fields) => {
227230
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
228231
return Subscription.findOneAndUpdate(
229232
{
230233
_id: id,
231-
$or: [{ stripeEventCreatedAt: null }, { stripeEventCreatedAt: { $lt: eventCreatedAt } }],
234+
$or: [
235+
{ stripeEventCreatedAt: { $exists: false } },
236+
{ stripeEventCreatedAt: null },
237+
{ stripeEventCreatedAt: { $lt: eventCreatedAt } },
238+
{
239+
stripeEventCreatedAt: eventCreatedAt,
240+
$or: [
241+
{ stripeEventId: { $exists: false } },
242+
{ stripeEventId: null },
243+
{ stripeEventId: { $lt: eventId } },
244+
],
245+
},
246+
],
232247
},
233-
{ $set: { ...fields, stripeEventCreatedAt: eventCreatedAt } },
248+
{ $set: { ...fields, stripeEventCreatedAt: eventCreatedAt, stripeEventId: eventId } },
234249
{ returnDocument: 'after', runValidators: true },
235250
)
236251
.populate(defaultPopulate)

modules/billing/services/billing.refund.service.js

Lines changed: 0 additions & 49 deletions
This file was deleted.

modules/billing/services/billing.service.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,15 +244,49 @@ const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl)
244244
};
245245

246246
/**
247-
* @desc Get subscription for the given organization
247+
* @desc Fetch live subscription details from Stripe API on-demand.
248+
* Used by UI routes that need currentPeriodEnd, cancelAtPeriodEnd etc.
249+
* +50ms latency is acceptable on infrequent "My Subscription" page loads.
250+
* Not cached — callers can layer a short TTL if needed in the future.
251+
* @param {String|null} stripeSubscriptionId - Stripe subscription ID (sub_xxx)
252+
* @returns {Promise<Object|null>} Live fields or null when id is absent / Stripe not configured
253+
*/
254+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
255+
const fetchSubscriptionDetails = async (stripeSubscriptionId) => {
256+
if (!stripeSubscriptionId) return null;
257+
const stripe = getStripe();
258+
if (!stripe) return null;
259+
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
260+
const cancelAt = stripeSub.cancel_at ? new Date(stripeSub.cancel_at * 1000) : null;
261+
return {
262+
currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
263+
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
264+
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
265+
status: stripeSub.status,
266+
nextRenewalDate: cancelAt ?? new Date(stripeSub.current_period_end * 1000),
267+
};
268+
};
269+
270+
/**
271+
* @desc Get subscription for the given organization.
272+
* Merges cached local fields with live Stripe details for UI consumers.
273+
* Falls back gracefully when Stripe is not configured or the subscription is free.
248274
* @param {String} organizationId - The organization ID
249-
* @returns {Promise<Object|null>} The subscription document or null
275+
* @returns {Promise<Object|null>} Merged subscription object or null
250276
*/
251-
const getSubscription = async (organizationId) => SubscriptionRepository.findByOrganization(organizationId);
277+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
278+
const getSubscription = async (organizationId) => {
279+
const sub = await SubscriptionRepository.findByOrganization(organizationId);
280+
if (!sub) return null;
281+
const details = await fetchSubscriptionDetails(sub.stripeSubscriptionId).catch(() => null);
282+
if (!details) return sub;
283+
return { ...sub.toJSON?.() ?? sub, ...details };
284+
};
252285

253286
export default {
254287
createCheckout,
255288
createExtrasCheckout,
256289
createPortalSession,
290+
fetchSubscriptionDetails,
257291
getSubscription,
258292
};

modules/billing/services/billing.webhook.service.js

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,10 @@ const handleSubscriptionUpdated = async (subscription, event) => {
212212
const fields = {
213213
plan: newPlan,
214214
status: subscription.status,
215-
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
216-
cancelAtPeriodEnd: subscription.cancel_at_period_end,
217215
};
218216
if (newPeriodStart) fields.currentPeriodStart = newPeriodStart;
219217

220-
const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, fields);
218+
const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, event.id, fields);
221219
if (!updated) {
222220
logger.info('[billing.webhook] skipped stale event', { eventId: event.id, type: event.type });
223221
return;
@@ -305,7 +303,7 @@ const handleSubscriptionDeleted = async (subscription, event) => {
305303
const existing = await SubscriptionRepository.findByStripeSubscriptionId(subscription.id);
306304
if (!existing) return;
307305

308-
const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, {
306+
const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, event.id, {
309307
plan: 'free',
310308
status: 'canceled',
311309
});
@@ -314,7 +312,16 @@ const handleSubscriptionDeleted = async (subscription, event) => {
314312
return;
315313
}
316314

317-
await syncOrganizationPlan(existing.organization?._id || existing.organization, 'free');
315+
const organizationId = String(existing.organization?._id || existing.organization);
316+
await syncOrganizationPlan(organizationId, 'free');
317+
318+
// Force reset meter to free quota — canceled sub must not retain paid-plan snapshot units.
319+
try {
320+
await BillingResetService.forceRotateForPlanChange(organizationId, { preserveUsage: false });
321+
} catch (err) {
322+
// Log for monitoring — not thrown so webhook processing continues
323+
console.error('[billing.webhook] forceRotateForPlanChange on cancel failed (non-fatal):', err?.message ?? err);
324+
}
318325
};
319326

320327
/**
@@ -340,7 +347,7 @@ const handleInvoicePaymentFailed = async (invoice, event) => {
340347
fields.pastDueSince = new Date();
341348
}
342349

343-
const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, fields);
350+
const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, event.id, fields);
344351
if (!updated) {
345352
logger.info('[billing.webhook] skipped stale event', { eventId: event.id, type: event.type });
346353
return;
@@ -359,24 +366,33 @@ const handleInvoicePaymentFailed = async (invoice, event) => {
359366
* @description Handle invoice.payment_succeeded event — clear degraded mode (pastDueSince).
360367
* When a past-due invoice is finally paid, remove the pastDueSince marker so
361368
* the subscription exits degraded mode on next request.
369+
* Uses updateIfEventNewer to guard against out-of-order webhook delivery (V5 P1 #1).
362370
* @param {Object} invoice - Stripe invoice object
371+
* @param {Object} event - Full Stripe event (with event.created and event.id for ordering)
363372
* @returns {Promise<void>}
364373
*/
365374
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
366-
const handleInvoicePaymentSucceeded = async (invoice) => {
375+
const handleInvoicePaymentSucceeded = async (invoice, event) => {
367376
const { subscription: stripeSubscriptionId } = invoice;
368377
if (!stripeSubscriptionId) return;
369378

370379
const existing = await SubscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId);
371380
if (!existing) return;
372381

373-
// Only clear if currently past_due (avoid unnecessary writes on routine invoices)
374-
if (existing.pastDueSince !== null && existing.pastDueSince !== undefined) {
375-
await SubscriptionRepository.update({
376-
_id: existing._id,
377-
pastDueSince: null,
378-
status: 'active',
379-
});
382+
// Always advance the event markers (stripeEventCreatedAt + stripeEventId) so out-of-order
383+
// replays are correctly rejected even when the sub is already active (pastDueSince == null).
384+
const fields = (existing.pastDueSince !== null && existing.pastDueSince !== undefined)
385+
? { pastDueSince: null, status: 'active' }
386+
: {};
387+
388+
const updated = await SubscriptionRepository.updateIfEventNewer(
389+
String(existing._id),
390+
event.created,
391+
event.id,
392+
fields,
393+
);
394+
if (!updated) {
395+
logger.info('[billing.webhook] skipped stale event', { eventId: event.id, type: event.type });
380396
}
381397
};
382398

0 commit comments

Comments
 (0)