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
| Table | Key columns | Purpose |
|---|---|---|
stores + registers | code; store_id, last_receipt_no | Where sales happen. Each register owns its receipt sequence |
staff | name, role | Everyone who rings, approves or closes anything |
products | sku (unique), price_cents, tax_rate_bp, is_active | The catalogue: today's price, not the price a sale used |
discount_reasons | code, max_pct_without_approval | The closed list of reasons a price can be reduced |
approvals | action, requested_by, approved_by, reason | The second signature on money-moving actions |
register_shifts | register_id, opening_float_cents, counted_cash_cents, expected_cash_cents | One till's trading day |
cash_movements | shift_id, kind, signed amount_cents, reason | Cash in or out of the drawer without a sale |
sales | client_txn_id (unique), register_id, shift_id, receipt_no, kind, original_sale_id | One document per receipt, returns and voids included |
sale_lines | product_id, signed qty, unit_price_cents, discount_cents, tax_cents, original_line_id | What was sold, frozen at the moment of sale |
tenders | sale_id, method, amount_cents, cash_received_cents, provider_ref | How it was paid. A split payment is two rows |
stock_movements | product_id, store_id, signed qty, reason, source_event | The inventory ledger. Every sale line writes one row |
sale_totals, shift_cash_summary (views) | per sale; per shift | Totals 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.
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.
- 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.
- 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.
- 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
pendingsale. - 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.
- Make every correction a new document with negative quantities, a link to the original and its own receipt number.
- Enforce the money rules in the database. Closed sets as
checkconstraints, tenders equal to the total at commit, no self-approval, no skipped receipt numbers. The constraints section lists them. - 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:
| Document | kind | When | What it does |
|---|---|---|---|
| Void | void | Same shift as the sale, always approved | Reverses every line and tender in full |
| Return | return | Whenever the return policy allows | Reverses some or all lines at the price and tax paid |
| Refund | the tenders of a return | With the return | Negative tenders: cash out, back to card, or store credit |
| Exchange | a return and a new sale | Together | The 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
updateordeleteonsales,sale_lines,tenders,cash_movements,approvalsorstock_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 acheckon 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_idis 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,roleandreasonare 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.