feat(credits): engångs trial-grant så ledgern körs skarpt #456

Merged
admin merged 1 commits from feat/credit-trial-grant into main 2026-11-02 21:17:39 +00:00
8 changed files with 101 additions and 7 deletions
+5
View File
@@ -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
+2
View File
@@ -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) {
+2
View File
@@ -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}`,
+5 -1
View File
@@ -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:<tenant>`),
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.
+16
View File
@@ -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 $$;
+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,
});
}
@@ -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);
});
});