import Big from "big.js"; /** * What counts as a valid discount amount, for the UI and the API alike. * * `Disc` is always the computed monetary discount and `RecDisc` is the * record-level discount already pro-rated onto a line, so a negative value in * either increases what a sale customer is charged. A return reverses its original discounts with nonpositive amounts. This * module decides that question once, so the Angular client and the Node API * cannot disagree about whether a given value is acceptable. * * Deliberately not in scope: whether a discount is *authorized*, whether `Disc` * agrees with `Perc`, whether a line's reference price was inflated, and * reconciling document totals. Those need a trusted basis and stored policy, * neither of which belongs in a pure function. * * Sharing the implementation removes drift between the two callers. It does not * make the client trustworthy: an installed client can be an old version or be * bypassed entirely, so the API remains the authority and must run these checks * at its own write boundary regardless of what the client did. * * Returns findings rather than throwing. The caller decides what a finding * means -- an HTTP 400 on the server, a red field on the client -- and the * stable `Code` lets the UI phrase its own message without depending on server * wording. */ export const DiscountIssueCodes = Object.freeze({ /** The exact decimal value is below zero. */ NEGATIVE_AMOUNT: "NEGATIVE_AMOUNT", /** Not a number at all, or not a finite one: NaN, Infinity, text, an object. */ INVALID_NUMBER: "INVALID_NUMBER", } as const); export type DiscountIssueCode = typeof DiscountIssueCodes[keyof typeof DiscountIssueCodes]; export interface DiscountIssue { /** Where the offending value sits, e.g. `Items[3].RecDisc`. */ readonly Field: string; readonly Code: DiscountIssueCode; /** Diagnostic text for logs and server messages. The UI should use `Code`. */ readonly Detail: string; } /** * The shared shape these checks read. * * Declared here as plain data on purpose. The package must not know about * Mongoose documents or about any one endpoint's request envelope: a caller * passes something structurally like this, and gets findings back. */ export interface DiscountLineInput { readonly Disc?: unknown; readonly RecDisc?: unknown; } export interface InvoiceDiscountInput { readonly Disc?: unknown; readonly PDisc?: unknown; readonly LDisc?: unknown; readonly Items?: unknown; readonly Ops?: unknown; } export interface SalesReceiptDiscountInput { readonly Disc?: unknown; readonly Items?: unknown; } /** Discount amounts carried by one line. */ const LineDiscountFields = Object.freeze(["Disc", "RecDisc"] as const); /** * Header discount amounts, per document family. An invoice carries an overall, a * parts and a labour discount; a sales receipt carries only the overall one. */ const InvoiceHeaderDiscountFields = Object.freeze(["Disc", "PDisc", "LDisc"] as const); const SalesReceiptHeaderDiscountFields = Object.freeze(["Disc"] as const); /** * A finite decimal with an optional sign, and nothing else. * * Exponent notation is refused rather than parsed. No client composes a money * amount that way, and accepting it widens what has to be reasoned about for no * gain. */ const DecimalText = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/u; function Describe(value: unknown): string { if (Array.isArray(value)) { return "an array"; } if (typeof value === "string") { return `the string ${JSON.stringify(value)}`; } if (value === null) { return "null"; } if (typeof value === "object") { return "an object"; } return `a ${typeof value}`; } function Issue(field: string, code: DiscountIssueCode, detail: string): DiscountIssue { return { Field: field, Code: code, Detail: detail }; } /** * Checks one discount amount, returning a finding or `null`. * * An absent value is not a finding: on a patch an omitted amount keeps whatever * is stored, and on a create it means no discount. * * A numeric string is accepted because clients do send them and Mongoose casts * one to a number on the way in. The parse is strict rather than `Number()`, * which reads `""` and `" "` as zero -- a forgiving fallback would turn * invalid input into "no discount" instead of reporting it. * * The sign is taken from the exact decimal value through `Big`, never from the * converted double. `Number("-0.000…001")` with enough zeros underflows to `-0`, * and `-0 < 0` is false, so a float comparison would pass a value whose written * form is negative. `"-0"` and `"-0.0"` are accepted, because their exact value * is zero. */ export function CheckDiscountAmount(value: unknown, field: string): DiscountIssue | null { if (value === undefined || value === null) { return null; } if (typeof value === "number") { if (!Number.isFinite(value)) { return Issue(field, DiscountIssueCodes.INVALID_NUMBER, `not a finite number (${String(value)})`); } // -0 is exactly zero, so it is not negative; Big agrees, and this keeps the // number and string paths deciding the same way. if (new Big(value).lt(0)) { return Issue(field, DiscountIssueCodes.NEGATIVE_AMOUNT, `negative (${value})`); } return null; } if (typeof value !== "string" || !DecimalText.test(value.trim())) { return Issue(field, DiscountIssueCodes.INVALID_NUMBER, `expected a number, received ${Describe(value)}`); } const text = value.trim(); // Big rejects a leading "+", which the format above accepts and which is an // unambiguously positive amount. Stripping it keeps the pattern the single // authority on format and Big the single authority on value. const exactText = text.startsWith("+") ? text.slice(1) : text; let exact: Big; try { exact = new Big(exactText); } catch { return Issue(field, DiscountIssueCodes.INVALID_NUMBER, `expected a number, received ${Describe(value)}`); } if (exact.lt(0)) { return Issue(field, DiscountIssueCodes.NEGATIVE_AMOUNT, `negative (${text})`); } if (!Number.isFinite(Number(text))) { // Exact but unrepresentable: a 400-digit integer is not a money amount. return Issue(field, DiscountIssueCodes.INVALID_NUMBER, `not a finite number (${text})`); } return null; } /** Convenience for a UI field check, where only valid/invalid matters. */ export function IsValidDiscountAmount(value: unknown): boolean { return CheckDiscountAmount(value, "") === null; } function IsRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } /** * Checks the discount amounts on one line. * * A returned line is not exempt. A sales-receipt return is a negative quantity * alongside a return marker; that negative quantity is legitimate and is not * read here. The discount on such a line still describes a reduction and is * nonpositive when allowReturns is set; invoice lines retain the sale rule. */ export function CheckLineDiscounts(line: DiscountLineInput | unknown, fieldPath: string, allowReturns = false): DiscountIssue[] { if (!IsRecord(line)) { return []; } const issues: DiscountIssue[] = []; for (const field of LineDiscountFields) { const isReturn = allowReturns && line.Ret === true && Number(line.Qty) < 0; const value = line[field]; const issue = isReturn && value != null ? CheckDiscountAmount(typeof value === "number" ? -value : typeof value === "string" && DecimalText.test(value.trim()) ? new Big(value.trim().replace(/^\+/, "")).neg().toString() : value, `${fieldPath}.${field}`) : CheckDiscountAmount(value, `${fieldPath}.${field}`); if (issue !== null) { issues.push(issue); } } return issues; } function CheckLineArray(lines: unknown, fieldPath: string, allowReturns = false): DiscountIssue[] { if (!Array.isArray(lines)) { return []; } return lines.flatMap((line, index) => CheckLineDiscounts(line, `${fieldPath}[${index}]`, allowReturns)); } function CheckHeader( document: Record, fields: readonly string[], fieldPath: string, ): DiscountIssue[] { const issues: DiscountIssue[] = []; for (const field of fields) { const issue = CheckDiscountAmount(document[field], fieldPath === "" ? field : `${fieldPath}.${field}`); if (issue !== null) { issues.push(issue); } } return issues; } function Prefix(fieldPath: string, name: string): string { return fieldPath === "" ? name : `${fieldPath}.${name}`; } /** * Every discount finding on an invoice: its header amounts and both line arrays. * * `fieldPath` prefixes the reported fields, for a caller that nests the document * inside an envelope. */ export function FindInvoiceDiscountIssues( document: InvoiceDiscountInput | unknown, fieldPath = "", ): DiscountIssue[] { if (!IsRecord(document)) { return []; } return [ ...CheckHeader(document, InvoiceHeaderDiscountFields, fieldPath), ...CheckLineArray(document.Items, Prefix(fieldPath, "Items")), ...CheckLineArray(document.Ops, Prefix(fieldPath, "Ops")), ]; } /** The sales-receipt equivalent. Receipts carry `Items` only, and no `Ops`. */ export function FindSalesReceiptDiscountIssues( document: SalesReceiptDiscountInput | unknown, fieldPath = "", ): DiscountIssue[] { if (!IsRecord(document)) { return []; } return [ ...CheckHeader(document, SalesReceiptHeaderDiscountFields, fieldPath), ...CheckLineArray(document.Items, Prefix(fieldPath, "Items"), true), ]; } /** One line of text summarising findings, for a server error message or a log. */ export function DescribeDiscountIssues(issues: readonly DiscountIssue[]): string { return issues.map(issue => `${issue.Field}: ${issue.Detail}`).join("; "); }