SAP Financial Operations Dashboard
Automated reconciliation, a duplicate-posting defense, and permissioned access
OBJECTIVE
SAP Business One holds the financial source of truth — invoices, credit notes, down payments — across every branch. Two problems sat on top of it: branch-to-branch balances had to be manually reconciled and cleared every cycle, and a subtle race condition in the posting pipeline was silently creating duplicate financial documents. This system automates the reconciliation math, posts the resulting SAP documents automatically, and closes the duplicate-posting hole for good — with fine-grained, role- and location-scoped access on top.
Why this is hard: this system writes real money into a financial system of record — there is no undo for a wrongly-posted SAP invoice, and the actual bug wasn't a logic error, it was a timing assumption (that an HTTP timeout means the write didn't happen) that was wrong just often enough to quietly duplicate financial documents in production before anyone noticed.
THE RECONCILIATION ENGINE
Each branch ("centre") can carry outstanding balances against a shared business partner across multiple other centres. The engine computes, per partner, per cycle:
- —M_own — the main branch's own invoices minus its credit notes
- —M_clear — net clearance across every other branch for the same partner, each first zeroed out via a generated payment document
- —D — any outstanding down payment still open on the main branch
A strategy selector reads the sign combination of (M_own, M_clear) — nine possible categories in a 3×3 matrix — and each category has its own SAP document-generation recipe (invoice, credit note, down payment, or a combination). Matching runs on computed totals, not SAP's stored DocTotal, because SAP's stored total can drift from the computed one by a small rounding difference — matching on it could hide a genuine duplicate. A fixed tolerance absorbs paisa-level rounding without false mismatches.
FETCH SAP DATA → CALCULATE → SELECT STRATEGY → GENERATE DOCS → SUBMIT, run as parallel job batches, one per partner — see Diagram 1 in §05 for how this fits into the wider system.
This module was reviewed after an early AI-assisted implementation pass came in noticeably over-engineered — a deliberate follow-up cut it by roughly a third without changing behavior, after catching a few real defects along the way (a lock call sitting outside its transaction, so it wasn't actually locking anything; a failure handler that re-created retry records on every attempt instead of once).
THE DUPLICATE-POSTING INCIDENT
The standout problem, and the reason this system exists in its current form: SAP's Service Layer can commit a write several minutes after the HTTP request that sent it has already timed out. A single invoice submission was observed committing on SAP's side long after the client had given up waiting — and every retry layered on top of that timeout was racing a write it couldn't see yet.
A production audit found a real, non-trivial volume of duplicate documents traced to two distinct causes: an automatic-retry race on submission, and re-uploaded import batches with no cross-batch duplicate check. Three independent retry layers were each individually reasonable and collectively the problem: an HTTP client's built-in auto-retry, the job queue's own retry policy, and a duplicate-check that failed open — if the check itself errored, it let the post through rather than blocking it.
if (! empty($items)) {
throw new InvoiceAlreadyPostedException(...);
}
The fix was a set of invariants applied everywhere SAP gets written to, not a single patch. Click one to expand it.
Every SAP-writing job runs at most once; resubmission is a deliberate, human-triggered action, never a silent background retry.
A structured reference tag is written into SAP's own free-text field on the document, since the SAP team declined a custom field for it — every document carries proof of which local record created it.
Every document-creating request is checked against SAP by business key first, and the connector refuses to let any of these requests silently auto-retry in-process.
Every SAP job has a failure handler that checks whether the outcome is actually known — if a submission was attempted but never resolved to success or failure, that's surfaced as its own alertable state instead of silently disappearing.
Synced, errored, or outcome-unknown — replacing a binary success/fail that had no honest way to represent "SAP might have accepted this, we can't tell yet."
Re-uploaded spreadsheets are checked against already-synced local records before anything is queued for submission.
A prior retention job deleted the only local record of what had been told to SAP after 30 days — financial audit trail data is now archived, never deleted.
Design philosophy stated directly in the engineering rules this module now follows: no duplicates > loud failure > convenience — a silently self-healing system that occasionally posts wrong data is worse than one that visibly stops and asks for help.
PERMISSIONS & ACCESS CONTROL
Role-based access via Spatie's permissions package, layered with a second, independent scoping dimension for branch/location access — the two don't collapse into one system:
- —Roles map to real business functions — e.g. sap-manager, centre-accountant, business-data-admin, e-invoicing-manager — not generic admin/user tiers.
- —Centre access is a separate pivot, not a permission string — a user either holds a blanket all-centres permission, or is explicitly linked to specific branches — location access and action permissions are deliberately orthogonal.
- —Every SAP submission runs under the submitting user's own SAP credentials — not a shared service account, so every document posted to SAP is attributable to a real person by design — there's no anonymous company-level fallback.
- —An organizational gate sits above the fine-grained roles — certain resources are restricted to company email holders regardless of assigned permissions.
SYSTEM ARCHITECTURE
Unlike the read-only analytics platform, which connects directly to SAP HANA, this module writes transactional documents back into SAP — so it goes through SAP Business One's Service Layer REST API instead, which runs SAP's own business logic, document-series numbering, and approval workflow on every post. A raw SQL insert against HANA can't do any of that. Two diagrams: how the system is organized, and what happens to one SAP write once it leaves the connector.
DIAGRAM 1 — RUNTIME ARCHITECTURE
Boxes are components, lines are who calls whom. Hover a box.
DIAGRAM 2 — SAP WRITE OUTCOME MODEL
The three-state outcome model (§03) as a state machine — the whole point being that a write is never allowed to just silently disappear:
RESULT
Branch-to-branch reconciliation that used to be manual per-cycle work now runs as an automated batch pipeline. The duplicate documents that had been quietly accumulating in SAP were traced to root cause and stopped — not by adding a lock, but by removing every layer of automatic retry and replacing silent ambiguity with a visible, alertable third outcome state. Access is role-scoped by business function and independently scoped by branch, with every SAP write attributable to a real person.
BY THE NUMBERS
STACK
Laravel 12 · Nova 5 · Horizon · Sanctum · Saloon (SAP Service Layer client) · Spatie Permissions · MySQL · MongoDB (audit log)
LESSONS LEARNED
- •The duplicate-posting incident (§03) happened because three independently reasonable retry layers were never reviewed together — I'd audit every layer that can retry a write, as a single cross-cutting concern, before adding any one of them in isolation next time.
- •The reconciliation engine (§02) needed a follow-up pass to undo AI-assisted over-engineering — I'd budget that simplification review as a standard step after any AI-generated implementation, not an afterthought triggered by a code review catching it.
- •The three-state outcome model (§03) should have been the default from the start for any external write — "succeeded or failed" is a convenient lie whenever the other system's commit isn't synchronous with its response.