Skip to content

Commit 7b89555

Browse files
fix(billing): pre-LIVE critical fixes — free plan extras, /plans DoS, dispute/customer handlers (#3602)
1. Free plan + extras pack: incrementMeter now debits extras when meterQuota=0 (was: extras balance never decreased — infinite usage from a $5 pack). 2. /api/billing/plans: in-flight promise dedup in BillingPlansService.getPlans + per-IP rate limit profile (60s/30 req in prod) on the public route. Hardens against Stripe-API-quota DoS that would break LIVE checkout + webhook signature verification. 3. Webhook handlers: - customer.deleted: nulls stripeCustomerId/subscriptionId, plan=free, status=canceled + meter rotation. Prevents "No such customer" crashes on next checkout when a customer is deleted in the Stripe dashboard. - charge.dispute.created: log + emit billing.dispute.opened event (no auto-debit — ~50% of disputes are won by merchant; real claw-back happens later on charge.dispute.funds_withdrawn or charge.refunded). Audit findings before LIVE recalibration ship. No drive-by changes.
1 parent e1a7bfd commit 7b89555

10 files changed

Lines changed: 728 additions & 8 deletions

config/defaults/production.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ const config = {
3838
standardHeaders: true,
3939
legacyHeaders: false,
4040
},
41+
// Public, unauthenticated route that fans out to Stripe on cache miss.
42+
// Tighter window than `api` to harden against Stripe-API-quota DoS.
43+
billingPlans: {
44+
windowMs: 60 * 1000,
45+
max: 30,
46+
message: { message: 'Too many requests, please try again later.' },
47+
standardHeaders: true,
48+
legacyHeaders: false,
49+
},
4150
},
4251
log: {
4352
format: 'custom',

config/defaults/test.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const config = {
4141
api: {
4242
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
4343
},
44+
billingPlans: {
45+
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
46+
},
4447
},
4548
uploads: {
4649
avatar: {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ const handleWebhook = async (req, res) => {
6262
BillingWebhookService.handleChargeRefunded(e.data.object),
6363
);
6464
break;
65+
case 'customer.deleted':
66+
await BillingWebhookService.withIdempotency(event, (e) =>
67+
BillingWebhookService.handleCustomerDeleted(e.data.object, e),
68+
);
69+
break;
70+
case 'charge.dispute.created':
71+
await BillingWebhookService.withIdempotency(event, (e) =>
72+
BillingWebhookService.handleChargeDisputeCreated(e.data.object, e),
73+
);
74+
break;
6575
default:
6676
break;
6777
}

modules/billing/routes/billing.routes.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import config from '../../../config/index.js';
55
import passport from 'passport';
66

7+
import limiters from '../../../lib/middlewares/rateLimiter.js';
78
import model from '../../../lib/middlewares/model.js';
89
import policy from '../../../lib/middlewares/policy.js';
910
import organization from '../../organizations/middlewares/organizations.middleware.js';
@@ -28,7 +29,11 @@ export default (app) => {
2829
];
2930

3031
// plans (public)
31-
app.route('/api/billing/plans').get(billingPlans.getPlans);
32+
// Rate-limited at the edge: this route fans out to Stripe products+prices on cache miss,
33+
// so an unauthenticated flood would drain the Stripe API quota and break LIVE checkout +
34+
// webhook signature verification. Service layer also dedups concurrent in-flight fetches.
35+
// Falls back to a passthrough middleware when `billingPlans` profile is not configured.
36+
app.route('/api/billing/plans').get(limiters.billingPlans, billingPlans.getPlans);
3237

3338
// checkout
3439
app

modules/billing/services/billing.plans.service.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ let cachedPlans = null;
1010
let cacheTimestamp = 0;
1111
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
1212

13+
/**
14+
* In-flight fetch promise — used to deduplicate concurrent cache-miss callers.
15+
* The /api/billing/plans route is public, so a thundering herd on cache miss
16+
* (e.g. cache expiry under traffic, or a small DoS) would otherwise fire N
17+
* parallel `stripe.products.list().autoPagingToArray()` + `stripe.prices.list()`
18+
* calls and exhaust the Stripe API quota — breaking live checkouts and webhook
19+
* signature verification. With this dedup, only one Stripe round-trip happens
20+
* per cache-miss window regardless of concurrency.
21+
*/
22+
let inFlightFetch = null;
23+
1324
/**
1425
* Default free plan returned when Stripe is not configured
1526
*/
@@ -83,10 +94,24 @@ const getPlans = async () => {
8394
const now = Date.now();
8495
if (cachedPlans && now - cacheTimestamp < CACHE_TTL) return cachedPlans;
8596

86-
const plans = await fetchPlansFromStripe(stripe);
87-
cachedPlans = plans;
88-
cacheTimestamp = Date.now();
89-
return plans;
97+
// In-flight dedup: if another caller is already fetching, await its result
98+
// instead of issuing parallel Stripe API calls. Reset on completion so the
99+
// next cache miss can issue a fresh fetch (success OR failure — failures must
100+
// not poison the slot, otherwise the next call retries cleanly).
101+
if (inFlightFetch) return inFlightFetch;
102+
103+
inFlightFetch = (async () => {
104+
try {
105+
const plans = await fetchPlansFromStripe(stripe);
106+
cachedPlans = plans;
107+
cacheTimestamp = Date.now();
108+
return plans;
109+
} finally {
110+
inFlightFetch = null;
111+
}
112+
})();
113+
114+
return inFlightFetch;
90115
};
91116

92117
export default {

modules/billing/services/billing.usage.service.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,17 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey)
122122
const newMeterUsed = updatedDoc.meterUsed ?? 0;
123123
const effectiveQuota = updatedDoc.meterQuota ?? meterQuota;
124124

125-
// Overflow detection: units consumed beyond the plan quota go to extras
125+
// Overflow detection: units consumed beyond the plan quota go to extras.
126+
// Free plan (effectiveQuota === 0): every unit must be debited from extras —
127+
// requireQuota middleware lets the request through only when extrasBalance > 0,
128+
// so reaching this branch means the org pays for usage from its extras pack.
129+
// Without this branch the extras balance would never decrease on free plans
130+
// (infinite usage from a $5 pack — money leak).
126131
let extrasConsumed = 0;
127-
if (effectiveQuota > 0 && newMeterUsed > effectiveQuota) {
128-
const previousUsed = newMeterUsed - units;
132+
if (effectiveQuota === 0) {
133+
extrasConsumed = units;
134+
} else if (newMeterUsed > effectiveQuota) {
135+
const previousUsed = Math.max(0, newMeterUsed - units);
129136
const overflowStart = Math.max(previousUsed, effectiveQuota);
130137
extrasConsumed = newMeterUsed - overflowStart;
131138
}

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

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,155 @@ const handleChargeRefunded = async (charge) => {
565565
}
566566
};
567567

568+
/**
569+
* @description Handle customer.deleted event — null out Stripe customer/subscription refs.
570+
* Triggered when an admin deletes a customer in the Stripe dashboard. If we keep the
571+
* stale stripeCustomerId in our DB, the next _ensureStripeCustomer / checkout call
572+
* will hit "No such customer" and crash the user's checkout flow.
573+
*
574+
* Conservative recovery:
575+
* 1. Find the subscription by Stripe customer id.
576+
* 2. Null out stripeCustomerId + stripeSubscriptionId, force plan='free' / status='canceled'.
577+
* 3. Sync organization plan to free.
578+
* 4. Force meter rotation so the org no longer carries the paid quota snapshot.
579+
*
580+
* No-op when no matching subscription exists in our DB (deletion of a customer we never
581+
* provisioned, or already cleaned up).
582+
* @param {Object} customer - Stripe customer object (data.object of customer.deleted event).
583+
* @param {Object} event - Full Stripe event (for ordering / event-newer guard).
584+
* @returns {Promise<void>}
585+
*/
586+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
587+
const handleCustomerDeleted = async (customer, event) => {
588+
const stripeCustomerId = customer?.id;
589+
if (!stripeCustomerId) return;
590+
591+
const existing = await SubscriptionRepository.findByStripeCustomerId(stripeCustomerId);
592+
if (!existing) return;
593+
594+
const updated = await SubscriptionRepository.updateIfEventNewer(
595+
String(existing._id),
596+
event.created,
597+
event.id,
598+
{
599+
stripeCustomerId: null,
600+
stripeSubscriptionId: null,
601+
plan: 'free',
602+
status: 'canceled',
603+
},
604+
'subscription',
605+
);
606+
if (!updated) {
607+
logger.info('[billing.webhook] skipped stale event', { eventId: event.id, type: event.type });
608+
return;
609+
}
610+
611+
const organizationId = String(existing.organization?._id || existing.organization);
612+
await syncOrganizationPlan(organizationId, 'free');
613+
614+
// Force meter reset so the cancelled org no longer retains a paid-plan snapshot.
615+
try {
616+
await BillingResetService.forceRotateForPlanChange(organizationId, { preserveUsage: false });
617+
} catch (err) {
618+
logger.error('[billing.webhook] forceRotateForPlanChange on customer.deleted failed (non-fatal)', {
619+
organizationId,
620+
error: err?.message ?? String(err),
621+
stack: err?.stack,
622+
});
623+
}
624+
};
625+
626+
/**
627+
* @description Handle charge.dispute.created event — log + emit (no auto-debit).
628+
* Triggered when a cardholder files a chargeback. Stripe will eventually debit the
629+
* merchant bank account, but disputes are often won by the merchant (~50% are user
630+
* error / "I don't recognise this charge"). Aggressive auto-debit on dispute opening
631+
* would punish customers who are filing legitimate disputes that we eventually win.
632+
*
633+
* Conservative policy: emit a `billing.dispute.opened` event for downstream listeners
634+
* (admin notifications) and log a critical alert. Real claw-back happens later via
635+
* `charge.dispute.funds_withdrawn` (when the dispute is lost) or `charge.refunded`
636+
* (when we choose to refund), both of which already debit the ledger via
637+
* handleChargeRefunded / refundPartial.
638+
*
639+
* Resolves organizationId via charge metadata first, then falls back to the
640+
* PaymentIntent metadata patched by handleCheckoutPaymentCompleted.
641+
* @param {Object} dispute - Stripe dispute object (data.object of charge.dispute.created).
642+
* @param {Object} event - Full Stripe event.
643+
* @returns {Promise<void>}
644+
*/
645+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
646+
const handleChargeDisputeCreated = async (dispute, event) => {
647+
const chargeId = dispute?.charge;
648+
const disputeId = dispute?.id;
649+
const amount = dispute?.amount ?? 0;
650+
const reason = dispute?.reason ?? null;
651+
652+
let organizationId = null;
653+
let stripeSessionId = null;
654+
let paymentIntentId = null;
655+
656+
// Best-effort: fetch charge → resolve org via charge or PaymentIntent metadata
657+
// (mirrors the resolver pattern in handleChargeRefunded).
658+
if (chargeId) {
659+
const stripe = getStripe();
660+
if (stripe) {
661+
try {
662+
const charge = await stripe.charges.retrieve(chargeId);
663+
const meta = charge?.metadata ?? {};
664+
organizationId = meta.organizationId ?? null;
665+
stripeSessionId = meta.stripeSessionId ?? null;
666+
paymentIntentId = charge?.payment_intent ?? null;
667+
668+
if ((!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) && paymentIntentId) {
669+
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
670+
const piMeta = paymentIntent?.metadata ?? {};
671+
if (!organizationId && piMeta.organizationId && mongoose.Types.ObjectId.isValid(piMeta.organizationId)) {
672+
organizationId = piMeta.organizationId;
673+
}
674+
if (!stripeSessionId) stripeSessionId = piMeta.stripeSessionId ?? null;
675+
}
676+
} catch (err) {
677+
logger.error('[billing.webhook] dispute charge/PI fetch failed', {
678+
chargeId,
679+
disputeId,
680+
error: err?.message ?? String(err),
681+
stack: err?.stack,
682+
});
683+
}
684+
}
685+
}
686+
687+
// Critical alert — ops MUST react manually (decide to fight or accept the dispute).
688+
// No auto-debit: real claw-back happens on charge.dispute.funds_withdrawn / charge.refunded.
689+
logger.error('[billing] dispute opened — manual review required', {
690+
disputeId,
691+
chargeId,
692+
organizationId,
693+
stripeSessionId,
694+
amount,
695+
reason,
696+
eventId: event?.id,
697+
});
698+
699+
try {
700+
billingEvents.emit('billing.dispute.opened', {
701+
disputeId,
702+
chargeId,
703+
organizationId,
704+
stripeSessionId,
705+
amount,
706+
reason,
707+
});
708+
} catch (evtErr) {
709+
// Listener errors must not break webhook processing — log for traceability.
710+
logger.error('[billing.webhook] billing.dispute.opened listener error (non-fatal)', {
711+
error: evtErr?.message ?? String(evtErr),
712+
stack: evtErr?.stack,
713+
});
714+
}
715+
};
716+
568717
export default {
569718
withIdempotency,
570719
handleCheckoutSessionCompleted,
@@ -575,4 +724,6 @@ export default {
575724
handleInvoicePaymentFailed,
576725
handleInvoicePaymentSucceeded,
577726
handleChargeRefunded,
727+
handleCustomerDeleted,
728+
handleChargeDisputeCreated,
578729
};

modules/billing/tests/billing.plans.unit.tests.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,91 @@ describe('Billing plans service unit tests:', () => {
226226
expect(mod.default.fetchPlansFromStripe).toBeUndefined();
227227
});
228228

229+
// ── In-flight dedup — Stripe-API-quota DoS hardening ────────────────────
230+
// The /api/billing/plans route is public, so a thundering herd on cache miss
231+
// (or a small DoS) would otherwise fire N parallel `stripe.products.list +
232+
// stripe.prices.list` calls and exhaust the Stripe API quota — breaking live
233+
// checkouts and webhook signature verification. The service layer dedups
234+
// concurrent in-flight fetches into a single Stripe round-trip.
235+
236+
test('100 concurrent getPlans() calls during cache miss → exactly ONE Stripe products.list call', async () => {
237+
const mod = await import('../services/billing.plans.service.js');
238+
BillingPlansService = mod.default;
239+
240+
// Resolve manually so all 100 callers race the same in-flight promise.
241+
let resolveProducts;
242+
mockStripeInstance.products.list.mockReturnValue({
243+
autoPagingToArray: jest.fn().mockReturnValue(
244+
new Promise((resolve) => {
245+
resolveProducts = () => resolve(productsData);
246+
}),
247+
),
248+
});
249+
250+
const inflight = Array.from({ length: 100 }, () => BillingPlansService.getPlans());
251+
252+
// Resolve the underlying Stripe call once — all 100 callers should share its result.
253+
resolveProducts();
254+
const results = await Promise.all(inflight);
255+
256+
expect(mockStripeInstance.products.list).toHaveBeenCalledTimes(1);
257+
expect(mockStripeInstance.prices.list).toHaveBeenCalledTimes(1);
258+
// Every caller gets the same plan list.
259+
for (const plans of results) expect(plans).toHaveLength(2);
260+
});
261+
262+
test('after cache TTL expires, a fresh thundering herd still triggers ONE fetch', async () => {
263+
const mod = await import('../services/billing.plans.service.js');
264+
BillingPlansService = mod.default;
265+
266+
// First call populates the cache.
267+
await BillingPlansService.getPlans();
268+
expect(mockStripeInstance.products.list).toHaveBeenCalledTimes(1);
269+
270+
// Advance time past TTL.
271+
const originalDateNow = Date.now;
272+
Date.now = jest.fn().mockReturnValue(originalDateNow() + 61 * 60 * 1000);
273+
274+
// 50 concurrent callers after TTL expires.
275+
let resolveProducts;
276+
mockStripeInstance.products.list.mockReturnValue({
277+
autoPagingToArray: jest.fn().mockReturnValue(
278+
new Promise((resolve) => {
279+
resolveProducts = () => resolve(productsData);
280+
}),
281+
),
282+
});
283+
284+
const inflight = Array.from({ length: 50 }, () => BillingPlansService.getPlans());
285+
resolveProducts();
286+
await Promise.all(inflight);
287+
288+
// Total: 1 (initial) + 1 (after TTL) — not 1 + 50.
289+
expect(mockStripeInstance.products.list).toHaveBeenCalledTimes(2);
290+
291+
Date.now = originalDateNow;
292+
});
293+
294+
test('in-flight slot resets after fetch failure → next call retries cleanly', async () => {
295+
const mod = await import('../services/billing.plans.service.js');
296+
BillingPlansService = mod.default;
297+
298+
// First fetch fails — slot must reset, otherwise the next call hangs forever.
299+
mockStripeInstance.products.list.mockReturnValueOnce({
300+
autoPagingToArray: jest.fn().mockRejectedValue(new Error('Stripe API down')),
301+
});
302+
303+
await expect(BillingPlansService.getPlans()).rejects.toThrow('Stripe API down');
304+
305+
// Second call should issue a NEW Stripe round-trip (slot was cleared in finally).
306+
mockStripeInstance.products.list.mockReturnValueOnce(mockListResult(productsData));
307+
mockStripeInstance.prices.list.mockReturnValueOnce(mockListResult(pricesData));
308+
309+
const plans = await BillingPlansService.getPlans();
310+
expect(plans).toHaveLength(2);
311+
expect(mockStripeInstance.products.list).toHaveBeenCalledTimes(2);
312+
});
313+
229314
test('facade: getPlans returns array of plan objects', async () => {
230315
const mod = await import('../services/billing.plans.service.js');
231316
BillingPlansService = mod.default;

0 commit comments

Comments
 (0)