Skip to content

Commit 1a0c7eb

Browse files
fix(billing): dispute reinstate credit + reconcile meter↔extras + balance guards (Batch 2) (#3620)
* fix(billing): dispute reinstate credit + reconcile meter↔extras + balance guards (Batch 2) Five money-flow safety items from the Opus/DeepSeek dual audit: Item 1 (DeepSeek HIGH): POST /api/admin/billing/dispute/credit/:orgId — idempotent admin endpoint to restore extras balance after dispute.funds_reinstated. Uses new creditCompensation() repo method (adjustment ledger kind). Updates RUNBOOKS #1 step 6 with concrete curl example. Item 2 (Opus C1): _checkMeterExtrasMismatch() in reconcile service — per-org meter↔extras divergence detection. Compares meterUsed overflow vs ledger debit sums for current period. Emits billing.reconciliation.divergence with subType:meter_extras_mismatch when delta exceeds tolerance (max(50, 0.5% × max(expected, actual))). Non-fatal; Batch 1 listener catches the event. Item 3 (Opus H1): Runaway negative balance detection in incrementMeter. After extras debit, checks cachedBalance < -10×meterQuota — emits billing.extras.runaway_debit + logs error. Check skipped for free plans (quota=0). Debit already applied (defensive only). Item 4 (Opus H4): Org-existence guard in BillingExtraBalanceRepository.debit. On fresh-doc path, validates Organization OR Subscription exists before creating an orphan balance doc. Accepts Subscription as proof of org existence to support provisioning flows. Item 5 (Opus H7): handleInvoicePaymentSucceeded early return on healthy subs (pastDueSince null). Skips the updateIfEventNewer call entirely — eliminating ~95% of runValidators overhead on normal payment webhooks. Past-due subs still update normally. * fix(billing): address DeepSeek pre-push review findings (Batch 2 follow-up) - [HIGH] advance invoice-family marker for healthy subs in handleInvoicePaymentSucceeded — always call updateIfEventNewer with {} fields to guard against stale replay of older invoice events - [CASL] fix policy path to /api/admin/billing/dispute/credit/:orgId (exact-match requires :orgId param for correct resolution) - [MEDIUM] remove mongoose from billing.admin.service — 24-char hex regex replaces mongoose.Types.ObjectId.isValid() (ERRORS.md architecture rule) - [MEDIUM] remove mongoose.model() from billing.reconcile.service — delegate to repositories: add SubscriptionRepository.findPageForReconciliation and BillingExtraBalanceRepository.findLedgerByOrg; _checkMeterExtrasMismatch now uses lazy repo imports via BillingUsageRepository.findByWeek - Tests: reconcile suite mocks repos directly; webhook test reflects new marker-advance behavior for healthy subs * fix(billing): address Copilot review threads (Batch 2 follow-up 2) - JSDoc + warn log: update route path to /dispute/credit/:orgId - reason param: pass as memo to creditCompensation (persisted in ledger) - debit() hot path: use .exists() instead of findOne+lean for doc check - reconcile comment: clarify period boundary vs weekly meter scope - Tests: update mocks from findOne to exists; add reason to creditCompensation assertion * test(billing): cover dispatchWebhookEvent switch — 11 cases + default Adds 12 unit tests for the replay-path switch in admin.service.js: - 11 case-each tests verifying each Stripe event type routes through withIdempotency to the matching webhook service handler - 1 test for the default case logging unhandled events and returning { skipped: true } Closes the codecov/patch gap (lines 378-422 in admin.service.js were 0% covered). * test(billing): cover adminDisputeCredit endpoint + adminListDeadLetters 422 path Adds 6 integration tests: - POST /api/admin/billing/dispute/credit/:orgId — admin 200, non-admin 403, invalid body 422, charge not found 404, unexpected error 500 - GET /api/admin/billing/dead-letters — invalid query 422 path Closes the codecov/patch gap on: - billing.admin.controller.js:282-315 (adminDisputeCredit body) - billing.admin.controller.js:219 (adminListDeadLetters Zod 422 path)
1 parent a92fdad commit 1a0c7eb

17 files changed

Lines changed: 1179 additions & 138 deletions

modules/billing/RUNBOOKS.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,24 @@ Operational runbooks for the billing module. Each runbook references real endpoi
2222
```
2323
4. Gather evidence in Stripe Dashboard: usage records, signup email, ToS acceptance timestamp, IP logs.
2424
5. Submit evidence before day 7 via Stripe Dashboard → Dispute → Submit evidence.
25-
6. If dispute won (`charge.dispute.funds_reinstated` received): verify the extras credit was re-applied by re-checking the ledger via `GET /api/admin/billing/customer/:orgId`.
25+
6. If dispute won (`charge.dispute.funds_reinstated` received): restore the customer's extras balance via:
26+
```
27+
POST /api/admin/billing/dispute/credit/:orgId
28+
Body: {
29+
"chargeId": "ch_xxx",
30+
"amountCents": <dispute_amount_cents>,
31+
"reason": "dispute won — funds reinstated on charge ch_xxx",
32+
"refundRequestId": "<uuid>"
33+
}
34+
```
35+
Example curl:
36+
```bash
37+
curl -X POST https://api.trawl.me/api/admin/billing/dispute/credit/<orgId> \
38+
-H "Authorization: Bearer $ADMIN_JWT" \
39+
-H "Content-Type: application/json" \
40+
-d '{"chargeId":"ch_xxx","amountCents":2000,"reason":"dispute won — Stripe reinstated funds","refundRequestId":"<uuid>"}'
41+
```
42+
Confirm credit applied: `GET /api/admin/billing/customer/:orgId` — check ledger for an `adjustment` entry with `refId: dispute-credit-<uuid>`.
2643
7. If dispute lost: extras balance debited by `charge.dispute.funds_withdrawn` is not refunded — log in incident tracker.
2744

2845
---

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import responses from '../../../lib/helpers/responses.js';
55
import getStripe from '../lib/stripe.js';
66
import BillingAdminService from '../services/billing.admin.service.js';
77
import { AdminDeadLettersQuery } from '../models/billing.subscription.schema.js';
8+
import logger from '../../../lib/services/logger.js';
89

910
/**
1011
* @desc Admin endpoint to trigger a Stripe refund for a charge.
@@ -268,6 +269,53 @@ const adminCancelSubscription = async (req, res) => {
268269
}
269270
};
270271

272+
/**
273+
* @desc POST /api/admin/billing/dispute/credit/:orgId
274+
* Apply a manual extras-balance credit after a dispute is won (funds_reinstated).
275+
* Idempotent via refundRequestId — safe to call multiple times per dispute.
276+
* @param {Object} req - Express request object (body validated as AdminDisputeCreditRequest)
277+
* @param {Object} res - Express response object
278+
* @returns {Promise<void>}
279+
*/
280+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js controller, not Qwik
281+
const adminDisputeCredit = async (req, res) => {
282+
try {
283+
const { chargeId, amountCents, reason, refundRequestId } = req.body;
284+
const { orgId } = req.params;
285+
286+
const rawAdminId = req.user?._id;
287+
if (!rawAdminId) {
288+
return responses.error(res, 422, 'Unprocessable Entity', 'Failed to apply dispute credit')(
289+
new Error('invalid argument: authenticated user id is missing'),
290+
);
291+
}
292+
const adminUserId = String(rawAdminId);
293+
294+
const result = await BillingAdminService.creditDisputeReinstated(
295+
chargeId,
296+
amountCents,
297+
reason,
298+
refundRequestId,
299+
orgId,
300+
adminUserId,
301+
);
302+
303+
logger.info('[billing.admin] adminDisputeCredit completed', {
304+
orgId,
305+
chargeId,
306+
amountCents,
307+
applied: result.applied,
308+
adminUserId,
309+
});
310+
311+
return responses.success(res, 'dispute credit applied')(result);
312+
} catch (err) {
313+
const status = err.status ?? 500;
314+
const title = status === 404 ? 'Not Found' : status === 422 ? 'Unprocessable Entity' : 'Internal Server Error';
315+
return responses.error(res, status, title, 'Failed to apply dispute credit')(err);
316+
}
317+
};
318+
271319
export default {
272320
adminRefundCharge,
273321
adminBumpPlan,
@@ -277,4 +325,5 @@ export default {
277325
adminListDeadLetters,
278326
adminPurgeDeadLetter,
279327
adminCancelSubscription,
328+
adminDisputeCredit,
280329
};

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,31 @@ const AdminDeadLettersQuery = z
127127
})
128128
.strict();
129129

130+
/**
131+
* Dispute credit request body schema.
132+
* Used by POST /api/admin/billing/dispute/credit to apply a manual ledger credit
133+
* when Stripe rules in our favor on a dispute (funds_reinstated).
134+
*
135+
* chargeId — Stripe charge ID (ch_xxx) to correlate with the dispute.
136+
* amountCents — Amount to credit in cents (positive integer).
137+
* reason — Ops note for audit trail (3–200 chars).
138+
* refundRequestId — UUID generated by the frontend per click for idempotency.
139+
*/
140+
const AdminDisputeCreditRequest = z
141+
.object({
142+
chargeId: z.string().regex(/^ch_/, 'chargeId must start with ch_'),
143+
amountCents: z.number().int().positive('amountCents must be a positive integer'),
144+
reason: z.string().min(3, 'reason must be at least 3 chars').max(200, 'reason must be at most 200 chars'),
145+
refundRequestId: z.string().min(8, 'refundRequestId must be at least 8 characters'),
146+
})
147+
.strict();
148+
130149
export {
131150
AdminRefundRequest,
132151
AdminBumpPlanRequest,
133152
AdminWebhookReplayRequest,
134153
AdminDeadLettersQuery,
154+
AdminDisputeCreditRequest,
135155
};
136156

137157
export default {
@@ -144,4 +164,5 @@ export default {
144164
AdminBumpPlanRequest,
145165
AdminWebhookReplayRequest,
146166
AdminDeadLettersQuery,
167+
AdminDisputeCreditRequest,
147168
};

modules/billing/policies/billing.policy.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export function billingSubjectRegistration({ registerPathSubject }) {
2727
registerPathSubject('/api/admin/billing/webhook/replay', 'BillingAdminOps');
2828
registerPathSubject('/api/admin/billing/dead-letters', 'BillingAdminOps');
2929
registerPathSubject('/api/admin/billing/cancel', 'BillingAdminOps');
30+
// Dispute credit — manual ledger credit after funds_reinstated (Batch 2)
31+
registerPathSubject('/api/admin/billing/dispute/credit/:orgId', 'BillingAdminOps');
3032
// Webhook is mounted in billing.preroute.js before body parsing and auth middleware,
3133
// so it bypasses policy.isAllowed entirely. Registered here for documentation completeness.
3234
registerPathSubject('/api/billing/webhook', 'BillingWebhook');

modules/billing/repositories/billing.extraBalance.repository.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Module dependencies
33
*/
44
import mongoose from 'mongoose';
5+
import AppError from '../../../lib/helpers/AppError.js';
56

67
/**
78
* Validate that orgId is a syntactically valid MongoDB ObjectId.
@@ -116,6 +117,27 @@ const debit = async (orgId, amount, refId) => {
116117
};
117118

118119
// Step 1: ensure the document exists (atomic getOrCreate, no-op if already present).
120+
// Guard: if no doc exists yet, validate that the organization exists before creating a
121+
// dangling ExtraBalance doc for a ghost org (Opus H4). Accepts proof via either the
122+
// Organization collection OR the Subscription collection — a valid Subscription is
123+
// sufficient evidence that the org is real (provisioning sometimes writes Subscription
124+
// before the full Organization doc, e.g. in CLI tools or integration tests).
125+
const existing = await BillingExtraBalance().exists({ organization: orgId });
126+
if (!existing) {
127+
const { default: OrganizationRepository } = await import('../../organizations/repositories/organizations.repository.js');
128+
const orgExists = await OrganizationRepository.exists({ _id: orgId });
129+
if (!orgExists) {
130+
const Subscription = mongoose.model('Subscription');
131+
const subExists = await Subscription.exists({ organization: orgId });
132+
if (!subExists) {
133+
throw Object.assign(
134+
new AppError(`Organization not found: ${orgId}`, { status: 404, code: 'ORGANIZATION_NOT_FOUND' }),
135+
{ organizationId: orgId },
136+
);
137+
}
138+
}
139+
}
140+
119141
await BillingExtraBalance().findOneAndUpdate(
120142
{ organization: orgId },
121143
{ $setOnInsert: { organization: orgId, ledger: [], cachedBalance: 0, cachedBalanceAt: new Date() } },
@@ -140,6 +162,57 @@ const debit = async (orgId, amount, refId) => {
140162
return { doc: null, applied: false, reason: 'duplicate_step' };
141163
};
142164

165+
/**
166+
* @function creditCompensation
167+
* @description Atomically push a positive 'adjustment' ledger entry for dispute/ops compensation.
168+
* Idempotent: if a ledger entry with the same refId already exists the update
169+
* is a no-op and applied=false is returned.
170+
* Uses a 2-step pattern aligned with creditPack:
171+
* Step 1 — ensure doc exists (getOrCreate, no-op on replay).
172+
* Step 2 — idempotency-guarded credit push.
173+
* Intended use: admin dispute reinstatement, manual ops corrections.
174+
*
175+
* @param {string} orgId - The organization ObjectId (string).
176+
* @param {number} amount - Meter units to credit (must be > 0).
177+
* @param {string} refId - Unique idempotency key (e.g. `dispute-credit-<refundRequestId>`).
178+
* @param {string} [memo] - Optional human-readable memo stored in the ledger entry.
179+
* @returns {Promise<{doc: Object|null, applied: boolean, reason?: string}>}
180+
*/
181+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
182+
const creditCompensation = async (orgId, amount, refId, memo = '') => {
183+
if (!isValidOrgId(orgId)) return { doc: null, applied: false };
184+
if (!Number.isFinite(amount) || amount <= 0) throw new Error('invalid argument: amount must be a positive finite number');
185+
if (typeof refId !== 'string' || refId.trim() === '') throw new Error('invalid argument: refId must be a non-empty string');
186+
187+
const entry = {
188+
kind: 'adjustment',
189+
amount,
190+
refId,
191+
at: new Date(),
192+
...(memo ? { memo } : {}),
193+
};
194+
195+
// Step 1: ensure the document exists (atomic getOrCreate, no-op if already present).
196+
await getOrCreate(orgId);
197+
198+
// Step 2: idempotency-guarded credit (no upsert — doc is guaranteed to exist after step 1).
199+
const doc = await BillingExtraBalance().findOneAndUpdate(
200+
{
201+
organization: orgId,
202+
'ledger.refId': { $ne: refId },
203+
},
204+
{
205+
$push: { ledger: entry },
206+
$inc: { cachedBalance: amount },
207+
$set: { cachedBalanceAt: new Date() },
208+
},
209+
{ returnDocument: 'after' },
210+
);
211+
212+
if (doc) return { doc, applied: true };
213+
return { doc: null, applied: false, reason: 'duplicate_refId' };
214+
};
215+
143216
/**
144217
* @function addExpirationEntries
145218
* @description Sweep topup entries that have expired and push matching expiration
@@ -355,13 +428,32 @@ const findOrgsWithExpiringTopups = async (now) => {
355428
return orgIds;
356429
};
357430

431+
/**
432+
* Fetch the full ledger array for an org.
433+
* Used by the reconcile service to compute actual extras debits in the current period.
434+
* Returns null when no document exists yet (org has never had extras).
435+
* @param {string} orgId - Organization ObjectId (string).
436+
* @returns {Promise<Object[]|null>} Ledger entries array or null.
437+
*/
438+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
439+
const findLedgerByOrg = async (orgId) => {
440+
if (!isValidOrgId(orgId)) return null;
441+
const doc = await BillingExtraBalance().findOne(
442+
{ organization: orgId },
443+
{ ledger: 1 },
444+
).lean();
445+
return doc?.ledger ?? null;
446+
};
447+
358448
export default {
359449
getOrCreate,
360450
creditPack,
451+
creditCompensation,
361452
debit,
362453
addExpirationEntries,
363454
refundPartial,
364455
getBalance,
365456
listLedgerPage,
366457
findOrgsWithExpiringTopups,
458+
findLedgerByOrg,
367459
};

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,25 @@ const adminUpdatePlanOnly = (id, planId, adminUserId) => {
310310
.exec();
311311
};
312312

313+
/**
314+
* Fetch one page of subscriptions matching given statuses for reconciliation.
315+
* Used by the billing reconcile service (replaces direct mongoose.model() access there).
316+
* @param {string[]} statuses - Subscription statuses to include.
317+
* @param {number} page - 0-based page index.
318+
* @param {number} limit - Page size.
319+
* @returns {Promise<Object[]>} Lean subscription documents.
320+
*/
321+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
322+
const findPageForReconciliation = (statuses, page, limit) =>
323+
Subscription.find(
324+
{ status: { $in: statuses }, stripeSubscriptionId: { $ne: null } },
325+
{ _id: 1, organization: 1, stripeSubscriptionId: 1, stripeCustomerId: 1, plan: 1, status: 1, currentPeriodStart: 1 },
326+
)
327+
.skip(page * limit)
328+
.limit(limit)
329+
.lean()
330+
.exec();
331+
313332
export default {
314333
list,
315334
create,
@@ -326,4 +345,5 @@ export default {
326345
markUnpaid,
327346
updateIfEventNewer,
328347
adminUpdatePlanOnly,
348+
findPageForReconciliation,
329349
};

modules/billing/routes/billing.admin.routes.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
AdminRefundRequest,
1111
AdminBumpPlanRequest,
1212
AdminWebhookReplayRequest,
13+
AdminDisputeCreditRequest,
1314
} from '../models/billing.subscription.schema.js';
1415

1516
/**
@@ -59,4 +60,9 @@ export default (app) => {
5960
.route('/api/admin/billing/cancel/:orgId')
6061
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
6162
.post(billingAdmin.adminCancelSubscription);
63+
64+
app
65+
.route('/api/admin/billing/dispute/credit/:orgId')
66+
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
67+
.post(model.isValid(AdminDisputeCreditRequest), billingAdmin.adminDisputeCredit);
6268
};

0 commit comments

Comments
 (0)