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.
101 lines
4.6 KiB
TypeScript
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 })
|
|
}
|