import Big from "big.js"; import type { CalculatedDiscountLine, DiscountProposal } from "./discount-calculator"; import { DecimalPlaces, FloorDivide, FormatMinor, MaxMinorUnitExponent, SumBig, Zero } from "./exact-money"; /** * Discount authority ceilings: how far one set of grant envelopes reaches on * one proposed document (docs/planning/discount-authority.md §4, §7 in * MainAPI). * * Shared by the API, which enforces, and the Angular client, which previews, * so both resolve a ceiling to the same minor unit. The rule: * * 1. Each envelope is one grant's `Constraint`, tested as a **complete * envelope**: its own line ceiling covers every line and its own document * ceiling covers the total. The document is allowed when at least one * envelope does. Ceilings are never combined across envelopes -- no * per-dimension maxima, no sums (§4.1). * 2. A grant without a ceiling, or with a malformed one, refuses the whole * decision, even when another envelope would cover: an unconstrained grant * must never read as "No Limit" (§9). * 3. An AMOUNT in another currency cannot authorize, and neither covers nor * blocks the envelopes that can (§5.4). * * Ceilings resolve to money against the same basis before any comparison * (§4.2): `PERCENT p` → `floor(B × p / 100)`, `AMOUNT a` → `min(B, a)`, * `UNLIMITED` → `B`. Exact, in integer minor units, with no tolerance. * * Pure: no I/O, no clock, no authorization model. Which grants apply -- scope, * expiry, SA/EA -- is the caller's decision; `ApplicableCeilingEnvelopes` * makes it from the client login `Authz` for the UI, and MainAPI makes it from * the session for enforcement. The API always decides; a UI result is a guide. */ // ============================================================================ // Ceilings // ============================================================================ export type MonetaryCeiling = | { readonly Mode: "UNLIMITED" } | { readonly Mode: "PERCENT"; readonly Value: number } | { readonly Mode: "AMOUNT"; readonly Value: number; readonly Currency: string }; /** One grant's narrowing of a discount capability: the `Constraint` in login `Authz` rules. */ export interface MonetaryCeilingConstraint { readonly Type: "MONETARY_CEILING"; readonly LineCeiling: MonetaryCeiling; readonly DocumentCeiling: MonetaryCeiling; } export const UnlimitedMonetaryCeiling: MonetaryCeilingConstraint = Object.freeze({ Type: "MONETARY_CEILING", LineCeiling: Object.freeze({ Mode: "UNLIMITED" }), DocumentCeiling: Object.freeze({ Mode: "UNLIMITED" }), }); // ============================================================================ // Input // ============================================================================ /** Every amount is a non-negative fixed-point string at the currency's precision, e.g. "145.00". */ export interface MonetaryCeilingLine { /** Where the line sits, e.g. `Items[2]`. Reported on refusal. */ readonly Location: string; readonly LineId: string | null; /** B_i: the line's eligible basis. */ readonly Basis: string; /** D_i: every manual concession on the line, counted once. */ readonly ManualDiscount: string; } export interface MonetaryCeilingDocument { /** B: Σ B_i over the lines supplied. Checked, not trusted. */ readonly Basis: string; /** D: the document's total manual concession. Checked, not trusted. */ readonly ManualDiscount: string; /** * The part of D that belongs to the document rather than to any line -- a * negative `Adjust`, normalized into concession. Defaults to zero. It lets * the total be verified: `D = Σ D_i + this`. */ readonly AdjustmentConcession?: string; } /** The document side of the evaluation: what `MonetaryCeilingProposalOf` builds from a proposal. */ export interface MonetaryCeilingProposal { readonly Currency: { readonly Code: string; readonly MinorUnitExponent: number }; /** SALE lines only: returns, non-discountable and non-goods rows carry no basis. */ readonly Lines: readonly MonetaryCeilingLine[]; readonly Document: MonetaryCeilingDocument; } export interface MonetaryCeilingInput extends MonetaryCeilingProposal { /** * One entry per applicable grant, in a stable order: its `Constraint`, or * null/undefined for a grant that carries none. SA and EA pass one * `UnlimitedMonetaryCeiling`. Empty means no grant applies. */ readonly Envelopes: readonly (MonetaryCeilingConstraint | null | undefined)[]; } // ============================================================================ // Output // ============================================================================ export const MonetaryCeilingIssueCodes = Object.freeze({ INPUT_MALFORMED: "INPUT_MALFORMED", /** No envelope was supplied: the user holds no applicable grant. */ NO_APPLICABLE_GRANT: "NO_APPLICABLE_GRANT", /** An applicable grant carries no ceiling. Never read as "No Limit". */ CEILING_MISSING: "CEILING_MISSING", CEILING_MALFORMED: "CEILING_MALFORMED", /** Every envelope is an AMOUNT in another currency; none can authorize. */ CEILING_CURRENCY_MISMATCH: "CEILING_CURRENCY_MISMATCH", /** No single envelope covers every line and the total. */ AUTHORITY_LIMIT_EXCEEDED: "AUTHORITY_LIMIT_EXCEEDED", } as const); export type MonetaryCeilingIssueCode = (typeof MonetaryCeilingIssueCodes)[keyof typeof MonetaryCeilingIssueCodes]; export interface MonetaryCeilingLineView { readonly Location: string; readonly LineId: string | null; readonly Basis: string; readonly Proposed: string; /** This envelope's ceiling on this line, in money. */ readonly Ceiling: string; } export interface MonetaryCeilingDocumentView { readonly Basis: string; readonly Proposed: string; readonly Ceiling: string; } /** One envelope resolved to money against this document. */ export interface MonetaryCeilingEnvelopeView { /** Position of the envelope in `Envelopes`, so the caller can name the grant behind it. */ readonly EnvelopeIndex: number; readonly Lines: readonly MonetaryCeilingLineView[]; readonly Document: MonetaryCeilingDocumentView; } /** * The most permissive envelope, for showing what this document allows. * Envelopes are only partially ordered (§4.1): `Dominant` is true when this one * is at least as permissive as every other on every line and the total. When * false, another grant allows some shape this one does not. */ export interface MonetaryCeilingPreview extends MonetaryCeilingEnvelopeView { readonly Dominant: boolean; } export interface MonetaryCeilingFailure { readonly Kind: "LINE" | "DOCUMENT"; /** `DOCUMENT` for the total. */ readonly Location: string; readonly LineId: string | null; readonly Basis: string; readonly Proposed: string; readonly Ceiling: string; } export interface MonetaryCeilingAllowed { readonly Allowed: true; readonly Currency: string; /** The witness: the complete envelope that covers the proposal. The preview when it covers. */ readonly Evaluated: MonetaryCeilingEnvelopeView; readonly Preview: MonetaryCeilingPreview; } export interface MonetaryCeilingRefused { readonly Allowed: false; readonly Reason: MonetaryCeilingIssueCode; /** Limit refusals only: the first line, else the total, that exceeds the preview envelope. */ readonly Failure?: MonetaryCeilingFailure; /** Present whenever the envelopes could be resolved, so the UI can still guide. */ readonly Preview?: MonetaryCeilingPreview; } export type MonetaryCeilingResult = MonetaryCeilingAllowed | MonetaryCeilingRefused; // ============================================================================ // Building the input // ============================================================================ /** The document field a calculated line came from, e.g. `Items[2]` or `Ops[0]`. */ export function DiscountLineLocation(line: Pick): string { return `${line.Group === "ITEM" ? "Items" : "Ops"}[${line.Position}]`; } /** The document side of the evaluation, from an accepted `CalculateDiscountProposal` result. */ export function MonetaryCeilingProposalOf(proposal: DiscountProposal): MonetaryCeilingProposal { return Object.freeze({ Currency: proposal.Currency, Lines: Object.freeze(proposal.Lines .filter(line => line.Partition === "SALE") .map(line => Object.freeze({ Location: DiscountLineLocation(line), LineId: line.Id, Basis: line.Basis, ManualDiscount: line.ManualDiscount, }))), Document: Object.freeze({ Basis: proposal.Totals.Basis, ManualDiscount: proposal.Totals.ManualDiscount, AdjustmentConcession: proposal.Totals.AdjustmentConcession, }), }); } /** One rule of a capability in the client login `Authz.Caps`. */ export interface ClientCeilingRule { readonly Scope?: { readonly Type: string; readonly Ids: readonly (string | number)[] }; readonly ExpiresAt?: string; readonly Constraint?: MonetaryCeilingConstraint; } /** The part of the client login `Authz` this reads. */ export interface ClientCeilingAuthz { readonly SA?: boolean; readonly EA?: boolean; readonly Caps?: Readonly>; } /** * The envelopes that apply to a document in `entityId` at `nowEpochMs`, from * the client login `Authz` -- the same filter the API applies to the session: * SA/EA are unlimited; a rule without `Scope` is the session's own entity; a * scoped rule applies only when it is ENTITY scope naming `entityId`; an * expired rule never applies. A capability held as `true` has no ceiling, so it * yields one missing envelope and the evaluation refuses (§9). */ export function ApplicableCeilingEnvelopes( authz: ClientCeilingAuthz | null | undefined, capabilityKey: string, entityId: number, nowEpochMs: number, ): (MonetaryCeilingConstraint | undefined)[] { if (authz?.SA === true || authz?.EA === true) { return [UnlimitedMonetaryCeiling]; } const capability = authz?.Caps?.[capabilityKey]; if (capability === true) { return [undefined]; } if (!Array.isArray(capability)) { return []; } return capability .filter(rule => rule.Scope == null || (rule.Scope.Type === "ENTITY" && rule.Scope.Ids.includes(entityId))) .filter(rule => rule.ExpiresAt == null || Date.parse(rule.ExpiresAt) > nowEpochMs) .map(rule => rule.Constraint); } // ============================================================================ // Exact money // ============================================================================ const FixedPointPattern = /^(0|[1-9]\d*)(?:\.(\d+))?$/u; const CurrencyCodePattern = /^[A-Z]{3}$/u; const Hundred = new Big(100); const Ten = new Big(10); class MalformedInput extends Error {} /** Parses a non-negative fixed-point amount to integer minor units, or throws. */ function ParseMoney(value: unknown, exponent: number, fieldName: string): Big { const match = typeof value === "string" ? FixedPointPattern.exec(value) : null; const fraction = match?.[2] ?? ""; if (match == null || fraction.length > exponent) { throw new MalformedInput(`${fieldName} must be a non-negative amount with at most ${exponent} decimal places.`); } return new Big(match[1] + fraction + "0".repeat(exponent - fraction.length)); } function MinBig(left: Big, right: Big): Big { return left.lt(right) ? left : right; } type CeilingResolution = | { readonly Kind: "RESOLVED"; readonly Amount: (basis: Big) => Big } | { readonly Kind: "CURRENCY_MISMATCH" } | { readonly Kind: "MALFORMED" }; function IsFiniteNonNegative(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } /** §4.2: one ceiling as a function of the basis it is measured against. */ function ResolveCeiling(ceiling: unknown, currency: string, exponent: number): CeilingResolution { const value = ceiling as Record | null; switch (value?.Mode) { case "UNLIMITED": return { Kind: "RESOLVED", Amount: basis => basis }; case "PERCENT": { if (!IsFiniteNonNegative(value.Value) || value.Value > 100) { return { Kind: "MALFORMED" }; } // Floored, so rounding never permits more than the configured // percentage. Integer arithmetic: p = digits × 10^-scale. const percent = new Big(value.Value); const scale = DecimalPlaces(percent); const digits = percent.times(Ten.pow(scale)); const divisor = Hundred.times(Ten.pow(scale)); return { Kind: "RESOLVED", Amount: basis => FloorDivide(basis.times(digits), divisor).Quotient }; } case "AMOUNT": { if (!IsFiniteNonNegative(value.Value) || value.Value > Number.MAX_SAFE_INTEGER || typeof value.Currency !== "string" || !CurrencyCodePattern.test(value.Currency)) { return { Kind: "MALFORMED" }; } if (value.Currency !== currency) { return { Kind: "CURRENCY_MISMATCH" }; } // More places than the currency has would be refused at save; here // it is corruption, and rounding it either way would invent a limit. const amount = new Big(value.Value); if (DecimalPlaces(amount) > exponent) { return { Kind: "MALFORMED" }; } const minor = amount.times(Ten.pow(exponent)); return { Kind: "RESOLVED", Amount: basis => MinBig(basis, minor) }; } default: return { Kind: "MALFORMED" }; } } // ============================================================================ // Envelopes // ============================================================================ interface ParsedLine { readonly Location: string; readonly LineId: string | null; readonly Basis: Big; readonly Proposed: Big; } interface ParsedProposal { readonly Currency: string; readonly Exponent: number; readonly Lines: readonly ParsedLine[]; readonly Basis: Big; readonly Proposed: Big; } /** Ceilings for one envelope on this document, in minor units, line by line then the total. */ interface MoneyEnvelope { readonly Index: number; readonly Lines: readonly Big[]; readonly Document: Big; } type EnvelopeResolution = | { readonly Kind: "RESOLVED"; readonly Envelope: MoneyEnvelope } | { readonly Kind: "CURRENCY_MISMATCH" } | { readonly Kind: "MALFORMED" }; function ResolveEnvelope(constraint: unknown, index: number, proposal: ParsedProposal): EnvelopeResolution { const value = constraint as Record; if (value.Type !== "MONETARY_CEILING") { return { Kind: "MALFORMED" }; } const line = ResolveCeiling(value.LineCeiling, proposal.Currency, proposal.Exponent); const document = ResolveCeiling(value.DocumentCeiling, proposal.Currency, proposal.Exponent); if (line.Kind === "MALFORMED" || document.Kind === "MALFORMED") { return { Kind: "MALFORMED" }; } if (line.Kind === "CURRENCY_MISMATCH" || document.Kind === "CURRENCY_MISMATCH") { return { Kind: "CURRENCY_MISMATCH" }; } return { Kind: "RESOLVED", Envelope: { Index: index, Lines: proposal.Lines.map(entry => line.Amount(entry.Basis)), Document: document.Amount(proposal.Basis), }, }; } /** The first line, else the total, whose proposal exceeds this envelope; null when it covers. */ function FindExcess(envelope: MoneyEnvelope, proposal: ParsedProposal): number | "DOCUMENT" | null { const line = proposal.Lines.findIndex((entry, index) => entry.Proposed.gt(envelope.Lines[index])); if (line >= 0) { return line; } return proposal.Proposed.gt(envelope.Document) ? "DOCUMENT" : null; } function Dominates(left: MoneyEnvelope, right: MoneyEnvelope): boolean { return left.Document.gte(right.Document) && left.Lines.every((ceiling, index) => ceiling.gte(right.Lines[index])); } /** * The most permissive envelope for preview. A dominant one when it exists; * otherwise the largest document ceiling, then the largest line ceilings in * total, then input order -- deterministic, and flagged as not dominant. */ function SelectPreview(envelopes: readonly MoneyEnvelope[]): { Envelope: MoneyEnvelope; Dominant: boolean } { const dominant = envelopes.find(candidate => envelopes.every(other => Dominates(candidate, other))); if (dominant != null) { return { Envelope: dominant, Dominant: true }; } const ranked = [...envelopes].sort((left, right) => { const document = right.Document.cmp(left.Document); return document !== 0 ? document : SumBig(right.Lines).cmp(SumBig(left.Lines)); }); return { Envelope: ranked[0], Dominant: false }; } function View(envelope: MoneyEnvelope, proposal: ParsedProposal): MonetaryCeilingEnvelopeView { const format = (minor: Big): string => FormatMinor(minor, proposal.Exponent); return Object.freeze({ EnvelopeIndex: envelope.Index, Lines: Object.freeze(proposal.Lines.map((line, index) => Object.freeze({ Location: line.Location, LineId: line.LineId, Basis: format(line.Basis), Proposed: format(line.Proposed), Ceiling: format(envelope.Lines[index]), }))), Document: Object.freeze({ Basis: format(proposal.Basis), Proposed: format(proposal.Proposed), Ceiling: format(envelope.Document), }), }); } function Failure(envelope: MoneyEnvelope, proposal: ParsedProposal, excess: number | "DOCUMENT"): MonetaryCeilingFailure { const format = (minor: Big): string => FormatMinor(minor, proposal.Exponent); if (excess === "DOCUMENT") { return Object.freeze({ Kind: "DOCUMENT", Location: "DOCUMENT", LineId: null, Basis: format(proposal.Basis), Proposed: format(proposal.Proposed), Ceiling: format(envelope.Document), }); } const line = proposal.Lines[excess]; return Object.freeze({ Kind: "LINE", Location: line.Location, LineId: line.LineId, Basis: format(line.Basis), Proposed: format(line.Proposed), Ceiling: format(envelope.Lines[excess]), }); } // ============================================================================ // Evaluation // ============================================================================ function IsRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function ParseProposal(input: MonetaryCeilingProposal): ParsedProposal { const currency = input.Currency; if (!IsRecord(currency) || typeof currency.Code !== "string" || !CurrencyCodePattern.test(currency.Code) || !Number.isSafeInteger(currency.MinorUnitExponent) || currency.MinorUnitExponent < 0 || currency.MinorUnitExponent > MaxMinorUnitExponent) { throw new MalformedInput("Currency is malformed."); } const exponent = currency.MinorUnitExponent; if (!Array.isArray(input.Lines) || !IsRecord(input.Document)) { throw new MalformedInput("Lines and Document are required."); } const lines = input.Lines.map((line, index): ParsedLine => { if (!IsRecord(line) || typeof line.Location !== "string" || line.Location.length === 0 || (line.LineId !== null && typeof line.LineId !== "string")) { throw new MalformedInput(`Lines[${index}] is malformed.`); } return { Location: line.Location, LineId: line.LineId, Basis: ParseMoney(line.Basis, exponent, `Lines[${index}].Basis`), Proposed: ParseMoney(line.ManualDiscount, exponent, `Lines[${index}].ManualDiscount`), }; }); const basis = ParseMoney(input.Document.Basis, exponent, "Document.Basis"); // The document percentage is measured against Σ B_i. A caller that passed // a non-SALE line, or dropped one, would silently move the denominator. if (!SumBig(lines.map(line => line.Basis)).eq(basis)) { throw new MalformedInput("Document.Basis must equal the sum of the line bases."); } const proposed = ParseMoney(input.Document.ManualDiscount, exponent, "Document.ManualDiscount"); const adjustment = input.Document.AdjustmentConcession == null ? Zero : ParseMoney(input.Document.AdjustmentConcession, exponent, "Document.AdjustmentConcession"); // The total is verified, not believed. A total below Σ D_i would leave the // document ceiling holding nothing while each line still passed its own. if (!SumBig(lines.map(line => line.Proposed)).plus(adjustment).eq(proposed)) { throw new MalformedInput( "Document.ManualDiscount must equal the sum of the line concessions plus Document.AdjustmentConcession.", ); } return { Currency: currency.Code, Exponent: exponent, Lines: lines, Basis: basis, Proposed: proposed }; } function Refuse(reason: MonetaryCeilingIssueCode, extra: Omit = {}): MonetaryCeilingRefused { return Object.freeze({ Allowed: false, Reason: reason, ...extra }); } function Evaluate(input: MonetaryCeilingInput): MonetaryCeilingResult { if (!IsRecord(input) || !Array.isArray(input.Envelopes)) { return Refuse(MonetaryCeilingIssueCodes.INPUT_MALFORMED); } let proposal: ParsedProposal; try { proposal = ParseProposal(input); } catch (error) { if (error instanceof MalformedInput) { return Refuse(MonetaryCeilingIssueCodes.INPUT_MALFORMED); } throw error; } if (input.Envelopes.length === 0) { return Refuse(MonetaryCeilingIssueCodes.NO_APPLICABLE_GRANT); } const envelopes: MoneyEnvelope[] = []; let currencyMismatches = 0; for (let index = 0; index < input.Envelopes.length; index += 1) { const constraint = input.Envelopes[index]; if (constraint == null) { return Refuse(MonetaryCeilingIssueCodes.CEILING_MISSING); } if (!IsRecord(constraint)) { return Refuse(MonetaryCeilingIssueCodes.CEILING_MALFORMED); } const resolved = ResolveEnvelope(constraint, index, proposal); if (resolved.Kind === "MALFORMED") { return Refuse(MonetaryCeilingIssueCodes.CEILING_MALFORMED); } if (resolved.Kind === "CURRENCY_MISMATCH") { currencyMismatches += 1; continue; } envelopes.push(resolved.Envelope); } if (envelopes.length === 0 && currencyMismatches > 0) { return Refuse(MonetaryCeilingIssueCodes.CEILING_CURRENCY_MISMATCH); } const preview = SelectPreview(envelopes); const previewView: MonetaryCeilingPreview = Object.freeze({ ...View(preview.Envelope, proposal), Dominant: preview.Dominant, }); const covering = envelopes.filter(envelope => FindExcess(envelope, proposal) == null); if (covering.length === 0) { // Report against what the user could have done: the preview envelope. const excess = FindExcess(preview.Envelope, proposal) ?? "DOCUMENT"; return Refuse(MonetaryCeilingIssueCodes.AUTHORITY_LIMIT_EXCEEDED, { Failure: Failure(preview.Envelope, proposal, excess), Preview: previewView, }); } // Prefer the previewed envelope as witness when it covers, so the decision // names the grant the UI showed; otherwise the first in input order. const witness = covering.includes(preview.Envelope) ? preview.Envelope : covering[0]; return Object.freeze({ Allowed: true, Currency: proposal.Currency, Evaluated: View(witness, proposal), Preview: previewView, }); } /** * Decides whether a set of grant envelopes covers one proposed document's * manual concessions. Pure and deterministic; never throws, and never clamps -- * a refusal carries where and by how much. */ export function EvaluateMonetaryCeiling(input: MonetaryCeilingInput): MonetaryCeilingResult { try { return Evaluate(input); } catch { return Refuse(MonetaryCeilingIssueCodes.INPUT_MALFORMED); } }