Accounting reconciliation
Cross sales, payments and cash movements to close the day and locate the difference
Updated on 2026-08-16For: Developers · Non-technical roles
Reconciling answers one question: do what was sold, what was collected and what is in the drawer agree? And when they do not, which shop, which register session and by how much.
This recipe uses the three cursor endpoints together, which is exactly what they are for.
The three legs
| Endpoint | What it contributes |
|---|---|
/api/v1.0/sales | How much was sold |
/api/v1.0/payments | How it was collected: cash, card, QR, account |
/api/v1.0/cash-movements | What happened to the cash: float, deposits, withdrawals, close |
The identity that must hold, per register session:
sales in session = sum of payments in session
expected cash = opening float
+ cash payments
+ deposits
- withdrawals
difference = declared cash at close - expected cash
Step 1. Pull all three for the same period
Use the same window for all three, or the reconciliation cannot close by construction.
sales = list(fetch_all(session, host, token, "sales", shops, window_from, window_to))
payments = list(fetch_all(session, host, token, "payments", shops, window_from, window_to))
movements = list(fetch_all(session, host, token, "cash-movements", shops, window_from, window_to))
A sale from last night's close can be collected or corrected this morning, and all
three queries filter on updated_at. Start a few hours before the business day
and filter by session afterwards.
Step 2. Group by register session, not calendar day
This is the step failed reconciliations skip. A shop closing at 3 AM has Saturday sales dated Sunday; to the business they belong to Saturday's session.
from collections import defaultdict
def build_cashbox_windows(movements):
windows = defaultdict(list)
opened = {}
for movement in sorted(movements, key=lambda m: m["occurred_at"]):
shop = movement["shop_code"]
if movement["movement_type"] == "OPEN":
opened[shop] = movement
elif movement["movement_type"] == "CLOSE" and shop in opened:
windows[shop].append({
"cashbox_id": movement["cashbox_id"],
"from": opened.pop(shop)["occurred_at"],
"to": movement["occurred_at"],
})
return windows
A session still open is not reconciled: it waits for the next run.
Step 3. Sales against payments
paid = defaultdict(Decimal)
for payment in payments:
paid[payment["sale_uuid"]] += payment["amount"]
mismatches = [
{
"uuid": sale["uuid"],
"shop_code": sale["shop_code"],
"sold": sale["total"],
"collected": paid.get(sale["uuid"], Decimal("0")),
}
for sale in sales
if sale["total"] != paid.get(sale["uuid"], Decimal("0"))
]
What shows up here:
- Sale without payment. Usually an account sale or a void whose payment was reversed.
- Payment without sale. Usually a sale whose
updated_atfell outside the window. - One or two cents. Rounding, not fraud. See
Step 4. Reconcile the cash
Withdrawals arrive negative. Subtracting them again doubles the shortfall and invents a difference that does not exist.
expected = opening_float + cash_payments + deposits + withdrawals # withdrawals are negative
Step 5. Report where it does not close
| Shop | Session | Sold | Collected | Expected cash | Declared | Difference |
|---|---|---|---|---|---|---|
| 1001 | 8842 | 412,500.00 | 412,500.00 | 118,300.00 | 118,300.00 | 0.00 |
| 1002 | 8843 | 289,140.50 | 289,140.50 | 74,020.50 | 73,520.50 | −500.00 |
That round −500.00 is the signature of an unrecorded withdrawal. Reconciliation does not solve it; it makes it visible, which is its job.
Mistakes that create fake differences
- Cutting by calendar day instead of by register session.
- Using different windows for the three datasets.
- Applying absolute value to negative amounts.
- Recomputing sale totals from line items.
- Not walking every page. A reconciliation over the first 500 rows always breaks, and the error looks accounting-shaped.