From dade37ee05005a18844b4e72c05eaf726574d3bd Mon Sep 17 00:00:00 2001 From: sax3l Date: Mon, 2 Nov 2026 22:05:06 +0100 Subject: [PATCH] feat(credits): one-time trial grant so the ledger runs in production - 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:) 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. --- .env.example | 5 +++ app/api/0pt/credits/balance/route.ts | 2 + app/api/0pt/documents/structure/route.ts | 2 + docs/guides/document-workbench.md | 6 ++- sql/020_credit_grants.sql | 16 ++++++++ src/document-workbench/credit-ledger.ts | 12 +++--- src/document-workbench/trial-credits.ts | 40 +++++++++++++++++++ .../document-workbench/trial-credits.test.ts | 25 ++++++++++++ 8 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 sql/020_credit_grants.sql create mode 100644 src/document-workbench/trial-credits.ts create mode 100644 tests/document-workbench/trial-credits.test.ts diff --git a/.env.example b/.env.example index 7b34f61..ea25440 100644 --- a/.env.example +++ b/.env.example @@ -280,6 +280,11 @@ OPT_CREDIT_PRICE_BOOK= # purchases stay disabled. Unset/`on` requires the price book + ledger. OPT_CREDITS_MODE= +# One-time trial credits granted per tenant (server-owned, idempotent, NOT a +# payment). 0 disables. With OPT_CREDITS_MODE=on this lets reserve/settle run +# in production before any customer purchase. +OPT_TRIAL_CREDITS=0 + # Central Keycloak (SIAX Portal) KEYCLOAK_ISSUER=https://auth.portal.siax.dev/realms/portal KEYCLOAK_EXPECTED_AUDIENCE=account diff --git a/app/api/0pt/credits/balance/route.ts b/app/api/0pt/credits/balance/route.ts index 9cf6f3e..0d9a42c 100644 --- a/app/api/0pt/credits/balance/route.ts +++ b/app/api/0pt/credits/balance/route.ts @@ -24,6 +24,8 @@ export async function GET(request: NextRequest) { try { const ledger = new PostgresCreditLedger(getRepositorySql(url)); + const trial = readTrialCredits(); + if (trial > 0) await ensureTrialCredits(ledger, verified.scope.tenantId, trial); const balance = await ledger.balance(verified.scope.tenantId); return NextResponse.json({ tenant_id: verified.scope.tenantId, ...balance }); } catch (error: unknown) { diff --git a/app/api/0pt/documents/structure/route.ts b/app/api/0pt/documents/structure/route.ts index cfa50ba..35a96fb 100644 --- a/app/api/0pt/documents/structure/route.ts +++ b/app/api/0pt/documents/structure/route.ts @@ -3,6 +3,7 @@ import { Doc0SourceClient, Doc0SourceUnavailableError } from '@/src/document-wor import { Inf0OutcomeUnknownError } from '@/src/document-workbench/inf0-gateway-client'; import { createInf0Gateway } from '@/src/document-workbench/inf0-gateway'; import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger'; +import { ensureTrialCredits, readTrialCredits } from '@/src/document-workbench/trial-credits'; import { creditsForUsage, readCreditPriceBook } from '@/src/document-workbench/price-book'; import { structureDocument } from '@/src/document-workbench/structure-document'; import { exportValidatedOutput } from '@/src/document-workbench/export-validated-output'; @@ -279,6 +280,7 @@ export async function POST(request: NextRequest) { creditsEnabled && databaseUrl ? new PostgresCreditLedger(getRepositorySql(databaseUrl)) : undefined; if (ledger) { try { + await ensureTrialCredits(ledger, tenantId, readTrialCredits()); await ledger.apply({ tenantId, idempotencyKey: `reserve:${reservationId}`, diff --git a/docs/guides/document-workbench.md b/docs/guides/document-workbench.md index 3f79f43..67af159 100644 --- a/docs/guides/document-workbench.md +++ b/docs/guides/document-workbench.md @@ -37,7 +37,11 @@ is accepted by middleware as a verified principal (Bearer still wins). releasing it. 6. **No price is ever read from the client.** The price book is a versioned, server-owned JSON document (`OPT_CREDIT_PRICE_BOOK`). -7. **Pilot mode is explicit.** `OPT_CREDITS_MODE=off` skips reserve/settle and +7. **Trial credits are a grant, not a payment.** `OPT_TRIAL_CREDITS=N` grants N + credits once per tenant (`kind='grant'`, idempotency key `trial:`), + so the ledger is fully exercised before any real purchase. It is never read + from a client and never looks like a Stripe payment. +8. **Pilot mode is explicit.** `OPT_CREDITS_MODE=off` skips reserve/settle and reports `credits: null`. It is not a silent fallback and it does not enable purchases; unset/`on` requires the price book and the ledger. diff --git a/sql/020_credit_grants.sql b/sql/020_credit_grants.sql new file mode 100644 index 0000000..8656c3d --- /dev/null +++ b/sql/020_credit_grants.sql @@ -0,0 +1,16 @@ +-- Trial / grant credits: a server-owned, idempotent balance grant that is not +-- a payment. Extends the existing kind constraint; safe to re-run. +DO $$ +DECLARE cname text; +BEGIN + SELECT conname INTO cname + FROM pg_constraint + WHERE conrelid = 'opt_credit_events'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) LIKE '%kind%'; + IF cname IS NOT NULL THEN + EXECUTE format('ALTER TABLE opt_credit_events DROP CONSTRAINT %I', cname); + END IF; + ALTER TABLE opt_credit_events ADD CONSTRAINT opt_credit_events_kind_check + CHECK (kind IN ('verified_payment', 'grant', 'reserve', 'settle', 'release')); +END $$; diff --git a/src/document-workbench/credit-ledger.ts b/src/document-workbench/credit-ledger.ts index 59fee41..e6d3aa2 100644 --- a/src/document-workbench/credit-ledger.ts +++ b/src/document-workbench/credit-ledger.ts @@ -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 { - 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'); diff --git a/src/document-workbench/trial-credits.ts b/src/document-workbench/trial-credits.ts new file mode 100644 index 0000000..e245d29 --- /dev/null +++ b/src/document-workbench/trial-credits.ts @@ -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; +} + +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 { + 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, + }); +} diff --git a/tests/document-workbench/trial-credits.test.ts b/tests/document-workbench/trial-credits.test.ts new file mode 100644 index 0000000..5629083 --- /dev/null +++ b/tests/document-workbench/trial-credits.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ensureTrialCredits, readTrialCredits } from '../../src/document-workbench/trial-credits'; + +describe('trial credits', () => { + it('is disabled by default and rejects invalid amounts', () => { + expect(readTrialCredits({} as unknown as NodeJS.ProcessEnv)).toBe(0); + expect(readTrialCredits({ OPT_TRIAL_CREDITS: '-5' } as unknown as NodeJS.ProcessEnv)).toBe(0); + expect(readTrialCredits({ OPT_TRIAL_CREDITS: 'abc' } as unknown as NodeJS.ProcessEnv)).toBe(0); + expect(readTrialCredits({ OPT_TRIAL_CREDITS: '100' } as unknown as NodeJS.ProcessEnv)).toBe(100); + }); + + it('applies one idempotent grant and does nothing for zero', async () => { + const apply = vi.fn(async () => ({ replay: false })); + await ensureTrialCredits({ apply }, 't_0pt', 100); + expect(apply).toHaveBeenCalledWith({ + tenantId: 't_0pt', + idempotencyKey: 'trial:t_0pt', + kind: 'grant', + reference: 'trial:t_0pt', + amount: 100, + }); + await ensureTrialCredits({ apply }, 't_0pt', 0); + expect(apply).toHaveBeenCalledTimes(1); + }); +}); -- 2.54.0