diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 06fa091..1e671f4 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -19,6 +19,10 @@ import { import { clientLedger, modulePaidView, recordPayment, type RecordPaymentInput, } from './repos-payments' +import { + createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, + updateRecurringPlan, type CreateRecurringInput, type RecurringPatch, +} from './repos-recurring' import { documentHtml } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' @@ -289,6 +293,38 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { })() }) + // ---------- recurring plans ---------- + r.get('/recurring', requireAuth, (req, res) => { + const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined + res.json({ ok: true, plans: listRecurringPlans(db, clientId) }) + }) + r.post('/clients/:id/recurring', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + try { + const plan = createRecurringPlan(db, staffId(res), { + ...(req.body as Omit), clientId: id, + }) + res.json({ ok: true, plan }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.patch('/recurring/:id', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } + try { + res.json({ ok: true, plan: updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/recurring/:id/deactivate', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } + res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) }) + }) + // ---------- payments ---------- r.post('/payments', requireAuth, (req, res) => { try { diff --git a/apps/hq/src/repos-recurring.ts b/apps/hq/src/repos-recurring.ts new file mode 100644 index 0000000..dfecaa2 --- /dev/null +++ b/apps/hq/src/repos-recurring.ts @@ -0,0 +1,101 @@ +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 function getRecurringPlan(db: DB, id: string): RecurringPlan | null { + const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined + return row === undefined ? null : toPlan(row) +} + +export function listRecurringPlans(db: DB, clientId?: string): RecurringPlan[] { + const rows = clientId === undefined + ? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[] + : db.prepare(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`).all(clientId) as RecurringRow[] + return rows.map(toPlan) +} + +export interface CreateRecurringInput { + clientId: string; clientModuleId: string; cadence: Cadence + amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual' +} + +export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + const cm = 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 (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() + db.prepare( + `INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active) + VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, + ).run( + id, input.clientId, input.clientModuleId, input.cadence, + input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual', + ) + const plan = getRecurringPlan(db, id)! + 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 function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan { + const before = 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) + db.prepare(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getRecurringPlan(db, id)! + writeAudit(db, userId, 'update', 'recurring_plan', id, before, after) + return after +} + +export function deactivateRecurringPlan(db: DB, userId: string, id: string): RecurringPlan { + return updateRecurringPlan(db, userId, id, { active: false }) +} diff --git a/apps/hq/test/recurring.test.ts b/apps/hq/test/recurring.test.ts new file mode 100644 index 0000000..19f753a --- /dev/null +++ b/apps/hq/test/recurring.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { assignModule, createModule, setPrice } from '../src/repos-modules' +import { + createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan, +} from '../src/repos-recurring' + +function setup() { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' }) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' }) + return { db, c, m, cm } +} + +describe('recurring plans', () => { + it('creates a plan, lists it, updates and deactivates with audit', () => { + const { db, c, cm } = setup() + const p = createRecurringPlan(db, 'u1', { + clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto', + }) + expect(p.policy).toBe('auto') + expect(p.amountPaise).toBeNull() // price resolves from the module at generation + expect(listRecurringPlans(db, c.id)).toHaveLength(1) + const up = updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 }) + expect(up.amountPaise).toBe(2_500_00) + const off = deactivateRecurringPlan(db, 'u1', p.id) + expect(off.active).toBe(false) + const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='recurring_plan'`).all() + expect(audits.length).toBe(3) // create + update + deactivate + }) + it('rejects a plan whose module has no price and no explicit amount', () => { + const db = openDb(':memory:') + const c = createClient(db, 'u1', { name: 'X', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + expect(() => createRecurringPlan(db, 'u1', { + clientId: c.id, clientModuleId: cm.id, cadence: 'yearly', nextRun: '2026-08-01', + })).toThrow(/price/i) + }) + it('rejects a client_module that belongs to another client', () => { + const { db, cm } = setup() + const other = createClient(db, 'u1', { name: 'Other', stateCode: '32' }) + expect(() => createRecurringPlan(db, 'u1', { + clientId: other.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', amountPaise: 100_00, + })).toThrow(/client/i) + }) +})