Problem
The CRM knew what happened. The books didn't.
The CRM behind roughly a crore of monthly bookings records everything that matters commercially — the trip, the customer, what they paid, what was refunded, what the vendor was owed. None of that is accounting. The books were being assembled by hand from the same events, which meant two systems drifting apart continuously, and a quarter-end where nobody could say with confidence which one was right.
The drift had a specific shape. Receipts posted against a generic control ledger instead of the trip. Refunds netted inside a sale so the revenue figure was gross when the money was not. Payments voided in the CRM by appending -deleted to the reference while the status stayed captured, so a naive query counted them twice. And a prior-year opening balance sitting in suspense because nobody could attribute it to a trip.
Reconciliation is not a report you generate. It is a claim you have to be able to defend, line by line, months later.
Architecture
Anchor every rupee to a trip, then never let it move
The design decision the whole system rests on: the customer ledger is per-trip, keyed on the trip identifier. Not per-customer, not per-invoice. A customer who books three trips has three ledgers, and no matching logic is ever allowed to collapse them — an earlier fuzzy-matching approach did exactly that and leaked a sale across trips, which is why the anchor is now enforced rather than inferred.
The CRM is read over its own connection as a strictly read-only role, on a separate host, with host-based access pinned to a single address. Nothing in the pipeline can write to the source of truth. An adapter compiles CRM events into vouchers and hands them to the accounting core's single posting service, which enforces idempotency on the reference — so the same CRM event replayed a hundred times posts once.
Client-specific logic lives entirely in the adapter and in a link table keyed by CRM identifiers. The accounting core has no idea TourTripX exists, which is what allows the same core to serve UnlistedZone without either client's rules contaminating the other.
AI components
Classification where it helps, exact matching where it must
- Bank narration classification — incoming statement lines are classified into a posting type by the tiered pipeline: deterministic rules, then learned patterns, then a model only on genuine ambiguity. 116 learned patterns have accumulated on this tenant, each one a transaction shape that no longer needs a model at all.
- Statement format detection — the institution and layout are identified before parsing, across seven bank-specific adapters and a universal parser, including password-protected credit card PDFs.
- Transaction classification for reconciliation — a single shared classifier decides whether a line is a customer receipt, a vendor payment, a refund or a fee, so refund netting is computed against the customer's net position rather than a sale's debit leg.
- Deliberately not AI — the actual bank-to-CRM match is exact. A statement line routes on trip or vendor reference, and matches on UTR plus amount only. Amount plus date was rejected outright: it produces plausible matches, and a plausible match in a reconciliation is worse than no match.
The match result is a three-way outcome, not a score. One match posts and links. Multiple matches return a 409 and stop. Zero matches return a 422 and stop. There is no confidence threshold anywhere in the money path — the model's job ends at suggesting a category, and a human confirms before anything posts.
What it runs today
A live ledger, closed quarter by quarter
The platform carries 6,139 vouchers across 15,451 journal legs in the current financial year to date, against 1,078 ledgers, with 424 invoices issued and 4,653 bank statement rows ingested and classified. Reconciliation runs continuously against the CRM; the bank reconciliation hub routes trip and vendor lines straight to the confirm-and-post endpoint.
Closing a quarter is an operational procedure, not a button. Cross-financial-year receipts that had been sitting in suspense were posted as 162 dedicated opening journals in a single audited batch, cutting the suspense balance by roughly 46% and clearing 269 trips for locking. Each opening is a dedicated per-trip journal rather than a share of one lump — a shared journal makes the whole amount appear against every trip that touches it.
Two audited cleanups removed 105 duplicate and phantom vouchers that predated the pipeline. Each ran as unlock, delete, re-lock in one atomic operation with a full JSON snapshot written before anything was touched, and each item was approved individually rather than swept.
162Opening journals posted
~46%Suspense reduction
105Bad vouchers removed
116Learned patterns
Outcome
A quarter that closes and stays closed
For the April–June quarter, every one of the 313 trips carrying a sale voucher matched the CRM exactly. Zero disagreements. All 313 are locked, which means the books for that quarter cannot be silently amended — an edit requires an explicit unlock that is itself audited.
What that number really represents is that two systems which used to drift now agree by construction. The CRM remains the commercial source of truth, the ledger is derived from it through one idempotent path, and any divergence surfaces as a specific trip with a specific difference rather than as a trial balance that won't tie.
The remaining open items are known and named rather than unknown: a small number of un-audited trips awaiting the operator's own review. That is the difference the system made — the unreconciled set went from "some of it, somewhere" to a list you can count.