import Big from "big.js"; import { CalculateDiscountProposal, DiscountCalculationIssueCodes } from "./discount-calculator"; import type { CalculatedDiscountLine, DiscountCalculationIssue, DiscountCalculationLine, DiscountDocumentKind, DiscountLineGroup, } from "./discount-calculator"; import { FormatMinor, MaxMinorUnitExponent, ParseExactDecimal, SumBig, ToMinorRounded, Zero } from "./exact-money"; import { CalculateLineTaxAmounts, ResolveLineTaxRounding, RoundMoney } from "../../tax/tax-calculator"; import type { CalcMethod, IRoundingConfig, ITaxCodeComponent } from "../../tax/tax.types"; import { TrUtils } from "../../utils/tr-utils"; import { TransactionCalculationEngine } from "../../transaction-calculations/transaction-calculation-engine"; /** * The record-level discount, applied while a document is being edited. * * A document can carry a header discount -- `PDisc` over its product lines, * `LDisc` over its service lines, and an overall `Disc` over everything -- that * is spread pro rata onto each line as `RecDisc`. Whenever the lines change * (a line added, removed, repriced, its quantity or own discount edited) that * spread has to be redone over the new set of lines, including the one just * added. This does that, finalizes each line's amount (`UnAmt` / `Amt`), and * recalculates each line's `Taxes` on its post-discount amount. * * It replaces `DiscountAndTaxCalculation` and `calculateRecordDiscountForLine` * from `transaction-calculations/discounts-distribution.ts`, covering * everything they did, and differs from them where they were wrong: * * - **The spread is the server's own.** Allocation is `CalculateDiscountProposal`, * the function MainAPI verifies `RecDisc` with, fed exactly as the server feeds * it (`master-prices.ts` `PrepareLines`/`IdText`). So the `RecDisc` the UI * sends is the `RecDisc` the server expects, to the minor unit. The old code * split proportionally in floating point, so the shares did not sum to the * header amount (100 over three equal lines gave 99.99999999999999). * - **Line order is kept.** The old code moved every `NoDisc` line to the end. * - **Stale `RecDisc` is cleared on excluded sale lines.** A line that became `NoDisc`, or a layout row * used to keep its old share and be double-counted. Returns retain the supplied * signed reversal; the return owner verifies it against the original sale. * - **Impossible input is reported, not spread.** A line discount larger than * its amount, or a header discount larger than what remains, used to produce * negative shares. They are now returned as issues and nothing is changed. * - **Nothing is added that was not there.** No `net` scratch field on every * line, and no `Perc`/`LPerc`/`PPerc` written onto documents that lacked them. * - **The input is not mutated.** A new record is returned. * * Header amounts in percent mode. When `Perc` (or `PPerc`/`LPerc`) holds a * positive percentage, the matching amount is recomputed from it over the * current lines -- adding a line makes a 10% discount larger. Otherwise the * amount is fixed and is spread more thinly. This is the documented UI side of * the Disc/Perc convention (docs/common/accounting/numbers-and-discounts.md): * the UI turns a typed percentage into money; the server never does. * * Money that carries more precision than the currency has is rounded half away * from zero to the minor unit, and the rounded value is written back, so the * document sent is the document that was allocated. The server refuses excess * precision rather than rounding it, so leaving it unrounded here would only * move the failure to the save. Taxes follow the same rule, through the same * `CalculateLineTaxAmounts` that `CalculateLineTax` uses: each component on * each line rounded once. Policy: MainAPI * docs/common/accounting/numbers-and-discounts.md#invoice-money-precision. */ // ============================================================================ // Public surface // ============================================================================ export interface RecordDiscountOptions { /** * Which document rules apply. Omit to infer: a document with a returned * line (`Ret: true`) is a sales receipt; everything else follows invoice * rules, which are a superset (items and services, parts/labour/overall * header discounts). Pass it explicitly when the document type is known. */ readonly Kind?: DiscountDocumentKind; /** Minor-unit exponent of the document currency: 2 for INR/USD, 0 for JPY, 3 for KWD. Defaults to 2. */ readonly MinorUnitExponent?: number; /** * Tax codes for recalculating each line's `Taxes`. With none, each line's * existing `Taxes` entries are re-rated from their own `Rate`. The same * input `DiscountAndTaxCalculation` took. A component's `AppliedOn`/`Type` * (tax on tax) is read from here. */ readonly TaxCodes?: readonly unknown[]; /** * The tax rounding in force: the regime's rule with any entity override * (MainUI `GetTaxRounding()`). Only `LineTax` and, with it, `Method`/ * `Precision` affect line tax; see `ResolveLineTaxRounding`. Omit to round * each component to the currency's minor unit. */ readonly TaxRounding?: Partial; } export interface RecordDiscountResult { /** * The updated document: header discount amounts, every line's `RecDisc`, * and the recalculated `Taxes` of each discountable and returned line. * When `Issues` is not empty this is the input, unchanged. */ readonly Record: T; /** * Empty when the discount was applied. Otherwise why it could not be -- * e.g. `HEADER_DISCOUNT_EXCEEDS_CAPACITY` after a line was removed, or * `LINE_DISCOUNT_EXCEEDS_AMOUNT`. `Field` names the offending value. */ readonly Issues: readonly DiscountCalculationIssue[]; } /** How `RecalculateLineTaxes` rates and rounds; the same meanings as in `RecordDiscountOptions`. */ export interface LineTaxOptions { readonly TaxCodes?: readonly unknown[]; readonly TaxRounding?: Partial; /** Minor-unit exponent of the document currency. Defaults to 2. */ readonly MinorUnitExponent?: number; } export interface RecordLineDiscountResult { /** The `RecDisc` this line should carry. Zero when there are issues, or the line is `NoDisc` or a return. */ readonly RecDisc: number; readonly Issues: readonly DiscountCalculationIssue[]; } /** * Spreads the record-level discount over the document's current lines and * recalculates their taxes. Returns a new record; the input is not modified. */ export function ApplyRecordDiscountAndTaxes( record: T, options: RecordDiscountOptions = {}, ): RecordDiscountResult { const exponent = options.MinorUnitExponent ?? 2; if (!Number.isInteger(exponent) || exponent < 0 || exponent > MaxMinorUnitExponent) { return Refused(record, [Issue(DiscountCalculationIssueCodes.INVALID_CURRENCY, "MinorUnitExponent", null, `a minor-unit exponent of 0-${MaxMinorUnitExponent} is required`)]); } const source = record as Record; const items = ArrayOf(source.Items); const ops = ArrayOf(source.Ops); const kind: DiscountDocumentKind = options.Kind ?? (items.some(line => IsRecord(line) && IsTruthy(line.Ret)) ? "SALES_RECEIPT" : "INVOICE"); // Mirrors the server: a sales receipt has no service lines. const serviceLines = kind === "INVOICE" ? ops : []; // ---- Money the calculator reads, at the currency's precision ---------------- const issues: DiscountCalculationIssue[] = []; const lineDisc = new Map(); const calcLines: DiscountCalculationLine[] = []; for (const [group, lines] of [["ITEM", items], ["SERVICE", serviceLines]] as const) { lines.forEach((raw, position) => { const line = IsRecord(raw) ? raw : {}; const key = SlotKey(group, position); const disc = ReadMoney(line.Disc, `${ArrayName(group)}[${position}].Disc`, exponent, issues, kind === "SALES_RECEIPT" && IsTruthy(line.Ret)); if (kind === "SALES_RECEIPT" && IsTruthy(line.Ret)) { const reversal = ReadMoney(line.RecDisc, `${ArrayName(group)}[${position}].RecDisc`, exponent, issues, true); if (reversal !== null && reversal.gt(0)) issues.push(Issue(DiscountCalculationIssueCodes.NEGATIVE_AMOUNT, `${ArrayName(group)}[${position}].RecDisc`, null, "return discount must be nonpositive")); } if (disc !== null) { lineDisc.set(key, disc); } calcLines.push({ Id: IdText(line._id), Group: group, Position: position, Type: line.Type, // An empty quantity is a line still being filled in: it takes no // share yet rather than blocking the rest of the document. Qty: line.Qty === undefined || line.Qty === null || line.Qty === "" ? 0 : line.Qty, UnitPrice: group === "ITEM" ? line.UnPr : line.Pr, OfQty: line.OfQty, NoDisc: line.NoDisc, Return: line.Ret, Disc: disc === null ? line.Disc : FormatMinor(disc, exponent), // Omitted on purpose: this is where RecDisc is decided, so a stale // value from before the edit must not be checked against. RecDisc: undefined, // In the UI the charged price is the reference. The server may use // the master price instead; allocation does not depend on it. Reference: { Kind: "AS_CHARGED" }, }); }); } if (issues.length > 0) { return Refused(record, issues); } // ---- Pass 1: capacities, to turn percentages into amounts ----------------------- const sizing = Run(kind, exponent, calcLines, { Disc: Zero, PDisc: Zero, LDisc: Zero }); const sized = sizing.Lines; if (sizing.Issues.length > 0 || sized === null) { return Refused(record, sizing.Issues); } const saleCapacity = (group: DiscountLineGroup | null): Big => SumBig(sized .filter(line => line.Partition === "SALE" && (group === null || line.Group === group)) .map(line => ParseMinor(line.Capacity, exponent))); const parts = kind === "INVOICE" ? HeaderAmount(source, "PDisc", "PPerc", saleCapacity("ITEM"), exponent, issues) : Zero; const labour = kind === "INVOICE" ? HeaderAmount(source, "LDisc", "LPerc", saleCapacity("SERVICE"), exponent, issues) : Zero; const afterCategories = parts === null || labour === null ? Zero : saleCapacity(null).minus(parts).minus(labour); const overall = HeaderAmount(source, "Disc", "Perc", afterCategories.lt(0) ? Zero : afterCategories, exponent, issues); if (issues.length > 0 || parts === null || labour === null || overall === null) { return Refused(record, issues); } // ---- Pass 2: the spread itself -------------------------------------------------- const spread = Run(kind, exponent, calcLines, { Disc: overall, PDisc: parts, LDisc: labour }); if (spread.Issues.length > 0 || spread.Lines === null) { return Refused(record, spread.Issues); } const byKey = new Map(spread.Lines.map(line => [SlotKey(line.Group, line.Position), line])); // ---- Write back --------------------------------------------------------------- const result: Record = { ...source }; WriteHeader(result, source, "Disc", "Perc", overall, exponent); if (kind === "INVOICE") { WriteHeader(result, source, "PDisc", "PPerc", parts, exponent); WriteHeader(result, source, "LDisc", "LPerc", labour, exponent); } const tax: LineTaxContext = { TaxCodes: options.TaxCodes ?? [], Rounding: ResolveLineTaxRounding(options.TaxRounding, exponent), }; if (Array.isArray(source.Items)) { result.Items = items.map((raw, position) => UpdateLine(raw, "ITEM", byKey.get(SlotKey("ITEM", position)), lineDisc.get(SlotKey("ITEM", position)), exponent, tax)); } if (Array.isArray(source.Ops) && kind === "INVOICE") { result.Ops = ops.map((raw, position) => UpdateLine(raw, "SERVICE", byKey.get(SlotKey("SERVICE", position)), lineDisc.get(SlotKey("SERVICE", position)), exponent, tax)); } return { Record: result as T, Issues: [] }; } /** * The `RecDisc` one line should carry, with the document's other lines as they * are. For a line being added or edited before it is saved: the line is matched * to its existing entry by `_id`, or treated as new and appended when it has no * match. The document itself is not modified. */ export function CalculateRecordDiscountForLine( record: object, line: Record, group: DiscountLineGroup, options: RecordDiscountOptions = {}, ): RecordLineDiscountResult { const source = record as Record; const arrayName = ArrayName(group); const lines = [...ArrayOf(source[arrayName])]; const id = IdText(line?._id); // Ids are compared as text, so an ObjectId held by the UI still matches the // same id held as a string. The old helper compared with ===, missed, and // silently returned the last line's share instead. let position = id === null ? -1 : lines.findIndex(existing => IsRecord(existing) && IdText(existing._id) === id); if (position === -1) { position = lines.length; lines.push(line); } else { lines[position] = line; } const outcome = ApplyRecordDiscountAndTaxes({ ...source, [arrayName]: lines }, options); if (outcome.Issues.length > 0) { return { RecDisc: 0, Issues: outcome.Issues }; } const updated = ArrayOf((outcome.Record as Record)[arrayName])[position]; const recDisc = IsRecord(updated) ? Number(updated.RecDisc ?? 0) : 0; return { RecDisc: Number.isFinite(recDisc) ? recDisc : 0, Issues: [] }; } /** * Re-rates one line's existing `Taxes[]` on its taxable amount, for a screen * that changes a line outside `ApplyRecordDiscountAndTaxes` (a price or * quantity edit on a document without a record-level discount). The same rule * `ApplyRecordDiscountAndTaxes` applies: `amount` is finalized to the minor * unit, then each component is rounded once. Returns new entries; the input is * not modified. */ export function RecalculateLineTaxes( amount: unknown, taxCode: unknown, existingTaxes: unknown, qty: unknown = 1, options: LineTaxOptions = {}, ): unknown[] { const exponent = options.MinorUnitExponent ?? 2; return RecalculateTaxes(RoundMoney(ToFiniteNumber(amount), exponent), taxCode, existingTaxes, qty, { TaxCodes: options.TaxCodes ?? [], Rounding: ResolveLineTaxRounding(options.TaxRounding, exponent), }); } // ============================================================================ // Allocation plumbing // ============================================================================ interface RunOutcome { readonly Lines: readonly CalculatedDiscountLine[] | null; readonly Issues: DiscountCalculationIssue[]; } /** One calculator pass. RecDisc is omitted by design, so "missing RecDisc" is expected, not an issue. */ function Run( kind: DiscountDocumentKind, exponent: number, lines: readonly DiscountCalculationLine[], header: { Disc: Big; PDisc: Big; LDisc: Big }, ): RunOutcome { const outcome = CalculateDiscountProposal({ Kind: kind, // The allocation depends only on the exponent. "XXX" is ISO 4217's code for // "no currency involved", which is exactly the case: no amount here is // compared across currencies, and inventing INR would be wrong elsewhere. Currency: { Code: "XXX", MinorUnitExponent: exponent }, Header: kind === "INVOICE" ? { Disc: FormatMinor(header.Disc, exponent), PDisc: FormatMinor(header.PDisc, exponent), LDisc: FormatMinor(header.LDisc, exponent) } : { Disc: FormatMinor(header.Disc, exponent) }, Lines: lines, }); const issues = outcome.Issues.filter(issue => issue.Code !== DiscountCalculationIssueCodes.RECDISC_MISSING); return { Lines: outcome.Proposal === null ? null : outcome.Proposal.Lines, Issues: issues }; } /** * A header amount in minor units: recomputed from its percentage when one is * set, otherwise the stored amount at the currency's precision. Null on invalid * input, with the issue recorded. */ function HeaderAmount( source: Record, amountField: string, percentField: string, base: Big, exponent: number, issues: DiscountCalculationIssue[], ): Big | null { const percent = ReadPercent(source[percentField], percentField, issues); if (percent === null) { return null; } if (percent.gt(0)) { // Percent of minor units is minor units; the one rounding step is here. return base.times(percent).div(100).round(0, Big.roundHalfUp); } return ReadMoney(source[amountField], amountField, exponent, issues); } /** Parses a percentage. Absent or empty means "no percentage", i.e. amount mode. */ function ReadPercent(value: unknown, field: string, issues: DiscountCalculationIssue[]): Big | null { if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) { return Zero; } const parsed = ParseExactDecimal(value); if (!parsed.Ok) { issues.push(Issue(DiscountCalculationIssueCodes.INVALID_NUMBER, field, null, parsed.Reason)); return null; } if (parsed.Value.lt(0)) { issues.push(Issue(DiscountCalculationIssueCodes.NEGATIVE_AMOUNT, field, null, `negative percentage (${parsed.Value.toString()})`)); return null; } return parsed.Value; } /** * Reads money as minor units, rounding excess precision half away from zero. * Absent is zero. Null on invalid or negative input, with the issue recorded. */ function ReadMoney(value: unknown, field: string, exponent: number, issues: DiscountCalculationIssue[], allowNegative = false): Big | null { if (value === undefined || value === null) { return Zero; } const parsed = ParseExactDecimal(value); if (!parsed.Ok) { issues.push(Issue(DiscountCalculationIssueCodes.INVALID_NUMBER, field, null, parsed.Reason)); return null; } if (!allowNegative && parsed.Value.lt(0)) { issues.push(Issue(DiscountCalculationIssueCodes.NEGATIVE_AMOUNT, field, null, `negative (${parsed.Value.toString()})`)); return null; } return ToMinorRounded(parsed.Value, exponent); } /** Minor units from one of the calculator's fixed-point strings. */ function ParseMinor(text: string, exponent: number): Big { return new Big(text).times(new Big(10).pow(exponent)); } /** * Writes a header amount back. Only onto a document that had the field, or where * it was derived from a percentage -- a document that never carried `PDisc` does * not gain one. */ function WriteHeader( result: Record, source: Record, amountField: string, percentField: string, amount: Big, exponent: number, ): void { const derived = source[percentField] !== undefined && source[percentField] !== null && source[percentField] !== ""; if (amountField in source || derived) { result[amountField] = ToAmount(amount, exponent); } } // ============================================================================ // Line write-back and tax recalculation // ============================================================================ interface LineTaxContext { readonly TaxCodes: readonly unknown[]; /** How each component on each line is rounded (`ResolveLineTaxRounding`). */ readonly Rounding: IRoundingConfig; } function UpdateLine( raw: unknown, group: DiscountLineGroup, calculated: CalculatedDiscountLine | undefined, disc: Big | undefined, exponent: number, tax: LineTaxContext, ): unknown { if (!IsRecord(raw) || calculated === undefined) { return raw; } const line: Record = { ...raw }; const taxQty = group === "ITEM" ? line.Qty : (line.Qty || 1); if (calculated.Partition === "SALE" || calculated.Partition === "NON_DISCOUNTABLE") { if (calculated.Partition === "SALE") { line.Disc = ToAmount(disc ?? Zero, exponent); line.RecDisc = Number(calculated.ExpectedRecDisc); } else if (line.RecDisc !== undefined && line.RecDisc !== null && Number(line.RecDisc) !== 0) { // A NoDisc line takes no share. Clear one left over from before the // line changed, rather than keep double-counting it. line.RecDisc = 0; } WriteLineAmount(line, group, calculated.Gross); line.Taxes = RecalculateTaxes(Number(calculated.NetAmount), line.TCode, line.Taxes, taxQty, tax); return line; } // Layout rows take no share. Returns retain the original sale allocation. if (calculated.Partition !== "RETURN" && line.RecDisc !== undefined && line.RecDisc !== null && Number(line.RecDisc) !== 0) { line.RecDisc = 0; } if (calculated.Partition === "RETURN") { // A return reverses its sale, so its taxes follow its own (negative) amount. const gross = ToMinorRounded(new Big(String(line.Qty)).times(new Big(String(line.UnPr ?? 0))), exponent); const net = gross.minus(disc ?? Zero).minus(ToMinorRounded(new Big(String(line.RecDisc ?? 0)), exponent)); line.Taxes = RecalculateTaxes(ToAmount(net, exponent), line.TCode, line.Taxes, line.Qty, tax); } return line; } /** * Writes the finalized line amount -- `Qty × price` rounded to the minor unit, * the figure the calculator allocated against -- over whatever the screen * computed. Items carry it as `UnAmt`, service lines as `Amt`. A line still * being filled in (no quantity) keeps what it has. */ function WriteLineAmount(line: Record, group: DiscountLineGroup, gross: string): void { if (line.Qty === undefined || line.Qty === null || line.Qty === "") { return; } line[group === "ITEM" ? "UnAmt" : "Amt"] = Number(gross); } /** * Re-rates a line's existing `Taxes[]` on `amount`, through the shared * `CalculateLineTaxAmounts` (the rule `CalculateLineTax` uses): each component * rounded once, tax-on-tax components on the net plus primary tax. Which * components a line has, and their rates, stay the line's own; the tax code * supplies a missing rate and each component's `AppliedOn`/`Type`. */ function RecalculateTaxes(amount: number, taxCode: unknown, existingTaxes: unknown, qty: unknown, tax: LineTaxContext): unknown[] { const normalizedTaxCode = TrUtils.IsNull(taxCode) ? null : Number(taxCode); const taxCodeRow = FindTaxCodeRow(taxCode, tax.TaxCodes); const defaultRates = GetDefaultTaxRates(taxCodeRow, taxCode, tax.TaxCodes); const entries: any[] = Array.isArray(existingTaxes) ? existingTaxes : []; const components = entries.map((entry: any): ITaxCodeComponent => { const code = entry?.Code; const rate = Number(entry?.Rate); const definition = FindComponentDefinition(taxCodeRow, code); return { Code: code, Name: definition?.Name ?? code, Rate: Number.isFinite(rate) ? rate : (defaultRates[code] || 0), Type: definition?.Type, CalcMethod: CalculationMethodOf(entry?.CalcMethod), PerUnitAmt: ToFiniteNumber(entry?.PerUnitAmt), AppliedOn: definition?.AppliedOn, }; }); const amounts = CalculateLineTaxAmounts(components, amount, ToFiniteNumber(qty, 1), tax.Rounding); return entries.map((entry: any, index) => ({ ...entry, Rate: components[index].Rate, Amt: amounts[index], TaxCodeId: !TrUtils.IsNull(entry?.TaxCodeId) ? Number(entry.TaxCodeId) : normalizedTaxCode, PerUnitAmt: entry?.PerUnitAmt || undefined, CalcMethod: NormalizeCalcMethod(entry?.CalcMethod), Unit: entry?.Unit ?? entry?.Units ?? undefined, })); } const KnownCalcMethods: ReadonlySet = new Set(["Percent", "PerUnit", "PerUnitPlusPercent", "MaxOfPercentOrPerUnit"]); /** An unknown method is a percentage, as it always was here. */ function CalculationMethodOf(calcMethod: unknown): CalcMethod { const normalized = NormalizeCalcMethod(calcMethod); return (KnownCalcMethods.has(normalized) ? normalized : "Percent") as CalcMethod; } function NormalizeCalcMethod(calcMethod: unknown): string { return calcMethod === "Percentage" || TrUtils.IsNull(calcMethod) ? "Percent" : String(calcMethod); } function ToFiniteNumber(value: unknown, fallback = 0): number { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : fallback; } function FindTaxCodeRow(taxCode: unknown, taxCodes: readonly unknown[]): any { const normalizedTaxCode = TrUtils.IsNull(taxCode) ? null : taxCode; return Array.isArray(taxCodes) ? taxCodes.find((code: any) => code?._id === normalizedTaxCode) ?? null : null; } function FindComponentDefinition(taxCodeRow: any, code: unknown): any { const components: any[] = Array.isArray(taxCodeRow?.Components) ? taxCodeRow.Components : []; return components.find((component: any) => component?.Code === code) ?? null; } function GetDefaultTaxRates(taxCodeRow: any, taxCode: unknown, taxCodes: readonly unknown[] = []): Record { const normalizedTaxCode = TrUtils.IsNull(taxCode) ? null : taxCode; const components: any[] = Array.isArray(taxCodeRow?.Components) ? taxCodeRow.Components : []; const getComponentRate = (code: string): number => { const match = components.find((component: any) => { const compCode = component?.Code ?? component?.Type ?? component?.Name; return compCode === code; }); const rate = Number(match?.Rate); return Number.isFinite(rate) ? rate : 0; }; let CGST = getComponentRate("CGST"); let SGST = getComponentRate("SGST"); let IGST = getComponentRate("IGST"); const CESS = getComponentRate("CESS"); if (CGST === 0 && SGST === 0 && IGST === 0) { const gstRateSum = TransactionCalculationEngine.getGSTRateSumByTaxCode(normalizedTaxCode, taxCodes as any[]); if (components.length === 1) { IGST = gstRateSum; } else if (components.length === 2) { CGST = gstRateSum / 2; SGST = gstRateSum / 2; } } return { CGST, SGST, IGST, CESS }; } // ============================================================================ // Small helpers // ============================================================================ function Refused(record: T, issues: readonly DiscountCalculationIssue[]): RecordDiscountResult { return { Record: record, Issues: issues }; } function Issue( code: DiscountCalculationIssue["Code"], field: string, lineId: string | null, detail: string, ): DiscountCalculationIssue { return { Code: code, Field: field, LineId: lineId, Detail: detail }; } function ToAmount(minor: Big, exponent: number): number { return Number(FormatMinor(minor, exponent)); } function SlotKey(group: DiscountLineGroup, position: number): string { return `${group}:${position}`; } function ArrayName(group: DiscountLineGroup): "Items" | "Ops" { return group === "ITEM" ? "Items" : "Ops"; } function ArrayOf(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function IsRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function IsTruthy(value: unknown): boolean { return value === true || value === "true"; } /** * A line id as text, exactly as the server's `IdText` (MainAPI * `accounting/services/master-prices.ts`) produces it. The allocation's * tie-break orders by this text, so it must match the server's to agree on * which line receives a leftover minor unit. */ function IdText(value: unknown): string | null { if (value === null || value === undefined) { return null; } if (typeof value === "string" || typeof value === "number") { return String(value); } if (typeof value === "object" && typeof (value as any).$oid === "string") { return (value as any).$oid; } const text = (value as any)?.toString?.(); return typeof text === "string" && text !== "[object Object]" ? text : null; }