Retail POS ·

POS database design: schema, SQL and ER diagram

A sound POS database design (point of sale database design, in full) rests on four tables and one rule. sales holds one header per receipt, sale_lines holds what was sold at the price and tax rate that applied at that moment, tenders holds one row per payment, and register_shifts holds each till's trading day from opening float to counted close. A completed sale is never edited. A void, a refund and a return are each a new document, with its own receipt number, pointing back at the sale it reverses. Stock and cash are derived from those documents, never typed in.

The POS database schema at a glance

TableKey columnsPurpose
stores + registerscode; store_id, last_receipt_noWhere sales happen. Each register owns its receipt sequence
staffname, roleEveryone who rings, approves or closes anything
productssku (unique), price_cents, tax_rate_bp, is_activeThe catalogue: today's price, not the price a sale used
discount_reasonscode, max_pct_without_approvalThe closed list of reasons a price can be reduced
approvalsaction, requested_by, approved_by, reasonThe second signature on money-moving actions
register_shiftsregister_id, opening_float_cents, counted_cash_cents, expected_cash_centsOne till's trading day
cash_movementsshift_id, kind, signed amount_cents, reasonCash in or out of the drawer without a sale
salesclient_txn_id (unique), register_id, shift_id, receipt_no, kind, original_sale_idOne document per receipt, returns and voids included
sale_linesproduct_id, signed qty, unit_price_cents, discount_cents, tax_cents, original_line_idWhat was sold, frozen at the moment of sale
tenderssale_id, method, amount_cents, cash_received_cents, provider_refHow it was paid. A split payment is two rows
stock_movementsproduct_id, store_id, signed qty, reason, source_eventThe inventory ledger. Every sale line writes one row
sale_totals, shift_cash_summary (views)per sale; per shiftTotals and the X/Z-report, derived

Customers, loyalty and gift-card balances hang off sales and tenders in the obvious places and are left out, because they change none of the decisions below.

Download the sample POS database

Download the PostgreSQL schema and sample day of trading (.sql) · Download the ER diagram (.svg)

Run it once in an empty practice database with PostgreSQL 16 or later:

psql -v ON_ERROR_STOP=1 -d your_practice_database -f pos-example.sql

It creates a pos_example schema with every table above, the triggers that enforce the rules below, and one fictional shift: a cash sale, a split card-and-cash sale with an approved discount, a price change, a paid-out, a void and a partial return. It replays a sale to show the idempotency key, attempts five writes the schema must refuse (each printed as rejected as expected), and closes the shift. The final query is the Z-report line: $100.00 float, $24.60 net cash sales, $15.00 paid out, $109.60 expected, $109.40 counted, variance −$0.20. A second run stops at the existing schema.

ER diagram of the POS database: stores have registers, registers have shifts and sales, shifts have cash movements and sales, each sale has lines and tenders, returns and voids point back at the original sale and lines, sale lines reference products and write stock movements, staff and approvals are referenced by the documents, and a view derives expected cash per shift from tenders and cash movements.

The file is the core till, not a product. Production still needs authentication, per-store authorization at the API boundary, and an application role that is not the schema owner: triggers stop ordinary updates and deletes, but the owner can drop them.

How to design a POS system database, step by step

Database design for a POS system starts from the questions the till has to answer, not from a list of tables.

  1. List the questions. What did this receipt contain, at what price, paid how? How much cash should be in drawer 2 now? Which refunds did this cashier give, and who approved them? Each is a query over documents, never a number someone maintains.
  2. Separate things from events. Stores, registers, staff and products are master tables you may edit. Shifts, sales, returns, voids and paid-outs are events: rows you never edit.
  3. Make the sale the unit of record. Header, lines and tenders, written in one transaction when the customer pays. A basket is not a sale until it is tendered, so there is no pending sale.
  4. Snapshot what a later change could alter. Unit price, tax rate, tax and discount live on the line, so tomorrow's price cannot rewrite yesterday's receipt.
  5. Make every correction a new document with negative quantities, a link to the original and its own receipt number.
  6. Enforce the money rules in the database. Closed sets as check constraints, tenders equal to the total at commit, no self-approval, no skipped receipt numbers. The constraints section lists them.
  7. Test with a real day. Sell, discount, split, void, return, pay out, count and close, then check the expected cash against a hand calculation, as the download does.

Why a sale is a document, not a row you edit

The POS database model in most tutorials, and the point of sale database schema a coding agent writes if you say nothing more, is an orders table with a total and a status, an order_items table with a product_id and a quantity, and a payment_method column. It is wrong in three places.

The status column is the first. If a void is update orders set status = 'voided', the sale and its cancellation are one row, and the evidence is whatever the last person to touch it left behind. The research behind our retail POS planner names the result: one person can ring a sale, pocket the cash, void it under their own name and leave a drawer that balances.

The single payment_method is the second. Twenty dollars on a card and the rest in cash cannot be stored, so cash_amount and card_amount appear, and then gift cards arrive. The third is that order_items reads its price from the product, so a price change rewrites every historical receipt.

A completed sale is never updated. Header, lines and tenders are written once. Corrections are new documents that reference the original, and stock and cash are derived from the documents.

That matches what the market ships. Our retail POS research audited a fixed cohort of 60 point-of-sale products from 127 inspected source pages. Checkout appears in all 60, tender handling in 36, returns and refunds in 29, and register and cash close in only 18. That last figure undersells it: marketing pages rarely mention closing the drawer, while the workflow documentation that does describe it treats it as a complete trading-day lifecycle.

Every snippet below is PostgreSQL. The design transfers to other databases; the trigger syntax does not.

Stores, registers, staff and products

create table stores (
  id    bigint generated always as identity primary key,
  code  text not null unique,
  name  text not null
);

create table registers (
  id               bigint generated always as identity primary key,
  store_id         bigint  not null references stores(id),
  code             text    not null,
  last_receipt_no  integer not null default 0 check (last_receipt_no >= 0),
  unique (store_id, code)
);

create table staff (
  id    bigint generated always as identity primary key,
  name  text not null,
  role  text not null check (role in ('cashier','manager','owner'))
);

create table products (
  id           bigint generated always as identity primary key,
  sku          text    not null unique,
  name         text    not null,
  price_cents  integer not null check (price_cents >= 0),
  tax_rate_bp  integer not null default 0 check (tax_rate_bp between 0 and 10000),
  is_active    boolean not null default true
);

create table discount_reasons (
  code                     text    primary key,
  label                    text    not null,
  max_pct_without_approval integer not null default 0
                           check (max_pct_without_approval between 0 and 100)
);

These are the only tables you may freely update. Money is integer cents, never float; tax rates are basis points (800 = 8.00%). A product that stops selling is made inactive, not deleted, because old lines still point at it. Add registers even with one till: numbering, shifts and cash are all per register, and a second till at Christmas should not need a migration.

Approvals: one table for the second signature

create table approvals (
  id            bigint generated always as identity primary key,
  action        text   not null check (action in ('discount','void','return','paid_out','variance')),
  requested_by  bigint not null references staff(id),
  approved_by   bigint not null references staff(id),
  reason        text   not null,
  created_at    timestamptz not null default now(),
  check (approved_by <> requested_by)
);

Discounts over a limit, voids, paid-outs and large variances need someone other than the person acting to agree. With one table, approved_by <> requested_by is written once, as a check, and every one of those documents points here. The download adds a trigger so an approval cannot be borrowed from another cashier's sale. No schema fixes a manager password the cashiers all know; a manager PIN at the till is the screen half of this table. Staff permissions and approvals appear in 16 of the 60 products, and the research puts them inside the cashier's workflow rather than in an admin console.

Register shifts and the opening float

create table register_shifts (
  id                   bigint generated always as identity primary key,
  register_id          bigint  not null references registers(id),
  opened_by            bigint  not null references staff(id),
  opening_float_cents  integer not null check (opening_float_cents >= 0),
  opened_at            timestamptz not null default now(),
  closed_at            timestamptz,
  closed_by            bigint  references staff(id),
  counted_cash_cents   integer check (counted_cash_cents >= 0),
  expected_cash_cents  integer,
  close_approval_id    bigint  references approvals(id),
  check ((closed_at is null) = (closed_by is null)
     and (closed_at is null) = (counted_cash_cents is null)),
  check (closed_at is null
         or abs(counted_cash_cents - expected_cash_cents) <= 500
         or close_approval_id is not null)
);

create unique index one_open_shift_per_register
  on register_shifts (register_id) where closed_at is null;

create table cash_movements (
  id            bigint generated always as identity primary key,
  shift_id      bigint  not null references register_shifts(id),
  kind          text    not null check (kind in ('paid_in','paid_out','drop')),
  amount_cents  integer not null check (amount_cents <> 0),
  reason        text    not null,
  actor_id      bigint  not null references staff(id),
  approval_id   bigint  references approvals(id),
  occurred_at   timestamptz not null default now(),
  check ((kind = 'paid_in') = (amount_cents > 0))
);

A shift is the unit of cash accountability: one register, one opening float, one counted close. The partial unique index allows one open shift per register, so two people cannot trade on one drawer under two shift records. The checks encode the close: the close columns are all empty or all filled, and a variance over $5.00 needs an approval (a store setting in real life, a literal here).

Cash that moves without a sale is a cash_movements row with a reason and an actor: a float top-up, a delivery paid from the till, a drop to the safe. Without it, a $15 paid-out for window cleaner shows up at close as a $15 shortage, and the cashier takes the blame.

The sale header

create table sales (
  id                bigint  generated always as identity primary key,
  client_txn_id     uuid    not null unique,
  register_id       bigint  not null references registers(id),
  shift_id          bigint  not null references register_shifts(id),
  receipt_no        integer not null check (receipt_no > 0),
  kind              text    not null check (kind in ('sale','return','void')),
  original_sale_id  bigint  references sales(id),
  cashier_id        bigint  not null references staff(id),
  approval_id       bigint  references approvals(id),
  occurred_at       timestamptz not null,
  recorded_at       timestamptz not null default now(),
  unique (register_id, receipt_no),
  check ((kind = 'sale') = (original_sale_id is null)),
  check (kind <> 'void' or approval_id is not null)
);

create unique index one_void_per_sale on sales (original_sale_id) where kind = 'void';

There is no status and no total. kind replaces status, and a document never changes kind; whether a sale was voided is a question (does a void point at it?), not a column. The total is a sum over the lines, exposed by the sale_totals view, because a stored total is a second copy that can disagree with the first. The checks say only a sale lacks an original and every void has an approval; the partial index lets a sale be voided once.

occurred_at is the register's clock when the customer paid; recorded_at is when the server got it. After an offline afternoon they are hours apart, and trading-day reports must use occurred_at.

Sale lines: snapshot the price and the tax

create table sale_lines (
  id                    bigint  generated always as identity primary key,
  sale_id               bigint  not null references sales(id),
  line_no               integer not null check (line_no > 0),
  product_id            bigint  not null references products(id),
  qty                   integer not null check (qty <> 0),
  unit_price_cents      integer not null check (unit_price_cents >= 0),
  discount_cents        integer not null default 0,
  discount_reason_code  text    references discount_reasons(code),
  discount_approval_id  bigint  references approvals(id),
  tax_rate_bp           integer not null check (tax_rate_bp between 0 and 10000),
  tax_cents             integer not null,
  original_line_id      bigint  references sale_lines(id),
  restock               boolean not null default true,
  line_total_cents      bigint  generated always as
                        (qty::bigint * unit_price_cents - discount_cents + tax_cents) stored,
  unique (sale_id, line_no),
  check (discount_cents::bigint * qty >= 0),
  check (abs(discount_cents::bigint) <= abs(qty::bigint) * unit_price_cents),
  check (discount_cents = 0 or discount_reason_code is not null),
  check (tax_cents::bigint * qty >= 0),
  check (restock or qty < 0)
);

The line is a photograph of the sale. Price and tax rate are copied from the product at scan time, and tax_cents is the tax actually printed. In the sample, the mug goes from $12.00 to $14.00 after the first sale, and the afternoon return still refunds $12.00 plus $0.96 tax, because that is what was paid.

Store the tax you calculated, not just the rate: rounding per line or per receipt depends on where you trade, and recomputing later produces reprints that are a cent out. The example is tax-exclusive, as US sales tax usually is; a VAT-inclusive shop stores gross prices and extracts the tax, with the same table.

qty is signed: positive on sales, negative on returns and voids, which point at the line they reverse. Every money column carries the quantity's sign, so receipts, refunds and net sales are one sum().

Discounts carry a reason

A discount is an amount, a reason code and, above that reason's limit, an approval. The check refuses a discount without a reason; the download's trigger refuses a "damaged packaging" discount (limit 0%) without an approval, while a 5% "regular customer" discount needs none. Spread a basket-level discount across the lines at completion, each share carrying the reason. Then a partial return refunds what that item actually cost the customer, not its shelf price.

Tenders: split payments are just rows

create table tenders (
  id                   bigint  generated always as identity primary key,
  sale_id              bigint  not null references sales(id),
  method               text    not null check (method in ('cash','card','gift_card','store_credit')),
  amount_cents         integer not null check (amount_cents <> 0),
  cash_received_cents  integer,
  change_cents         integer generated always as (cash_received_cents - amount_cents) stored,
  provider_ref         text    unique,
  check (method = 'cash' or cash_received_cents is null),
  check (cash_received_cents is null or cash_received_cents >= amount_cents),
  check (method <> 'card' or provider_ref is not null)
);

A split payment is two tender rows. amount_cents is what the tender contributed, so a sale's tenders sum to its total; for cash, cash_received_cents is the note handed over and change is derived, so the drawer gains amount_cents, not the note. A card tender carries the provider's unique reference, so card takings reconcile against the processor's settlement line by line.

The rule that makes the table trustworthy, that a sale balances, spans three tables whose rows arrive one statement at a time. So it is a constraint trigger, deferred to commit:

create function sale_balances() returns trigger language plpgsql as $$
declare sid bigint; lines_total bigint; n_lines int; paid bigint;
begin
  if tg_table_name = 'sales' then sid := new.id; else sid := new.sale_id; end if;
  select coalesce(sum(line_total_cents), 0), count(*) into lines_total, n_lines
    from sale_lines where sale_id = sid;
  select coalesce(sum(amount_cents), 0) into paid from tenders where sale_id = sid;
  if n_lines = 0 or lines_total <> paid then
    raise exception 'sale % does not balance: lines % (% rows), tenders %', sid, lines_total, n_lines, paid;
  end if;
  return null;
end $$;

create constraint trigger sales_balance after insert on sales
  deferrable initially deferred for each row execute function sale_balances();
create constraint trigger sale_lines_balance after insert on sale_lines
  deferrable initially deferred for each row execute function sale_balances();
create constraint trigger tenders_balance after insert on tenders
  deferrable initially deferred for each row execute function sale_balances();

At COMMIT, every touched sale must have a line and tenders equal to its total, or the whole sale rolls back. A sale with no payment, or a payment with nothing sold, cannot exist.

Voids, refunds and returns are new documents

create function reject_change() returns trigger language plpgsql as $$
begin
  raise exception '% is append-only: % is not permitted', tg_table_name, tg_op;
end $$;

create trigger sales_immutable before update or delete on sales
  for each row execute function reject_change();
create trigger sale_lines_immutable before update or delete on sale_lines
  for each row execute function reject_change();
create trigger tenders_immutable before update or delete on tenders
  for each row execute function reject_change();

Once documents cannot be edited, corrections have to be documents. In this schema the words mean:

DocumentkindWhenWhat it does
VoidvoidSame shift as the sale, always approvedReverses every line and tender in full
ReturnreturnWhenever the return policy allowsReverses some or all lines at the price and tax paid
Refundthe tenders of a returnWith the returnNegative tenders: cash out, back to card, or store credit
Exchangea return and a new saleTogetherThe customer pays or receives only the difference

Each gets the next receipt number, so the roll shows sale 3 then void 4, which is what an auditor wants. The download's trigger enforces the rest: a reversing line copies the original's product, price and tax rate, and total returns of a line never exceed what was sold. It locks the original line while checking, so two registers cannot both accept the same return. The sample returns one of two mugs, then tries two more, and gets only 1 left.

A void outside the original shift is refused, because reversing into a counted drawer would change a closed Z-report; the correction is a return in the current shift. One build in our benchmark removed the after-the-fact void entirely for this reason. And restock = false records a damaged return: the money goes back, the stock does not.

Receipt numbering without gaps

A sequence is the wrong tool. PostgreSQL sequences are deliberately non-transactional: a rolled-back insert still consumes its number, so a failed payment leaves a hole that looks exactly like a deleted sale. Number per register, and accept only the next number:

create function receipt_sequence_rule() returns trigger language plpgsql as $$
begin
  update registers set last_receipt_no = new.receipt_no
  where id = new.register_id and last_receipt_no = new.receipt_no - 1;
  if not found then
    raise exception 'receipt % on register % is out of sequence', new.receipt_no, new.register_id;
  end if;
  return null;
end $$;

create trigger receipt_sequence after insert on sales
  for each row execute function receipt_sequence_rule();

The counter updates in the sale's transaction, so a rolled-back sale returns its number, and the row lock serialises one register without touching the others. With unique (register_id, receipt_no), each roll runs 1, 2, 3 with no gaps or duplicates. A printed S01-R1-000123 is formatting, not a column.

Per register matters. A store-wide counter makes every till wait on one row, and an offline register cannot know the next store-wide number. It can always know its own.

Stock: the till writes to the inventory ledger

The till keeps no stock figure of its own. It writes one row per sale line to the append-only stock_movements ledger, in the sale's transaction. Inventory database design covers that ledger and why stock is never a column; this is only the hand-off:

create table stock_movements (
  id            bigint  generated always as identity primary key,
  product_id    bigint  not null references products(id),
  store_id      bigint  not null references stores(id),
  qty           integer not null check (qty <> 0),
  reason        text    not null check (reason in ('opening','receipt','sale','return','void','adjustment','count','damage')),
  source_event  text    not null unique,
  sale_line_id  bigint  references sale_lines(id),
  actor_id      bigint  not null references staff(id),
  occurred_at   timestamptz not null default now()
);

create function sale_line_to_stock() returns trigger language plpgsql as $$
declare hdr sales;
begin
  select * into hdr from sales where id = new.sale_id;
  if new.restock then
    insert into stock_movements (product_id, store_id, qty, reason, source_event, sale_line_id, actor_id, occurred_at)
    select new.product_id, r.store_id, -new.qty, hdr.kind, 'sale_line:' || new.id, new.id, hdr.cashier_id, hdr.occurred_at
    from registers r where r.id = hdr.register_id;
  end if;
  return null;
end $$;

create trigger sale_line_to_stock after insert on sale_lines
  for each row execute function sale_line_to_stock();

Selling two mugs is −2 stock; returning one is +1. source_event is derived from the line and unique, so no line moves stock twice. A trigger rather than application code is a trade: no sale can commit without its movement, at the cost of logic in the database. Stock visibility appears in 58 of the 60 products, second only to checkout, so this hand-off is not optional.

Negative stock is a policy choice. The example allows it: the item is in the customer's hand, so it is the count that is wrong.

Closing the shift: the X-report and the Z-report

create function shift_close_rules() returns trigger language plpgsql as $$
begin
  if old.closed_at is not null then
    raise exception 'shift % is already closed', old.id;
  end if;
  if (new.register_id, new.opened_by, new.opening_float_cents, new.opened_at)
     is distinct from (old.register_id, old.opened_by, old.opening_float_cents, old.opened_at) then
    raise exception 'only the close fields of a shift may be set';
  end if;
  if new.closed_at is not null then
    new.expected_cash_cents :=
      old.opening_float_cents
      + coalesce((select sum(t.amount_cents) from tenders t join sales s on s.id = t.sale_id
                  where s.shift_id = old.id and t.method = 'cash'), 0)
      + coalesce((select sum(m.amount_cents) from cash_movements m where m.shift_id = old.id), 0);
  end if;
  return new;
end $$;

create trigger shift_close before update on register_shifts
  for each row execute function shift_close_rules();

create view shift_cash_summary as
select sh.id as shift_id, sh.register_id, sh.opening_float_cents,
       coalesce((select sum(t.amount_cents) from tenders t join sales s on s.id = t.sale_id
                 where s.shift_id = sh.id and t.method = 'cash'), 0) as cash_tenders_cents,
       coalesce((select sum(m.amount_cents) from cash_movements m
                 where m.shift_id = sh.id), 0) as cash_movements_cents
from register_shifts sh;

Expected cash is float plus net cash tenders plus cash movements. The view gives it at any moment: the X-report, a mid-shift reading that changes nothing. The Z-report is the close. The cashier enters the counted cash; the trigger computes expected cash itself, so nobody can type an expected figure that matches their count. Variance over the threshold needs an approval.

A closed shift cannot be updated, and the download refuses any document into it, so a printed Z-report cannot drift. Count blind if you can, with the count entered before the expected figure is shown: a screen decision, and the difference between a count and a confirmation.

Offline sales and idempotency keys

Offline operation appears in 12 of the 60 products. You may not need it on day one, but the schema should survive adding it, and that comes down to two columns already in sales.

client_txn_id is a UUID the register generates when the sale starts. A double tap, a timed-out request and an offline queue replaying after reconnect all carry the same key, so a second arrival is a no-op:

insert into sales (client_txn_id, register_id, shift_id, receipt_no, kind, cashier_id, occurred_at)
values ('00000000-0000-4000-8000-000000000001', 1, 1, 1, 'sale', 1, '2026-10-17 09:12')
on conflict (client_txn_id) do nothing;

Against the sample data that prints INSERT 0 0. The receipt check is an AFTER INSERT trigger, so a skipped row never fires it: no number consumed, no stock moved. BEFORE triggers are the trap, because PostgreSQL runs them before deciding the conflict; a trigger refusing sales into a closed shift would reject a late retry instead of ignoring it, so the download's header trigger waves known keys through to the unique index. In application code, insert the header first and stop if no row comes back.

The register allocates its own receipt numbers, which is why numbering is per register. Offline it sells 41, 42, 43; on reconnect the server accepts them in order, and a missing number is a sale not yet synced, not one that vanished. That gives a close rule worth adding: close a shift only when the server holds every number up to the register's last. Which tenders to allow offline is for your payment provider and your risk appetite; the schema only guarantees that whatever was taken arrives exactly once.

The constraints that are load-bearing

  • No update or delete on sales, sale_lines, tenders, cash_movements, approvals or stock_movements. Enforced by trigger. Corrections are new documents.
  • Tenders equal the total, at commit. A deferred constraint trigger, not application code.
  • approved_by <> requested_by, as a check on the one approvals table, with every money-moving document pointing at it.
  • unique (register_id, receipt_no), and only the next number is accepted. Gapless per register, with rollbacks returning the number.
  • client_txn_id is unique. Every retry and every offline replay is idempotent.
  • One open shift per register, as a partial unique index, and no document into a closed shift.
  • Return quantity never exceeds sold quantity, checked under a row lock on the original line.
  • Price, tax rate and tax are copied onto the line. Nothing on a receipt reads the product table.
  • Every status-like column is a check. kind, method, action, role and reason are closed sets, so a Z-report can group by them without cleaning.

Example queries

All four run unchanged against the downloadable file.

The Z-report, by tender method, for a shift:

select t.method,
       count(*) filter (where t.amount_cents > 0) as payments,
       count(*) filter (where t.amount_cents < 0) as refunds,
       sum(t.amount_cents) as net_cents
from tenders t
join sales s on s.id = t.sale_id
where s.shift_id = 1
group by t.method
order by t.method;

The sample returns card at 2000 cents and cash at 2460: $44.60 of net takings, with the same cash figure the close used.

Missing receipt numbers on any register:

select r.code as register, n as missing_receipt_no
from registers r
cross join lateral generate_series(1, r.last_receipt_no) as n
where not exists (
  select 1 from sales s where s.register_id = r.id and s.receipt_no = n
)
order by r.code, n;

No rows, which is the point: it is the auditor's query, and you can run it first. With offline registers, rows here are sales still waiting to sync.

Net sales by product, after returns and voids:

select p.sku, p.name,
       sum(l.qty) as net_units,
       sum(l.qty::bigint * l.unit_price_cents - l.discount_cents) as net_sales_cents,
       sum(l.tax_cents) as tax_cents
from sale_lines l
join products p on p.id = l.product_id
group by p.id
order by net_sales_cents desc;

Returns and voids are negative lines, so there is no where status <> 'voided' to forget. The sample gives MUG-01 2 units and 2280 cents, CRD-01 4 and 1400, TEA-20 1 and 450 (sold twice, voided once).

Every discount, void and refund, with who asked and who approved:

select s.receipt_no, s.kind, coalesce(a.action, 'refund') as action,
       c.name as cashier, m.name as approved_by, a.reason,
       st.total_cents
from sales s
join sale_totals st on st.sale_id = s.id
join staff c on c.id = s.cashier_id
left join approvals a on a.id = coalesce(
  s.approval_id,
  (select l.discount_approval_id from sale_lines l
   where l.sale_id = s.id and l.discount_approval_id is not null limit 1))
left join staff m on m.id = a.approved_by
where s.kind <> 'sale' or a.id is not null
order by s.receipt_no;

The exceptions report owners actually read. It returns receipt 2 (approved discount), 4 (the void) and 5 (a return with no approver). The sample allows unapproved returns; if your policy does not, add kind <> 'return' or approval_id is not null to the sales checks.

What changes for a restaurant POS database design

The core holds: immutable documents, tender rows, shifts that own the cash. A restaurant adds a layer in front of the sale: an open check per table and server that grows for an hour, modifiers that change price and kitchen ticket, items fired to stations, and a split by seat or amount before the check becomes one or more sales, with tips recorded on the tender. The open check is edited constantly, so it gets its own tables, never sales. Our retail research treats restaurants as a separate product, covered by the Restaurant Management planner.

Why coding agents get this wrong

Ask a coding agent for a POS and the sale works; it is the first thing anyone builds and tests. What it does not produce unprompted is everything after the sale: the void as a document, the return at the original price, tender rows, a close that computes expected cash itself. Nothing in "build a point of sale system" says a completed sale can never be edited.

We have watched this directly. Three coding models (GPT-5.6 Sol, Claude Opus 4.8 and Grok 4.5) built point-of-sale systems from one planner, and all three could ring up a sale. What separated them on the published benchmark was the money path. The top build reversed loyalty points and gift-card value inside the same return and needed a second person for every money-moving action. Claude Opus 4.8's own review found the classic hole (ring a sale, take the cash, erase the record) and closed it by removing the after-the-fact void and routing every correction through returns. The full story is in three models, one POS system, and all three run as live demos.

Every one of those differences is a schema decision made before the first screen exists.


If you are not ready for a database yet, a POS system in Excel is the same design as sheets: products, sale lines, tenders and a cash-drawer close. If you are still deciding whether to build at all, POS system cost gives you the number to beat.

For the full build order around this schema, with a done-when check for each step, see how to build a POS system.

And if you are pointing a coding agent at this, the Retail POS planner is the spec we hand ours: the cashier-first scope, the void and return rules, the second-signature controls and the register close, written so the agent has to build them rather than stop at the sale.