feat(credits): engångs trial-grant så ledgern körs skarpt #456
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 $$;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user