/** * Per-counter, per-FY document series (02-ARCHITECTURE §3 rule 3): counters * number documents offline with zero coordination. GST caps a document number * at 16 chars from the set [A-Z a-z 0-9 - /]. */ export interface DocSeries { tenantId: string storeId: string counterId: string docType: string fy: string prefix: string nextSeq: number } const GST_DOC_NO = /^[A-Za-z0-9/-]{1,16}$/ export function assertGstDocNo(docNo: string): void { if (!GST_DOC_NO.test(docNo)) { throw new Error(`Document number "${docNo}" violates the GST 16-char [A-Za-z0-9/-] rule`) } } export function formatDocNo(prefix: string, seq: number, width = 6): string { const docNo = `${prefix}-${String(seq).padStart(width, '0')}` assertGstDocNo(docNo) return docNo } /** * The per-counter series prefix. It embeds the FULL 4-digit FY START YEAR (M9 collision fix): * a 2-digit marker made FY 2026-27 and 2126-27 share the prefix "ST1C2/26", so a century apart * their doc numbers collided on the bill's UNIQUE(tenant, doc_no) and billing on that prefix was * blocked. The 4-digit start year ("2026" vs "2126") cannot collide within a millennium. * * GST caps a doc number at 16 chars from [A-Za-z0-9/-]. At a 6-digit sequence, * store(3)+counter(2)+"/"+FY(4)+"-"+seq(6) = 17 would BREAK that rule — so the counter↔FY "/" * separator is dropped (the sequence "-" from formatDocNo stays for readability): * seriesPrefix("ST1", "C2", "2026-27") → "ST1C22026" ⇒ doc no "ST1C22026-000123" (16 chars). * This keeps the 16-char rule for store+counter codes totalling ≤ 5 chars (the demo ST1+C2). A * store with a 3-char counter code (C10+) plus a 4-digit FY would need a shorter store code — a * documented onboarding constraint, the same tightness the 2-digit format already had at C10. * * `fy` is the full FY label ("2026-27"); only its 4-digit start year is used. */ export function seriesPrefix(storeCode: string, counterCode: string, fy: string): string { return `${storeCode}${counterCode}${fy.slice(0, 4)}` } /** * Per-counter credit-note (SALE_RETURN) series prefix. A credit note lives in the same * document table as sales under the SAME UNIQUE(tenant, doc_no), so its number must never * collide with a sale's — the leading "CN" marker guarantees that (a sale never starts "CN"). * It reuses M9's 4-digit-FY scheme (store + counter + 4-digit FY start year) so credit notes * of FY 2026-27 and 2126-27 can't collide either. * * The "CN" marker costs two characters, so the sequence is 4 digits (`CREDIT_NOTE_SEQ_WIDTH`) * rather than the sale's 6, keeping the GST 16-char [A-Za-z0-9/-] rule at the demo's ST1+C2 * sizing: `creditNotePrefix("ST1","C2","2026-27")` → "CNST1C22026" ⇒ "CNST1C22026-0001" (16). * That caps a counter at 9,999 credit notes per FY — far beyond any real counter — and, as for * sales, needs store+counter codes to total ≤ 5 chars (a documented onboarding constraint). */ export function creditNotePrefix(storeCode: string, counterCode: string, fy: string): string { return `CN${storeCode}${counterCode}${fy.slice(0, 4)}` } /** Credit-note sequence width — 4 digits so the "CN"-marked doc no stays within 16 chars. */ export const CREDIT_NOTE_SEQ_WIDTH = 4 export function nextDocNo(series: DocSeries): { docNo: string; series: DocSeries } { const docNo = formatDocNo(series.prefix, series.nextSeq) return { docNo, series: { ...series, nextSeq: series.nextSeq + 1 } } } /** FY rollover starts a fresh series; the old one is retained for returns lookups. */ export function rolloverForFy(series: DocSeries, fy: string, prefix: string): DocSeries { if (fy === series.fy) return series return { ...series, fy, prefix, nextSeq: 1 } }