dodil/erp-method

modules/pos/module.yaml

# Point of sale: the module source.
#
# PROVENANCE. Written 2026-10-25 in the wave-3 spec pass (erp-method-w3, spec/wave3-v1). Version 1 SIGNED
# by seemo 2026-10-25 (signoff/pos/; wave 3). Written from:
#   - the neighbours' specs as they stand at erp-method v0.1.29: product v5 (items, units, prices), inventory v5 (stock,
#     lots, goods issues, valuation), tax v5 (POST /documents/compute with pinned lines), payments v5 (cash journal,
#     deposit batches, card fees from the statement, statement reconciliation), gl v6 (POST /journal-entry/journals,
#     periods, companies), business-partner (GET /partners/{bp_id}), hr v1 (people; it lists no read routes yet)
#   - the motivating customer: a coffee-shop chain with shops in the UK, Germany, Austria and France, one or two
#     registers a shop, a card terminal per register, a safe, and a weekly bank run with the cash bags
#   - fiscal law, as config rows and extensions, never as code paths: Germany KassenSichV (Kassensicherungsverordnung
#     2017) with a TSE certified to BSI TR-03153 and the DSFinV-K export, s. 146a AO (Belegausgabepflicht); France
#     NF525 (CGI art. 286-I-3 bis, inalterability, security, storage and archiving of cash data); Austria RKSV
#     (Registrierkassensicherheitsverordnung 2015, s. 131b BAO); the UK has no fiscal-till law
#   - IFRS 15 (revenue net of discounts; vouchers as contract liabilities, breakage para B44-B47), Council Directive
#     (EU) 2016/1065 and UK VATA 1994 Schedule 10A (single- and multi-purpose vouchers), the UK Employment (Allocation
#     of Tips) Act 2023 (tips passed to workers in full)
#   - Odoo 17 point_of_sale (pos.config, pos.session, pos.order, pos.payment) as the migration source and SAP
#     S/4HANA with SAP Customer Activity Repository / POS data management as a parity reference only
#
# The decisions a retail finance lead checks first:
#   - a sale is final when the receipt is printed; nothing edits it afterwards. A refund is a new receipt that names
#     the original, and a correction is a later document, never an update
#   - the register keeps selling offline; a receipt is keyed on the device (shop, register, session, receipt number),
#     so a sync that runs twice, or a register that sends a batch again, never makes a second sale
#   - gl gets ONE summary journal per shop and business day (and a supplement for anything that arrives late), not a
#     journal per receipt; stock leaves inventory per receipt line through the recipe
#   - card money is cleared, not assumed: the day posts card takings to a clearing account and payments clears it
#     when the provider's settlement reaches the bank; cash is counted, the difference approved by a second person,
#     and banked through payments' deposit batches
#   - recipes: a drink consumes beans, milk and a cup at sale time. pos keeps a recipe of its own when manufacturing
#     is absent, and reads manufacturing's bill of materials when it is installed (decided below: pos depends on
#     manufacturing, optionally; manufacturing never calls pos)
#
# APQC PCF v8.0: 3.5.2 Manage sales orders / 3.5.3 retail point-of-sale transactions (ids to confirm against the PCF
# file; pcf_id null until then).

# Version 2 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 1 stays the signed one until it is signed):
#   - PROCESS change (the migration run, seemo's decided answer 4, pattern §11): load writes staging rows only; sign
#     is the first write to the ledger, through the normal use cases; discard deletes staging rows only; a signed
#     run is reversed, never deleted. Effects added; no step renamed or removed
#   - PROCESS change (receipt): void_open_receipt goes to a voided state (was to: none); the step id is kept
#   - the shop day is the only journal; a supplement's source_document is 'pos:<shop>:<date>:supplement:<n>' so each
#     has its own id (gl v9's rule)
#   - cash moves post with gl's pos purposes (safe drop and float top-up between safe and till; paid-out and paid-in
#     against the reason's purpose, default shop_supplies)
#   - a refund copies the original's frozen tax code and rate at the till and is recomputed by tax with pinned_from
#     at sync; a different answer quarantines it
#   - business_timezone is read from gl GET /companies/{company_code} per request, never a pos_settings row

id: pos
name: Point of sale (shops and tills)
short: Point of sale
version: 2
maturity: tested                     # version 1 signed by seemo 2026-10-25; erp-brick-pos 0.2.0 builds it (slices A and B, tested, not deployed); the latest version is a draft for re-sign
summary: >-
  Sell over the counter in every shop: open the till with a float, ring up drinks and food, take cash, card or a
  voucher (or several at once), print a receipt the tax office accepts, refund against the original receipt, keep
  selling when the internet drops, and at closing count the drawer, print the Z report and let the books, the stock
  and the bank catch up on their own.

decisions:                            # nothing signed; these are proposals for the reference customer
  - proposed: "a finalised receipt is immutable; a refund is a new receipt of kind refund naming the original receipt and line; a void is only possible before the receipt is finalised"
    status: "for sign-off"
  - proposed: "receipt_id is minted on the register: <shop_id>-<register_id>-<session_no>-<receipt_no> (receipt_no gapless per register), so an offline register needs no server to sell and a re-sent receipt names the same row"
    status: "for sign-off"
  - proposed: "gl receives one summary journal per shop and business date, posted when the last session of the day closes; a receipt synced after that posts a supplement journal for the difference only, never a rewrite"
    status: "for sign-off"
  - proposed: "tips are a liability to staff by default (tips_payable), never revenue; the allocation to staff is an extension feeding payroll (UK Employment (Allocation of Tips) Act 2023)"
    status: "for sign-off: question for the reference customer (customer_varies)"
  - proposed: "the recipe source is manufacturing's bill of materials (usage sale) when manufacturing is installed, else pos's own recipe table; the receipt line freezes the recipe version it consumed"
    status: "for sign-off"
  - proposed: "fiscal signing is a country capability named by the shop's fiscal profile row (de_kassensichv, fr_nf525, at_rksv, none); the core never branches on a country code"
    status: "for sign-off"

facets:
  domain: sales
  industries: [retail, hospitality, food-service]
  customer_words:
    - "till"
    - "point of sale"
    - "cash register"
    - "the card machine"
    - "open and close the till"
    - "Z report"
    - "end of day"
    - "our coffee shops"
    - "the till is offline"
    - "gift cards"
    - "loyalty stamps"
    - "TSE"
    - "NF525"
    - "Registrierkasse"
  replaces: [stand-alone till systems, Odoo Point of Sale, Square, Lightspeed, spreadsheets of Z reports]
  pillars: [sql]
  first_module_fit: medium
  depends_on:
    - module: product
      why: every button on the till is a product with its sales unit, tax category and price; pos keeps no item master and no price of its own
      required: true
    - module: tax
      why: the VAT on every line comes from tax (a dated rate snapshot on the register, POST /documents/compute at sync); pos never stores a rate it computed itself
      required: true
    - module: gl
      why: the daily summary journal, the shop's company and the open periods
      required: true
    - module: inventory
      why: a drink consumes beans, milk and a cup from the shop's stock at sale time; without inventory pos sells but nobody knows what is in the back room
      required: recommended
    - module: payments
      why: card settlements and cash bags are cleared against the bank there; without payments the clearing accounts are cleared by hand in gl
      required: recommended
    - module: business-partner
      why: a named customer (a company asking for a full VAT invoice, a loyalty member) is a partner; walk-in customers are never created as partners
      required: optional
    - module: hr
      why: a cashier is a person employed on the day they ring up a sale; a leaver's till PIN stops working on their last day
      required: optional
    - module: manufacturing
      why: a drink's recipe is manufacturing's bill of materials of usage sale when it is installed (POS-31); without it pos keeps its own recipe (POS-15). manufacturing never calls pos
      required: optional
  feeds: [gl, inventory, payments]
  owns_master_data: [shop, register]
  references_master_data: [product, price_list, tax_category, tax_code, company, person, business_partner, warehouse, location]

actors:
  - id: cashier
    name: Barista / cashier
    does: opens their session with a float, rings up sales, takes payments and tips, refunds within their limit, gives discounts within their role's limit, counts the drawer at close
    sees: their own open session and the receipts of their shop for refunds
    permissions: [pos:session:open, pos:session:count, pos:receipt:sell, pos:receipt:refund, pos:discount:apply, pos:cash:move, pos:voucher:sell, pos:shop:read]
  - id: shift_lead
    name: Shift lead / shop manager
    does: approves cash differences, refunds and discounts above a cashier's limit, forces a session closed when a register has died, takes the cash bag to the bank, reads the shop's day
    sees: everything in their shop
    permissions: [pos:shop:read, pos:session:open, pos:session:count, pos:receipt:sell, pos:receipt:refund, pos:discount:apply, pos:cash:move, pos:voucher:sell, pos:session:approve_difference, pos:override:approve, pos:session:force_close, pos:deposit:prepare, pos:report:read]
    note: "never approves their own difference, refund or discount: matched on email AND sub"
  - id: retail_ops
    name: Retail operations / area manager
    does: keeps shops, registers, the till layout, discount limits and recipes; reads every shop's days
    sees: every shop
    permissions: [pos:shop:read, pos:shop:write, pos:register:write, pos:layout:write, pos:recipe:write, pos:report:read, pos:settings:write]
  - id: accountant
    name: Retail accountant
    does: checks each shop's day before it posts, decides tax mismatches and late receipts, reads the trail from a Z report to the journal, the stock moves and the bank
    sees: every shop and day
    permissions: [pos:shop:read, pos:report:read, pos:day:review, pos:day:post, pos:migration:read]
  - id: owner
    name: The owner (or whoever the owner names)
    does: holds the controls on the people who handle cash (difference threshold, refund and discount limits, blind close, offline limits, fiscal profiles), signs the migration reconciliation
    sees: everything
    permissions: [pos:shop:read, pos:report:read, pos:settings:controls, pos:migration:read, pos:migration:sign]
    note: "a cashier, shift lead or accountant holding pos:settings:controls is a configuration error the access test refuses"
  - id: customer
    name: The customer at the counter
    external: true
    does: pays, asks for a receipt or a VAT invoice, collects stamps
    sees: their receipt
    permissions: []
  - id: system
    name: The system
    does: takes receipts from registers (online and offline), signs them through the shop's fiscal capability, sends stock consumption, VAT and the day's journal to the neighbours from pending rows, hands card settlements and cash bags to payments, marks days stale when something arrives late, loads an Odoo migration
    sees: every shop, through its own service account
    permissions: [pos:receipt:ingest, pos:fiscal:sign, pos:outbox:dispatch, pos:day:derive, pos:number_series:claim, pos:migration:load, pos:migration:reconcile]
    note: "each is an exact grant to pos's service client, never reached by a wildcard; the same client holds product:product:read, product:price_list:read, tax:document_tax:compute, gl:journal:post, gl:period:read, gl:company:read, inv:availability:read and bp:partner:read"

stories:
  # ── shops and registers ──────────────────────────────────────────────────────────────────
  - id: POS-01
    kind: person
    actor: retail_ops
    story: As retail operations, I want each shop and its registers set up once, with the company, the warehouse its stock comes from and its fiscal profile, so that a new shop sells correctly from its first receipt.
    given: 'gl company UK01 (GBP) and DE01 (EUR); inventory warehouses LON-SOHO (location LON-SOHO/BAR) and BER-MITTE (BER-MITTE/BAR); fiscal profiles none (UK) and de_kassensichv (DE) seeded'
    when: 'I create shop LON-SOHO (company UK01, warehouse LON-SOHO, consumption location LON-SOHO/BAR, price list LIST-UK-SHOP, fiscal profile none) with registers R1 and R2, and shop BER-MITTE (DE01, fiscal profile de_kassensichv) with register R1 and TSE serial 7f3a...e1'
    then: >-
      shop_id and register_id are the customer's codes, fixed once a receipt names them; the company must exist in gl
      (GET /companies/{company_code}), the warehouse and location in inventory (GET /warehouses/{warehouse_id}, GET
      /locations); a register in a shop whose fiscal profile needs a device (de_kassensichv, at_rksv) cannot open a
      session until the device is registered (gate fiscal_device_ready); the register's receipt series starts at 1 and is
      gapless; a register is retired, never deleted, and a retired register's id is never reused
    screen: Shops
    reference: not built
    customer_varies: which shops, which registers, which fiscal profile per country
  - id: POS-02
    kind: person
    actor: retail_ops
    story: As retail operations, I want the till layout (the buttons, their categories and modifiers such as oat milk or an extra shot) kept per shop or group of shops, so that baristas find a drink in one tap.
    given: 'products LATTE-REG, FLAT-WHITE, CROISSANT in product v5, sellable, with sales unit C62; modifiers MOD-OAT (+0.40 GBP, replaces MILK-WHOLE with MILK-OAT in the recipe) and MOD-SHOT (+0.60 GBP, adds 0.009 KGM BEANS-HOUSE)'
    when: I publish layout UK-STD to LON-SOHO and LON-KX
    then: >-
      a button names a product_id, never a copy of its name or price; a product not sellable or not on the shop's price
      list on the publish date is refused with the product named; the layout is versioned and a register picks up the
      new version at its next session open, never in the middle of a session
    screen: Layout
    reference: not built
  # ── sessions ─────────────────────────────────────────────────────────────────────────────
  - id: POS-03
    kind: person
    actor: cashier
    story: As a barista, I want to open my session by counting the float into the drawer, so that the drawer I close is measured against what I started with.
    given: 'register LON-SOHO-R1 closed; the previous session LON-SOHO-R1-0412 closed with 150.00 GBP left as float; business date 2026-11-14 (business_timezone Europe/London, day_cutoff 04:00)'
    when: 'I sign in with my PIN at 06:52 and count 150.00 (3 x 20.00, 6 x 10.00, 4 x 5.00, 10.00 in coins)'
    then: >-
      session LON-SOHO-R1-0413 is open for business date 2026-11-14 with opening_float 150.00 counted by denomination;
      a count differing from the previous closing float by more than the difference threshold opens a float difference
      that a shift lead approves before the first sale; one register has at most one open session (a second open is
      refused, gate one_open_session); a cashier who is not employed today in hr (when hr is installed) is refused; a
      session opened at 01:30 belongs to the previous business date (before day_cutoff)
    screen: Till
    reference: not built
  - id: POS-04
    kind: person
    actor: cashier
    story: As a barista, I want to record cash going in or out of the drawer (a safe drop, a paid-out for milk from the corner shop), so that the drawer count at close still adds up.
    given: 'session LON-SOHO-R1-0413 open; cash sales so far 380.00'
    when: 'I record a safe drop of 200.00 (bag none, to the safe) and a paid-out of 12.00 for 6 LTR of milk with reason MILK and a photo of the shop receipt'
    then: >-
      each movement is a cash_move row of the session with kind (safe_drop | paid_in | paid_out | float_top_up), amount,
      reason and who; a paid-out names a reason whose purpose decides its account (MILK -> purpose shop_supplies) and
      keeps its receipt image as a DataK3 object raw/pos/<shop_id>/<business_date>/<cash_move_id>.jpg; an amount of zero,
      negative, or above NUMERIC(18,2) is refused 422; a paid-out above paid_out_limit (default 50.00) needs a shift lead
    screen: Till
    reference: not built
  - id: POS-05
    kind: person
    actor: cashier
    story: As a barista, I want to close my session by counting the drawer without seeing what the till expects, so that the count is honest and a difference is found, not explained away.
    given: 'session LON-SOHO-R1-0413: opening float 150.00, cash sales 416.40, cash refunds 3.60, paid-out 12.00, safe drops 200.00; expected cash 350.80; blind_close on; difference_threshold 5.00'
    when: 'I count 344.30 at 19:05 and close'
    then: >-
      the session moves to counted with counted_cash 344.30, expected 350.80, difference -6.50; because the absolute
      difference is above the threshold it waits for a shift lead (POS-06); at or below the threshold it closes at once
      and the difference is still recorded and posted; I see the expected amount only after my count is saved; a session
      cannot be counted while the register reports receipts not yet synced (gate all_synced) unless a shift lead forces the
      close (POS-07); a counted session never goes back to open
    screen: Till
    reference: not built
  - id: POS-06
    kind: person
    actor: shift_lead
    story: As the shift lead, I want to approve or query a cash difference with a reason, so that every pound missing from a drawer has a name next to it.
    given: 'session LON-SOHO-R1-0413 counted with difference -6.50, counted by barista A (sub a1, a@shop)'
    when: 'barista A tries to approve it under a second login (same sub, other email); then I (shift lead B) approve it with reason "coins short in the 2.00 tube"'
    then: >-
      the first approval is refused (gate second_person, "you counted this session"); mine closes the session with
      approved_by, approved_sub, reason and time; the difference posts to purpose cash_over_short in the day's journal
      (POS-20); a shift lead may instead recount once, which replaces the count before approval and keeps the first
      count on record
    screen: Shop day
    reference: not built
  - id: POS-07
    kind: person
    actor: shift_lead
    story: As the shift lead, I want to force a session closed when its register has died with receipts on it, so that the shop's day can close and the receipts still arrive later.
    given: 'register LON-SOHO-R2 dropped at 15:10 with local receipts up to R2-0388-0161 and the server holding up to 0154'
    when: 'I force-close session LON-SOHO-R2-0388 with reason "tablet broken", counting the drawer at 210.00'
    then: >-
      the session closes as force_closed with the server's last receipt number 0154 and a claimed gap 0155..0161
      recorded; the day may post without them; when the receipts arrive (POS-12) they are accepted into the closed
      session, never into another, the day is marked stale and posts a supplement (POS-21); a receipt number above
      the claimed gap on that session is refused
    screen: Shop day
    reference: not built
  # ── selling ──────────────────────────────────────────────────────────────────────────────
  - id: POS-08
    kind: person
    actor: cashier
    story: As a barista, I want to ring up drinks and food and take the payment, so that the customer leaves with a receipt and the drink is made.
    given: 'LON-SOHO price list LIST-UK-SHOP: LATTE-REG 3.60, CROISSANT 2.40, prices include VAT; tax categories hot_drink and food_takeaway, both S 20% in the UK on 2026-11-14 (the register''s tax snapshot); MOD-OAT +0.40'
    when: 'I ring 2 x LATTE-REG with MOD-OAT on one and 1 x CROISSANT, takeaway, and the customer pays 10.00 cash'
    then: >-
      receipt LON-SOHO-R1-0413-0057: lines 3.60, 4.00, 2.40, gross 10.00, net 8.33, VAT 1.67 (VAT computed per tax code
      on the receipt total including tax, rounded once half up: 10.00 x 20/120 = 1.67), cash tendered 10.00, change 0.00;
      the price comes from product's price list as of the business date, never typed; a price override needs
      pos:override:approve from another person; the receipt is signed by the shop's fiscal capability before it prints
      (POS-24); it is final once printed
    screen: Till
    reference: not built
    customer_varies: eat-in or takeaway changes the VAT rate for food in Germany, France and Austria (not for UK hot drinks)
  - id: POS-09
    kind: person
    actor: cashier
    story: As a barista, I want to split one bill across cash, card and a gift voucher, so that a group can pay however they like.
    given: 'a receipt of 23.80 GBP; voucher GV-UK-000812 with balance 15.00; the card terminal of R1 online'
    when: 'the customer pays 15.00 by voucher, 5.00 cash and the rest by card, adding a 1.00 tip on the card'
    then: >-
      three payment lines keyed (receipt_id, pay_no): voucher 15.00 (the voucher claimed to 0.00), cash 5.00, card 3.80
      plus tip 1.00 = 4.80 authorised on the terminal with idempotency key <receipt_id>:3; the receipt finalises only when
      the payment lines sum exactly to the gross (23.80) and every card line is authorised; a declined card leaves the
      receipt open with the other lines intact; a terminal timeout leaves the line pending and the register asks the
      terminal for the outcome by its key before it offers to try again, so a card is never charged twice
    screen: Till
    reference: not built
  - id: POS-10
    kind: person
    actor: cashier
    story: As a barista, I want to give a discount within what my role allows, and ask the shift lead for more, so that a complaint is settled at the counter without anyone giving the shop away.
    given: 'discount limits: cashier 10%, shift_lead 25%, retail_ops 100%; staff discount 50% for a staff member''s own drink (reason STAFF, staff PIN required)'
    when: 'I give 10% on a 3.60 latte (reason COMPLAINT), then try 30%, and then ring a staff drink for myself'
    then: >-
      the 10% line is 3.24 with discount 0.36 recorded (discount_pct 10, a percentage per shared.yaml units); 30% is
      refused and offers an override; the override must come from someone whose limit covers 30% and who is not me
      (email AND sub); my own staff drink needs another staff member to enter the STAFF discount, never me; revenue is
      posted net of the discount and the discount is reported by reason
    screen: Till
    reference: not built
  - id: POS-11
    kind: person
    actor: cashier
    story: As a barista, I want to refund against the original receipt, so that the customer gets back what they paid, the way they paid it, and nothing is refunded twice.
    given: 'receipt LON-SOHO-R1-0413-0057 (2 x LATTE-REG 3.60 and 4.00, 1 x CROISSANT 2.40, cash 10.00); cash_refund_limit 20.00'
    when: 'the next day at LON-SOHO I scan its receipt code and refund the oat latte (4.00) in cash; then a colleague tries to refund the same oat latte again'
    then: >-
      refund receipt LON-SOHO-R1-0414-0003 of kind refund, naming original receipt and line 2, gross -4.00, VAT -0.67
      with the original's tax code and rate (v2: the till copies the original line's frozen tax code and rate, so it
      refunds offline; at sync the refund goes to tax POST /documents/compute with pinned_from = the original's
      document_ref like every receipt (POS-28), and tax must answer the same code and rate, a different answer
      quarantining the refund for the accountant; a rate change since the sale never changes the refund); the refundable quantity is claimed per
      original line (claim key refund:<original receipt_id>:<line_no>:<claim_no>), so the second refund is refused
      (nothing left to refund); a card sale is refunded to the card through the terminal as a referenced refund; a cash
      refund above the limit needs a shift lead; a drink is not restocked (refund_restock false for category hot_drink),
      a packaged bag of beans is returned to stock
    screen: Till
    reference: not built
  - id: POS-12
    kind: person
    actor: cashier
    story: As a barista, I want to keep selling when the shop's internet drops, so that the queue never stops for a router.
    given: 'register LON-SOHO-R2 offline from 10:02 to 11:47 on 2026-11-14; its tax snapshot and price list snapshot dated 2026-11-14; offline_card_mode terminal (the terminal''s own store-and-forward); voucher_offline_limit 0.00'
    when: 'I ring 38 receipts R2-0388-0117 to 0154 offline; a customer offers a gift voucher; the register comes back and syncs, and the sync is sent again by a flaky retry'
    then: >-
      the 38 receipts are numbered and signed on the register (fiscal capability in its offline mode, POS-24) and printed;
      the voucher is refused offline ("vouchers need the till online"); the sync creates 38 receipts; the repeat creates
      none (same receipt_id, same content digest answers 200 replay); a receipt arriving with a known receipt_id and a
      different digest is quarantined with both versions kept and is never a second sale (409 receipt_conflict, the
      accountant decides, POS-22); stock, VAT and the journal follow from pending rows after the sync commits
    screen: Till
    reference: not built
  - id: POS-13
    kind: person
    actor: cashier
    story: As a barista, I want to sell a gift voucher and redeem one, so that a voucher behaves like money and the books show what we still owe.
    given: 'voucher policy UK: multi-purpose (VAT at redemption), validity 24 months, breakage recognised by the accountant (IFRS 15 B46)'
    when: 'I sell voucher GV-UK-000813 for 25.00 by card, and later a customer redeems 9.20 of it on a receipt of 9.20'
    then: >-
      the sale is a receipt line of kind voucher_sale, outside the scope of VAT for a multi-purpose voucher, posting
      25.00 to purpose voucher_liability; the redemption is a payment line of method voucher 9.20 that claims the
      voucher's balance (claim key voucher:<voucher_code>:<claim_no>, operation redeem) leaving 15.80; the receipt's
      VAT is computed on the goods as usual; a redemption above the balance or on an expired voucher is refused; a
      single-purpose voucher profile (VAT at issue) is a config row, not a code path
    screen: Till
    reference: not built
    customer_varies: single- or multi-purpose vouchers per country; validity; whether breakage is recognised
  - id: POS-14
    kind: person
    actor: cashier
    story: As a barista, I want a named customer (a company) to get a full VAT invoice for their receipt, so that they can reclaim the VAT and we never create partners for walk-in customers.
    given: 'business partner 50211 (a design studio, UK VAT GB123456789) exists in business-partner; receipt LON-SOHO-R1-0413-0090 of 312.00 for a catered meeting'
    when: 'I attach partner 50211 to the receipt before payment'
    then: >-
      the receipt carries bp_id 50211 and prints as a full VAT invoice with the partner's name, address and VAT number read
      from GET /partners/{bp_id}; a receipt without a partner is a simplified invoice (allowed up to 250.00 GBP in the UK,
      VAT Notice 700 s. 16.7; 100.00 EUR where the member state uses Directive 2006/112/EC art. 238); above the limit a
      receipt with no partner prints with a warning to take the customer's details; pos never creates a partner
    screen: Till
    reference: not built
  # ── recipes and stock ────────────────────────────────────────────────────────────────────
  - id: POS-15
    kind: person
    actor: retail_ops
    story: As retail operations, I want each drink's recipe (beans, milk, cup, lid) written once, so that selling a latte takes the right stock out of the shop.
    given: 'manufacturing not installed; products BEANS-HOUSE (base unit KGM, lot-tracked), MILK-WHOLE (LTR), MILK-OAT (LTR), CUP-12OZ (C62), LID-12OZ (C62)'
    when: 'I write recipe LATTE-REG v1 from 2026-11-01: 0.018 KGM BEANS-HOUSE, 0.250 LTR MILK-WHOLE, 1 C62 CUP-12OZ, 1 C62 LID-12OZ, and MOD-OAT replacing MILK-WHOLE with MILK-OAT 0.250 LTR'
    then: >-
      the recipe is a dated version (recipe_id LATTE-REG, version 1, valid_from 2026-11-01, after today); each quantity is
      in the component's issue unit and converts exactly to its base unit (product GET /products/{product_id}/convert), or
      is refused; a component that is not stocked is refused; when manufacturing is installed this screen shows its
      bill of materials of usage sale read-only and the recipe source setting is manufacturing (POS-31)
    screen: Recipes
    reference: not built
  - id: POS-16
    kind: system
    actor: system
    trigger: after a receipt (or a synced batch) commits
    story: The system takes the recipe's components out of the shop's stock, receipt line by receipt line, so that the back room's stock follows what was sold.
    given: 'receipt LON-SOHO-R1-0413-0057 committed: LATTE-REG x1, LATTE-REG+MOD-OAT x1, CROISSANT x1 (CROISSANT is stocked itself, no recipe)'
    when: 'the outbox dispatcher runs, twice'
    then: >-
      one consumption request per receipt to inventory with lines BEANS-HOUSE 0.036 KGM, MILK-WHOLE 0.250 LTR, MILK-OAT
      0.250 LTR, CUP-12OZ 2, LID-12OZ 2, CROISSANT 1, each with move_ref pos:<receipt_id>:<line_no>:<component_no> from
      location LON-SOHO/BAR; the recipe version used is frozen on the receipt line, so a later recipe change never changes
      a past sale's consumption; the second dispatch is a replay (inventory answers 200 for a known move_ref); stock that
      would go negative is allowed at a bar location by inventory's own setting, and is then reported, never blocking a sale
    reference: not built
  # ── day close ────────────────────────────────────────────────────────────────────────────
  - id: POS-17
    kind: person
    actor: shift_lead
    story: As the shift lead, I want the Z report of each session and of the shop's day, so that what the tills took, by payment and by tax, is on one page I can sign.
    given: 'session LON-SOHO-R1-0413 closed: 214 receipts, sales gross 1,290.00, refunds 3.60, net gross 1,286.40, net 1,072.00, VAT S 214.40; tenders cash 412.80 net, card 851.60, voucher 22.00; card tips 38.50; discounts 18.40 (COMPLAINT 3.60, STAFF 14.80); cash difference -6.50'
    when: I print the Z report
    then: >-
      Z report LON-SOHO-R1-Z0413 numbered gapless per register, with the counts and totals above, receipt numbers first
      and last (0001 to 0214, no gap or the claimed gap named), grand totals since the register started (needed by NF525
      and RKSV), and the fiscal profile's own fields; printing it twice prints the same report marked copy; it is derived
      from the session's rows only, so a receipt in another session, register or shop never changes it
    screen: Shop day
    reference: not built
  - id: POS-18
    kind: person
    actor: accountant
    story: As the retail accountant, I want each shop's day checked before it posts, so that a tax mismatch or a missing session is caught before it reaches the ledger.
    given: 'LON-SOHO 2026-11-14: sessions R1-0413 closed and R2-0388 force-closed; every receipt''s VAT recomputed by tax at sync; one receipt''s printed VAT 0.01 off tax''s answer'
    when: I open the day
    then: >-
      the readiness list {gate, met, reason, fix}: all_sessions_closed met; differences_approved met; tax_agrees not met
      ("receipt R2-0388-0131: printed 0.83, tax 0.84"); with auto_post on and every gate met the day posts on its own
      (POS-20); otherwise I accept the printed amount (the receipt is the legal document) with a reason and the difference
      posts to purpose tax_rounding, or I hold the day; a day is reviewed by an accountant, never by the shift lead who
      approved its differences
    screen: Shop day
    reference: not built
  - id: POS-19
    kind: person
    actor: shift_lead
    story: As the shift lead, I want to prepare the cash bag for the bank from the safe, so that the bank's credit matches a bag we can name.
    given: 'LON-SOHO safe holds 600.00 from safe drops on 2026-11-13 and 2026-11-14; house bank account BARC-SHOP-UK (payments, purpose receive)'
    when: 'I seal bag B-00451 with 600.00 (by denomination) and the courier collects it on 2026-11-15'
    then: >-
      a cash deposit keyed dep:pos:LON-SOHO:B-00451 for 600.00 GBP, moving cash in safe to cash in transit in the next day
      journal, is handed to payments as an expected deposit batch (POS-26); when the bank credit arrives payments matches
      it and clears cash in transit; a bag can be sealed once; the amount is bounded (NUMERIC(18,2), positive) and cannot
      exceed what the safe holds
    screen: Shop day
    reference: not built
  - id: POS-20
    kind: system
    actor: system
    trigger: when the last session of a shop's business date closes and the day's gates are met (or the accountant posts it)
    story: The system posts one summary journal for the shop's day, so that the ledger has the day's revenue, VAT, tenders, tips and cash differences without a line per coffee.
    given: 'LON-SOHO 2026-11-14 ready (the figures of POS-17; paid-out 12.00 MILK; safe drop 200.00)'
    when: the day posts, and the post is retried after a timeout
    then: >-
      gl POST /journal-entry/journals with journal_id derived_id("pos:LON-SOHO:2026-11-14", "day_close"), source_module pos,
      company UK01, journal_date 2026-11-14: debit pos_cash_in_till 412.80, card_clearing 890.10 (851.60 + tips 38.50),
      voucher_liability 22.00, cash_over_short 6.50, shop_supplies 12.00, pos_cash_in_safe 200.00; credit revenue 1,072.00
      (one line per revenue purpose and cost centre), VAT S 214.40 (purpose tax with tax_code S), tips_payable 38.50,
      pos_cash_in_till 6.50 + 12.00 + 200.00; the journal balances exactly (1,543.40 each side); one line per (purpose,
      tax code, cost centre), never per receipt; the retry answers gl's replay; a closed gl period refuses it (409
      period_open) and the day waits as post_failed with the reason, never posting into the next period silently
    reference: not built
  - id: POS-21
    kind: system
    actor: system
    trigger: when a receipt or a correction arrives for a shop day already posted
    story: The system marks the day stale and posts a supplement for the difference, so that a late receipt is in the books without rewriting what was posted.
    given: 'LON-SOHO 2026-11-14 posted; on 2026-11-15 at 09:10 register R2 syncs receipts 0155 to 0161 (gross 41.20, cash 18.00, card 23.20)'
    when: the sync commits
    then: >-
      the day shows stale with the supplement pending; the supplement journal is source_document
      "pos:LON-SOHO:2026-11-14:supplement:1", journal_id derived_id(source_document, "supplement") (v2: gl's one id
      rule), carrying only the difference (revenue 34.33, VAT 6.87, cash 18.00, card clearing 23.20), dated the day
      if its gl period is open, else the first open day with a memo naming the business date; the next late batch
      takes ...:supplement:2; the Z report of session R2-0388 is reissued as a new version, the first kept
    reference: not built
  # ── fiscal ───────────────────────────────────────────────────────────────────────────────
  - id: POS-24
    kind: system
    actor: system
    trigger: before a receipt is printed, and at session and day close
    story: The system signs each receipt through the shop's fiscal profile, so that every shop meets its country's till law and no country is hard-coded.
    given: 'fiscal profiles: none (UK); de_kassensichv (TSE per register, DSFinV-K export); fr_nf525 (chained signature, perpetual grand total, daily, monthly and yearly closures); at_rksv (signature device, chained signature, turnover counter, QR code, start, month, year and null receipts)'
    when: a receipt is finalised in BER-MITTE, PAR-01, VIE-01 and LON-SOHO
    then: >-
      the core calls the capability named by the profile (pos.fiscal.de_kassensichv | fr_nf525 | at_rksv; none does
      nothing) with the receipt's canonical content and stores its answer as fiscal_json on the receipt (DE: TSE serial,
      transaction number, signature counter, start and end time, signature; AT: the RKSV machine-readable code; FR: the
      chained signature and grand totals); a device outage follows the profile's rule (DE: sell and print "TSE
      ausgefallen", record the outage; AT: sell, mark "Sicherheitseinrichtung ausgefallen", issue a null receipt after
      recovery); the receipt's content never changes after signing
    reference: not built
    customer_varies: the countries the chain trades in; which certified TSE or signature provider
  - id: POS-25
    kind: person
    actor: accountant
    story: As the retail accountant, I want the fiscal export for a tax audit (DSFinV-K in Germany, the NF525 archive in France, the RKSV DEP in Austria), so that an inspector gets the file the law names.
    given: 'BER-MITTE receipts for 2026-01-01 to 2026-12-31'
    when: I request the DSFinV-K export for BER-MITTE for 2026
    then: >-
      the export is produced by the fiscal capability from pos's receipts and fiscal_json, stored as a DataK3 object
      raw/pos/fiscal/BER-MITTE/2026/dsfinvk.zip with its digest, and listed with who asked and when; asking again for the
      same period returns the same digest unless a supplement arrived, which is named
    screen: Reports
    reference: not built
  # ── integrations ─────────────────────────────────────────────────────────────────────────
  - id: POS-26
    kind: integration
    actor: system
    trigger: when a cash bag is sealed (POS-19), and when the day posts card takings
    story: Cash bags and card settlements are handed to payments, which matches them to the bank statement and clears the accounts the day posted.
    given: 'bag dep:pos:LON-SOHO:B-00451 of 600.00; card takings 890.10 for terminal batch LON-SOHO-R1-T-20261114 on 2026-11-14'
    when: the outbox dispatches them, and again after a retry
    then: >-
      payments receives an expected deposit batch (600.00, cash) and an expected card settlement (gross 890.10, the
      provider, the terminal batch reference); when the statement shows 600.00 and 876.75 (890.10 less fees 13.35),
      payments matches both, posts the fees (its PAY-24) and clears cash_in_transit and card_clearing; a retry is a
      replay on the batch and settlement refs; pos never writes pay_ tables and never posts the bank side
    owner: payments
    reference: not built
  - id: POS-27
    kind: integration
    actor: system
    trigger: when the stock consumption of a receipt is dispatched (POS-16)
    story: Stock leaves inventory through inventory's route, which owns the move, the lot and the cost.
    given: a committed receipt with recipe components
    when: pos sends the consumption
    then: inventory records goods issues of purpose sale_consumption from the shop's consumption location, values them and posts the cost of sales itself; pos keeps the move ids it is given and never writes inv_ tables
    owner: inventory
    reference: not built
  - id: POS-28
    kind: integration
    actor: system
    trigger: at sync of each receipt, and for each refund
    story: Tax recomputes the VAT of every receipt, so that the day's VAT is tax's answer, and a refund uses the original's code and rate.
    given: a receipt with lines, categories and the shop's country
    when: pos calls tax
    then: tax answers per line tax_code, rate_pct, net, vat; pos stores the answer as its copy and compares it with what was printed (POS-18); a refund line is sent with pinned_from the original receipt's document_ref
    owner: tax
    reference: not built
  - id: POS-29
    kind: integration
    actor: system
    trigger: when a day or a supplement posts
    story: The day's journal posts to gl.
    given: a ready shop day
    when: pos posts
    then: gl posts it, numbers it and answers replay for the same body; pos stores the journal id on the day
    owner: gl
    reference: not built
  - id: POS-30
    kind: integration
    actor: system
    trigger: when a register opens a session, and on read
    story: The register's catalogue (products, prices, tax categories) and the tax rates it sells with offline come from product and tax, as dated snapshots.
    given: 'LON-SOHO on business date 2026-11-14'
    when: R1 opens a session
    then: >-
      pos builds the register snapshot from product (each product on the layout, its sales unit, tax category and price on
      the shop's price list for the business date) and tax (the rate per category for the shop's country on that date);
      the snapshot is versioned and its digest printed on the Z report; a price or rate dated tomorrow is in tomorrow's
      snapshot, never today's
    owner: product
    reference: not built
  - id: POS-31
    kind: integration
    actor: system
    trigger: when the recipe source is manufacturing and a receipt line needs its components
    story: The recipe comes from manufacturing's bill of materials (usage sale) when manufacturing is installed.
    given: 'manufacturing installed; BOM LATTE-REG usage sale version 2 valid from 2026-12-01'
    when: a latte is sold on 2026-12-02
    then: pos explodes it through manufacturing's route for the business date and freezes the BOM id and version on the receipt line; manufacturing never calls pos; with manufacturing absent pos uses its own recipe (POS-15)
    owner: manufacturing
    reference: not built
  - id: POS-32
    kind: integration
    actor: system
    trigger: at session open and at PIN sign-in
    story: A cashier is a person employed on the business date, read from hr.
    given: 'barista C leaves on 2026-11-30 (hr)'
    when: barista C signs in on 2026-12-01
    then: the PIN is refused ("not employed on 2026-12-01"); pos keeps only the PIN hash and the person_id, never a copy of the person
    owner: hr
    reference: not built
  # ── extension points and migration ───────────────────────────────────────────────────────
  - id: POS-33
    kind: person
    actor: cashier
    story: As a barista, I want to add a stamp to a customer's loyalty card and redeem a free drink after nine, so that regulars come back, without the till knowing how the programme works.
    given: 'the loyalty extension installed with programme STAMP-9 (one stamp per hot drink, the tenth free); card LC-0045 holds 9 stamps'
    when: 'I scan LC-0045 on a receipt with one LATTE-REG'
    then: >-
      the extension answers a 100% line discount with reason LOYALTY on the latte and pos records it like any discount
      (revenue net, IFRS 15: the free drink is a material right whose value was deferred by the extension when it issued
      the stamps); the extension is told of the finalised receipt only after it commits, from a pending row; with no
      extension installed, scanning a card does nothing and the till works as before
    screen: Till
    reference: not built
    customer_varies: whether to defer revenue for stamps (IFRS 15 B39-B43) or treat them as immaterial
  - id: POS-34
    kind: person
    actor: owner
    story: As the owner moving off Odoo, I want the closed sessions and their orders carried and reconciled per shop and day, so that I can sign that the history is complete.
    given: 'Odoo 17 point_of_sale with pos.config LON-SOHO, 2025 and 2026 sessions closed, gl''s migration signed'
    when: the migration loads, reconciles and I sign
    then: >-
      every closed pos.session becomes a closed session with its receipts (read-only history, migrated receipt_id
      odoo:<db>:pos.order:<id>), the day totals per shop and date equal Odoo's to the penny, and open sessions are refused
      (close them in Odoo first); I sign only when every check is equal or explained
    screen: Migration
    reference: not built
  - id: POS-35
    kind: person
    actor: owner
    story: As the owner, I want the controls on the people who handle cash held by me, so that a shift lead cannot raise their own limits or switch off the blind close.
    given: 'difference_threshold 5.00, cash_refund_limit 20.00, blind_close on, discount limits per role'
    when: a shift lead tries to raise difference_threshold to 50.00; I change it to 10.00 from 2026-12-01
    then: the shift lead is refused (pos:settings:controls); my change is a dated row starting after today; a session counted on 2026-11-30 uses 5.00
    screen: Settings
    reference: not built

processes:
  - key: shop_day
    pcf_id: null                       # APQC 3.5 retail point-of-sale transactions: id to confirm
    pcf_name: Process point-of-sale transactions and close the shop day
    reference_names: ["Store operations", "POS to cash"]
    brick: pos
  - key: refund
    pcf_id: null
    pcf_name: Process point-of-sale returns and refunds
    reference_names: ["Store returns"]
    brick: pos

workflows:
  - entity: session
    states: [open, counted, closed, force_closed, posted]
    terminal: [posted]
    transitions:
      - { key: open_session, name: Open the session with a float, from: none, to: open, by: cashier, gate: "no open session on the register; the fiscal device ready where the profile needs one; the cashier employed today (hr, when installed); the float counted", effect: "business date fixed (business_timezone and day_cutoff); register snapshot built (POS-30)" }
      - { key: record_cash_move, name: Record a safe drop or paid-out, from: open, to: open, by: cashier, gate: "amount positive, NUMERIC(18,2); a paid-out above paid_out_limit approved by another person" }
      - { key: count_session, name: Count the drawer, from: open, to: counted, by: cashier, gate: "every receipt synced (the register's last number equals the server's)", effect: "difference = counted - expected; within the threshold it closes at once" }
      - { key: approve_difference, name: Approve the difference, from: counted, to: closed, by: shift_lead, approval: true, gate: "the approver is not the counter (email AND sub)", requires: reason }
      - { key: force_close_session, name: Force the session closed, from: open, to: force_closed, by: shift_lead, approval: true, requires: reason, effect: "the claimed gap of receipt numbers recorded; late receipts accepted into this session only" }
      - { key: post_session, name: Posted with the shop day, from: [closed, force_closed], to: posted, by: system, gate: "the shop day posted (POS-20)" }
    readiness:
      - { transition: open_session, gate: one_open_session, reason: "the register already has session {session_id} open", fix: "close it first" }
      - { transition: open_session, gate: fiscal_device_ready, reason: "the register's TSE or signature device is not registered", fix: "Shops: register the device" }
      - { transition: count_session, gate: all_synced, reason: "the register holds receipts the server has not received: {from} to {to}", fix: "bring the register online, or a shift lead force-closes" }
      - { transition: approve_difference, gate: second_person, reason: "you counted this session", fix: "ask another shift lead" }
    note: "a session never reopens; a correction is a later document in a later session"
  - entity: receipt
    states: [open, finalised, quarantined, voided]
    terminal: [finalised, voided]
    transitions:
      - { key: ring_up, name: Ring up lines, from: none, to: open, by: cashier, gate: "the session open; the product on the snapshot" }
      - { key: take_payment, name: Take a payment line, from: open, to: open, by: cashier, gate: "a card line authorised; a voucher line claimed; amounts NUMERIC(18,2)" }
      - { key: void_open_receipt, name: Void before finalising, from: open, to: voided, by: cashier, effect: "kept as a void record with its lines (fiscal law counts voids); card lines reversed on the terminal; v2: the record is the receipt in state voided (was to: none, which left the record no state)" }
      - { key: finalise_receipt, name: Finalise and print, from: open, to: finalised, by: cashier, gate: "payment lines sum exactly to the gross; the fiscal signature returned", effect: "receipt number claimed gapless per register; outbox rows for stock, tax, loyalty written in the same transaction" }
      - { key: quarantine_receipt, name: Quarantine a conflicting sync, from: none, to: quarantined, by: system, gate: "a known receipt_id arrives with a different digest" }
      - { key: resolve_quarantine, name: Resolve the conflict, from: quarantined, to: finalised, by: accountant, requires: reason, effect: "the accepted version is the receipt; the other kept as evidence; never two receipts" }
  - entity: shop_day
    states: [open, ready, posted, stale, post_failed]
    transitions:
      - { key: derive_day_ready, name: Day ready, from: open, to: ready, by: system, gate: "every session of the shop and date closed or force-closed; differences approved" }
      - { key: post_day, name: Post the day, from: [ready, post_failed], to: posted, by: [system, accountant], gate: "tax agrees or the accountant accepted the printed amounts; the gl period open", effect: "one summary journal (POS-20)" }
      - { key: fail_day_post, name: Posting refused, from: ready, to: post_failed, by: system, effect: "gl's gate and reason kept" }
      - { key: mark_day_stale, name: A late receipt arrived, from: posted, to: stale, by: system, effect: "supplement n+1 pending" }
      - { key: post_supplement, name: Post the supplement, from: stale, to: posted, by: system, effect: "supplement journal for the difference only" }
    readiness:
      - { transition: post_day, gate: tax_agrees, reason: "receipts whose printed VAT differs from tax's answer: named", fix: "Shop day: accept the printed amount with a reason" }
      - { transition: post_day, gate: period_open, reason: "the gl period of the business date is closed", fix: "ask finance to post into the next open period or reopen" }
      - { transition: post_day, gate: reviewer_not_approver, reason: "you approved a difference of this day", fix: "ask another accountant" }
    note: "stale -> posted is the supplement, never a repost: the posted journal is not touched"
  - entity: cash_deposit
    states: [sealed, handed_over, matched]
    terminal: [matched]
    transitions:
      - { key: seal_bag, name: Seal the cash bag, from: none, to: sealed, by: shift_lead, gate: "amount at most the safe's balance; bag number unused" }
      - { key: hand_over_bag, name: Hand the bag to payments, from: sealed, to: handed_over, by: system, effect: "payments' expected deposit batch (POS-26)" }
      - { key: match_bag, name: Matched to the bank, from: handed_over, to: matched, by: system, gate: "payments reports the batch matched" }
  - entity: pos_migration_run
    states: [loaded, reconciled, signed, discarded]
    terminal: [signed, discarded]
    transitions:
      - { key: load_pos_from_odoo, name: Load Odoo's sessions and orders, from: none, to: loaded, by: system, gate: "gl, product and tax migrations signed; no open pos.session", effect: "v2 (pattern §11, seemo's decided answer 4): staging rows only (pos_migration_row, keyed by the source id); nothing written to the ledger, balances or journals" }
      - { key: reconcile_pos_migration, name: Reconcile against Odoo, from: loaded, to: reconciled, by: system }
      - { key: sign_pos_reconciliation, name: Sign the reconciliation, from: reconciled, to: signed, by: owner, approval: true, gate: "every check equal or explained", effect: "v2 (decided answer 4): the staged rows written through this module's normal use cases, idempotent on the source key; the first write to the ledger; a signed run is later reversed by reversing entries, never deleted" }
      - { key: discard_pos_migration, name: Discard the run, from: [loaded, reconciled], to: discarded, by: owner, requires: reason, effect: "v2 (decided answer 4): the run's staging rows deleted, nothing else (no ledger row was written)" }

entities:
  - id: shop
    meaning: a place that sells, belonging to one company and drawing stock from one warehouse
    natural_key: shop_id VARCHAR (the customer's code, LON-SOHO)
    key_attributes: [name, company_code (gl), warehouse_id (inventory), consumption_location_id (inventory), price_list_id (product), fiscal_profile, country (ISO 3166), cost_centre (gl), day_cutoff_time, status (active | closed)]
    relationships: ["-> company (gl)", "-> warehouse (inventory)", "-> register"]
    master_data: SHARED, sole writer (payments and reports name shop_id)
    reference_table: pos_shop
  - id: register
    meaning: one till in a shop, with its own receipt series, card terminal and fiscal device
    natural_key: [shop_id, register_id]
    key_attributes: [terminal_id, fiscal_device_serial, fiscal_device_status, receipt_series_last, z_series_last, grand_total (perpetual, NF525 and RKSV), status (active | retired)]
    master_data: SHARED, sole writer
    reference_table: pos_register
  - id: session
    meaning: one cashier's time on one register between opening the drawer and counting it
    natural_key: session_id = "<shop_id>-<register_id>-<session_no>"
    key_attributes: [business_date, opened_by, opened_sub, opened_at, opening_float, counted_cash, expected_cash, difference, approved_by, approved_sub, reason, status, last_receipt_no, claimed_gap_from, claimed_gap_to, snapshot_digest]
    master_data: module-owned
    reference_table: pos_session
  - id: receipt
    meaning: one sale or refund as printed for the customer; the legal document
    natural_key: receipt_id = "<session_id>-<receipt_no>" (receipt_no gapless per register; migrated odoo:<db>:pos.order:<id>)
    key_attributes: [kind (sale | refund), original_receipt_id, business_date, eat_in (bool), bp_id, gross, net, vat, digest, fiscal_json, status, cashier_person_id, created_offline (bool), synced_at]
    relationships: ["-> session", "-> receipt (refund -> original)", "-> business_partner (optional)"]
    master_data: module-owned
    reference_table: pos_receipt
  - id: receipt_line
    meaning: one product sold or refunded, with the price, discount, tax and the recipe it consumed
    natural_key: [receipt_id, line_no]
    key_attributes: [product_id, quantity, unit_code, unit_price (NUMERIC 19,4), discount_pct, discount_reason, line_gross, tax_category, tax_code, rate_pct, vat, recipe_source (pos | manufacturing), recipe_ref, recipe_version, original_line_no, kind (product | voucher_sale)]
    master_data: module-owned
    reference_table: pos_receipt_line
  - id: payment_line
    meaning: one tender on a receipt (cash, card, voucher), with its tip
    natural_key: [receipt_id, pay_no]
    key_attributes: [method (cash | card | voucher), amount, tip_amount, change_amount, terminal_txn_ref, terminal_status (pending | authorised | declined | reversed), voucher_code, idempotency_key ("<receipt_id>:<pay_no>")]
    master_data: module-owned
    reference_table: pos_payment_line
  - id: refund_claim
    meaning: the row two refunds race for, so a line is never refunded past what was sold
    natural_key: [original_receipt_id, line_no, claim_no]
    key_attributes: [refund_receipt_id, quantity, refunded_after]
    master_data: "module-owned (append-only; a plain INSERT, the loser gets 23505, re-reads and is refused or retries)"
    reference_table: pos_refund_claim
  - id: voucher
    meaning: a gift voucher; money we owe until it is spent or expires
    natural_key: voucher_code VARCHAR (GV-UK-000812)
    key_attributes: [company_code, currency, face_value, balance (derived from claims, cached), profile (single_purpose | multi_purpose), issued_receipt_id, expires_on, status]
    master_data: module-owned
    reference_table: pos_voucher
  - id: voucher_claim
    meaning: one redemption, reload or refund-to-voucher of a voucher, in order
    natural_key: [voucher_code, claim_no]
    key_attributes: [operation (issue | redeem | refund_to_voucher | expire), receipt_id, pay_no, amount, balance_after]
    master_data: "module-owned (append-only claim rows; the operation is part of what is claimed)"
    reference_table: pos_voucher_claim
  - id: cash_move
    meaning: cash put in or taken out of a drawer other than by a sale
    natural_key: cash_move_id = "<session_id>-cm<seq>"
    key_attributes: [kind (safe_drop | paid_in | paid_out | float_top_up), amount, reason_code, receipt_image_key, approved_by, approved_sub]
    master_data: module-owned
    reference_table: pos_cash_move
    rule: "v2, how each kind posts in the shop day (gl's pos purposes, none new): safe_drop Dr pos_cash_in_safe / Cr pos_cash_in_till; float_top_up Dr pos_cash_in_till / Cr pos_cash_in_safe; paid_out Dr the reason's purpose / Cr pos_cash_in_till; paid_in Dr pos_cash_in_till / Cr the reason's purpose; a reason's purpose (pos_cash_reason) is one of gl's pos purposes, default shop_supplies"
  - id: cash_deposit
    meaning: a sealed bag of cash on its way to the bank
    natural_key: deposit_ref = "dep:pos:<shop_id>:<bag_no>"
    key_attributes: [amount, currency, denominations_json, sealed_by, sealed_at, bank_account_id (payments), status]
    master_data: module-owned
    reference_table: pos_cash_deposit
  - id: shop_day
    meaning: a shop's trading for one business date, as posted to the ledger
    natural_key: [shop_id, business_date]
    key_attributes: [status, journal_id, supplement_count, posted_at, digest]
    master_data: module-owned
    reference_table: pos_shop_day
  - id: recipe
    meaning: what one product consumes when sold, when manufacturing is not installed
    natural_key: [product_id, version]
    key_attributes: [valid_from, lines (component product_id, quantity, unit_code), modifiers (modifier product_id, replaces, adds)]
    master_data: "module-owned; read-only while recipe_source is manufacturing"
    reference_table: pos_recipe
  - id: layout
    meaning: the buttons a register shows, per shop group, versioned
    natural_key: [layout_id, version]
    key_attributes: [shops, buttons (product_id, category, position, modifiers)]
    master_data: module-owned
    reference_table: pos_layout
  - id: register_snapshot
    meaning: the dated catalogue, prices and tax rates a register sells with, offline or not
    natural_key: [shop_id, business_date, snapshot_no]
    key_attributes: [digest, products_json, rates_json, built_at]
    master_data: "module-owned (a copy of product's and tax's answers on a date, never edited)"
    reference_table: pos_register_snapshot
  - id: outbox
    meaning: a side effect waiting to go to a neighbour after the local commit
    natural_key: [target, ref]
    key_attributes: [payload, status (pending | sent | failed), attempts, last_error, sent_at]
    master_data: module-owned
    reference_table: pos_outbox
  - id: settings
    meaning: the controls and the defaults, dated
    natural_key: [key, valid_from]
    key_attributes: [value, set_by]
    master_data: module-owned
    reference_table: pos_settings

master_data:
  owns: [pos_shop, pos_register]
  references:
    - entity: product
      owner: product
      how: "product_id on every line and button, read through GET /products/{product_id}?on= and GET /products/{product_id}/price"
      if_absent: "no till: pos keeps no item master"
    - entity: tax_code
      owner: tax
      how: "tax_code and rate on each line, from tax's compute answer (and the register snapshot)"
      if_absent: "no receipt: a till cannot print VAT it did not get from tax"
    - entity: company
      owner: gl
      how: "company_code on the shop, GET /companies/{company_code}"
      if_absent: "no posting"
    - entity: warehouse
      owner: inventory
      how: "warehouse_id and consumption_location_id on the shop"
      if_absent: "sales do not move stock; recipes are kept but not consumed"
    - entity: business_partner
      owner: business-partner
      how: "bp_id on a receipt for a named customer only"
      if_absent: "every receipt is a simplified invoice"
    - entity: person
      owner: hr
      how: "person_id on the cashier's PIN record"
      if_absent: "cashiers are pool users with a PIN, and a leaver's PIN is disabled by hand"
  warning: >-
    A till that keeps its own copy of the price list, the VAT rates or the item master is how a shop sells at last
    month's price and the books disagree with the shelf. pos keeps only dated snapshots of product's and tax's answers,
    each with its digest on the Z report.

jobs:
  - story: POS-16
    trigger: after each receipt or sync batch commits
    runs: dispatch pending outbox rows (stock consumption, tax recompute, loyalty, payments hand-overs)
    where_options: [in the same request after commit, always-on worker (--reserved 1 --max-replicas 1)]
    idempotent: "each row's ref is the neighbour's idempotency key (move_ref, document_ref, journal_id, batch ref); a re-send is the neighbour's replay"
  - story: POS-20
    trigger: when a shop day is ready
    runs: post the day's summary journal
    where_options: [in the request that closes the last session, the composition's worker]
    idempotent: 'journal_id = derived_id("pos:<shop_id>:<business_date>", "day_close")'
  - story: POS-21
    trigger: when a receipt arrives for a posted day
    runs: mark the day stale, post supplement n+1
    where_options: [in the sync request after commit, the composition's worker]
    idempotent: 'supplement n claimed with a plain INSERT on (shop_id, business_date, supplement_no); journal_id derived_id("pos:<shop_id>:<business_date>", "supplement:<n>")'
  - story: POS-24
    trigger: before a receipt prints; at session and day close
    runs: fiscal signature through the shop's profile capability
    where_options: [on the register (offline mode), in the finalise request]
    idempotent: "the capability is called with the receipt_id; a repeat returns the same signature"

seams:
  - story: POS-26
    with: payments
    owner: payments
    route: "POST /deposit-batches/expected"
    permission: pay:deposit_batch:intake
    request: '{batch_ref: "dep:pos:<shop_id>:<bag_no>", kind: cash, bank_account_id, company_code, currency, amount (string, NUMERIC 18,2), expected_on (YYYY-MM-DD), clearing_purpose: cash_in_transit, source_module: pos}'
    request_schema:
      - { field: batch_ref, type: string, required: true }
      - { field: kind, type: string, required: true, values: [cash] }
      - { field: bank_account_id, type: string, required: true }
      - { field: company_code, type: company_code, required: true }
      - { field: currency, type: currency, required: true }
      - { field: amount, type: decimal, required: true }
      - { field: expected_on, type: date, required: true }
      - { field: clearing_purpose, type: string, required: true, values: [cash_in_transit] }
      - { field: source_module, type: string, required: true, values: [pos] }
    response: '201 {batch_id (= batch_ref), status: deposited, total}; 200 the same for the same batch_ref; 422 {gate: bank_account_receives | bounds, reason, fix}'
    rule: "pos hands over the bag; payments matches the statement credit to the batch (its PAY-28 deposit batch) and posts bank against cash_in_transit; pos never writes pay_ tables"
    status: "satisfied by payments v6 (PAY-49, expect_deposit_batch)"
  - story: POS-26
    with: payments
    owner: payments
    route: "POST /card-settlements/expected"
    permission: pay:card_settlement:intake
    request: '{settlement_ref: "card:pos:<shop_id>:<terminal_batch_ref>", provider, company_code, currency, gross (string, NUMERIC 18,2), business_date, transaction_count (int), source_module: pos}'
    request_schema:
      - { field: settlement_ref, type: string, required: true }
      - { field: provider, type: string, required: true }
      - { field: company_code, type: company_code, required: true }
      - { field: currency, type: currency, required: true }
      - { field: gross, type: decimal, required: true }
      - { field: business_date, type: date, required: true }
      - { field: transaction_count, type: integer, required: true }
      - { field: source_module, type: string, required: true, values: [pos] }
    response: '201 {settlement_ref, status: expected}; 200 the same for the same settlement_ref; 422 {gate: provider_known | bounds, reason, fix}'
    rule: "payments matches the provider's net credit to the expected gross less the fees, posts the fees (PAY-24) and clears card_clearing; a difference beyond the fee stays open on payments' statement screen"
    status: "satisfied by payments v6 (PAY-50, card_settlement)"
  - story: POS-26
    with: payments
    owner: payments
    route: "GET /bank-accounts/default?company_code=&currency="
    permission: pay:receipt:read
    request: "query company_code, currency"
    rule: "the account a shop's cash bag is paid into, unless the shop names one"
    status: "exists in payments v5 (PAY-47)"
  - story: POS-27
    with: inventory
    owner: inventory
    route: "POST /goods-issues/from-source"
    permission: inv:issue:intake
    request: '{source_kind: pos_sale, source_id: receipt_id, company_code, location_id (the shop''s consumption location), issued_on (the business date), lines: [{move_ref: "pos:<receipt_id>:<line_no>:<component_no>", product_id, quantity (string), unit_code, lot_code (optional)}]}'
    request_schema:
      - { field: source_kind, type: string, required: true, values: [pos_sale] }
      - { field: source_id, type: string, required: true }
      - { field: company_code, type: company_code, required: true }
      - { field: location_id, type: location_id, required: true }
      - { field: issued_on, type: date, required: true }
      - { field: lines, type: array, required: true, items: [{ field: move_ref, type: string, required: true }, { field: product_id, type: product_id, required: true }, { field: quantity, type: decimal, required: true }, { field: unit_code, type: unit_code, required: true }, { field: lot_code, type: lot_code, required: false }] }
    response: "201 {issue_id, moves: [{move_ref, move_id, quantity_base, value}]}; 200 the same for known move_refs; 422 {gate: units_exact | bounds, reason, fix}"
    rule: "inventory owns the move, its lot (FEFO when none is named) and its valuation, and posts cost of sales itself; idempotent on move_ref; a sale is never refused for stock (inventory INV-58)"
    status: "satisfied by inventory v6 (INV-58, issue_from_source)"
  - story: POS-11
    with: inventory
    owner: inventory
    route: "POST /receipts"
    permission: inv:receipt:intake
    request: '{move_ref: "pos:<refund_receipt_id>:<line_no>:<component_no>", source_kind: pos_refund_restock, source_id: refund receipt_id, company_code, product_id, quantity (string), unit_code, location_id, received_on (the business date), reverses_move_ref: "pos:<original receipt_id>:<line_no>:<component_no>", lot_code (optional)}'
    request_schema:
      - { field: move_ref, type: string, required: true }
      - { field: source_kind, type: string, required: true, values: [pos_refund_restock] }
      - { field: source_id, type: string, required: true }
      - { field: company_code, type: company_code, required: true }
      - { field: product_id, type: product_id, required: true }
      - { field: quantity, type: decimal, required: true }
      - { field: unit_code, type: unit_code, required: true }
      - { field: location_id, type: location_id, required: true }
      - { field: received_on, type: date, required: true }
      - { field: reverses_move_ref, type: string, required: true }
      - { field: lot_code, type: lot_code, required: false }
    response: "201 {move_id, quantity_base, value}; 200 the same for a repeated move_ref; 422 {gate: reverses_known | within_issued, reason, fix}"
    rule: "a restocked refund only (the cashier says the item goes back on the shelf); valued by inventory at the cost the reversed move left at; a refund not restocked sends nothing"
    status: "satisfied by inventory v6 (INV-59, receive_from_source)"
  - story: POS-28
    with: tax
    owner: tax
    route: "POST /documents/compute"
    permission: tax:document_tax:compute
    request: '{document_ref: "pos:<receipt_id>", direction: sale, tax_point: business_date, currency, treatment_code: uk_consumer (tax''s walk_in_treatment_code; an account customer''s with bp_id), bp_id (optional), prices_include_tax: true, ship_to_country: the shop''s country, lines: [{line_no, category_code, supply: goods, consumption (eat_in | takeaway, where the category''s rules distinguish it), amount, quantity, unit_price, pinned_from (a refund: "pos:<original receipt_id>"), tax_code (with pinned_from)}]}'
    rule: "pos stores the answer as its copy; a refund pins the original's code and rate"
    status: "exists in tax v5 (TAX-23); compute without bp_id (TAX-53) and the line consumption (TAX-51) satisfied by tax v6"
  - story: POS-30
    with: tax
    owner: tax
    route: "GET /rates?country=&categories=&on=&consumption=&treatment_code="
    permission: tax:code:read
    request: "query country (ISO 3166), categories (comma-separated tax categories), on (the business date), consumption (eat_in | takeaway, optional)"
    rule: "the register's offline rate snapshot; the receipt is recomputed by POST /documents/compute at sync"
    response: "200 {country, on, treatment_code, valid_until, rates: [{category_code, supply, consumption, tax_code, rate_pct, rule_valid_from, rate_valid_from}], missing: [category_code]}"
    status: "satisfied by tax v6 (TAX-52); the snapshot is refreshed before valid_until"
  - story: POS-30
    with: product
    owner: product
    route: "GET /products/{product_id}?on="
    permission: product:product:read
    request: "path product_id; query on (the business date)"
    rule: "name, sales unit, tax category, sellable for each layout button"
    status: "exists in product v5 (PRD-28)"
  - story: POS-30
    with: product
    owner: product
    route: "GET /products/{product_id}/price?quantity=&unit_code=&bp_id=&currency=&priced_on="
    permission: product:price_list:read
    request: "query quantity 1, unit_code the sales unit, currency the shop's, priced_on the business date"
    rule: "one product's price; the snapshot uses the whole list (GET /price-lists/{price_list_id}/prices, product v6 PRD-40) and this route only for a product added since"
    status: "exists in product v5 (PRD-31)"
  - story: POS-30
    with: product
    owner: product
    route: "GET /price-lists/{price_list_id}/prices?on=&limit=&offset="
    permission: product:price_list:read
    request: "path price_list_id; query on (the business date), limit (1 to 1000), offset"
    response: "200 {price_list_id, currency, prices_include_tax, on, next_change_on, prices: [{product_id, unit_code, from_quantity, unit_price, pricing_quantity, pricing_unit_code, valid_from, valid_to, priced_from}], more}"
    rule: "one read for a whole shop's prices instead of one per button (a layout has 80 to 200 buttons)"
    status: "satisfied by product v6 (PRD-40); the snapshot goes stale at next_change_on"
  - story: POS-15
    with: product
    owner: product
    route: "GET /products/{product_id}/convert?quantity=&from_unit=&to_unit="
    permission: product:product:read
    request: "query quantity (string), from_unit, to_unit"
    rule: "a recipe quantity converts exactly to the component's base unit or is refused"
    status: "exists in product v5 (PRD-21)"
  - story: POS-29
    with: gl
    owner: gl
    route: "POST /journal-entry/journals"
    permission: gl:journal:post
    request: '{journal_id: derived_id(source_document, event), source_module: pos, source_document: "pos:<shop_id>:<business_date>" (event day_close) | "pos:<shop_id>:<business_date>:supplement:<n>" (event supplement; v2, so each supplement has its own id), event: day_close | supplement, journal_date, currency, company_code, memo, lines: [{purpose: revenue | tax | card_clearing | voucher_liability | tips_payable | cash_over_short | pos_cash_in_till | pos_cash_in_safe | cash_in_transit | shop_supplies | tax_rounding, tax_code, cost_centre, debit, credit}]}'
    rule: "one balanced journal per shop and day (v2: the shop day is the only journal; a session is never posted on its own: post_session follows the day), then one per supplement; pos never writes gl_ tables"
    status: "satisfied by gl v7 (GL-30 row for source_module pos with these purposes)"
  - story: POS-29
    with: gl
    owner: gl
    route: "GET /periods/open"
    permission: gl:period:read
    request: "query date (the business date), company_code"
    rule: "the gate period_open before a day posts"
    status: "exists in gl v6 (GL-32)"
  - story: POS-29
    with: gl
    owner: gl
    route: "GET /companies/{company_code}"
    permission: gl:company:read
    request: "path company_code"
    rule: "the shop's company and functional currency; v2: business_timezone is read here (gl v9 answers the bucket's one value) at each request that needs it, never stored and never a pos_settings row (as consolidation reads it), until the platform serves it itself (shared.yaml conventions.business_timezone)"
    status: "exists in gl v6 (GL-61)"
  - story: POS-14
    with: business-partner
    owner: business-partner
    route: "GET /partners/{bp_id}"
    permission: bp:partner:read
    request: "path bp_id"
    rule: "name and VAT number (tax_id) on a full VAT invoice; business-partner keeps no postal address, so the cashier types the customer's address on the invoice request; pos never creates a partner"
    status: "exists in business-partner (BP-01)"
  - story: POS-31
    with: manufacturing
    owner: manufacturing
    route: "GET /boms/{product_id}/explode?usage=sale&quantity=&on="
    permission: mfg:bom:explode             # manufacturing's own name for it (MFG-30)
    request: "path product_id; query usage sale, quantity (string), unit_code, modifiers (comma-separated product ids), on (the business date)"
    rule: "manufacturing's MFG-30 route; pos freezes bom_id and version on the receipt line"
    status: "specified in manufacturing v1 (this wave)"
  - story: POS-14
    with: ar
    owner: ar
    route: POST /invoices/raise
    permission: ar:invoice:raise
    request: '{source_kind: pos, source_id: "pos:<receipt_id>", bp_id (the account customer), currency, kind: regular, customer_po_ref (optional), lines: [{description, quantity (string), unit_code, unit_price, discount_pct, tax_category, product_id}]}'
    response: '201 the invoice {invoice_id, status: draft | issued, lines, tax, totals}; 200 the same for the same source_id'
    rule: "extension on_account only: a till sale charged to an account customer is invoiced by ar and collected by payments; not used by a v1 shop that takes every payment at the till"
    status: "satisfied by ar v7 (AR-20 row for pos)"
  - story: POS-32
    with: hr
    owner: hr
    route: "GET /persons/{person_id}/employment?on="
    permission: hr:employment:read          # to be added by hr v2 (machine)
    request: "path person_id; query on (the business date)"
    response: "200 {person_id, on, employed, engagement_id, worker_type, company_code, org_unit_id, location_code, cost_centre, bp_id}"
    rule: "employed on the date or not, and the work location; pos reads nothing else"
    status: "satisfied by hr v2 (HR-56, employment on a date)"

tool_names:                            # shared.yaml conventions.tool_names (2026-10-25): the migration family's names, so this brick composes beside the others; the transitions keep their keys
  - { tool: load_pos_from_odoo, route: "the pos_migration_run workflow's transition (route chosen at build)" }
  - { tool: reconcile_pos_migration, route: "the pos_migration_run workflow's transition (route chosen at build)" }
  - { tool: discard_pos_migration, route: "the pos_migration_run workflow's transition (route chosen at build)" }
  - { tool: get_pos_migration_map, route: "GET /migration-map/{db}/{model}" }   # the one map route, shared.yaml conventions.odoo_migration.map_route

screens:
  - { id: till, name: Till, stories: [POS-03, POS-04, POS-05, POS-08, POS-09, POS-10, POS-11, POS-12, POS-13, POS-14, POS-33], actors: [cashier, shift_lead], note: "the register app: one tap per product, modifiers, tenders, the terminal's status, offline banner with the count of receipts waiting" }
  - { id: shop_day, name: Shop day, stories: [POS-06, POS-07, POS-17, POS-18, POS-19], actors: [shift_lead, accountant], note: "sessions with their counts and differences; the Z reports; the readiness panel {gate, met, reason, fix}; cash bags" }
  - { id: shops, name: Shops, stories: [POS-01], actors: [retail_ops] }
  - { id: layout, name: Layout, stories: [POS-02], actors: [retail_ops] }
  - { id: recipes, name: Recipes, stories: [POS-15], actors: [retail_ops], note: "read-only when the recipe source is manufacturing" }
  - { id: reports, name: Reports, stories: [POS-25], actors: [accountant, retail_ops, shift_lead], note: "sales by shop, hour, product and reason; discounts; voids; fiscal exports" }
  - { id: settings, name: Settings, stories: [POS-35], actors: [retail_ops, owner], note: "every config_row below lives here; the controls to the owner only" }
  - { id: migration, name: Migration, stories: [POS-34], actors: [owner, accountant] }

surfaces:
  private:
    app: "the composition's staff app for Shop day, Shops, Layout, Recipes, Reports, Settings, Migration, behind the dodil-appid pool"
    register: >-
      the Till is a separate client on the register (a tablet), signed in as the shop's register service client
      (client_credentials, scoped to one shop) with cashiers switching by PIN on top; it holds the snapshot and the
      unsynced receipts locally and talks only to pos's sync route, never to a neighbour and never to DataK3
  public: "none: a receipt's QR code carries the fiscal fields, not a link"

permissions:
  namespace: pos
  catalog:
    - pos:shop:read
    - pos:shop:write
    - pos:register:write
    - pos:layout:write
    - pos:recipe:write
    - pos:session:open
    - pos:session:count
    - pos:session:approve_difference
    - pos:session:force_close
    - pos:receipt:sell
    - pos:receipt:refund
    - pos:discount:apply
    - pos:override:approve
    - pos:cash:move
    - pos:voucher:sell
    - pos:deposit:prepare
    - pos:report:read
    - pos:day:review
    - pos:day:post
    - pos:settings:write
    - pos:settings:controls      # thresholds, limits, blind close, offline limits, fiscal profiles; never held by whoever they control
    - pos:migration:read
    - pos:migration:sign
    - pos:receipt:ingest         # machine: the register's sync
    - pos:fiscal:sign            # machine
    - pos:outbox:dispatch        # machine
    - pos:day:derive             # machine
    - pos:number_series:claim    # machine
    - pos:migration:load         # machine
    - pos:migration:reconcile    # machine
  machine:
    - pos:receipt:ingest
    - pos:fiscal:sign
    - pos:outbox:dispatch
    - pos:day:derive
    - pos:number_series:claim
    - pos:migration:load
    - pos:migration:reconcile
  row_visibility:
    rule: "a cashier and a shift lead see their own shop (the shops on their pool user's shop list); retail operations, accountants and the owner see every shop"
    enforced: "in every use case, from the caller's shop list; a register client is scoped to one shop"
    not_found_not_forbidden: true

config_rows:
  - table: pos_settings
    changes: "how cash is handled; every row dated (valid_from after today; the first row of a key may start today)"
    settings:
      - { key: difference_threshold,  type: decimal, default: "5.00",  changes: "a cash difference above this needs a second person; pos:settings:controls" }
      - { key: blind_close,           type: bool,    default: true,    changes: "the cashier counts before seeing the expected amount; pos:settings:controls" }
      - { key: cash_refund_limit,     type: decimal, default: "20.00", changes: "a cash refund above this needs a shift lead; pos:settings:controls" }
      - { key: paid_out_limit,        type: decimal, default: "50.00", changes: "a paid-out above this needs a shift lead; pos:settings:controls" }
      - { key: voucher_offline_limit, type: decimal, default: "0.00",  changes: "vouchers redeemable offline up to this (risk: a voucher spent twice in two offline shops); pos:settings:controls" }
      - { key: offline_card_mode,     type: str,     default: "terminal", changes: "terminal (the terminal's store-and-forward) | refuse (cards only online)" }
      - { key: day_cutoff_time,       type: str,     default: "04:00", changes: "sales before this time belong to the previous business date" }
      - { key: auto_post_day,         type: bool,    default: true,    changes: "post a day on its own when every gate is met" }
      - { key: recipe_source,         type: str,     default: "pos",   changes: "pos | manufacturing (set to manufacturing when it is installed)" }
      - { key: tips_treatment,        type: str,     default: "liability", changes: "liability (tips_payable, passed to staff) | revenue; see customer_varies" }
  - { table: pos_settings, changes: "each AI step's mode (ai_steps: off | suggest)" }
  - { table: pos_discount_limit, changes: "the largest discount each role may give, and the reasons (COMPLAINT, STAFF, LOYALTY)" }
  - { table: pos_cash_reason, changes: "paid-out and paid-in reasons and the purpose each posts to" }
  - { table: pos_fiscal_profile, changes: "per country: the fiscal capability, its outage rule, the receipt fields it prints, the simplified-invoice limit" }
  - { table: pos_voucher_profile, changes: "per company: single- or multi-purpose, validity months, breakage" }
  - { table: pos_category_rule, changes: "per product category: restock on refund, eat-in question shown" }
  - { table: pos_shop, changes: "the shops and their company, warehouse, price list and fiscal profile" }
in_code_not_rows:
  - "that a finalised receipt is never edited; a refund or correction is a new document naming it"
  - "that receipt numbers are gapless per register and a claimed gap is recorded, never filled with another receipt"
  - "that a receipt_id seen twice is one sale: the same digest is a replay, a different digest is quarantined"
  - "that payment lines sum exactly to the gross before a receipt finalises"
  - "that each operation on the same money has its own claim key (pay, refund, voucher redemption)"
  - "that neighbours are called only from pending outbox rows after the local commit"
  - "that the day journal balances exactly and has one line per purpose, tax code and cost centre"
  - "that a fiscal signature is stored as returned and the content it signed never changes"
  - "that no person approves their own difference, override, refund or day (email AND sub)"
  - "that 'today' is the business date in business_timezone with the day cutoff, never the UTC date"

ai_steps:
  - key: explain_cash_difference
    benefit: "a shift lead approving a difference sees the likely cause (a refund rung as cash but paid by card, a paid-out not recorded, change given from a voucher)"
    mode: suggest
    modes_allowed: ["off", suggest]
    safe_for_auto: false
    inputs: [the session's receipts, payment lines and cash moves, the count by denomination]
    output: "up to three likely causes with the receipts that point to them; the shift lead decides"
    fallback: "the shift lead reviews the session's receipts by hand"
    consent: "nothing is approved or changed by the step"
    audit: "pos_ai_finding: model, inputs digest, finding, decision, who, when"
    eval: "sessions with seeded differences of each kind; accuracy of the first cause"
    note: "an assistant beside the process, not a step in the signed lifecycle; runs on the platform's sovereign models"

extension_points:
  - { name: loyalty, what: "stamps, points, member prices: told of each finalised receipt from a pending row, answers line discounts at ring-up; pos holds no programme rules (POS-33)" }
  - { name: fiscal, what: "one capability per country profile (de_kassensichv, fr_nf525, at_rksv) with sign, close_day, export; a new country is a new capability and a config row" }
  - { name: tips_allocation, what: "tips per shop and business date allocated to the staff on shift and handed to payroll; pos only records and posts the liability" }
  - { name: on_account, what: "a named customer pays later: the receipt is raised as an invoice in ar (needs a pos source_kind in ar)" }
  - { name: kitchen_display, what: "orders shown to the bar or kitchen from finalised receipts; no change to the receipt" }
  - { name: demand_history, what: "GET /sales-history?product_id=&shop_id=&from=&to= for manufacturing's MRP-lite and inventory's replenishment (the consumption a shop's sales caused)" }

tests:
  acceptance: "one per story id above, named for it"
  access:
    - "a cashier cannot approve a difference, an override, a force-close, or post a day; and sees only their shop"
    - "a shift lead cannot approve a difference they counted, an override they asked for, or a day they approved differences in"
    - "an accountant cannot sell, refund or change settings"
    - "pos:settings:write cannot change a control; any role but the owner holding pos:settings:controls is refused at configuration"
    - "a register client of LON-SOHO cannot sync a receipt for BER-MITTE (404)"
    - "the machine permissions are refused to every person role and to a wildcard grant"
  invariants:
    - "claim keys: a sale's payment (pay:<receipt_id>:<pay_no>), a refund's payment (pay:<refund receipt_id>:<pay_no>), a refund's quantity (refund:<original receipt_id>:<line_no>:<claim_no>) and a voucher's redemption (voucher:<voucher_code>:<claim_no> with its operation) never share a key, so a refund is never answered as the sale's replay and two refunds of one line never both succeed"
    - "the card terminal's idempotency key is the payment line (<receipt_id>:<pay_no>), never the receipt; a sale and its refund on one card are two terminal transactions"
    - "side effects (inventory consumption, tax recompute, payments hand-overs, the gl journal, loyalty) are sent only from pending outbox rows written in the same transaction as the receipt or day; a crash between commit and dispatch sends them on the next run; a rolled-back receipt sends nothing"
    - "state machines never go backwards: a finalised receipt never reopens, a counted session never returns to open, a posted day changes only by a supplement, a sealed bag is never unsealed; each is tested by attempting the reverse transition"
    - "self-approval: a difference, an override, a refund above the limit, a force-close and a day review are refused to the person who caused them under a second identity (matched on email AND sub)"
    - "the business date is the date in business_timezone with day_cutoff_time: a sale at 2026-11-15 00:30 Europe/London belongs to 2026-11-14; a sale at 03:30 UTC on 2026-07-15 (04:30 BST) belongs to 2026-07-15, though the UTC clock is before the cutoff; the UTC date is never used"
    - "money bounded: an amount, tip, float or count that is negative where it cannot be, above NUMERIC(18,2), or with more decimals than the currency's minor unit answers 422 {gate: bounds}, never a 500"
    - "results marked stale: a late receipt marks its posted day stale and its Z report reissued; a price or rate change after a snapshot never changes a finalised receipt; a recipe change never changes a past consumption"
    - "no statement growth with unrelated rows: the Z report, the day journal and the cash expected amount are computed from their own session, shop and date; inserting receipts in another session, shop or date changes none of them (tested by seeding unrelated rows)"
    - "a receipt_id synced twice with the same digest creates one receipt; with a different digest it is quarantined; never two receipts"
    - "the day journal balances to the penny and its tenders equal the sum of the payment lines of its sessions"
    - "receipt and Z numbers per register have no gaps other than a recorded claimed gap"
  scenarios:
    - id: SC-POS-01
      name: an ordinary day in a London shop
      stories: [POS-03, POS-08, POS-09, POS-05, POS-06, POS-17, POS-20]
      given: LON-SOHO R1 opens with 150.00 on 2026-11-14
      when: 214 receipts are sold, a 6.50 difference counted and approved by another person, and the day posts
      then: the Z report and the day journal of POS-17 and POS-20, balanced at 1,543.40, one journal
    - id: SC-POS-02
      name: offline for two hours, a register dies, receipts arrive the next day
      stories: [POS-12, POS-07, POS-21]
      given: R2 offline 10:02 to 11:47, dead at 15:10 with 7 receipts unsynced
      when: the sync repeats, the session is force-closed, the day posts, and the 7 receipts arrive on 2026-11-15
      then: no receipt twice; one supplement journal for 41.20; the original day journal untouched
    - id: SC-POS-03
      name: refund twice at once
      stories: [POS-11]
      given: one oat latte on receipt 0057
      when: two registers refund it at the same second
      then: one refund succeeds, the other is refused; the money and the claim agree
    - id: SC-POS-04
      name: a German shop under KassenSichV with a TSE outage
      stories: [POS-24, POS-25]
      given: BER-MITTE R1 with its TSE
      when: the TSE fails for 20 minutes during trading
      then: receipts print marked as sold without the TSE, the outage is recorded, the DSFinV-K export for the day contains every receipt with its signature or the outage marker
    - id: SC-POS-05
      name: card settlement and the cash bag clear in payments
      stories: [POS-19, POS-26]
      given: card clearing 890.10 and bag B-00451 of 600.00
      when: the statement shows 876.75 and 600.00
      then: payments matches both; card_clearing and cash_in_transit for the day are zero; fees 13.35 posted by payments

scale:
  reads: "the tables reader serves 16 concurrent reads; a shop's day reads its sessions' rows in one query per aggregate; registers sync through an admission semaphore below 16 on pos's client"
  hot_paths: ["receipt finalise and sync", "the outbox dispatcher", "the day journal derivation"]
  workers: "the outbox dispatcher runs in the composition's worker, pinned warm (--reserved 1 --max-replicas 1); there is no server-side scheduler"
  volumes: "a chain of 40 shops x 2 registers x 400 receipts a day is 32,000 receipts and about 100,000 consumption moves a day; inventory receives one request per receipt, not per line; above 200 shops, batch consumption per session"

platform_constraints:
  - constraint: "No read-your-writes inside an open transaction."
    today: "a receipt and its outbox rows commit in one transaction; the day's totals are derived in a second transaction after the last session commits"
  - constraint: "A plain INSERT of a committed key is rejected (23505)."
    today: "receipt numbers, refund claims and voucher claims are plain INSERTs, so the loser of a race is refused and re-reads; a sync replay is detected by reading the receipt first"
  - constraint: "ON CONFLICT DO UPDATE SET accepts only EXCLUDED.<col>."
    today: "the outbox status and the day's status upsert with every value in VALUES"

customer_varies:
  - question: Are tips your staff's money (passed on in full) or the shop's revenue?
    changes: tips_treatment, purpose tips_payable or revenue, the tips_allocation extension
    standard: "UK Employment (Allocation of Tips) Act 2023 (in force 2024-10-01): employers pass all tips to workers; Germany s. 3 Nr. 51 EStG (voluntary tips to employees tax-free)"
    typical:
      coffee_chain_uk: liability, pooled per shop and shift, paid through payroll
      owner_operated_cafe: liability, shared in cash at the end of the week
      warning: "treating tips as revenue in the UK breaches the 2023 Act; the default is liability"
  - question: In which countries do you have shops, and which certified TSE or signature provider do you use there?
    changes: pos_fiscal_profile, the fiscal capability per country
    standard: "DE KassenSichV with BSI TR-03153 TSE and DSFinV-K; FR CGI art. 286-I-3 bis (NF525 certification); AT RKSV and s. 131b BAO"
  - question: Do your shops in different countries run on their own local time?
    changes: "the business date: shared.yaml has ONE business_timezone per bucket; shops in Vienna and London differ by an hour, so either a bucket per time zone or a per-shop zone (a change to the shared convention)"
  - question: Do you sell food to eat in and to take away at different prices or VAT rates?
    changes: eat_in on the receipt, the eat-in question per category (pos_category_rule), tax's consumption flag
    standard: "Directive 2006/112/EC art. 98 and Annex III (reduced rates); DE UStG s. 12; FR CGI art. 278-0 bis and 279"
  - question: Are your gift vouchers spent only on one kind of thing in one country (single-purpose) or on anything (multi-purpose)?
    changes: pos_voucher_profile
    standard: "Council Directive (EU) 2016/1065; UK VATA 1994 Schedule 10A"
  - question: How big a cash difference can a barista close on their own, and how big a refund in cash?
    changes: difference_threshold, cash_refund_limit
    typical:
      coffee_chain: 5.00 difference, 20.00 cash refund
      single_cafe: 2.00 difference, any refund by the owner
  - question: Do you run a loyalty card, and do you want its free drinks deferred as revenue?
    changes: the loyalty extension, POS-33
    standard: "IFRS 15 B39-B43 (customer options for additional goods: material rights)"

variation_points:
  - node: Task_shop_day_approve_difference
    op: narrow
    default: "a second person approves a difference above the threshold"
    depth: 1
    guideline: "warn - a cashier approving their own drawer is the first control an auditor tests"
  - node: Task_shop_day_count_session
    op: on_off
    default: "blind close on"
    depth: 1
    guideline: "warn - a cashier who sees the expected amount first tends to find it"
  - node: Task_shop_day_finalise_receipt
    op: row_value
    default: "fiscal profile per shop country"
    depth: 1
    requirement: "refuse - a shop in Germany, France or Austria without its fiscal profile breaks that country's till law"

migration:
  odoo:
    runs_after: [business-partner, product, tax, gl]
    reads:
      - model: pos.config
        fields: [id, name, company_id, picking_type_id, pricelist_id, payment_method_ids, iface_tipproduct, tip_product_id]
        filter: "every point of sale"
        why: "shops and registers (Odoo's pos.config is one register; the door groups them into shops)"
      - model: pos.session
        fields: [id, name, config_id, user_id, start_at, stop_at, state (opening_control | opened | closing_control | closed), cash_register_balance_start, cash_register_balance_end_real, cash_register_difference, move_id]
        filter: "state closed, within the door's years; an open session stops the run"
        why: "the closed sessions with their float, count and difference"
      - model: pos.order / pos.order.line
        fields: [id, name, pos_reference, session_id, date_order, partner_id, amount_total, amount_tax, amount_paid, amount_return, state (paid | done | invoiced | cancel), refunded_order_ids; line: product_id, qty, price_unit, discount, tax_ids, price_subtotal, price_subtotal_incl, refunded_orderline_id]
        filter: "orders of the sessions above"
        why: "receipts and lines as read-only history; refunds keep their link to the original line"
      - model: pos.payment / pos.payment.method
        fields: [id, pos_order_id, payment_method_id, amount, payment_date, card_type, transaction_id; method: name, type (cash | bank | pay_later), journal_id]
        filter: "payments of the orders above"
        why: "tenders per receipt"
      - model: loyalty.card / loyalty.program
        fields: [id, code, partner_id, points, program_id; program: name, program_type (gift_card | loyalty | ewallet)]
        filter: "program_type gift_card with points above 0"
        why: "open gift vouchers carried as vouchers with their balance; loyalty points go to the loyalty extension, if installed"
    maps:
      - { from: "pos.config", to: "register", rule: "shop_id and register_id named at the door per config" }
      - { from: "pos.session", to: "session (status posted)", rule: 'session_id = "<shop>-<register>-odoo<id>"; float, count and difference to the penny' }
      - { from: "pos.order", to: "receipt", rule: 'receipt_id = "odoo:<db>:pos.order:<id>"; pos_reference kept; state cancel not carried; refunds by refunded_order_ids' }
      - { from: "pos.order.line", to: "receipt_line", rule: "product through product's GET /migration-map; tax through tax's map; discount is a percentage in Odoo, as here" }
      - { from: "loyalty.card (gift_card)", to: "voucher", rule: "voucher_code = code; balance = points (Odoo gift card points are currency); expiry from the program" }
    door:
      - "which pos.config registers make up which shop"
      - "how many closed years to carry (recommended: the current and the prior financial year)"
      - "whether Odoo's session journals (move_id) stay gl's history or are recomputed (they stay: gl's migration carries them)"
      - "the fiscal history for DE, FR and AT stays in its certified archive; it is not re-signed here"
    reconcile:
      - { check: "gross, VAT and tenders per shop and business date", odoo: "SUM(amount_total), SUM(amount_tax) of pos.order and SUM(amount) of pos.payment by method per session date", ours: "receipts and payment lines per shop_day", must: equal }
      - { check: "cash difference per session", odoo: "pos.session.cash_register_difference", ours: "session.difference", must: equal }
      - { check: "open gift voucher balances", odoo: "loyalty.card points of gift_card programs", ours: "voucher.balance", must: equal }
      - { check: "receipts carried", odoo: "search_count of pos.order by session", ours: "receipts per session", must: equal }
    not_carried:
      - "open sessions (closed in Odoo first)"
      - "fiscal signatures: the certified archive of the old till stays the legal record for those years"
      - "Odoo's consumption pickings: inventory's migration carries stock, not the moves of each sale"
    idempotent_on: 'receipt_id = "odoo:<db>:pos.order:<id>"; lines on [receipt_id, line_no]; session on session_id; voucher on voucher_code; pos_migration_map on [odoo_db, odoo_model, odoo_id]'

needs: []                              # wave 3: every route this spec needed in an existing module is now in that module (seam statuses say which version), except hr's below
needs_from_hr_v2:                      # added in hr v2 (spec/hr-v2, HR-56..59); kept as the record of what this module asked hr for. What hr v2 must add for pos:
  - route: "GET /persons/{person_id}/employment?on="
    permission: hr:employment:read          # new, machine: held by pos's and expenses' service clients
    request: "path person_id; query on (YYYY-MM-DD, default today in business_timezone)"
    response: "200 {person_id, on, employed (bool: an engagement active on `on`), engagement_id, worker_type, company_code (the legal entity), org_unit_id, location_code, cost_centre, bp_id (the person partner)}; employed false (and nulls) when no engagement covers the date, never a 404 for a person the caller may ask about"
    from: "hr's engagement and assignment slices in force on the date (hr v1 entities engagement, assignment); no pay, no sensitive data"
    callers: [pos POS-32, expenses EXP-19]

reference:
  # `name` is the brick's DISTRIBUTION name (pyproject [project].name): erp-compose init pins
  # `<name> @ git+<https>@<tag>`, and pip refuses a pin whose name differs from the package's.
  repos:
    - name: erp-brick-pos
      role: brick
      https: https://git.dodil.io/dodil/erp-brick-pos.git
      ssh: git@git.dodil.io:dodil/erp-brick-pos.git
      tag: v0.2.0
      read_first: [README.md, AGENTS.md]
    - name: brick-business-partner
      role: brick
      https: https://git.dodil.io/dodil/erp-brick-business-partner.git
      ssh: git@git.dodil.io:dodil/erp-brick-business-partner.git
      tag: v0.4.0
      read_first: [README.md, AGENTS.md]
    - name: erp-platform
      role: platform
      https: https://git.dodil.io/dodil/erp-platform.git
      ssh: git@git.dodil.io:dodil/erp-platform.git
      tag: v0.9.13
      read_first: [README.md, AGENTS.md]
  packages: []
  contracts: []
  build_guide: null
  known_gaps:
    - "nothing is built; this is version 1 of the spec, unsigned"
    - "the needs above are not in their modules' specs yet; each is a change request there"
    - "the register client (offline store, terminal and fiscal device drivers) is a separate deliverable from the brick"
    - "restaurant table service (tables, courses, split by seat) is not specified; a coffee chain sells at the counter"
    - "APQC ids are null until verified against PCF v8.0"

# SAP S/4HANA retail parity at mid-market scope. covered | different | deferred | not_planned.
sap_parity:
  - { sap: "POS inbound of transaction logs (POS DM / CAR, /POSDW/)", ours: different, where: "POS-12, receipts synced by register", why: "the till IS the module: receipts are rows here, not a log imported from a third-party till" }
  - { sap: "Aggregated sales posting to FI (POS ag