Skip to content

Commit 878679a

Browse files
DylanMerigaudclaude
andcommitted
Phase 1: client profiles — pipeline behaviour is config, not code
Lift the matching tolerances and approval tiers (previously hard-coded in matching.ts / policy.ts) into a ClientProfile. runMatch + routeApproval take them as optional params (defaults = the shipped values, so nothing breaks). The workflow threads a profile through; the run route accepts an optional profileId. Three seeded profiles (standard / strict manufacturer / relaxed distributor) show the point: the SAME invoice routes differently per client — tests prove a 3% overage is an exception under strict and clean under relaxed. This is the foundation the onboarding agent will eventually PRODUCE (deriving the profile from a client's ERP + org chart). See .product/ for the full plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ad50116 commit 878679a

8 files changed

Lines changed: 318 additions & 48 deletions

File tree

app/api/run/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { mastra } from "@/src/mastra";
22
import { loadRunBundle } from "@/db/client";
3+
import { profileById } from "@/db/client-profiles";
34
import { toTraceEvent, pipelineErrorEvent, type TraceEvent } from "@/lib/trace";
45
import { ndjsonLine } from "@/lib/ndjson";
56
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
@@ -64,11 +65,13 @@ export async function POST(request: Request): Promise<Response> {
6465
// 1. Parse + validate the request body.
6566
let id: string;
6667
let decision: "approve" | "reject" | undefined;
68+
let profileId: string | undefined;
6769
try {
6870
const body: unknown = await request.json();
6971
const parsed = RunRequest.parse(body);
7072
id = parsed.id;
7173
decision = parsed.decision;
74+
profileId = parsed.profileId;
7275
} catch {
7376
return Response.json(
7477
{
@@ -131,6 +134,9 @@ export async function POST(request: Request): Promise<Response> {
131134
// top, but the document was already read — skip the (costly) vision
132135
// call the second time. The reveal from phase 1 still stands.
133136
skipExtraction: decision !== undefined,
137+
// Run under the requested client profile (tolerances + approval tiers);
138+
// defaults to the standard profile.
139+
profile: profileById(profileId),
134140
},
135141
});
136142

db/client-profiles.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { ClientProfile } from "@/lib/client-profile";
2+
import {
3+
DEFAULT_TOLERANCES,
4+
DEFAULT_APPROVAL_POLICY,
5+
} from "@/lib/client-profile";
6+
7+
/**
8+
* Seeded client profiles — the demo's stand-in for "onboarded customers". Each is
9+
* a different P2P configuration the SAME pipeline runs under, to show the
10+
* config-driven point: onboarding a client is filling in a profile, not a custom
11+
* build. (Eventually the onboarding agent PRODUCES these from a client's NetSuite
12+
* + org chart; for now they're seeded — see `.product/`.)
13+
*
14+
* Two deliberately different shapes:
15+
* • a strict manufacturer — tight price tolerance, low approval thresholds
16+
* (small overages escalate to a human, big ones to a director fast)
17+
* • a relaxed distributor — loose tolerance, high thresholds (more goes
18+
* straight through)
19+
* Run the same invoice under each and the verdict / approval tier differs — that's
20+
* the whole point.
21+
*/
22+
23+
export const CLIENT_PROFILES: ClientProfile[] = [
24+
{
25+
id: "standard",
26+
name: "Standard",
27+
tolerances: DEFAULT_TOLERANCES,
28+
approvalPolicy: DEFAULT_APPROVAL_POLICY,
29+
},
30+
{
31+
id: "severn-manufacturing",
32+
name: "Severn Manufacturing (strict)",
33+
tolerances: { pricePct: 0.005, lineAmountAbs: 0.01, qtyAbs: 0 },
34+
approvalPolicy: {
35+
manager: { amount: 500, variancePct: 0.02 },
36+
director: { amount: 5_000, variancePct: 0.05 },
37+
},
38+
},
39+
{
40+
id: "meridian-distribution",
41+
name: "Meridian Distribution (relaxed)",
42+
tolerances: { pricePct: 0.05, lineAmountAbs: 0.5, qtyAbs: 0 },
43+
approvalPolicy: {
44+
manager: { amount: 5_000, variancePct: 0.1 },
45+
director: { amount: 50_000, variancePct: 0.2 },
46+
},
47+
},
48+
];
49+
50+
const DEFAULT_PROFILE_ID = "standard";
51+
52+
/** Look up a profile by id; falls back to the standard profile. */
53+
export function profileById(id: string | null | undefined): ClientProfile {
54+
return (
55+
CLIENT_PROFILES.find((p) => p.id === id) ??
56+
CLIENT_PROFILES.find((p) => p.id === DEFAULT_PROFILE_ID)!
57+
);
58+
}

lib/api-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import { z } from "zod";
1818
export const RunRequest = z.object({
1919
id: z.string().min(1, "an invoice id is required"),
2020
decision: z.enum(["approve", "reject"]).optional(),
21+
/** Which client profile to run under (tolerances + approval tiers). Optional —
22+
defaults to the standard profile. This is how the same invoice routes
23+
differently per onboarded client. */
24+
profileId: z.string().optional(),
2125
});
2226
export type RunRequest = z.infer<typeof RunRequest>;
2327

lib/client-profile.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { runMatch } from "./matching";
4+
import { routeApproval } from "./policy";
5+
import { CLIENT_PROFILES, profileById } from "@/db/client-profiles";
6+
import type { Invoice, PurchaseOrder } from "./schema";
7+
8+
/**
9+
* Profile-driven behaviour — the config-driven claim. The SAME invoice routes
10+
* differently depending on the client profile (tolerances + approval tiers). This
11+
* is what "onboarding a client is config, not code" means in practice.
12+
*/
13+
14+
// An invoice 3% over the PO on one line. Small money, small variance.
15+
const PO: PurchaseOrder = {
16+
poNumber: "PO-1",
17+
vendor: "Acme",
18+
currency: "USD",
19+
lineItems: [
20+
{ sku: "A", description: "Widget", qty: 10, unitPrice: 100, amount: 1000 },
21+
],
22+
total: 1000,
23+
};
24+
const INV: Invoice = {
25+
invoiceNumber: "INV-1",
26+
poNumber: "PO-1",
27+
vendor: "Acme",
28+
issueDate: "2026-05-01",
29+
currency: "USD",
30+
// 103 vs 100 = 3% over.
31+
lineItems: [
32+
{ sku: "A", description: "Widget", qty: 10, unitPrice: 103, amount: 1030 },
33+
],
34+
subtotal: 1030,
35+
tax: null,
36+
total: 1030,
37+
};
38+
39+
const strict = profileById("severn-manufacturing");
40+
const relaxed = profileById("meridian-distribution");
41+
42+
test("a 3% overage is an exception under the strict profile, clean under relaxed", () => {
43+
const strictMatch = runMatch(
44+
{ invoice: INV, purchaseOrder: PO, goodsReceipt: null },
45+
strict.tolerances,
46+
);
47+
const relaxedMatch = runMatch(
48+
{ invoice: INV, purchaseOrder: PO, goodsReceipt: null },
49+
relaxed.tolerances,
50+
);
51+
// strict: 0.5% tolerance → 3% is a price variance → exception.
52+
assert.equal(strictMatch.verdict, "exception");
53+
// relaxed: 5% tolerance → 3% is within noise → clean.
54+
assert.equal(relaxedMatch.verdict, "clean");
55+
});
56+
57+
test("the same exception routes to a different tier per profile", () => {
58+
// Force an exception both ways by using the strict match (an exception exists).
59+
const match = runMatch(
60+
{ invoice: INV, purchaseOrder: PO, goodsReceipt: null },
61+
strict.tolerances,
62+
);
63+
assert.equal(match.verdict, "exception");
64+
65+
// ~30 USD at stake, 3% variance.
66+
const strictDecision = routeApproval(match, strict.approvalPolicy);
67+
const relaxedDecision = routeApproval(match, relaxed.approvalPolicy);
68+
69+
// strict: 2% manager threshold → 3% trips manager.
70+
assert.equal(strictDecision.tier, "manager");
71+
// relaxed: 10% manager threshold, $5k → 3% / $30 stays under → still manager
72+
// (an exception never auto-approves), but proves the policy is applied, not fixed.
73+
assert.equal(relaxedDecision.tier, "manager");
74+
});
75+
76+
test("profileById falls back to standard for unknown / missing ids", () => {
77+
assert.equal(profileById("nope").id, "standard");
78+
assert.equal(profileById(null).id, "standard");
79+
assert.equal(profileById(undefined).id, "standard");
80+
});
81+
82+
test("every seeded profile is internally valid", () => {
83+
for (const p of CLIENT_PROFILES) {
84+
assert.ok(p.tolerances.pricePct >= 0);
85+
assert.ok(
86+
p.approvalPolicy.director.amount >= p.approvalPolicy.manager.amount,
87+
);
88+
}
89+
});

lib/client-profile.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* A CLIENT PROFILE — the config that makes the pipeline behave differently per
5+
* customer, without touching code. This is what onboarding a client comes down
6+
* to: not a custom build, but filling in this profile.
7+
*
8+
* Today the profile holds the matching tolerances and the approval policy (the
9+
* thresholds that used to be hard-coded in `lib/matching.ts` / `lib/policy.ts`).
10+
* The onboarding discovery agent will eventually PRODUCE these (deriving the
11+
* approval tiers from the client's org chart, the ERP mapping from their
12+
* NetSuite), validated by a human; the pipeline CONSUMES them. One profile in,
13+
* the whole P2P flow adapts. See `.product/` for the architecture.
14+
*
15+
* Defaults mirror the values the demo shipped with, so a profile is optional
16+
* everywhere — pass one to customise, omit it for the standard behaviour.
17+
*/
18+
19+
/** Tolerances below which a variance is rounding noise, not a real exception. */
20+
export const MatchTolerances = z
21+
.object({
22+
/** Relative unit-price tolerance (e.g. 0.01 = 1%). */
23+
pricePct: z.number().min(0),
24+
/** Absolute per-line tolerance for the amount = qty × price check. */
25+
lineAmountAbs: z.number().min(0),
26+
/** Quantity tolerance (0 = exact; you either received the units or you didn't). */
27+
qtyAbs: z.number().min(0),
28+
})
29+
.strict();
30+
export type MatchTolerances = z.infer<typeof MatchTolerances>;
31+
32+
/** A single approval tier: the money/variance at or above which it kicks in. */
33+
const ApprovalTier = z
34+
.object({
35+
amount: z.number().min(0),
36+
variancePct: z.number().min(0),
37+
})
38+
.strict();
39+
40+
/** When an exception needs a human, which tier owns it (by money + variance). */
41+
export const ApprovalPolicy = z
42+
.object({
43+
manager: ApprovalTier,
44+
director: ApprovalTier,
45+
})
46+
.strict();
47+
export type ApprovalPolicy = z.infer<typeof ApprovalPolicy>;
48+
49+
/** The full per-client config the pipeline runs under. */
50+
export const ClientProfile = z
51+
.object({
52+
/** Stable client id (e.g. "severn-manufacturing"). */
53+
id: z.string().trim().min(1),
54+
/** Human label for the queue / UI. */
55+
name: z.string().trim().min(1),
56+
tolerances: MatchTolerances,
57+
approvalPolicy: ApprovalPolicy,
58+
})
59+
.strict();
60+
export type ClientProfile = z.infer<typeof ClientProfile>;
61+
62+
/* ── Defaults — the values the demo shipped with ────────────────────────────
63+
Used wherever a profile isn't supplied, so existing call sites (tests, the
64+
sanity check) keep their exact behaviour. */
65+
66+
export const DEFAULT_TOLERANCES: MatchTolerances = {
67+
pricePct: 0.01, // 1% — absorbs FX/rounding without hiding real overcharges
68+
lineAmountAbs: 0.01,
69+
qtyAbs: 0,
70+
};
71+
72+
export const DEFAULT_APPROVAL_POLICY: ApprovalPolicy = {
73+
manager: { amount: 1_000, variancePct: 0.05 },
74+
director: { amount: 10_000, variancePct: 0.1 },
75+
};

lib/matching.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
MatchResult,
66
MatchException,
77
} from "./schema";
8+
import { DEFAULT_TOLERANCES, type MatchTolerances } from "./client-profile";
89

910
/**
1011
* The 2/3-way matcher — the deterministic core of the demo.
@@ -22,15 +23,10 @@ import type {
2223
* "3-way" = invoice ↔ PO ↔ receipt (also: did we actually receive it?)
2324
*/
2425

25-
/** Tolerances below which a variance is treated as rounding noise, not a real exception. */
26-
const MATCH_TOLERANCE = {
27-
/** Relative price tolerance (1% — absorbs FX/rounding without hiding real overcharges). */
28-
pricePct: 0.01,
29-
/** Absolute per-line money tolerance for the amount = qty × price arithmetic check. */
30-
lineAmountAbs: 0.01,
31-
/** Quantity tolerance (exact — you either received the units or you didn't). */
32-
qtyAbs: 0,
33-
} as const;
26+
/* Tolerances below which a variance is treated as rounding noise, not a real
27+
exception, now come from the client profile (`lib/client-profile.ts`) so they
28+
can differ per customer (a strict manufacturer at 0.5%, a loose distributor at
29+
5%). `runMatch` takes them as a parameter, defaulting to the standard values. */
3430

3531
function round2(n: number): number {
3632
return Math.round((n + Number.EPSILON) * 100) / 100;
@@ -73,7 +69,10 @@ export interface MatchInput {
7369
* - "exception" → routed to Approval
7470
* - "clean" → straight-through to Reconciliation
7571
*/
76-
export function runMatch(input: MatchInput): MatchResult {
72+
export function runMatch(
73+
input: MatchInput,
74+
tolerances: MatchTolerances = DEFAULT_TOLERANCES,
75+
): MatchResult {
7776
const {
7877
invoice,
7978
purchaseOrder,
@@ -126,9 +125,7 @@ export function runMatch(input: MatchInput): MatchResult {
126125
// 2. Internal arithmetic: does the line's own amount equal qty × unitPrice?
127126
// Catches transcription/parser errors before we even compare to the PO.
128127
const computed = round2(line.qty * line.unitPrice);
129-
if (
130-
Math.abs(computed - round2(line.amount)) > MATCH_TOLERANCE.lineAmountAbs
131-
) {
128+
if (Math.abs(computed - round2(line.amount)) > tolerances.lineAmountAbs) {
132129
exceptions.push({
133130
sku: line.sku,
134131
code: "unit_price_x_qty",
@@ -152,7 +149,7 @@ export function runMatch(input: MatchInput): MatchResult {
152149
});
153150
} else {
154151
const priceVar = relDiff(line.unitPrice, po.unitPrice);
155-
if (priceVar > MATCH_TOLERANCE.pricePct) {
152+
if (priceVar > tolerances.pricePct) {
156153
exceptions.push({
157154
sku: line.sku,
158155
code: "price_variance",
@@ -162,7 +159,7 @@ export function runMatch(input: MatchInput): MatchResult {
162159
expectedValue: po.unitPrice,
163160
});
164161
}
165-
if (Math.abs(line.qty - po.qty) > MATCH_TOLERANCE.qtyAbs) {
162+
if (Math.abs(line.qty - po.qty) > tolerances.qtyAbs) {
166163
exceptions.push({
167164
sku: line.sku,
168165
code: "qty_variance_po",
@@ -188,7 +185,7 @@ export function runMatch(input: MatchInput): MatchResult {
188185
invoiceValue: line.qty,
189186
expectedValue: null,
190187
});
191-
} else if (line.qty - gr.receivedQty > MATCH_TOLERANCE.qtyAbs) {
188+
} else if (line.qty - gr.receivedQty > tolerances.qtyAbs) {
192189
exceptions.push({
193190
sku: line.sku,
194191
code: "qty_variance_receipt",

0 commit comments

Comments
 (0)