fix(billing): dispute reinstate credit + reconcile meter↔extras + balance guards (Batch 2)#3620
Conversation
…ance 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.
…ow-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
|
Warning Rate limit exceeded
To continue reviewing without waiting, purchase usage credits in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThis PR implements a complete admin-driven dispute credit recovery flow for billing extras balances, enhanced reconciliation monitoring for meter-to-extras divergence, and usage runaway balance detection. It adds a new ChangesDispute Credit Admin Flow & Reconciliation Monitoring
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #3620 +/- ##
==========================================
+ Coverage 88.37% 88.70% +0.32%
==========================================
Files 134 134
Lines 4474 4576 +102
Branches 1378 1413 +35
==========================================
+ Hits 3954 4059 +105
+ Misses 409 404 -5
- Partials 111 113 +2
Flags with carried forward coverage won't be shown. Click here to find out more. Continue to review full report in Codecov by Sentry.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR (Billing Batch 2) adds an admin dispute-credit workflow, expands reconciliation with meter↔extras mismatch detection, and introduces safety/guardrails for extras debits and webhook replay ordering.
Changes:
- Adds
POST /api/admin/billing/dispute/credit/:orgId(schema + policy + controller + service) to apply an idempotent extras ledger credit aftercharge.dispute.funds_reinstated. - Extends reconciliation to detect meter overflow vs extras-ledger debit divergence and emit
billing.reconciliation.divergence(subType: meter_extras_mismatch). - Adds a runaway negative-balance guard after extras debits and improves invoice marker advancement to prevent stale invoice-event replays.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/billing/tests/billing.webhook.subscription.unit.tests.js | Updates/renames tests to assert invoice marker advancement for healthy and past-due subscriptions. |
| modules/billing/tests/billing.usage.service.unit.tests.js | Adds unit tests for runaway negative balance detection event + log behavior. |
| modules/billing/tests/billing.reconcile.service.unit.tests.js | Refactors tests to use SubscriptionRepository paging + adds meter↔extras mismatch test coverage. |
| modules/billing/tests/billing.extraBalance.unit.tests.js | Adds/adjusts tests for org-existence guard and new creditCompensation repository method. |
| modules/billing/tests/billing.admin.service.unit.tests.js | Adds tests for creditDisputeReinstated behavior and validations. |
| modules/billing/services/billing.webhook.service.js | Ensures invoice-family markers advance even for healthy subs; updates dispute reinstated warning message. |
| modules/billing/services/billing.usage.service.js | Emits billing.extras.runaway_debit and logs when cachedBalance drops below -10×quota. |
| modules/billing/services/billing.reconcile.service.js | Replaces direct mongoose access with repository paging; adds _checkMeterExtrasMismatch. |
| modules/billing/services/billing.admin.service.js | Removes mongoose dependency; adds creditDisputeReinstated to apply compensation credits. |
| modules/billing/RUNBOOKS.md | Updates dispute runbook with the new dispute-credit endpoint and curl example. |
| modules/billing/routes/billing.admin.routes.js | Wires the new admin route with auth + policy + Zod body validation. |
| modules/billing/repositories/billing.subscription.repository.js | Adds findPageForReconciliation for reconcile paging without service-layer mongoose access. |
| modules/billing/repositories/billing.extraBalance.repository.js | Adds org-existence guard on first debit; adds creditCompensation + findLedgerByOrg. |
| modules/billing/policies/billing.policy.js | Registers the new dispute-credit endpoint path for CASL subject mapping. |
| modules/billing/models/billing.subscription.schema.js | Adds AdminDisputeCreditRequest Zod schema and exports it. |
| modules/billing/controllers/billing.admin.controller.js | Adds adminDisputeCredit controller to invoke the new admin service method. |
Comments suppressed due to low confidence (1)
modules/billing/repositories/billing.extraBalance.repository.js:145
- After the org-existence guard, Step 1 duplicates the
getOrCreateupsert logic but without therunValidators/returnDocumentoptions used there. ReusinggetOrCreate(orgId)here would reduce duplication and keep insert behavior consistent (including validation).
await BillingExtraBalance().findOneAndUpdate(
{ organization: orgId },
{ $setOnInsert: { organization: orgId, ledger: [], cachedBalance: 0, cachedBalanceAt: new Date() } },
{ upsert: true },
);
- 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
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).
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@modules/billing/repositories/billing.extraBalance.repository.js`:
- Around line 431-445: The findLedgerByOrg helper currently fetches the entire
ledger array which causes heavy reads; instead modify findLedgerByOrg (or add a
new helper) to perform the filter/aggregation in Mongo: match { organization:
orgId }, unwind the ledger array (or use an aggregation pipeline), filter ledger
entries by the current-period criteria (e.g., ledger.kind and/or period field)
and only sum debit amounts (e.g., ledger.type === 'debit') in the DB, then
return the single aggregate total (or the filtered entries) rather than the full
ledger; use BillingExtraBalance() aggregation pipeline (match → unwind → match →
group) to compute the debit sum server-side and return null/0 when no entries
exist.
In `@modules/billing/repositories/billing.subscription.repository.js`:
- Around line 322-329: The reconciliation query in findPageForReconciliation
uses { stripeSubscriptionId: { $ne: null } } which matches missing fields and
lacks a deterministic sort causing unstable skip/limit pagination; update the
Subscription.find filter to require the field exists and is not null (use
$exists: true plus $ne: null) and add a stable sort (e.g. sort by _id ascending
or another immutable field) to the query chain so skip/limit pagination returns
consistent, non-duplicating pages.
In `@modules/billing/RUNBOOKS.md`:
- Around line 25-41: The markdown fenced-code blocks in the dispute credit
section are missing blank lines around them and the first fence lacks a language
tag; update the block that contains the POST
/api/admin/billing/dispute/credit/:orgId body to use a fenced block with a
language tag (e.g., ```text) and ensure there is a blank line before and after
that fence, and also add a blank line before the following Example curl and its
```bash fence so both fenced blocks are separated by blank lines to satisfy
markdownlint.
In `@modules/billing/services/billing.admin.service.js`:
- Around line 305-327: creditDisputeReinstated currently only validates orgId
format and then calls BillingExtraBalanceRepository.creditCompensation which can
create an extras balance for a non-existent org; query the org repository first
and reject if the org doesn't exist before any ledger/write. Specifically,
before constructing refId or calling
BillingExtraBalanceRepository.creditCompensation, call the canonical org lookup
(e.g., OrganizationRepository.findById or OrganizationRepository.existsById)
with orgId and throw the same 422-style error if not found, so the function
(creditDisputeReinstated) fails early and never writes for ghost orgs.
In `@modules/billing/services/billing.reconcile.service.js`:
- Around line 145-176: The code compares expectedExtrasUsage (computed from
currentWeekKey() via BillingUsageRepository.findByWeek) against
actualExtrasDebits (summing ledger entries since sub.currentPeriodStart via
BillingExtraBalanceRepository.findLedgerByOrg), causing mismatched time windows;
fix by aligning both sides to the same window: either (A) compute
expectedExtrasUsage for the billing period using sub.currentPeriodStart/end
(fetch usage for the period instead of currentWeekKey()) or (B) restrict
actualExtrasDebits to the current ISO week (use currentWeekKey()/week boundaries
when filtering ledger entries) so expectedExtrasUsage and actualExtrasDebits use
the same cutoff; update the logic around expectedExtrasUsage,
BillingUsageRepository.findByWeek, actualExtrasDebits loop and the
periodStart/ledger filtering accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ec6ed0eb-5df8-4455-a0f9-b4623143635a
📒 Files selected for processing (16)
modules/billing/RUNBOOKS.mdmodules/billing/controllers/billing.admin.controller.jsmodules/billing/models/billing.subscription.schema.jsmodules/billing/policies/billing.policy.jsmodules/billing/repositories/billing.extraBalance.repository.jsmodules/billing/repositories/billing.subscription.repository.jsmodules/billing/routes/billing.admin.routes.jsmodules/billing/services/billing.admin.service.jsmodules/billing/services/billing.reconcile.service.jsmodules/billing/services/billing.usage.service.jsmodules/billing/services/billing.webhook.service.jsmodules/billing/tests/billing.admin.service.unit.tests.jsmodules/billing/tests/billing.extraBalance.unit.tests.jsmodules/billing/tests/billing.reconcile.service.unit.tests.jsmodules/billing/tests/billing.usage.service.unit.tests.jsmodules/billing/tests/billing.webhook.subscription.unit.tests.js
…rs 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)
Summary
Billing Batch 2 — money flow safety layer on top of Batch 1 (#3619).
Items implemented
Item 1 (Opus C1/DeepSeek HIGH) — Dispute credit endpoint
POST /api/admin/billing/dispute/credit/:orgId— idempotent viarefundRequestIdAdminDisputeCreditRequest+ CASL policy + controller + service + routeadjustmentledger entry viaBillingExtraBalanceRepository.creditCompensation()Item 2 (Opus H1) — Meter↔extras reconcile mismatch detection
_checkMeterExtrasMismatch()in reconcile service: BillingUsage overflow vs ledger debitsbilling.reconciliation.divergencewithsubType: meter_extras_mismatchwhen delta > tolerancemax(50, 0.5% × max(expected, actual))to avoid timing-skew noiseItem 3 (Opus H4) — Runaway balance guard
debit(): ifcachedBalance < -(10 × meterQuota), emitsbilling.extras.runaway_debitmeterQuota === 0)Item 4 (Opus H4) — Org-existence guard on first debit
debit()now validates org existence (OrganizationRepository OR Subscription fallback) before first upsertItem 5 (Opus H7) — Early return perf + replay protection
handleInvoicePaymentSucceeded: always advances invoice-family marker even for healthy subs(previously skipped → replay of old invoice events could bypass ordering guard — DeepSeek HIGH fix)
DeepSeek pre-push review (2 commits)
DeepSeek V4 flagged: HIGH (invoice marker skipped), MEDIUM (mongoose in services), CASL path mismatch.
All fixed in follow-up commit
e506f062:updateIfEventNewer({}, 'invoice')for healthy subs/api/admin/billing/dispute/credit/:orgId(exact match requires :orgId)billing.admin.service: replacedmongoose.Types.ObjectId.isValidwith 24-char hex regexbilling.reconcile.service: removedmongoose.model()— addedSubscriptionRepository.findPageForReconciliation+BillingExtraBalanceRepository.findLedgerByOrgArchitecture
import()for OrganizationRepository in extraBalance repoSummary by CodeRabbit
Release Notes
New Features
Bug Fixes
Documentation