Skip to content

Commit 9d39136

Browse files
fix(billing): cumulative review — refund correlation + cleanup (#3543)
* fix(billing): apply cumulative review — refund correlation + cleanup CRITICAL: backfill PaymentIntent metadata with real cs_* session ID after creditPack succeeds in handleCheckoutPaymentCompleted, so charge.refunded events can correlate the charge back to the correct ledger entry (the __pending__ placeholder was breaking refundPartial lookups). Add 3 unit tests covering PI update, absent PI, and non-fatal failure path. HIGH: replace stale TODO PR-N3 in refundPartial with accurate comment — heuristic is still intentionally needed since refundPartial does not receive packId from the webhook call-site. HIGH: add Mixed type caveat JSDoc to billing.usage.model (meterBreakdown and counters fields) matching the existing note in billing.extraBalance.model. MEDIUM: document BillingExtraBalance.organization vs BillingUsage.organizationId asymmetry in billing.controller.js. MEDIUM: add startup priceUsd validation warning in billing.init.js so misconfigured packs are caught before a refund event exposes the gap. * fix(billing): correct organization field type comment (both ObjectId)
1 parent 0c3c90d commit 9d39136

6 files changed

Lines changed: 113 additions & 3 deletions

File tree

modules/billing/billing.init.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Module dependencies
33
*/
4+
import config from '../../config/index.js';
45
import AnalyticsService from '../../lib/services/analytics.js';
56
import billingEvents from './lib/events.js';
67

@@ -13,6 +14,15 @@ import billingEvents from './lib/events.js';
1314
*/
1415
// eslint-disable-next-line no-unused-vars
1516
export default async (app) => {
17+
// Warn at startup if any pack is missing a valid priceUsd — refundPartial fallback will be inaccurate
18+
if (config.billing?.packs?.length) {
19+
for (const pack of config.billing.packs) {
20+
if (typeof pack.priceUsd !== 'number' || pack.priceUsd <= 0) {
21+
console.warn(`[billing] pack '${pack.packId}' missing valid priceUsd; refundPartial fallback will be inaccurate`);
22+
}
23+
}
24+
}
25+
1626
// Update analytics group properties when a subscription plan changes
1727
billingEvents.on('plan.changed', ({ organizationId, newPlan }) => {
1828
try {

modules/billing/controllers/billing.controller.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import BillingUsageService from '../services/billing.usage.service.js';
99
import BillingExtraService from '../services/billing.extra.service.js';
1010
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
1111

12+
// NOTE: BillingExtraBalance uses field name 'organization', BillingUsage uses 'organizationId' — both are Schema.ObjectId refs to Organization.
13+
// Only the field name differs (historical reasons) — keep queries consistent with each model's own convention.
14+
1215
/**
1316
* @desc Endpoint to create a Stripe Checkout session
1417
* @param {Object} req - Express request object

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const Schema = mongoose.Schema;
1212
* Meter fields (weekKey, meterUsed, etc.) are sparse/optional — only
1313
* populated when config.billing.meterMode is true. This ensures full
1414
* backward compatibility for non-meter downstream projects.
15+
*
16+
* NOTE — Mixed type caveats (applies to 'counters' and 'meterBreakdown' fields):
17+
* Mongoose validators are NOT executed for in-place mutations on Mixed fields
18+
* (doc.field.x = y; doc.save() silently skips validators).
19+
* Always use atomic MongoDB operators ($inc, $set via findOneAndUpdate)
20+
* or Model.create() which runs validators on the full document.
1521
*/
1622
const UsageMongoose = new Schema(
1723
{

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,12 @@ const refundPartial = async (orgId, stripeSessionId, amountRefundedCents) => {
8787
return { doc, applied: false, refundUnits: 0 };
8888
}
8989

90-
// Find the pack config to compute proportion.
90+
// Find the pack config to compute proportion using the topup entry's meterUnits.
9191
// Ambiguity guard: if 0 or >1 packs share the same meterUnits, fall back to
9292
// applied=false rather than using a wrong priceUsd.
93-
// TODO PR-N3: webhook handler will pass packId from session metadata, removing the need for this heuristic.
93+
// NOTE: packId is not passed to refundPartial — charge.refunded carries it in
94+
// charge.metadata but the webhook call-site only passes (orgId, stripeSessionId, amountCents).
95+
// This heuristic is therefore intentionally retained; the ambiguity guard is the safety net.
9496
const packs = config?.billing?.packs ?? [];
9597
const matchingPacks = packs.filter(
9698
(p) => (p.meterUnits ?? p.computeUnits) === topupEntry.amount,

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import mongoose from 'mongoose';
55

66
import config from '../../../config/index.js';
7+
import getStripe from '../lib/stripe.js';
78
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
89
import ProcessedStripeEventRepository from '../repositories/billing.processedStripeEvent.repository.js';
910
import OrganizationRepository from '../../organizations/repositories/organizations.repository.js';
@@ -152,14 +153,38 @@ const handleCheckoutCompleted = async (session) => {
152153
const handleCheckoutPaymentCompleted = async (session) => {
153154
if (session.payment_status !== 'paid') return;
154155

155-
const { metadata, id: stripeSessionId } = session;
156+
const { metadata, id: stripeSessionId, payment_intent: paymentIntentId } = session;
156157
const { organizationId, packId, kind } = metadata ?? {};
157158

158159
if (kind !== 'extras') return;
159160
if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) return;
160161
if (!packId) return;
161162

162163
await BillingExtraService.creditPack(organizationId, packId, stripeSessionId);
164+
165+
// Backfill PaymentIntent metadata with the real session ID so that charge.refunded
166+
// events can correlate the charge back to this ledger entry.
167+
// At session.create time stripeSessionId was set to '__pending__' (Stripe forbids
168+
// self-reference). Propagating the real cs_* ID here ensures charge.metadata carries
169+
// it when a refund is issued later.
170+
if (paymentIntentId) {
171+
const stripe = getStripe();
172+
if (stripe) {
173+
try {
174+
await stripe.paymentIntents.update(paymentIntentId, {
175+
metadata: {
176+
organizationId,
177+
packId,
178+
kind: 'extras',
179+
stripeSessionId, // real cs_* ID
180+
},
181+
});
182+
} catch (err) {
183+
// Log but don't fail — refund correlation may need fallback path
184+
console.warn('[billing.webhook] PaymentIntent metadata update failed:', err.message);
185+
}
186+
}
187+
}
163188
};
164189

165190
/**

modules/billing/tests/billing.webhook.checkout.unit.tests.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe('Billing webhook checkout unit tests:', () => {
1414
let mockSubscriptionRepository;
1515
let mockOrganizationRepository;
1616
let mockExtraService;
17+
let mockStripeInstance;
1718

1819
const orgId = '507f1f77bcf86cd799439011';
1920
const subId = '607f1f77bcf86cd799439022';
@@ -39,6 +40,12 @@ describe('Billing webhook checkout unit tests:', () => {
3940
refundPartial: jest.fn(),
4041
};
4142

43+
mockStripeInstance = {
44+
paymentIntents: {
45+
update: jest.fn().mockResolvedValue({}),
46+
},
47+
};
48+
4249
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
4350
default: mockSubscriptionRepository,
4451
}));
@@ -66,6 +73,10 @@ describe('Billing webhook checkout unit tests:', () => {
6673
default: { emit: jest.fn() },
6774
}));
6875

76+
jest.unstable_mockModule('../lib/stripe.js', () => ({
77+
default: jest.fn(() => mockStripeInstance),
78+
}));
79+
6980
jest.unstable_mockModule('../../../config/index.js', () => ({
7081
default: {
7182
billing: { plans: ['free', 'starter', 'pro', 'enterprise'] },
@@ -220,6 +231,59 @@ describe('Billing webhook checkout unit tests:', () => {
220231

221232
expect(mockExtraService.creditPack).not.toHaveBeenCalled();
222233
});
234+
235+
test('should call stripe.paymentIntents.update with real sessionId after creditPack succeeds (CRITICAL: refund correlation)', async () => {
236+
const paymentIntentId = 'pi_test_abc123';
237+
238+
await BillingWebhookService.handleCheckoutPaymentCompleted({
239+
id: stripeSessionId,
240+
payment_status: 'paid',
241+
payment_intent: paymentIntentId,
242+
metadata: { organizationId: orgId, packId: 'pack_500k', kind: 'extras' },
243+
});
244+
245+
expect(mockExtraService.creditPack).toHaveBeenCalledWith(orgId, 'pack_500k', stripeSessionId);
246+
expect(mockStripeInstance.paymentIntents.update).toHaveBeenCalledWith(
247+
paymentIntentId,
248+
{
249+
metadata: {
250+
organizationId: orgId,
251+
packId: 'pack_500k',
252+
kind: 'extras',
253+
stripeSessionId, // real cs_* ID (not '__pending__')
254+
},
255+
},
256+
);
257+
});
258+
259+
test('should skip paymentIntents.update when payment_intent is absent', async () => {
260+
await BillingWebhookService.handleCheckoutPaymentCompleted({
261+
id: stripeSessionId,
262+
payment_status: 'paid',
263+
// payment_intent omitted — e.g. in test fixtures without PI
264+
metadata: { organizationId: orgId, packId: 'pack_500k', kind: 'extras' },
265+
});
266+
267+
expect(mockExtraService.creditPack).toHaveBeenCalled();
268+
expect(mockStripeInstance.paymentIntents.update).not.toHaveBeenCalled();
269+
});
270+
271+
test('should not throw when paymentIntents.update fails (non-fatal fallback)', async () => {
272+
const paymentIntentId = 'pi_test_failing';
273+
mockStripeInstance.paymentIntents.update.mockRejectedValue(new Error('Stripe API error'));
274+
275+
await expect(
276+
BillingWebhookService.handleCheckoutPaymentCompleted({
277+
id: stripeSessionId,
278+
payment_status: 'paid',
279+
payment_intent: paymentIntentId,
280+
metadata: { organizationId: orgId, packId: 'pack_500k', kind: 'extras' },
281+
}),
282+
).resolves.toBeUndefined();
283+
284+
// creditPack should still have run despite the PI update failure
285+
expect(mockExtraService.creditPack).toHaveBeenCalledWith(orgId, 'pack_500k', stripeSessionId);
286+
});
223287
});
224288

225289
describe('handleCheckoutCompleted (mode=subscription)', () => {

0 commit comments

Comments
 (0)