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
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:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user