@crewhaus/tool-money 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Splitting money without losing or inventing any.
3
+ *
4
+ * Allocating an order-level discount across lines, or tax across returned
5
+ * items, is the place cents go missing: divide, round each share, and the
6
+ * shares no longer sum to what you started with. The refund is then a cent
7
+ * short, or a cent over, and the ledger does not balance.
8
+ *
9
+ * Largest-remainder allocation fixes that by construction: the shares always
10
+ * sum to the total, exactly, whatever the weights.
11
+ */
12
+ /**
13
+ * Split `totalMinor` across `weights` so the parts sum to it exactly.
14
+ *
15
+ * Each part is the floor of its exact share; the leftover units go to the
16
+ * largest remainders, ties broken by position so the answer is stable. With
17
+ * all-zero weights the total is spread evenly from the front rather than
18
+ * dropped, since dropping it would silently lose the money.
19
+ */
20
+ export function allocateProportional(totalMinor, weights) {
21
+ if (!Number.isInteger(totalMinor))
22
+ throw new Error("totalMinor must be an integer");
23
+ if (weights.length === 0)
24
+ return [];
25
+ if (weights.some((w) => w < 0))
26
+ throw new Error("weights must not be negative");
27
+ const sum = weights.reduce((a, b) => a + b, 0);
28
+ const negative = totalMinor < 0;
29
+ const magnitude = Math.abs(totalMinor);
30
+ if (sum === 0) {
31
+ // Nothing to weigh by. Spread evenly rather than returning zeros, which
32
+ // would drop the amount entirely.
33
+ const base = Math.floor(magnitude / weights.length);
34
+ const parts = weights.map(() => base);
35
+ let leftover = magnitude - base * weights.length;
36
+ for (let i = 0; leftover > 0; i = (i + 1) % weights.length, leftover--) {
37
+ parts[i] = parts[i] + 1;
38
+ }
39
+ return negative ? parts.map((p) => -p) : parts;
40
+ }
41
+ const exact = weights.map((w) => (magnitude * w) / sum);
42
+ const parts = exact.map((value) => Math.floor(value));
43
+ let leftover = magnitude - parts.reduce((a, b) => a + b, 0);
44
+ const order = exact
45
+ .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
46
+ .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
47
+ for (let i = 0; leftover > 0; i++, leftover--) {
48
+ const target = order[i % order.length]?.index;
49
+ parts[target] = parts[target] + 1;
50
+ }
51
+ return negative ? parts.map((p) => -p) : parts;
52
+ }
53
+ export const SHIPPING_POLICIES = ["none", "proportional", "full"];
54
+ /**
55
+ * What to refund for a partial return.
56
+ *
57
+ * Both allocations are proportional to what the customer actually paid for
58
+ * the returned units: an order-level discount applies to them in the same
59
+ * proportion it applied to the order, and tax follows the discounted amount.
60
+ * Refunding the list price of a discounted item refunds more than was taken.
61
+ */
62
+ export function computeRefund(lines, returned, options = {}) {
63
+ const index = new Map(lines.map((l) => [l.id, l]));
64
+ for (const item of returned) {
65
+ const line = index.get(item.lineId);
66
+ if (!line)
67
+ throw new Error(`returned line "${item.lineId}" is not in the order`);
68
+ if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
69
+ throw new Error(`returned line "${item.lineId}" has quantity ${item.quantity}; it must be a positive integer`);
70
+ }
71
+ if (item.quantity > line.quantity) {
72
+ throw new Error(`returned line "${item.lineId}" returns ${item.quantity} of ${line.quantity} ordered`);
73
+ }
74
+ }
75
+ // The order discount is spread over ALL lines by their net value, then the
76
+ // returned fraction of each line's share comes back. Spreading it over the
77
+ // returned lines only would refund the whole discount on a partial return.
78
+ const lineNet = lines.map((l) => l.quantity * l.unitPriceMinor - (l.discountMinor ?? 0));
79
+ const orderDiscountShares = allocateProportional(options.orderDiscountMinor ?? 0, lineNet);
80
+ const shareByLine = new Map(lines.map((l, i) => [l.id, orderDiscountShares[i]]));
81
+ const results = [];
82
+ for (const item of returned) {
83
+ const line = index.get(item.lineId);
84
+ const fraction = [item.quantity, line.quantity];
85
+ const gross = line.unitPriceMinor * item.quantity;
86
+ const lineDiscount = splitByQuantity(line.discountMinor ?? 0, fraction);
87
+ const orderDiscount = splitByQuantity(shareByLine.get(line.id) ?? 0, fraction);
88
+ const tax = splitByQuantity(line.taxMinor ?? 0, fraction);
89
+ results.push({
90
+ lineId: line.id,
91
+ quantity: item.quantity,
92
+ grossMinor: gross,
93
+ lineDiscountMinor: lineDiscount,
94
+ orderDiscountMinor: orderDiscount,
95
+ taxMinor: tax,
96
+ refundMinor: gross - lineDiscount - orderDiscount + tax,
97
+ });
98
+ }
99
+ const fullReturn = lines.every((l) => (returned.find((r) => r.lineId === l.id)?.quantity ?? 0) === l.quantity);
100
+ const policy = options.shippingPolicy ?? "none";
101
+ const shippingTotal = options.shippingMinor ?? 0;
102
+ let shippingMinor = 0;
103
+ if (policy === "full" || (policy === "proportional" && fullReturn)) {
104
+ shippingMinor = shippingTotal;
105
+ }
106
+ else if (policy === "proportional") {
107
+ // Spread the shipping over EVERY line by its value, then take only the
108
+ // returned fraction of each share. Allocating across the returned lines
109
+ // and summing the parts gives back the whole amount every time — an
110
+ // allocation's parts always sum to what was allocated — so a one-item
111
+ // return refunded the entire shipping charge.
112
+ const shares = allocateProportional(shippingTotal, lines.map((l) => l.quantity * l.unitPriceMinor));
113
+ shippingMinor = lines.reduce((total, line, i) => {
114
+ const returnedQuantity = returned.find((r) => r.lineId === line.id)?.quantity ?? 0;
115
+ if (returnedQuantity === 0)
116
+ return total;
117
+ return total + splitByQuantity(shares[i], [returnedQuantity, line.quantity]);
118
+ }, 0);
119
+ }
120
+ const subtotalMinor = results.reduce((s, r) => s + r.grossMinor - r.lineDiscountMinor - r.orderDiscountMinor, 0);
121
+ const taxMinor = results.reduce((s, r) => s + r.taxMinor, 0);
122
+ const restockingFeeMinor = options.restockingFeeMinor ?? 0;
123
+ return {
124
+ lines: results,
125
+ shippingMinor,
126
+ restockingFeeMinor,
127
+ subtotalMinor,
128
+ taxMinor,
129
+ totalMinor: subtotalMinor + taxMinor + shippingMinor - restockingFeeMinor,
130
+ fullReturn,
131
+ };
132
+ }
133
+ /**
134
+ * The returned fraction of a line-level amount.
135
+ *
136
+ * Allocating across the line's units and summing the returned ones — rather
137
+ * than multiplying by a fraction and rounding — is what makes the parts of a
138
+ * fully returned line add back up to the whole.
139
+ */
140
+ function splitByQuantity(amountMinor, [returned, total]) {
141
+ if (total <= 0 || amountMinor === 0)
142
+ return 0;
143
+ const perUnit = allocateProportional(amountMinor, new Array(total).fill(1));
144
+ return perUnit.slice(0, returned).reduce((a, b) => a + b, 0);
145
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Code invoice lines to a GL account and cost centre from operator rules.
3
+ *
4
+ * Most lines on most invoices are routine and code the same way every month.
5
+ * Spending a model turn on each one costs tokens and, worse, produces a
6
+ * coding nobody can reproduce at year end. Rules code the routine ones
7
+ * identically forever and leave the genuinely unclear ones in a review
8
+ * queue, which is the only part a person should see.
9
+ *
10
+ * Conditions are `@crewhaus/tool-schema`'s check grammar, the same one
11
+ * `Assert` and `Branch` use.
12
+ */
13
+ import { type Check } from "@crewhaus/tool-schema";
14
+ export type CodingRule = {
15
+ readonly id: string;
16
+ readonly when: ReadonlyArray<Check>;
17
+ readonly account: string;
18
+ readonly costCenter?: string;
19
+ readonly taxCode?: string;
20
+ /** Higher wins when several rules match. Defaults to 0. */
21
+ readonly priority?: number;
22
+ };
23
+ export type CodedLine = {
24
+ readonly lineId: string;
25
+ readonly account: string | null;
26
+ readonly costCenter: string | null;
27
+ readonly taxCode: string | null;
28
+ /** Rule ids that matched, highest priority first. */
29
+ readonly matched: ReadonlyArray<string>;
30
+ /** True when more than one rule matched at the winning priority. */
31
+ readonly ambiguous: boolean;
32
+ readonly needsReview: boolean;
33
+ readonly reason: string;
34
+ };
35
+ export type CodingResult = {
36
+ readonly lines: ReadonlyArray<CodedLine>;
37
+ readonly coded: number;
38
+ readonly needsReview: number;
39
+ readonly version: string | null;
40
+ readonly byAccount: ReadonlyArray<{
41
+ readonly account: string;
42
+ readonly lines: number;
43
+ }>;
44
+ };
45
+ export declare function codeLines(lines: ReadonlyArray<{
46
+ readonly id: string;
47
+ } & Record<string, unknown>>, rules: ReadonlyArray<CodingRule>, options?: {
48
+ readonly version?: string;
49
+ readonly defaultAccount?: string;
50
+ }): CodingResult;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Code invoice lines to a GL account and cost centre from operator rules.
3
+ *
4
+ * Most lines on most invoices are routine and code the same way every month.
5
+ * Spending a model turn on each one costs tokens and, worse, produces a
6
+ * coding nobody can reproduce at year end. Rules code the routine ones
7
+ * identically forever and leave the genuinely unclear ones in a review
8
+ * queue, which is the only part a person should see.
9
+ *
10
+ * Conditions are `@crewhaus/tool-schema`'s check grammar, the same one
11
+ * `Assert` and `Branch` use.
12
+ */
13
+ import { runChecks } from "@crewhaus/tool-schema";
14
+ export function codeLines(lines, rules, options = {}) {
15
+ const seen = new Set();
16
+ for (const rule of rules) {
17
+ if (rule.when.length === 0) {
18
+ throw new Error(`rule "${rule.id}" has no conditions, so it would code every line`);
19
+ }
20
+ if (seen.has(rule.id))
21
+ throw new Error(`two rules share the id "${rule.id}"`);
22
+ seen.add(rule.id);
23
+ }
24
+ const results = [];
25
+ for (const line of lines) {
26
+ const matches = rules
27
+ .filter((rule) => runChecks(line, rule.when).ok)
28
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
29
+ if (matches.length === 0) {
30
+ results.push({
31
+ lineId: line.id,
32
+ account: options.defaultAccount ?? null,
33
+ costCenter: null,
34
+ taxCode: null,
35
+ matched: [],
36
+ ambiguous: false,
37
+ // A default account is a place to put it, not a coding decision, so
38
+ // the line still goes to review rather than quietly landing in a
39
+ // suspense account nobody looks at.
40
+ needsReview: true,
41
+ reason: "no rule matched",
42
+ });
43
+ continue;
44
+ }
45
+ const top = matches[0];
46
+ const topPriority = top.priority ?? 0;
47
+ const tied = matches.filter((r) => (r.priority ?? 0) === topPriority);
48
+ // Two rules at the same priority disagreeing is a rule-set bug. Picking
49
+ // one would hide it, and the wrong account is found in an audit, not in
50
+ // a test.
51
+ const conflicting = tied.some((r) => r.account !== top.account);
52
+ results.push({
53
+ lineId: line.id,
54
+ account: conflicting ? null : top.account,
55
+ costCenter: conflicting ? null : (top.costCenter ?? null),
56
+ taxCode: conflicting ? null : (top.taxCode ?? null),
57
+ matched: matches.map((r) => r.id),
58
+ ambiguous: conflicting,
59
+ needsReview: conflicting,
60
+ reason: conflicting
61
+ ? `rules ${tied.map((r) => `"${r.id}"`).join(", ")} match at the same priority and disagree on the account`
62
+ : "",
63
+ });
64
+ }
65
+ const tally = new Map();
66
+ for (const line of results) {
67
+ if (line.account === null || line.needsReview)
68
+ continue;
69
+ tally.set(line.account, (tally.get(line.account) ?? 0) + 1);
70
+ }
71
+ return {
72
+ lines: results,
73
+ coded: results.filter((l) => !l.needsReview).length,
74
+ needsReview: results.filter((l) => l.needsReview).length,
75
+ version: options.version ?? null,
76
+ byAccount: [...tally.entries()]
77
+ .map(([account, count]) => ({ account, lines: count }))
78
+ .sort((a, b) => b.lines - a.lines || (a.account < b.account ? -1 : 1)),
79
+ };
80
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Spending controls and refund-abuse signals.
3
+ *
4
+ * An unattended harness that can move money needs a limit it cannot talk
5
+ * itself past. A limit a model is asked to respect is a suggestion; a limit
6
+ * computed from the record of what has already been spent is a limit.
7
+ *
8
+ * Both functions take the history as an argument and the clock as a
9
+ * parameter. Nothing here reads a database or the time, so the same facts
10
+ * always give the same verdict — which is what makes a refusal explicable
11
+ * afterwards.
12
+ */
13
+ export type Spend = {
14
+ readonly id: string;
15
+ /** ISO-8601 with an offset. */
16
+ readonly at: string;
17
+ readonly amountMinor: number;
18
+ readonly counterparty?: string;
19
+ };
20
+ export type SpendLimits = {
21
+ /** Largest single payment allowed. */
22
+ readonly perTransactionMinor?: number;
23
+ readonly perHourMinor?: number;
24
+ readonly perDayMinor?: number;
25
+ readonly perWeekMinor?: number;
26
+ /** Cap on the total to any one counterparty within the day window. */
27
+ readonly perCounterpartyPerDayMinor?: number;
28
+ readonly maxTransactionsPerHour?: number;
29
+ /**
30
+ * Hours during which nothing may move, as UTC hours 0-23. The window is
31
+ * about when a human could notice, so it is deliberately absolute rather
32
+ * than local to anybody.
33
+ */
34
+ readonly quietHoursUtc?: ReadonlyArray<number>;
35
+ /** Payments to a counterparty never paid before are refused. */
36
+ readonly knownCounterpartiesOnly?: boolean;
37
+ readonly knownCounterparties?: ReadonlyArray<string>;
38
+ };
39
+ export type SpendDecision = {
40
+ readonly allowed: boolean;
41
+ /** Every limit the payment breaks, not just the first. */
42
+ readonly violations: ReadonlyArray<{
43
+ readonly limit: string;
44
+ readonly capMinor: number | null;
45
+ readonly wouldBeMinor: number | null;
46
+ readonly detail: string;
47
+ }>;
48
+ readonly windows: Readonly<Record<string, number>>;
49
+ readonly headroomMinor: number | null;
50
+ };
51
+ export declare function checkSpendLimit(proposed: {
52
+ readonly amountMinor: number;
53
+ readonly counterparty?: string;
54
+ }, history: ReadonlyArray<Spend>, limits: SpendLimits, nowMs: number): SpendDecision;
55
+ export type RefundEvent = {
56
+ readonly id: string;
57
+ readonly at: string;
58
+ readonly amountMinor: number;
59
+ readonly reason?: string;
60
+ readonly replacement?: boolean;
61
+ };
62
+ export type OrderEvent = {
63
+ readonly id: string;
64
+ readonly at: string;
65
+ readonly amountMinor: number;
66
+ };
67
+ export type AbuseSignals = {
68
+ readonly windows: ReadonlyArray<{
69
+ readonly days: number;
70
+ readonly refunds: number;
71
+ readonly refundedMinor: number;
72
+ readonly orders: number;
73
+ readonly spentMinor: number;
74
+ /** Refunded over spent, in basis points. 10000 is everything. */
75
+ readonly ratioBps: number | null;
76
+ }>;
77
+ readonly replacements: number;
78
+ readonly nonDeliveryClaims: number;
79
+ readonly flags: ReadonlyArray<string>;
80
+ };
81
+ /**
82
+ * Refund-abuse signals over fixed windows.
83
+ *
84
+ * This reports ratios and counts. It does not decide that somebody is a
85
+ * fraudster, and the tool that wraps it must not present it as if it had —
86
+ * a high ratio is also what a customer with one broken delivery looks like.
87
+ */
88
+ export declare function refundAbuseSignals(refunds: ReadonlyArray<RefundEvent>, orders: ReadonlyArray<OrderEvent>, nowMs: number, thresholds?: {
89
+ readonly ratioBps?: number;
90
+ readonly refundCount?: number;
91
+ }): AbuseSignals;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Spending controls and refund-abuse signals.
3
+ *
4
+ * An unattended harness that can move money needs a limit it cannot talk
5
+ * itself past. A limit a model is asked to respect is a suggestion; a limit
6
+ * computed from the record of what has already been spent is a limit.
7
+ *
8
+ * Both functions take the history as an argument and the clock as a
9
+ * parameter. Nothing here reads a database or the time, so the same facts
10
+ * always give the same verdict — which is what makes a refusal explicable
11
+ * afterwards.
12
+ */
13
+ function instant(value, what) {
14
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())) {
15
+ throw new Error(`${what} ("${value}") has no UTC offset`);
16
+ }
17
+ const parsed = Date.parse(value);
18
+ if (Number.isNaN(parsed))
19
+ throw new Error(`${what} ("${value}") is not a valid ISO-8601 instant`);
20
+ return parsed;
21
+ }
22
+ const HOUR = 3_600_000;
23
+ const DAY = 24 * HOUR;
24
+ const WEEK = 7 * DAY;
25
+ export function checkSpendLimit(proposed, history, limits, nowMs) {
26
+ const entries = history.map((s) => ({ ...s, atMs: instant(s.at, `spend "${s.id}" at`) }));
27
+ const since = (ms) => entries.filter((e) => nowMs - e.atMs < ms && e.atMs <= nowMs);
28
+ const sum = (list) => list.reduce((t, e) => t + e.amountMinor, 0);
29
+ const hour = since(HOUR);
30
+ const day = since(DAY);
31
+ const week = since(WEEK);
32
+ const windows = {
33
+ hourMinor: sum(hour),
34
+ dayMinor: sum(day),
35
+ weekMinor: sum(week),
36
+ hourCount: hour.length,
37
+ };
38
+ const violations = [];
39
+ const check = (name, cap, spent) => {
40
+ if (cap === undefined)
41
+ return;
42
+ const wouldBe = spent + proposed.amountMinor;
43
+ if (wouldBe > cap) {
44
+ violations.push({
45
+ limit: name,
46
+ capMinor: cap,
47
+ wouldBeMinor: wouldBe,
48
+ detail: `${spent} already spent in this window, ${proposed.amountMinor} proposed, cap ${cap}`,
49
+ });
50
+ }
51
+ };
52
+ if (limits.perTransactionMinor !== undefined &&
53
+ proposed.amountMinor > limits.perTransactionMinor) {
54
+ violations.push({
55
+ limit: "perTransaction",
56
+ capMinor: limits.perTransactionMinor,
57
+ wouldBeMinor: proposed.amountMinor,
58
+ detail: `a single payment of ${proposed.amountMinor} is over the ${limits.perTransactionMinor} ceiling`,
59
+ });
60
+ }
61
+ check("perHour", limits.perHourMinor, windows.hourMinor);
62
+ check("perDay", limits.perDayMinor, windows.dayMinor);
63
+ check("perWeek", limits.perWeekMinor, windows.weekMinor);
64
+ if (limits.perCounterpartyPerDayMinor !== undefined && proposed.counterparty !== undefined) {
65
+ const spent = sum(day.filter((e) => e.counterparty === proposed.counterparty));
66
+ check(`perCounterpartyPerDay:${proposed.counterparty}`, limits.perCounterpartyPerDayMinor, spent);
67
+ }
68
+ if (limits.maxTransactionsPerHour !== undefined &&
69
+ windows.hourCount + 1 > limits.maxTransactionsPerHour) {
70
+ violations.push({
71
+ limit: "maxTransactionsPerHour",
72
+ capMinor: null,
73
+ wouldBeMinor: null,
74
+ detail: `${windows.hourCount} payments in the last hour, cap ${limits.maxTransactionsPerHour}`,
75
+ });
76
+ }
77
+ if (limits.quietHoursUtc && limits.quietHoursUtc.length > 0) {
78
+ const hourUtc = new Date(nowMs).getUTCHours();
79
+ if (limits.quietHoursUtc.includes(hourUtc)) {
80
+ violations.push({
81
+ limit: "quietHours",
82
+ capMinor: null,
83
+ wouldBeMinor: null,
84
+ detail: `${hourUtc}:00 UTC is inside the quiet window, when nobody is watching`,
85
+ });
86
+ }
87
+ }
88
+ if (limits.knownCounterpartiesOnly) {
89
+ // When the operator supplies a list, that list IS the allow-list.
90
+ //
91
+ // Deriving "known" from payment history as well looked like a
92
+ // convenience and was a hole: one payment that got through by any means
93
+ // added its counterparty to the allow-list permanently, so the control
94
+ // stopped exactly one payment and then approved every one after it. A
95
+ // control that weakens itself the first time it is defeated is not a
96
+ // control.
97
+ //
98
+ // With no list supplied there is nothing else to go on, so history is
99
+ // used — a weaker rule, and one the result names.
100
+ const known = new Set(limits.knownCounterparties && limits.knownCounterparties.length > 0
101
+ ? limits.knownCounterparties
102
+ : entries.map((e) => e.counterparty).filter((c) => c !== undefined));
103
+ const basis = limits.knownCounterparties && limits.knownCounterparties.length > 0
104
+ ? "the declared allow-list"
105
+ : "counterparties paid before, since no allow-list was declared";
106
+ if (proposed.counterparty === undefined || !known.has(proposed.counterparty)) {
107
+ violations.push({
108
+ limit: "knownCounterpartiesOnly",
109
+ capMinor: null,
110
+ wouldBeMinor: null,
111
+ detail: proposed.counterparty === undefined
112
+ ? "the payment names no counterparty, and only known ones are allowed"
113
+ : `"${proposed.counterparty}" is not in ${basis}`,
114
+ });
115
+ }
116
+ }
117
+ // Headroom is the tightest remaining amount limit, which is the number a
118
+ // caller needs to propose something that would pass.
119
+ const caps = [
120
+ limits.perTransactionMinor,
121
+ limits.perHourMinor === undefined ? undefined : limits.perHourMinor - windows.hourMinor,
122
+ limits.perDayMinor === undefined ? undefined : limits.perDayMinor - windows.dayMinor,
123
+ limits.perWeekMinor === undefined ? undefined : limits.perWeekMinor - windows.weekMinor,
124
+ ].filter((v) => v !== undefined);
125
+ return {
126
+ allowed: violations.length === 0,
127
+ violations,
128
+ windows,
129
+ headroomMinor: caps.length === 0 ? null : Math.max(0, Math.min(...caps)),
130
+ };
131
+ }
132
+ /**
133
+ * Refund-abuse signals over fixed windows.
134
+ *
135
+ * This reports ratios and counts. It does not decide that somebody is a
136
+ * fraudster, and the tool that wraps it must not present it as if it had —
137
+ * a high ratio is also what a customer with one broken delivery looks like.
138
+ */
139
+ export function refundAbuseSignals(refunds, orders, nowMs, thresholds = {}) {
140
+ const windows = [30, 90, 365].map((days) => {
141
+ const cutoff = nowMs - days * DAY;
142
+ const r = refunds.filter((x) => instant(x.at, `refund "${x.id}" at`) >= cutoff);
143
+ const o = orders.filter((x) => instant(x.at, `order "${x.id}" at`) >= cutoff);
144
+ const refundedMinor = r.reduce((s, x) => s + x.amountMinor, 0);
145
+ const spentMinor = o.reduce((s, x) => s + x.amountMinor, 0);
146
+ return {
147
+ days,
148
+ refunds: r.length,
149
+ refundedMinor,
150
+ orders: o.length,
151
+ spentMinor,
152
+ // Undefined rather than infinite when nothing was spent: a ratio
153
+ // against zero is not a large number, it is not a number.
154
+ ratioBps: spentMinor === 0 ? null : Math.round((refundedMinor * 10_000) / spentMinor),
155
+ };
156
+ });
157
+ const flags = [];
158
+ const ratioCap = thresholds.ratioBps;
159
+ const countCap = thresholds.refundCount;
160
+ for (const w of windows) {
161
+ if (ratioCap !== undefined && w.ratioBps !== null && w.ratioBps > ratioCap) {
162
+ flags.push(`refunded ${(w.ratioBps / 100).toFixed(1)}% of spend in ${w.days} days`);
163
+ }
164
+ if (countCap !== undefined && w.refunds > countCap) {
165
+ flags.push(`${w.refunds} refunds in ${w.days} days`);
166
+ }
167
+ }
168
+ return {
169
+ windows,
170
+ replacements: refunds.filter((r) => r.replacement).length,
171
+ nonDeliveryClaims: refunds.filter((r) => /not.?(delivered|received)|never arrived|missing/i.test(r.reason ?? "")).length,
172
+ flags,
173
+ };
174
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Payment identifier validation.
3
+ *
4
+ * Every one of these carries a checksum precisely so a typo is caught before
5
+ * money moves, and every one of them is arithmetic — there is nothing for a
6
+ * model to judge. Getting it wrong is expensive in a way that most wrong
7
+ * answers are not, so each algorithm here is the published one and the tests
8
+ * use published vectors.
9
+ *
10
+ * A valid checksum means the identifier is well-formed. It does not mean the
11
+ * account exists, is open, or belongs to who you think. Nothing here reaches
12
+ * a network, and the result says as much.
13
+ */
14
+ export declare const IDENTIFIER_KINDS: readonly ["iban", "bic", "aba", "card", "sortcode"];
15
+ export type IdentifierKind = (typeof IDENTIFIER_KINDS)[number];
16
+ export type IdentifierResult = {
17
+ readonly kind: IdentifierKind;
18
+ readonly valid: boolean;
19
+ /** The canonical form: spacing removed, case normalized. */
20
+ readonly normalized: string;
21
+ /** How it should be shown to a person, where a convention exists. */
22
+ readonly formatted: string;
23
+ /** Empty when valid; otherwise the specific thing that is wrong. */
24
+ readonly reason: string;
25
+ /** Whatever the identifier itself encodes — country, bank, check digits. */
26
+ readonly parts: Readonly<Record<string, string>>;
27
+ };
28
+ export declare function validateIban(raw: string): IdentifierResult;
29
+ export declare function validateBic(raw: string): IdentifierResult;
30
+ /** ABA routing number: a weighted sum over nine digits, 3-7-1 repeating. */
31
+ export declare function validateAba(raw: string): IdentifierResult;
32
+ /** Luhn, as used by payment cards. */
33
+ export declare function luhnOk(digits: string): boolean;
34
+ /**
35
+ * Validate a card number's checksum.
36
+ *
37
+ * Only the last four digits are ever echoed. A validator that returned the
38
+ * number it was given would take a value the caller has to protect and copy
39
+ * it into a log, a transcript and a model's context.
40
+ */
41
+ export declare function validateCard(raw: string): IdentifierResult;
42
+ /** UK sort code: six digits, and no checksum exists to check. */
43
+ export declare function validateSortCode(raw: string): IdentifierResult;
44
+ /** Guess which kind an identifier is, for callers that do not know. */
45
+ export declare function detectKind(raw: string): IdentifierKind | null;
46
+ export declare function validateIdentifier(raw: string, kind: IdentifierKind): IdentifierResult;