Ir al contenido
Developers

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

EndpointWhat it contributes
/api/v1.0/salesHow much was sold
/api/v1.0/paymentsHow it was collected: cash, card, QR, account
/api/v1.0/cash-movementsWhat 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))
Atención: Extend the window backwards

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_at fell 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

ShopSessionSoldCollectedExpected cashDeclaredDifference
10018842412,500.00412,500.00118,300.00118,300.000.00
10028843289,140.50289,140.5074,020.5073,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

  1. Cutting by calendar day instead of by register session.
  2. Using different windows for the three datasets.
  3. Applying absolute value to negative amounts.
  4. Recomputing sale totals from line items.
  5. Not walking every page. A reconciliation over the first 500 rows always breaks, and the error looks accounting-shaped.