Christian TorresFull stack developer
← All work

Client under NDA · Engineer · Ongoing

Ops Platform

One internal platform for a business that was running on a pile of SaaS tools that didn't talk to each other. Now they do, including two that never wanted to, and one that spends real money.

  • Next.js
  • Python
  • Flask
  • Postgres
  • OCR
  • Gmail Pub/Sub
  • QuickBooks
  1. Project SaaSprivate API, mapped by hand

    No public API, so I mapped the private one. Token auth refreshes itself, and 20 async workers pull jobs, invoices and payments.

  2. Shared inboxvendor invoice PDFs

    A Gmail watch pushes new messages through Pub/Sub into the invoice API.

  3. Payment APIbuilt for its own app

    A payment API that was never meant to be called by anyone but its own app. Hence everything the payments service refuses to do.

  4. Sync serviceclient's machine · tunnel

    Gunicorn binds to localhost and the only way in is a Cloudflare Tunnel. A second scrape while one is running gets a 409. Keyed on the upstream internal ID, never the invoice number.

  5. Invoice APIPub/Sub · OCR · dedup

    OCR, dedup and a review queue, then accounting. Nobody retypes invoices anymore.

  6. Payments serviceoff by default

    Starts with payments switched off. Kill switch, expected-amount check, and idempotency keys so a retry never pays twice. It never handles a password and never automates a captcha.

  7. Postgres12 switches, all live

    Moved off the spreadsheet-database one feature at a time, each behind its own switch. When the quota ran out, rollback took one flip. An unchanged poll went from 168,833 bytes to 108.

  8. Dashboardone screen, not seven tabs

    Jobs, invoices, payments, call intake, equipment and a field portal. Home routes p95 under 0.7 s, down from up to 17 s.

Sheet
1 of 1 · Ops Platform
Client
Client under NDA
Role · period
Engineer · Ongoing
Stack
Next.js · Python · Flask · Postgres
Status
Active

The problem

A growing business running on a pile of separate tools: a project management SaaS, a spreadsheet-database, desktop accounting software, a shared inbox, card payments, bank feeds and a city payment service. Every job meant copying the same numbers between seven tabs and hoping nobody typed a 7 as a 1.

The client's name stays private, so you get the architecture and none of the gossip.

The system

Project SaaS ──webhooks──▶ Sync service (Flask, on the client's own machine)
                             │   reachable only through a Cloudflare Tunnel
Shared inbox ──Pub/Sub───▶ Invoice API ──OCR──▶ dedup ──▶ review queue
                             │
Accounting desktop ◀─connector─┤
Payment service ◀──────────────┤   kill switch, expected-amount check, idempotency keys
                             ▼
                          Postgres  ◀── Dashboard (Next.js on Vercel)
                                         office, field portal, kiosk

Four services, one database, one dashboard.

What I built

  • Dashboard: jobs, clients, invoices, payments, call intake, equipment, materials, work orders, purchasing, and a field portal for the people out on site. One screen instead of seven tabs.
  • Invoice pipeline: vendor invoice PDFs arrive in the shared inbox, a Gmail watch pushes them through Pub/Sub, OCR extracts them, they get deduplicated and land in a review queue, then sync to accounting. Nobody retypes invoices anymore, which is the closest thing to a love letter in back office software.
  • The undocumented API: the core project tool has no public API. It does now, sort of. I mapped its private one and built a sync service with token auth that refreshes itself, 20 async workers, and webhook endpoints behind an API key.
  • Automated payments: a service that pays recurring fees through a payment API that was never meant to be called by anyone but its own app.
  • Getting off the spreadsheet-database: moving every feature to Postgres without stopping the business.

Decisions I'd defend in a review

1. Key the sync on the upstream internal ID, never the human number. A duplicate invoice number showed up and the obvious fix was to dedupe on invoice number. I scanned all 567 records first. Invoice numbers aren't unique upstream: two genuinely different invoices can share one, so keying on it would have merged them and deleted real data. The real cause was that re-issuing an invoice upstream mints a new internal ID and silently deletes the old one. The fix is a reconciliation step that removes rows whose ID no longer exists upstream. The scan found 9 stale rows across 8 invoice numbers, all confirmed dead, and left the legitimate pairs alone.

2. When the sync fought the client, pause it behind a flag, but keep the money right. The client cleaned up job addresses by hand, and the next sync put the wrong ones back, because the upstream tool stores the billing address where the job site should be. I shipped a flag that stops the sync writing jobs, clients, locations and contacts while invoices and payments keep flowing. The catch: payments used to update totals by re-syncing the whole job. With the flag off, they switch to a money-only update of contract value and amount paid. I checked it against the live jobs before turning it on, and all six computed identical values.

3. Migrate one feature at a time, with a switch you can flip back. The move off the spreadsheet-database went feature by feature, each behind its own switch between the old backend and Postgres. All 12 switches are live now. The switches earned their keep the day the database hit its quota (below): rollback took one flip, and nothing was lost to a migration-day panic. Removing the old code follows the same rule: oldest switch first, one PR per feature, and production logs must show zero calls to the old backend before each removal. The last audit covered 10,000 requests across 22 timed routes, with zero old-backend calls.

4. Run the sync on the client's machine, exposed through a tunnel. The sync has to live near the client's systems, so it runs on their own server. Gunicorn binds to localhost only, and the only way in is a Cloudflare Tunnel with TLS. A second manual scrape while one is running gets a 409 instead of a race. When Cloudflare asked every instance to upgrade the tunnel after a certificate issue, the upgrade script verified the checksum, backed up the old binary, validated the config, and armed a two-minute auto-rollback timer. If you're going to touch the only door into a client's server, bring a way back out.

5. Software that spends money has to be boring. The payment service starts with payments switched off. It has a kill switch, checks the expected amount before paying, and uses idempotency keys so a retry never pays twice. It never handles the account password and never automates captchas or MFA. That's a standing rule, even when it would make reconnecting easier. When the session expired, the proposed fixes were a browser extension that hands over cookies after a real login, plus an alert on the first failed call, not a fake login form that holds their password.

Incidents, and what they changed

The database quota. One morning every database query was refused: the free plan's quota was gone. I flipped the three migrated features back to the old backend within about 20 minutes, and they were back on Postgres the same afternoon. The cause was egress, not storage. The home screen polled the jobs list every 60 seconds per open tab, and each poll scanned 90 days of activity log selecting every column: about 1.7 MB a call, or around 2.4 GB a day for one open tab. On the old backend the same scan cost API calls instead of bytes, so nobody had noticed. The fixes: select only the needed columns everywhere, aggregate in SQL instead of shipping rows to JavaScript, and add change stamps so an unchanged poll returns 108 bytes instead of 168,833.

The accounting connector that went quiet. Calls to the desktop accounting software started timing out. The connector's own fields told the story: the last request time kept moving while the last successful request stayed frozen on one date. So the connector was fine, and the desktop app on the client's machine wasn't being polled. I ruled out our side first, then wrote up the fix for the client, plus a health alert for when the last success is more than a day old.

Measured, not guessed

Before optimising the dashboard I measured it. Cold loads were 3 to 8 seconds, and Postgres turned out to be fine, with the slowest common query at 17 ms. The slow part was reads from tables not yet migrated, queued behind the old backend's rate limit. After moving those features over, every home route's p95 is under 0.7 seconds. The contacts lookup went from 10 to 17 seconds down to 0.54, jobs from 4.3 down to 0.66, and invoices from up to 6.5 down to 0.27.