Skip to content

Commit 41e7bd9

Browse files
feat(billing): add creditGrant idempotent method + source field on entries (#3662)
* feat(billing): add creditGrant idempotent method + source field on entries Adds creditGrant(orgId, amount, source) to BillingExtraBalanceRepository — mirrors creditPack's 2-step pattern but uses a synthetic refId (source-orgId) for idempotency instead of stripeSessionId. Adds source enum field to LedgerEntrySchema + ExtraBalanceCreditGrant Zod schema for input validation. * fix(billing): address Copilot review — enum validation, accurate comments - creditGrant now validates source via Zod (ExtraBalanceCreditGrant.parse) before write — findOneAndUpdate does not run validators so the explicit parse is necessary - Add test for Zod throw on invalid source enum value - Fix index comment: $ne predicate does not use tight index bounds (noted actual benefit) - Fix GrantSource/LedgerEntry comments: creditCompensation does NOT set source; source is exclusively set by creditGrant and future grant methods
1 parent dd4ec25 commit 41e7bd9

4 files changed

Lines changed: 287 additions & 1 deletion

File tree

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,21 @@ const LedgerEntrySchema = new Schema(
6060
/**
6161
* Generic external reference string.
6262
* Used for: debit idempotency key, expiration ref ('expire-<entryId>'),
63-
* or adjustment memo.
63+
* adjustment memo, or grant idempotency key ('signup_grant-<orgId>').
6464
*/
6565
refId: {
6666
type: String,
6767
},
68+
/**
69+
* Credit source tag — discriminates pack purchases from grants.
70+
* 'signup_grant' — one-shot free tier grant on org creation.
71+
* 'adjustment' — manual ops credit (non-Stripe).
72+
* Omitted for kind='topup' entries created by creditPack (Stripe path).
73+
*/
74+
source: {
75+
type: String,
76+
enum: ['signup_grant', 'adjustment'],
77+
},
6878
at: {
6979
type: Date,
7080
default: Date.now,
@@ -128,6 +138,16 @@ ExtraBalanceMongoose.index({ 'ledger.historyId': 1 }, { sparse: true });
128138
*/
129139
ExtraBalanceMongoose.index({ 'ledger.expiresAt': 1 }, { sparse: true });
130140

141+
/**
142+
* Index for grant analytics + idempotency support.
143+
* refId is the leading key for analytics and admin queries that filter grant entries by refId prefix.
144+
* source is a trailing key for filtering entries by grant type (e.g. all signup_grant entries).
145+
* Note: the creditGrant idempotency guard (`ledger.refId: {$ne: key}`) is an exclusion predicate
146+
* scoped by the unique `organization` field — it does not use tight index bounds, but the sparse
147+
* index still reduces the scan set to grant entries only.
148+
*/
149+
ExtraBalanceMongoose.index({ 'ledger.refId': 1, 'ledger.source': 1 }, { sparse: true });
150+
131151
/**
132152
* Returns the hex string representation of the document ObjectId.
133153
* @returns {string} Hex string of the ObjectId.

modules/billing/models/billing.extraBalance.schema.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ const objectIdRegex = /^[a-f\d]{24}$/i;
1414
*/
1515
const LedgerKind = z.enum(['topup', 'debit', 'refund', 'expiration', 'adjustment']);
1616

17+
/**
18+
* Allowed grant sources — mirrors the Mongoose enum on LedgerEntrySchema.source.
19+
* 'signup_grant' — one-shot free tier grant on org creation (kind='topup').
20+
* 'adjustment' — reserved for future non-Stripe manual credits.
21+
* NOTE: 'adjustment' here is a source tag (provenance), distinct from
22+
* LedgerKind 'adjustment' (balance mutation type). Existing creditCompensation()
23+
* writes kind='adjustment' entries WITHOUT setting source — source is only set by
24+
* creditGrant() and future grant methods. Do not assume kind='adjustment' implies
25+
* source='adjustment'.
26+
*/
27+
const GrantSource = z.enum(['signup_grant', 'adjustment']);
28+
1729
/**
1830
* Single ledger entry schema.
1931
* Enforces:
@@ -39,6 +51,12 @@ const LedgerEntry = z
3951
.optional()
4052
.nullable(),
4153
refId: z.string().trim().optional().nullable(),
54+
/**
55+
* Credit source tag — set only by creditGrant() (and future grant methods).
56+
* Absent on Stripe topup entries (creditPack) and creditCompensation entries.
57+
* Mirrors LedgerEntrySchema.source in billing.extraBalance.model.mongoose.js.
58+
*/
59+
source: GrantSource.optional().nullable(),
4260
at: z.coerce.date().optional(),
4361
expiresAt: z.coerce.date().optional().nullable(),
4462
})
@@ -92,10 +110,23 @@ const ExtraBalanceDebit = z.object({
92110
refId: z.string().trim().min(1, 'refId is required'),
93111
});
94112

113+
/**
114+
* Schema for creditGrant input.
115+
* Unlike creditPack, no stripeSessionId is required — idempotency is
116+
* derived from `source + orgId` (synthetic key stored as refId).
117+
*/
118+
const ExtraBalanceCreditGrant = z.object({
119+
orgId: z.string().trim().regex(objectIdRegex, 'orgId must be a valid ObjectId'),
120+
amount: z.number().int().min(1, 'amount must be >= 1'),
121+
source: GrantSource,
122+
});
123+
95124
export default {
96125
LedgerKind,
126+
GrantSource,
97127
LedgerEntry,
98128
BillingExtraBalance,
99129
ExtraBalanceCreditPack,
100130
ExtraBalanceDebit,
131+
ExtraBalanceCreditGrant,
101132
};

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import mongoose from 'mongoose';
55
import AppError from '../../../lib/helpers/AppError.js';
6+
import BillingExtraBalanceSchema from '../models/billing.extraBalance.schema.js';
67

78
/**
89
* Validate that orgId is a syntactically valid MongoDB ObjectId.
@@ -162,6 +163,60 @@ const debit = async (orgId, amount, refId) => {
162163
return { doc: null, applied: false, reason: 'duplicate_step' };
163164
};
164165

166+
/**
167+
* @function creditGrant
168+
* @description Atomically credit extra meter units for a non-Stripe grant (e.g. signup free tier).
169+
* Idempotent: if a ledger entry with the same synthetic refId
170+
* (`<source>-<orgId>`) already exists, the update is a no-op and
171+
* applied=false is returned.
172+
* 2-step pattern aligned with creditPack:
173+
* Step 1 — ensure doc exists (atomic getOrCreate, no-op on replay).
174+
* Step 2 — idempotency-guarded credit (no upsert).
175+
* Synthetic idempotency key: `<source>-<orgId>` stored as ledger.refId.
176+
* No stripeSessionId required.
177+
* @param {string} orgId - The organization ObjectId (string).
178+
* @param {number} amount - Meter units to credit (must be > 0).
179+
* @param {string} source - Grant source tag (e.g. 'signup_grant').
180+
* @returns {Promise<{doc: Object|null, applied: boolean, reason?: string}>}
181+
*/
182+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
183+
const creditGrant = async (orgId, amount, source) => {
184+
if (!isValidOrgId(orgId)) return { doc: null, applied: false };
185+
if (!Number.isFinite(amount) || amount <= 0) throw new Error('invalid argument: amount must be a positive finite number');
186+
if (typeof source !== 'string' || source.trim() === '') throw new Error('invalid argument: source must be a non-empty string');
187+
// Validate source against the enum before writing — findOneAndUpdate does not run validators.
188+
BillingExtraBalanceSchema.ExtraBalanceCreditGrant.parse({ orgId, amount, source: source.trim() });
189+
190+
const idempotencyKey = `${source.trim()}-${orgId}`;
191+
const entry = {
192+
kind: 'topup',
193+
amount,
194+
source: source.trim(),
195+
refId: idempotencyKey,
196+
at: new Date(),
197+
};
198+
199+
// Step 1: ensure the document exists (atomic getOrCreate, no-op if already present).
200+
await getOrCreate(orgId);
201+
202+
// Step 2: idempotency-guarded credit (no upsert — doc is guaranteed to exist after step 1).
203+
const doc = await BillingExtraBalance().findOneAndUpdate(
204+
{
205+
organization: orgId,
206+
'ledger.refId': { $ne: idempotencyKey },
207+
},
208+
{
209+
$push: { ledger: entry },
210+
$inc: { cachedBalance: amount },
211+
$set: { cachedBalanceAt: new Date() },
212+
},
213+
{ returnDocument: 'after' },
214+
);
215+
216+
if (doc) return { doc, applied: true };
217+
return { doc: null, applied: false, reason: 'duplicate_grant' };
218+
};
219+
165220
/**
166221
* @function creditCompensation
167222
* @description Atomically push a positive 'adjustment' ledger entry for dispute/ops compensation.
@@ -482,6 +537,7 @@ const sumDebitsByWindow = async (orgId, windowStart, windowEnd) => {
482537
export default {
483538
getOrCreate,
484539
creditPack,
540+
creditGrant,
485541
creditCompensation,
486542
debit,
487543
addExpirationEntries,

modules/billing/tests/billing.extraBalance.unit.tests.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,5 +777,184 @@ describe('BillingExtraBalance unit tests:', () => {
777777
).rejects.toThrow('invalid argument: refId must be a non-empty string');
778778
});
779779
});
780+
781+
// ─────────────────────────────────────────────────────────────────────────────
782+
// creditGrant — signup grant (no stripeSessionId)
783+
// ─────────────────────────────────────────────────────────────────────────────
784+
describe('creditGrant:', () => {
785+
test('should apply grant with topup kind and return applied:true', async () => {
786+
const updatedDoc = makeDoc({
787+
cachedBalance: 500,
788+
ledger: [{ kind: 'topup', amount: 500, refId: 'signup_grant-507f1f77bcf86cd799439011' }],
789+
});
790+
// Step 1: getOrCreate; Step 2: idempotency-guarded credit
791+
mockModel.findOneAndUpdate
792+
.mockResolvedValueOnce(makeDoc())
793+
.mockResolvedValueOnce(updatedDoc);
794+
795+
const result = await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');
796+
797+
expect(result.applied).toBe(true);
798+
expect(result.doc.cachedBalance).toBe(500);
799+
expect(mockModel.findOneAndUpdate).toHaveBeenCalledTimes(2);
800+
});
801+
802+
test('should return applied:false with reason duplicate_grant when idempotency key already used', async () => {
803+
// Step 1: getOrCreate; Step 2: idempotency filter excludes → null (already credited)
804+
mockModel.findOneAndUpdate
805+
.mockResolvedValueOnce(makeDoc())
806+
.mockResolvedValueOnce(null);
807+
808+
const result = await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');
809+
810+
expect(result.applied).toBe(false);
811+
expect(result.reason).toBe('duplicate_grant');
812+
expect(result.doc).toBeNull();
813+
});
814+
815+
test('step 2 filter uses refId derived from source+orgId for idempotency', async () => {
816+
const updatedDoc = makeDoc({ cachedBalance: 500 });
817+
let step2Filter;
818+
mockModel.findOneAndUpdate
819+
.mockResolvedValueOnce(makeDoc())
820+
.mockImplementation((filter) => {
821+
step2Filter = filter;
822+
return Promise.resolve(updatedDoc);
823+
});
824+
825+
await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');
826+
827+
expect(step2Filter).toMatchObject({
828+
organization: orgId,
829+
'ledger.refId': { $ne: `signup_grant-${orgId}` },
830+
});
831+
});
832+
833+
test('step 1 issues upsert getOrCreate (fresh org support)', async () => {
834+
let step1Options;
835+
let step1Update;
836+
const updatedDoc = makeDoc({ cachedBalance: 500 });
837+
mockModel.findOneAndUpdate.mockImplementation((filter, update, options) => {
838+
if (!step1Options) {
839+
step1Options = options;
840+
step1Update = update;
841+
return Promise.resolve(null);
842+
}
843+
return Promise.resolve(updatedDoc);
844+
});
845+
846+
await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');
847+
848+
expect(step1Options?.upsert).toBe(true);
849+
expect(step1Update.$setOnInsert).toMatchObject({ organization: orgId, ledger: [], cachedBalance: 0 });
850+
});
851+
852+
test('step 2 does NOT include upsert (doc guaranteed by step 1)', async () => {
853+
let step2Options;
854+
mockModel.findOneAndUpdate
855+
.mockResolvedValueOnce(makeDoc())
856+
.mockImplementation((filter, update, options) => {
857+
step2Options = options;
858+
return Promise.resolve(makeDoc({ cachedBalance: 500 }));
859+
});
860+
861+
await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');
862+
863+
expect(step2Options?.upsert).toBeFalsy();
864+
});
865+
866+
test('should throw on zero amount', async () => {
867+
await expect(
868+
BillingExtraBalanceRepository.creditGrant(orgId, 0, 'signup_grant'),
869+
).rejects.toThrow('invalid argument: amount must be a positive finite number');
870+
});
871+
872+
test('should throw on negative amount', async () => {
873+
await expect(
874+
BillingExtraBalanceRepository.creditGrant(orgId, -100, 'signup_grant'),
875+
).rejects.toThrow('invalid argument: amount must be a positive finite number');
876+
});
877+
878+
test('should throw on empty source', async () => {
879+
await expect(
880+
BillingExtraBalanceRepository.creditGrant(orgId, 500, ''),
881+
).rejects.toThrow('invalid argument: source must be a non-empty string');
882+
});
883+
884+
test('should throw (Zod) on source value not in allowed enum', async () => {
885+
await expect(
886+
BillingExtraBalanceRepository.creditGrant(orgId, 500, 'freebie'),
887+
).rejects.toThrow();
888+
expect(mockModel.findOneAndUpdate).not.toHaveBeenCalled();
889+
});
890+
891+
test('should return null doc with applied:false for invalid orgId', async () => {
892+
const { default: mongoose } = await import('mongoose');
893+
mongoose.Types.ObjectId.isValid = jest.fn(() => false);
894+
895+
const result = await BillingExtraBalanceRepository.creditGrant('bad-id', 500, 'signup_grant');
896+
expect(result).toEqual({ doc: null, applied: false });
897+
expect(mockModel.findOneAndUpdate).not.toHaveBeenCalled();
898+
});
899+
});
900+
});
901+
});
902+
903+
// ─── ExtraBalanceCreditGrant schema tests ───────────────────────────────────────
904+
describe('ExtraBalanceCreditGrant schema:', () => {
905+
let schema;
906+
907+
beforeEach(async () => {
908+
jest.resetModules();
909+
const mod = await import('../models/billing.extraBalance.schema.js');
910+
schema = mod.default;
911+
});
912+
913+
test('should be valid without stripeSessionId', () => {
914+
const result = schema.ExtraBalanceCreditGrant.safeParse({
915+
orgId: '507f1f77bcf86cd799439011',
916+
amount: 500,
917+
source: 'signup_grant',
918+
});
919+
expect(result.error).toBeFalsy();
920+
expect(result.data.source).toBe('signup_grant');
921+
});
922+
923+
test('should accept valid source values', () => {
924+
for (const source of ['signup_grant', 'adjustment']) {
925+
const result = schema.ExtraBalanceCreditGrant.safeParse({
926+
orgId: '507f1f77bcf86cd799439011',
927+
amount: 500,
928+
source,
929+
});
930+
expect(result.error).toBeFalsy();
931+
}
932+
});
933+
934+
test('should reject amount of 0', () => {
935+
const result = schema.ExtraBalanceCreditGrant.safeParse({
936+
orgId: '507f1f77bcf86cd799439011',
937+
amount: 0,
938+
source: 'signup_grant',
939+
});
940+
expect(result.error).toBeDefined();
941+
});
942+
943+
test('should reject invalid orgId', () => {
944+
const result = schema.ExtraBalanceCreditGrant.safeParse({
945+
orgId: 'not-valid',
946+
amount: 500,
947+
source: 'signup_grant',
948+
});
949+
expect(result.error).toBeDefined();
950+
});
951+
952+
test('should reject unknown source value', () => {
953+
const result = schema.ExtraBalanceCreditGrant.safeParse({
954+
orgId: '507f1f77bcf86cd799439011',
955+
amount: 500,
956+
source: 'freebie',
957+
});
958+
expect(result.error).toBeDefined();
780959
});
781960
});

0 commit comments

Comments
 (0)