Skip to main content

How a Reconciliation Run Works (Technical)

This page is the technical companion to the Running a Reconciliation guide. It explains what actually happens under the hood when a coordinator clicks Run in the dashboard โ€” and, most importantly, how and when CieTrade data flows into a run.

The live app is the standalone FastAPI + React service at portal-recon.politegrass-38e79dd2.eastus.azurecontainerapps.io.

1. The crux: two independent data flowsโ€‹

The app compares portal submissions (the files coordinators upload) against CieTrade AR (the system of record for what's actually been billed). Those two sides come from two entirely separate pipelines running on different schedules:

PipelineWhat it doesWhen it runs
CieTrade โ†’ PostgreSQLSyncs CieTrade AR, billing-sheet charges, and billing-sheet headers into the cietrade schemaScheduled โ€” a Prefect ETL on separate infrastructure, on its own clock
A reconciliation runReads whatever CieTrade data is already in Postgres, parses uploaded portal files, and compares themOn demand โ€” the moment a coordinator uploads files and clicks Run
Clicking "Run" does NOT refresh CieTrade data

A reconciliation run reads the CieTrade snapshot as of the last Prefect sync โ€” it does not pull anything fresh from CieTrade. If the CieTrade data in Postgres is stale or lagging, a fresh run alone will not fix it. The upstream ETL has to sync first, then you re-run.

   Prefect ETL (scheduled, upstream)          Coordinator (on demand)
โ”‚ โ”‚
โ–ผ โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ CieTrade โ†’ Postgres โ”‚ โ”‚ Portal export files โ”‚
โ”‚ cietrade.* schema โ”‚ โ”‚ (Ariba, Coupa, FM Pilotโ€ฆ) โ”‚
โ”‚ (AR daily, billsheet inc) โ”‚ โ”‚ uploaded via React UI โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ reads the snapshot that โ”‚
โ”‚ already exists at run time โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ–ผ
run_reconciliation()
โ–ผ
Reports + rollup + snapshot
persisted to portal_recon.*

2. The CieTrade ETL (scheduled, upstream)โ€‹

A Prefect ETL (defined in the separate prefect-azure-infrastructure repo, run on an Azure ACI push pool) populates the cietrade schema in the wasteology_dev PostgreSQL database. This is the authoritative billed-invoice source. Three tables matter:

TableContentsSync behaviour
cietrade.accounts_receivableAccounts Receivable โ€” the open-balance ledgerRefreshed daily as a full snapshot. Holds only OPEN receivables (balance > 0). Paid invoices are purged โ€” they simply disappear from the table once settled.
cietrade.billing_sheet_chargesFull POSTED billing history, including already-paid chargesSynced incrementally โ€” can lag AR.
cietrade.billing_sheetsBilling-sheet headers (including posting_date)Synced with the charges.
Why "paid invoices are purged" matters

Because AR drops invoices the instant they're paid, you can't reconstruct a fully-billed group's total from AR alone โ€” a partially-paid group would look short. That's why group totals are summed from POSTED billing_sheet_charges history instead. See Group Number Matching.

3. How a run is triggeredโ€‹

A run can start two ways. Both execute the same reconciliation logic.

Dashboard (the normal path)โ€‹

The React UI's Run action posts to POST /api/v1/upload/run (api/routers/upload.py). The flow:

  1. The coordinator uploads the portal export files (uploader-allowlisted users only).
  2. trigger_run reads all file bytes immediately โ€” before any background work โ€” because the request's temporary file handles aren't safe to hold once the request context tears down.
  3. It schedules run_reconciliation as a FastAPI BackgroundTask and returns 202 {job_id} right away.
  4. The client polls GET /api/v1/jobs/{job_id} for live status and, on completion, the results.

CLI (headless)โ€‹

uv run python reconcile.py --portal-dir <dir> --ar-file <path>

Same reconciliation engine, but the portal files must already be on disk.

Uploaded files are not persisted

Each uploaded file is staged to a temporary path (_staged_upload), parsed, and then deleted. Only the parsed records are stored โ€” never the raw files. There is no "re-run last upload" button: starting a new run requires the source files again.

(The temp path deliberately keeps the original filename, because a few adapters โ€” notably Coupa CSV โ€” derive the customer name from the filename stem.)

4. What run_reconciliation does, in orderโ€‹

This is the pipeline that executes in the background task:

Step 1 โ€” Load CieTrade data live at run timeโ€‹

CieTrade is queried at the moment the run executes, via portal_reconciliation/cietrade.py, wrapped in a per-process 5-minute cache (api/cache.py) so concurrent viewers don't re-hit the DB. Four loaders run:

LoaderReturnsNotes
load_billing_charges()Open AR invoicesSelected directly from accounts_receivable (balance > 0), gated by third-party account + a per-portal service-date window. The billing-sheet LEFT JOIN only enriches service_period โ€” it does not gate which invoices are included.
load_needs_billing_charges()Not-yet-posted revenuebilling_sheet_charges in APPROVED/COMPLETED/OPEN status โ€” billed work that hasn't posted to AR yet.
load_group_ct_totals()Per-group CT totalA hybrid: AR โˆช billsheet-only, so partially-paid groups still total correctly (AR alone would undercount).
load_wo_invoice_map()VAWS WO# โ†’ invoice_noBridges VAWS work-order numbers to CieTrade invoices.

Step 2 โ€” Parse each uploaded portal fileโ€‹

Each file runs through detect_portal โ†’ the matching adapter's .load(), producing the common NormalizedInvoice type. Eight portals are supported: Ariba, Coupa, FM Pilot, Corrigo, Mercado, Oracle, VendorCafe, VAWS.

Step 3 โ€” De-duplicateโ€‹

dedupe_exact_portal_records() removes exact-duplicate portal rows, and Coupa draft rows are dropped โ€” before reconciling and before persistence, so stored rows and group sums stay clean.

Step 4 โ€” Reconcileโ€‹

The engine (portal_reconciliation/reconciler.py + rollup.py) matches records with priority direct invoice # โ†’ Group # โ†’ VAWS WO# and produces:

  • Unbilled WOs โ€” work orders with no matching CieTrade invoice
  • Invoices Needing Action โ€” open AR (and needs-billing) invoices to work
  • Amount Discrepancies โ€” matched invoices whose amounts differ
  • a per-customer rollup and the flagged submissions list

(For the full matching rules, see Matching Logic.)

Step 5 โ€” Persist the runโ€‹

ReconRunStore (storage.py) writes to the portal_recon schema in wasteology_dev:

TableContents
recon_runsRun metadata + aggregate counts, plus snapshot_json โ€” a full, re-renderable JSONB snapshot of the whole run
recon_resultsFlagged items (unbilled / unsubmitted / discrepancy)
recon_portal_invoicesEvery normalized portal record
recon_customer_summaryThe per-customer rollup output

5. Every run is a frozen snapshotโ€‹

Every run writes a point-in-time snapshot with all values baked in. The dashboard renders that stored snapshot โ€” it does not recompute on the fly. Two consequences:

  • Code or data changes only appear after a NEW run. A deploy alone changes nothing on screen.
  • Stale upstream data stays stale until the ETL syncs and you re-run.
The golden rule

Deploy โ†’ run. Any bug fix or CieTrade re-sync must be followed by a fresh reconciliation run before it shows up in the dashboard.

6. Integrations at a glanceโ€‹

IntegrationDirectionMechanismCadence
CieTrade โ†’ cietrade.* (wasteology_dev Postgres)inboundPrefect ETL (ACI push pool)Scheduled โ€” AR daily full snapshot; billsheet incremental
Portal files โ†’ appinboundCoordinator upload via React UI (POST /upload/run)On demand
App โ†’ portal_recon.* (wasteology_dev Postgres)outbound (persist)ReconRunStore.save_runPer run
Authโ€”Microsoft Entra ID (MSAL); uploader allowlist gates who can runโ€”
Hostingโ€”Azure Container App portal-recon (RG wdp-palantiri-rg), image in wdppalantiriacrโ€”

7. Recent changes (2026-08-05)โ€‹

Three coordinator-reported bugs were fixed and deployed:

#Fix
#204Group-invoice variance no longer counts voided/cancelled portal submissions toward the group total.
#205Coupa invoice totals now include the per-line "Total tax" column.
#206Corrigo "Disputed" invoice status now surfaces correctly.
These need a fresh run to appear

Per ยง5, these fixes only show up on a new reconciliation run โ€” existing snapshots are unchanged.


See also: Running a Reconciliation ยท Matching Logic ยท Go-Live Status & Remaining Features