import Big from "big.js"; import { FormatMinor, MaxMinorUnitExponent, ParseExactDecimal, SumBig, ToMinorExact, ToMinorRounded, Zero, } from "./exact-money"; import { AllocateLargestRemainder } from "./header-allocation"; import type { AllocationSlot } from "./header-allocation"; /** * The trusted discount calculation for invoices and sales receipts: * docs/planning/discount-authority.md §5, delivery step 3 (pure layer). * * Given a proposed document whose commercial facts have already been * established by the caller -- each line's reference price and the document * currency -- this derives * every line's eligible basis and manual concession, allocates the header * discounts, verifies the client's `RecDisc`, and totals the result. It decides * nothing about authority: no ceiling, grant or role is read here. Its output * is what a later evaluator compares against a ceiling. * * Pure: no I/O, no clock, no framework, no mutation of the input. Looking up a * master price is I/O and belongs to the caller (in MainAPI, * `accounting/services/discount-proposal.ts`); it arrives here only as the * `Reference` on each line. * * Shared with the Angular client for the same reason as the allocation it * contains (§12 decision 5): the server compares the client's `RecDisc` to the * minor unit, which is only safe when both run this one implementation. * * --------------------------------------------------------------------------- * The header discounts, established from stored data (2026-09-21) * --------------------------------------------------------------------------- * `PDisc`, `LDisc` and `Disc` are independent monetary amounts applied in * sequence, not overlapping summaries. Across stored invoices, Σ`RecDisc` * equals `PDisc + LDisc + Disc` on 378 of 395 discounted invoices (exactly, or * within 0.01 where the old allocator failed to conserve), and the overlap * reading -- `Disc` already containing the category amounts -- fails on every * one of the 34 that carry a category discount. So, for an invoice: * * 1. direct line discount `Disc` on each line; * 2. `PDisc` over the discountable product lines (`Items`), weighted by * remaining capacity; `LDisc` likewise over the service lines (`Ops`); * 3. overall `Disc` over every discountable line, weighted by the capacity * left after step 2. * * A sales receipt carries the overall `Disc` only. * * --------------------------------------------------------------------------- * Arithmetic * --------------------------------------------------------------------------- * Exact decimal throughout, compared in integer minor units, no tolerance * (§5.4). Money supplied by the client -- `Disc`, `RecDisc`, the header * amounts, `Adjust` -- must already be exact at the currency's precision and is * refused, never rounded, when it is not. The one rounding boundary is the * extended line amount, quantity × unit price, rounded half away from zero to * the minor unit, because a fractional quantity or a sub-minor unit price * legitimately produces one. */ // ============================================================================ // Input // ============================================================================ export type DiscountDocumentKind = "INVOICE" | "SALES_RECEIPT"; /** `ITEM` lines come from `Items` (the parts pool); `SERVICE` lines from `Ops` (the labour pool). */ export type DiscountLineGroup = "ITEM" | "SERVICE"; /** * What a line's price is measured against, established by the caller. * * - `MASTER`: the master record's sale price (after any price list). A charged * price below it is a manual concession; above it, a markup that must not * carry a discount. * - `AS_CHARGED`: the owner allows users to edit prices, so the charged price * is itself the authorized reference and no master lookup is made. * - `UNVERIFIED`: price editing is not allowed but no master price exists -- * a free-form line. It may not take part in a discount. * * Automatic promotions are not modelled: the engine is not in use, so every * reduction below the reference counts as manual. */ export type DiscountLineReference = | { readonly Kind: "MASTER"; readonly UnitPrice: unknown; /** False when the master record marks the item or service non-discountable. */ readonly Discountable: boolean; } | { readonly Kind: "AS_CHARGED" } | { readonly Kind: "UNVERIFIED"; readonly Reason: string }; export interface DiscountCalculationLine { /** Stable line id (stringified `_id`), or null when the line has none yet. */ readonly Id: string | null; readonly Group: DiscountLineGroup; /** Index within its source array; used for field paths and as the last tie-break. */ readonly Position: number; readonly Type?: unknown; readonly Qty: unknown; /** `UnPr` for an item line, `Pr` for a service line. */ readonly UnitPrice?: unknown; readonly OfQty?: unknown; readonly NoDisc?: unknown; /** The sales-receipt return marker `Ret`. */ readonly Return?: unknown; readonly Disc?: unknown; readonly RecDisc?: unknown; readonly Reference: DiscountLineReference; /** True when the line's price includes tax. */ readonly TaxInclusive?: boolean; } export interface DiscountCalculationInput { readonly Kind: DiscountDocumentKind; /** Trusted currency: ISO 4217 code and its minor-unit exponent. */ readonly Currency: { readonly Code: string; readonly MinorUnitExponent: number }; readonly Header: { readonly Disc?: unknown; readonly PDisc?: unknown; readonly LDisc?: unknown; readonly Round?: unknown; readonly Adjust?: unknown; }; readonly Lines: readonly DiscountCalculationLine[]; } // ============================================================================ // Output // ============================================================================ export const DiscountCalculationIssueCodes = Object.freeze({ INVALID_CURRENCY: "INVALID_CURRENCY", INVALID_NUMBER: "INVALID_NUMBER", NEGATIVE_AMOUNT: "NEGATIVE_AMOUNT", /** Supplied money carries more decimal places than the currency has. */ PRECISION_EXCEEDED: "PRECISION_EXCEEDED", /** A header field the document kind does not carry (PDisc/LDisc on a receipt). */ UNSUPPORTED_HEADER_FIELD: "UNSUPPORTED_HEADER_FIELD", /** `Round` of a whole major unit or more: a concession, not rounding. */ ROUND_OUT_OF_RANGE: "ROUND_OUT_OF_RANGE", UNSUPPORTED_LINE_TYPE: "UNSUPPORTED_LINE_TYPE", /** A synthetic `Type: "Disc"` row carrying value. */ UNSUPPORTED_DISCOUNT_ROW: "UNSUPPORTED_DISCOUNT_ROW", /** A Sub/Note/Tax/Tot row carrying an amount or a discount. */ NON_GOODS_ROW_WITH_VALUE: "NON_GOODS_ROW_WITH_VALUE", /** A return marker on an invoice line; invoice returns go through credit notes. */ UNSUPPORTED_RETURN_LINE: "UNSUPPORTED_RETURN_LINE", /** A return marker with a non-negative quantity. */ CONTRADICTORY_RETURN: "CONTRADICTORY_RETURN", /** A negative quantity without a return marker. */ NEGATIVE_SALE_QUANTITY: "NEGATIVE_SALE_QUANTITY", NEGATIVE_PRICE: "NEGATIVE_PRICE", DISCOUNT_ON_NON_DISCOUNTABLE_LINE: "DISCOUNT_ON_NON_DISCOUNTABLE_LINE", /** The master record says non-discountable but the line is not marked `NoDisc`. */ ELIGIBILITY_MISMATCH: "ELIGIBILITY_MISMATCH", LINE_DISCOUNT_EXCEEDS_AMOUNT: "LINE_DISCOUNT_EXCEEDS_AMOUNT", HEADER_DISCOUNT_WITHOUT_CAPACITY: "HEADER_DISCOUNT_WITHOUT_CAPACITY", HEADER_DISCOUNT_EXCEEDS_CAPACITY: "HEADER_DISCOUNT_EXCEEDS_CAPACITY", RECDISC_MISSING: "RECDISC_MISSING", RECDISC_MISMATCH: "RECDISC_MISMATCH", /** A discountable line without a trusted reference on a document carrying manual concession. */ UNVERIFIED_REFERENCE_PRICE: "UNVERIFIED_REFERENCE_PRICE", /** A line priced above its reference that also carries a concession. */ PRICE_ABOVE_REFERENCE: "PRICE_ABOVE_REFERENCE", /** A free quantity on a line whose price cannot be trusted, so it cannot be valued. */ UNVERIFIED_FREE_QUANTITY: "UNVERIFIED_FREE_QUANTITY", /** A tax-inclusive price on a document carrying manual concession. */ TAX_INCLUSIVE_UNSUPPORTED: "TAX_INCLUSIVE_UNSUPPORTED", /** The caller's reference is malformed or internally inconsistent. */ INVALID_REFERENCE: "INVALID_REFERENCE", } as const); export type DiscountCalculationIssueCode = typeof DiscountCalculationIssueCodes[keyof typeof DiscountCalculationIssueCodes]; export interface DiscountCalculationIssue { readonly Code: DiscountCalculationIssueCode; /** Where it sits, e.g. `Items[2].RecDisc` or `PDisc`. */ readonly Field: string; readonly LineId: string | null; readonly Detail: string; } /** * How a line participates. * * - `SALE`: a discountable positive sale line. Contributes basis and capacity. * - `NON_DISCOUNTABLE`: a positive sale line marked `NoDisc`. Charged, but * contributes neither basis nor capacity and must carry no concession. * - `RETURN`: a sales-receipt return. A separate domain (§6.1): outside the * numerator and denominator of every sale figure here. * - `NON_GOODS`: a Sub/Note/Tax/Tot/Disc row with no value. */ export type DiscountLinePartition = "SALE" | "NON_DISCOUNTABLE" | "RETURN" | "NON_GOODS"; /** Every amount is a fixed-point string at the currency's precision, e.g. "145.00". */ export interface CalculatedDiscountLine { readonly Id: string | null; readonly Group: DiscountLineGroup; readonly Position: number; readonly Partition: DiscountLinePartition; readonly ReferenceVerified: boolean; /** Quantity × unit price as charged. */ readonly Gross: string; /** B_i: the eligible basis -- quantity × reference price, before manual concession and tax. */ readonly Basis: string; /** Reference minus charged price, when the line is priced below its reference. */ readonly PriceConcession: string; /** Value of a free quantity (`OfQty`) at the reference price. */ readonly FreeQuantityConcession: string; /** The line's own `Disc`. */ readonly LineDiscount: string; /** R_i: capacity for header allocation, `Gross − Disc`. Zero outside `SALE`. */ readonly Capacity: string; /** Share of `PDisc` (item lines) or `LDisc` (service lines). */ readonly CategoryAllocation: string; /** Share of the overall header `Disc`. */ readonly OverallAllocation: string; /** The `RecDisc` this line must carry: category plus overall allocation. */ readonly ExpectedRecDisc: string; /** D_i: every manual concession on the line, counted once. */ readonly ManualDiscount: string; /** `Gross − Disc − RecDisc`: the pre-tax amount charged. */ readonly NetAmount: string; } export interface DiscountProposalTotals { /** Σ Basis over `SALE` lines: the document denominator. */ readonly Basis: string; readonly Gross: string; readonly PriceConcession: string; readonly FreeQuantityConcession: string; readonly LineDiscount: string; readonly PartsDiscount: string; readonly LabourDiscount: string; readonly OverallDiscount: string; /** A negative `Adjust`, counted as document-level concession. */ readonly AdjustmentConcession: string; /** D: Σ D_i plus `AdjustmentConcession`. The document numerator. */ readonly ManualDiscount: string; /** Σ `NetAmount` over `SALE` and `NON_DISCOUNTABLE` lines, before tax, rounding and adjustment. */ readonly NetAmount: string; readonly ReturnLines: number; } export interface DiscountProposal { readonly Currency: { readonly Code: string; readonly MinorUnitExponent: number }; readonly Lines: readonly CalculatedDiscountLine[]; readonly Totals: DiscountProposalTotals; } export interface DiscountCalculationResult { /** True only when there are no issues. */ readonly Accepted: boolean; readonly Issues: readonly DiscountCalculationIssue[]; /** Null when the input could not be parsed far enough to calculate. */ readonly Proposal: DiscountProposal | null; } // ============================================================================ // Implementation // ============================================================================ const Codes = DiscountCalculationIssueCodes; /** Rows that are layout or commentary, not goods or services. */ const NonGoodsTypes: ReadonlySet = new Set(["Sub", "Note", "Tax", "Tot"]); /** Types that are ordinary goods or service rows. Absent is the common case. */ const GoodsTypes: ReadonlySet = new Set(["Lab"]); class IssueList { readonly Items: DiscountCalculationIssue[] = []; Add(code: DiscountCalculationIssueCode, field: string, lineId: string | null, detail: string): void { this.Items.push({ Code: code, Field: field, LineId: lineId, Detail: detail }); } } interface MoneyReader { readonly Exponent: number; readonly Issues: IssueList; } /** Reads a non-negative discount amount as exact minor units; absent is zero. */ function ReadDiscountMinor(reader: MoneyReader, value: unknown, field: string, lineId: string | null): Big | null { if (value === undefined || value === null) { return Zero; } return ReadSignedMinor(reader, value, field, lineId, false); } function ReadSignedMinor( reader: MoneyReader, value: unknown, field: string, lineId: string | null, allowNegative: boolean, ): Big | null { const parsed = ParseExactDecimal(value); if (!parsed.Ok) { reader.Issues.Add(Codes.INVALID_NUMBER, field, lineId, parsed.Reason); return null; } if (!allowNegative && parsed.Value.lt(0)) { reader.Issues.Add(Codes.NEGATIVE_AMOUNT, field, lineId, `negative (${parsed.Value.toString()})`); return null; } const minor = ToMinorExact(parsed.Value, reader.Exponent); if (minor === null) { reader.Issues.Add( Codes.PRECISION_EXCEEDED, field, lineId, `${parsed.Value.toString()} has more than ${reader.Exponent} decimal places`, ); return null; } return minor; } /** Reads a quantity or unit price exactly; not money, so no precision limit. */ function ReadDecimal(issues: IssueList, value: unknown, field: string, lineId: string | null, absentAs: Big | null): Big | null { if (value === undefined || value === null) { if (absentAs === null) { issues.Add(Codes.INVALID_NUMBER, field, lineId, "required"); } return absentAs; } const parsed = ParseExactDecimal(value); if (!parsed.Ok) { issues.Add(Codes.INVALID_NUMBER, field, lineId, parsed.Reason); return null; } return parsed.Value; } function FieldPath(line: DiscountCalculationLine, field: string): string { return `${line.Group === "ITEM" ? "Items" : "Ops"}[${line.Position}].${field}`; } function SlotKey(line: DiscountCalculationLine): string { return `${line.Group}:${line.Position}`; } /** * Tie-break for equal allocation remainders. Lines with an id order by id; * lines without one follow, by group then position. Positions are padded so * that string order is numeric order. */ function TieBreak(line: DiscountCalculationLine): string { // Manual padding: the package targets ES2016, which has no padStart. const position = String(line.Position); const place = `${line.Group === "ITEM" ? "0" : "1"}:${"000000000".slice(position.length)}${position}`; return line.Id !== null && line.Id !== "" ? `0:${line.Id}:${place}` : `1:${place}`; } function IsTruthy(value: unknown): boolean { return value === true || value === "true"; } /** Working state for one line between the parsing and allocation passes. */ interface WorkingLine { readonly Line: DiscountCalculationLine; readonly Partition: DiscountLinePartition; readonly Verified: boolean; readonly TaxInclusive: boolean; readonly Gross: Big; readonly Basis: Big; readonly Markup: Big; readonly PriceConcession: Big; readonly FreeQuantityConcession: Big; readonly Disc: Big; readonly Capacity: Big; /** `undefined` when the line omitted `RecDisc`. */ readonly SuppliedRecDisc: Big | undefined; } /** The reference unit price, or null for an unverified line; reports a malformed one. */ function ReadReference( issues: IssueList, line: DiscountCalculationLine, unitPrice: Big, ): { Verified: false } | { Verified: true; Unit: Big; Discountable: boolean } | null { const reference = line.Reference; if (reference.Kind === "UNVERIFIED") { return { Verified: false }; } if (reference.Kind === "AS_CHARGED") { return { Verified: true, Unit: unitPrice, Discountable: true }; } const unit = ParseExactDecimal(reference.UnitPrice); if (!unit.Ok || unit.Value.lt(0)) { issues.Add(Codes.INVALID_REFERENCE, FieldPath(line, "Reference"), line.Id, "the master price is not a non-negative number"); return null; } return { Verified: true, Unit: unit.Value, Discountable: reference.Discountable === true }; } /** First pass: classify, parse and derive everything that does not depend on allocation. */ function ReadLine(reader: MoneyReader, kind: DiscountDocumentKind, line: DiscountCalculationLine): WorkingLine | null { const issues = reader.Issues; const exponent = reader.Exponent; const id = line.Id; const before = issues.Items.length; const returning = kind === "SALES_RECEIPT" && IsTruthy(line.Return); const readLineDiscount = (value: unknown, field: string) => { const parsed = value == null ? Zero : ReadSignedMinor(reader, value, FieldPath(line, field), id, returning); if (returning && parsed != null && parsed.gt(0)) { issues.Add(Codes.NEGATIVE_AMOUNT, FieldPath(line, field), id, "a return discount must be nonpositive"); return null; } return parsed; }; const disc = readLineDiscount(line.Disc, "Disc"); const recDiscAbsent = line.RecDisc === undefined || line.RecDisc === null; const recDisc = recDiscAbsent ? Zero : readLineDiscount(line.RecDisc, "RecDisc"); const qty = ReadDecimal(issues, line.Qty, FieldPath(line, "Qty"), id, null); const unitPrice = ReadDecimal(issues, line.UnitPrice, FieldPath(line, line.Group === "ITEM" ? "UnPr" : "Pr"), id, Zero); const ofQty = ReadDecimal(issues, line.OfQty, FieldPath(line, "OfQty"), id, Zero); if (ofQty !== null && ofQty.lt(0)) { issues.Add(Codes.NEGATIVE_AMOUNT, FieldPath(line, "OfQty"), id, "negative free quantity"); } if (disc === null || recDisc === null || qty === null || unitPrice === null || ofQty === null || issues.Items.length > before) { return null; } const suppliedRecDisc = recDiscAbsent ? undefined : recDisc; // ---- Row type ------------------------------------------------------------- const type = line.Type === undefined || line.Type === null || line.Type === "" ? null : line.Type; const gross = ToMinorRounded(qty.times(unitPrice), exponent); if (type !== null && (typeof type !== "string" || (!GoodsTypes.has(type) && !NonGoodsTypes.has(type) && type !== "Disc"))) { issues.Add(Codes.UNSUPPORTED_LINE_TYPE, FieldPath(line, "Type"), id, `unsupported row type ${JSON.stringify(type)}`); return null; } if (type === "Disc" || (typeof type === "string" && NonGoodsTypes.has(type))) { const carriesValue = !gross.eq(0) || !disc.eq(0) || !recDisc.eq(0) || !ofQty.eq(0); if (carriesValue) { issues.Add( type === "Disc" ? Codes.UNSUPPORTED_DISCOUNT_ROW : Codes.NON_GOODS_ROW_WITH_VALUE, FieldPath(line, "Type"), id, type === "Disc" ? "a discount row is not a supported way to give a discount; use the line or header discount" : `a ${type} row must not carry an amount or a discount`, ); return null; } return Excluded(line, "NON_GOODS", suppliedRecDisc); } // ---- Returns -------------------------------------------------------------- const isReturn = IsTruthy(line.Return); if (isReturn) { if (kind === "INVOICE") { issues.Add(Codes.UNSUPPORTED_RETURN_LINE, FieldPath(line, "Ret"), id, "invoice lines cannot be returns; use a credit note"); return null; } if (!qty.lt(0)) { issues.Add(Codes.CONTRADICTORY_RETURN, FieldPath(line, "Qty"), id, "a return line must have a negative quantity"); return null; } return Excluded(line, "RETURN", suppliedRecDisc); } if (qty.lt(0)) { issues.Add(Codes.NEGATIVE_SALE_QUANTITY, FieldPath(line, "Qty"), id, "a negative quantity needs a return marker"); return null; } if (unitPrice.lt(0)) { issues.Add(Codes.NEGATIVE_PRICE, FieldPath(line, line.Group === "ITEM" ? "UnPr" : "Pr"), id, "a sale line cannot have a negative price"); return null; } // ---- Reference and concession forms ----------------------------------------- const reference = ReadReference(issues, line, unitPrice); if (reference === null) { return null; } const noDisc = IsTruthy(line.NoDisc); if (reference.Verified && !reference.Discountable && !noDisc) { issues.Add(Codes.ELIGIBILITY_MISMATCH, FieldPath(line, "NoDisc"), id, "the master record is not discountable, so the line must be marked NoDisc"); return null; } let basis = gross; let freeConcession = Zero; if (reference.Verified) { basis = ToMinorRounded(qty.times(reference.Unit), exponent); if (ofQty.gt(0)) { freeConcession = ToMinorRounded(ofQty.times(reference.Unit), exponent); } } else if (ofQty.gt(0)) { issues.Add(Codes.UNVERIFIED_FREE_QUANTITY, FieldPath(line, "OfQty"), id, "a free quantity on a line without a trusted reference price cannot be valued"); return null; } const taxInclusive = line.TaxInclusive === true; const priceConcession = basis.gt(gross) ? basis.minus(gross) : Zero; const markup = gross.gt(basis) ? gross.minus(basis) : Zero; if (noDisc) { const concession = disc.plus(priceConcession).plus(freeConcession); if (concession.gt(0)) { issues.Add(Codes.DISCOUNT_ON_NON_DISCOUNTABLE_LINE, FieldPath(line, "Disc"), id, "a non-discountable line must carry no discount, price reduction or free quantity"); return null; } return { Line: line, Partition: "NON_DISCOUNTABLE", Verified: reference.Verified, TaxInclusive: taxInclusive, Gross: gross, Basis: basis, Markup: markup, PriceConcession: Zero, FreeQuantityConcession: Zero, Disc: Zero, Capacity: Zero, SuppliedRecDisc: suppliedRecDisc, }; } if (disc.gt(gross)) { issues.Add(Codes.LINE_DISCOUNT_EXCEEDS_AMOUNT, FieldPath(line, "Disc"), id, "the line discount is larger than the line amount"); return null; } return { Line: line, Partition: "SALE", Verified: reference.Verified, TaxInclusive: taxInclusive, Gross: gross, Basis: basis, Markup: markup, PriceConcession: priceConcession, FreeQuantityConcession: freeConcession, Disc: disc, Capacity: gross.minus(disc), SuppliedRecDisc: suppliedRecDisc, }; } function Excluded(line: DiscountCalculationLine, partition: DiscountLinePartition, suppliedRecDisc: Big | undefined): WorkingLine { return { Line: line, Partition: partition, Verified: false, TaxInclusive: false, Gross: Zero, Basis: Zero, Markup: Zero, PriceConcession: Zero, FreeQuantityConcession: Zero, Disc: Zero, Capacity: Zero, SuppliedRecDisc: suppliedRecDisc, }; } /** Allocates one header amount over the given lines; reports and returns null on failure. */ function Allocate( issues: IssueList, amount: Big, field: string, lines: readonly WorkingLine[], weightOf: (line: WorkingLine) => Big, ): Map | null { const slots: AllocationSlot[] = lines.map(line => ({ Key: SlotKey(line.Line), Weight: weightOf(line), TieBreak: TieBreak(line.Line) })); const outcome = AllocateLargestRemainder(amount, slots); if (outcome.Ok) { return new Map(outcome.Shares); } if (outcome.Code === "NO_CAPACITY") { issues.Add(Codes.HEADER_DISCOUNT_WITHOUT_CAPACITY, field, null, "a positive header discount with no discountable amount to apply it to"); } else { issues.Add(Codes.HEADER_DISCOUNT_EXCEEDS_CAPACITY, field, null, "the header discount is larger than the discountable amount remaining"); } return null; } function ValidCurrency(currency: DiscountCalculationInput["Currency"] | null | undefined): boolean { return currency !== undefined && currency !== null && typeof currency === "object" && typeof currency.Code === "string" && /^[A-Z]{3}$/u.test(currency.Code) && Number.isInteger(currency.MinorUnitExponent) && currency.MinorUnitExponent >= 0 && currency.MinorUnitExponent <= MaxMinorUnitExponent; } /** * Calculates the trusted discount proposal for one merged document. * * Returns issues rather than throwing. Every issue is disqualifying: the * caller maps it to a refusal (a `400` for invalid or unsupported input). * Nothing is clamped, redistributed or filled in. */ export function CalculateDiscountProposal(input: DiscountCalculationInput): DiscountCalculationResult { const issues = new IssueList(); if (!ValidCurrency(input?.Currency)) { issues.Add(Codes.INVALID_CURRENCY, "Currency", null, "a trusted ISO 4217 code and a minor-unit exponent of 0-4 are required"); return { Accepted: false, Issues: issues.Items, Proposal: null }; } const exponent = input.Currency.MinorUnitExponent; const reader: MoneyReader = { Exponent: exponent, Issues: issues }; const header = input.Header ?? {}; // ---- Header --------------------------------------------------------------- const overall = ReadDiscountMinor(reader, header.Disc, "Disc", null); let parts: Big | null = Zero; let labour: Big | null = Zero; if (input.Kind === "INVOICE") { parts = ReadDiscountMinor(reader, header.PDisc, "PDisc", null); labour = ReadDiscountMinor(reader, header.LDisc, "LDisc", null); } else { for (const field of ["PDisc", "LDisc"] as const) { const value = header[field]; if (value !== undefined && value !== null) { const parsed = ParseExactDecimal(value); if (!parsed.Ok || !parsed.Value.eq(0)) { issues.Add(Codes.UNSUPPORTED_HEADER_FIELD, field, null, "a sales receipt carries only the overall discount"); } } } } let adjustConcession = Zero; if (header.Adjust !== undefined && header.Adjust !== null) { const adjust = ReadSignedMinor(reader, header.Adjust, "Adjust", null, true); if (adjust !== null && adjust.lt(0)) { adjustConcession = adjust.abs(); } } if (header.Round !== undefined && header.Round !== null) { // Not an authority input and not precision-checked: stored rounding // carries float noise. It only has to be rounding rather than a // concession, i.e. under one major unit either way. const round = ParseExactDecimal(header.Round); if (!round.Ok) { issues.Add(Codes.INVALID_NUMBER, "Round", null, round.Reason); } else if (round.Value.abs().gte(1)) { issues.Add(Codes.ROUND_OUT_OF_RANGE, "Round", null, "rounding must be less than one major unit"); } } // ---- Lines ---------------------------------------------------------------- const working: WorkingLine[] = []; for (const line of input.Lines ?? []) { if (line.Group === "SERVICE" && input.Kind === "SALES_RECEIPT") { issues.Add(Codes.UNSUPPORTED_LINE_TYPE, FieldPath(line, "Group"), line.Id, "a sales receipt has no service lines"); continue; } const read = ReadLine(reader, input.Kind, line); if (read !== null) { working.push(read); } } if (issues.Items.length > 0 || overall === null || parts === null || labour === null) { return { Accepted: false, Issues: issues.Items, Proposal: null }; } // ---- Allocation (§5.2 step 3) ---------------------------------------------- const sale = working.filter(line => line.Partition === "SALE"); const category = new Map(); for (const [amount, group, field] of [[parts, "ITEM", "PDisc"], [labour, "SERVICE", "LDisc"]] as const) { const pool = sale.filter(line => line.Line.Group === group); const shares = Allocate(issues, amount, field, pool, line => line.Capacity); shares?.forEach((share, key) => category.set(key, share)); } const categoryOf = (line: WorkingLine): Big => category.get(SlotKey(line.Line)) ?? Zero; const overallShares = issues.Items.length === 0 ? Allocate(issues, overall, "Disc", sale, line => line.Capacity.minus(categoryOf(line))) : null; if (overallShares === null) { return { Accepted: false, Issues: issues.Items, Proposal: null }; } const overallOf = (line: WorkingLine): Big => overallShares.get(SlotKey(line.Line)) ?? Zero; // ---- Verification of the client's RecDisc (§12 decision 5) -------------------- for (const line of working) { if (line.Partition === "RETURN") continue; // Original allocation is verified against the source receipt. const expected = categoryOf(line).plus(overallOf(line)); const field = FieldPath(line.Line, "RecDisc"); if (line.SuppliedRecDisc === undefined) { if (expected.gt(0)) { issues.Add(Codes.RECDISC_MISSING, field, line.Line.Id, `expected ${FormatMinor(expected, exponent)}; the server does not fill allocations`); } } else if (!line.SuppliedRecDisc.eq(expected)) { issues.Add( Codes.RECDISC_MISMATCH, field, line.Line.Id, `expected ${FormatMinor(expected, exponent)}, received ${FormatMinor(line.SuppliedRecDisc, exponent)}`, ); } } // ---- Line results ------------------------------------------------------- const lines: CalculatedDiscountLine[] = working.map(line => { const categoryShare = categoryOf(line); const overallShare = overallOf(line); const recDisc = categoryShare.plus(overallShare); const manual = line.PriceConcession.plus(line.FreeQuantityConcession).plus(line.Disc).plus(recDisc); return { Id: line.Line.Id, Group: line.Line.Group, Position: line.Line.Position, Partition: line.Partition, ReferenceVerified: line.Verified, Gross: FormatMinor(line.Gross, exponent), Basis: FormatMinor(line.Partition === "SALE" ? line.Basis : Zero, exponent), PriceConcession: FormatMinor(line.PriceConcession, exponent), FreeQuantityConcession: FormatMinor(line.FreeQuantityConcession, exponent), LineDiscount: FormatMinor(line.Disc, exponent), Capacity: FormatMinor(line.Capacity, exponent), CategoryAllocation: FormatMinor(categoryShare, exponent), OverallAllocation: FormatMinor(overallShare, exponent), ExpectedRecDisc: FormatMinor(recDisc, exponent), ManualDiscount: FormatMinor(manual, exponent), NetAmount: FormatMinor(line.Gross.minus(line.Disc).minus(recDisc), exponent), }; }); // ---- Concession-dependent shape checks ------------------------------------- const manualLines = SumBig(sale.map(line => line.PriceConcession.plus(line.FreeQuantityConcession).plus(line.Disc).plus(categoryOf(line)).plus(overallOf(line)))); const manualTotal = manualLines.plus(adjustConcession); for (const line of sale) { const concession = line.Disc.plus(line.FreeQuantityConcession).plus(categoryOf(line)).plus(overallOf(line)); if (line.Markup.gt(0) && concession.gt(0)) { issues.Add( Codes.PRICE_ABOVE_REFERENCE, FieldPath(line.Line, line.Line.Group === "ITEM" ? "UnPr" : "Pr"), line.Line.Id, "a line priced above its reference cannot also carry a discount; the markup would manufacture discount authority", ); } if (manualTotal.gt(0) && !line.Verified) { issues.Add( Codes.UNVERIFIED_REFERENCE_PRICE, FieldPath(line.Line, line.Line.Group === "ITEM" ? "UnPr" : "Pr"), line.Line.Id, `${line.Line.Reference.Kind === "UNVERIFIED" ? line.Line.Reference.Reason : "no trusted reference"}; ` + "a discountable line on a discounted document needs a trusted reference price, or must be marked NoDisc", ); } if (manualTotal.gt(0) && line.TaxInclusive) { issues.Add( Codes.TAX_INCLUSIVE_UNSUPPORTED, FieldPath(line.Line, line.Line.Group === "ITEM" ? "UnPr" : "Pr"), line.Line.Id, "the price includes tax; a tax-exclusive basis needs extraction through the tax rules, which is not supported yet", ); } } const sum = (pick: (line: WorkingLine) => Big, only: readonly WorkingLine[] = sale): string => FormatMinor(SumBig(only.map(pick)), exponent); const charged = working.filter(line => line.Partition === "SALE" || line.Partition === "NON_DISCOUNTABLE"); const totals: DiscountProposalTotals = { Basis: sum(line => line.Basis), Gross: sum(line => line.Gross, charged), PriceConcession: sum(line => line.PriceConcession), FreeQuantityConcession: sum(line => line.FreeQuantityConcession), LineDiscount: sum(line => line.Disc), PartsDiscount: FormatMinor(parts, exponent), LabourDiscount: FormatMinor(labour, exponent), OverallDiscount: FormatMinor(overall, exponent), AdjustmentConcession: FormatMinor(adjustConcession, exponent), ManualDiscount: FormatMinor(manualTotal, exponent), NetAmount: sum(line => line.Gross.minus(line.Disc).minus(categoryOf(line)).minus(overallOf(line)), charged), ReturnLines: working.filter(line => line.Partition === "RETURN").length, }; return { Accepted: issues.Items.length === 0, Issues: issues.Items, Proposal: { Currency: { Code: input.Currency.Code, MinorUnitExponent: exponent }, Lines: lines, Totals: totals }, }; }