feat(hq): recurring plan repo and owner-gated routes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
c6a8058430
commit
405ee2917f
@ -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<Cadence, Kind> = { 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 })
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue