Skip to main content
BlogERP

Designing POS Cash-Session Reconciliation in an ERP

How I designed opening float, cash-in/out, expected-vs-counted variance, and an append-only audit trail for a real Indonesian carwash POS.

Matthews Wong

Software Engineer

July 20269 min read
Designing POS Cash-Session Reconciliation in an ERP

Photo by Kecko via Openverse (CC BY 2.0)

Key Takeaway

A POS cash session is a shift-scoped record: a cashier opens with a counted float, every cash sale and paid-out is logged, and at close the system computes expected cash as opening float plus cash-in minus cash-out. The cashier counts the physical drawer, the difference is the variance, and both numbers plus who closed are written to an append-only audit log.

When I built the JID Carwash ERP for an Indonesian carwash SME, the single feature that generated the most owner anxiety was not payroll or inventory. It was the cash drawer. Every shift, a cashier handles a stack of physical banknotes, and at the end of the day the owner wants one honest answer: does the cash in the drawer match what the system says it should be? Getting that answer reliably is the entire job of the cash-session module.

The temptation is to store a single running cash balance and adjust it on every transaction. I learned the hard way that this is the wrong model. A running balance hides history, cannot answer who was on shift when a discrepancy appeared, and turns a concurrency bug into lost money. Instead I modelled the cash session as an explicit, immutable-once-closed record, and I made the cash book a derived view rather than a table anyone writes to.

The real cashier workflow at the carwash

Before writing a single Prisma model I sat at the counter and watched. The carwash runs two shifts a day across a couple of branches. A shift begins when the cashier counts the drawer and records the opening float, usually around Rp 500.000 in small notes for change. Through the day cash comes in from wash orders and leaves for small expenses such as buying detergent or paying a kasbon, an employee cash advance. At shift end the cashier counts the drawer again, hands over to the next person, and the money above the float is deposited.

  • Open: cashier physically counts the drawer and records the opening float for the shift.
  • During: cash-in from paid cash sales, cash-out for petty expenses and kasbon disbursements, each logged as its own movement.
  • Close: cashier counts the drawer again, the system shows expected cash, and the difference becomes the recorded variance.
  • Handover: everything above the float is deposited and the next shift opens a fresh session.

Modelling a cash session, not a balance

The core entity is a CashSession scoped to a branch, an employee, and a status. Money is stored as integer Indonesian Rupiah throughout the codebase, never as a floating-point number, because IDR has no sub-unit in daily use and floats silently corrupt sums. The opening and counted-closing amounts live directly on the session; everything that happens in between is a stream of cash movements that reference the session.

model CashSession {
  id             String        @id @default(cuid())
  branchId       String        // every query scopes by this
  openedById     String
  closedById     String?
  status         SessionStatus @default(OPEN)
  openingFloat   Int           // integer IDR, e.g. 500000
  countedClosing Int?          // what the cashier physically counted
  expectedClosing Int?         // computed at close, snapshotted
  variance       Int?          // countedClosing - expectedClosing
  openedAt       DateTime      @default(now())
  closedAt       DateTime?

  branch    Branch         @relation(fields: [branchId], references: [id])
  movements CashMovement[]

  @@index([branchId, status])
}

enum SessionStatus { OPEN CLOSED SUSPENDED }

model CashMovement {
  id         String       @id @default(cuid())
  sessionId  String
  branchId   String
  type       MovementType // CASH_IN | CASH_OUT
  source     String       // 'order' | 'expense' | 'kasbon'
  refId      String?      // order/expense id for traceability
  amount     Int          // always positive integer IDR
  createdAt  DateTime     @default(now())

  session CashSession @relation(fields: [sessionId], references: [id])
  @@index([sessionId])
}

enum MovementType { CASH_IN CASH_OUT }

Every list and detail query in the API scopes by branchId. An early version let a manager at branch A load a session id from branch B just by guessing the URL, a classic IDOR. Scoping by branchId in the Prisma where-clause on every read closed it. Treat the session id as non-secret and the branch scope as the real boundary.

The cash book is a derived view, never a written table

Owners wanted a running cash book, called the buku kas, showing every entry chronologically. The naive approach is a CashLedgerEntry table that you insert into on every transaction. I deliberately did not build that. Instead CashLedgerEntry is a derived union view assembled at read time from the real source tables: cash movements, settled payments, and expenses. There is no write path to the ledger, so it can never drift from the transactions it summarises.

This mirrors a rule I applied everywhere in the ERP: caches and summaries must have a single source of truth. Product stock quantity is a cache too; the truth is the sum of stock movements. If a derived number and its source disagree, the source wins and the derived value is simply recomputed. That principle removes an entire category of reconciliation bug where a summary silently lies.

Expected vs counted: the variance formula

At close, the service sums the movements for the session and computes expected cash. The formula is the industry-standard one: opening float plus total cash-in minus total cash-out. The cashier enters the physically counted amount, and variance is counted minus expected. A positive variance is an overage, a negative one is a shortage, matching the accounting concept of cash over and short.

// cash-session.service.ts (close flow, simplified)
async closeSession(sessionId: string, countedClosing: number, actor: Actor) {
  const session = await this.prisma.cashSession.findFirstOrThrow({
    where: { id: sessionId, branchId: actor.branchId, status: 'OPEN' },
    include: { movements: true },
  });

  const cashIn  = session.movements
    .filter(m => m.type === 'CASH_IN')
    .reduce((s, m) => s + m.amount, 0);
  const cashOut = session.movements
    .filter(m => m.type === 'CASH_OUT')
    .reduce((s, m) => s + m.amount, 0);

  const expectedClosing = session.openingFloat + cashIn - cashOut;
  const variance = countedClosing - expectedClosing; // + over, - short

  return this.prisma.cashSession.update({
    where: { id: session.id },
    data: {
      status: 'CLOSED',
      closedById: actor.userId,
      countedClosing,
      expectedClosing,   // snapshot, so later edits can't rewrite history
      variance,
      closedAt: new Date(),
    },
  });
}

Note that expected closing is snapshotted onto the row at close time. I do not recompute it later from live movements, because a backdated correction to some order should never silently change what a shift was reconciled against months ago. The snapshot freezes the reconciliation as it stood when the human counted the drawer.

Payment settlement uses idempotency keys. A cashier tapping confirm twice, or a flaky mobile connection retrying, must not create two cash-in movements for one wash. The settlement service dedupes on a client-supplied key so a retry returns the original result instead of double-counting. Idempotency is a core part of a healthy cash flow, not an add-on.

Variance handling and who can close

Not every variance is fraud, and the system must not treat it as such. The carwash target is well under one percent of cash sales; industry guidance puts a well-run drawer under 0.1 percent. I built a two-tier policy: small variances within a configurable threshold close automatically with a logged note, while anything beyond it requires a manager to review and approve the close. A single small shortage is documented without accusation; the module surfaces patterns across shifts, because a repeated small shortage is a stronger signal than one large one.

Variance bandWho can closeWhat is recorded
Within thresholdCashier on shiftAuto-close with variance note
Beyond thresholdManager or owner onlyReason text plus approver id
Repeated patternOwner reviewCross-shift report flag

The audit trail that makes it defensible

Every open, movement, and close writes to an append-only AuditLog. Rows are never updated or deleted, personally identifiable information is redacted on write, and each entry records who did what, when, and against which session and branch. When the owner asks why branch B was short Rp 40.000 on a Tuesday, the answer is a query, not a guess. The audit log is what turns a pile of cash into an accountable process the owner actually trusts.

The payoff of this design is that reconciliation stopped being an argument. The numbers are derived from immutable movements, the variance is frozen at close, permissions decide who can sign off, and the audit log remembers everything. That is the difference between software that tracks cash and software an owner is willing to bet the business on.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is a POS cash session and why not just track a running balance?

A cash session is a shift-scoped record with an opening float, a stream of cash movements, and a counted closing amount. It beats a single running balance because it preserves who was on shift, when each movement happened, and freezes the reconciliation at close. A running balance hides that history and turns any concurrency bug into money you cannot trace.

How do you calculate expected closing cash and variance?

Expected closing equals the opening float plus total cash-in minus total cash-out for the shift. The cashier counts the physical drawer and variance is the counted amount minus the expected amount. A positive variance is an overage and a negative one is a shortage, matching the cash-over-and-short accounting concept.

Why should the cash ledger be a derived view instead of a table?

A written ledger table can silently drift from the transactions it is supposed to summarise, especially under retries or partial failures. Deriving the cash book as a read-time union of the real movement, payment, and expense tables means there is no write path to corrupt. If the derived total ever disagrees with the source, the source wins and the view is recomputed.

Who should be allowed to close a cash session with a variance?

Use a tiered policy. Variances within a configurable threshold can be closed by the cashier on shift with a logged note. Anything beyond the threshold requires a manager or owner to review, enter a reason, and approve, and repeated small shortages should be flagged for owner review across shifts rather than judged one shift at a time.

How do you keep cash reconciliation defensible and auditable?

Write every open, movement, and close to an append-only audit log that is never updated or deleted, with personally identifiable information redacted on write. Snapshot the expected closing amount onto the session at close so later backdated edits cannot rewrite what a shift was reconciled against. Together this makes any discrepancy answerable with a query instead of a guess.