Invoices in. Reconciled numbers out.
Built solo for a finance client. Custom-trained Document Intelligence models read each invoice once, T-SQL ties provider invoices to client invoices and matches deposits and ACH payouts against them, and a Next.js dashboard turns the result into executive, cash, and risk views. The chart was the easy part. The number behind it wasn't.
The numbers leadership wanted were split across PDFs, bank files, and manual review.
The business pays independent providers and bills the client organizations the work was done for, earning a spread plus finance charges. Margin, finance charges, and cash depend on linking four records: a provider's invoice, the client invoice it was billed on, the provider's ACH payout, and the incoming deposit. They arrive separately and connect mostly by names, dates, and amounts.
The goal was one governed model for documents, payments, and exceptions, so nobody has to trust a re-keyed value.
Documents in, SQL-governed analytics out.
Two repos, one system: the function app is the ingestion and OCR engine behind the analytics app.
The semantic layer, not the dashboard chrome.
Matching rules, exception flags, and metric definitions, kept close to the data.
One OCR pass, many downstream guarantees
Every row points to its PDF, OCR JSON, and log. A file hash, canonical name, or invoice number spots a resubmission, so revisions update in place, and re-extraction reruns SQL over the stored JSON instead of paying for OCR again.
Payments are matched, not assumed
Automatic matching is strict: a deposit alert marks an invoice paid only on an exact amount within 30 days, and never over an existing payment date. Looser matchers score evidence (ACH ID, then exact name, then fuzzy name), and a manual workbench handles the rest.
Risk is modeled as an operations signal
Risk views score severity × exposure and flag processing times, OCR confidence, and deposit amounts past 1.5σ, rising to high at 2σ and critical at 3σ. It's anomaly flagging, not fraud detection.
Finance rules live in SQL too. A finance charge is total × rate × days out, with a two-day minimum and nothing for a future-dated payout, and margin counts only fully linked invoices. There's no automated test suite, so a script checks the views for those guards.
The paperwork fought back.
626 commits, one developer, and a lot of first attempts.
Off-the-shelf OCR couldn't make sense of these invoices. Prebuilt invoice and layout models and a key-position approach all fell short, so each format got a custom-trained model, retrained several times, and the old client format kept its own. Hours don't always land in the hours column either, so the SQL finds the labeled values wherever OCR put them.
Linking starts with names, and the original normalizer ate their last letter. Invoices that should have linked didn't. The 2,519-line linker normalizes inline and reads "Last, First" and "First Last". Work also splits across sites, so a client invoice may cover only part of a provider's hours; that's recorded as partial coverage, not forced into a match.
One deposit, two sources, a lot of lookalikes. The bank export fills in the thin record a real-time alert created, instead of adding a twin. Worse, recurring identical amounts matched many invoices, and new deposits matched invoices paid years earlier. The answer: strict date windows, a preview that writes nothing, and a note kept with any replaced payment date.
A spreadsheet destroyed account numbers. ACH account numbers that passed through one came back in scientific notation, and the rounded-away digits are gone for good. No parser fixes that. A repair script flags those rows for re-sourcing from the payment processor, and the importer now keeps every field a string.
Boring serverless, hard-won deploys. In week one the pipeline dropped to one blob at a time with a five-minute timeout, and SDK retries stay off so timeouts can't stack. That tuned build backfilled the full archive. Getting Prisma's engine into the Next.js standalone build on App Service Linux took 33 commits in one day; now CI fails the build if it's missing.
Client names, figures, and screenshots stay under NDA; the architecture and the approach don't. Want to talk it through? Reach out →