feat(credits): one-time trial grant so the ledger runs in production
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m58s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m25s

- Ledger accepts kind='grant' (a server-owned balance grant, not a payment).
- OPT_TRIAL_CREDITS=N grants N credits once per tenant (idempotent key
  trial:<tenant>) on balance/structure, so reserve -> settle is exercised
  before any customer purchase.
- sql/020 extends the kind constraint (re-runnable).
- Tests for the grant helper; full suite 1345 passed.
This commit is contained in:
2026-11-02 22:05:06 +01:00
parent 456e331d80
commit dade37ee05
8 changed files with 101 additions and 7 deletions
+6 -6
View File
@@ -1,7 +1,7 @@
import type { RepositorySql } from '../repositories/db';
import { withCreditTenant } from './credit-tenant-scope';
export type CreditOperation = 'verified_payment' | 'reserve' | 'settle' | 'release';
export type CreditOperation = 'verified_payment' | 'grant' | 'reserve' | 'settle' | 'release';
export interface CreditCommand {
tenantId: string;
idempotencyKey: string;
@@ -26,8 +26,8 @@ export class PostgresCreditLedger {
/** Read-only balance for the authenticated tenant. Never creates a row. */
async balance(tenantId: string): Promise<CreditBalance> {
if (typeof tenantId !== "string" || !tenantId.trim() || tenantId.length > 256) {
throw new Error("Invalid credit tenant");
if (typeof tenantId !== 'string' || !tenantId.trim() || tenantId.length > 256) {
throw new Error('Invalid credit tenant');
}
return withCreditTenant(this.sql, tenantId, async (tx) => {
const rows = await tx<{ available: string; reserved: string }[]>`
@@ -45,10 +45,10 @@ export class PostgresCreditLedger {
![tenantId, idempotencyKey, reference].every(
(value) => typeof value === 'string' && value.trim().length > 0,
) ||
!['verified_payment', 'reserve', 'settle', 'release'].includes(kind) ||
!['verified_payment', 'grant', 'reserve', 'settle', 'release'].includes(kind) ||
!Number.isSafeInteger(amount) ||
amount < 0 ||
((kind === 'verified_payment' || kind === 'reserve') && amount === 0) ||
((kind === 'verified_payment' || kind === 'grant' || kind === 'reserve') && amount === 0) ||
(kind === 'release' && amount !== 0)
)
throw new Error('Invalid credit command');
@@ -73,7 +73,7 @@ export class PostgresCreditLedger {
return { replay: true, balance: { available, reserved } };
}
if (kind === 'verified_payment') {
if (kind === 'verified_payment' || kind === 'grant') {
available += amount;
} else if (kind === 'reserve') {
if (available < amount) throw new Error('Insufficient available credits');
+40
View File
@@ -0,0 +1,40 @@
/**
* One-time trial credits.
*
* A new tenant can be granted a fixed, server-owned amount so the credit
* ledger (reserve → settle) works in production without a card charge. It is
* idempotent per tenant: the ledger's unique (tenant, idempotency_key) makes
* every repeat a replay, never a second grant. The amount is never read from a
* client; `OPT_TRIAL_CREDITS=0` (default) disables it entirely.
*/
export interface CreditCommandSink {
apply(command: {
tenantId: string;
idempotencyKey: string;
kind: 'grant';
reference: string;
amount: number;
}): Promise<unknown>;
}
export function readTrialCredits(env: NodeJS.ProcessEnv = process.env): number {
const parsed = Number(env.OPT_TRIAL_CREDITS ?? '0');
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 1_000_000) return 0;
return parsed;
}
export async function ensureTrialCredits(
ledger: CreditCommandSink,
tenantId: string,
amount: number,
): Promise<void> {
if (!Number.isSafeInteger(amount) || amount <= 0) return;
if (typeof tenantId !== 'string' || !tenantId.trim()) return;
await ledger.apply({
tenantId,
idempotencyKey: `trial:${tenantId}`,
kind: 'grant',
reference: `trial:${tenantId}`,
amount,
});
}