@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.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @crewhaus/tool-money
2
+
3
+ Money arithmetic and the controls around it.
4
+
5
+ Everything here has a right answer that a model can get wrong in a way nobody
6
+ notices until an audit: a cent lost allocating a discount, tax charged on the
7
+ wrong base, a date read month-first, a refund that returns list price for a
8
+ discounted item.
9
+
10
+ | Tool | Answers |
11
+ |---|---|
12
+ | `PaymentIdentifierValidate` | is this IBAN, BIC, routing number, card or sort code well-formed |
13
+ | `TaxCalculate` | what tax is due, per line and per invoice |
14
+ | `RefundAmountCompute` | what to refund for a partial return |
15
+ | `PurchaseOrderMatch` | does this invoice agree with the order and the receipt |
16
+ | `CostBasisCompute` | what gain did this disposal realize, and from which lots |
17
+ | `SpendLimitCheck` | may this payment go out |
18
+ | `RefundAbuseCheck` | what do this customer's refund patterns look like |
19
+ | `WebhookSignatureVerify` | did this event really come from the provider |
20
+ | `StatementParse` | what transactions does this export actually contain |
21
+ | `GlCodeSuggest` | which lines code themselves, and which need a person |
22
+
23
+ ## Money is integer minor units
24
+
25
+ Cents, pence, yen. Floating point produces totals that do not add up, and
26
+ "off by a cent" on an invoice is not a rounding detail — it is a document
27
+ that fails reconciliation. Every amount in and out of these tools is an
28
+ integer, and the schemas reject anything else.
29
+
30
+ Splitting is largest-remainder, so the parts always sum to the whole. 100
31
+ cents three ways is 34, 33, 33 — never 33, 33, 33 with a cent unaccounted
32
+ for. A full return refunds exactly what was charged, and a lot's partial
33
+ disposals always sum to exactly what the lot cost.
34
+
35
+ ## What has no safe default is asked, not assumed
36
+
37
+ Three choices change the answer and nothing in the data settles them, so the
38
+ tools require them rather than picking:
39
+
40
+ - **Tax-inclusive or exclusive pricing**, and whether rounding happens per
41
+ line or once per invoice. Jurisdictions differ and the totals differ.
42
+ - **Date order.** `03/04/2026` is 3 April in most of the world and 4 March in
43
+ the United States. `StatementParse` REFUSES a file whose dates could be
44
+ read either way unless the caller states the order — a wrong guess moves
45
+ transactions between months, and the reconciliation then balances to
46
+ exactly twice the error.
47
+ - **Sign convention.** Some exports use a signed amount, some a debit column
48
+ and a credit column. The sign a debit column implies is applied rather than
49
+ read.
50
+
51
+ Timestamps must carry a UTC offset. An offset-less one is refused, because
52
+ per ECMAScript it means local time — which would put a holding period on the
53
+ wrong side of a year boundary depending on the machine.
54
+
55
+ ## Controls, not suggestions
56
+
57
+ `SpendLimitCheck` is the gate an unattended harness consults before moving
58
+ money. A limit a model is asked to respect is a suggestion; a limit computed
59
+ from the record of what has already been spent is a limit. It reports every
60
+ limit a payment breaks rather than the first, and the headroom that would
61
+ pass.
62
+
63
+ When `knownCounterparties` is declared, that list **is** the allow-list, and
64
+ payment history does not extend it. Treating anyone previously paid as known
65
+ looks like a convenience and is a hole: one payment that got through by any
66
+ means would allowlist its counterparty permanently, so the control would stop
67
+ exactly one payment and approve every one after it. With no list declared
68
+ there is nothing else to go on, so history is used — a weaker rule, and one
69
+ the refusal names.
70
+
71
+ `WebhookSignatureVerify` exists because anyone who can reach an endpoint can
72
+ post "payment succeeded", and a harness that believes it ships goods for
73
+ free. The comparison is constant-time, the timestamp is checked against a
74
+ replay tolerance, and the body must be the raw bytes — re-serializing parsed
75
+ JSON changes what the signature covers. The signing secret is taken as the
76
+ NAME of an environment variable, never inline: a key in a spec ends up in a
77
+ transcript and in a model's context.
78
+
79
+ `RefundAbuseCheck` reports signals and never a verdict. A high refund ratio
80
+ is also what a customer with one genuinely broken delivery looks like, and a
81
+ ratio against zero spend is reported as absent rather than as a large number.
82
+
83
+ ## What it does not do
84
+
85
+ - **It does not move money.** No payment provider, no transfers, no refunds
86
+ issued, no orders cancelled. These tools compute and check; something else
87
+ acts, and a person authorizes it.
88
+ - **It does not reach a network.** A valid IBAN checksum means well-formed,
89
+ not that the account exists — and the result says so rather than letting
90
+ `valid: true` be read as "verified".
91
+ - **It does not decide that somebody is committing fraud.**
92
+ - **It does not file anything.** No tax returns, no e-invoicing submission,
93
+ no regulatory reporting. `TaxCalculate` computes from rate tables the
94
+ operator supplies; keeping those correct is their job.
95
+ - **It reads CSV and OFX**, not CAMT.053, MT940 or QIF.
96
+ - **It is not accounting advice.** The methods are implemented as specified;
97
+ which one applies to you is a question for somebody qualified.
@@ -0,0 +1,24 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ export declare const paymentIdentifierValidate: RegisteredTool;
3
+ export declare const taxCalculate: RegisteredTool;
4
+ export declare const refundAmountCompute: RegisteredTool;
5
+ export declare const purchaseOrderMatch: RegisteredTool;
6
+ export declare const costBasisCompute: RegisteredTool;
7
+ export declare const spendLimitCheck: RegisteredTool;
8
+ export declare const refundAbuseCheck: RegisteredTool;
9
+ export declare const webhookSignatureVerify: RegisteredTool;
10
+ export declare const statementParse: RegisteredTool;
11
+ export declare const glCodeSuggest: RegisteredTool;
12
+ /**
13
+ * The normalized statement row, and the parser that produces it.
14
+ *
15
+ * `StatementParse` is the contract every reconciler in this repo matches
16
+ * against, so the TYPE has to be importable — `@crewhaus/tool-ledger`'s
17
+ * `LedgerReconcile` consumes exactly this shape, and a second declaration of
18
+ * it somewhere else is a shape that drifts the first time a field is added.
19
+ * The parser goes with it so a reconciler reading a CSV gets byte-identical
20
+ * behaviour, date-ambiguity refusal included, rather than its own CSV reader.
21
+ */
22
+ export { type DateOrder, type ParseOptions, type ParseResult, type StatementFormat, type Transaction, parseStatement, } from "./lib/statement";
23
+ /** Every tool this package registers, in the order a catalog should list them. */
24
+ export declare const MONEY_TOOLS: ReadonlyArray<RegisteredTool>;
package/dist/index.js ADDED
@@ -0,0 +1,455 @@
1
+ /**
2
+ * @crewhaus/tool-money — money arithmetic and the controls around it.
3
+ *
4
+ * Everything here has a right answer that a model can get wrong in a way
5
+ * nobody notices: a cent lost allocating a discount, a tax charged on the
6
+ * wrong base, a date read month-first, a refund that returns list price for
7
+ * a discounted item. These are the calculations that end up in documents
8
+ * somebody audits.
9
+ *
10
+ * Money is integer minor units throughout — cents, pence — because floating
11
+ * point produces totals that do not add up. Timestamps must carry a UTC
12
+ * offset; an offset-less one is refused rather than read as local time.
13
+ *
14
+ * Nothing here moves money, reaches a payment provider, or decides that
15
+ * somebody is committing fraud. It computes, checks and reports.
16
+ */
17
+ import { readFileSync, statSync } from "node:fs";
18
+ import { buildTool } from "@crewhaus/tool-builder";
19
+ import { ASSERT_OPS } from "@crewhaus/tool-schema";
20
+ import { z } from "zod";
21
+ import { SHIPPING_POLICIES, computeRefund, } from "./lib/allocate";
22
+ import { codeLines } from "./lib/coding";
23
+ import { checkSpendLimit, refundAbuseSignals } from "./lib/controls";
24
+ import { IDENTIFIER_KINDS, detectKind, validateIdentifier, } from "./lib/identifiers";
25
+ import { LOT_METHODS, computeCostBasis } from "./lib/lots";
26
+ import { matchInvoiceToPurchaseOrder } from "./lib/match";
27
+ import { DATE_ORDERS, STATEMENT_FORMATS, parseStatement } from "./lib/statement";
28
+ import { ROUNDING_MODES, ROUNDING_SCOPES, calculateTax } from "./lib/tax";
29
+ import { verifyWebhookSignature } from "./lib/webhook";
30
+ import { resolveSafe } from "./paths";
31
+ const json = (value) => JSON.stringify(value);
32
+ const LIMITS = {
33
+ lines: 5_000,
34
+ rules: 1_000,
35
+ lots: 10_000,
36
+ history: 20_000,
37
+ statementBytes: 64 * 1024 * 1024,
38
+ checks: 64,
39
+ };
40
+ const checkSchema = z.object({
41
+ path: z.string().optional().describe("dotted path into the line, e.g. 'vendor.name'"),
42
+ op: z.enum(ASSERT_OPS),
43
+ expected: z.unknown().describe("the operand; its meaning depends on the op"),
44
+ flags: z.string().optional(),
45
+ message: z.string().optional(),
46
+ });
47
+ const minorUnits = z.number().int();
48
+ /**
49
+ * An instant, as epoch milliseconds or ISO-8601 WITH an offset.
50
+ *
51
+ * Offset-less strings are rejected: per ECMAScript they mean local time, so
52
+ * the same statement would land in different months on two machines.
53
+ */
54
+ function parseInstant(value, field) {
55
+ if (typeof value === "number") {
56
+ if (!Number.isFinite(value))
57
+ throw new Error(`${field} is not a finite epoch-millisecond value`);
58
+ return value;
59
+ }
60
+ const text = value.trim();
61
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) {
62
+ throw new Error(`${field} ("${text}") has no UTC offset — write it as e.g. 2026-01-01T00:00:00Z, because an offset-less string means local time and would differ between machines`);
63
+ }
64
+ const parsed = Date.parse(text);
65
+ if (Number.isNaN(parsed))
66
+ throw new Error(`${field} ("${text}") is not a valid ISO-8601 instant`);
67
+ return parsed;
68
+ }
69
+ const instantField = z.union([z.string(), z.number()]);
70
+ // ---------------------------------------------------------------------------
71
+ export const paymentIdentifierValidate = buildTool({
72
+ name: "PaymentIdentifierValidate",
73
+ description: "Check an IBAN, BIC, ABA routing number, card number or UK sort code against its published checksum, and return the canonical form. Use it before composing a payout or an invoice: these identifiers carry check digits precisely so a transposed pair of digits is caught before money moves, and the arithmetic has one right answer. A card number is echoed only as its last four. A valid checksum means well-formed, not that the account exists — nothing here reaches a network, and the result says so.",
74
+ inputSchema: z.object({
75
+ value: z.string().min(1).describe("the identifier, spacing and case as written"),
76
+ kind: z
77
+ .enum(IDENTIFIER_KINDS)
78
+ .optional()
79
+ .describe("iban | bic | aba | card | sortcode; detected from the shape when omitted"),
80
+ }),
81
+ readOnly: true,
82
+ concurrencySafe: true,
83
+ execute: async (input) => {
84
+ const kind = (input.kind ?? detectKind(input.value));
85
+ if (kind === null) {
86
+ return `"${input.value.slice(0, 12)}…" does not have the shape of an IBAN, BIC, routing number, card number or sort code — pass kind explicitly if you know what it should be`;
87
+ }
88
+ return json({
89
+ ...validateIdentifier(input.value, kind),
90
+ checkedAgainst: "checksum only; no account was looked up",
91
+ });
92
+ },
93
+ });
94
+ export const taxCalculate = buildTool({
95
+ name: "TaxCalculate",
96
+ description: "Compute VAT, GST or sales tax per line and per invoice from the operator's own rate tables, in integer minor units. Use it instead of a model multiplying percentages: it handles tax-inclusive pricing, compound rates, exemptions and reverse charge, and it makes the two choices that have no safe default explicit — whether listed prices already include tax, and whether rounding happens per line or once per invoice. A tax code missing from the rate table is an error rather than an untaxed line.",
97
+ inputSchema: z.object({
98
+ lines: z
99
+ .array(z.object({
100
+ id: z.string().min(1),
101
+ amountMinor: minorUnits.describe("net of tax unless pricesIncludeTax is set"),
102
+ taxCodes: z.array(z.string()).max(8),
103
+ exempt: z
104
+ .boolean()
105
+ .optional()
106
+ .describe("outside the scope of tax, which is not a zero rate"),
107
+ }))
108
+ .min(1)
109
+ .max(LIMITS.lines),
110
+ rates: z
111
+ .array(z.object({
112
+ code: z.string().min(1),
113
+ bps: z.number().int().describe("basis points: 2000 is 20%"),
114
+ name: z.string().optional(),
115
+ compound: z
116
+ .boolean()
117
+ .optional()
118
+ .describe("charged on the net plus taxes already applied"),
119
+ }))
120
+ .min(1)
121
+ .max(200),
122
+ pricesIncludeTax: z.boolean().optional(),
123
+ rounding: z.enum(ROUNDING_MODES).optional().describe("default half-up"),
124
+ scope: z
125
+ .enum(ROUNDING_SCOPES)
126
+ .optional()
127
+ .describe("round per line (default) or once per invoice"),
128
+ reverseCharge: z.boolean().optional().describe("the customer accounts for the tax"),
129
+ }),
130
+ readOnly: true,
131
+ concurrencySafe: true,
132
+ execute: async (input) => json(calculateTax(input.lines, input.rates, input)),
133
+ });
134
+ export const refundAmountCompute = buildTool({
135
+ name: "RefundAmountCompute",
136
+ description: "Work out what to refund for a partial return: the returned units at what was actually paid for them, with the order-level discount and the tax allocated proportionally, plus whatever the shipping policy says. Use it because refunding list price for a discounted item refunds more than was taken, and because splitting an amount by rounding each share loses cents. Allocation is largest-remainder, so a full return refunds exactly what was charged.",
137
+ inputSchema: z.object({
138
+ lines: z
139
+ .array(z.object({
140
+ id: z.string().min(1),
141
+ quantity: z.number().int().positive(),
142
+ unitPriceMinor: minorUnits,
143
+ taxMinor: minorUnits.optional(),
144
+ discountMinor: minorUnits.optional(),
145
+ }))
146
+ .min(1)
147
+ .max(LIMITS.lines),
148
+ returned: z
149
+ .array(z.object({ lineId: z.string().min(1), quantity: z.number().int().positive() }))
150
+ .min(1)
151
+ .max(LIMITS.lines),
152
+ orderDiscountMinor: minorUnits.optional(),
153
+ shippingMinor: minorUnits.optional(),
154
+ shippingPolicy: z
155
+ .enum(SHIPPING_POLICIES)
156
+ .optional()
157
+ .describe("none (default), proportional, or full"),
158
+ restockingFeeMinor: minorUnits.optional(),
159
+ }),
160
+ readOnly: true,
161
+ concurrencySafe: true,
162
+ execute: async (input) => json(computeRefund(input.lines, input.returned, input)),
163
+ });
164
+ export const purchaseOrderMatch = buildTool({
165
+ name: "PurchaseOrderMatch",
166
+ description: "Run the accounts-payable three-way match: invoice lines against purchase-order lines against goods received, with price and quantity tolerances, reporting every exception and what it would cost to pay it. Use it before approving a bill. Lines pair by explicit reference, then SKU, then normalized description, and each pair records which was used so a reviewer can see when a match rested on fuzzy text. Billing for more than arrived is invisible in a two-way match however well the invoice agrees with the order.",
167
+ inputSchema: z.object({
168
+ invoiceLines: z
169
+ .array(z.object({
170
+ id: z.string().min(1),
171
+ poLineId: z.string().optional(),
172
+ sku: z.string().optional(),
173
+ description: z.string().optional(),
174
+ quantity: z.number(),
175
+ unitPriceMinor: minorUnits,
176
+ }))
177
+ .min(1)
178
+ .max(LIMITS.lines),
179
+ poLines: z
180
+ .array(z.object({
181
+ id: z.string().min(1),
182
+ sku: z.string().optional(),
183
+ description: z.string().optional(),
184
+ quantity: z.number(),
185
+ unitPriceMinor: minorUnits,
186
+ }))
187
+ .min(1)
188
+ .max(LIMITS.lines),
189
+ receiptLines: z
190
+ .array(z.object({ poLineId: z.string().min(1), quantity: z.number() }))
191
+ .max(LIMITS.lines)
192
+ .optional()
193
+ .describe("omit for a two-way match"),
194
+ tolerance: z
195
+ .object({
196
+ pricePercentBps: z.number().int().nonnegative().optional(),
197
+ priceAbsoluteMinor: minorUnits.nonnegative().optional(),
198
+ quantityPercentBps: z.number().int().nonnegative().optional(),
199
+ quantityAbsolute: z.number().nonnegative().optional(),
200
+ })
201
+ .optional(),
202
+ }),
203
+ readOnly: true,
204
+ concurrencySafe: true,
205
+ execute: async (input) => json(matchInvoiceToPurchaseOrder(input.invoiceLines, input.poLines, input.receiptLines ?? [], input.tolerance ?? {})),
206
+ });
207
+ export const costBasisCompute = buildTool({
208
+ name: "CostBasisCompute",
209
+ description: "Consume lots against disposals by FIFO, LIFO, HIFO or specific identification, and report realized gain split into short and long term with the lots each disposal consumed. Use it for crypto or securities bookkeeping: once the method is chosen the arithmetic is fixed, and a figure a model produced cannot be reproduced next quarter, which is the one property a cost-basis number must have. A lot's costs always sum to exactly what it cost, however many partial disposals came before.",
210
+ inputSchema: z.object({
211
+ lots: z
212
+ .array(z.object({
213
+ id: z.string().min(1),
214
+ acquiredAt: z.string().describe("ISO-8601 with a UTC offset"),
215
+ quantity: z.number().positive(),
216
+ costMinor: minorUnits,
217
+ }))
218
+ .min(1)
219
+ .max(LIMITS.lots),
220
+ disposals: z
221
+ .array(z.object({
222
+ id: z.string().min(1),
223
+ disposedAt: z.string(),
224
+ quantity: z.number().positive(),
225
+ proceedsMinor: minorUnits,
226
+ lotIds: z.array(z.string()).max(LIMITS.lots).optional().describe("specific method only"),
227
+ }))
228
+ .max(LIMITS.lots),
229
+ method: z.enum(LOT_METHODS),
230
+ }),
231
+ readOnly: true,
232
+ concurrencySafe: true,
233
+ execute: async (input) => json(computeCostBasis(input.lots, input.disposals, input.method)),
234
+ });
235
+ export const spendLimitCheck = buildTool({
236
+ name: "SpendLimitCheck",
237
+ description: "Decide whether a proposed payment fits inside the operator's velocity, counterparty and quiet-hours limits, given what has already been spent. Use it as the gate an unattended harness actually consults before moving money: a limit a model is asked to respect is a suggestion, while one computed from the record of prior spend is a limit. It reports every limit the payment breaks rather than the first, and the headroom that would pass.",
238
+ inputSchema: z.object({
239
+ proposed: z.object({ amountMinor: minorUnits, counterparty: z.string().optional() }),
240
+ history: z
241
+ .array(z.object({
242
+ id: z.string().min(1),
243
+ at: z.string().describe("ISO-8601 with a UTC offset"),
244
+ amountMinor: minorUnits,
245
+ counterparty: z.string().optional(),
246
+ }))
247
+ .max(LIMITS.history),
248
+ limits: z.object({
249
+ perTransactionMinor: minorUnits.optional(),
250
+ perHourMinor: minorUnits.optional(),
251
+ perDayMinor: minorUnits.optional(),
252
+ perWeekMinor: minorUnits.optional(),
253
+ perCounterpartyPerDayMinor: minorUnits.optional(),
254
+ maxTransactionsPerHour: z.number().int().positive().optional(),
255
+ quietHoursUtc: z.array(z.number().int().min(0).max(23)).max(24).optional(),
256
+ knownCounterpartiesOnly: z.boolean().optional(),
257
+ knownCounterparties: z.array(z.string()).max(10_000).optional(),
258
+ }),
259
+ now: instantField.optional().describe("overrides the real clock, for tests and replays"),
260
+ }),
261
+ readOnly: true,
262
+ concurrencySafe: true,
263
+ execute: async (input) => {
264
+ const nowMs = input.now === undefined ? Date.now() : parseInstant(input.now, "now");
265
+ return json(checkSpendLimit(input.proposed, input.history, input.limits, nowMs));
266
+ },
267
+ });
268
+ export const refundAbuseCheck = buildTool({
269
+ name: "RefundAbuseCheck",
270
+ description: "Report refund counts, refunded-to-spent ratios over 30, 90 and 365 days, replacement counts and non-delivery claims for one customer. Use it to decide which refund requests a person should look at. It reports signals and never a verdict: a high ratio is also what a customer with one genuinely broken delivery looks like, and a ratio against zero spend is reported as absent rather than as a large number.",
271
+ inputSchema: z.object({
272
+ refunds: z
273
+ .array(z.object({
274
+ id: z.string().min(1),
275
+ at: z.string(),
276
+ amountMinor: minorUnits,
277
+ reason: z.string().optional(),
278
+ replacement: z.boolean().optional(),
279
+ }))
280
+ .max(LIMITS.history),
281
+ orders: z
282
+ .array(z.object({ id: z.string().min(1), at: z.string(), amountMinor: minorUnits }))
283
+ .max(LIMITS.history),
284
+ thresholds: z
285
+ .object({
286
+ ratioBps: z.number().int().nonnegative().optional(),
287
+ refundCount: z.number().int().nonnegative().optional(),
288
+ })
289
+ .optional(),
290
+ now: instantField.optional(),
291
+ }),
292
+ readOnly: true,
293
+ concurrencySafe: true,
294
+ execute: async (input) => {
295
+ const nowMs = input.now === undefined ? Date.now() : parseInstant(input.now, "now");
296
+ return json({
297
+ ...refundAbuseSignals(input.refunds, input.orders, nowMs, input.thresholds ?? {}),
298
+ note: "these are signals, not a finding; a person decides",
299
+ });
300
+ },
301
+ });
302
+ export const webhookSignatureVerify = buildTool({
303
+ name: "WebhookSignatureVerify",
304
+ description: "Verify a payment webhook's HMAC-SHA256 signature over its raw body, in the scheme Stripe uses and several providers copy, and check the timestamp against a replay tolerance. Use it before acting on any event that claims money moved: anyone who can reach the endpoint can post that claim, and a harness that believes an unverified 'payment succeeded' ships goods for free. The comparison is constant-time, and the body must be the raw bytes as received — re-serializing parsed JSON changes the bytes the signature covers.",
305
+ inputSchema: z.object({
306
+ body: z.string().describe("the raw request body, exactly as received"),
307
+ signatureHeader: z.string().min(1).describe("e.g. t=1614556800,v1=abc…"),
308
+ secretEnvVar: z
309
+ .string()
310
+ .min(1)
311
+ .describe("NAME of the environment variable holding the signing secret, never the secret"),
312
+ toleranceSeconds: z.number().int().positive().max(86_400).optional().describe("default 300"),
313
+ scheme: z.string().optional().describe("default v1"),
314
+ now: instantField.optional(),
315
+ }),
316
+ readOnly: true,
317
+ concurrencySafe: true,
318
+ execute: async (input) => {
319
+ // The secret arrives as the NAME of an environment variable. Taking it
320
+ // inline would put a signing key into a spec, a transcript and a model's
321
+ // context, which is the posture tool-fetch established for credentials.
322
+ const secret = process.env[input.secretEnvVar];
323
+ if (secret === undefined || secret === "") {
324
+ return `the environment variable "${input.secretEnvVar}" is not set, so the signature cannot be checked — refusing rather than reporting the event as unverified`;
325
+ }
326
+ const nowMs = input.now === undefined ? Date.now() : parseInstant(input.now, "now");
327
+ return json(verifyWebhookSignature(input.body, input.signatureHeader, secret, {
328
+ nowMs,
329
+ toleranceSeconds: input.toleranceSeconds,
330
+ scheme: input.scheme,
331
+ }));
332
+ },
333
+ });
334
+ export const statementParse = buildTool({
335
+ name: "StatementParse",
336
+ description: "Turn a bank, card or exchange export into one normalized transaction list with ISO dates and signed minor-unit amounts. Use it to reconcile without putting the statement through a context window. It reads CSV and OFX, applies the sign a debit or credit column implies rather than reading it, and REFUSES a file whose dates could be day-first or month-first unless the order is stated — a wrong guess moves transactions between months, which reconciles to exactly twice the error. Rows it cannot read are listed with reasons rather than dropped.",
337
+ inputSchema: z.object({
338
+ file: z.string().min(1).describe("workspace-relative path to the export"),
339
+ format: z
340
+ .enum(STATEMENT_FORMATS)
341
+ .optional()
342
+ .describe("detected from the contents when omitted"),
343
+ dateOrder: z
344
+ .enum(DATE_ORDERS)
345
+ .optional()
346
+ .describe("iso, dmy or mdy; required when the file is ambiguous"),
347
+ decimalComma: z.boolean().optional().describe("1.234,56 rather than 1,234.56"),
348
+ decimals: z
349
+ .number()
350
+ .int()
351
+ .min(0)
352
+ .max(8)
353
+ .optional()
354
+ .describe("minor-unit exponent; 2 by default, 0 for JPY"),
355
+ columns: z
356
+ .object({
357
+ date: z.string().optional(),
358
+ description: z.string().optional(),
359
+ amount: z.string().optional(),
360
+ debit: z.string().optional(),
361
+ credit: z.string().optional(),
362
+ balance: z.string().optional(),
363
+ reference: z.string().optional(),
364
+ })
365
+ .optional()
366
+ .describe("column names, when the header does not use recognizable ones"),
367
+ limit: z
368
+ .number()
369
+ .int()
370
+ .positive()
371
+ .max(20_000)
372
+ .optional()
373
+ .describe("cap the returned list; default 500"),
374
+ }),
375
+ readOnly: true,
376
+ concurrencySafe: true,
377
+ execute: async (input) => {
378
+ const at = resolveSafe("StatementParse", input.file);
379
+ const size = statSync(at.real).size;
380
+ if (size > LIMITS.statementBytes) {
381
+ throw new Error(`${at.rel} is ${size} bytes, over the ${LIMITS.statementBytes}-byte limit`);
382
+ }
383
+ const result = parseStatement(readFileSync(at.real, "utf-8"), input);
384
+ const limit = input.limit ?? 500;
385
+ return json({
386
+ file: at.rel,
387
+ format: result.format,
388
+ dateOrder: result.dateOrder,
389
+ count: result.count,
390
+ totalMinor: result.totalMinor,
391
+ debitMinor: result.debitMinor,
392
+ creditMinor: result.creditMinor,
393
+ rejected: result.rejected,
394
+ transactions: result.transactions.slice(0, limit),
395
+ truncated: result.count > limit,
396
+ });
397
+ },
398
+ });
399
+ export const glCodeSuggest = buildTool({
400
+ name: "GlCodeSuggest",
401
+ description: "Code invoice lines to a GL account and cost centre from versioned operator rules, and put only the genuinely unclear ones in a review queue. Use it because most lines on most invoices code the same way every month, and a coding a model produced cannot be reproduced at year end. Two rules that match at the same priority and disagree are reported as ambiguous rather than resolved — the wrong account is found in an audit, not in a test — and a line no rule matched always goes to review.",
402
+ inputSchema: z.object({
403
+ lines: z
404
+ .array(z.object({ id: z.string().min(1) }).passthrough())
405
+ .min(1)
406
+ .max(LIMITS.lines)
407
+ .describe("invoice lines; any fields, referenced by the rules' paths"),
408
+ rules: z
409
+ .array(z.object({
410
+ id: z.string().min(1),
411
+ when: z.array(checkSchema).min(1).max(LIMITS.checks),
412
+ account: z.string().min(1),
413
+ costCenter: z.string().optional(),
414
+ taxCode: z.string().optional(),
415
+ priority: z.number().optional(),
416
+ }))
417
+ .min(1)
418
+ .max(LIMITS.rules),
419
+ version: z.string().optional().describe("echoed into the result, so a coding is attributable"),
420
+ defaultAccount: z
421
+ .string()
422
+ .optional()
423
+ .describe("where unmatched lines go; they still need review"),
424
+ }),
425
+ readOnly: true,
426
+ concurrencySafe: true,
427
+ execute: async (input) => json(codeLines(input.lines, input.rules, {
428
+ version: input.version,
429
+ defaultAccount: input.defaultAccount,
430
+ })),
431
+ });
432
+ /**
433
+ * The normalized statement row, and the parser that produces it.
434
+ *
435
+ * `StatementParse` is the contract every reconciler in this repo matches
436
+ * against, so the TYPE has to be importable — `@crewhaus/tool-ledger`'s
437
+ * `LedgerReconcile` consumes exactly this shape, and a second declaration of
438
+ * it somewhere else is a shape that drifts the first time a field is added.
439
+ * The parser goes with it so a reconciler reading a CSV gets byte-identical
440
+ * behaviour, date-ambiguity refusal included, rather than its own CSV reader.
441
+ */
442
+ export { parseStatement, } from "./lib/statement";
443
+ /** Every tool this package registers, in the order a catalog should list them. */
444
+ export const MONEY_TOOLS = Object.freeze([
445
+ costBasisCompute,
446
+ glCodeSuggest,
447
+ paymentIdentifierValidate,
448
+ purchaseOrderMatch,
449
+ refundAbuseCheck,
450
+ refundAmountCompute,
451
+ spendLimitCheck,
452
+ statementParse,
453
+ taxCalculate,
454
+ webhookSignatureVerify,
455
+ ]);
@@ -0,0 +1,70 @@
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 declare function allocateProportional(totalMinor: number, weights: ReadonlyArray<number>): number[];
21
+ export type OrderLine = {
22
+ readonly id: string;
23
+ readonly quantity: number;
24
+ readonly unitPriceMinor: number;
25
+ /** Tax charged on this line as invoiced. */
26
+ readonly taxMinor?: number;
27
+ /** A discount already applied to this line, as invoiced. */
28
+ readonly discountMinor?: number;
29
+ };
30
+ export type ReturnedLine = {
31
+ readonly lineId: string;
32
+ readonly quantity: number;
33
+ };
34
+ export declare const SHIPPING_POLICIES: readonly ["none", "proportional", "full"];
35
+ export type ShippingPolicy = (typeof SHIPPING_POLICIES)[number];
36
+ export type RefundOptions = {
37
+ /** A discount applied to the order as a whole, to be spread over lines. */
38
+ readonly orderDiscountMinor?: number;
39
+ readonly shippingMinor?: number;
40
+ readonly shippingPolicy?: ShippingPolicy;
41
+ /** Kept by the merchant, subtracted from the refund. */
42
+ readonly restockingFeeMinor?: number;
43
+ };
44
+ export type RefundLineResult = {
45
+ readonly lineId: string;
46
+ readonly quantity: number;
47
+ readonly grossMinor: number;
48
+ readonly lineDiscountMinor: number;
49
+ readonly orderDiscountMinor: number;
50
+ readonly taxMinor: number;
51
+ readonly refundMinor: number;
52
+ };
53
+ export type RefundResult = {
54
+ readonly lines: ReadonlyArray<RefundLineResult>;
55
+ readonly shippingMinor: number;
56
+ readonly restockingFeeMinor: number;
57
+ readonly subtotalMinor: number;
58
+ readonly taxMinor: number;
59
+ readonly totalMinor: number;
60
+ readonly fullReturn: boolean;
61
+ };
62
+ /**
63
+ * What to refund for a partial return.
64
+ *
65
+ * Both allocations are proportional to what the customer actually paid for
66
+ * the returned units: an order-level discount applies to them in the same
67
+ * proportion it applied to the order, and tax follows the discounted amount.
68
+ * Refunding the list price of a discounted item refunds more than was taken.
69
+ */
70
+ export declare function computeRefund(lines: ReadonlyArray<OrderLine>, returned: ReadonlyArray<ReturnedLine>, options?: RefundOptions): RefundResult;