feat(billing): meter service, extras balance, weekly reset (PR-N2)#3536
Conversation
Add compute attribution layer built on PR-N1 plan versioning. New files: - billing.extraBalance model + schema (ledger-based, atomic topup/debit/expiration) - billing.extraBalance repository (creditPack, debit, addExpirationEntries, getBalance) - billing.extra.service (creditPack, debit, expireOldEntries, refundPartial, listLedger) - billing.meter.service (unitsFromCosts via frozen ratios, attribute with replay protection) - billing.reset.service (resetWeek archive-then-upsert, resetAllDue, isoWeekKey) Modified files: - billing.usage.repository: add findByWeek, incrementMeter (atomic, idempotency via $ne) - billing.usage.service: add currentWeekKey, incrementMeter (overflow + thresholds), getMeter - billing.plan.service: add bumpVersionWithRetry (exponential backoff on E11000) All new code paths gated behind config.billing.meterMode=false (backward compat). 79 new unit tests (739 total, up from 660). Refs #3533
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 12 high |
| Security | 3 high |
🟢 Metrics 332 complexity · 41 duplication
Metric Results Complexity 332 Duplication 41
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
|
Warning Rate limit exceeded
To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing. ⌛ 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 (16)
WalkthroughThis PR introduces a comprehensive billing subsystem for managing prepaid "extra" meter units across organizations. It adds Mongoose models and Zod schemas for billing extra-balance ledgers, repositories for atomic ledger mutations with idempotency, services for credit packs/debits/refunds/expirations, and related meter/reset/usage service enhancements, alongside extensive unit test coverage. Changes
Sequence DiagramsequenceDiagram
actor Client
participant ExtraService as BillingExtraService
participant Config
participant Repository as ExtraBalanceRepository
participant Mongoose as Mongoose Model
rect rgba(100, 150, 200, 0.5)
Note over Client,Mongoose: Credit Pack Flow
Client->>ExtraService: creditPack(orgId, packId, stripeSessionId)
ExtraService->>Config: read pack metadata
ExtraService->>ExtraService: compute expiresAt if expiryDays present
ExtraService->>Repository: creditPack(orgId, units, stripeSessionId, expiresAt)
Repository->>Mongoose: findOneAndUpdate with $push ledger + $inc cachedBalance
Mongoose-->>Repository: { doc, applied }
Repository-->>ExtraService: { doc, applied }
ExtraService-->>Client: { applied, doc }
end
rect rgba(200, 100, 150, 0.5)
Note over Client,Mongoose: Debit Flow (with Balance Check)
Client->>ExtraService: debit(orgId, units, refId)
ExtraService->>Repository: debit(orgId, units, refId)
Repository->>Mongoose: findOneAndUpdate with cachedBalance >= amount guard
alt Sufficient Balance & Not Replayed
Mongoose-->>Repository: { doc, applied: true }
else Insufficient Balance or Replayed
Mongoose-->>Repository: { doc, applied: false }
end
Repository-->>ExtraService: { doc, applied }
ExtraService-->>Client: { applied }
end
rect rgba(150, 200, 100, 0.5)
Note over Client,Mongoose: Expiration Sweep Flow
Client->>ExtraService: expireOldEntries(orgId)
ExtraService->>Repository: addExpirationEntries(orgId, now)
Repository->>Mongoose: fetch topups without expire entries
Mongoose-->>Repository: [expired topup docs]
Repository->>Mongoose: findOneAndUpdate $push expiration entries
Mongoose-->>Repository: count added
Repository-->>ExtraService: count
ExtraService-->>Client: count
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
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. Review rate limit: 0/1 reviews remaining, refill in 23 minutes and 42 seconds.Comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #3536 +/- ##
==========================================
+ Coverage 87.01% 87.33% +0.31%
==========================================
Files 120 125 +5
Lines 3050 3355 +305
Branches 853 962 +109
==========================================
+ Hits 2654 2930 +276
- Misses 315 336 +21
- Partials 81 89 +8 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds the PR-N2 “compute pricing” building blocks to the billing module: weekly meter attribution, an extras-balance ledger, and reset/idempotency/retry helpers, all gated behind config.billing.meterMode.
Changes:
- Introduces meter attribution services (
billing.meter.service.js,billing.usage.service.js) including ISO-week metering and 80%/100% threshold marking. - Adds extras balance persistence via a ledger-based Mongoose model + repository + service (
BillingExtraBalance). - Adds weekly reset logic (
billing.reset.service.js) and E11000-safe version bump retries for plans (bumpVersionWithRetry).
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/billing/services/billing.usage.service.js | Adds ISO-week metering flow, quota snapshot, overflow computation, and threshold notification logic. |
| modules/billing/repositories/billing.usage.repository.js | Adds findByWeek and atomic incrementMeter with idempotency guard. |
| modules/billing/services/billing.reset.service.js | New weekly reset service (archive + upsert) and a sweep helper (resetAllDue). |
| modules/billing/services/billing.meter.service.js | New service converting costs→units and attributing usage (with extras fallback). |
| modules/billing/services/billing.extra.service.js | New extras pack credit/debit/expiration/refundPartial/ledger listing orchestration. |
| modules/billing/services/billing.plan.service.js | Adds bumpVersionWithRetry for E11000 races with backoff. |
| modules/billing/repositories/billing.extraBalance.repository.js | New repository implementing atomic idempotent credit/debit and expiration sweep helpers. |
| modules/billing/models/billing.extraBalance.model.mongoose.js | New Mongoose model for extras ledger + cached balance + idempotency indexes. |
| modules/billing/models/billing.extraBalance.schema.js | New Zod schemas for extras-balance documents and inputs. |
| modules/billing/tests/billing.usage.service.unit.tests.js | Unit tests for week key, metering flow, replay behavior, overflow, thresholds, and getMeter. |
| modules/billing/tests/billing.usage.repository.unit.tests.js | Unit tests for findByWeek and incrementMeter (idempotency + E11000 retry). |
| modules/billing/tests/billing.reset.service.unit.tests.js | Unit tests for ISO week key, resetWeek idempotency, and resetAllDue sweep. |
| modules/billing/tests/billing.plan.service.bumpretry.unit.tests.js | Unit tests covering E11000 retry behavior for bumpVersionWithRetry. |
| modules/billing/tests/billing.meter.service.unit.tests.js | Unit tests for costs→units conversion and meterMode gating. |
| modules/billing/tests/billing.extraBalance.unit.tests.js | Unit tests for extras Zod schema + repository idempotency behaviors. |
| modules/billing/tests/billing.extra.service.unit.tests.js | Unit tests for extras service behaviors (credit/debit/expire/list/refundPartial). |
… edge guards HIGH: Move all mongoose access out of the 3 target services into repositories. - extraBalance.repository: add refundPartial() atomic ledger write - usage.repository: add markThreshold(), archiveOtherWeeks(), upsertWeekSnapshot() - subscription.repository: add findAllDueForReset() (also fixes BillingSubscription->Subscription model name bug in resetAllDue) MEDIUM: refundPartial ambiguous-pack guard — return applied=false+reason when 0 or >1 packs share same meterUnits; TODO PR-N3 for packId from metadata. resetAllDue window query limitation documented with TODO PR-N5. LOW: Remove no-op sparse:true from LedgerEntry subdocument fields. Add z.refine(n !== 0) on LedgerEntry.amount to reject zero-amount entries. Tests: 749 passing (+14 new tests for new repo methods + edge cases).
- add ObjectId.isValid guard to all BillingExtraBalance repo methods - fix $setOnInsert to init alertedAt80/100 and meterBreakdown on upsert - fix month fallback: weekKey.slice produced invalid YYYY-W string - reconcile sign-convention docs: refund entries are negative clawbacks - update attribute() JSDoc: no MeterQuotaExhausted throw, best-effort - expand getBillingEvents JSDoc with async and @returns annotation - add regression tests for all above fixes
The Subscription model uses 'organization' (not 'organizationId'). Projection + service consumer were both broken, silently making resetAllDue a no-op (sub.organizationId always undefined). Caught by critical-review pass 2 on PR-N2.
There was a problem hiding this comment.
Actionable comments posted: 18
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@modules/billing/models/billing.extraBalance.model.mongoose.js`:
- Around line 38-41: Add a schema-level validator to the Mongoose schema that
rejects zero ledger amounts by updating the amount field definition (the
"amount" property on the ExtraBalance/extraBalance schema in
billing.extraBalance.model.mongoose.js) to include a validator that fails when
value === 0 and returns a clear error message; implement this as a `validate`
function or a custom validator option on the existing amount field so any
attempt to save an amount of 0 is rejected at the schema level.
In `@modules/billing/models/billing.extraBalance.schema.js`:
- Around line 20-40: LedgerEntry currently only rejects zero amounts but allows
wrong signs for different LedgerKind values; update the LedgerEntry schema to
add a refine check on the pair (kind, amount) that enforces sign rules (e.g.,
for kinds representing topup/adjustment require amount > 0, for kinds
representing debit/expiration/refund/refund-clawback require amount < 0) and
return a clear validation message; locate the LedgerEntry z.object and add a
refinement that reads kind and amount and uses the LedgerKind discriminants (the
LedgerEntry schema, the amount field, and the kind value) to validate sign
consistency.
In `@modules/billing/repositories/billing.extraBalance.repository.js`:
- Around line 56-57: Add repository-level guards in creditPack, debit, and
refundPartial to validate numeric amounts and non-empty idempotency/session
keys: at the top of each function (after the existing isValidOrgId check in
creditPack) verify that amount is a finite number > 0 and that the idempotency
key / stripeSessionId parameter is a non-empty string; if a check fails return
the same shape used for invalid orgs (e.g., { doc: null, applied: false }) and
optionally log the invalid input, mirroring the existing early-return style to
prevent any ledger mutation from invalid inputs.
In `@modules/billing/repositories/billing.usage.repository.js`:
- Around line 103-108: The code currently validates breakdown keys but not their
values, so non-numeric values can break the MongoDB $inc update; inside the loop
over Object.entries(breakdown) (see SAFE_KEY_RE, incPayload and meterBreakdown
references) validate each value before adding to incPayload by checking it is a
finite number (e.g., typeof value === 'number' && isFinite(value') or by
coercing with Number(value) and ensuring !Number.isNaN(result)); if the value is
invalid, skip that entry (or optionally log a warning) and only add numeric
values as incPayload[`meterBreakdown.${key}`] = numericValue.
In `@modules/billing/services/billing.extra.service.js`:
- Around line 115-116: The idempotency key refundRefId built as
`refund-${stripeSessionId}-${amountRefundedCents}` can collide for multiple
partial refunds of the same cents; change the key to include a truly unique
component (for example append a UUID or timestamp/sequence) so each refund
attempt is distinct—update the construction of refundRefId (referencing
refundRefId, stripeSessionId, amountRefundedCents) to
`refund-${stripeSessionId}-${amountRefundedCents}-${uniqueSuffix}` where
uniqueSuffix is generated via crypto.randomUUID() or an injected
refundAttemptId/paymentIntent/sequence, and add the necessary import or
parameter to supply that unique value.
- Around line 79-83: The code assumes
BillingExtraBalanceRepository.getOrCreate(orgId) always returns a doc and
dereferences doc.ledger in refundPartial and listLedger, which can crash if
getOrCreate returns null; update both functions to check the returned value (the
local doc variable from getOrCreate) before accessing doc.ledger, e.g., if
(!doc) handle the error/return early (throw a descriptive error or return a safe
default) so subsequent uses of doc.ledger are guarded; reference the call
BillingExtraBalanceRepository.getOrCreate and the variables doc and doc.ledger
in your changes.
In `@modules/billing/services/billing.meter.service.js`:
- Around line 107-113: The code currently sets extrasConsumed based solely on
result.extrasConsumed before calling BillingExtraService.debit, which can
misreport consumption if the debit call fails or returns applied:false; change
the flow so you call const debitResult = await
BillingExtraService.debit(organizationId, result.extrasConsumed, idempotencyKey)
when result.extrasConsumed > 0, then only set extrasConsumed =
result.extrasConsumed if debitResult.applied is true (otherwise leave
extrasConsumed = 0), and wrap the debit call in a try/catch so exceptions don’t
cause extras to be reported as consumed; refer to BillingExtraService.debit and
the local extrasConsumed variable to locate where to apply this logic.
In `@modules/billing/services/billing.plan.service.js`:
- Around line 142-159: Validate and normalize the maxAttempts option at the
start of bumpVersionWithRetry: ensure maxAttempts is a positive integer (e.g.,
coerce via Math.floor and enforce >=1) or throw a clear error if invalid, so the
retry loop always runs at least once; update the function signature handling for
bumpVersionWithRetry to perform this guard before using backoffMs and entering
the for-loop and ensure any downstream logic (delay index into backoffMs) uses a
safe fallback if attempt index exceeds the array.
In `@modules/billing/services/billing.reset.service.js`:
- Around line 40-45: Update the JSDoc for the resetWeek function to reflect that
it can return either the upserted usage object or null when meter mode is off by
changing the `@returns` description/type to include null (e.g.,
Promise<Object|null> or Promise<null|Object>) and update the text to mention the
null return path when config?.billing?.meterMode is falsy; locate the JSDoc
above the resetWeek declaration to make this change.
In `@modules/billing/services/billing.usage.service.js`:
- Around line 161-193: The threshold event is emitted even when
UsageRepository.markThreshold did not actually update the doc (no dedup),
causing duplicate events; modify the logic in the alert branches (the block
using updatedDoc.alertedAt100/alertedAt80 and calling
UsageRepository.markThreshold) to capture the result of markThreshold and only
call billingEvents.emit('meter.threshold_crossed', ...) if the repository call
indicates a successful write (e.g., returns an object with modifiedCount > 0 or
a truthy success flag); keep the existing try/catch for best-effort but skip
emission when the markThreshold result shows no modification.
In `@modules/billing/tests/billing.extra.service.unit.tests.js`:
- Around line 16-22: The helper function makeDoc is a named standalone utility
and needs a JSDoc header: add a JSDoc block above makeDoc that documents the
parameter "overrides" (type Object, optional, describe possible keys like _id,
organization, ledger, cachedBalance) and the return type (Object with those
fields), using `@param` and `@returns` tags and a short description; ensure the
JSDoc clearly states defaults used by makeDoc and that the function returns the
merged document object.
In `@modules/billing/tests/billing.extraBalance.unit.tests.js`:
- Around line 135-143: The helper function makeDoc is missing a JSDoc header;
add a JSDoc block above makeDoc that documents the parameter and return value
(e.g., `@param` {Object} overrides - optional fields to merge into the default doc
and `@returns` {Object} the constructed document with properties like _id,
organization, ledger, cachedBalance, cachedBalanceAt). Ensure the JSDoc
describes the optional nature of overrides and the shape of the returned object
to satisfy the repository rule that every function include `@param` and `@returns`.
In `@modules/billing/tests/billing.meter.service.unit.tests.js`:
- Around line 16-24: Add a JSDoc block above the makePlan function describing
the function, include an `@param` {Object} [overrides] annotation that documents
optional override fields (e.g., _id, planId, version, meterQuota, ratios,
active) and an `@returns` {Object} annotation that describes the returned plan
object shape (same fields as defaults). Keep the JSDoc concise and place it
immediately above the makePlan declaration so linters recognize the
documentation.
In `@modules/billing/tests/billing.plan.service.bumpretry.unit.tests.js`:
- Around line 13-23: The named test helper makeDoc lacks a JSDoc header; add a
JSDoc block above the makeDoc function that documents the overrides parameter
(object, optional) with `@param` and describes the returned document object with
`@returns`, briefly outlining key properties (e.g., _id, planId, version,
meterQuota, ratios, effectiveFrom, effectiveUntil, active) so the helper
complies with the project /**/*.js rule requiring `@param/`@returns on named
functions.
In `@modules/billing/tests/billing.reset.service.unit.tests.js`:
- Around line 18-34: Add JSDoc blocks above the named helper functions makePlan
and makeUsageDoc describing their purpose, the single optional parameter
(overrides: Object) and the structure of the returned object; include `@param`
{Object} overrides - optional properties to override defaults and `@returns`
{Object} describing the returned plan/usage document fields (e.g., planId,
version, meterQuota, active for makePlan; _id, organizationId, weekKey,
meterUsed, meterQuota, planVersion for makeUsageDoc) so the tests comply with
the project's JSDoc requirement.
- Around line 200-203: The test fixtures set organizationId but the code under
test (resetAllDue) reads sub.organization, so update the mocked records returned
by mockMongooseSubscription.findAllDueForReset (and the similar fixtures at
lines ~216-218) to include the organization property (e.g., organization:
'507f1f77bcf86cd799439011' and organization: '507f1f77bcf86cd799439022') so the
test exercises the real wiring and passes the actual organization through to the
resetAllDue call.
In `@modules/billing/tests/billing.usage.repository.unit.tests.js`:
- Around line 16-25: Add a JSDoc block above the named function makeUsageDoc
that documents the parameter and return value: include an `@param` {Object}
overrides description (optional fields merged into the usage document) and an
`@returns` {Object} description (the constructed usage document object with
defaults applied); ensure the JSDoc briefly describes the function purpose to
satisfy the repo's /** */ rule for .js files.
In `@modules/billing/tests/billing.usage.service.unit.tests.js`:
- Around line 17-37: Add full JSDoc blocks above the named test helper functions
makePlan and makeUsageDoc describing their purpose, the optional overrides
parameter (object) and the shape of the returned object (properties like planId,
version, meterQuota, active for makePlan; _id, organizationId, weekKey, month,
meterUsed, meterQuota, planVersion, alertedAt80, alertedAt100,
consumedHistoryIds for makeUsageDoc). Ensure the JSDoc tags (`@param` {Object}
[overrides], `@returns` {Object}) are present and accurate so these named helpers
satisfy the file's JSDoc requirement (anonymous test callbacks remain exempt).
🪄 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: 79962e28-48ef-4f8a-9c2c-5f0a7694b922
📒 Files selected for processing (17)
modules/billing/models/billing.extraBalance.model.mongoose.jsmodules/billing/models/billing.extraBalance.schema.jsmodules/billing/repositories/billing.extraBalance.repository.jsmodules/billing/repositories/billing.subscription.repository.jsmodules/billing/repositories/billing.usage.repository.jsmodules/billing/services/billing.extra.service.jsmodules/billing/services/billing.meter.service.jsmodules/billing/services/billing.plan.service.jsmodules/billing/services/billing.reset.service.jsmodules/billing/services/billing.usage.service.jsmodules/billing/tests/billing.extra.service.unit.tests.jsmodules/billing/tests/billing.extraBalance.unit.tests.jsmodules/billing/tests/billing.meter.service.unit.tests.jsmodules/billing/tests/billing.plan.service.bumpretry.unit.tests.jsmodules/billing/tests/billing.reset.service.unit.tests.jsmodules/billing/tests/billing.usage.repository.unit.tests.jsmodules/billing/tests/billing.usage.service.unit.tests.js
…ency, threshold gating - CRITICAL: null-guard getOrCreate in refundPartial + listLedger - Mongoose: validator rejecting zero ledger amounts - Zod: superRefine sign-by-kind (topup/adjustment > 0; debit/expiry/refund < 0) - Repository: input guards on creditPack/debit/refundPartial (amount > 0, non-empty key) - Repository: breakdown $inc filter (only Number.isFinite && > 0 entries) - Service: refund idempotency key includes topupEntry._id to avoid collision - Service: attribute() reports extrasConsumed=0 when debit returns applied=false - Service: threshold event emitted only when markThreshold modifiedCount > 0 - Service: bumpVersionWithRetry maxAttempts guard (must be positive integer) - JSDoc: resetWeek @returns includes null; all named test helpers documented - Tests: fixtures use sub.organization not organizationId; regression guard added - Tests: new tests for every added guard
Superseded by CodeRabbit APPROVED on 063a1c9 — all 18 threads addressed.
Summary
PR-N2 of the Compute Pricing layer (#3533). Builds the attribution + extras balance
services on top of PR-N1 plan versioning. All new code paths are gated behind
config.billing.meterMode = false(backward compat absolute — non-compute downstreamsare entirely unaffected).
New files
models/billing.extraBalance.model.mongoose.js— ledger-based extra balance doc(topup / debit / refund / expiration / adjustment). Idempotency indexes on
ledger.stripeSessionId,ledger.historyId,ledger.expiresAt.models/billing.extraBalance.schema.js— Zod mirror with full constraint symmetry.repositories/billing.extraBalance.repository.js— atomic Mongo ops:getOrCreate,creditPack(idempotent on stripeSessionId),debit(guarded bycachedBalance >= amount AND refId dedup),
addExpirationEntries(idempotent sweep),getBalance(cheap read).services/billing.extra.service.js—creditPack,debit,expireOldEntries,refundPartial(proportional, balance may go negative = economic reflection),listLedger(paginated, newest-first).services/billing.meter.service.js—unitsFromCosts(frozen plan ratio lookup,dollarsToUnitRatio, METER_RUN_BASE floor),
attribute(idempotent on history._id).services/billing.reset.service.js—resetWeek(archive-then-upsert, E11000-safe),resetAllDue(sweeps active subs by currentPeriodStart),isoWeekKeyhelper.Modified files
repositories/billing.usage.repository.js— addfindByWeek(lean read),incrementMeter(atomic upsert with $ne idempotency key, E11000 retry, $setOnInsertsnapshot).
services/billing.usage.service.js— addcurrentWeekKey(ISO 8601),incrementMeter(full flow: quota snapshot, overflow→extras, 80%/100% threshold events deduped per
cycle),
getMeter.services/billing.plan.service.js— addbumpVersionWithRetry(exp backoff100/300/900ms on E11000, max 3 attempts).
Architecture decisions
creditPackanddebituse$neguardsinside the
findOneAndUpdatefilter so the check + update is a single round-trip(no TOCTOU). No separate "does it exist?" read for the happy path.
alertedAt80/alertedAt100are set atomically viaupdateOnewith
{ alertedAt80: null }filter so only one pod wins the event emit per cycle.$ne weekKeyarchive pass + E11000 retry on upserthandles concurrent pod resets gracefully.
this is the correct economic reflection and matches the design intent.
Tests
79 new tests (739 total, baseline 660). All edge cases from the spec:
Test plan
npm run lint— cleannpm run test:unit— 739 passingRefs #3533
Summary by CodeRabbit
Release Notes