Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion modules/billing/models/billing.extraBalance.model.mongoose.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,21 @@ const LedgerEntrySchema = new Schema(
/**
* Generic external reference string.
* Used for: debit idempotency key, expiration ref ('expire-<entryId>'),
* or adjustment memo.
* adjustment memo, or grant idempotency key ('signup_grant-<orgId>').
*/
refId: {
type: String,
},
/**
* Credit source tag — discriminates pack purchases from grants.
* 'signup_grant' — one-shot free tier grant on org creation.
* 'adjustment' — manual ops credit (non-Stripe).
* Omitted for kind='topup' entries created by creditPack (Stripe path).
*/
source: {
type: String,
enum: ['signup_grant', 'adjustment'],
},
at: {
type: Date,
default: Date.now,
Expand Down Expand Up @@ -128,6 +138,16 @@ ExtraBalanceMongoose.index({ 'ledger.historyId': 1 }, { sparse: true });
*/
ExtraBalanceMongoose.index({ 'ledger.expiresAt': 1 }, { sparse: true });

/**
* Index for grant analytics + idempotency support.
* refId is the leading key for analytics and admin queries that filter grant entries by refId prefix.
* source is a trailing key for filtering entries by grant type (e.g. all signup_grant entries).
* Note: the creditGrant idempotency guard (`ledger.refId: {$ne: key}`) is an exclusion predicate
* scoped by the unique `organization` field — it does not use tight index bounds, but the sparse
* index still reduces the scan set to grant entries only.
*/
ExtraBalanceMongoose.index({ 'ledger.refId': 1, 'ledger.source': 1 }, { sparse: true });

/**
* Returns the hex string representation of the document ObjectId.
* @returns {string} Hex string of the ObjectId.
Expand Down
31 changes: 31 additions & 0 deletions modules/billing/models/billing.extraBalance.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ const objectIdRegex = /^[a-f\d]{24}$/i;
*/
const LedgerKind = z.enum(['topup', 'debit', 'refund', 'expiration', 'adjustment']);

/**
* Allowed grant sources — mirrors the Mongoose enum on LedgerEntrySchema.source.
* 'signup_grant' — one-shot free tier grant on org creation (kind='topup').
* 'adjustment' — reserved for future non-Stripe manual credits.
* NOTE: 'adjustment' here is a source tag (provenance), distinct from
* LedgerKind 'adjustment' (balance mutation type). Existing creditCompensation()
* writes kind='adjustment' entries WITHOUT setting source — source is only set by
* creditGrant() and future grant methods. Do not assume kind='adjustment' implies
* source='adjustment'.
*/
const GrantSource = z.enum(['signup_grant', 'adjustment']);

/**
* Single ledger entry schema.
* Enforces:
Expand All @@ -39,6 +51,12 @@ const LedgerEntry = z
.optional()
.nullable(),
refId: z.string().trim().optional().nullable(),
/**
* Credit source tag — set only by creditGrant() (and future grant methods).
* Absent on Stripe topup entries (creditPack) and creditCompensation entries.
* Mirrors LedgerEntrySchema.source in billing.extraBalance.model.mongoose.js.
*/
source: GrantSource.optional().nullable(),
at: z.coerce.date().optional(),
expiresAt: z.coerce.date().optional().nullable(),
})
Expand Down Expand Up @@ -92,10 +110,23 @@ const ExtraBalanceDebit = z.object({
refId: z.string().trim().min(1, 'refId is required'),
});

/**
* Schema for creditGrant input.
* Unlike creditPack, no stripeSessionId is required — idempotency is
* derived from `source + orgId` (synthetic key stored as refId).
*/
const ExtraBalanceCreditGrant = z.object({
orgId: z.string().trim().regex(objectIdRegex, 'orgId must be a valid ObjectId'),
amount: z.number().int().min(1, 'amount must be >= 1'),
source: GrantSource,
});

export default {
LedgerKind,
GrantSource,
LedgerEntry,
BillingExtraBalance,
ExtraBalanceCreditPack,
ExtraBalanceDebit,
ExtraBalanceCreditGrant,
};
56 changes: 56 additions & 0 deletions modules/billing/repositories/billing.extraBalance.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import mongoose from 'mongoose';
import AppError from '../../../lib/helpers/AppError.js';
import BillingExtraBalanceSchema from '../models/billing.extraBalance.schema.js';

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

/**
* @function creditGrant
* @description Atomically credit extra meter units for a non-Stripe grant (e.g. signup free tier).
* Idempotent: if a ledger entry with the same synthetic refId
* (`<source>-<orgId>`) already exists, the update is a no-op and
* applied=false is returned.
* 2-step pattern aligned with creditPack:
* Step 1 — ensure doc exists (atomic getOrCreate, no-op on replay).
* Step 2 — idempotency-guarded credit (no upsert).
* Synthetic idempotency key: `<source>-<orgId>` stored as ledger.refId.
* No stripeSessionId required.
* @param {string} orgId - The organization ObjectId (string).
* @param {number} amount - Meter units to credit (must be > 0).
* @param {string} source - Grant source tag (e.g. 'signup_grant').
* @returns {Promise<{doc: Object|null, applied: boolean, reason?: string}>}
*/
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
const creditGrant = async (orgId, amount, source) => {
if (!isValidOrgId(orgId)) return { doc: null, applied: false };
if (!Number.isFinite(amount) || amount <= 0) throw new Error('invalid argument: amount must be a positive finite number');
if (typeof source !== 'string' || source.trim() === '') throw new Error('invalid argument: source must be a non-empty string');
// Validate source against the enum before writing — findOneAndUpdate does not run validators.
BillingExtraBalanceSchema.ExtraBalanceCreditGrant.parse({ orgId, amount, source: source.trim() });

const idempotencyKey = `${source.trim()}-${orgId}`;
const entry = {
kind: 'topup',
amount,
source: source.trim(),
refId: idempotencyKey,
at: new Date(),
Comment thread
PierreBrisorgueil marked this conversation as resolved.
};

// Step 1: ensure the document exists (atomic getOrCreate, no-op if already present).
await getOrCreate(orgId);

// Step 2: idempotency-guarded credit (no upsert — doc is guaranteed to exist after step 1).
const doc = await BillingExtraBalance().findOneAndUpdate(
{
organization: orgId,
'ledger.refId': { $ne: idempotencyKey },
},
{
$push: { ledger: entry },
$inc: { cachedBalance: amount },
$set: { cachedBalanceAt: new Date() },
},
{ returnDocument: 'after' },
);

if (doc) return { doc, applied: true };
return { doc: null, applied: false, reason: 'duplicate_grant' };
};

/**
* @function creditCompensation
* @description Atomically push a positive 'adjustment' ledger entry for dispute/ops compensation.
Expand Down Expand Up @@ -482,6 +537,7 @@ const sumDebitsByWindow = async (orgId, windowStart, windowEnd) => {
export default {
getOrCreate,
creditPack,
creditGrant,
creditCompensation,
debit,
addExpirationEntries,
Expand Down
179 changes: 179 additions & 0 deletions modules/billing/tests/billing.extraBalance.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -777,5 +777,184 @@ describe('BillingExtraBalance unit tests:', () => {
).rejects.toThrow('invalid argument: refId must be a non-empty string');
});
});

// ─────────────────────────────────────────────────────────────────────────────
// creditGrant — signup grant (no stripeSessionId)
// ─────────────────────────────────────────────────────────────────────────────
describe('creditGrant:', () => {
test('should apply grant with topup kind and return applied:true', async () => {
const updatedDoc = makeDoc({
cachedBalance: 500,
ledger: [{ kind: 'topup', amount: 500, refId: 'signup_grant-507f1f77bcf86cd799439011' }],
});
// Step 1: getOrCreate; Step 2: idempotency-guarded credit
mockModel.findOneAndUpdate
.mockResolvedValueOnce(makeDoc())
.mockResolvedValueOnce(updatedDoc);

const result = await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');

expect(result.applied).toBe(true);
expect(result.doc.cachedBalance).toBe(500);
expect(mockModel.findOneAndUpdate).toHaveBeenCalledTimes(2);
});

test('should return applied:false with reason duplicate_grant when idempotency key already used', async () => {
// Step 1: getOrCreate; Step 2: idempotency filter excludes → null (already credited)
mockModel.findOneAndUpdate
.mockResolvedValueOnce(makeDoc())
.mockResolvedValueOnce(null);

const result = await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');

expect(result.applied).toBe(false);
expect(result.reason).toBe('duplicate_grant');
expect(result.doc).toBeNull();
});

test('step 2 filter uses refId derived from source+orgId for idempotency', async () => {
const updatedDoc = makeDoc({ cachedBalance: 500 });
let step2Filter;
mockModel.findOneAndUpdate
.mockResolvedValueOnce(makeDoc())
.mockImplementation((filter) => {
step2Filter = filter;
return Promise.resolve(updatedDoc);
});

await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');

expect(step2Filter).toMatchObject({
organization: orgId,
'ledger.refId': { $ne: `signup_grant-${orgId}` },
});
});

test('step 1 issues upsert getOrCreate (fresh org support)', async () => {
let step1Options;
let step1Update;
const updatedDoc = makeDoc({ cachedBalance: 500 });
mockModel.findOneAndUpdate.mockImplementation((filter, update, options) => {
if (!step1Options) {
step1Options = options;
step1Update = update;
return Promise.resolve(null);
}
return Promise.resolve(updatedDoc);
});

await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');

expect(step1Options?.upsert).toBe(true);
expect(step1Update.$setOnInsert).toMatchObject({ organization: orgId, ledger: [], cachedBalance: 0 });
});

test('step 2 does NOT include upsert (doc guaranteed by step 1)', async () => {
let step2Options;
mockModel.findOneAndUpdate
.mockResolvedValueOnce(makeDoc())
.mockImplementation((filter, update, options) => {
step2Options = options;
return Promise.resolve(makeDoc({ cachedBalance: 500 }));
});

await BillingExtraBalanceRepository.creditGrant(orgId, 500, 'signup_grant');

expect(step2Options?.upsert).toBeFalsy();
});

test('should throw on zero amount', async () => {
await expect(
BillingExtraBalanceRepository.creditGrant(orgId, 0, 'signup_grant'),
).rejects.toThrow('invalid argument: amount must be a positive finite number');
});

test('should throw on negative amount', async () => {
await expect(
BillingExtraBalanceRepository.creditGrant(orgId, -100, 'signup_grant'),
).rejects.toThrow('invalid argument: amount must be a positive finite number');
});

test('should throw on empty source', async () => {
await expect(
BillingExtraBalanceRepository.creditGrant(orgId, 500, ''),
).rejects.toThrow('invalid argument: source must be a non-empty string');
});

test('should throw (Zod) on source value not in allowed enum', async () => {
await expect(
BillingExtraBalanceRepository.creditGrant(orgId, 500, 'freebie'),
).rejects.toThrow();
expect(mockModel.findOneAndUpdate).not.toHaveBeenCalled();
});

test('should return null doc with applied:false for invalid orgId', async () => {
const { default: mongoose } = await import('mongoose');
mongoose.Types.ObjectId.isValid = jest.fn(() => false);

const result = await BillingExtraBalanceRepository.creditGrant('bad-id', 500, 'signup_grant');
expect(result).toEqual({ doc: null, applied: false });
expect(mockModel.findOneAndUpdate).not.toHaveBeenCalled();
});
});
});
});

// ─── ExtraBalanceCreditGrant schema tests ───────────────────────────────────────
describe('ExtraBalanceCreditGrant schema:', () => {
let schema;

beforeEach(async () => {
jest.resetModules();
const mod = await import('../models/billing.extraBalance.schema.js');
schema = mod.default;
});

test('should be valid without stripeSessionId', () => {
const result = schema.ExtraBalanceCreditGrant.safeParse({
orgId: '507f1f77bcf86cd799439011',
amount: 500,
source: 'signup_grant',
});
expect(result.error).toBeFalsy();
expect(result.data.source).toBe('signup_grant');
});

test('should accept valid source values', () => {
for (const source of ['signup_grant', 'adjustment']) {
const result = schema.ExtraBalanceCreditGrant.safeParse({
orgId: '507f1f77bcf86cd799439011',
amount: 500,
source,
});
expect(result.error).toBeFalsy();
}
});

test('should reject amount of 0', () => {
const result = schema.ExtraBalanceCreditGrant.safeParse({
orgId: '507f1f77bcf86cd799439011',
amount: 0,
source: 'signup_grant',
});
expect(result.error).toBeDefined();
});

test('should reject invalid orgId', () => {
const result = schema.ExtraBalanceCreditGrant.safeParse({
orgId: 'not-valid',
amount: 500,
source: 'signup_grant',
});
expect(result.error).toBeDefined();
});

test('should reject unknown source value', () => {
const result = schema.ExtraBalanceCreditGrant.safeParse({
orgId: '507f1f77bcf86cd799439011',
amount: 500,
source: 'freebie',
});
expect(result.error).toBeDefined();
});
});
Loading