Skip to content

Commit c568a3c

Browse files
feat(billing): meter service, extras balance, weekly reset (PR-N2) (#3536)
* feat(billing): add meter service, extras balance, weekly reset (PR-N2) 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 * refactor(billing): apply critical-review fixes — repository pattern + 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). * fix(billing): objectId guards, sign-convention docs, $setOnInsert fields - 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 * fix(billing): add biome-ignore comment to isValidOrgId for Codacy false positive * fix(billing): correct organization field name in findAllDueForReset 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. * fix(billing): apply CodeRabbit pass 2 — guards, sign-by-kind, idempotency, 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
1 parent 0f2320d commit c568a3c

17 files changed

Lines changed: 3349 additions & 0 deletions
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
const Schema = mongoose.Schema;
7+
8+
/**
9+
* ExtraBalance Data Model Mongoose
10+
*
11+
* Tracks prepaid "extra" meter units per organization.
12+
* Each org has at most one ExtraBalance document (unique on organization).
13+
*
14+
* The ledger array is an append-only audit trail of all balance mutations.
15+
* cachedBalance is kept in sync on every atomic write to avoid scanning
16+
* the entire ledger on hot-path reads.
17+
*
18+
* NOTE — Mixed type caveats (applies to embedded objects in subdocuments):
19+
* Mongoose validators are NOT executed for in-place mutations on Mixed fields
20+
* (doc.field.x = y; doc.save() silently skips validators).
21+
* Always use atomic MongoDB operators ($inc, $push, $set via findOneAndUpdate)
22+
* or Model.create() which runs validators on the full document.
23+
*/
24+
const LedgerEntrySchema = new Schema(
25+
{
26+
kind: {
27+
type: String,
28+
enum: ['topup', 'debit', 'refund', 'expiration', 'adjustment'],
29+
required: true,
30+
},
31+
/**
32+
* Signed amount in meter units.
33+
* Positive for topup/adjustment (add to balance).
34+
* Negative for debit/expiration/refund (subtract from balance).
35+
* Note: 'refund' entries are clawbacks and carry a negative amount,
36+
* reflecting the economic debt when credits already consumed must be reclaimed.
37+
*/
38+
amount: {
39+
type: Number,
40+
required: true,
41+
validate: {
42+
validator: (v) => v !== 0,
43+
message: 'Ledger entry amount cannot be zero',
44+
},
45+
},
46+
/**
47+
* Stripe checkout session ID — used for topup idempotency.
48+
* Only set for kind='topup'.
49+
*/
50+
stripeSessionId: {
51+
type: String,
52+
},
53+
/**
54+
* ObjectId reference to the History document that triggered a debit.
55+
* Only set for kind='debit'.
56+
*/
57+
historyId: {
58+
type: Schema.ObjectId,
59+
},
60+
/**
61+
* Generic external reference string.
62+
* Used for: debit idempotency key, expiration ref ('expire-<entryId>'),
63+
* or adjustment memo.
64+
*/
65+
refId: {
66+
type: String,
67+
},
68+
at: {
69+
type: Date,
70+
default: Date.now,
71+
},
72+
/**
73+
* Expiry date for topup entries.
74+
* Only set on kind='topup' when the pack has a finite lifespan.
75+
*/
76+
expiresAt: {
77+
type: Date,
78+
},
79+
},
80+
{ _id: true },
81+
);
82+
83+
const ExtraBalanceMongoose = new Schema(
84+
{
85+
organization: {
86+
type: Schema.ObjectId,
87+
ref: 'Organization',
88+
required: true,
89+
unique: true,
90+
},
91+
ledger: {
92+
type: [LedgerEntrySchema],
93+
default: () => [],
94+
},
95+
/**
96+
* Running total of available meter units.
97+
* Updated atomically on every write (via $inc) to avoid full ledger scans.
98+
* May temporarily diverge from sum(ledger.amount) during partial expiration
99+
* sweeps; addExpirationEntries + cachedBalance update is always atomic.
100+
*/
101+
cachedBalance: {
102+
type: Number,
103+
default: 0,
104+
},
105+
cachedBalanceAt: {
106+
type: Date,
107+
default: Date.now,
108+
},
109+
},
110+
{
111+
timestamps: true,
112+
},
113+
);
114+
115+
/**
116+
* Index for idempotent topup lookups — check if a stripeSessionId was already
117+
* applied before pushing a new ledger entry.
118+
*/
119+
ExtraBalanceMongoose.index({ 'ledger.stripeSessionId': 1 }, { sparse: true });
120+
121+
/**
122+
* Index for debit replay protection — check if a historyId was already debited.
123+
*/
124+
ExtraBalanceMongoose.index({ 'ledger.historyId': 1 }, { sparse: true });
125+
126+
/**
127+
* Index for expiration sweeps — find topup entries with expiresAt in the past.
128+
*/
129+
ExtraBalanceMongoose.index({ 'ledger.expiresAt': 1 }, { sparse: true });
130+
131+
/**
132+
* Returns the hex string representation of the document ObjectId.
133+
* @returns {string} Hex string of the ObjectId.
134+
*/
135+
function addID() {
136+
return this._id.toHexString();
137+
}
138+
139+
/**
140+
* Model configuration
141+
*/
142+
ExtraBalanceMongoose.virtual('id').get(addID);
143+
ExtraBalanceMongoose.set('toJSON', {
144+
virtuals: true,
145+
});
146+
147+
mongoose.model('BillingExtraBalance', ExtraBalanceMongoose);
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import { z } from 'zod';
5+
6+
/**
7+
* BillingExtraBalance Zod schema — mirrors billing.extraBalance.model.mongoose.js
8+
*/
9+
10+
const objectIdRegex = /^[a-f\d]{24}$/i;
11+
12+
/**
13+
* Ledger entry kinds enum — mirrors the Mongoose enum.
14+
*/
15+
const LedgerKind = z.enum(['topup', 'debit', 'refund', 'expiration', 'adjustment']);
16+
17+
/**
18+
* Single ledger entry schema.
19+
* Enforces:
20+
* - amount !== 0 (zero is always a bug)
21+
* - sign by kind: topup/adjustment must be > 0; debit/expiration/refund must be < 0
22+
*/
23+
const LedgerEntry = z
24+
.object({
25+
_id: z.string().trim().regex(objectIdRegex, '_id must be a valid ObjectId').optional(),
26+
kind: LedgerKind,
27+
/**
28+
* Signed amount in meter units.
29+
* Positive for topup/adjustment; negative for debit/expiration/refund.
30+
* 'refund' entries are clawbacks (negative) reflecting reclaimed units.
31+
* Zero is rejected as an operational guard (zero-amount entries are always a bug).
32+
*/
33+
amount: z.number().refine((n) => n !== 0, { message: 'Ledger entry amount must not be zero' }),
34+
stripeSessionId: z.string().trim().optional().nullable(),
35+
historyId: z
36+
.string()
37+
.trim()
38+
.regex(objectIdRegex, 'historyId must be a valid ObjectId')
39+
.optional()
40+
.nullable(),
41+
refId: z.string().trim().optional().nullable(),
42+
at: z.coerce.date().optional(),
43+
expiresAt: z.coerce.date().optional().nullable(),
44+
})
45+
.superRefine((entry, ctx) => {
46+
const { kind, amount } = entry;
47+
if (kind === 'topup' || kind === 'adjustment') {
48+
if (amount <= 0) {
49+
ctx.addIssue({
50+
code: z.ZodIssueCode.custom,
51+
message: `Ledger entry of kind '${kind}' must have a positive amount`,
52+
path: ['amount'],
53+
});
54+
}
55+
} else if (kind === 'debit' || kind === 'expiration' || kind === 'refund') {
56+
if (amount >= 0) {
57+
ctx.addIssue({
58+
code: z.ZodIssueCode.custom,
59+
message: `Ledger entry of kind '${kind}' must have a negative amount`,
60+
path: ['amount'],
61+
});
62+
}
63+
}
64+
});
65+
66+
/**
67+
* Full ExtraBalance document schema.
68+
*/
69+
const BillingExtraBalance = z.object({
70+
organization: z.string().trim().regex(objectIdRegex, 'organization must be a valid ObjectId'),
71+
ledger: z.array(LedgerEntry).default(() => []),
72+
cachedBalance: z.number().default(0),
73+
cachedBalanceAt: z.coerce.date().optional(),
74+
});
75+
76+
/**
77+
* Schema for creditPack input.
78+
*/
79+
const ExtraBalanceCreditPack = z.object({
80+
orgId: z.string().trim().regex(objectIdRegex, 'orgId must be a valid ObjectId'),
81+
amount: z.number().int().min(1, 'amount must be >= 1'),
82+
stripeSessionId: z.string().trim().min(1, 'stripeSessionId is required'),
83+
expiresAt: z.coerce.date().optional().nullable(),
84+
});
85+
86+
/**
87+
* Schema for debit input.
88+
*/
89+
const ExtraBalanceDebit = z.object({
90+
orgId: z.string().trim().regex(objectIdRegex, 'orgId must be a valid ObjectId'),
91+
amount: z.number().int().min(1, 'amount must be >= 1'),
92+
refId: z.string().trim().min(1, 'refId is required'),
93+
});
94+
95+
export default {
96+
LedgerKind,
97+
LedgerEntry,
98+
BillingExtraBalance,
99+
ExtraBalanceCreditPack,
100+
ExtraBalanceDebit,
101+
};

0 commit comments

Comments
 (0)