Skip to content
ERPNext Data Model
Esc
navigateopen⌘Jpreview
On this page

Point-of-Sale Transaction & Cash Closing Architecture

A design reference for high-volume register sales, shift-based cash custody, and batch consolidation into standard accounting records

1. Requirements

1.1 Functional requirements

  • Support a checkout flow that can create a sale, take one or more payment lines against it, and settle it fast enough for a queue of walk-in customers — without forcing every sale through the full weight of ordinary invoice posting.
  • Scope every register to a terminal profile: its own price list, currency, item and customer group filters, allowed payment methods, warehouse, and default accounts, all keyed to one legal entity.
  • Require every register to operate inside a bounded shift: a cashier opens with a counted float per payment method, sells against that shift, and closes by counting the drawer again — refusing a second concurrent shift on the same terminal or the same cashier.
  • At shift close, reconcile expected cash (opening float plus everything recorded as collected, net of any change handed back) against what the cashier actually counted, per payment method, and surface the difference.
  • Batch the shift’s individual sales into ordinary, fully-posted invoices (and credit notes for returns) so downstream accounting, tax, and inventory processes see the same document they would from any other sales channel — surviving a retry after partial failure.
  • Support returns raised against a sale not yet consolidated, and against one that already has been, without double-posting or losing the link to the original.
  • Let an operator choose, system-wide, whether the register creates the lightweight deferred-posting sale document or an ordinary invoice directly — both flowing through the same shift-opening/shift-closing lifecycle.
  • Provide an alternate, simpler cash-tally record a cashier can fill in for a plain time-boxed window (independent of the shift/terminal model), summing already-posted invoices’ outstanding amounts against manually entered expenses, custody withdrawals, and returns.

1.2 Non-functional requirements

  • Low per-sale latency: the checkout path must not block on the same heavyweight validation, stock-ledger, and ledger-posting logic that an ordinary invoice runs, since a register may process many sales per minute.
  • Crash-safe consolidation: a batch of sales being merged into standard invoices must not leave the books half-updated if one customer’s batch fails partway through a run of many.
  • Re-entrant retry: a failed consolidation attempt must be safely re-runnable against the same fixed set of sales without creating duplicates.
  • Custody accountability: cash discrepancies at close must be visible per payment method and per shift, not buried in an aggregate.
  • Configuration isolation: a mis-set discount, tax, or account default on one terminal profile must not leak into another terminal or another legal entity.
  • Non-blocking bulk operation: consolidating a very large shift (many sales) must not tie up the cashier’s closing request itself.

1.3 Constraints

  • A cashier is a system user, and one user account can hold at most one open shift across all terminal profiles at a time — the concurrency guard is per-user and per-profile, not per-device.
  • The deferred-posting sale document deliberately skips the parent invoice-controller’s own submit-time hooks (ledger and stock-ledger posting); it only re-uses the shared selling-side field logic. It is a genuine fork in behavior, not a thin subclass.
  • Stock availability at the register is checked against a live, near-real-time reservation calculation rather than a posted stock-ledger balance, because the deferred sale document never touches the stock ledger on its own.
  • The choice between the deferred sale document and an ordinary invoice for the checkout screen is a single global setting, not something a terminal profile can override independently.

2. High-Level Design

2.1 Component diagram

2.2 The two checkout paths converge at shift close

A single system-wide setting decides which document type the checkout screen creates. Both paths still live inside the same shift and both still get swept up by the same closing step, but what happens to each is different:

2.3 Shift-close and consolidation walkthrough

This is a bounded procedure rather than a graph, so it is written as steps:

  1. Cashier opens a shift. A Session Opening Record is created against a terminal profile, carrying one counted opening amount per payment method the profile accepts. Refused if the profile already has an open shift, or this cashier already has one elsewhere.
  2. Sales accumulate, each stamped with the terminal profile and owned by the signed-in cashier.
  3. Cashier begins closing. A Session Closing Record is drafted against the open shift; it queries every sale owned by this cashier, on this profile, inside the shift’s window, not yet folded into an invoice, and sums grand totals, quantities, taxes, and per-payment-method amounts (net of change given) into an expected-cash table seeded from the opening float.
  4. Cashier counts the drawer and enters a closing amount per payment method; a difference is computed per row.
  5. Cashier submits. At ten or more unconsolidated sales, consolidation is pushed to a background job and the record is marked “Queued”; otherwise it runs inline.
  6. Consolidation groups deferred-path sales by customer (or customer group) and matching accounting-dimension values, then splits out any group where a return references a serialized/batched item sold in the same batch, so the original is merged before its return.
  7. Each group becomes one new Standard Invoice (or, for returns, a credit note against the already-consolidated original), running the ordinary full submit lifecycle — stock ledger and ledger postings included.
  8. On success, every source sale is stamped with its resulting invoice, the opening record is linked to the now-closed shift, and the closing record reflects completion. On any failure, the whole attempt is rolled back and marked “Failed,” with the error retained for inspection and retry.

3. Deep Dive

3.1 Data model

Terminal Profile The profile-scoped configuration root for a register: legal entity and currency; the terminal payment methods it accepts (exactly one default, plus a per-mode return-eligibility flag); item-group and customer-group filters; selling price list; a default warehouse (used only when stock is updated at sale time); income, expense, and write-off ledger accounts plus a cost center; a write-off limit that auto-absorbs small rounding differences created during consolidation; a flag to disable rounded totals on consolidated invoices; and the list of users it applies to, one of whom may be that user’s default profile. A profile cannot be disabled while a shift against it is still open.

Terminal Payment Method A join row on a Terminal Profile (or on a sale itself) linking a mode of payment to that profile, with two flags: default tender, and permitted on a return. Every mode of payment referenced this way must resolve to a real default cash or bank ledger account for the owning legal entity, or the profile fails validation.

Terminal Sale The deferred-posting sale document the checkout screen creates on the fast path. It reuses the ordinary invoice’s selling-side field logic (items, taxes, pricing, customer), but its own submit-time behavior does not call the ordinary invoice’s ledger-posting or stock-ledger routines — those are simply skipped for this document type. Its own validation instead checks stock against a live, computed reservation (on-hand balance minus everything already sold on other still-unconsolidated sales for that item and warehouse), preventing overselling across simultaneously active registers without a posted stock-ledger entry per sale. It requires at least one payment line, requires its terminal profile to share the sale’s legal entity, and carries a reference to the invoice that eventually consolidates it. A return against an already-consolidated original must reference the consolidated invoice, not the original document.

Session Opening Record One per shift. Carries the terminal profile, the cashier, a start timestamp, and one opening-amount row per payment method the profile accepts. Validation blocks it if the profile is disabled or already has an open record, or if this cashier already has an open record on any profile. Cancelling it is blocked while any sale from its window is still unconsolidated.

Session Closing Record One per shift-end. Fetches the profile and cashier from the linked opening record, then re-derives the qualifying sales for the window (both checkout paths), the aggregated taxes, and a per-payment-method reconciliation row seeded from the opening float — each row holding an opening amount, a system-computed expected amount, a cashier-entered closing (counted) amount, and the difference. On submit it flips the linked opening record to closed and hands its deferred-path sales to consolidation; direct-path invoices are only tagged with a reference to the closing record, since they were already fully posted.

Consolidation Log The batch record that performs the actual merge. One log is created per customer (or customer group), further split so a return is never merged before the original sale it references. On submit, it maps every field, item line, tax line, and payment line from its member sales onto one freshly created Standard Invoice (or, for a returns-only group, a credit note against the already-consolidated original), summing duplicate tax and payment rows, carrying forward loyalty-point redemption, and clearing discount/pricing-rule fields so consolidation never re-triggers pricing logic. That new invoice is saved and submitted through its own ordinary lifecycle; every member sale is then updated to point at it and its status recalculated. Cancelling a Consolidation Log reverses this — credit notes cancelled before the invoice they may reference, and the consolidated-invoice reference cleared on every member sale.

Ad Hoc Cash Tally A standalone reconciliation record, unconnected to the terminal-profile / shift model above. Given a cashier, a date, and a from/to time window, it sums the outstanding amount of every already-posted standard invoice created by that user in the window, adds manually entered “returns,” subtracts manually entered “custody” (cash pulled for safekeeping), adds a manually entered “expense” figure, and adds whatever payment amounts are entered directly against it, producing one net figure. It has no opening float, no terminal profile, and no linked shift — a lighter, older, independent mechanism for the same underlying question (“does the drawer match the books”), not a component of the shift lifecycle above.

3.2 Consolidation algorithm

Triggered on submitting (or retrying) a Session Closing Record, or run ad hoc against every unconsolidated sale system-wide:

  1. Group by customer. Every qualifying deferred-path sale is bucketed by its customer (or, if configured, effectively by group).
  2. Split by accounting dimension. Within a customer’s bucket, sales are further split by their exact combination of accounting-dimension values (department, project, and similar), since one consolidated invoice cannot mix dimension values.
  3. Split returns from their originals. Within each dimension bucket, if a return references an item sold with a serial or batch identity, and that original sale is in the same bucket and not yet consolidated, the original is peeled into its own group and consolidated first; everything else stays together.
  4. One Consolidation Log per resulting group, each merged (per the data model above) into one new invoice or credit note.
  5. Threshold-based dispatch. At ten or more sales for one closing attempt, the whole run is pushed to a background queue and the closing record is marked “Queued” rather than blocking the request; the queued job is keyed by the closing record’s own identity, so a duplicate submit cannot enqueue it twice. Fewer than ten sales run synchronously.
  6. Whole-run atomicity. The loop that creates and submits every Consolidation Log for one attempt runs in a single transaction — any single group’s failure (a missing mandatory dimension value, a validation error) rolls back the entire attempt, leaving no group half-committed. The closing record is marked “Failed” with the error captured.
  7. Retry re-runs the identical batch, since the closing record already carries its fixed set of qualifying sales rather than re-querying for new ones.
  8. Unconsolidation mirrors consolidation. Cancelling a closing record cancels its Consolidation Logs, which unwind the invoices/credit notes they created, clear the consolidated-invoice reference on every member sale, and flip the linked opening record back to not-closed.

3.3 Cash reconciliation algorithm

  1. Seed from the float. Each payment-method row in the closing reconciliation table starts from the opening amount recorded when the shift began.
  2. Add expected collections. For every qualifying sale in the window (deferred-path and direct-path alike), payment lines are summed per mode of payment / ledger account, added onto the corresponding row’s expected amount.
  3. Net out change given. Because a cash payment line can include tendered cash that exceeds the total, with the excess recorded separately as change owed back to the customer, the total change amount attributable to each sale’s designated change ledger account is subtracted from that account’s expected figure — the expected amount represents cash that should still be in the drawer, not cash that was handed across the counter.
  4. Cashier counts and enters. The closing (counted) amount per row is entered manually; a difference (counted minus expected) is computed per row as soon as it is entered.
  5. No blocking threshold. A nonzero difference does not, on its own, block submission — the mechanism surfaces the discrepancy for review rather than enforcing a tolerance band; any policy about acceptable variance is an operational, not a system, control.

3.4 How postings differ from an ordinary invoice

This is the central divergence in the whole design. An ordinary invoice’s submit-time behavior always runs stock-ledger updates (when stock is tracked) and always produces ledger postings through the posting funnel described in the general-ledger design. The deferred-path sale document is built on the same invoice controller, but its own submit-time method replaces that behavior rather than extending it — no stock-ledger entries and no ledger postings are produced when a Terminal Sale is submitted. What it does instead:

  • Loyalty-point entries, if the customer is enrolled, are created immediately (a separate ledger, not the stock or accounting one).
  • Stock is only checked, never moved: availability is validated against a live sum of on-hand balance minus everything already reserved by other still-unconsolidated sales of the same item and warehouse — a computed, real-time figure, not a posted balance.
  • Serial/batch identity bundles on line items are still submitted (marking the specific units used), even though the stock-ledger quantity movement they drive does not happen until consolidation.
  • The true stock-ledger and ledger postings happen once, when the Consolidation Log’s newly created invoice is submitted, running the full, unmodified posting lifecycle exactly like any other sale.

The direct-path option (an ordinary invoice created straight from the checkout screen) has no such gap: every sale posts immediately and in full. The closing record’s role for that path is purely to tag each invoice with a reference to the shift, for reporting — nothing is re-posted or rewritten. The trade-off is explicit: the deferred path buys checkout speed and avoids ledger contention by deliberately running “behind” the books until the shift closes; the direct path keeps every sale continuously in sync with the books at the cost of paying the full posting effort on every transaction.

3.5 Error handling & recovery

  • Duplicate shifts: blocked at validation time, both per terminal profile and per cashier, before an opening record can even be submitted.
  • Consolidation failure: caught around the whole batch; the transaction is rolled back, the closing record is marked “Failed” with the captured error message, and a manual retry re-runs the identical batch.
  • Scheduler unavailable: if background processing is not running at all, a large batch that would need to be queued is refused outright rather than silently enqueued and never picked up.
  • Cancellation ordering: reversing a consolidation cancels a credit note before the invoice it may reference, avoiding a dangling reference.
  • Blocked cancellation of a sale: an individual sale that has already been folded into an invoice cannot be cancelled directly — the operator is pointed at the closing record that must be cancelled first, so consolidation and its source sales cannot drift out of sync.
  • Blocked cancellation of an opening record: a shift cannot be cancelled while it still has unconsolidated sales sitting against it, preventing an open shift’s activity from silently disappearing.
  • Mid-consolidation configuration drift: if a mandatory accounting-dimension value is introduced on the legal entity after some sales were already recorded, consolidation of a batch missing that value fails loudly (and rolls back) rather than posting an incomplete invoice.

4. Scale and Reliability

  • Load shape: bursty and register-count-driven — checkout traffic peaks during store hours across many simultaneously open shifts, while consolidation is a periodic, cashier-triggered batch operation rather than a constant background load.
  • Why the deferred path exists: skipping ledger and stock-ledger postings on every individual sale keeps the hot path (ringing up one more item, taking one more payment) cheap and free of contention on shared ledger and stock-ledger rows — exactly where many concurrent registers would otherwise collide.
  • Background offload above a fixed threshold: once a shift’s unconsolidated count reaches ten, consolidation moves off the request thread so a large shift close does not tie up the cashier’s session; smaller closes stay synchronous. The threshold is a fixed constant, not configurable per profile.
  • Idempotent job dispatch: the background job for one closing record is keyed by that record’s own identity, so a repeated submit cannot pile up duplicate consolidation runs for the same shift.
  • Live reservation query as a scaling risk: since the deferred path never posts to the stock ledger, availability is answered by summing quantities across every still-unconsolidated sale line for that item and warehouse on every check — a wider scan than a posted running balance as concurrently open sales grow, and a candidate for a maintained counter if volume grows substantially.
  • Serialization of shifts: a terminal profile and a cashier can each carry only one open shift, trading away concurrent sessions sharing a drawer or a cashier — this caps parallelism per register but keeps cash custody unambiguous.
  • Failure visibility: a failed consolidation leaves a clearly flagged record with the captured error rather than a silently stuck queue entry, and retry re-uses the already-computed sale list rather than re-deriving it.
  • Two independent reconciliation surfaces: the shift-scoped mechanism and the standalone ad hoc tally serve overlapping but not identical needs, and neither is aware of the other’s tallies.

5. Trade-off Analysis

Decision Trade-off
Deferred-posting sale document skips ledger and stock-ledger postings entirely on submit Fast, low-contention checkout at high volume, at the cost of a window (until shift close) where the books and the stock ledger do not yet reflect sales that have already legally happened — acceptable for a retail counter, but means any report reading the ledger directly during the shift will miss unconsolidated sales.
Stock availability answered by a live reservation query instead of a posted balance Avoids needing a stock-ledger entry per sale, but the query widens as concurrently open, unconsolidated sales accumulate — cheap at typical shift sizes, a scaling concern at very high concurrent register counts.
Consolidation batches by customer, dimension, and split-for-returns, one new invoice per group Produces clean, ordinary invoices downstream (nothing special-cased later in accounting or tax processing), at the cost of the invoice a customer’s return references not being the sale document they were actually handed at the counter — it is a newly created, merged document.
Whole consolidation run wrapped in one transaction, all-or-nothing Guarantees no half-merged shift, but means one bad group (say, a missing accounting-dimension value on a single customer) blocks every other group in that same run until fixed and retried.
Background dispatch only above a fixed count threshold Keeps small, common closes fast and synchronous, but the threshold is a blunt, non-configurable cutoff — a store that always closes with nine sales never benefits from the queue, and a store at exactly ten always pays the background round-trip.
A single global setting picks the sale document type for every register Simple to reason about system-wide, but a business running both a very high-volume counter and a low-volume one cannot choose the lighter path for one and the heavier, always-posted path for the other.
Two unrelated cash-reconciliation mechanisms (shift-scoped closing vs. the ad hoc tally) coexist Gives smaller operations a lightweight option without adopting the full terminal-profile/shift model, but the two never cross-check each other, so nothing prevents both being used inconsistently at the same site.

6. What to Revisit as the System Grows

  • Per-register or per-profile choice of sale document type: replacing the single global toggle with a profile-level setting would let a business run the deferred path where volume justifies it and the always-posted path elsewhere, without an all-or-nothing switch.
  • A maintained stock-reservation counter: if concurrent, unconsolidated sale volume grows large enough that the live reservation query becomes a hot spot, replacing it with an incrementally maintained counter (updated on sale submit and on consolidation) would keep availability checks cheap without giving up the deferred-posting benefit.
  • Partial-batch resilience in consolidation: today one failing group rolls back the entire attempt; a design that could commit the groups that succeeded and report only the failing ones would shrink the blast radius of a single bad customer record or a single missing configuration value.
  • Configurable background threshold: making the ten-sale cutoff a per-profile setting (or driven by measured request latency rather than a fixed count) would let very high-volume stores tune when consolidation moves off the request thread.
  • Reconciling the two cash-tally mechanisms: unifying the shift-scoped closing reconciliation with the standalone ad hoc tally — or at least cross-referencing them — would remove the current gap where a site could use both without either being aware of the other.
  • Tolerance-aware cash reconciliation: today a nonzero difference between expected and counted cash is only surfaced, never enforced; a configurable variance threshold with required sign-off above it would formalize what is currently an unmanaged manual review step.

Was this page helpful?