-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathschema.ts
More file actions
422 lines (386 loc) · 20 KB
/
Copy pathschema.ts
File metadata and controls
422 lines (386 loc) · 20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { assertRecord, isRecord } from "@/lib/assert";
/**
* The Zod schemas in this file are the SINGLE SOURCE OF TRUTH for the whole
* pipeline, the same discipline as the sibling ai-invoice-parser repo:
*
* 1. They can constrain a model at generation time, `INVOICE_JSON_SCHEMA`
* (below) is derived from the `Invoice` object for a structured-output model.
* 2. They validate every model/tool/DB boundary at runtime (`.safeParse`),
* so a bad value becomes a handled trace step, never a crash.
* 3. Their inferred TypeScript types flow into the Drizzle layer, the Mastra
* step I/O, the streaming trace, and the React UI, one definition, no
* drift between the model, the validator, the database, and the screen.
*
* The procure-to-pay state accretes as it moves down the pipeline: each stage's
* output schema is the next stage's input schema. Read top-to-bottom and you're
* reading the data model of the whole demo.
*/
/* ────────────────────────────────────────────────────────────────────────── *
* Primitives (shared, reused, keep validation identical everywhere)
* ────────────────────────────────────────────────────────────────────────── */
/** A 3-letter ISO-4217-ish currency code. Shape only, we don't enumerate codes. */
const Currency = z
.string()
.trim()
.regex(/^[A-Z]{3}$/, "currency must be a 3-letter code, e.g. USD, EUR, GBP");
/** ISO-8601 calendar date (YYYY-MM-DD), validated as a real date. */
const IsoDate = z
.string()
.trim()
.regex(/^\d{4}-\d{2}-\d{2}$/, "date must be ISO-8601 (YYYY-MM-DD)")
.refine((s) => !Number.isNaN(Date.parse(s)), "date is not a valid calendar date");
/** A finite monetary / numeric amount. */
const Amount = z
.number({ invalid_type_error: "expected a number" })
.finite("must be a finite number");
/** A non-negative quantity. */
const Quantity = Amount.nonnegative("quantity cannot be negative");
/* ────────────────────────────────────────────────────────────────────────── *
* Line items, the unit that matching compares across documents
* ────────────────────────────────────────────────────────────────────────── */
/**
* A single billed line. `sku` is the join key used by the matcher to line up an
* invoice line against its purchase-order line and goods-receipt line. Real AP
* systems match on a mix of SKU + description fuzzy-match; we use a clean SKU so
* the demo's matching is explainable on a sales call.
*/
export const LineItem = z
.object({
sku: z.string().trim().min(1, "line item needs a SKU"),
description: z.string().trim().min(1, "line item needs a description"),
qty: Quantity.describe("quantity for this line item"),
unitPrice: Amount.describe("price per unit"),
amount: Amount.describe("line total (qty x unitPrice)"),
})
.strict();
export type LineItem = z.infer<typeof LineItem>;
/* ────────────────────────────────────────────────────────────────────────── *
* Stage 0, the documents (seeded, read-only)
* ────────────────────────────────────────────────────────────────────────── */
/**
* The parsed invoice. This is also the shape a document-extraction step would
* produce from a PDF/text, `INVOICE_JSON_SCHEMA` below is derived from it.
*/
export const Invoice = z
.object({
invoiceNumber: z.string().trim().min(1, "invoice number is required"),
poNumber: z.string().trim().min(1).nullish(),
vendor: z.string().trim().min(1, "vendor is required"),
issueDate: IsoDate,
currency: Currency,
lineItems: z.array(LineItem).min(1, "at least one line item is required"),
subtotal: Amount,
tax: Amount.nullish(),
total: Amount,
})
.strict();
export type Invoice = z.infer<typeof Invoice>;
/** A purchase order, what we agreed to buy and at what price. */
export const PurchaseOrder = z
.object({
poNumber: z.string().trim().min(1),
vendor: z.string().trim().min(1),
currency: Currency,
lineItems: z.array(LineItem).min(1),
total: Amount,
/** The BUYING department (the internal team the spend belongs to), so an
approval workflow can route a department-specific review. It lives on the PO,
not the invoice: a vendor doesn't know your cost centres, your PO does. "" =
no department, so a PO without one routes normally and a department-scoped
gate simply doesn't fire. Required (explicit "") to keep the seed + fixtures
unambiguous, same "no hidden defaults" discipline as the rest of the schema. */
department: z.string(),
})
.strict();
export type PurchaseOrder = z.infer<typeof PurchaseOrder>;
/**
* A goods receipt, what the warehouse actually accepted. Only quantities matter
* here (you receive units, not prices), so each line is sku + receivedQty. The
* presence/absence of a goods receipt is what makes a match "3-way" vs "2-way".
*/
export const GoodsReceiptLine = z
.object({
sku: z.string().trim().min(1),
description: z.string().trim().min(1),
receivedQty: Quantity,
})
.strict();
export type GoodsReceiptLine = z.infer<typeof GoodsReceiptLine>;
export const GoodsReceipt = z
.object({
grNumber: z.string().trim().min(1),
poNumber: z.string().trim().min(1),
receivedDate: IsoDate,
lineItems: z.array(GoodsReceiptLine).min(1),
})
.strict();
export type GoodsReceipt = z.infer<typeof GoodsReceipt>;
/* ────────────────────────────────────────────────────────────────────────── *
* Stage 2, matching result (output of the deterministic matcher)
* ────────────────────────────────────────────────────────────────────────── */
/** Why a given line failed to reconcile (or, for `duplicate`, the whole invoice).
Exported so the condition editor can offer this canonical list as the value
choices for an `exceptionCode` gate (one source of truth for the codes). */
export const MatchExceptionCode = z.enum([
"price_variance", // invoice unit price differs from the PO unit price
"qty_variance_po", // invoice qty differs from the PO qty
"qty_variance_receipt", // invoice qty exceeds what was actually received
"unit_price_x_qty", // line amount != qty * unitPrice (arithmetic)
"no_po_line", // invoice line has no matching PO line
"no_receipt_line", // (3-way only) invoice line was never received
"duplicate", // this invoice number was already processed THIS RUN (re-send in the queue)
// ── ERP master-data controls (the invoice is checked against what the client's
// ERP actually holds, see lib/erp.ts pullVendors/pullItems/pullPostedBills) ──
"duplicate_in_erp", // a bill with this vendor+number is already posted in the ERP (already paid)
"vendor_inactive", // the billing vendor is marked inactive in the ERP (control/fraud signal)
"sku_not_in_catalog", // an invoiced SKU isn't in the vendor's item catalog in the ERP
]);
/** A single reconciliation problem on a single line, with the supporting numbers. */
export const MatchException = z
.object({
sku: z.string(),
code: MatchExceptionCode,
message: z.string(),
/** Magnitude of the discrepancy as a fraction (0.07 = 7%). 0 for binary issues. */
variancePct: z.number().nonnegative(),
invoiceValue: z.number().nullable(),
expectedValue: z.number().nullable(),
})
.strict();
export type MatchException = z.infer<typeof MatchException>;
/** The overall matching verdict for an invoice. */
const MatchVerdict = z.enum([
"clean", // everything reconciles within tolerance → eligible for straight-through
"exception", // one or more lines have variances → needs a decision
"duplicate", // this invoice number was already seen → block, don't pay twice
]);
export const MatchResult = z
.object({
invoiceNumber: z.string(),
poNumber: z.string().nullable(),
/** "three_way" when a goods receipt was present, else "two_way". */
matchType: z.enum(["two_way", "three_way"]),
verdict: MatchVerdict,
exceptions: z.array(MatchException),
/** Largest single-line variance, used by the approval policy for tiering. */
maxVariancePct: z.number().nonnegative(),
/** Absolute money at stake across all exception lines, for the approval tier. */
exceptionAmount: z.number().nonnegative(),
currency: Currency,
invoiceTotal: Amount,
/** The buying department, carried from the PO so a department-scoped approval
gate can route on it. "" when the PO has none (or there's no PO). */
department: z.string(),
/** The billing vendor, carried so a vendor-scoped approval gate can route on it. */
vendor: z.string(),
})
.strict();
export type MatchResult = z.infer<typeof MatchResult>;
/* ────────────────────────────────────────────────────────────────────────── *
* Stage 2.5, exception investigation (the one open-ended AGENT step)
* ────────────────────────────────────────────────────────────────────────── */
/**
* The investigator agent's recommendation, produced ONLY on the exception path.
* It's a recommendation for the human reviewer, never a decision: the agent reads
* messy vendor records (price history, PO notes, receipt notes) of its own
* choosing and forms a view on whether the flagged variance looks legitimate.
*
* recommendation, what the agent suggests the reviewer do
* rationale , one or two sentences citing what it found
* toolsUsed , which records it pulled (shows the open-ended trajectory)
*/
export const Investigation = z
.object({
invoiceNumber: z.string(),
recommendation: z.enum(["likely_legitimate", "likely_overcharge", "unclear"]),
rationale: z.string(),
toolsUsed: z.array(z.string()),
})
.strict();
export type Investigation = z.infer<typeof Investigation>;
/* Approval is no longer a single tier decision, it's a conditional workflow DAG
(lib/approval-workflow.ts) executed per invoice (lib/approval-engine.ts). The
old `ApproverTier` / `ApprovalDecision` types were retired in that migration. */
/* ────────────────────────────────────────────────────────────────────────── *
* Stage 4, reconciliation result (output of the deterministic ERP post)
* ────────────────────────────────────────────────────────────────────────── */
/** A double-entry GL posting line. Debits and credits must net to zero. */
export const GlEntry = z
.object({
account: z.string(),
debit: Amount.nonnegative(),
credit: Amount.nonnegative(),
})
.strict();
export type GlEntry = z.infer<typeof GlEntry>;
/**
* The vendor bill we WOULD post to the ERP once an invoice clears, a DRY-RUN
* payload, never actually sent (the write-back is a stub; see lib/erp.ts). It
* mirrors the real shape a QuickBooks `POST /bill` carries (see the bill the seed
* script posts in scripts/seed-quickbooks.ts: a DocNumber, a vendor ref, and a
* single account-based expense line for the total). Surfaced on the trace so the
* reconciliation step shows the concrete artifact ("here's the bill we'd create")
* next to the synthetic reference. Read-only, no side effect.
*/
export const VendorBill = z
.object({
/** The vendor's invoice number, used as the bill's DocNumber. */
docNumber: z.string(),
vendor: z.string(),
/** The PO this bill settles, when there is one. */
poNumber: z.string().nullable(),
currency: Currency,
/** The GL/expense account the bill books to (the account-based line). */
expenseAccount: z.string(),
total: Amount,
})
.strict();
export type VendorBill = z.infer<typeof VendorBill>;
/**
* How the reconciliation step resolved:
* posted , booked to the ERP (clean auto, or human-approved)
* awaiting , held pending a human approval decision (the run paused here)
* rejected , a reviewer declined it; not posted
* blocked , a duplicate; never posted
*/
const ReconOutcome = z.enum(["posted", "awaiting", "rejected", "blocked"]);
export const ReconResult = z
.object({
invoiceNumber: z.string(),
outcome: ReconOutcome,
posted: z.boolean(),
/** Reference returned by the (fake) ERP adapter, e.g. "NETSUITE-BILL-1042". */
erpRef: z.string().nullable(),
glEntries: z.array(GlEntry),
currency: Currency,
amount: Amount,
note: z.string(),
/** The bill we'd post to the ERP, a DRY-RUN payload (never sent). Present only
* on the `posted` path; null when nothing clears (awaiting/rejected/blocked). */
vendorBill: VendorBill.nullable().default(null),
})
.strict();
export type ReconResult = z.infer<typeof ReconResult>;
/* ────────────────────────────────────────────────────────────────────────── *
* HRIS / org model, the onboarding side, INTERNAL types
* ────────────────────────────────────────────────────────────────────────── *
*
* The onboarding discovery agent reads a client's HRIS (BambooHR today) and
* derives the approval matrix that drives the P2P pipeline. These are OUR types,
* not the HRIS's. Every HRIS shapes employees/reporting differently (BambooHR's
* `supervisorEId`, Workday's worker references, …); the adapter
* ([`lib/hris.ts`](./hris.ts)) maps each vendor onto this one model, so the agent
* and the rest of the app never see a vendor-specific field. Same discipline as
* the invoice side: one internal model, adapters at the edge.
*/
/** One person in the org, normalised from whatever the HRIS calls these. */
export const Employee = z
.object({
/** Stable HRIS id (string, BambooHR ids are numeric-but-stringly). */
id: z.string().min(1),
name: z.string().min(1),
title: z.string(),
department: z.string(),
/** Org unit above department where the HRIS has one; "" when it doesn't. */
division: z.string(),
/**
* The id of this person's manager, or null at the top of the tree. We key
* the hierarchy on ID, never on a name string: the same HRIS returns a
* person's name in different formats across endpoints ("Jennifer Caldwell"
* vs "Caldwell, Jennifer"), so name-matching silently breaks. ID is the only
* reliable edge.
*/
managerId: z.string().nullable(),
})
.strict();
export type Employee = z.infer<typeof Employee>;
/**
* A reporting edge the agent could NOT resolve cleanly, a person whose manager
* id points nowhere, a cycle, or an active employee with no manager who isn't
* plausibly the CEO. Surfaced to the human reviewer rather than guessed: this is
* the data-quality work a forward-deployed engineer actually does on onboarding,
* made explicit instead of hidden.
*/
export const OrgIssue = z
.object({
employeeId: z.string(),
employeeName: z.string(),
kind: z.enum(["dangling-manager", "cycle", "orphan", "self-managed"]),
detail: z.string(),
})
.strict();
export type OrgIssue = z.infer<typeof OrgIssue>;
/**
* The normalised org as the agent sees it: the clean roster plus the issues that
* need a human. The pipeline never consumes this directly, the agent turns it
* into a proposed approval policy, a human validates, and THAT becomes a
* ClientProfile (see [`lib/client-profile.ts`](./client-profile.ts)).
*/
export const OrgChart = z
.object({
/** Where this org was read from, e.g. "bamboohr" or "bamboohr (recorded)". */
source: z.string(),
employees: z.array(Employee),
issues: z.array(OrgIssue),
})
.strict();
export type OrgChart = z.infer<typeof OrgChart>;
/* ────────────────────────────────────────────────────────────────────────── *
* JSON Schema derived from the Invoice Zod object
* ────────────────────────────────────────────────────────────────────────── */
/**
* Built from the SAME `Invoice` Zod object so a structured-output model and the
* runtime validator can't drift, the single-source-of-truth discipline applied
* to the model boundary. Emitted inline (no `$ref`/`definitions` wrapper) with
* `$schema` stripped, the cleanest shape to hand a model. The intake extraction
* ([`lib/extract.ts`](./extract.ts)) hands this to the model, then
* `Invoice.safeParse`s the output.
*
* The Anthropic structured-output schema doesn't support numeric range keywords
* (`minimum`/`maximum`/…) or string `format`, but our Zod has `.nonnegative()`
* etc. So we STRIP those keywords for the model, they're advisory there anyway,
* while `Invoice.safeParse` still enforces every constraint at runtime. The model
* is shaped; the validator is the real gate.
*/
const UNSUPPORTED_SCHEMA_KEYS = [
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"format",
];
const stripUnsupported = (node: unknown): void => {
if (Array.isArray(node)) {
for (const item of node) stripUnsupported(item);
return;
}
if (isRecord(node)) {
for (const key of UNSUPPORTED_SCHEMA_KEYS) delete node[key];
for (const value of Object.values(node)) stripUnsupported(value);
}
};
/**
* Turn any Zod object into a JSON schema shaped for an Anthropic structured-output
* call: inlined (no `$ref`/`definitions`), `$schema` removed, and the keywords the
* structured-output schema rejects stripped. The Zod object stays the runtime gate
* (`.safeParse`); this is only what we hand the model. Shared by every structured
* generation (invoice extraction, onboarding proposal) so the discipline is identical.
*/
export const toModelJsonSchema = (schema: z.ZodType<unknown>): Record<string, unknown> => {
// zodToJsonSchema returns the library's structured JsonSchema7Type union; assert it's
// the generic object we hand the model (it always is, a schema is an object) to narrow
// it cast-free, then strip the unsupported keys. A non-object here would be a library
// contract break, so throw rather than paper over it.
const produced = zodToJsonSchema(schema, {
$refStrategy: "none",
target: "jsonSchema7",
});
assertRecord(produced, "zodToJsonSchema always returns a schema object");
delete produced["$schema"];
stripUnsupported(produced);
return produced;
};
export const INVOICE_JSON_SCHEMA = toModelJsonSchema(Invoice);