import { formatDocNo } from '@sims/domain' import type { DB } from './db' export const TYPE_PREFIX: Record = { QUOTATION: 'QT', PROFORMA: 'PI', INVOICE: 'INV', RECEIPT: 'RCT', CREDIT_NOTE: 'CN', } export async function nextDocNo(db: DB, docType: string, fy: string): Promise { const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}` await db.run( // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). `INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 1) ON CONFLICT (doc_type, fy) DO NOTHING`, docType, fy, prefix, ) // D24 (red-team): allocate the number in ONE atomic UPDATE ... RETURNING. This takes a // row lock, so concurrent issuers serialize on the row and can never read the same seq // twice (the old read-then-bump raced → duplicate numbers / burned sequence on Postgres). // Both engines support UPDATE ... RETURNING (SQLite ≥3.35, node pg). Runs inside the // caller's issueDocument transaction. const row = (await db.get<{ prefix: string; seq: number }>( `UPDATE doc_series SET next_seq = next_seq + 1 WHERE doc_type=? AND fy=? RETURNING prefix, next_seq - 1 AS seq`, docType, fy, ))! return formatDocNo(row.prefix, row.seq, 4) } /** Mid-FY cutover (spec §4): continue after the last APEX-issued number. */ export async function seedSeries(db: DB, docType: string, fy: string, lastUsedSeq: number): Promise { const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}` await db.run( // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). `INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, ?) ON CONFLICT (doc_type, fy) DO UPDATE SET next_seq = excluded.next_seq`, docType, fy, prefix, lastUsedSeq + 1, ) }