Shortpay: Project Write-up
Pre-Pay Freight Surcharge Audit for the Autonomous Office of the CFO Track 2: Syndicate by Maximor Hackathon Beautiful Preview at : https://gist.github.com/DevloperHS/dbec4a1e1b12c0958b67013b8fcee8a4
1. Why we built this project
Freight audit and payment already have clear owners in the CFO's office. Logistics books the load, AP pays the carrier, and finance owns tolerance and coding. The leak happens before payment. Most AP teams sample some carrier invoices, or they outsource recovery to contingency freight-audit (FAP) firms. These firms take a cut after the money has left the business. FAP vendors claim that 3–7% of freight spend leaks through accessorial errors. Examples include a liftgate charge at a dock door, detention billed past free time, the wrong DOE fuel week, and duplicate invoices. Shortpay does not rely on that estimate. It proves one invoice.
We built Shortpay as an AP desk that audits carrier accessorials before payment:
- A logistics controller uploads the rate baseline (contract terms CSV).
- Carrier invoices (PDF/text), IoT dock timestamps, and facility facts land beside them.
- Each LTL/TL invoice is matched to its shipment. An authorized payable is computed in integer cents by a pure mathematical engine. No LLM runs on the money path.
- The case either auto-closes under policy, or one card with one intuitive math grid is put in front of a human controller who approves a short-pay packet: pay $925.00, dispute $195.00, attach the dock receipt.
The framing was deliberate. Maximor's product story is a loop: learn a short-pay policy, auto-close $38, escalate $495, tighten the policy. Shortpay applies that loop to a real AP document instead of building a general "invoice copilot." The ERP remains the system of record. Shortpay proposes the payable and the dispute notice, but it does not become the GL.
The hero case shows the full thesis. FedEx Freight SHP-88220 was billed $1,120.00. The authorized amount is $925.00, and the dispute is $195.00. The liftgate is not authorized because the destination has a dock. Detention is recomputed from 93 minutes of dock dwell against 30 minutes of contract free time. That gives 1 completed hour × $75. One unit test checks those exact cents.
2. Pain points it solves
| Pain point today | How Shortpay solves it |
|---|---|
| Sampling instead of auditing. Auditing takes minutes per line and hours per invoice, so teams sample. The unsampled invoices can still contain errors. | Every invoice is matched and computed automatically. Exceptions land on a Kanban board, and clean invoices close themselves. |
| Swivel-chair reconciliation. The clerk opens the bill, searches the TMS for the BOL/PRO, and opens the rate con, POD, and dock log for every line. | All four evidence sources (contract baseline, facility master, IoT dock log, invoice) are ingested and joined per shipment. The variance is computed for them. |
| Error-prone re-math. Detention must be recomputed from timestamps, and liftgate depends on whether the destination facility has a dock. People can get this wrong, especially at high volume. | match_evidence() is a pure function: billable = max(0, dwell − free), hours = billable // 60, and liftgate is authorized at $0.00 when a dock exists. The same inputs always produce the same cents. |
| Accessorial leakage. Liftgate, detention, residential, and reweigh lines are exactly what a sample audit can miss. | v1 proves base freight against the contract rate, liftgate against facility facts, and detention against dock dwell. These are the lines that a dock clock and facility master can prove. |
| Untrustworthy AI output. An accountant cannot approve a result by simply accepting an agent's answer. | The controller approves a math grid. The LLM only extracts billed facts. The prompt and the architecture prevent it from computing a payable. |
| Post-payment recovery is slow and adversarial. Contingency FAP firms chase refunds after payment. | Short-pay happens pre-payment: pay the authorized amount, dispute the variance with evidence attached. |
| Opaque automation. Nobody can answer "where did $75 come from?" after the fact. | Every extraction, match, and decision emits Neatlogs spans with a stable trace id (trace-freight-shp-88220) and a SHA-256 evidence-pack hash stored on the case. |
3. Challenges faced while building
3.1 Keeping the LLM off the money path
The biggest architectural challenge was keeping the model from doing the audit. An LLM that emits payable cents cannot be locked to $925 and cannot support an audit. The solution is simple. The LLM acts as an extraction adapter only. It maps invoice text to a validated InvoiceFact (Pydantic AI PromptedOutput, temperature 0, max_tokens 768, retry 2). Its instructions say, "Do not calculate an authorized payable, dispute amount, or policy decision." All money math lives in match_evidence(), a pure function with zero I/O and zero model calls. If extraction fails, such as when "gate fee" cannot be mapped, the system creates an UnexplainedLine. That blocks auto-close instead of silently paying $0.
3.2 Integer-cents discipline
Floating-point money is a silent killer (0.1 + 0.2 ≠ 0.3). The rule is absolute: no floats on the domain path. Money and Minutes are frozen dataclasses whose __post_init__ rejects bools and floats outright, and Pydantic field validators re-assert integrality at the evidence edge. The hero lock test (92500 / 19500) was written before any UI existed, per the PRD's "must" list.
3.3 Two-tier LLM resilience and sponsor guardrails
Sponsor APIs can fail, rate-limit, or lack keys. The extraction adapter uses a strict fallback chain. TensorMux (glm-4-7-flash) is primary, and Groq (qwen/qwen3.8-27b) is secondary. The adapter raises an explicit ExtractionError with the failure reason from each provider only when both providers fail. The hackathon also required a sponsor guardrail. Each provider allows 60 outbound HTTP attempts per rolling 60 seconds. A thread-safe ProviderRequestLimiter connects to the httpx transport layer, so SDK retries and concurrent requests count too. Attempt 61 is blocked before transmission.
3.4 Human-in-the-loop that fails closed
A controller could click "approve short-pay" on a stale screen and pay the wrong amount. To prevent this, ApproveShortPay must echo the payable shown on screen. AuditOffice.decide raises StaleDecisionError when that amount differs from match_result.expected_total_cents. The HITL amount is deliberately "ceremonial." It makes the stale-screen race fail closed without adding a version field. Dispositions are a sum type (NeedsReview | AutoClosed | ShortPaid | PaidAsBilled | SkippedOutOfScope), so the system cannot represent illegal AP states such as short-paid and paid-as-billed.
3.5 Idempotency and convergence
Re-ingesting the same invoice must not create a duplicate card. Fact identity is keyed on (invoice_id, shipment_id) per fact type; same-values re-ingest is a no-op; changed values on a non-terminal case replace the fact and rematch; terminal cases never reopen in v1 (new invoice id → new case); match is a pure function of current facts, so re-runs converge.
3.6 Architecture selection under deadline pressure
The team used an "arena" process. We sketched two structurally different candidates, A (AuditCase aggregate plus disposition machine) and B (dual money ledger as the root). We judged them against a rubric and then combined the strongest parts. We selected A as the root, with B's ledgers inside the case. B's outer join on ChargeType is the right model for money, but the specialist's work item is a case with a column and a button. If we had shipped B as the root, it would still have needed a case object, which would have created two work items. The rejected options are also documented: hunter→extractor→matcher as pipeline modules (temporal decomposition), LLM-emitted cents, mutable invoices, HITL amount entry, and IEEE-ceil detention.
3.7 Frontend pivot mid-build
ADR 0001 planned a Next.js frontend. What shipped is a Vite React SPA served by a Flask BFF (ADR 0004, which supersedes the Next.js ADRs). Flask owns cookies, static files, and /api/ui/* proxies. React never calls FastAPI directly. This removes CORS from the demo path and gives the BFF a natural place to prevent the human from posting a typed payable. The Flask layer validates that expected_payable_cents is an integer and forwards it unchanged. The matcher still rejects a stale number. The cost is documented clearly: a clone must run npm run build before Flask can serve a working UI.
3.8 Domain edge cases
Overnight dock logs (departure before arrival) are explicitly rejected in v1 rather than guessed; ocean/parcel modes surface as human-readable SkippedOutOfScope cards instead of running wrong math; PDF ingestion is bounded (≤ 15 MB, minimum extractable text) and failed PDFs raise PdfParseError instead of limping into the LLM.
4. Technicalities & architecture
4.1 Stack at a glance
| Layer | Technology | Port |
|---|---|---|
| Domain engine | Python 3.11+, Pydantic v2, pure-function matcher | N/A |
| Domain HTTP API | FastAPI + Uvicorn, CORS enabled, auto-ingests fixtures on startup | 8000 |
| BFF / UI server | Flask (python -m frontend), owns static files + /api/ui/* proxy |
5000 |
| Specialist UI | Vite + React SPA (built to frontend/static/react/), lucide-react icons |
N/A |
| Extraction (primary) | Pydantic AI Agent + PromptedOutput(InvoiceFact) over TensorMux (glm-4-7-flash) |
N/A |
| Extraction (fallback) | Groq OpenAI-compatible endpoint (qwen/qwen3.8-27b) |
N/A |
| Observability | Neatlogs spans: extract_billed, match_evidence, decide; SHA-256 evidence hash |
N/A |
| Tests | Pytest (37 passing; live-sponsor suite opt-in) | N/A |
4.2 System context
flowchart LR
subgraph Sources["Evidence sources"]
CSV["Rate baseline CSV<br/>(controller upload)"]
PDF["Carrier invoice<br/>(PDF / text)"]
DOCK["IoT dock log<br/>(arrival/departure)"]
FAC["Facility master<br/>(has_dock)"]
end
subgraph Frontend["Presentation (port 5000)"]
UI["Vite React SPA<br/>Kanban board + Math Grid modal"]
BFF["Flask BFF<br/>/api/ui/* proxy, cookies,<br/>static files, PDF upload"]
end
subgraph Backend["Domain (port 8000)"]
API["FastAPI<br/>/api/ingest /api/cases<br/>/api/cases/{id} /api/decide"]
OFFICE["AuditOffice<br/>ingest · match · decide"]
end
LLM["TensorMux (primary)<br/>Groq (fallback)<br/>Pydantic AI extraction"]
NL["Neatlogs<br/>observability"]
ERP["ERP proposal +<br/>dispute packet (mock)"]
CSV --> API
PDF --> BFF --> API
DOCK --> API
FAC --> API
UI --> BFF
API --> OFFICE
OFFICE --> LLM
OFFICE --> NL
OFFICE --> ERP
BFF --> UI
4.3 Domain core: knowledge ownership, not pipeline stages
The shortpay/ package is deliberately not a hunter→extractor→matcher pipeline. It is a map of who owns which knowledge:
flowchart TD
UI["harvest / UI / demo"] --> OFFICE["AuditOffice<br/>ingest · match · decide"]
OFFICE --> STORE["CaseStore (in-memory)<br/>facts + case snapshots"]
OFFICE --> CASE["AuditCase<br/>two ledgers + disposition"]
OFFICE --> MATCH["match_evidence<br/>(pure function, no I/O)"]
STORE --> CASE
ADAPTERS["adapters/<br/>baseline_csv · invoice_extract<br/>dock_log · facility_master<br/>erp_propose · vault_hunter"] --> STORE
POLICY["PolicyBook<br/>SHORT-PAY-01<br/>$50 auto-close threshold<br/>per-lane approved rules"] --> OFFICE
Module map:
shortpay/
money.py Money, Minutes (frozen, rejects bool/float)
evidence.py InvoiceFact, ContractFact, FacilityFact, DockDwellFact
+ constructors that validate at the edge
ledgers.py BilledLine, ExpectedLine, LedgerJoin
matching.py match_evidence (pure), completed_detention_hours
policy.py LaneKey, PolicyBook (SHORT-PAY-01)
case.py AuditCase, Disposition sum type, decisions
store.py InMemoryStore (fact + case storage; the seam for a future
Postgres adapter, all persistence flows through this one object)
office.py AuditOffice, the only public verbs: ingest / match / decide
neatlogs.py failure-isolated tracer facade + evidence hashing
adapters/ vendor I/O; never exported from __init__
The money model. Inside a case, truth is two frozen ledgers plus a join. The carrier invoice is immutable (BilledLedger), expected money is computed separately (ExpectedLedger), and variance is an outer join on ChargeType. Payable = sum(expected). Dispute lines are billed lines where billed > expected. Liftgate is an explicit expected $0 when a dock is present, so the math grid is a total function. Every line gets an explanation.
Disposition is a sum type, so illegal AP states (short-paid and paid-as-billed) are unrepresentable:
stateDiagram-v2
[*] --> NeedsReview: overbill above $50<br/>or unapproved lane rules
[*] --> AutoClosed: dispute ≤ $50 AND all<br/>fired rules pre-approved on lane
[*] --> SkippedOutOfScope: mode not LTL/TL
NeedsReview --> ShortPaid: ApproveShortPay<br/>(amount must equal expected, else fail closed)
NeedsReview --> PaidAsBilled: OverridePayAsBilled<br/>(reason required)
ShortPaid --> [*]: ERP payable proposed<br/>+ dispute packet attached
PaidAsBilled --> [*]: override reason stored
AutoClosed --> [*]: auto ERP posting
SkippedOutOfScope --> [*]: human-readable skip reason
The autonomy loop. Every human ApproveShortPay records the fired rule ids, such as LIFTGATE_DOCK_PRESENT and DETENTION_HOURS, on the lane's PolicyBook. The next small overbill on the same lane, based on carrier, mode, and dock configuration, auto-closes under SHORT-PAY-01 when the dispute is ≤ $50, there are no unexplained lines, and all fired rules are pre-approved. The product learns from human approvals. That is Maximor's loop made real.
4.4 Full request lifecycle (hero case)
sequenceDiagram
autonumber
participant C as Controller (React UI)
participant F as Flask BFF (5000)
participant A as FastAPI (8000)
participant O as AuditOffice
participant L as TensorMux→Groq
participant N as Neatlogs
participant E as ERP / Dispute (mock)
A->>A: startup, auto-ingest fixtures<br/>(baseline CSV, dock, facility, invoice JSON)
C->>F: paste invoice text / upload PDF
F->>A: POST /api/ingest/invoice
A->>L: extract billed facts (Pydantic AI,<br/>60 req/60s guardrail per provider)
L-->>A: validated InvoiceFact (integer cents)
A->>O: ingest(invoice_fact)
A->>O: match(CaseKey)
Note over O: match_evidence():<br/>dwell 93 − 30 = 63 min → 1 hr × $75<br/>liftgate $0 (dock present)<br/>expected 92500 · dispute 19500
O->>N: span match_evidence + SHA-256 evidence hash
A-->>F: case payload (disposition NeedsReview)
F-->>C: render Math Grid modal
C->>F: POST /api/ui/cases/{id}/decide<br/>(ApproveShortPay, 92500, echoed from screen)
F->>A: POST /api/decide
A->>O: decide(key, ApproveShortPay)
Note over O: stale amount → StaleDecisionError;<br/>else record rules on lane PolicyBook,<br/>disposition → ShortPaid(92500)
O->>N: span decide
O->>E: PayablePosting(92500) + dispute packet
A-->>F: ShortPaid + ERP proposal + dispute packet
F-->>C: card moves to Short-paid column
4.5 Boundaries, idempotency, observability
- Parse at the boundary. Every wire format (CSV rows, PDF JSON, IoT payloads, TMS tables, NetSuite vendor-bill objects) is converted into domain facts by an adapter and never leaks past it. The architecture test is simple. If TensorMux and the hunter adapter disappear,
AuditOffice.ingeststill works with hand-built facts. The agents are not the architecture. - Idempotency. One invoice per
(invoice_id, shipment_id), one dwell / facility / contract per shipment. Same-values re-ingest is a no-op; changed values on a non-terminal case replace the fact and rematch; terminal cases never reopen in v1;matchre-runs converge; re-sending the samedecideon a terminal case is a no-op. - Observability without coupling. Domain types never import Neatlogs. A thin, failure-isolated tracer facade wraps
AuditOfficemethods and adapter calls. Every match stores a SHA-256 hash of the canonical-JSON evidence pack on the case, so a later auditor can see exactly what the $75 was computed from. Trace ids are stable per shipment (trace-freight-shp-88220). - Evals are fixtures, not vibes. HERO, DOCK_FALSE, DWELL_AT_FREE, OCEAN_SKIP, IDEMPOTENT, BAD_EXTRACT, AUTO_CLOSE, and STALE are executable fixtures with exact cent expectations. Pytest locked them before any UI existed.
5. Future improvements
5.1 Domain breadth (highest value next)
- More accessorials. Fuel surcharge (validated against the contract's DOE week), residential delivery, reweigh/reclassification. The
ChargeTypejoin and rule-firing pattern are designed to extend without touching the case aggregate. - Duplicate-invoice detection. Same BOL + carrier + amounts under different invoice ids is a classic leak; a fuzzy-match warning lane would catch it pre-payment.
- Overnight dock logs & timezones. v1 rejects departure-before-arrival. Facilities in other timezones and cross-midnight dwell need normalizing before
dwell_minutesmath. - Reopen terminal cases. Late-arriving dock IoT on a decided case currently creates nothing; a controlled reopen (new snapshot, preserves audit trail) would make the office more forgiving without breaking trust.
5.2 Platform hardening
- Durable persistence. All persistence already flows through a single
InMemoryStoreobject; extracting it into aCaseStoreprotocol and adding a Postgres adapter is a contained change. Add optimistic concurrency (version onAuditCase) instead of relying on fail-closed echo amounts alone. - Auth, orgs, tenancy. v1 has none by design (per PRD: "must not build auth, orgs, or a marketplace"). Multi-tenant lanes and role-based approval limits are the first production blockers.
- Real ERP integration. Replace the
PayablePosting/DisputePacketmock with a NetSuite (or EDI 210) adapter behind the same proposal contract, plus carrier-side dispute email delivery with the dock receipt attached. - Production serving. Flask's dev server and single-process Uvicorn need a WSGI/ASGI production setup; extraction calls should move off
run_synconto async paths.
5.3 Extraction & AI quality
- Extraction confidence & review queue. Score
InvoiceFactextractions and route low-confidence ones straight to NeedsReview with a highlighted "extracted, verify" badge. - Few-shot per carrier. Carrier PDF layouts vary wildly; per-carrier extraction examples (FedEx Freight, XPO, Estes) would lift first-pass accuracy and reduce Groq fallbacks.
- Expanded eval set. Grow the 8 fixtures into a regression suite of real (anonymized) invoices with per-line gold labels, run in CI against both providers.
5.4 Product depth
- Audit trail UI. The evidence-pack hash is stored today; surfacing "what exactly produced this $75" (facts, fired rules, evidence hash, Neatlogs link) inside the case modal turns observability into a controller-facing feature.
- Policy authoring. Let the controller define thresholds per lane beyond the fixed $50 /
SHORT-PAY-01(e.g. per-rule dollar caps, auto-approve clean underbills), with the same human-approves-first gating. - Board ergonomics. Bulk approve, SLA timers on stale exceptions, CSV export of decided cases for finance close, and WebSocket-driven board refresh.
- Anomaly surfacing. Lane-level trends, such as detention creep on one dock or a liftgate spike on one carrier, from accumulated case history. This can turn the AP desk from line auditor into spend controller.
6. Verification
uv run pytest backend/testsreturned 37 passed, 4 skipped (opt-in live-sponsor suite). The suite includes the hero cents lock (92500/19500), API contract tests, extraction fallback tests, guardrail limiter tests, and Neatlogs emission tests.- Full stack runs locally: FastAPI on
:8000(Swagger at/docs), Flask + React on:5000; fixtures auto-ingest on startup and the hero case appears in Major exceptions with billed $1,120.00 / dispute $195.00.
If mermaid is not rendering, kindly check with any of the render, it will help.
Log in or sign up for Devpost to join the conversation.