import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' import { getClient } from './repos-clients' import { getClientModule, priceOn, type Kind } from './repos-modules' /** Recurring billing plans — plain functions over the handle (D12 portable-repo pattern). */ export type Cadence = 'monthly' | 'yearly' export const CADENCE_KIND: Record = { monthly: 'monthly', yearly: 'yearly' } export interface RecurringPlan { id: string; clientId: string; clientModuleId: string | null; cadence: Cadence amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean } interface RecurringRow { id: string; client_id: string; client_module_id: string | null; cadence: string amount_paise: number | null; next_run: string; policy: string; active: number } function toPlan(r: RecurringRow): RecurringPlan { return { id: r.id, clientId: r.client_id, clientModuleId: r.client_module_id, cadence: r.cadence as Cadence, amountPaise: r.amount_paise, nextRun: r.next_run, policy: r.policy as 'auto' | 'manual', active: r.active === 1, } } export async function getRecurringPlan(db: DB, id: string): Promise { const row = await db.get(`SELECT * FROM recurring_plan WHERE id=?`, id) return row === undefined ? null : toPlan(row) } export async function listRecurringPlans(db: DB, clientId?: string): Promise { const rows = clientId === undefined ? await db.all(`SELECT * FROM recurring_plan ORDER BY id DESC`) : await db.all(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`, clientId) return rows.map(toPlan) } export interface CreateRecurringInput { clientId: string; clientModuleId: string; cadence: Cadence amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual' } export async function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): Promise { if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found') const cm = await getClientModule(db, input.clientModuleId) if (cm === null) throw new Error('Client module not found') if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client') if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) { throw new Error('amountPaise must be a non-negative integer (paise)') } // A plan must be priceable at generation time: explicit amount, or a resolvable module price. if (input.amountPaise === undefined) { const kind = CADENCE_KIND[input.cadence] if ((await priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun)) === null) { throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`) } } const id = uuidv7() await db.run( `INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active) VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, id, input.clientId, input.clientModuleId, input.cadence, input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual', ) const plan = (await getRecurringPlan(db, id))! await writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan) return plan } export interface RecurringPatch { cadence?: Cadence; amountPaise?: number | null; nextRun?: string policy?: 'auto' | 'manual'; active?: boolean } export async function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): Promise { const before = await getRecurringPlan(db, id) if (before === null) throw new Error('Recurring plan not found') const sets: string[] = [] const args: unknown[] = [] if (patch.cadence !== undefined) { sets.push('cadence=?'); args.push(patch.cadence) } if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) } if (patch.nextRun !== undefined) { sets.push('next_run=?'); args.push(patch.nextRun) } if (patch.policy !== undefined) { sets.push('policy=?'); args.push(patch.policy) } if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } if (sets.length > 0) { args.push(id) await db.run(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`, ...args) } const after = (await getRecurringPlan(db, id))! await writeAudit(db, userId, 'update', 'recurring_plan', id, before, after) return after } export async function deactivateRecurringPlan(db: DB, userId: string, id: string): Promise { return updateRecurringPlan(db, userId, id, { active: false }) }