You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-recurring.ts

101 lines
4.6 KiB
TypeScript

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 async function getRecurringPlan(db: DB, id: string): Promise<RecurringPlan | null> {
const row = await db.get<RecurringRow>(`SELECT * FROM recurring_plan WHERE id=?`, id)
return row === undefined ? null : toPlan(row)
}
export async function listRecurringPlans(db: DB, clientId?: string): Promise<RecurringPlan[]> {
const rows = clientId === undefined
? await db.all<RecurringRow>(`SELECT * FROM recurring_plan ORDER BY id DESC`)
: await db.all<RecurringRow>(`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<RecurringPlan> {
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<RecurringPlan> {
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<RecurringPlan> {
return updateRecurringPlan(db, userId, id, { active: false })
}