InvoicePilot
AI-powered invoice processing platform that extracts, validates, and analyzes invoices while detecting duplicates and anomalies to streamline financial document workflows.
[ 01 ] — Context
Problem
- Bookkeeping firms process invoices by hand: transcribing totals, matching purchase orders, and hunting for duplicates — an error-prone paper trail that takes hours and leaves no proof of what was actually done.
- Duplicate payments and fraud slip through because there is no structured audit trail, and any claim that an invoice was processed rests on someone's word.
- Generic extraction tools pull fields but cannot vouch that the extracted data matches the file — so automation stops before approval, exactly where the hours get saved.
[ 02 ] — Response
Solution
InvoicePilot runs one end-to-end pipeline per document: capture → extraction → normalization → duplicate, business, and fraud checks → human approval → on-chain cryptographic seal → accounting sync. A sha-256 of the exact uploaded bytes is the document's identity, re-verified by the AI service, so a seal can never cover a file that doesn't match its hash.
OCR and sealing are deliberately one pipeline — what the OCR extracts is what gets sealed on-chain. Two ingest routes (sync and async) fold onto the same invoices row through a single shared, frozen mapper, so the same document always produces the same on-chain hash.
Async work runs on BullMQ workers — extraction, duplicate-check, anomaly-check, blockchain-seal, solidinvoice-sync — while PostgreSQL row-level security FORCE policies keep every tenant's data isolated at the database, not the ORM.
[ 03 ] — Structure
Architecture
Frontend (Vite + React) · Supabase auth · Cloudflare R2
│ multipart upload / presigned R2 URL
▼
Fastify API — JWT (Supabase JWKS) · RBAC · rate-limit · audit log
│ │
│ sync /invoices/process │ async /invoices → BullMQ
▼ ▼
invoice-ai-service (FastAPI) Worker queues
verify sha-256 · OCR (Mindee) invoice-extraction · duplicate-check
transform · normalize · anomaly-check · blockchain-seal
business rules (12) · fraud (9) solidinvoice-sync
classify · validate · persist
│ │
▼──────────────────────────▼
PostgreSQL 17 — RLS FORCE · NOBYPASSRLS service role
│
▼
SealRegistry.sol (Base Sepolia)
{ recordHash, recordType, sealedAt, sealedBy } — append-only, no PII[ 04 ] — Trade-offs
Key engineering decisions
- 01
Hash-only on-chain (privacy by design)
The contract stores only {recordHash, recordType, sealedAt, sealedBy} — never amounts, PII, or vendor data. Each hash is sha-256 over RFC 8785 (JCS) canonical JSON (NFC-normalized, money in integer minor units) tagged with a versioned domain string, so a document can be verified without any sensitive data leaving its owner's control.
- 02
The frozen anti-fabrication mapper
The AI service emits {value: 0.0, present: false} for a field it never found. The one shared mapper omits any key the extractor did not produce instead of writing a zero — so a $0 invoice invented from a default can never reach an approver, let alone a seal. It is deliberately frozen in shared-types so both ingest routes seal identical bytes.
- 03
RLS FORCE + service-role split
Tenant isolation is enforced at the database, not the ORM. The API sets per-request GUCs, the worker connects with app.current_role='service' under a NOBYPASSRLS production role, and FORCE ROW LEVEL SECURITY keeps reads tenant-scoped however the query is written.
- 04
AES-256-GCM for PII at rest
Vendor bank details and firm tax IDs are encrypted field-level with aes-256-gcm, stored hex-serialized, and tied to a referenced KMS key alias. Decryption happens only where a route genuinely needs the plaintext — the rest of the system never sees it.
- 05
Versioned, frozen approval hashes
The hashing kernel lives in shared-types behind a versioned domain tag (invoicepilot/approval/v1). v1 seals must stay verifiable forever, so a changed sealed-field set ships as approval-v2 rather than an edit to the payload builder.
- 06
Verify never writes
The public verify path recomputes sha-256 in-process and calls a keyless view function on-chain: no DB write, no audit row, no signer. Every outcome — match, mismatch, not-yet-sealed, chain error, payload error — resolves explicitly and never throws.
[ 05 ] — Under the hood
Engineering highlights
// shared-types/src/canonical.ts — the backend-agnostic seal core.
// recordHash = '0x' + sha256( domainTag + '\n' + canonicalJSON(payload) )
export function computeRecordHash(
payload: unknown,
domainTag: string,
): `0x${string}` {
return `0x${sha256Hex(domainTag + '\n' + canonicalJSON(payload))}`;
}// AI emits { value: 0.0, present: false } for fields it never found.
// A mapper checking only typeof value === 'number' writes amount 0,
// marks the invoice approvable — then it gets SEALED ON-CHAIN.
// So every key is OMITTED unless the extractor produced it:
export interface ExtractedInvoiceFields {
vendorName?: string; // nfc'd, varchar(255)
invoiceNumber?: string; // nfc'd, varchar(128)
amount?: string; // fixed-4 decimal, numeric(19,4)
currency?: string; // /^[A-Z]{3}$/
lineItems?: NormalizedLineItem[]; // jsonb, sealed as-stored
}// seal.ts — the only tx this worker ever pays for.
const [block, suggestedTip] = await Promise.all([
publicClient.getBlock(),
publicClient.estimateMaxPriorityFeePerGas(),
]);
const fees = computeSealFees({
baseFeePerGas: block.baseFeePerGas,
suggestedPriorityFeePerGas: suggestedTip,
maxPriorityFeeWei: config.maxPriorityFeeWei,
baseFeeMultiplier: config.baseFeeMultiplier,
maxFeeCapWei: config.maxFeeCapWei,
});
if (fees.maxFeePerGas > config.maxFeeCapWei) {
throw new Error('seal: chain is expensive — not submitting, retry later');
}
const txHash = await walletClient.writeContract({
address: SEAL_CONTRACT_ADDRESS, abi: sealRegistryAbi,
functionName: 'sealRecord',
args: [recordHash, RECORD_TYPE_APPROVAL],
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
});[ 06 ] — Outcomes
Results
[ 07 ] — Visuals
Screenshots
[ 08 ] — Where it's going
Status & next steps
- Reconcile schema drift: base SQL defines 7 invoice_status values, the code enum has 12, the SOC 2 spec lists 15 — align before the base schema is ever re-imported.
- Add a per-currency minor-unit exponent table (JPY=0, BHD=3) before any non-2-decimal currency reaches production.
- Backfill pre-006 rows and replace the rule-based anomaly placeholder with the planned Isolation Forest path.
- Public demo environment with seeded sample invoices so the pipeline is explorable without credentials.
[ 09 ] — Keep exploring
More projects
LedgerTurf
Real-time turf booking platform that lets players discover and reserve grounds on a map while owners publish and manage slots with overlapping-time protection.
CaseVault GraphRAG
Privacy-first legal research workspace that ingests case documents, ranks search results by relevance, and provides AI-generated summaries with verifiable citations.