From d67bf5b182d64367450c0cda316d34b93a8cc248 Mon Sep 17 00:00:00 2001 From: colton Date: Fri, 9 Oct 2026 21:15:21 -0700 Subject: [PATCH 001/113] C2-5: the counter onto the wire contract, first cut Migration 105 pos_wire_contract (ADR 0005 section 13 C2-5): the sale numbered POS- and the return RTN- through sequences, currency, the invoice link, the shared line columns on pos_line_items and pos_return_lines with the per type CHECKs, the tender's payment link, each historic ACCOUNT return migrated as an OPEN credit memo carrying the return's own entry, and the walk-in customer with its setting. The pos module rebuilt on the recipe: the sale and its lines in the shared salesdoc shape (product, charge and text lines, typed override and discount with audit, kit explosion), tenders in cents, the completed sale one transaction (stock out through inventory.IssueQty, the invoice with origin POS and pickup posted through the AR core with tax and COGS, each cash, check or card tender a payment applied to it stored net of change, an ACCOUNT tender left open on the invoice and subject to the credit check), the void while the session is open one transaction over the sale, its payments and its invoice with the card refund through the gateway before it, the return a credit memo created and posted in one act refunded in cash or card or left as account credit, the till's expected cash from the session's payments plus the float less cash refunds with the over/short entry inside the close's transaction, and the offline sync replaying each sale through the same path with a tax provider failure pending, never a rejection. The card charge carries the sale's currency, never a hard coded one. inventory.GetInventory's write path read now follows the branch wall for a bound caller with no location (the carried fix from PR 44's review). Signed-off-by: colton --- core/internal/apicontract/model_shape_test.go | 16 +- .../app/serve/pos_calc_adapter_test.go | 55 - core/internal/app/serve/serve.go | 51 +- core/internal/inventory/qty.go | 53 + core/internal/inventory/repository.go | 23 +- core/internal/invoice/fulfilment.go | 106 ++ core/internal/invoice/repository.go | 51 + core/internal/pos/adapters.go | 80 ++ core/internal/pos/complete.go | 562 +++++++++ core/internal/pos/handler.go | 616 +++++----- core/internal/pos/handler_machinekey_test.go | 132 -- core/internal/pos/input.go | 378 ++++++ core/internal/pos/model.go | 350 ++++-- core/internal/pos/repository.go | 869 +++++++++---- core/internal/pos/returns.go | 511 ++++---- core/internal/pos/returns_repository.go | 165 --- core/internal/pos/returns_test.go | 74 -- core/internal/pos/service.go | 1082 ++++++++--------- core/internal/pos/service_audit_tx_test.go | 117 -- core/internal/pos/sync.go | 146 +++ core/internal/pos/till.go | 282 ++--- core/internal/pos/till_repository.go | 285 ++--- core/internal/pos/till_test.go | 44 - core/internal/pos/void.go | 221 ++++ core/internal/pos/zreport.go | 46 +- core/migrations/105_pos_wire_contract.sql | 324 +++++ .../down/105_pos_wire_contract_down.sql | 101 ++ 27 files changed, 4282 insertions(+), 2458 deletions(-) delete mode 100644 core/internal/app/serve/pos_calc_adapter_test.go create mode 100644 core/internal/pos/adapters.go create mode 100644 core/internal/pos/complete.go delete mode 100644 core/internal/pos/handler_machinekey_test.go create mode 100644 core/internal/pos/input.go delete mode 100644 core/internal/pos/returns_repository.go delete mode 100644 core/internal/pos/returns_test.go delete mode 100644 core/internal/pos/service_audit_tx_test.go create mode 100644 core/internal/pos/sync.go delete mode 100644 core/internal/pos/till_test.go create mode 100644 core/internal/pos/void.go create mode 100644 core/migrations/105_pos_wire_contract.sql create mode 100644 core/migrations/down/105_pos_wire_contract_down.sql diff --git a/core/internal/apicontract/model_shape_test.go b/core/internal/apicontract/model_shape_test.go index 9904f3cad..8ab44e0d4 100644 --- a/core/internal/apicontract/model_shape_test.go +++ b/core/internal/apicontract/model_shape_test.go @@ -155,20 +155,20 @@ var modelBoundSchemas = []struct { {"ExposureRow", pricing.ExposureRow{}}, {"EscalateNowResult", pricing.EscalateNowResult{}}, {"EscalateNowLine", pricing.EscalateNowLine{}}, - // pos - {"PosTransaction", pos.POSTransaction{}}, - {"PosLineItem", pos.POSLineItem{}}, - {"PosTender", pos.POSTender{}}, - {"PosTransactionSummary", pos.TransactionSummary{}}, + // pos (C2-5: the counter on the wire contract) + {"PosTransaction", pos.Sale{}}, + {"PosLineItem", salesdoc.Line{}}, + {"PosTender", pos.Tender{}}, + {"PosTransactionSummary", pos.SaleSummary{}}, {"PosSearchResult", pos.QuickSearchResult{}}, {"PosCatalogProduct", pos.CatalogProduct{}}, {"PosTillSession", pos.TillSession{}}, {"PosTillReport", pos.TillReport{}}, {"PosZReport", pos.ZReport{}}, - {"PosReturn", pos.POSReturn{}}, - {"PosReturnLine", pos.POSReturnLine{}}, + {"PosReturn", pos.Return{}}, + {"PosReturnLine", pos.ReturnLine{}}, {"PosSyncResponse", pos.OfflineSyncResponse{}}, - {"PosSyncError", pos.SyncError{}}, + {"PosSyncItemResult", pos.SyncItemResult{}}, // portal (R1-7c) // Health schemas (live, ready, metrics) are inline map[string]any in serve.go // with no struct to bind, so they are omitted from this table. diff --git a/core/internal/app/serve/pos_calc_adapter_test.go b/core/internal/app/serve/pos_calc_adapter_test.go deleted file mode 100644 index cc193a64e..000000000 --- a/core/internal/app/serve/pos_calc_adapter_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package serve - -import ( - "context" - "testing" - - "github.com/gablelbm/gable/internal/customer" - "github.com/gablelbm/gable/internal/pricing" - "github.com/gablelbm/gable/internal/testutil" - "github.com/google/uuid" -) - -// The counter (fenced) turns the adapter's float into cents with -// int64(price*100+0.5). The adapter hands it a price already rounded half -// away from zero to whole cents, so a half cent price rings the cent above: -// 20.025 rings 20.03, where truncating the float 2002.4999 rang 20.02. -func TestPosCalcAdapter_RingsHalfCentsAwayFromZero(t *testing.T) { - db := testutil.RequireDB(t) - ctx := context.Background() - customerID, productID := uuid.New(), uuid.New() - branch := `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` - if _, err := db.Pool.Exec(ctx, `INSERT INTO customers (id, name, account_number, primary_branch_id) - VALUES ($1, 'Counter Co', $2, `+branch+`)`, customerID, "CTR-"+uuid.NewString()[:8]); err != nil { - t.Fatalf("seed customer: %v", err) - } - if _, err := db.Pool.Exec(ctx, `INSERT INTO products (id, sku, description, uom_primary, base_price) - VALUES ($1, $2, 'half cent', 'PCS', 20.025)`, productID, "CTR-"+productID.String()[:8]); err != nil { - t.Fatalf("seed product: %v", err) - } - t.Cleanup(func() { - _, _ = db.Pool.Exec(ctx, `DELETE FROM products WHERE id = $1`, productID) - _, _ = db.Pool.Exec(ctx, `DELETE FROM customers WHERE id = $1`, customerID) - }) - - a := &posCalcAdapter{ - pricingSvc: pricing.NewService(pricing.NewRepository(db)), - customerSvc: customer.NewService(customer.NewRepository(db)), - } - got, err := a.CalculateItemPrice(ctx, customerID, productID, 20.025, 1) - if err != nil { - t.Fatal(err) - } - if cents := int64(got*100.0 + 0.5); cents != 2003 { - t.Fatalf("the counter would ring %d cents from %v, want 2003", cents, got) - } - // Every whole cent value survives the counter's own conversion. - for c := int64(0); c <= 2_000_000; c += 7 { - if back := int64((float64(c)/100)*100.0 + 0.5); back != c { - t.Fatalf("%d cents come back as %d through the counter's conversion", c, back) - } - } -} diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index 865f91d20..4c9bc700d 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -532,18 +532,26 @@ func Run() { wall.payments(mux, paymentSvc) - // POS Module (Retail Counter Sales) + // POS Module (Retail Counter Sales), on the wire contract (ADR 0005 + // section 14.2 C2-5): the completed sale one transaction (stock out, the + // POS invoice posted with tax and COGS, each tender a payment applied), + // the void through the counter's own path, returns as credit memos, and + // the till's expected cash from the payments the session's sales became. posRepo := pos.NewRepository(db) - posSvc := pos.NewService(db, posRepo, productSvc, inventorySvc, invoiceSvc, paymentSvc, logger) - posSvc.WithPricing(&posCalcAdapter{pricingSvc: pricingSvc, customerSvc: customerSvc}) - posSvc.WithAuditLog(auditLog) - posSvc.WithTax(taxSvc, invoiceRepo) - posSvc.WithTillLedger(glSvc) // post drawer over/short to the GL at close - // POS card tenders ride the CARD-PRESENT rail (Clover merchant terminal / - // Run Terminal API), NOT the Run online/keyed-web charge API (rpGateway) — - // that rail is the Contractor Portal / invoice online-payment path. Until - // the Clover terminal integration lands, counter CARD tenders are recorded - // as externally-captured (the device settles); posSvc.WithGateway is where + posSvc := pos.NewService(db, posRepo, logger). + WithPriceEngine(&orderwire.PriceEngineAdapter{Pricing: pricingSvc, Customers: customerSvc}). + WithTaxProvider(taxSvc). + WithInvoices(invoiceSvc). + WithAR(accountSvc). + WithInventory(inventorySvc). + WithLedger(glSvc). // post drawer over/short to the GL inside the close's transaction + WithAuditLog(auditLog). + WithOutbox(outbox.NewWriter(db, cfg.EventsOrg)). + WithTxRunner(db) + // POS card tenders ride the CARD-PRESENT rail (Clover merchant terminal), + // NOT the Run online/keyed-web charge API: until the Clover terminal + // integration lands, counter CARD tenders are recorded as + // externally-captured (the device settles); posSvc.WithGateway is where // the Clover terminal gateway wires in. posHandler := pos.NewHandler(posSvc) posHandler.RegisterRoutes(mux, scoped("admin", "owner", "cashier")) @@ -1202,24 +1210,3 @@ func (a pricingAuditAdapter) Log(ctx context.Context, e pricing.AuditEntry) erro }) } -// posCalcAdapter bridges pricing.Service + customer.Service to pos.PriceCalculator. -type posCalcAdapter struct { - pricingSvc *pricing.Service - customerSvc *customer.Service -} - -func (a *posCalcAdapter) CalculateItemPrice(ctx context.Context, customerID uuid.UUID, productID uuid.UUID, basePrice float64, quantity float64) (float64, error) { - cust, err := a.customerSvc.GetCustomer(ctx, customerID) - if err != nil { - return basePrice, nil // Fallback to base price if customer lookup fails - } - // The counter (fenced) turns this float into cents with a +0.5 truncation, - // which misrounds a half cent price whose float is just below it (20.025 - // is 2002.4999 cents). So hand it a price already rounded half away from - // zero to whole cents, in integers. - sp, err := a.pricingSvc.CalculateScaled(ctx, cust, productID, basePrice, quantity, nil) - if err != nil { - return basePrice, nil - } - return float64(pricing.CentsOf(sp.Price)) / 100, nil -} diff --git a/core/internal/inventory/qty.go b/core/internal/inventory/qty.go index 7d12ed931..2d682e356 100644 --- a/core/internal/inventory/qty.go +++ b/core/internal/inventory/qty.go @@ -212,6 +212,59 @@ func scaled(f float64) int64 { return int64(f*10000 + 0.5) } +// ErrInsufficientOnHand is the counter's name for a short free stock: a +// sale at the till that asks for more than the branch has free. +var ErrInsufficientOnHand = ErrInsufficientAvailable + +// IssueQty takes qty of the product from on hand at the branch for a +// counter sale: a counter sale never allocates (there is no confirm moment +// between the goods leaving the shelf and the money), so it takes from the +// free stock, rows with the most free first, and refuses below what is +// allocated with ErrInsufficientOnHand. +func (s *Service) IssueQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error { + if qty <= 0 { + return nil + } + items, err := s.repo.LockBranchInventory(ctx, productID, branchID) + if err != nil { + return err + } + type slot struct { + id uuid.UUID + free int64 + } + var slots []slot + var total int64 + for i := range items { + if f := scaled(items[i].Quantity) - scaled(items[i].Allocated); f > 0 { + slots = append(slots, slot{items[i].ID, f}) + total += f + } + } + if total < int64(qty) { + return fmt.Errorf("%w: product %s has %d free, the sale takes %d", ErrInsufficientOnHand, productID, total, qty) + } + remaining := int64(qty) + for remaining > 0 { + best := 0 + for i := range slots { + if slots[i].free > slots[best].free { + best = i + } + } + take := remaining + if slots[best].free < take { + take = slots[best].free + } + if err := s.repo.UnstockQty(ctx, slots[best].id, take); err != nil { + return err + } + slots[best].free -= take + remaining -= take + } + return nil +} + // UnrestockQty takes qty back off on hand at the branch, the reverse of // RestockQty (a voided restocking credit memo). It takes from the rows // holding the most unallocated stock first and never below what is diff --git a/core/internal/inventory/repository.go b/core/internal/inventory/repository.go index 41afed4fb..9c0e27c72 100644 --- a/core/internal/inventory/repository.go +++ b/core/internal/inventory/repository.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/gablelbm/gable/pkg/branchctx" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" @@ -51,13 +52,27 @@ func (r *PostgresRepository) ExecuteInTx(ctx context.Context, fn func(context.Co } func (r *PostgresRepository) GetInventory(ctx context.Context, productID uuid.UUID, locationID *uuid.UUID) (*Inventory, error) { + // The write path's read follows the branch wall (C2-5's carried fix from + // PR 44's review): a bound caller with no location reads its own + // branch's rows, never another branch's; an unbound caller keeps the + // legacy behaviour (the named location's row, else the row with no + // location). + branch := branchctx.IDForQuery(ctx) query := ` - SELECT id, product_id, location_id, location, quantity, allocated, updated_at - FROM inventory - WHERE product_id = $1 AND (($2::uuid IS NULL AND location_id IS NULL) OR location_id = $2) + SELECT i.id, i.product_id, i.location_id, i.location, i.quantity, i.allocated, i.updated_at + FROM inventory i + LEFT JOIN locations l ON l.id = i.location_id + WHERE i.product_id = $1 + AND ( + ($2::uuid IS NOT NULL AND l.branch_id = $2) + OR ($2::uuid IS NULL AND $3::uuid IS NULL AND i.location_id IS NULL) + OR ($2::uuid IS NULL AND $3::uuid IS NOT NULL AND i.location_id = $3) + ) + ORDER BY i.quantity DESC NULLS LAST, i.id + LIMIT 1 ` var inv Inventory - err := r.db.GetExecutor(ctx).QueryRow(ctx, query, productID, locationID).Scan( + err := r.db.GetExecutor(ctx).QueryRow(ctx, query, productID, branch, locationID).Scan( &inv.ID, &inv.ProductID, &inv.LocationID, diff --git a/core/internal/invoice/fulfilment.go b/core/internal/invoice/fulfilment.go index 0f6f77589..663ef5fb5 100644 --- a/core/internal/invoice/fulfilment.go +++ b/core/internal/invoice/fulfilment.go @@ -98,6 +98,111 @@ type FulfilmentInvoice struct { Effects *account.Effects } +// CounterInvoice is the invoice a completed counter sale bills (ADR 0005 +// 14.2 C2-5): origin POS, pickup, no order, everything its entry needs. The +// number, the terms and their dates are filled by CreateCounterInvoice +// (the number is minted late, section 11). +type CounterInvoice struct { + ID uuid.UUID + Number string + BranchID uuid.UUID + CustomerID uuid.UUID + Currency string + + PickedUpBy *string + InvoiceDate time.Time // the branch's local date + + SubtotalCents int64 + TaxCents int64 + TotalCents int64 + TaxRate *string // the decimal rate "0.088750", nil when a provider priced it + TaxExempt bool + TaxSource string // EXEMPT, PROVIDER, BRANCH_RATE + + Lines []FulfilmentLine + Revenue []RevenueLeg + CostCents int64 // the entry's COGS legs + + Actor string + + // Effects is what the AR core did posting the invoice: the caller writes + // its events (invoice.created, customer.updated, part balance) last. + Effects *account.Effects +} + +// CreateCounterInvoice writes the invoice of a completed counter sale inside +// the caller's transaction, on the same path a fulfilment's invoice takes: +// the customer row is locked, THEN the gapless number is minted, +// immediately before the insert; the balanced entry (DR 1020 the total and +// 5010 the cost; CR each revenue account, 2020 the tax and 1030 the cost) +// and the subledger debit go through the AR core. origin POS, +// delivery_type PICKUP, no order. +func (s *Service) CreateCounterInvoice(ctx context.Context, in *CounterInvoice) error { + if !database.InTx(ctx) { + return gl.ErrNoTransaction + } + store, ok := s.repo.(FulfilmentStore) + if !ok { + return errors.New("invoice: the repository cannot store a fulfilment invoice") + } + if in.ID == uuid.Nil { + in.ID = uuid.New() + } + if len(in.Lines) == 0 { + return fmt.Errorf("invoice must have lines") + } + terms, err := store.TermsFor(ctx, in.CustomerID) + if err != nil { + return err + } + if err := store.LockCustomer(ctx, in.CustomerID); err != nil { + return err + } + if in.Number, err = store.NextInvoiceNumber(ctx); err != nil { + return err + } + inv := &FulfilmentInvoice{ + ID: in.ID, Number: in.Number, BranchID: in.BranchID, CustomerID: in.CustomerID, Currency: in.Currency, + DeliveryType: "PICKUP", PickedUpBy: in.PickedUpBy, InvoiceDate: in.InvoiceDate, + SubtotalCents: in.SubtotalCents, TaxCents: in.TaxCents, TotalCents: in.TotalCents, + TaxRate: in.TaxRate, TaxExempt: in.TaxExempt, TaxSource: in.TaxSource, + Lines: in.Lines, Revenue: in.Revenue, CostCents: in.CostCents, Actor: in.Actor, + PaymentTermsID: terms.ID, + } + inv.DueDate = terms.Terms.DueDate(in.InvoiceDate) + if err := store.InsertCounterInvoice(ctx, inv); err != nil { + return err + } + if s.account != nil { + legs := []gl.Leg{{AccountCode: gl.AccountCodeCOGS, Description: "Cost of Goods Sold", Debit: in.CostCents}} + for _, r := range in.Revenue { + legs = append(legs, gl.Leg{AccountCode: r.AccountCode, Description: "Revenue " + r.AccountCode, Credit: r.Cents}) + } + legs = append(legs, + gl.Leg{AccountCode: gl.AccountCodeSalesTax, Description: "Sales Tax Payable", Credit: in.TaxCents}, + gl.Leg{AccountCode: gl.AccountCodeInventory, Description: "Inventory", Credit: in.CostCents}, + ) + fx, err := s.account.PostInvoice(ctx, account.PostInvoiceIn{InvoiceID: in.ID, CustomerID: in.CustomerID, + Number: in.Number, Currency: in.Currency, TotalCents: in.TotalCents, On: in.InvoiceDate, Actor: in.Actor, Legs: legs}) + if err != nil { + return mapPostingError(fmt.Errorf("failed to post the invoice: %w", err)) + } + in.Effects = fx + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "invoice.created", EntityType: "invoice", EntityID: in.ID, UserID: in.Actor, + Changes: map[string]interface{}{ + "number": in.Number, "customer_id": in.CustomerID, "order_id": nil, "total_cents": in.TotalCents, + "cost_cents": in.CostCents, "origin": "POS", + }, + }); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + return nil +} + // FulfilmentStore is the repository half the fulfilment invoice needs; the // Postgres repository implements it. type FulfilmentStore interface { @@ -105,6 +210,7 @@ type FulfilmentStore interface { LockCustomer(ctx context.Context, customerID uuid.UUID) error NextInvoiceNumber(ctx context.Context) (string, error) InsertFulfilmentInvoice(ctx context.Context, in *FulfilmentInvoice) error + InsertCounterInvoice(ctx context.Context, in *FulfilmentInvoice) error } // CreateFulfilmentInvoice writes the invoice of an order fulfilment inside the diff --git a/core/internal/invoice/repository.go b/core/internal/invoice/repository.go index d68e60369..9bdd2e10e 100644 --- a/core/internal/invoice/repository.go +++ b/core/internal/invoice/repository.go @@ -594,6 +594,57 @@ func (r *PostgresRepository) InsertFulfilmentInvoice(ctx context.Context, in *Fu return nil } +// InsertCounterInvoice writes the invoice of a completed counter sale: the +// same shape as a fulfilment's invoice with origin POS, no order and the +// pickup will-call fields (ADR 0005 6.1 and 14.2 C2-5). +func (r *PostgresRepository) InsertCounterInvoice(ctx context.Context, in *FulfilmentInvoice) error { + exec := r.db.GetExecutor(ctx) + var discountDue any + if in.DiscountDueDate != nil { + discountDue = in.DiscountDueDate.Format("2006-01-02") + } + _, err := exec.Exec(ctx, ` + INSERT INTO invoices (id, number, order_id, customer_id, status, subtotal, tax_rate, tax_amount, total_amount, + payment_terms_id, due_date, discount_due_date, discount_percent, + branch_id, currency, delivery_type, picked_up_by, delivery_id, + ship_to_id, ship_to_snapshot, project_id, tax_exempt, tax_source, invoice_date, origin, paid_at, created_at, updated_at) + VALUES ($1, $2, NULL, $3, CASE WHEN $7::bigint = 0 THEN 'PAID' ELSE 'UNPAID' END, + $4::numeric / 100, $5::numeric, $6::numeric / 100, $7::numeric / 100, + $8, $9::date, $10::date, $11::numeric / 10000, + $12, $13, 'PICKUP', $14, NULL, + NULL, NULL, NULL, $15, $16, $17::date, 'POS', CASE WHEN $7::bigint = 0 THEN NOW() END, NOW(), NOW())`, + in.ID, in.Number, in.CustomerID, in.SubtotalCents, in.TaxRate, in.TaxCents, in.TotalCents, + in.PaymentTermsID, in.DueDate.Format("2006-01-02"), discountDue, in.DiscountPercent, + in.BranchID, in.Currency, in.PickedUpBy, in.TaxExempt, in.TaxSource, in.InvoiceDate) + if err != nil { + return fmt.Errorf("failed to insert invoice: %w", err) + } + for i := range in.Lines { + l := &in.Lines[i] + _, err := exec.Exec(ctx, ` + INSERT INTO invoice_lines (id, invoice_id, position, line_type, parent_line_id, product_id, charge_code_id, + sku, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, price_each, price_source, + priced_unit_price, override_reason, price_adjusted_by, + discount_percent, discount_amount, discount_reason, line_total, taxable, revenue_account_code, + order_line_id, unit_cost, cost, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, + $8, $9, $10::numeric / 10000, $11, $12, $13::numeric / 10000, $14::numeric / 10000, $15::numeric / 10000, + CASE WHEN $10::numeric > 0 AND $24::numeric IS NOT NULL THEN ROUND(($24::numeric / 100) / ($10::numeric / 10000), 4) ELSE $15::numeric / 10000 END, + $16, $26::numeric / 10000, $27, $28, + $17::numeric / 10000, $18::numeric / 100, $19, $24::numeric / 100, $20, $21, + $22, $23::numeric / 10000, $25::numeric / 100, NOW())`, + l.ID, in.ID, l.Position, l.LineType, l.ParentLineID, l.ProductID, l.ChargeCodeID, + l.SKU, l.Description, l.Quantity, l.UOM, l.PriceUOM, l.UOMQty, l.PriceUOMQty, l.UnitPrice, l.PriceSource, + l.DiscountPercent, l.DiscountCents, l.DiscountReason, l.Taxable, l.RevenueAccountCode, + l.OrderLineID, l.UnitCost, l.LineTotalCents, l.CostCents, + l.PricedUnitPrice, l.OverrideReason, l.PriceAdjustedBy) + if err != nil { + return fmt.Errorf("failed to insert invoice line: %w", err) + } + } + return nil +} + // --------------------------------------------------------------------------- // The void. // --------------------------------------------------------------------------- diff --git a/core/internal/pos/adapters.go b/core/internal/pos/adapters.go new file mode 100644 index 000000000..2c4d55c2a --- /dev/null +++ b/core/internal/pos/adapters.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "context" + "fmt" + + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/gablelbm/gable/internal/tax" + "github.com/google/uuid" +) + +// PaymentGateway is the card-present terminal rail (the payment package's +// own interface). +type PaymentGateway = payment.PaymentGateway + +// chargeApproved is the gateway status a tender needs to join a sale. +const chargeApproved = payment.GatewayStatusApproved + +// inventoryErrInsufficient maps the inventory package's issue refusal. +var inventoryErrInsufficient = inventory.ErrInsufficientOnHand + +// salesdocProductRef aliases the product slice the cost read answers. +type salesdocProductRef = salesdoc.ProductRef + +// taxPreviewRequest builds the provider call for a sale's lines. +func taxPreviewRequest(sale *Sale, lines []salesdoc.Line, taxableCents httpx.Cents) *tax.TaxPreviewRequest { + req := &tax.TaxPreviewRequest{ + CustomerID: sale.CustomerID, + DocumentType: "SalesInvoice", + } + for i := range lines { + l := &lines[i] + if l.LineTotal == nil || !l.Taxable { + continue + } + req.Lines = append(req.Lines, tax.TaxLineInput{ + LineNumber: i + 1, + ItemCode: productCode(l.ProductID), + Description: l.Description, + Quantity: float64(derefQty(l.Quantity)) / 10000, + Amount: int64(*l.LineTotal), + }) + } + return req +} + +func productCode(id *uuid.UUID) string { + if id == nil { + return "" + } + return id.String() +} + +// chargeRequest builds the gateway charge of one card tender, in the sale's +// own currency (never a hard coded one). +func chargeRequest(saleID uuid.UUID, t *TenderIn, currency string) payment.ChargeRequest { + return payment.ChargeRequest{ + TokenID: t.TokenID, + AmountCents: int64(t.AmountCents), + Currency: currency, + Description: fmt.Sprintf("POS sale %s", saleID), + InvoiceID: saleID.String(), + } +} + +// paymentNumberOf reads a payment's number through the database. +func paymentNumberOf(ctx context.Context, s *Service, id uuid.UUID) (string, error) { + var number string + err := s.db.GetExecutor(ctx).QueryRow(ctx, `SELECT number FROM payments WHERE id = $1`, id).Scan(&number) + if err != nil { + return "", nil // the number is display data; a miss never fails the sale + } + return number, nil +} diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go new file mode 100644 index 000000000..c3b569126 --- /dev/null +++ b/core/internal/pos/complete.go @@ -0,0 +1,562 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +// TenderIn is one parsed tender of a completion request. +type TenderIn struct { + Method TenderMethod + AmountCents httpx.Cents + Reference string + TokenID string +} + +// ErrTaxProviderUnavailable is the 503 the counter answers when the tax +// provider does not answer. The offline sync treats it as pending, never a +// rejection (ADR 0005 section 3). +var ErrTaxProviderUnavailable = &httpx.Error{Status: http.StatusServiceUnavailable, Code: httpx.CodeUnavailable, + Message: "the tax provider did not answer: the sale is not completed"} + +// chargeResult is a gateway charge made before the transaction opened. +type chargeResult struct { + GatewayTxID, AuthCode, Last4, Brand string +} + +// providerTax is the provider's answer, priced before the transaction with +// the fingerprint of what it priced. +type providerTax struct { + taxCents httpx.Cents + fingerprint string +} + +// linesFingerprint is a stable digest of the lines a tax price was taken +// against: any change to a billed line between the price and the +// transaction is a stale quote (ADR 0005 section 3). +func linesFingerprint(lines []salesdoc.Line) string { + var b strings.Builder + for i := range lines { + l := &lines[i] + fmt.Fprintf(&b, "%s|%d|%s|%s|", l.ID, derefQty(l.Quantity), derefString(l.UOM), derefString(l.PriceUOM)) + if l.LineTotal != nil { + fmt.Fprintf(&b, "%d|%t", int64(*l.LineTotal), l.Taxable) + } + b.WriteByte(';') + } + return b.String() +} + +func derefQty(q *httpx.Quantity) int64 { + if q == nil { + return 0 + } + return int64(*q) +} + +// prepareSaleTax prices the completion's tax with the provider BEFORE the +// transaction opens (ADR 0005 section 3). Nil means "resolve by rate inside +// the transaction". A provider failure is the 503 the caller may treat as +// pending (the offline sync). +func (s *Service) prepareSaleTax(ctx context.Context, sale *Sale, lines []salesdoc.Line, customerID uuid.UUID) (*providerTax, error) { + if s.tax == nil || !s.tax.ProviderConfigured() { + return nil, nil + } + exempt, err := s.repo.CustomerExempt(ctx, customerID) + if err != nil { + return nil, err + } + if exempt { + return nil, nil + } + totals := salesdoc.SumTotals(lines) + req := taxPreviewRequest(sale, lines, totals.TaxableCents) + answer, err := s.tax.PreviewTax(ctx, req) + if err != nil { + return nil, ErrTaxProviderUnavailable + } + return &providerTax{taxCents: httpx.Cents(answer.TotalTax), fingerprint: linesFingerprint(lines)}, nil +} + +// CompleteSale is the counter's money moment (ADR 0005 section 14.2 C2-5): +// ONE transaction that takes the stock out, writes the invoice (origin POS, +// pickup) posted with tax and cost of goods sold, turns each cash, check or +// card tender into a payment applied to it (stored net: the tendered amount +// less change, so the payment is the money kept), leaves an ACCOUNT tender +// open on the invoice, and writes the events last. The card charges and the +// tax provider call happen before the transaction opens; their results are +// written inside it. +func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch string, bodyRevision *int64, + tenders []TenderIn, pickedUpBy, actor string) (*Sale, error) { + if s.ar == nil || s.invoices == nil || s.inventory == nil { + return nil, errNotConfigured + } + pre, err := s.repo.GetSale(ctx, saleID) + if err != nil { + return nil, err + } + if pre.Status != StatusOpen && pre.Status != StatusHeld { + return nil, httpx.InvalidStateTransition(fmt.Sprintf("cannot complete a %s sale", pre.Status.Status())) + } + preLines, err := s.repo.GetLines(ctx, saleID) + if err != nil { + return nil, err + } + customerID := pre.WalkInID + if pre.CustomerID != nil { + customerID = *pre.CustomerID + } + // The tender plan: what the drawer keeps per tender and what walks out + // as change (only cash makes change; an ACCOUNT tender needs a named + // customer and passes the credit check). + var totalTendered, cashTendered, accountPortion int64 + for i := range tenders { + t := &tenders[i] + totalTendered += int64(t.AmountCents) + if t.Method == TenderCash { + cashTendered += int64(t.AmountCents) + } + if t.Method == TenderAccount { + accountPortion += int64(t.AmountCents) + } + } + totals := salesdoc.SumTotals(preLines) + total := int64(totals.SubtotalCents) + int64(s.estimateTax(ctx, pre, totals.TaxableCents)) + change := totalTendered - total + if change < 0 { + return nil, conflict("insufficient_tender", + fmt.Sprintf("the sale needs %d cents and the tenders carry %d", total, totalTendered)) + } + if change > 0 && cashTendered < change { + return nil, conflict("change_from_cash", + "only a cash tender can give change: the over tender exceeds the cash taken") + } + if accountPortion > 0 { + if pre.CustomerID == nil { + return nil, conflict("walk_in_account", + "an ACCOUNT tender needs a named customer: the walk-in customer buys on the barrelhead") + } + } + if len(preLines) == 0 { + return nil, invalid("lines", "the sale has no lines to complete") + } + // Card charges: outside the transaction, with the sale's own currency + // (the hard coded USD of the base commit is the bug this fixes). + charges := make([]*chargeResult, len(tenders)) + for i := range tenders { + t := &tenders[i] + if t.Method != TenderCard || t.TokenID == "" { + continue + } + if s.gateway == nil { + return nil, conflict("card_terminal", + "this register has no card terminal gateway: record the card tender without a token (the terminal settles it)") + } + res, err := s.gateway.Charge(ctx, chargeRequest(saleID, t, pre.Currency)) + if err != nil { + return nil, fmt.Errorf("card charge failed: %w", err) + } + if res.Status != chargeApproved { + return nil, conflict("card_declined", fmt.Sprintf("the card was declined (%s)", res.Status)) + } + charges[i] = &chargeResult{GatewayTxID: res.TransactionID, AuthCode: res.AuthCode, Last4: res.CardLast4, Brand: res.CardBrand} + } + priced, err := s.prepareSaleTax(ctx, pre, preLines, customerID) + if err != nil { + return nil, err + } + var out *Sale + err = s.inTx(ctx, func(ctx context.Context) error { + // Section 11, step 1: the counter sale row. + if err := s.repo.LockSale(ctx, saleID); err != nil { + return err + } + sale, err := s.repo.GetSale(ctx, saleID) + if err != nil { + return err + } + sale.WalkInID = pre.WalkInID + if err := httpx.CheckRevision(sale.Revision, ifMatch, bodyRevision); err != nil { + return err + } + if sale.Status != StatusOpen && sale.Status != StatusHeld { + return httpx.InvalidStateTransition(fmt.Sprintf("cannot complete a %s sale", sale.Status.Status())) + } + lines, err := s.repo.GetLines(ctx, saleID) + if err != nil { + return err + } + if len(lines) == 0 { + return invalid("lines", "the sale has no lines to complete") + } + totals := salesdoc.SumTotals(lines) + + // Tax: the provider's answer (verified against what is billed now) + // or the rate resolver. A pickup sale takes the branch rate + // (ADR 0005 section 5.5). + var taxCents int64 + var taxRate *string + taxExempt := false + taxSource := "" + if priced != nil { + if linesFingerprint(lines) != priced.fingerprint { + return conflict("tax_quote_stale", + "the sale's lines moved between the tax price and the completion: retry to price afresh") + } + taxCents = int64(priced.taxCents) + taxSource = string(salesdoc.TaxSourceProvider) + } else { + exempt, err := s.repo.CustomerExempt(ctx, customerID) + if err != nil { + return err + } + rate, ok, err := s.repo.BranchTaxRate(ctx, &sale.BranchID) + if err != nil { + return err + } + switch { + case exempt: + taxSource = string(salesdoc.TaxSourceExempt) + zero := "0" + taxRate = &zero + case ok: + scaled, _, err := salesdoc.ParseTaxRate(rate) + if err != nil { + return err + } + taxCents = int64(salesdoc.TaxAt(totals.TaxableCents, scaled)) + taxRate = &rate + taxSource = string(salesdoc.TaxSourceBranchRate) + default: + return conflict("tax_rate_not_configured", + "set the branch's default tax rate before completing a sale") + } + } + total := int64(totals.SubtotalCents) + taxCents + if totalTendered-recomputedChange(tenders, total) < 0 { + return conflict("insufficient_tender", + fmt.Sprintf("the sale needs %d cents and the tenders carry %d", total, totalTendered)) + } + + // The credit check an ACCOUNT tender owes (ADR 0005 14.2 C2-5). + if accountPortion > 0 { + facts, err := s.repo.CustomerFacts(ctx, customerID) + if err != nil { + return err + } + if facts.CreditLimitCents != nil { + open, err := s.repo.OpenReceivableCents(ctx, customerID) + if err != nil { + return err + } + if open+accountPortion > int64(*facts.CreditLimitCents) { + return conflict("credit_limit", + fmt.Sprintf("the customer's open receivable plus the %d cents on account is over the credit limit", accountPortion)) + } + } + } + + // Stock out: each stocked line's quantity from on hand, in + // (product id, line id) order (section 11, step 6). + stocked := stockedLines(lines) + for _, i := range stocked { + l := &lines[i] + if err := s.inventory.IssueQty(ctx, *l.ProductID, sale.BranchID, *l.Quantity); err != nil { + if errors.Is(err, inventoryErrInsufficient) { + return conflict("insufficient_stock", + fmt.Sprintf("line %s asks for more %s than the branch holds on hand", l.ID, (*l.Quantity).DecimalString())) + } + return err + } + } + + // The invoice: origin POS, pickup, its lines the sale's, posted + // with the tax and the cost of goods sold (8.2 and 8.4). + date, err := s.repo.BranchLocalDate(ctx, sale.BranchID, s.now()) + if err != nil { + return err + } + var pickedBy *string + if pickedUpBy != "" { + p := pickedUpBy + pickedBy = &p + } + inv := &invoice.CounterInvoice{ + BranchID: sale.BranchID, CustomerID: customerID, Currency: sale.Currency, + PickedUpBy: pickedBy, InvoiceDate: date, Actor: actor, + SubtotalCents: int64(totals.SubtotalCents), TaxCents: taxCents, TotalCents: total, + TaxRate: taxRate, TaxExempt: taxExempt, TaxSource: taxSource, + } + refs, err := s.costRefs(ctx, lines) + if err != nil { + return err + } + for n := range lines { + b := &lines[n] + fl := invoice.FulfilmentLine{ + ID: uuid.New(), Position: n, LineType: string(b.LineType), ProductID: b.ProductID, + ChargeCodeID: b.ChargeCodeID, SKU: b.SKU, Description: b.Description, UOM: b.UOM, PriceUOM: b.PriceUOM, + PriceSource: string(b.PriceSource), Taxable: b.Taxable, RevenueAccountCode: b.RevenueAccountCode, + OverrideReason: b.OverrideReason, PriceAdjustedBy: b.PriceAdjustedBy, + } + if b.PricedUnitPrice != nil { + p := int64(*b.PricedUnitPrice) + fl.PricedUnitPrice = &p + } + if b.ParentLineID != nil { + fl.ParentLineID = b.ParentLineID + } + if b.LineType != salesdoc.LineText { + q := int64(*b.Quantity) + fl.Quantity = &q + uq, pq := int64(*b.UOMQty), int64(*b.PriceUOMQty) + fl.UOMQty, fl.PriceUOMQty = &uq, &pq + up := int64(*b.UnitPrice) + fl.UnitPrice = &up + lt := int64(*b.LineTotal) + fl.LineTotalCents = < + if b.DiscountPercent != nil { + dp := int64(*b.DiscountPercent) + fl.DiscountPercent = &dp + } + if b.DiscountAmount != nil { + dc := int64(*b.DiscountAmount) + fl.DiscountCents = &dc + } + fl.DiscountReason = b.DiscountReason + if b.ProductID != nil && (b.LineType == salesdoc.LineProduct || b.LineType == salesdoc.LineComponent) { + if ref, ok := refs[b.ProductID.String()]; ok && ref.AverageCost > 0 { + uc := int64(ref.AverageCost) + fl.UnitCost = &uc + cost := int64(salesdoc.CostOf(*b.Quantity, ref.AverageCost)) + fl.CostCents = cost + inv.CostCents += cost + } + } + } + inv.Lines = append(inv.Lines, fl) + } + for _, g := range salesdoc.RevenueGroups(lines) { + inv.Revenue = append(inv.Revenue, invoice.RevenueLeg{AccountCode: g.AccountCode, Cents: int64(g.Cents)}) + } + if err := s.invoices.CreateCounterInvoice(ctx, inv); err != nil { + return err + } + + // The tenders become payments applied to the invoice, stored net: + // change comes out of the cash tendered, so the payment is the + // money kept (ADR 0005 14.2 C2-5). + change := totalTendered - total + cashChangeLeft := change + var fxAll *account.Effects + for i := range tenders { + t := &tenders[i] + if t.Method == TenderAccount { + tender := &Tender{SaleID: saleID, Method: t.Method, AmountCents: t.AmountCents} + if t.Reference != "" { + r := t.Reference + tender.Reference = &r + } + if err := s.repo.AddTender(ctx, tender); err != nil { + return err + } + continue + } + amount := int64(t.AmountCents) + if t.Method == TenderCash && cashChangeLeft > 0 { + give := min64(amount, cashChangeLeft) + amount -= give + cashChangeLeft -= give + } + if amount <= 0 { + // the whole tender walked out as change: no payment, but + // the tender row keeps what was tendered + tender := &Tender{SaleID: saleID, Method: t.Method, AmountCents: t.AmountCents} + if t.Reference != "" { + r := t.Reference + tender.Reference = &r + } + if err := s.repo.AddTender(ctx, tender); err != nil { + return err + } + continue + } + card := (*account.CardFacts)(nil) + if t.Method == TenderCard && charges[i] != nil { + card = &account.CardFacts{GatewayTxID: charges[i].GatewayTxID, GatewayStatus: "SETTLED", + Last4: charges[i].Last4, Brand: charges[i].Brand, AuthCode: charges[i].AuthCode} + } + payID, fx, err := s.ar.RecordPayment(ctx, account.RecordPaymentIn{ + CustomerID: customerID, BranchID: sale.BranchID, Currency: sale.Currency, + Method: string(t.Method), AmountCents: amount, Reference: t.Reference, ReceivedOn: date, + Actor: actor, Card: card, + Applications: []account.ApplyLine{{InvoiceID: inv.ID, AmountCents: amount}}, + }) + if err != nil { + return err + } + mergeEffects(&fxAll, fx) + tender := &Tender{SaleID: saleID, Method: t.Method, AmountCents: httpx.Cents(amount), + PaymentID: &payID} + if t.Reference != "" { + r := t.Reference + tender.Reference = &r + } + if charges[i] != nil { + g, a, l, b := charges[i].GatewayTxID, charges[i].AuthCode, charges[i].Last4, charges[i].Brand + tender.GatewayTxID, tender.AuthCode = &g, &a + tender.CardLast4, tender.CardBrand = &l, &b + } + if err := s.repo.AddTender(ctx, tender); err != nil { + return err + } + number, _ := s.paymentNumber(ctx, payID) + if err := s.recordEvent(ctx, outbox.Event{Type: "payment.recorded", EntityType: "payment", EntityID: payID, + BranchID: &sale.BranchID, Data: eventJSON(map[string]any{ + "number": number, "customer_id": customerID, "status": "posted", "currency": sale.Currency, + "amount_cents": amount, "unapplied_cents": 0, "register_id": sale.RegisterID, + })}); err != nil { + return err + } + } + if err := s.repo.CompleteSale(ctx, saleID, inv.ID, int64(totals.SubtotalCents), taxCents, total, change); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "pos.transaction.completed", EntityType: "pos_transaction", EntityID: saleID, UserID: actor, + Changes: map[string]any{ + "number": sale.Number, "invoice_id": inv.ID, "total_cents": total, "tax_cents": taxCents, + "register_id": sale.RegisterID, "customer_id": sale.CustomerID, "tenders": len(tenders), + }}); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + + // The events, last (ADR 0005 section 11 step 10): the invoice's, + // the AR core's, then the sale's own. + branch := sale.BranchID + if err := s.recordEvent(ctx, outbox.Event{Type: "invoice.created", EntityType: "invoice", EntityID: inv.ID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "number": inv.Number, "customer_id": customerID, "order_id": nil, "status": "unpaid", + "currency": sale.Currency, "total_cents": total, "subtotal_cents": int64(totals.SubtotalCents), + "tax_cents": taxCents, "origin": "pos", + })}); err != nil { + return err + } + if fxAll != nil { + for _, ev := range fxAll.Events() { + if err := s.recordEvent(ctx, ev); err != nil { + return err + } + } + } + if err := s.recordEvent(ctx, outbox.Event{Type: EventSaleCompleted, EntityType: "pos_transaction", EntityID: saleID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "number": sale.Number, "customer_id": sale.CustomerID, "status": StatusCompleted.Status(), + "from_status": StatusOpen.Status(), "revision": sale.Revision + 1, "currency": sale.Currency, + "total_cents": total, "invoice_id": inv.ID, "change_cents": change, + })}); err != nil { + return err + } + out, err = s.GetSale(ctx, saleID) + return err + }) + if err != nil { + return nil, err + } + return out, nil +} + +// recomputedChange is the change the recomputed total implies. +func recomputedChange(tenders []TenderIn, total int64) int64 { + var tendered int64 + for i := range tenders { + tendered += int64(tenders[i].AmountCents) + } + if tendered <= total { + return 0 + } + return tendered - total +} + +func min64(a, b int64) int64 { + if a < b { + return a + } + return b +} + +func mergeEffects(dst **account.Effects, fx *account.Effects) { + if fx == nil { + return + } + if *dst == nil { + *dst = fx + return + } + (*dst).Merge(fx) +} + +// stockedLines indexes the lines that move stock, in (product id, line id) +// order (section 11, step 6). +func stockedLines(lines []salesdoc.Line) []int { + var out []int + for i := range lines { + if lines[i].IsStocked() { + out = append(out, i) + } + } + for i := 1; i < len(out); i++ { + for j := i; j > 0; j-- { + a, b := &lines[out[j]], &lines[out[j-1]] + if *a.ProductID == *b.ProductID && a.ID.String() < b.ID.String() { + out[j], out[j-1] = out[j-1], out[j] + } else if productLess(a, b) { + out[j], out[j-1] = out[j-1], out[j] + } else { + break + } + } + } + return out +} + +func productLess(a, b *salesdoc.Line) bool { + if a.ProductID == nil || b.ProductID == nil { + return false + } + return a.ProductID.String() < b.ProductID.String() +} + +// costRefs reads the products the billed lines name, for the cost of goods +// sold (8.4: the average unit cost read inside the posting transaction). +func (s *Service) costRefs(ctx context.Context, lines []salesdoc.Line) (map[string]salesdocProductRef, error) { + var ids []uuid.UUID + for i := range lines { + if lines[i].ProductID != nil { + ids = append(ids, *lines[i].ProductID) + } + } + return s.repo.LookupProducts(ctx, ids) +} + +// paymentNumber reads a payment's number inside the transaction, for its +// payment.recorded event. +func (s *Service) paymentNumber(ctx context.Context, id uuid.UUID) (string, error) { + return paymentNumberOf(ctx, s, id) +} diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index c4b03d63d..3c9ce8c58 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -9,23 +9,23 @@ import ( "net/http" "time" - "github.com/gablelbm/gable/pkg/httputil" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) -// Handler handles POS HTTP endpoints. +// Handler handles the counter's routes on the wire contract: strict query +// parsing, DecodeJSON bodies, the error envelope, revision preconditions +// and list envelopes (the recipe's handler step). type Handler struct { service *Service } -// NewHandler creates a new POS handler. -func NewHandler(service *Service) *Handler { - return &Handler{service: service} -} +// NewHandler creates the counter's handler. +func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// RegisterRoutes registers POS API routes. -// NOTE: POS routes use /api/pos/* (legacy). Migrate to /api/v1/pos/* in API versioning sprint. +// RegisterRoutes registers the counter's routes. roleGuard composes with +// the branch middleware in serve.go. func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Handler) http.Handler) { guard := func(handler http.HandlerFunc) http.HandlerFunc { if len(roleGuard) > 0 && roleGuard[0] != nil { @@ -36,8 +36,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand return handler } - // Transaction lifecycle + // The sale lifecycle mux.HandleFunc("POST /api/v1/pos/transactions", guard(h.StartTransaction)) + mux.HandleFunc("GET /api/v1/pos/transactions", guard(h.ListTransactions)) mux.HandleFunc("GET /api/v1/pos/transactions/{id}", guard(h.GetTransaction)) mux.HandleFunc("POST /api/v1/pos/transactions/{id}/items", guard(h.AddItem)) mux.HandleFunc("DELETE /api/v1/pos/transactions/{id}/items/{itemId}", guard(h.RemoveItem)) @@ -45,7 +46,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand mux.HandleFunc("POST /api/v1/pos/transactions/{id}/void", guard(h.VoidTransaction)) // History and search - mux.HandleFunc("GET /api/v1/pos/transactions", guard(h.ListTransactions)) mux.HandleFunc("GET /api/v1/pos/products/search", guard(h.SearchProducts)) // Offline sync @@ -60,15 +60,14 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand mux.HandleFunc("GET /api/v1/pos/till/{id}/zreport", guard(h.GetZReport)) mux.HandleFunc("GET /api/v1/pos/zreports", guard(h.ListZReports)) - // Returns / refunds + // Returns mux.HandleFunc("POST /api/v1/pos/returns", guard(h.CreateReturn)) - mux.HandleFunc("GET /api/v1/pos/returns/{id}", guard(h.GetReturn)) mux.HandleFunc("GET /api/v1/pos/returns", guard(h.ListReturns)) + mux.HandleFunc("GET /api/v1/pos/returns/{id}", guard(h.GetReturn)) } -// cashierRefusalBody is the wire ADR's error envelope for the one refusal -// this package writes. The shared writer lands with the platform packages; -// pkg/actor and pkg/middleware write the same shape for theirs. +// cashierRefusalBody is the wire error envelope for the one refusal this +// package writes itself; the shared writer handles every other. type cashierRefusalBody struct { Error struct { Code string `json:"code"` @@ -81,458 +80,499 @@ type cashierRefusalBody struct { // refuseMachineKeyCashier answers the request with 403 when the caller // authenticated with a machine key, reporting whether it answered. The -// cashier on a POS mutation is a human user whose JWT names them; a machine -// key is not a user, so a route that would otherwise resolve its cashier -// from the request identity refuses the key outright rather than fabricate -// the stand-in UUID the dev-mode fallback mints for keyless demo callers. A -// body-supplied cashier does not help a key either: it names a user the key -// asserts, not one the request authenticated. +// cashier on a counter mutation is a human user whose JWT names them. func refuseMachineKeyCashier(w http.ResponseWriter, r *http.Request) bool { keyID, isKey := middleware.KeyIDFromContext(r.Context()) if !isKey { return false } - reqID := w.Header().Get("X-Request-ID") if reqID == "" { reqID = r.Header.Get("X-Request-ID") } - var body cashierRefusalBody body.Error.Code = "forbidden" body.Error.Message = "a cashier must be a user" body.Meta.RequestID = reqID - slog.Warn("machine key refused on a POS cashier route", - "key_id", keyID, - "status", http.StatusForbidden, - "method", r.Method, - "path", r.URL.Path, - "request_id", reqID, - ) + "key_id", keyID, "status", http.StatusForbidden, "method", r.Method, "path", r.URL.Path, "request_id", reqID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(body) return true } -// CreateReturn records a merchandise return and issues the refund. -func (h *Handler) CreateReturn(w http.ResponseWriter, r *http.Request) { +// cashierOf resolves the acting cashier from the JWT, refusing a machine +// key. +func cashierOf(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { if refuseMachineKeyCashier(w, r) { - return + return uuid.Nil, false } - var req ReturnRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) - return - } - if req.RegisterID == "" { - req.RegisterID = "REG-01" - } - cashierID := uuid.New() // Dev-mode fallback; real deployments carry JWT identity if claims := middleware.ClaimsFromContext(r.Context()); claims != nil && claims.Subject != "" { - if parsed, err := uuid.Parse(claims.Subject); err == nil { - cashierID = parsed + if id, err := uuid.Parse(claims.Subject); err == nil { + return id, true } } - ret, err := h.service.ReturnSale(r.Context(), cashierID, req) - if err != nil { - httputil.RespondError(w, r, err.Error(), http.StatusBadRequest, err) - return - } + return uuid.Nil, true +} + +func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(ret) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) } -// GetReturn returns a single return with its lines. -func (h *Handler) GetReturn(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - httputil.RespondError(w, r, "Invalid return ID", http.StatusBadRequest, err) +// StartTransaction is POST /pos/transactions. +func (h *Handler) StartTransaction(w http.ResponseWriter, r *http.Request) { + cashierID, ok := cashierOf(w, r) + if !ok { return } - ret, err := h.service.GetReturn(r.Context(), id) + var req startSaleRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.parse() if err != nil { - httputil.RespondError(w, r, err.Error(), http.StatusNotFound, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ret) -} - -// ListReturns lists returns (optional register_id + date query params). -func (h *Handler) ListReturns(w http.ResponseWriter, r *http.Request) { - registerID := r.URL.Query().Get("register_id") - var date time.Time - if d := r.URL.Query().Get("date"); d != "" { - if parsed, err := time.Parse("2006-01-02", d); err == nil { - date = parsed - } + if in.CashierID == uuid.Nil { + in.CashierID = cashierID } - list, err := h.service.ListReturns(r.Context(), registerID, date) + sale, err := h.service.StartSale(r.Context(), in, in.CashierID.String()) if err != nil { - httputil.RespondError(w, r, "failed to list returns", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"returns": list}) + w.Header().Set("Location", "/api/v1/pos/transactions/"+sale.ID.String()) + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusCreated, sale) } -// GetZReport returns the immutable Z snapshot for a closed session. -func (h *Handler) GetZReport(w http.ResponseWriter, r *http.Request) { +// GetTransaction is GET /pos/transactions/{id}. +func (h *Handler) GetTransaction(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "Invalid till session ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - z, err := h.service.GetZReport(r.Context(), id) + sale, err := h.service.GetSale(r.Context(), id) if err != nil { - httputil.RespondError(w, r, err.Error(), http.StatusNotFound, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(z) + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -// ListZReports lists Z snapshots (optional register_id + date query params). -func (h *Handler) ListZReports(w http.ResponseWriter, r *http.Request) { - registerID := r.URL.Query().Get("register_id") - var date time.Time - if d := r.URL.Query().Get("date"); d != "" { - if parsed, err := time.Parse("2006-01-02", d); err == nil { - date = parsed - } - } - list, err := h.service.ListZReports(r.Context(), registerID, date) +// AddItem is POST /pos/transactions/{id}/items. +func (h *Handler) AddItem(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "failed to list Z-reports", http.StatusInternalServerError, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"z_reports": list}) -} - -// --- Till handlers --- - -func (h *Handler) OpenTill(w http.ResponseWriter, r *http.Request) { - if refuseMachineKeyCashier(w, r) { + cashierID, ok := cashierOf(w, r) + if !ok { return } - var req OpenTillRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + var req addLineRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - if req.RegisterID == "" { - req.RegisterID = "REG-01" - } - cashierID := uuid.New() // Dev-mode fallback; real deployments carry JWT identity - if claims := middleware.ClaimsFromContext(r.Context()); claims != nil && claims.Subject != "" { - if parsed, err := uuid.Parse(claims.Subject); err == nil { - cashierID = parsed - } + parsed, err := req.parse() + if err != nil { + httpx.WriteError(w, r, err) + return } - session, err := h.service.OpenTill(r.Context(), req.RegisterID, cashierID, int64(req.OpeningFloat*100.0+0.5)) + sale, err := h.service.AddLine(r.Context(), id, parsed, cashierID.String()) if err != nil { - httputil.RespondError(w, r, err.Error(), http.StatusConflict, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(session) + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -func (h *Handler) CurrentTill(w http.ResponseWriter, r *http.Request) { - registerID := r.URL.Query().Get("register_id") - if registerID == "" { - registerID = "REG-01" +// RemoveItem is DELETE /pos/transactions/{id}/items/{itemId}. +func (h *Handler) RemoveItem(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) + return } - session, err := h.service.CurrentTill(r.Context(), registerID) + itemID, err := uuid.Parse(r.PathValue("itemId")) if err != nil { - httputil.RespondError(w, r, "failed to look up till session", http.StatusInternalServerError, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "itemId", Message: "must be a UUID"})) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"session": session}) + sale, err := h.service.RemoveLine(r.Context(), id, itemID) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -func (h *Handler) TillReportHandler(w http.ResponseWriter, r *http.Request) { +// CompleteTransaction is POST /pos/transactions/{id}/complete: the tenders +// in cents, one transaction, everything booked or nothing. +func (h *Handler) CompleteTransaction(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "Invalid till session ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - report, err := h.service.TillReport(r.Context(), id) + var req completeSaleRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + tenders, pickedUpBy, revision, err := req.parse() if err != nil { - httputil.RespondError(w, r, "failed to build till report", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(report) + sale, err := h.service.CompleteSale(r.Context(), id, r.Header.Get("If-Match"), revision, tenders, pickedUpBy, actorFrom(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -func (h *Handler) CloseTill(w http.ResponseWriter, r *http.Request) { +// VoidTransaction is POST /pos/transactions/{id}/void. +func (h *Handler) VoidTransaction(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "Invalid till session ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - var req CloseTillRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + var req voidSaleRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - counted := make(map[string]int64, len(req.CountedByMethod)) - for method, dollars := range req.CountedByMethod { - counted[method] = int64(dollars*100.0 + 0.5) + reason, revision, err := req.parse() + if err != nil { + httpx.WriteError(w, r, err) + return } - report, err := h.service.CloseTill(r.Context(), id, counted, req.Notes) + sale, err := h.service.VoidSale(r.Context(), id, r.Header.Get("If-Match"), revision, reason, actorFrom(r)) if err != nil { - httputil.RespondError(w, r, err.Error(), http.StatusConflict, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(report) -} - -// --- Request types --- - -type startTransactionRequest struct { - RegisterID string `json:"register_id"` - CashierID uuid.UUID `json:"cashier_id"` - CustomerID *uuid.UUID `json:"customer_id,omitempty"` + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -type completeTransactionRequest struct { - Tenders []AddTenderRequest `json:"tenders"` -} - -// --- Handlers --- - -func (h *Handler) StartTransaction(w http.ResponseWriter, r *http.Request) { - if refuseMachineKeyCashier(w, r) { +// ListTransactions is GET /pos/transactions: the list envelope. +func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { + q, err := httpx.StrictQuery(r, "register_id", "date", "status", "limit", "cursor", "include") + if err != nil { + httpx.WriteError(w, r, err) return } - var req startTransactionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + f := SaleFilter{RegisterID: q.Get("register_id")} + if d := q.Get("date"); d != "" { + parsed, err := time.Parse("2006-01-02", d) + if err != nil { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "date", Message: "must be a date as YYYY-MM-DD"})) + return + } + f.Date = &parsed + } + if s := q.Get("status"); s != "" { + switch s { + case "open", "held", "completed", "voided": + f.Status = uppercase(s) + default: + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "status", Message: "must be one of: open, held, completed, voided"})) + return + } + } + limit := 50 + items, err := h.service.repo.ListSales(r.Context(), f, limit) + if err != nil { + httpx.WriteError(w, r, err) return } + httpx.WriteList(w, items, "", limit) +} - if req.RegisterID == "" { - req.RegisterID = "REG-01" +// SearchProducts is GET /pos/products/search: the counter's typeahead (the +// product module's data; a bare array, never null). +func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) { + _, err := httpx.StrictQuery(r, "q", "limit") + if err != nil { + httpx.WriteError(w, r, err) + return } - if req.CashierID == uuid.Nil { - req.CashierID = uuid.New() // Demo fallback + query := r.URL.Query().Get("q") + if query == "" { + writeJSON(w, http.StatusOK, []QuickSearchResult{}) + return } - - tx, err := h.service.StartTransaction(r.Context(), req.RegisterID, req.CashierID, req.CustomerID) + limit := 20 + results, err := h.service.repo.SearchProducts(r.Context(), query, limit) if err != nil { - httputil.RespondError(w, r, "failed to start transaction", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(tx) + if results == nil { + results = []QuickSearchResult{} + } + writeJSON(w, http.StatusOK, results) } -func (h *Handler) GetTransaction(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) +// SyncOffline is POST /pos/sync. +func (h *Handler) SyncOffline(w http.ResponseWriter, r *http.Request) { + var req offlineSyncRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + batch, err := req.parse() if err != nil { - httputil.RespondError(w, r, "Invalid transaction ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } - - tx, err := h.service.GetTransaction(r.Context(), id) + resp, err := h.service.SyncOfflineTransactions(r.Context(), batch, actorFrom(r)) if err != nil { - httputil.RespondError(w, r, "transaction not found", http.StatusNotFound, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tx) + writeJSON(w, http.StatusOK, resp) } -func (h *Handler) AddItem(w http.ResponseWriter, r *http.Request) { - txID, err := uuid.Parse(r.PathValue("id")) +// GetCatalog is GET /pos/catalog: the offline cache (a bare array, never +// null). +func (h *Handler) GetCatalog(w http.ResponseWriter, r *http.Request) { + if _, err := httpx.StrictQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + catalog, err := h.service.repo.GetProductCatalog(r.Context()) if err != nil { - httputil.RespondError(w, r, "Invalid transaction ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } + if catalog == nil { + catalog = []CatalogProduct{} + } + writeJSON(w, http.StatusOK, catalog) +} - var req AddLineItemRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) +// OpenTill is POST /pos/till/open. +func (h *Handler) OpenTill(w http.ResponseWriter, r *http.Request) { + cashierID, ok := cashierOf(w, r) + if !ok { return } - - tx, err := h.service.AddItem(r.Context(), txID, req) + var req openTillRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + register, cents, err := req.parse() if err != nil { - httputil.RespondError(w, r, "failed to add item", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tx) + session, err := h.service.OpenTill(r.Context(), register, cashierID, cents, cashierID.String()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + w.Header().Set("Location", "/api/v1/pos/till/"+session.ID.String()+"/report") + writeJSON(w, http.StatusCreated, session) } -func (h *Handler) RemoveItem(w http.ResponseWriter, r *http.Request) { - txID, err := uuid.Parse(r.PathValue("id")) +// CurrentTill is GET /pos/till/current. +func (h *Handler) CurrentTill(w http.ResponseWriter, r *http.Request) { + q, err := httpx.StrictQuery(r, "register_id") if err != nil { - httputil.RespondError(w, r, "Invalid transaction ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } - - itemID, err := uuid.Parse(r.PathValue("itemId")) + register := q.Get("register_id") + if register == "" { + register = "REG-01" + } + session, err := h.service.CurrentTill(r.Context(), register) if err != nil { - httputil.RespondError(w, r, "Invalid item ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } + writeJSON(w, http.StatusOK, session) +} - tx, err := h.service.RemoveItem(r.Context(), txID, itemID) +// TillReportHandler is GET /pos/till/{id}/report. +func (h *Handler) TillReportHandler(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "failed to remove item", http.StatusInternalServerError, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tx) + report, err := h.service.TillReport(r.Context(), id) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, report) } -func (h *Handler) CompleteTransaction(w http.ResponseWriter, r *http.Request) { - txID, err := uuid.Parse(r.PathValue("id")) +// CloseTill is POST /pos/till/{id}/close. +func (h *Handler) CloseTill(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "Invalid transaction ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - - var req completeTransactionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + cashierID, ok := cashierOf(w, r) + if !ok { return } - - if len(req.Tenders) == 0 { - httputil.RespondError(w, r, "At least one tender is required", http.StatusBadRequest, nil) + var req closeTillRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - - tx, err := h.service.CompleteTransaction(r.Context(), txID, req.Tenders) + counted, notes, err := req.parse() if err != nil { - httputil.RespondError(w, r, "failed to complete transaction", http.StatusUnprocessableEntity, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tx) + report, err := h.service.CloseTill(r.Context(), id, counted, notes, cashierID.String()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, report) } -func (h *Handler) VoidTransaction(w http.ResponseWriter, r *http.Request) { - txID, err := uuid.Parse(r.PathValue("id")) +// GetZReport is GET /pos/till/{id}/zreport. +func (h *Handler) GetZReport(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "Invalid transaction ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - - tx, err := h.service.VoidTransaction(r.Context(), txID) + z, err := h.service.GetZReport(r.Context(), id) if err != nil { - httputil.RespondError(w, r, "failed to void transaction", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tx) + writeJSON(w, http.StatusOK, z) } -func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { - registerID := r.URL.Query().Get("register_id") - dateStr := r.URL.Query().Get("date") - +// ListZReports is GET /pos/zreports. +func (h *Handler) ListZReports(w http.ResponseWriter, r *http.Request) { + q, err := httpx.StrictQuery(r, "register_id", "date") + if err != nil { + httpx.WriteError(w, r, err) + return + } date := time.Now() - if dateStr != "" { - parsed, err := time.Parse("2006-01-02", dateStr) - if err == nil { - date = parsed + if d := q.Get("date"); d != "" { + parsed, err := time.Parse("2006-01-02", d) + if err != nil { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "date", Message: "must be a date as YYYY-MM-DD"})) + return } + date = parsed } - - summaries, err := h.service.ListTransactions(r.Context(), registerID, date) + list, err := h.service.ListZReports(r.Context(), q.Get("register_id"), date) if err != nil { - httputil.RespondError(w, r, "failed to list transactions", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - - if summaries == nil { - summaries = []TransactionSummary{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(summaries) + writeJSON(w, http.StatusOK, list) } -func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("q") - if query == "" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]QuickSearchResult{}) +// CreateReturn is POST /pos/returns. +func (h *Handler) CreateReturn(w http.ResponseWriter, r *http.Request) { + cashierID, ok := cashierOf(w, r) + if !ok { return } - - results, err := h.service.SearchProducts(r.Context(), query) - if err != nil { - httputil.RespondError(w, r, "product search failed", http.StatusInternalServerError, err) + var req returnRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - - if results == nil { - results = []QuickSearchResult{} + in, err := req.parse() + if err != nil { + httpx.WriteError(w, r, err) + return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(results) -} - -// SyncOffline handles POST /api/pos/sync — replays offline POS transactions. -func (h *Handler) SyncOffline(w http.ResponseWriter, r *http.Request) { - var req OfflineSyncRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + ret, err := h.service.ReturnSale(r.Context(), cashierID, in, cashierID.String()) + if err != nil { + httpx.WriteError(w, r, err) return } + w.Header().Set("Location", "/api/v1/pos/returns/"+ret.ID.String()) + writeJSON(w, http.StatusCreated, ret) +} - if req.BatchID == "" { - httputil.RespondError(w, r, "batch_id is required", http.StatusBadRequest, nil) +// GetReturn is GET /pos/returns/{id}. +func (h *Handler) GetReturn(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - if len(req.Items) == 0 { - httputil.RespondError(w, r, "items cannot be empty", http.StatusBadRequest, nil) + ret, err := h.service.GetReturn(r.Context(), id) + if err != nil { + httpx.WriteError(w, r, err) return } + writeJSON(w, http.StatusOK, ret) +} - resp, err := h.service.SyncOfflineTransactions(r.Context(), req) +// ListReturns is GET /pos/returns. +func (h *Handler) ListReturns(w http.ResponseWriter, r *http.Request) { + q, err := httpx.StrictQuery(r, "register_id", "date") if err != nil { - httputil.RespondError(w, r, "offline sync failed", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// GetCatalog handles GET /api/pos/catalog — returns full product catalog for offline cache. -func (h *Handler) GetCatalog(w http.ResponseWriter, r *http.Request) { - catalog, err := h.service.GetProductCatalog(r.Context()) + f := ReturnFilter{RegisterID: q.Get("register_id")} + if d := q.Get("date"); d != "" { + parsed, err := time.Parse("2006-01-02", d) + if err != nil { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "date", Message: "must be a date as YYYY-MM-DD"})) + return + } + f.Date = &parsed + } + list, err := h.service.ListReturns(r.Context(), f) if err != nil { - httputil.RespondError(w, r, "failed to get catalog", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } + writeJSON(w, http.StatusOK, list) +} - if catalog == nil { - catalog = []CatalogProduct{} +// actorFrom reads the acting user's subject for audit rows. +func actorFrom(r *http.Request) string { + if claims := middleware.ClaimsFromContext(r.Context()); claims != nil { + return claims.Subject } + return "" +} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(catalog) +func uppercase(s string) string { + out := []byte(s) + for i := range out { + if out[i] >= 'a' && out[i] <= 'z' { + out[i] -= 'a' - 'A' + } + } + return string(out) } diff --git a/core/internal/pos/handler_machinekey_test.go b/core/internal/pos/handler_machinekey_test.go deleted file mode 100644 index 68e44c39e..000000000 --- a/core/internal/pos/handler_machinekey_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package pos_test - -import ( - "encoding/json" - "log/slog" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gablelbm/gable/internal/account" - "github.com/gablelbm/gable/internal/gl" - glint "github.com/gablelbm/gable/internal/integrations/gl" - "github.com/gablelbm/gable/internal/inventory" - "github.com/gablelbm/gable/internal/invoice" - "github.com/gablelbm/gable/internal/payment" - "github.com/gablelbm/gable/internal/pos" - "github.com/gablelbm/gable/internal/product" - "github.com/gablelbm/gable/internal/testutil" - "github.com/gablelbm/gable/pkg/audit" - "github.com/gablelbm/gable/pkg/middleware" - "github.com/google/uuid" -) - -// newHandlerMux builds the POS handler over the real service stack, exactly -// as cmd/server wires it, and registers its routes on a fresh mux. -func newHandlerMux(t *testing.T) *http.ServeMux { - t.Helper() - db := testutil.RequireDB(t) - logger := slog.Default() - - glSvc := gl.NewService(gl.NewRepository(db), glint.NewMockGLAdapter(), logger) - accountSvc := account.NewService(db, glSvc, logger) - invoiceSvc := invoice.NewService(invoice.NewRepository(db), glSvc, accountSvc, db). - WithAuditLog(audit.NewLogger(db)) - paymentSvc := payment.NewService(db, payment.NewRepository(db), accountSvc). - WithAuditLog(audit.NewLogger(db)) - svc := pos.NewService(db, pos.NewRepository(db), - product.NewService(product.NewRepository(db)), - inventory.NewService(inventory.NewRepository(db)), - invoiceSvc, paymentSvc, logger). - WithAuditLog(audit.NewLogger(db)) - - mux := http.NewServeMux() - pos.NewHandler(svc).RegisterRoutes(mux) - return mux -} - -// withMachineKey stands where the machine-key auth core sits: once a key -// validates it puts the key's id in the request context, and downstream -// handlers see it (the scopes the core also sets are irrelevant here, these -// routes have no scope-based branching of their own). -func withMachineKey(keyID string, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r.WithContext(middleware.WithKeyID(r.Context(), keyID))) - }) -} - -// keyRefusalBody decodes the refusal envelope the handler writes. -type keyRefusalBody struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - Meta struct { - RequestID string `json:"request_id"` - } `json:"meta"` -} - -// TestMachineKeyNeverGetsFabricatedCashier drives the three POS routes whose -// handlers fall back to a fabricated cashier when no user identity is on the -// request. A machine key has no user identity, so before this fix a -// pos:write key reaching these routes had its sales and drawer sessions -// attributed to a random UUID no operator ever met. The routes now refuse a -// machine key with 403 forbidden and a message saying a cashier must be a -// user; callers without a key (dev mode's unauthenticated demo) keep the -// fallback. -func TestMachineKeyNeverGetsFabricatedCashier(t *testing.T) { - mux := newHandlerMux(t) - keyID := uuid.New().String() - keyed := withMachineKey(keyID, mux) - - routes := []string{ - "/api/v1/pos/transactions", - "/api/v1/pos/till/open", - "/api/v1/pos/returns", - } - for _, route := range routes { - t.Run(route, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, route, - strings.NewReader(`{"register_id":"KEYTEST-`+keyID[:8]+`"}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Request-ID", "req-pos-key-test") - rec := httptest.NewRecorder() - keyed.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("machine key on %s: status = %d, want 403; body: %s", route, rec.Code, rec.Body.String()) - } - var body keyRefusalBody - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatalf("decode refusal envelope: %v (body: %s)", err, rec.Body.String()) - } - if body.Error.Code != "forbidden" { - t.Errorf("code = %q, want forbidden", body.Error.Code) - } - if !strings.Contains(body.Error.Message, "a cashier must be a user") { - t.Errorf("message = %q, want it to say a cashier must be a user", body.Error.Message) - } - }) - } - - // The dev-mode control: the same routes without a key keep serving their - // unauthenticated callers (the fabricated stand-in), so the demo POS - // still works; only the keyed caller is refused. - for _, route := range routes { - t.Run("dev "+route, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, route, - strings.NewReader(`{"register_id":"KEYTEST-DEV-`+keyID[:8]+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - - if rec.Code == http.StatusForbidden { - t.Fatalf("keyless dev caller on %s: status 403, want the dev fallback (body: %s)", route, rec.Body.String()) - } - }) - } -} diff --git a/core/internal/pos/input.go b/core/internal/pos/input.go new file mode 100644 index 000000000..69618f3ae --- /dev/null +++ b/core/internal/pos/input.go @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "encoding/json" + "fmt" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/google/uuid" +) + +// isAbsentRaw reports whether a raw JSON field was missing or null. +func isAbsentRaw(raw json.RawMessage) bool { + s := string(raw) + return s == "" || s == "null" +} + +// startSaleRequest is POST /pos/transactions. +type startSaleRequest struct { + RegisterID *string `json:"register_id"` + CashierID *string `json:"cashier_id"` + CustomerID *string `json:"customer_id"` +} + +func (r *startSaleRequest) parse() (*StartSale, error) { + v := &httpx.Validator{} + out := &StartSale{} + if r.RegisterID != nil { + out.RegisterID = *r.RegisterID + } + if out.RegisterID == "" { + out.RegisterID = "REG-01" + } + if id, ok := v.UUID("cashier_id", r.CashierID, false); ok { + out.CashierID = id + } + if id, ok := v.UUID("customer_id", r.CustomerID, false); ok { + out.CustomerID = &id + } + if err := v.Err(); err != nil { + return nil, err + } + return out, nil +} + +// StartSale is a parsed counter sale create. +type StartSale struct { + RegisterID string + CashierID uuid.UUID + CustomerID *uuid.UUID +} + +// addLineRequest is POST /pos/transactions/{id}/items: one line in the shared +// shape (ADR 0005 section 2), parsed by salesdoc.ParseLines. +type addLineRequest struct { + Line salesdoc.LineRequest `json:"line"` +} + +func (r *addLineRequest) parse() ([]salesdoc.ParsedLine, error) { + v := &httpx.Validator{} + lines := salesdoc.ParseLines(v, []salesdoc.LineRequest{r.Line}) + if err := v.Err(); err != nil { + return nil, err + } + return lines, nil +} + +// completeSaleRequest is POST /pos/transactions/{id}/complete: the tenders in +// cents (IN-1.2) and who collected the basket. +type completeSaleRequest struct { + Tenders []tenderRequest `json:"tenders"` + PickedUpBy *string `json:"picked_up_by"` + Revision json.RawMessage `json:"revision"` +} + +type tenderRequest struct { + Method string `json:"method"` + AmountCents json.RawMessage `json:"amount_cents"` + Reference *string `json:"reference"` + TokenID *string `json:"token_id"` +} + +func parseTenders(v *httpx.Validator, prefix string, tenders []tenderRequest) []TenderIn { + out := make([]TenderIn, 0, len(tenders)) + for i := range tenders { + t := &tenders[i] + field := func(name string) string { return fmt.Sprintf("%s[%d].%s", prefix, i, name) } + in := TenderIn{} + switch t.Method { + case "cash": + in.Method = TenderCash + case "check": + in.Method = TenderCheck + case "card": + in.Method = TenderCard + case "account": + in.Method = TenderAccount + default: + v.Check(false, field("method"), "must be cash, check, card or account") + } + if n, ok := v.Int(field("amount_cents"), t.AmountCents, true); ok { + v.Check(n > 0, field("amount_cents"), "must be greater than zero") + in.AmountCents = httpx.Cents(n) + } + if t.Reference != nil { + in.Reference = *t.Reference + } + if t.TokenID != nil { + in.TokenID = *t.TokenID + } + out = append(out, in) + } + return out +} + +func (r *completeSaleRequest) parse() ([]TenderIn, string, *int64, error) { + v := &httpx.Validator{} + if len(r.Tenders) == 0 { + v.Check(false, "tenders", "name at least one tender") + } + tenders := parseTenders(v, "tenders", r.Tenders) + pickedUpBy := "" + if r.PickedUpBy != nil { + pickedUpBy = *r.PickedUpBy + if pickedUpBy != "" { + v.Check(len(pickedUpBy) <= 200, "picked_up_by", "must be at most 200 characters") + } + } + var revision *int64 + if !isAbsentRaw(r.Revision) { + if n, ok := v.Int("revision", r.Revision, true); ok { + v.Check(n >= 1, "revision", "must be a revision number, 1 or more") + revision = &n + } + } + if err := v.Err(); err != nil { + return nil, "", nil, err + } + return tenders, pickedUpBy, revision, nil +} + +// voidSaleRequest is POST /pos/transactions/{id}/void. +type voidSaleRequest struct { + Reason *string `json:"reason"` + Revision json.RawMessage `json:"revision"` +} + +func (r *voidSaleRequest) parse() (string, *int64, error) { + v := &httpx.Validator{} + reason := "" + if r.Reason != nil { + reason = *r.Reason + } + v.Required("reason", reason) + var revision *int64 + if !isAbsentRaw(r.Revision) { + if n, ok := v.Int("revision", r.Revision, true); ok { + v.Check(n >= 1, "revision", "must be a revision number, 1 or more") + revision = &n + } + } + if err := v.Err(); err != nil { + return "", nil, err + } + return reason, revision, nil +} + +// returnRequest is POST /pos/returns. +type returnRequest struct { + RegisterID *string `json:"register_id"` + OriginalSale *string `json:"original_sale_id"` + CustomerID *string `json:"customer_id"` + RefundMethod string `json:"refund_method"` + Reason *string `json:"reason"` + GatewayTxID *string `json:"gateway_tx_id"` + Lines []returnLineRequest `json:"lines"` +} + +type returnLineRequest struct { + ProductID *string `json:"product_id"` + LineID *string `json:"line_id"` + Description *string `json:"description"` + Quantity json.RawMessage `json:"quantity"` + UnitPrice json.RawMessage `json:"unit_price_ten_thousandths"` + Restock *bool `json:"restock"` +} + +func (r *returnRequest) parse() (*ReturnIn, error) { + v := &httpx.Validator{} + out := &ReturnIn{} + if r.RegisterID != nil { + out.RegisterID = *r.RegisterID + } + if out.RegisterID == "" { + out.RegisterID = "REG-01" + } + if id, ok := v.UUID("original_sale_id", r.OriginalSale, false); ok { + out.OriginalSaleID = &id + } + if id, ok := v.UUID("customer_id", r.CustomerID, false); ok { + out.CustomerID = &id + } + switch r.RefundMethod { + case "": + out.RefundMethod = RefundCash + case "cash": + out.RefundMethod = RefundCash + case "card": + out.RefundMethod = RefundCard + case "account": + out.RefundMethod = RefundAccount + default: + v.Check(false, "refund_method", "must be cash, card or account") + } + if r.Reason != nil { + out.Reason = *r.Reason + } + v.Required("reason", out.Reason) + if r.GatewayTxID != nil { + out.GatewayTxID = *r.GatewayTxID + } + if len(r.Lines) == 0 { + v.Check(false, "lines", "name at least one returned line") + } + for i := range r.Lines { + l := &r.Lines[i] + field := func(name string) string { return fmt.Sprintf("lines[%d].%s", i, name) } + rl := ReturnLineIn{Restock: true} + if id, ok := v.UUID(field("product_id"), l.ProductID, true); ok { + rl.ProductID = &id + } + if id, ok := v.UUID(field("line_id"), l.LineID, false); ok { + rl.SaleLineID = &id + } + if l.Description != nil { + rl.Description = *l.Description + } + if q, ok := v.Quantity(field("quantity"), l.Quantity, true); ok { + v.Check(q > 0, field("quantity"), "must be greater than zero") + rl.Quantity = q + } + if !isAbsentRaw(l.UnitPrice) { + if n, ok := v.Int(field("unit_price_ten_thousandths"), l.UnitPrice, true); ok { + v.Check(n >= 0, field("unit_price_ten_thousandths"), "a unit price is never negative") + p := httpx.Price(n) + rl.UnitPrice = &p + } + } + if l.Restock != nil { + rl.Restock = *l.Restock + } + out.Lines = append(out.Lines, rl) + } + if err := v.Err(); err != nil { + return nil, err + } + return out, nil +} + +// openTillRequest is POST /pos/till/open. +type openTillRequest struct { + RegisterID *string `json:"register_id"` + OpeningFloat json.RawMessage `json:"opening_float_cents"` +} + +func (r *openTillRequest) parse() (string, httpx.Cents, error) { + v := &httpx.Validator{} + register := "" + if r.RegisterID != nil { + register = *r.RegisterID + } + if register == "" { + register = "REG-01" + } + var cents httpx.Cents + if !isAbsentRaw(r.OpeningFloat) { + if n, ok := v.Int("opening_float_cents", r.OpeningFloat, true); ok { + v.Check(n >= 0, "opening_float_cents", "must not be negative") + cents = httpx.Cents(n) + } + } + if err := v.Err(); err != nil { + return "", 0, err + } + return register, cents, nil +} + +// closeTillRequest is POST /pos/till/{id}/close. +type closeTillRequest struct { + CountedByMethod map[string]json.RawMessage `json:"counted_by_method"` + Notes *string `json:"notes"` +} + +func (r *closeTillRequest) parse() (map[string]int64, string, error) { + v := &httpx.Validator{} + counted := make(map[string]int64, len(r.CountedByMethod)) + for m, raw := range r.CountedByMethod { + if _, ok := ParseTenderMethod(m); !ok { + v.Check(false, "counted_by_method."+m, "must be cash, check, card or account") + continue + } + if isAbsentRaw(raw) { + continue + } + if n, ok := v.Int("counted_by_method."+m, raw, true); ok { + v.Check(n >= 0, "counted_by_method."+m, "must not be negative") + counted[m] = n + } + } + if err := v.Err(); err != nil { + return nil, "", err + } + notes := "" + if r.Notes != nil { + notes = *r.Notes + } + return counted, notes, nil +} + +// offlineSyncRequest is POST /pos/sync: a batch of completed sales captured +// offline, replayed through the same completion path. +type offlineSyncRequest struct { + BatchID *string `json:"batch_id"` + RegisterID *string `json:"register_id"` + Items []offlineSaleRequest `json:"items"` +} + +type offlineSaleRequest struct { + ClientID *string `json:"client_id"` + CashierID *string `json:"cashier_id"` + CustomerID *string `json:"customer_id"` + Items []salesdoc.LineRequest `json:"items"` + Tenders []tenderRequest `json:"tenders"` + ClientCreatedAt json.RawMessage `json:"client_created_at"` +} + +func (r *offlineSyncRequest) parse() (*OfflineSync, error) { + v := &httpx.Validator{} + out := &OfflineSync{} + if r.BatchID != nil { + out.BatchID = *r.BatchID + } + v.Required("batch_id", out.BatchID) + if r.RegisterID != nil { + out.RegisterID = *r.RegisterID + } + v.Check(len(r.Items) > 0, "items", "cannot be empty") + for i := range r.Items { + it := &r.Items[i] + field := func(name string) string { return fmt.Sprintf("items[%d].%s", i, name) } + sale := OfflineSale{} + if id, ok := v.UUID(field("client_id"), it.ClientID, true); ok { + sale.ClientID = id + } + if id, ok := v.UUID(field("cashier_id"), it.CashierID, false); ok { + sale.CashierID = id + } + if id, ok := v.UUID(field("customer_id"), it.CustomerID, false); ok { + sale.CustomerID = &id + } + if !isAbsentRaw(it.ClientCreatedAt) { + if ts, ok := v.Timestamp(field("client_created_at"), it.ClientCreatedAt, true); ok { + sale.ClientCreatedAt = ts + } + } + sale.Lines = salesdoc.ParseLines(v, it.Items) + v.Check(len(it.Tenders) > 0, field("tenders"), "name at least one tender") + sale.Tenders = parseTenders(v, field("tenders"), it.Tenders) + out.Items = append(out.Items, sale) + } + if err := v.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/core/internal/pos/model.go b/core/internal/pos/model.go index c86e88dfe..5779dfe4c 100644 --- a/core/internal/pos/model.go +++ b/core/internal/pos/model.go @@ -4,168 +4,262 @@ package pos import ( + "strings" "time" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" "github.com/google/uuid" ) -// TransactionStatus tracks the lifecycle of a POS transaction. -type TransactionStatus string +// SaleStatus is a counter sale's lifecycle state. Storage keeps the +// uppercase vocabulary; the wire is lowercase (ADR 0001 section 6). +type SaleStatus string const ( - TransactionStatusOpen TransactionStatus = "OPEN" - TransactionStatusCompleted TransactionStatus = "COMPLETED" - TransactionStatusVoided TransactionStatus = "VOIDED" - TransactionStatusReturned TransactionStatus = "RETURNED" - TransactionStatusHeld TransactionStatus = "HELD" + StatusOpen SaleStatus = "OPEN" + StatusHeld SaleStatus = "HELD" + StatusCompleted SaleStatus = "COMPLETED" + StatusVoided SaleStatus = "VOIDED" ) -// POSTransaction represents a retail point-of-sale transaction. -type POSTransaction struct { - ID uuid.UUID `json:"id" db:"id"` - BranchID uuid.UUID `json:"branch_id" db:"branch_id"` - RegisterID string `json:"register_id" db:"register_id"` - CashierID uuid.UUID `json:"cashier_id" db:"cashier_id"` - CustomerID *uuid.UUID `json:"customer_id,omitempty" db:"customer_id"` - Subtotal int64 `json:"subtotal" db:"subtotal"` // Cents - TaxAmount int64 `json:"tax_amount" db:"tax_amount"` // Cents - Total int64 `json:"total" db:"total"` // Cents - ChangeDue int64 `json:"change_due" db:"change_due"` // Cents, set at completion - TillSessionID *uuid.UUID `json:"till_session_id,omitempty" db:"till_session_id"` - Status TransactionStatus `json:"status" db:"status"` - CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - - // Offline sync fields - SyncedFrom *string `json:"synced_from,omitempty" db:"synced_from"` // nil = live, "offline-v1" = synced - ClientCreatedAt *time.Time `json:"client_created_at,omitempty" db:"client_created_at"` // original offline timestamp - - // Populated on read - LineItems []POSLineItem `json:"line_items,omitempty"` - Tenders []POSTender `json:"tenders,omitempty"` -} +// Status returns the wire spelling. +func (s SaleStatus) Status() string { return strings.ToLower(string(s)) } -// POSLineItem represents a product line within a POS transaction. -type POSLineItem struct { - ID uuid.UUID `json:"id" db:"id"` - TransactionID uuid.UUID `json:"transaction_id" db:"transaction_id"` - ProductID uuid.UUID `json:"product_id" db:"product_id"` - Description string `json:"description" db:"description"` - Quantity float64 `json:"quantity" db:"quantity"` - UOM string `json:"uom" db:"uom"` - UnitPrice int64 `json:"unit_price" db:"unit_price"` // Cents - LineTotal int64 `json:"line_total" db:"line_total"` // Cents - CreatedAt time.Time `json:"created_at" db:"created_at"` -} +// MarshalText writes the lowercase wire name. +func (s SaleStatus) MarshalText() ([]byte, error) { return []byte(s.Status()), nil } -// POSTender represents a payment method applied to a POS transaction. -type POSTender struct { - ID uuid.UUID `json:"id" db:"id"` - TransactionID uuid.UUID `json:"transaction_id" db:"transaction_id"` - Method string `json:"method" db:"method"` // CASH, CARD, CHECK, ACCOUNT - Amount int64 `json:"amount" db:"amount"` // Cents - Reference string `json:"reference,omitempty" db:"reference"` - CardLast4 string `json:"card_last4,omitempty" db:"card_last4"` - CardBrand string `json:"card_brand,omitempty" db:"card_brand"` - GatewayTxID string `json:"gateway_tx_id,omitempty" db:"gateway_tx_id"` - AuthCode string `json:"auth_code,omitempty" db:"auth_code"` - CreatedAt time.Time `json:"created_at" db:"created_at"` -} +// TenderMethod is how a sale was paid. Storage keeps the uppercase +// vocabulary; the wire is lowercase. +type TenderMethod string + +const ( + TenderCash TenderMethod = "CASH" + TenderCheck TenderMethod = "CHECK" + TenderCard TenderMethod = "CARD" + TenderAccount TenderMethod = "ACCOUNT" +) + +// Status returns the wire spelling. +func (m TenderMethod) Status() string { return strings.ToLower(string(m)) } -// POSRegister represents a physical or virtual POS register. -type POSRegister struct { - ID string `json:"id" db:"id"` - LocationID *uuid.UUID `json:"location_id,omitempty" db:"location_id"` - BranchID *uuid.UUID `json:"branch_id,omitempty" db:"branch_id"` // Derived from locations.branch_id - Name string `json:"name" db:"name"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` +// MarshalText writes the lowercase wire name. +func (m TenderMethod) MarshalText() ([]byte, error) { return []byte(m.Status()), nil } + +// ParseTenderMethod maps a lowercase wire name to its method. +func ParseTenderMethod(name string) (TenderMethod, bool) { + switch name { + case "cash": + return TenderCash, true + case "check": + return TenderCheck, true + case "card": + return TenderCard, true + case "account": + return TenderAccount, true + } + return "", false } -// --- Request/Response Types --- +// RefundMethod is how a return is refunded: out of the drawer, back on the +// card, or left as account credit. +type RefundMethod string + +const ( + RefundCash RefundMethod = "CASH" + RefundCard RefundMethod = "CARD" + RefundAccount RefundMethod = "ACCOUNT" +) + +// Status returns the wire spelling. +func (m RefundMethod) Status() string { return strings.ToLower(string(m)) } + +// MarshalText writes the lowercase wire name. +func (m RefundMethod) MarshalText() ([]byte, error) { return []byte(m.Status()), nil } -// AddLineItemRequest is sent when scanning/adding a product to the cart. -type AddLineItemRequest struct { - ProductID uuid.UUID `json:"product_id"` - Quantity float64 `json:"quantity"` - UOM string `json:"uom"` +// Sale is one counter sale on the wire (ADR 0005 section 14.2 C2-5): the +// cart the register builds, then the completed money moment, all money in +// cents and prices at scale 4, the lines in the shared shape of section 2.2. +type Sale struct { + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Revision int64 `json:"revision"` + BranchID uuid.UUID `json:"branch_id"` + RegisterID string `json:"register_id"` + CashierID uuid.UUID `json:"cashier_id"` + CustomerID *uuid.UUID `json:"customer_id"` + Currency string `json:"currency"` + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TotalCents httpx.Cents `json:"total_cents"` + ChangeCents httpx.Cents `json:"change_cents"` + TillSessionID *uuid.UUID `json:"till_session_id"` + Status SaleStatus `json:"status"` + InvoiceID *uuid.UUID `json:"invoice_id"` + CompletedAt *httpx.Timestamp `json:"completed_at"` + CreatedAt httpx.Timestamp `json:"created_at"` + + Lines []salesdoc.Line `json:"lines"` + Tenders []Tender `json:"tenders"` + + // WalkInID is the walk-in customer the invoice falls back to when the + // sale names no customer; never on the wire. + WalkInID uuid.UUID `json:"-"` } -// AddTenderRequest is sent when applying a payment method. -type AddTenderRequest struct { - Method string `json:"method"` - Amount float64 `json:"amount"` // Dollars - Reference string `json:"reference,omitempty"` - TokenID string `json:"token_id,omitempty"` // For card payments via Run Payments +// SaleSummary is the list item; the full document embeds it, so the two +// cannot drift (the recipe's rule). +type SaleSummary struct { + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Revision int64 `json:"revision"` + BranchID uuid.UUID `json:"branch_id"` + RegisterID string `json:"register_id"` + CashierID uuid.UUID `json:"cashier_id"` + CustomerID *uuid.UUID `json:"customer_id"` + Currency string `json:"currency"` + TotalCents httpx.Cents `json:"total_cents"` + Status SaleStatus `json:"status"` + InvoiceID *uuid.UUID `json:"invoice_id"` + CompletedAt *httpx.Timestamp `json:"completed_at"` + CreatedAt httpx.Timestamp `json:"created_at"` + ItemCount int `json:"item_count"` } -// QuickSearchRequest is the typeahead product search. -type QuickSearchRequest struct { - Query string `json:"query"` // SKU, description, or barcode +// Tender is one payment taken at the counter, stored net: the tendered +// amount less any change given from it, so the payment it became is the +// money kept (ADR 0005 section 14.2 C2-5). +type Tender struct { + ID uuid.UUID `json:"id"` + SaleID uuid.UUID `json:"sale_id"` + Method TenderMethod `json:"method"` + AmountCents httpx.Cents `json:"amount_cents"` + PaymentID *uuid.UUID `json:"payment_id"` + Reference *string `json:"reference"` + CardLast4 *string `json:"card_last4"` + CardBrand *string `json:"card_brand"` + GatewayTxID *string `json:"gateway_tx_id"` + AuthCode *string `json:"auth_code"` + CreatedAt httpx.Timestamp `json:"created_at"` } -// QuickSearchResult is a lightweight product result for POS. -type QuickSearchResult struct { - ProductID uuid.UUID `json:"product_id"` - SKU string `json:"sku"` - Description string `json:"description"` - UnitPrice float64 `json:"unit_price"` // Dollars - UOM string `json:"uom"` - InStock float64 `json:"in_stock"` +// Return is one counter return: a credit memo created and posted in one act +// (ADR 0005 section 14.2 C2-5), refunded in cash or card or left as account +// credit. +type Return struct { + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Revision int64 `json:"revision"` + BranchID *uuid.UUID `json:"branch_id"` + RegisterID string `json:"register_id"` + TillSessionID *uuid.UUID `json:"till_session_id"` + OriginalSaleID *uuid.UUID `json:"original_sale_id"` + CustomerID *uuid.UUID `json:"customer_id"` + CashierID uuid.UUID `json:"cashier_id"` + Currency string `json:"currency"` + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TotalCents httpx.Cents `json:"total_cents"` + RefundMethod RefundMethod `json:"refund_method"` + Reason string `json:"reason"` + CreditMemoID *uuid.UUID `json:"credit_memo_id"` + CreatedAt httpx.Timestamp `json:"created_at"` + + Lines []ReturnLine `json:"lines"` } -// TransactionSummary is a lightweight view for transaction history. -type TransactionSummary struct { - ID uuid.UUID `json:"id"` - RegisterID string `json:"register_id"` - Total int64 `json:"total"` - Status TransactionStatus `json:"status"` - ItemCount int `json:"item_count"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - CreatedAt time.Time `json:"created_at"` +// ReturnLine is one returned line, negative quantities and extensions (the +// credit memo line shape). +type ReturnLine struct { + ID uuid.UUID `json:"id"` + Position int `json:"position"` + ProductID *uuid.UUID `json:"product_id"` + Description string `json:"description"` + Quantity *httpx.Quantity `json:"quantity"` + UOM *string `json:"uom"` + UnitPrice *httpx.Price `json:"unit_price_ten_thousandths"` + LineTotal *httpx.Cents `json:"line_total_cents"` + Restock bool `json:"restock"` } -// --- Offline Sync DTOs --- +// TillSessionStatus is the drawer lifecycle. +type TillSessionStatus string + +const ( + TillOpen TillSessionStatus = "OPEN" + TillClosed TillSessionStatus = "CLOSED" +) + +// Status returns the wire spelling. +func (s TillSessionStatus) Status() string { return strings.ToLower(string(s)) } + +// MarshalText writes the lowercase wire name. +func (s TillSessionStatus) MarshalText() ([]byte, error) { return []byte(s.Status()), nil } -// OfflineSyncRequest is a batch of completed transactions from an offline POS. -type OfflineSyncRequest struct { - BatchID string `json:"batch_id"` - RegisterID string `json:"register_id"` - Items []OfflineTransaction `json:"items"` +// TillSession is one drawer shift on a register. The expected cash is the +// sum of the session's cash payments (already net of change) plus the opening +// float less cash refunds (ADR 0005 section 14.2 C2-5). +type TillSession struct { + ID uuid.UUID `json:"id"` + RegisterID string `json:"register_id"` + BranchID *uuid.UUID `json:"branch_id"` + CashierID uuid.UUID `json:"cashier_id"` + Status TillSessionStatus `json:"status"` + OpeningFloat httpx.Cents `json:"opening_float_cents"` + OpenedAt httpx.Timestamp `json:"opened_at"` + ClosedAt *httpx.Timestamp `json:"closed_at"` + + ExpectedByMethod map[string]int64 `json:"expected_by_method"` + CountedByMethod map[string]int64 `json:"counted_by_method"` + OverShort *httpx.Cents `json:"over_short_cents"` + GLEntryID *uuid.UUID `json:"gl_entry_id"` + Notes string `json:"notes"` } -// OfflineTransaction is a single completed POS transaction captured offline. -type OfflineTransaction struct { - ClientID uuid.UUID `json:"client_id"` // client-generated UUID - RegisterID string `json:"register_id"` - CashierID uuid.UUID `json:"cashier_id"` - CustomerID *uuid.UUID `json:"customer_id,omitempty"` - Items []AddLineItemRequest `json:"items"` - Tenders []AddTenderRequest `json:"tenders"` - ClientCreatedAt time.Time `json:"client_created_at"` +// TillReport is the live (X) or closing (Z) summary for a session. +type TillReport struct { + Session TillSession `json:"session"` + SaleCount int `json:"sale_count"` + SalesTotalCents httpx.Cents `json:"sales_total_cents"` + TaxTotalCents httpx.Cents `json:"tax_total_cents"` + ChangeCents httpx.Cents `json:"change_cents"` + TenderedByMethod map[string]int64 `json:"tendered_by_method"` + ExpectedByMethod map[string]int64 `json:"expected_by_method"` } -// OfflineSyncResponse reports results of a batch sync. -type OfflineSyncResponse struct { - BatchID string `json:"batch_id"` - SyncedCount int `json:"synced_count"` - DuplicateCount int `json:"duplicate_count"` - ErrorCount int `json:"error_count"` - Errors []SyncError `json:"errors,omitempty"` +// ZReport is the immutable end-of-day snapshot generated once when a till +// session closes. Payload is the frozen closing TillReport. +type ZReport struct { + ID uuid.UUID `json:"id"` + TillSessionID uuid.UUID `json:"till_session_id"` + RegisterID string `json:"register_id"` + BranchID *uuid.UUID `json:"branch_id"` + OverShort httpx.Cents `json:"over_short_cents"` + Payload []byte `json:"payload"` + GeneratedAt httpx.Timestamp `json:"generated_at"` } -// SyncError describes a single transaction that failed during sync. -type SyncError struct { - ClientID string `json:"client_id"` - Reason string `json:"reason"` +// QuickSearchResult is a lightweight product result for the counter's +// typeahead (the product module's data; a bare array, like the partner +// surface the recipe exempts). +type QuickSearchResult struct { + ProductID uuid.UUID `json:"product_id"` + SKU string `json:"sku"` + Description string `json:"description"` + UnitPriceCents httpx.Cents `json:"unit_price_cents"` + UOM string `json:"uom"` + InStock httpx.Quantity `json:"in_stock"` } // CatalogProduct is a lightweight product for the offline catalog cache. type CatalogProduct struct { - ProductID uuid.UUID `json:"product_id"` - SKU string `json:"sku"` - Description string `json:"description"` - Price float64 `json:"price"` // dollars - UOM string `json:"uom"` - InStock float64 `json:"in_stock"` + ProductID uuid.UUID `json:"product_id"` + SKU string `json:"sku"` + Description string `json:"description"` + UnitPriceCents httpx.Cents `json:"unit_price_cents"` + UOM string `json:"uom"` + InStock httpx.Quantity `json:"in_stock"` } + +// now is the service's clock, replaceable in tests. +var saleNow = time.Now diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 4299e3cf9..099483dc2 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -6,34 +6,54 @@ package pos import ( "context" "encoding/json" + "errors" "fmt" "time" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" "github.com/gablelbm/gable/pkg/branchctx" "github.com/gablelbm/gable/pkg/database" "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) -// Repository handles POS data persistence. +// Repository is the store half of the counter. Every statement goes through +// GetExecutor (the caller's transaction when one is open), money is written +// as `$n::numeric / 100` and read as `ROUND(col * 100)::bigint`, never +// through float64, and every read and lock carries the branch wall. type Repository interface { - CreateTransaction(ctx context.Context, tx *POSTransaction) error - GetTransaction(ctx context.Context, id uuid.UUID) (*POSTransaction, error) - UpdateTransaction(ctx context.Context, tx *POSTransaction) error - ListTransactions(ctx context.Context, registerID string, date time.Time) ([]TransactionSummary, error) + // Sales + CreateSale(ctx context.Context, s *Sale) error + LockSale(ctx context.Context, id uuid.UUID) error + GetSale(ctx context.Context, id uuid.UUID) (*Sale, error) + UpdateSaleTotals(ctx context.Context, id uuid.UUID, subtotal, tax, total int64) error + CompleteSale(ctx context.Context, id, invoiceID uuid.UUID, subtotal, tax, total, change int64) error + VoidSale(ctx context.Context, id uuid.UUID) error + NextSaleNumber(ctx context.Context) (string, error) + NextReturnNumber(ctx context.Context) (string, error) + ListSales(ctx context.Context, f SaleFilter, limit int) ([]SaleSummary, error) + + // Lines + AddLines(ctx context.Context, saleID uuid.UUID, lines []salesdoc.Line) error + RemoveLine(ctx context.Context, saleID, lineID uuid.UUID) error + GetLines(ctx context.Context, saleID uuid.UUID) ([]salesdoc.Line, error) + + // Tenders + AddTender(ctx context.Context, t *Tender) error + GetTenders(ctx context.Context, saleID uuid.UUID) ([]Tender, error) - AddLineItem(ctx context.Context, item *POSLineItem) error - RemoveLineItem(ctx context.Context, itemID uuid.UUID) error - GetLineItems(ctx context.Context, txID uuid.UUID) ([]POSLineItem, error) - - AddTender(ctx context.Context, tender *POSTender) error - GetTenders(ctx context.Context, txID uuid.UUID) ([]POSTender, error) + // Returns + CreateReturn(ctx context.Context, ret *Return, lines []ReturnLine) error + GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) + ListReturns(ctx context.Context, f ReturnFilter, limit int) ([]Return, error) // Till sessions CreateTillSession(ctx context.Context, s *TillSession) error GetTillSession(ctx context.Context, id uuid.UUID) (*TillSession, error) GetOpenTillSession(ctx context.Context, registerID string) (*TillSession, error) CloseTillSession(ctx context.Context, s *TillSession) error - SetTillSessionGLEntry(ctx context.Context, sessionID, glEntryID uuid.UUID) error AggregateTillSession(ctx context.Context, sessionID uuid.UUID) (*TillAggregate, error) // Z-reports @@ -41,350 +61,681 @@ type Repository interface { GetZReportBySession(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) - // Returns + // Lookups GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) - CreateReturn(ctx context.Context, ret *POSReturn) error - SetReturnGLEntry(ctx context.Context, returnID, glEntryID uuid.UUID) error - GetReturn(ctx context.Context, id uuid.UUID) (*POSReturn, error) - ListReturns(ctx context.Context, registerID string, date time.Time) ([]POSReturn, error) - + LookupProducts(ctx context.Context, ids []uuid.UUID) (map[string]salesdoc.ProductRef, error) + LookupKitComponents(ctx context.Context, kitIDs []uuid.UUID) (map[string][]salesdoc.KitComponent, error) + LookupChargeCodes(ctx context.Context, codes []string) (map[string]salesdoc.ChargeCode, error) SearchProducts(ctx context.Context, query string, limit int) ([]QuickSearchResult, error) + GetProductCatalog(ctx context.Context) ([]CatalogProduct, error) + WalkInCustomer(ctx context.Context) (uuid.UUID, string, error) + CustomerFacts(ctx context.Context, customerID uuid.UUID) (CustomerFacts, error) + CustomerExempt(ctx context.Context, customerID uuid.UUID) (bool, error) + OpenReceivableCents(ctx context.Context, customerID uuid.UUID) (int64, error) + BranchLocalDate(ctx context.Context, branchID uuid.UUID, at time.Time) (time.Time, error) + BranchTaxRate(ctx context.Context, branchID *uuid.UUID) (string, bool, error) // Offline sync - TransactionExists(ctx context.Context, id uuid.UUID) (bool, error) - GetProductCatalog(ctx context.Context) ([]CatalogProduct, error) - LogSyncBatch(ctx context.Context, batchID, registerID string, synced, duplicates, errors int, errorDetails []SyncError) error + SaleExists(ctx context.Context, id uuid.UUID) (bool, error) + LogSyncBatch(ctx context.Context, batch LogBatch) error +} + +// CustomerFacts is what the counter reads about the sale's customer: the +// effective currency and the credit limit the ACCOUNT tender checks. +type CustomerFacts struct { + CreditLimitCents *int64 + Name string + Currency string +} + +// TillAggregate is a session's raw sums, taken from the payments its sales +// became: change never subtracts a second time because a cash payment is +// already the money kept (ADR 0005 section 14.2 C2-5). +type TillAggregate struct { + SaleCount int + SalesTotalCents int64 + TaxTotalCents int64 + ChangeCents int64 + TenderedByMethod map[string]int64 + CashRefundsCents int64 +} + +// SaleFilter is the sale list's filters. +type SaleFilter struct { + RegisterID string + Date *time.Time + Status string +} + +// ReturnFilter is the return list's filters. +type ReturnFilter struct { + RegisterID string + Date *time.Time +} + +// LogBatch is one offline sync's outcome record: Errors lists the failed +// items, Details the pending ones beside them. +type LogBatch struct { + BatchID, RegisterID string + Synced, Duplicates, ErrorCount, Pending int + Errors []SyncItemResult + Details []SyncItemResult +} + +// SyncItemResult is one offline sale's outcome in the sync log. +type SyncItemResult struct { + ClientID string `json:"client_id"` + Reason string `json:"reason"` } -// PostgresRepository implements Repository for PostgreSQL. +// PostgresRepository implements Repository. type PostgresRepository struct { db *database.DB } -// NewRepository creates a new POS repository. -func NewRepository(db *database.DB) *PostgresRepository { - return &PostgresRepository{db: db} +// NewRepository creates the counter's repository. +func NewRepository(db *database.DB) *PostgresRepository { return &PostgresRepository{db: db} } + +func (r *PostgresRepository) ex(ctx context.Context) database.Executor { return r.db.GetExecutor(ctx) } + +// posLineCols is the shared line projection over pos_line_items. +var posLineCols = salesdoc.LineColumns(`false, NULL::uuid, NULL::bigint`) + +func mapWriteError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == "23503" { + return fmt.Errorf("a referenced record does not exist: %s", pgErr.ConstraintName) + } + } + return err +} + +func (r *PostgresRepository) CreateSale(ctx context.Context, s *Sale) error { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + number, err := httpx.NextDocumentNumber(ctx, r.ex(ctx), "pos_transaction_number_seq", "POS", httpx.DefaultDocNumberWidth) + if err != nil { + return err + } + s.Number = number + err = r.ex(ctx).QueryRow(ctx, ` + INSERT INTO pos_transactions (id, number, register_id, cashier_id, customer_id, currency, subtotal, tax_amount, + total, change_due, status, till_session_id, branch_id, created_at, updated_at, revision) + VALUES ($1, $2, $3, $4, $5, $6, 0, 0, 0, 0, $7, $8, + COALESCE((SELECT l.branch_id FROM pos_registers pr LEFT JOIN locations l ON l.id = pr.location_id WHERE pr.id = $3), + (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')), + NOW(), NOW(), 1) + RETURNING branch_id, created_at, revision`, + s.ID, s.Number, s.RegisterID, s.CashierID, s.CustomerID, s.Currency, StatusOpen, s.TillSessionID, + ).Scan(&s.BranchID, &s.CreatedAt, &s.Revision) + if err != nil { + return fmt.Errorf("failed to create the sale: %w", mapWriteError(err)) + } + return nil } -func (r *PostgresRepository) CreateTransaction(ctx context.Context, tx *POSTransaction) error { - if tx.ID == uuid.Nil { - tx.ID = uuid.New() +func (r *PostgresRepository) LockSale(ctx context.Context, id uuid.UUID) error { + var one int + err := r.ex(ctx).QueryRow(ctx, `SELECT 1 FROM pos_transactions WHERE id = $1 AND ($2::uuid IS NULL OR branch_id = $2) + FOR NO KEY UPDATE`, id, branchctx.IDForQuery(ctx)).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return httpx.NotFound("sale not found") } - tx.CreatedAt = time.Now() + return err +} + +const saleCols = `t.id, t.number, t.revision, t.branch_id, t.register_id, t.cashier_id, t.customer_id, t.currency, + ROUND(t.subtotal * 100)::bigint, ROUND(t.tax_amount * 100)::bigint, ROUND(t.total * 100)::bigint, + ROUND(t.change_due * 100)::bigint, t.till_session_id, t.status, t.invoice_id, t.completed_at, t.created_at` - // Derive the register's branch_id (via its assigned location). - var registerBranch *uuid.UUID - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT l.branch_id - FROM pos_registers r - LEFT JOIN locations l ON l.id = r.location_id - WHERE r.id = $1`, tx.RegisterID).Scan(®isterBranch) +func scanSale(row pgx.Row, s *Sale) error { + return row.Scan(&s.ID, &s.Number, &s.Revision, &s.BranchID, &s.RegisterID, &s.CashierID, &s.CustomerID, &s.Currency, + &s.SubtotalCents, &s.TaxCents, &s.TotalCents, &s.ChangeCents, &s.TillSessionID, &s.Status, &s.InvoiceID, + &s.CompletedAt, &s.CreatedAt) +} + +func (r *PostgresRepository) GetSale(ctx context.Context, id uuid.UUID) (*Sale, error) { + s := &Sale{} + err := scanSale(r.ex(ctx).QueryRow(ctx, `SELECT `+saleCols+` FROM pos_transactions t + WHERE t.id = $1 AND ($2::uuid IS NULL OR t.branch_id = $2)`, id, branchctx.IDForQuery(ctx)), s) + if errors.Is(err, pgx.ErrNoRows) { + return nil, httpx.NotFound("sale not found") + } if err != nil { - return fmt.Errorf("failed to resolve register branch: %w", err) + return nil, fmt.Errorf("failed to read the sale: %w", err) } + return s, nil +} - // Reject if the caller's branch context disagrees with the register's branch. - if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && registerBranch != nil && *reqBranch != *registerBranch { - return fmt.Errorf("register %s belongs to a different branch than the request", tx.RegisterID) +func (r *PostgresRepository) UpdateSaleTotals(ctx context.Context, id uuid.UUID, subtotal, tax, total int64) error { + _, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_transactions SET subtotal = $2::numeric / 100, tax_amount = $3::numeric / 100, total = $4::numeric / 100, + updated_at = NOW(), revision = revision + 1 WHERE id = $1`, id, subtotal, tax, total) + if err != nil { + return fmt.Errorf("failed to update the sale's totals: %w", err) } + return nil +} - query := ` - INSERT INTO pos_transactions (id, register_id, cashier_id, customer_id, subtotal, tax_amount, total, status, created_at, synced_from, client_created_at, branch_id, till_session_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::uuid, (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')), $13) - RETURNING branch_id - ` - err = r.db.GetExecutor(ctx).QueryRow(ctx, query, - tx.ID, tx.RegisterID, tx.CashierID, tx.CustomerID, - float64(tx.Subtotal)/100.0, float64(tx.TaxAmount)/100.0, float64(tx.Total)/100.0, - tx.Status, tx.CreatedAt, tx.SyncedFrom, tx.ClientCreatedAt, registerBranch, tx.TillSessionID, - ).Scan(&tx.BranchID) +func (r *PostgresRepository) CompleteSale(ctx context.Context, id, invoiceID uuid.UUID, subtotal, tax, total, change int64) error { + tag, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_transactions SET invoice_id = $2, subtotal = $3::numeric / 100, tax_amount = $4::numeric / 100, + total = $5::numeric / 100, change_due = $6::numeric / 100, status = 'COMPLETED', completed_at = NOW(), + updated_at = NOW(), revision = revision + 1 WHERE id = $1 AND status IN ('OPEN', 'HELD')`, id, invoiceID, subtotal, tax, total, change) if err != nil { - return fmt.Errorf("failed to create POS transaction: %w", err) + return fmt.Errorf("failed to complete the sale: %w", err) + } + if tag.RowsAffected() == 0 { + return httpx.InvalidStateTransition("only an open or held sale can be completed") } return nil } -// TransactionExists checks if a transaction with the given ID already exists (for idempotent sync). -func (r *PostgresRepository) TransactionExists(ctx context.Context, id uuid.UUID) (bool, error) { - var exists bool - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT EXISTS(SELECT 1 FROM pos_transactions WHERE id = $1)`, id, - ).Scan(&exists) +func (r *PostgresRepository) VoidSale(ctx context.Context, id uuid.UUID) error { + tag, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_transactions SET status = 'VOIDED', updated_at = NOW(), revision = revision + 1 + WHERE id = $1 AND status = 'COMPLETED'`, id) if err != nil { - return false, fmt.Errorf("failed to check transaction existence: %w", err) + return fmt.Errorf("failed to void the sale: %w", err) + } + if tag.RowsAffected() == 0 { + return httpx.InvalidStateTransition("only a completed sale can be voided") } - return exists, nil + return nil } -// GetProductCatalog returns all active products for offline caching. -func (r *PostgresRepository) GetProductCatalog(ctx context.Context) ([]CatalogProduct, error) { - query := ` - SELECT p.id, p.sku, p.description, COALESCE(p.base_price, 0) as price, - COALESCE(p.uom_primary::text, 'EA') as uom, - COALESCE(i.quantity, 0) as in_stock - FROM products p - LEFT JOIN inventory i ON i.product_id = p.id - WHERE p.is_active = true OR p.is_active IS NULL - ORDER BY p.sku ASC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query) +func (r *PostgresRepository) NextSaleNumber(ctx context.Context) (string, error) { + return httpx.NextDocumentNumber(ctx, r.ex(ctx), "pos_transaction_number_seq", "POS", httpx.DefaultDocNumberWidth) +} + +func (r *PostgresRepository) NextReturnNumber(ctx context.Context) (string, error) { + return httpx.NextDocumentNumber(ctx, r.ex(ctx), "pos_return_number_seq", "RTN", httpx.DefaultDocNumberWidth) +} + +func (r *PostgresRepository) ListSales(ctx context.Context, f SaleFilter, limit int) ([]SaleSummary, error) { + predicate := `WHERE ($1 = '' OR t.register_id = $1) + AND ($2::text IS NULL OR t.status = $2) + AND (t.created_at >= $3::date AND t.created_at < ($3::date + 1)) + AND ($4::uuid IS NULL OR t.branch_id = $4)` + args := []any{f.RegisterID, nil, time.Now(), branchctx.IDForQuery(ctx)} + if f.Status != "" { + args[1] = f.Status + } + if f.Date != nil { + args[2] = *f.Date + } + rows, err := r.ex(ctx).Query(ctx, `SELECT `+saleCols+`, + (SELECT count(*) FROM pos_line_items l WHERE l.transaction_id = t.id AND l.line_type <> 'TEXT') + FROM pos_transactions t `+predicate+` ORDER BY t.created_at DESC, t.id DESC LIMIT $5`, append(args, limit)...) if err != nil { - return nil, fmt.Errorf("failed to get product catalog: %w", err) + return nil, fmt.Errorf("failed to list sales: %w", err) } defer rows.Close() - - var products []CatalogProduct + var out []SaleSummary for rows.Next() { - var p CatalogProduct - if err := rows.Scan(&p.ProductID, &p.SKU, &p.Description, &p.Price, &p.UOM, &p.InStock); err != nil { - return nil, fmt.Errorf("failed to scan catalog product: %w", err) + s := &Sale{} + var count int + if err := rows.Scan(&s.ID, &s.Number, &s.Revision, &s.BranchID, &s.RegisterID, &s.CashierID, &s.CustomerID, + &s.Currency, &s.SubtotalCents, &s.TaxCents, &s.TotalCents, &s.ChangeCents, &s.TillSessionID, &s.Status, + &s.InvoiceID, &s.CompletedAt, &s.CreatedAt, &count); err != nil { + return nil, err } - products = append(products, p) + out = append(out, SaleSummary{ID: s.ID, Number: s.Number, Revision: s.Revision, BranchID: s.BranchID, + RegisterID: s.RegisterID, CashierID: s.CashierID, CustomerID: s.CustomerID, Currency: s.Currency, + TotalCents: s.TotalCents, Status: s.Status, InvoiceID: s.InvoiceID, CompletedAt: s.CompletedAt, + CreatedAt: s.CreatedAt, ItemCount: count}) } - return products, nil -} - -// syncErrorJSON is used for safe JSON marshalling of sync error details. -type syncErrorJSON struct { - ClientID string `json:"client_id"` - Reason string `json:"reason"` + return out, rows.Err() } -// LogSyncBatch records a sync batch result for auditing. -func (r *PostgresRepository) LogSyncBatch(ctx context.Context, batchID, registerID string, synced, duplicates, errors int, errorDetails []SyncError) error { - errJSON := "[]" - if len(errorDetails) > 0 { - items := make([]syncErrorJSON, len(errorDetails)) - for i, e := range errorDetails { - items[i] = syncErrorJSON{ClientID: e.ClientID, Reason: e.Reason} +func (r *PostgresRepository) AddLines(ctx context.Context, saleID uuid.UUID, lines []salesdoc.Line) error { + for i := range lines { + l := &lines[i] + if l.ID == uuid.Nil { + l.ID = uuid.New() } - data, err := json.Marshal(items) + _, err := r.ex(ctx).Exec(ctx, ` + INSERT INTO pos_line_items (id, transaction_id, position, line_type, parent_line_id, product_id, charge_code_id, + sku, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, priced_unit_price, + price_source, override_reason, discount_percent, discount_amount, discount_reason, price_adjusted_by, + line_total, taxable, revenue_account_code, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::numeric / 10000, $11, $12, $13::numeric / 10000, + $14::numeric / 10000, $15::numeric / 10000, $16::numeric / 10000, $17, $18, $19::numeric / 10000, + $20::numeric / 100, $21, $22, $23::numeric / 100, $24, $25, NOW())`, + l.ID, saleID, l.Position, string(l.LineType), l.ParentLineID, l.ProductID, l.ChargeCodeID, + l.SKU, l.Description, qtyArg(l.Quantity), l.UOM, l.PriceUOM, qtyArg(l.UOMQty), qtyArg(l.PriceUOMQty), + priceArg(l.UnitPrice), priceArg(l.PricedUnitPrice), string(l.PriceSource), l.OverrideReason, + qtyArg(l.DiscountPercent), centsArg(l.DiscountAmount), l.DiscountReason, l.PriceAdjustedBy, + centsArg(l.LineTotal), l.Taxable, l.RevenueAccountCode) if err != nil { - return fmt.Errorf("failed to marshal sync errors: %w", err) + return fmt.Errorf("failed to add the sale line: %w", mapWriteError(err)) } - errJSON = string(data) } - _, err := r.db.GetExecutor(ctx).Exec(ctx, - `INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, errors) VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, - batchID, registerID, synced, duplicates, errors, errJSON, - ) - return err + return nil } -func (r *PostgresRepository) GetTransaction(ctx context.Context, id uuid.UUID) (*POSTransaction, error) { - query := ` - SELECT id, register_id, cashier_id, customer_id, subtotal, tax_amount, total, change_due, till_session_id, status, completed_at, created_at, branch_id - FROM pos_transactions - WHERE id = $1 - AND ($2::uuid IS NULL OR branch_id = $2) - ` - var tx POSTransaction - var subtotal, taxAmount, total, changeDue float64 - err := r.db.GetExecutor(ctx).QueryRow(ctx, query, id, branchctx.IDForQuery(ctx)).Scan( - &tx.ID, &tx.RegisterID, &tx.CashierID, &tx.CustomerID, - &subtotal, &taxAmount, &total, &changeDue, &tx.TillSessionID, - &tx.Status, &tx.CompletedAt, &tx.CreatedAt, &tx.BranchID, - ) - if err != nil { - return nil, fmt.Errorf("failed to get POS transaction: %w", err) - } - tx.Subtotal = int64(subtotal*100.0 + 0.5) - tx.TaxAmount = int64(taxAmount*100.0 + 0.5) - tx.Total = int64(total*100.0 + 0.5) - tx.ChangeDue = int64(changeDue*100.0 + 0.5) - return &tx, nil -} - -func (r *PostgresRepository) UpdateTransaction(ctx context.Context, tx *POSTransaction) error { - query := ` - UPDATE pos_transactions - SET subtotal = $2, tax_amount = $3, total = $4, status = $5, completed_at = $6, customer_id = $7, change_due = $9 - WHERE id = $1 - AND ($8::uuid IS NULL OR branch_id = $8) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - tx.ID, - float64(tx.Subtotal)/100.0, float64(tx.TaxAmount)/100.0, float64(tx.Total)/100.0, - tx.Status, tx.CompletedAt, tx.CustomerID, branchctx.IDForQuery(ctx), - float64(tx.ChangeDue)/100.0, - ) +func qtyArg(q *httpx.Quantity) any { + if q == nil { + return nil + } + return int64(*q) +} + +func priceArg(p *httpx.Price) any { + if p == nil { + return nil + } + return int64(*p) +} + +func centsArg(c *httpx.Cents) any { + if c == nil { + return nil + } + return int64(*c) +} + +func (r *PostgresRepository) RemoveLine(ctx context.Context, saleID, lineID uuid.UUID) error { + tag, err := r.ex(ctx).Exec(ctx, `DELETE FROM pos_line_items WHERE id = $1 AND transaction_id = $2`, lineID, saleID) if err != nil { - return fmt.Errorf("failed to update POS transaction: %w", err) + return fmt.Errorf("failed to remove the line: %w", err) + } + if tag.RowsAffected() == 0 { + return httpx.NotFound("line not found on this sale") } return nil } -func (r *PostgresRepository) ListTransactions(ctx context.Context, registerID string, date time.Time) ([]TransactionSummary, error) { - query := ` - SELECT t.id, t.register_id, t.total, t.status, t.completed_at, t.created_at, - (SELECT COUNT(*) FROM pos_line_items li WHERE li.transaction_id = t.id) as item_count - FROM pos_transactions t - WHERE ($1 = '' OR t.register_id = $1) - AND t.created_at >= $2 AND t.created_at < $3 - AND ($4::uuid IS NULL OR t.branch_id = $4) - ORDER BY t.created_at DESC - ` - startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - endOfDay := startOfDay.Add(24 * time.Hour) - - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, registerID, startOfDay, endOfDay, branchctx.IDForQuery(ctx)) +func (r *PostgresRepository) GetLines(ctx context.Context, saleID uuid.UUID) ([]salesdoc.Line, error) { + rows, err := r.ex(ctx).Query(ctx, `SELECT `+posLineCols+` FROM pos_line_items l + LEFT JOIN charge_codes cc ON cc.id = l.charge_code_id + WHERE l.transaction_id = $1 ORDER BY l.position, l.created_at, l.id`, saleID) if err != nil { - return nil, fmt.Errorf("failed to list POS transactions: %w", err) + return nil, fmt.Errorf("failed to read the sale's lines: %w", err) } defer rows.Close() - - var summaries []TransactionSummary + var out []salesdoc.Line for rows.Next() { - var s TransactionSummary - var total float64 - if err := rows.Scan(&s.ID, &s.RegisterID, &total, &s.Status, &s.CompletedAt, &s.CreatedAt, &s.ItemCount); err != nil { - return nil, fmt.Errorf("failed to scan transaction summary: %w", err) + var l salesdoc.Line + var sc salesdoc.LineScan + if err := rows.Scan(sc.Dests(&l)...); err != nil { + return nil, err } - s.Total = int64(total*100.0 + 0.5) - summaries = append(summaries, s) + sc.Finish(&l) + out = append(out, l) } - return summaries, nil + return out, rows.Err() } -func (r *PostgresRepository) AddLineItem(ctx context.Context, item *POSLineItem) error { - if item.ID == uuid.Nil { - item.ID = uuid.New() +func (r *PostgresRepository) AddTender(ctx context.Context, t *Tender) error { + if t.ID == uuid.Nil { + t.ID = uuid.New() } - item.CreatedAt = time.Now() - - query := ` - INSERT INTO pos_line_items (id, transaction_id, product_id, description, quantity, uom, unit_price, line_total, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - item.ID, item.TransactionID, item.ProductID, item.Description, - item.Quantity, item.UOM, - float64(item.UnitPrice)/100.0, float64(item.LineTotal)/100.0, - item.CreatedAt, - ) + err := r.ex(ctx).QueryRow(ctx, ` + INSERT INTO pos_tenders (id, transaction_id, method, amount, payment_id, reference, card_last4, card_brand, + gateway_tx_id, auth_code, created_at) + VALUES ($1, $2, $3, $4::numeric / 100, $5, NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NOW()) + RETURNING created_at`, + t.ID, t.SaleID, string(t.Method), int64(t.AmountCents), t.PaymentID, t.Reference, t.CardLast4, t.CardBrand, + t.GatewayTxID, t.AuthCode).Scan(&t.CreatedAt) if err != nil { - return fmt.Errorf("failed to add POS line item: %w", err) + return fmt.Errorf("failed to record the tender: %w", mapWriteError(err)) } return nil } -func (r *PostgresRepository) RemoveLineItem(ctx context.Context, itemID uuid.UUID) error { - query := `DELETE FROM pos_line_items WHERE id = $1` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, itemID) +func (r *PostgresRepository) GetTenders(ctx context.Context, saleID uuid.UUID) ([]Tender, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, method, ROUND(amount * 100)::bigint, payment_id, reference, card_last4, card_brand, gateway_tx_id, + auth_code, created_at + FROM pos_tenders WHERE transaction_id = $1 ORDER BY created_at, id`, saleID) if err != nil { - return fmt.Errorf("failed to remove POS line item: %w", err) + return nil, fmt.Errorf("failed to read the tenders: %w", err) + } + defer rows.Close() + var out []Tender + for rows.Next() { + var t Tender + var method string + if err := rows.Scan(&t.ID, &method, &t.AmountCents, &t.PaymentID, &t.Reference, &t.CardLast4, &t.CardBrand, + &t.GatewayTxID, &t.AuthCode, &t.CreatedAt); err != nil { + return nil, err + } + t.Method = TenderMethod(method) + t.SaleID = saleID + out = append(out, t) + } + return out, rows.Err() +} + +func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, lines []ReturnLine) error { + if ret.ID == uuid.Nil { + ret.ID = uuid.New() + } + number, err := httpx.NextDocumentNumber(ctx, r.ex(ctx), "pos_return_number_seq", "RTN", httpx.DefaultDocNumberWidth) + if err != nil { + return err + } + ret.Number = number + err = r.ex(ctx).QueryRow(ctx, ` + INSERT INTO pos_returns (id, number, register_id, till_session_id, original_transaction_id, customer_id, branch_id, + cashier_id, currency, subtotal, tax_amount, total, refund_method, reason, status, credit_memo_id, created_at, updated_at, revision) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::numeric / 100, $11::numeric / 100, $12::numeric / 100, $13, $14, + 'COMPLETED', $15, NOW(), NOW(), 1) + RETURNING created_at`, + ret.ID, ret.Number, ret.RegisterID, ret.TillSessionID, ret.OriginalSaleID, ret.CustomerID, ret.BranchID, + ret.CashierID, ret.Currency, int64(ret.SubtotalCents), int64(ret.TaxCents), int64(ret.TotalCents), + string(ret.RefundMethod), ret.Reason, ret.CreditMemoID).Scan(&ret.CreatedAt) + if err != nil { + return fmt.Errorf("failed to record the return: %w", mapWriteError(err)) + } + for i := range lines { + l := &lines[i] + if l.ID == uuid.Nil { + l.ID = uuid.New() + } + _, err := r.ex(ctx).Exec(ctx, ` + INSERT INTO pos_return_lines (id, return_id, position, product_id, description, quantity, uom, unit_price, + line_total, restock, created_at) + VALUES ($1, $2, $3, $4, $5, -($6::numeric / 10000), $7, $8::numeric / 10000, -($9::numeric / 100), $10, NOW())`, + l.ID, ret.ID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, priceArg(l.UnitPrice), + centsArg(l.LineTotal), l.Restock) + if err != nil { + return fmt.Errorf("failed to record the return line: %w", mapWriteError(err)) + } } return nil } -func (r *PostgresRepository) GetLineItems(ctx context.Context, txID uuid.UUID) ([]POSLineItem, error) { - query := ` - SELECT id, transaction_id, product_id, description, quantity, uom, unit_price, line_total, created_at - FROM pos_line_items - WHERE transaction_id = $1 - ORDER BY created_at ASC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, txID) +func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) { + ret := &Return{} + var method string + err := r.ex(ctx).QueryRow(ctx, ` + SELECT id, number, revision, branch_id, register_id, till_session_id, original_transaction_id, customer_id, + cashier_id, currency, ROUND(subtotal * 100)::bigint, ROUND(tax_amount * 100)::bigint, ROUND(total * 100)::bigint, + refund_method, reason, credit_memo_id, created_at + FROM pos_returns WHERE id = $1 AND ($2::uuid IS NULL OR branch_id = $2)`, id, branchctx.IDForQuery(ctx)). + Scan(&ret.ID, &ret.Number, &ret.Revision, &ret.BranchID, &ret.RegisterID, &ret.TillSessionID, &ret.OriginalSaleID, + &ret.CustomerID, &ret.CashierID, &ret.Currency, &ret.SubtotalCents, &ret.TaxCents, &ret.TotalCents, + &method, &ret.Reason, &ret.CreditMemoID, &ret.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, httpx.NotFound("return not found") + } if err != nil { - return nil, fmt.Errorf("failed to get POS line items: %w", err) + return nil, fmt.Errorf("failed to read the return: %w", err) + } + ret.RefundMethod = RefundMethod(method) + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, position, product_id, description, ROUND(-quantity * 10000)::bigint, uom, + ROUND(unit_price * 10000)::bigint, ROUND(-line_total * 100)::bigint, restock + FROM pos_return_lines WHERE return_id = $1 ORDER BY position, id`, id) + if err != nil { + return nil, err } defer rows.Close() + for rows.Next() { + var l ReturnLine + if err := rows.Scan(&l.ID, &l.Position, &l.ProductID, &l.Description, &l.Quantity, &l.UOM, &l.UnitPrice, + &l.LineTotal, &l.Restock); err != nil { + return nil, err + } + ret.Lines = append(ret.Lines, l) + } + return ret, rows.Err() +} - var items []POSLineItem +func (r *PostgresRepository) ListReturns(ctx context.Context, f ReturnFilter, limit int) ([]Return, error) { + args := []any{f.RegisterID, time.Now(), branchctx.IDForQuery(ctx), limit} + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, number, revision, branch_id, register_id, till_session_id, original_transaction_id, customer_id, + cashier_id, currency, ROUND(subtotal * 100)::bigint, ROUND(tax_amount * 100)::bigint, ROUND(total * 100)::bigint, + refund_method, reason, credit_memo_id, created_at + FROM pos_returns + WHERE ($1 = '' OR register_id = $1) + AND (created_at >= $2::date AND created_at < ($2::date + 1)) + AND ($3::uuid IS NULL OR branch_id = $3) + ORDER BY created_at DESC, id DESC LIMIT $4`, args...) + if err != nil { + return nil, fmt.Errorf("failed to list returns: %w", err) + } + defer rows.Close() + var out []Return for rows.Next() { - var item POSLineItem - var unitPrice, lineTotal float64 - if err := rows.Scan( - &item.ID, &item.TransactionID, &item.ProductID, &item.Description, - &item.Quantity, &item.UOM, &unitPrice, &lineTotal, &item.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("failed to scan POS line item: %w", err) + ret := &Return{} + var method string + if err := rows.Scan(&ret.ID, &ret.Number, &ret.Revision, &ret.BranchID, &ret.RegisterID, &ret.TillSessionID, + &ret.OriginalSaleID, &ret.CustomerID, &ret.CashierID, &ret.Currency, &ret.SubtotalCents, &ret.TaxCents, + &ret.TotalCents, &method, &ret.Reason, &ret.CreditMemoID, &ret.CreatedAt); err != nil { + return nil, err } - item.UnitPrice = int64(unitPrice*100.0 + 0.5) - item.LineTotal = int64(lineTotal*100.0 + 0.5) - items = append(items, item) + ret.RefundMethod = RefundMethod(method) + out = append(out, *ret) } - return items, nil + return out, rows.Err() } -func (r *PostgresRepository) AddTender(ctx context.Context, tender *POSTender) error { - if tender.ID == uuid.Nil { - tender.ID = uuid.New() +func (r *PostgresRepository) GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) { + var branch *uuid.UUID + err := r.ex(ctx).QueryRow(ctx, `SELECT l.branch_id FROM pos_registers pr + LEFT JOIN locations l ON l.id = pr.location_id WHERE pr.id = $1`, registerID).Scan(&branch) + if errors.Is(err, pgx.ErrNoRows) { + return nil, httpx.NotFound("no such register") + } + if err != nil { + return nil, fmt.Errorf("failed to resolve the register's branch: %w", err) } - tender.CreatedAt = time.Now() + return branch, nil +} - query := ` - INSERT INTO pos_tenders (id, transaction_id, method, amount, reference, card_last4, card_brand, gateway_tx_id, auth_code, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - tender.ID, tender.TransactionID, tender.Method, - float64(tender.Amount)/100.0, tender.Reference, - tender.CardLast4, tender.CardBrand, - tender.GatewayTxID, tender.AuthCode, tender.CreatedAt, - ) +func (r *PostgresRepository) LookupProducts(ctx context.Context, ids []uuid.UUID) (map[string]salesdoc.ProductRef, error) { + out := map[string]salesdoc.ProductRef{} + if len(ids) == 0 { + return out, nil + } + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, COALESCE(sku, ''), COALESCE(description, ''), COALESCE(uom_primary::text, 'EA'), + COALESCE(ROUND(base_price * 10000)::bigint, 0), COALESCE(ROUND(average_unit_cost * 10000)::bigint, 0), + COALESCE(is_kit, FALSE), COALESCE(taxable, TRUE) + FROM products WHERE id = ANY($1)`, ids) if err != nil { - return fmt.Errorf("failed to add POS tender: %w", err) + return nil, err } - return nil + defer rows.Close() + for rows.Next() { + var ref salesdoc.ProductRef + if err := rows.Scan(&ref.ID, &ref.SKU, &ref.Description, &ref.UOMPrimary, &ref.BasePrice, &ref.AverageCost, + &ref.IsKit, &ref.Taxable); err != nil { + return nil, err + } + out[ref.ID.String()] = ref + } + return out, rows.Err() } -func (r *PostgresRepository) GetTenders(ctx context.Context, txID uuid.UUID) ([]POSTender, error) { - query := ` - SELECT id, transaction_id, method, amount, COALESCE(reference, '') as reference, - COALESCE(card_last4, '') as card_last4, COALESCE(card_brand, '') as card_brand, - COALESCE(gateway_tx_id, '') as gateway_tx_id, COALESCE(auth_code, '') as auth_code, created_at - FROM pos_tenders - WHERE transaction_id = $1 - ORDER BY created_at ASC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, txID) +func (r *PostgresRepository) LookupKitComponents(ctx context.Context, kitIDs []uuid.UUID) (map[string][]salesdoc.KitComponent, error) { + out := map[string][]salesdoc.KitComponent{} + if len(kitIDs) == 0 { + return out, nil + } + rows, err := r.ex(ctx).Query(ctx, ` + SELECT kit_product_id, component_product_id, ROUND(quantity * 10000)::bigint, position + FROM product_kit_components WHERE kit_product_id = ANY($1) ORDER BY position`, kitIDs) if err != nil { - return nil, fmt.Errorf("failed to get POS tenders: %w", err) + return nil, err } defer rows.Close() + for rows.Next() { + var c salesdoc.KitComponent + if err := rows.Scan(&c.KitProductID, &c.ComponentProductID, &c.Quantity, &c.Position); err != nil { + return nil, err + } + out[c.KitProductID.String()] = append(out[c.KitProductID.String()], c) + } + return out, rows.Err() +} - var tenders []POSTender +func (r *PostgresRepository) LookupChargeCodes(ctx context.Context, codes []string) (map[string]salesdoc.ChargeCode, error) { + out := map[string]salesdoc.ChargeCode{} + if len(codes) == 0 { + return out, nil + } + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, code, name, revenue_account_code, taxable, ROUND(default_unit_price * 10000)::bigint, is_active, revision + FROM charge_codes WHERE code = ANY($1)`, codes) + if err != nil { + return nil, err + } + defer rows.Close() for rows.Next() { - var t POSTender - var amount float64 - if err := rows.Scan( - &t.ID, &t.TransactionID, &t.Method, &amount, &t.Reference, - &t.CardLast4, &t.CardBrand, &t.GatewayTxID, &t.AuthCode, &t.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("failed to scan POS tender: %w", err) + var c salesdoc.ChargeCode + if err := rows.Scan(&c.ID, &c.Code, &c.Name, &c.RevenueAccountCode, &c.Taxable, &c.DefaultUnitPrice, + &c.IsActive, &c.Revision); err != nil { + return nil, err } - t.Amount = int64(amount*100.0 + 0.5) - tenders = append(tenders, t) + out[c.Code] = c } - return tenders, nil + return out, rows.Err() } func (r *PostgresRepository) SearchProducts(ctx context.Context, query string, limit int) ([]QuickSearchResult, error) { - sql := ` - SELECT p.id, p.sku, p.description, COALESCE(p.base_price, 0) as price, COALESCE(p.uom_primary::text, 'EA') as uom, - COALESCE(SUM(i.quantity), 0) as in_stock - FROM products p - LEFT JOIN inventory i ON i.product_id = p.id - WHERE p.sku ILIKE $1 OR p.description ILIKE $1 + rows, err := r.ex(ctx).Query(ctx, ` + SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), COALESCE(ROUND(p.base_price * 100)::bigint, 0), + COALESCE(p.uom_primary::text, 'EA'), COALESCE(ROUND(SUM(i.quantity - i.allocated) * 10000)::bigint, 0) + FROM products p LEFT JOIN inventory i ON i.product_id = p.id + WHERE (p.sku ILIKE $1 OR p.description ILIKE $1) AND COALESCE(p.is_active, TRUE) GROUP BY p.id, p.sku, p.description, p.base_price, p.uom_primary - ORDER BY p.sku ASC - LIMIT $2 - ` - searchTerm := "%" + query + "%" - rows, err := r.db.GetExecutor(ctx).Query(ctx, sql, searchTerm, limit) + ORDER BY p.sku LIMIT $2`, "%"+query+"%", limit) if err != nil { - return nil, fmt.Errorf("failed to search products: %w", err) + return nil, err } defer rows.Close() + var out []QuickSearchResult + for rows.Next() { + var q QuickSearchResult + if err := rows.Scan(&q.ProductID, &q.SKU, &q.Description, &q.UnitPriceCents, &q.UOM, &q.InStock); err != nil { + return nil, err + } + out = append(out, q) + } + return out, rows.Err() +} - var results []QuickSearchResult +func (r *PostgresRepository) GetProductCatalog(ctx context.Context) ([]CatalogProduct, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), COALESCE(ROUND(p.base_price * 100)::bigint, 0), + COALESCE(p.uom_primary::text, 'EA'), COALESCE(ROUND(SUM(i.quantity - i.allocated) * 10000)::bigint, 0) + FROM products p LEFT JOIN inventory i ON i.product_id = p.id + WHERE COALESCE(p.is_active, TRUE) + GROUP BY p.id, p.sku, p.description, p.base_price, p.uom_primary + ORDER BY p.sku`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CatalogProduct for rows.Next() { - var r QuickSearchResult - if err := rows.Scan(&r.ProductID, &r.SKU, &r.Description, &r.UnitPrice, &r.UOM, &r.InStock); err != nil { - return nil, fmt.Errorf("failed to scan product: %w", err) + var c CatalogProduct + if err := rows.Scan(&c.ProductID, &c.SKU, &c.Description, &c.UnitPriceCents, &c.UOM, &c.InStock); err != nil { + return nil, err } - results = append(results, r) + out = append(out, c) + } + return out, rows.Err() +} + +// WalkInCustomer answers the walk-in customer's id and the dealer's default +// currency (ADR 0005 section 13 C2-5 step 4). +func (r *PostgresRepository) WalkInCustomer(ctx context.Context) (uuid.UUID, string, error) { + var id uuid.UUID + var currency string + err := r.ex(ctx).QueryRow(ctx, ` + SELECT c.id, COALESCE(c.currency, (SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD') + FROM customers c WHERE c.account_number = 'WALK-IN'`).Scan(&id, ¤cy) + if err != nil { + return uuid.Nil, "", fmt.Errorf("the walk-in customer is not configured: %w", err) + } + return id, currency, nil +} + +func (r *PostgresRepository) CustomerFacts(ctx context.Context, customerID uuid.UUID) (CustomerFacts, error) { + var f CustomerFacts + var limit *int64 + err := r.ex(ctx).QueryRow(ctx, ` + SELECT name, ROUND(credit_limit * 100)::bigint, + COALESCE(currency, (SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD') + FROM customers WHERE id = $1`, customerID).Scan(&f.Name, &limit, &f.Currency) + if errors.Is(err, pgx.ErrNoRows) { + return f, httpx.NotFound("no such customer") + } + if err != nil { + return f, err } - return results, nil + f.CreditLimitCents = limit + return f, nil +} + +func (r *PostgresRepository) CustomerExempt(ctx context.Context, customerID uuid.UUID) (bool, error) { + var exempt bool + err := r.ex(ctx).QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tax_exemptions WHERE customer_id = $1 AND active)`, customerID).Scan(&exempt) + return exempt, err +} + +func (r *PostgresRepository) OpenReceivableCents(ctx context.Context, customerID uuid.UUID) (int64, error) { + var open int64 + err := r.ex(ctx).QueryRow(ctx, ` + SELECT COALESCE((SELECT SUM(ROUND(i.amount_open * 100)::bigint) FROM invoices i + WHERE i.customer_id = $1 AND i.status IN ('UNPAID', 'PARTIAL', 'OVERDUE')), 0) + - COALESCE((SELECT SUM(ROUND(m.amount_open * 100)::bigint) FROM credit_memos m + WHERE m.customer_id = $1 AND m.status IN ('OPEN', 'PARTIAL')), 0) + - COALESCE((SELECT SUM(ROUND(p.amount_unapplied * 100)::bigint) FROM payments p + WHERE p.customer_id = $1 AND p.status = 'POSTED'), 0)`, customerID).Scan(&open) + return open, err +} + +func (r *PostgresRepository) BranchLocalDate(ctx context.Context, branchID uuid.UUID, at time.Time) (time.Time, error) { + var date time.Time + err := r.ex(ctx).QueryRow(ctx, ` + SELECT (timezone(COALESCE((SELECT l.timezone FROM locations l WHERE l.id = $1), 'UTC'), $2))::date`, branchID, at).Scan(&date) + if err != nil { + return at.UTC(), nil + } + return date, nil +} + +// BranchTaxRate reads the branch's configured rate as the decimal string the +// resolver takes. +func (r *PostgresRepository) BranchTaxRate(ctx context.Context, branchID *uuid.UUID) (string, bool, error) { + var rate *string + err := r.ex(ctx).QueryRow(ctx, `SELECT default_tax_rate::text FROM locations WHERE id = $1`, branchID).Scan(&rate) + if errors.Is(err, pgx.ErrNoRows) || rate == nil { + return "", false, nil + } + if err != nil { + return "", false, err + } + return *rate, true, nil +} + +func (r *PostgresRepository) SaleExists(ctx context.Context, id uuid.UUID) (bool, error) { + var exists bool + err := r.ex(ctx).QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pos_transactions WHERE id = $1)`, id).Scan(&exists) + return exists, err +} + +func (r *PostgresRepository) LogSyncBatch(ctx context.Context, b LogBatch) error { + // The jsonb carries both the failures and the pending items (a pending + // offline sale stays visible in the log until a retry completes it). + details := make([]SyncItemResult, 0, len(b.Details)+len(b.Errors)) + details = append(details, b.Errors...) + details = append(details, b.Details...) + raw, err := json.Marshal(details) + if err != nil { + return err + } + _, err = r.ex(ctx).Exec(ctx, ` + INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, errors) + VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, b.BatchID, b.RegisterID, b.Synced, b.Duplicates, b.ErrorCount, string(raw)) + return err } diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index d32ca6aa8..cc3599161 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -6,276 +6,341 @@ package pos import ( "context" "fmt" - "math" - "strings" - "time" - "github.com/gablelbm/gable/internal/inventory" - "github.com/gablelbm/gable/internal/payment" - "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" ) -// ReturnRefundMethod is how a return is refunded to the customer. -type ReturnRefundMethod string - -const ( - RefundCash ReturnRefundMethod = "CASH" // out of the drawer - RefundCard ReturnRefundMethod = "CARD" // reversed on the card (gateway) - RefundAccount ReturnRefundMethod = "ACCOUNT" // store credit against AR -) - -// POSReturn is a completed merchandise return at the till. Its lines restock -// sellable inventory; its total books a GL reversal of the original sale -// (DR Sales Revenue / CR Cash for a drawer refund, DR Revenue / CR AR for -// store credit). -type POSReturn struct { - ID uuid.UUID `json:"id" db:"id"` - RegisterID string `json:"register_id" db:"register_id"` - TillSessionID *uuid.UUID `json:"till_session_id,omitempty" db:"till_session_id"` - OriginalTransactionID *uuid.UUID `json:"original_transaction_id,omitempty" db:"original_transaction_id"` - CustomerID *uuid.UUID `json:"customer_id,omitempty" db:"customer_id"` - BranchID *uuid.UUID `json:"branch_id,omitempty" db:"branch_id"` - CashierID uuid.UUID `json:"cashier_id" db:"cashier_id"` - Subtotal int64 `json:"subtotal" db:"subtotal"` // Cents - TaxAmount int64 `json:"tax_amount" db:"tax_amount"` // Cents - Total int64 `json:"total" db:"total"` // Cents - RefundMethod string `json:"refund_method" db:"refund_method"` - Reason string `json:"reason" db:"reason"` - Status string `json:"status" db:"status"` - GLEntryID *uuid.UUID `json:"gl_entry_id,omitempty" db:"gl_entry_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - - Lines []POSReturnLine `json:"lines,omitempty"` +// ReturnIn is a parsed counter return request. +type ReturnIn struct { + RegisterID string + OriginalSaleID *uuid.UUID + CustomerID *uuid.UUID + RefundMethod RefundMethod + Reason string + GatewayTxID string + Lines []ReturnLineIn } -// POSReturnLine is one returned product line. -type POSReturnLine struct { - ID uuid.UUID `json:"id" db:"id"` - ReturnID uuid.UUID `json:"return_id" db:"return_id"` - ProductID uuid.UUID `json:"product_id" db:"product_id"` - Description string `json:"description" db:"description"` - Quantity float64 `json:"quantity" db:"quantity"` - UOM string `json:"uom" db:"uom"` - UnitPrice int64 `json:"unit_price" db:"unit_price"` // Cents - LineTotal int64 `json:"line_total" db:"line_total"` // Cents - Restock bool `json:"restock" db:"restock"` +// ReturnLineIn is one parsed returned line. +type ReturnLineIn struct { + ProductID *uuid.UUID + SaleLineID *uuid.UUID + Description string + Quantity httpx.Quantity + UnitPrice *httpx.Price + Restock bool } -// ReturnLineRequest is one line of a return request (money in dollars, matching -// the POS wire convention). -type ReturnLineRequest struct { - ProductID uuid.UUID `json:"product_id"` - Description string `json:"description,omitempty"` - Quantity float64 `json:"quantity"` // positive units returned - UOM string `json:"uom,omitempty"` - UnitPrice float64 `json:"unit_price"` // Dollars - Restock *bool `json:"restock,omitempty"` // default true; false for damaged goods +// returnLinePriced is a returned line with its money resolved. +type returnLinePriced struct { + in ReturnLineIn + lineTotal int64 // positive; the memo line stores it negative + taxable bool } -// ReturnRequest initiates a merchandise return. -type ReturnRequest struct { - RegisterID string `json:"register_id"` - OriginalTransactionID *uuid.UUID `json:"original_transaction_id,omitempty"` - CustomerID *uuid.UUID `json:"customer_id,omitempty"` - RefundMethod string `json:"refund_method"` // CASH | CARD | ACCOUNT - Reason string `json:"reason,omitempty"` - GatewayTxID string `json:"gateway_tx_id,omitempty"` // original card tx to reverse - Lines []ReturnLineRequest `json:"lines"` -} - -// ReturnSale records a merchandise return: it restocks the sellable lines, -// refunds via the chosen method, and books the GL reversal of the sale. Cash -// and card refunds unwind against Cash (DR Revenue / CR Cash); account refunds -// issue store credit (DR Revenue / CR AR) and lower the customer's balance. -// -// Ordering mirrors CompleteTransaction: the card reversal (slow, external, not -// transactional) happens BEFORE the DB transaction; restock + persistence -// commit together; GL posting is best-effort AFTER commit so a ledger hiccup -// never unwinds a refund the customer already received. -func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, req ReturnRequest) (*POSReturn, error) { - if req.RegisterID == "" { - return nil, fmt.Errorf("register_id is required") +// ReturnSale records a counter return (ADR 0005 section 14.2 C2-5): a +// credit memo created and posted in ONE act with its restock lines, +// refunded in cash or card or left as account credit. The card refund goes +// through the gateway before the transaction; the memo's entry, subledger +// row, the stock return and the refund commit together. +func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *ReturnIn, actor string) (*Return, error) { + if s.ar == nil || s.inventory == nil { + return nil, errNotConfigured } - if len(req.Lines) == 0 { - return nil, fmt.Errorf("a return needs at least one line") + branchID, err := s.repo.GetRegisterBranch(ctx, in.RegisterID) + if err != nil { + return nil, err } - method := ReturnRefundMethod(strings.ToUpper(strings.TrimSpace(req.RefundMethod))) - if method == "" { - method = RefundCash + // The customer: named, the original sale's, or the walk-in. + customerID := in.CustomerID + if customerID == nil && in.OriginalSaleID != nil { + if original, err := s.repo.GetSale(ctx, *in.OriginalSaleID); err == nil && original.CustomerID != nil { + customerID = original.CustomerID + } } - switch method { - case RefundCash, RefundCard, RefundAccount: - default: - return nil, fmt.Errorf("invalid refund_method %q (want CASH, CARD, or ACCOUNT)", req.RefundMethod) + if customerID == nil { + walkInID, _, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return nil, err + } + customerID = &walkInID } - if method == RefundAccount && req.CustomerID == nil { - return nil, fmt.Errorf("ACCOUNT refund requires a customer on the return") + facts, err := s.repo.CustomerFacts(ctx, *customerID) + if err != nil { + return nil, err } - - // Build lines + subtotal. - lines := make([]POSReturnLine, 0, len(req.Lines)) - taxItems := make([]POSLineItem, 0, len(req.Lines)) - var subtotal int64 - for _, l := range req.Lines { - if l.Quantity <= 0 { - return nil, fmt.Errorf("return quantity must be positive (product %s)", l.ProductID) + // Price the lines: from the original sale's lines when named, else the + // request's own price (which is then required). + var saleLines []salesdoc.Line + if in.OriginalSaleID != nil { + if saleLines, err = s.repo.GetLines(ctx, *in.OriginalSaleID); err != nil { + return nil, err } - unit := int64(l.UnitPrice*100.0 + 0.5) - lineTotal := int64(math.Round(float64(unit) * l.Quantity)) - restock := true - if l.Restock != nil { - restock = *l.Restock + } + byID := map[uuid.UUID]salesdoc.Line{} + for i := range saleLines { + byID[saleLines[i].ID] = saleLines[i] + } + productIDs := make([]uuid.UUID, 0, len(in.Lines)) + for i := range in.Lines { + if in.Lines[i].ProductID != nil { + productIDs = append(productIDs, *in.Lines[i].ProductID) } - uom := l.UOM - if uom == "" { - uom = "EA" + } + refs, err := s.repo.LookupProducts(ctx, productIDs) + if err != nil { + return nil, err + } + priced := make([]returnLinePriced, 0, len(in.Lines)) + for i := range in.Lines { + rl := in.Lines[i] + if src, ok := byID[*rl.SaleLineID]; ok && rl.SaleLineID != nil { + if rl.UnitPrice == nil && src.UnitPrice != nil { + p := *src.UnitPrice + rl.UnitPrice = &p + } + if rl.Description == "" { + rl.Description = src.Description + } + if rl.ProductID == nil && src.ProductID != nil { + p := *src.ProductID + rl.ProductID = &p + } + } + if rl.UnitPrice == nil { + return nil, invalid("lines.unit_price_ten_thousandths", + "is required when the line names no line of the original sale") } - lines = append(lines, POSReturnLine{ - ProductID: l.ProductID, Description: l.Description, Quantity: l.Quantity, - UOM: uom, UnitPrice: unit, LineTotal: lineTotal, Restock: restock, - }) - taxItems = append(taxItems, POSLineItem{ - ProductID: l.ProductID, Description: l.Description, Quantity: l.Quantity, LineTotal: lineTotal, - }) - subtotal += lineTotal + taxable := true + if ref, ok := refs[rl.ProductID.String()]; ok { + taxable = ref.Taxable + } + priced = append(priced, returnLinePriced{in: rl, lineTotal: int64(salesdoc.CostOf(rl.Quantity, *rl.UnitPrice)), taxable: taxable}) } - if subtotal <= 0 { - return nil, fmt.Errorf("return total must be positive") + // Tax: the sale path's resolver, the branch rate (a return at the + // counter is a pickup, 5.5), exemptions first. + exempt, err := s.repo.CustomerExempt(ctx, *customerID) + if err != nil { + return nil, err } - - // Resolve the register's branch (for tax and stamping); best-effort. - branchID, _ := s.repo.GetRegisterBranch(ctx, req.RegisterID) - - // Tax mirrors the sale path so a full return refunds the tax the sale - // charged (same exemption-aware, branch-rate calculation). - taxTmp := &POSTransaction{CustomerID: req.CustomerID, Subtotal: subtotal} - if branchID != nil { - taxTmp.BranchID = *branchID + var taxableSum int64 + for i := range priced { + if priced[i].taxable { + taxableSum += priced[i].lineTotal + } } - taxAmount := s.calculateTax(ctx, taxTmp, taxItems) - total := subtotal + taxAmount - - // Attach the register's open drawer so cash refunds reconcile against it. - var tillSessionID *uuid.UUID - if sess, err := s.repo.GetOpenTillSession(ctx, req.RegisterID); err == nil && sess != nil { - tillSessionID = &sess.ID + subtotal := taxableSum + for i := range priced { + if !priced[i].taxable { + subtotal += priced[i].lineTotal + } } - - ret := &POSReturn{ - RegisterID: req.RegisterID, - TillSessionID: tillSessionID, - OriginalTransactionID: req.OriginalTransactionID, - CustomerID: req.CustomerID, - BranchID: branchID, - CashierID: cashierID, - Subtotal: subtotal, - TaxAmount: taxAmount, - Total: total, - RefundMethod: string(method), - Reason: req.Reason, - Status: "COMPLETED", - Lines: lines, + var taxCents int64 + var taxRate *string + if exempt { + zero := "0" + taxRate = &zero + } else { + rate, ok, err := s.repo.BranchTaxRate(ctx, branchID) + if err != nil { + return nil, err + } + if !ok { + return nil, conflict("tax_rate_not_configured", "set the branch's default tax rate before returning goods") + } + scaled, _, err := salesdoc.ParseTaxRate(rate) + if err != nil { + return nil, err + } + taxCents = int64(salesdoc.TaxAt(httpx.Cents(taxableSum), scaled)) + taxRate = &rate } - - // Card reversal is external and non-transactional — do it before the DB - // transaction so a decline aborts cleanly with nothing persisted. - if method == RefundCard && req.GatewayTxID != "" { + total := -(subtotal + taxCents) + // A card refund goes through the gateway before the transaction, so a + // decline aborts with nothing persisted. + if in.RefundMethod == RefundCard && in.GatewayTxID != "" { if s.gateway == nil { - return nil, fmt.Errorf("card refund requested but no terminal gateway is configured on this register") + return nil, conflict("card_terminal", "this register has no card terminal gateway to refund the card") } - res, err := s.gateway.Refund(ctx, req.GatewayTxID, total) + res, err := s.gateway.Refund(ctx, in.GatewayTxID, -total) if err != nil { return nil, fmt.Errorf("card refund failed: %w", err) } - if res.Status != payment.GatewayStatusRefunded && res.Status != payment.GatewayStatusApproved { - return nil, fmt.Errorf("card refund not accepted (%s)", res.Status) + if res.Status != "REFUNDED" && res.Status != "APPROVED" { + return nil, conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) } } - - // Persist the return + restock sellable lines in one transaction. - if err := s.db.RunInTx(ctx, func(ctx context.Context) error { - if err := s.repo.CreateReturn(ctx, ret); err != nil { + var tillSessionID *uuid.UUID + if session, err := s.repo.GetOpenTillSession(ctx, in.RegisterID); err == nil && session != nil { + tillSessionID = &session.ID + } + var out *Return + err = s.inTx(ctx, func(ctx context.Context) error { + date, err := s.repo.BranchLocalDate(ctx, *branchID, s.now()) + if err != nil { return err } - for _, l := range ret.Lines { - if !l.Restock { + // The credit memo: a DRAFT row with its negative lines, then the AR + // core's PostCreditMemo mints the gapless number, posts the entry + // (DR each revenue account its group's line totals, DR the tax, DR + // 1030 / CR 5010 the restocked cost; CR 1020 the core adds) and the + // subledger row, all in this transaction. + memoID := uuid.New() + var lines []ReturnLine + var restockCost int64 + for i := range priced { + p := &priced[i] + qty := p.in.Quantity + uom := "EA" + lines = append(lines, ReturnLine{ + ID: uuid.New(), Position: i, ProductID: p.in.ProductID, Description: p.in.Description, + Quantity: &qty, UOM: &uom, UnitPrice: p.in.UnitPrice, LineTotal: ptrC(-p.lineTotal), Restock: p.in.Restock, + }) + if p.in.Restock && p.in.ProductID != nil { + if ref, ok := refs[p.in.ProductID.String()]; ok && ref.AverageCost > 0 { + restockCost += int64(salesdoc.CostOf(p.in.Quantity, ref.AverageCost)) + } + } + } + if _, err := s.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO credit_memos (id, customer_id, branch_id, currency, reason_code, reason, status, created_at, updated_at, revision) + VALUES ($1, $2, $3, $4, 'RETURN', $5, 'DRAFT', NOW(), NOW(), 1)`, + memoID, customerID, branchID, facts.Currency, in.Reason); err != nil { + return fmt.Errorf("failed to create the credit memo: %w", mapWriteError(err)) + } + for i := range lines { + l := &lines[i] + if _, err := s.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, product_id, description, quantity, + uom, price_uom, uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, restock, created_at) + VALUES ($1, $2, 'PRODUCT', $3, $4, -($5::numeric / 10000), $6, $6, 1, 1, $7::numeric / 10000, 'MANUAL', + -($8::numeric / 100), $9, $10, NOW())`, + memoID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, priceArg(l.UnitPrice), + centsArg(l.LineTotal), priced[i].taxable, l.Restock); err != nil { + return fmt.Errorf("failed to create the credit memo line: %w", mapWriteError(err)) + } + } + legs := []gl.Leg{{AccountCode: gl.AccountCodeRevenue, Description: "Sales Revenue", Debit: subtotal}} + if taxCents > 0 { + legs = append(legs, gl.Leg{AccountCode: gl.AccountCodeSalesTax, Description: "Sales Tax Payable", Debit: taxCents}) + } + if restockCost > 0 { + legs = append(legs, + gl.Leg{AccountCode: gl.AccountCodeInventory, Description: "Inventory", Debit: restockCost}, + gl.Leg{AccountCode: gl.AccountCodeCOGS, Description: "Cost of Goods Sold", Credit: restockCost}) + } + fxPost, err := s.ar.PostCreditMemo(ctx, account.PostCreditMemoIn{ + MemoID: memoID, CustomerID: *customerID, Currency: facts.Currency, MemoDate: date, + SubtotalCents: -subtotal, TaxCents: -taxCents, TotalCents: total, TaxRate: taxRate, Actor: actor, Legs: legs}) + if err != nil { + return err + } + var fxAll *account.Effects + mergeEffects(&fxAll, fxPost) + // The stock returns: restock lines back on hand. + for i := range lines { + l := &lines[i] + if !l.Restock || l.ProductID == nil { continue } - if err := s.inventorySvc.AdjustStock(ctx, inventory.StockAdjustmentRequest{ - ProductID: l.ProductID, - LocationID: nil, - Quantity: l.Quantity, // positive delta — goods come back - IsDelta: true, - Reason: "POS return " + ret.ID.String(), - }); err != nil { - return fmt.Errorf("restock failed for product %s: %w", l.ProductID, err) + if err := s.inventory.RestockQty(ctx, *l.ProductID, *branchID, *l.Quantity); err != nil { + return err } } - // Audit log: inside the transaction, so it shares the return's fate - // — a rolled back return leaves no audit row. - if s.auditLog != nil { - if err := s.auditLog.Log(ctx, audit.Entry{ - Action: "pos.return.completed", - EntityType: "pos_return", - EntityID: ret.ID, - Changes: map[string]interface{}{ - "register_id": ret.RegisterID, - "refund_method": ret.RefundMethod, - "total_cents": ret.Total, - "customer_id": ret.CustomerID, - "line_count": len(ret.Lines), - }, - }); err != nil { - return fmt.Errorf("failed to write audit log: %w", err) + // The refund: cash or card pays the credit out (DR 1020 / CR 1010), + // account leaves the credit open. + if in.RefundMethod == RefundCash || in.RefundMethod == RefundCard { + _, rfx, err := s.ar.RefundCreditMemo(ctx, account.RefundCreditMemoIn{ + MemoID: memoID, AmountCents: -total, Reason: in.Reason, Method: string(in.RefundMethod), + GatewayRefundID: in.GatewayTxID, Actor: actor, On: date}) + if err != nil { + return err } + mergeEffects(&fxAll, rfx) } - return nil - }); err != nil { - return nil, err - } - - // Post-commit GL (best-effort — a refunded customer must not be blocked by - // a ledger hiccup; a failure is logged for manual reconciliation). - if s.invoiceSvc != nil && total > 0 { - if method == RefundAccount { - if glID, err := s.invoiceSvc.PostAccountReturnToLedger(ctx, *ret.CustomerID, ret.ID, total); err != nil { - s.logger.Error("CRITICAL: POS account return booked no ledger — reconcile manually", - "return", ret.ID, "customer", *ret.CustomerID, "amount_cents", total, "error", err) - } else { - s.linkReturnGL(ctx, ret, glID) + // The return row, linked to its memo. + ret := &Return{ + RegisterID: in.RegisterID, TillSessionID: tillSessionID, OriginalSaleID: in.OriginalSaleID, + CustomerID: customerID, BranchID: branchID, CashierID: cashierID, Currency: facts.Currency, + SubtotalCents: httpx.Cents(-subtotal), TaxCents: httpx.Cents(-taxCents), TotalCents: httpx.Cents(total), + RefundMethod: in.RefundMethod, Reason: in.Reason, CreditMemoID: &memoID, Lines: lines, + } + if err := s.repo.CreateReturn(ctx, ret, lines); err != nil { + return err + } + // The events, last: the memo's lifecycle, the AR core's, the + // return's own. + branch := *branchID + number := fxMemoNumber(ctx, s, memoID) + if err := s.recordEvent(ctx, outbox.Event{Type: "credit_memo.created", EntityType: "credit_memo", EntityID: memoID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "number": nil, "customer_id": customerID, "status": "draft", "currency": facts.Currency, + "pos_return_id": ret.ID, + })}); err != nil { + return err + } + if err := s.recordEvent(ctx, outbox.Event{Type: "credit_memo.posted", EntityType: "credit_memo", EntityID: memoID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "number": number, "customer_id": customerID, "status": "open", "from_status": "draft", + "currency": facts.Currency, "total_cents": total, + })}); err != nil { + return err + } + if in.RefundMethod == RefundCash || in.RefundMethod == RefundCard { + if err := s.recordEvent(ctx, outbox.Event{Type: "credit_memo.refunded", EntityType: "credit_memo", EntityID: memoID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "customer_id": customerID, "status": "applied", "currency": facts.Currency, + "amount_cents": -total, "method": in.RefundMethod.Status(), + })}); err != nil { + return err } - } else { // CASH, CARD — both settle against the cash bucket in v1 - if glID, err := s.invoiceSvc.PostCashReturnToGL(ctx, ret.ID.String(), total); err != nil { - s.logger.Error("failed to post POS cash return to GL", "return", ret.ID, "amount_cents", total, "error", err) - } else { - s.linkReturnGL(ctx, ret, glID) + } + if fxAll != nil { + for _, ev := range fxAll.Events() { + if err := s.recordEvent(ctx, ev); err != nil { + return err + } } } + if err := s.recordEvent(ctx, outbox.Event{Type: EventReturnCompleted, EntityType: "pos_return", EntityID: ret.ID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "number": ret.Number, "customer_id": customerID, "status": "completed", "currency": facts.Currency, + "total_cents": total, "refund_method": in.RefundMethod.Status(), "credit_memo_id": memoID, + })}); err != nil { + return err + } + out = ret + return nil + }) + if err != nil { + return nil, err } + return out, nil +} - s.logger.Info("POS return completed", "id", ret.ID, "register", ret.RegisterID, "method", ret.RefundMethod, "total_cents", ret.Total) - return ret, nil +func ptrC(v int64) *httpx.Cents { + c := httpx.Cents(v) + return &c } -// linkReturnGL records the return's reversal entry back onto the return row. -func (s *Service) linkReturnGL(ctx context.Context, ret *POSReturn, glID uuid.UUID) { - if glID == uuid.Nil { - return +// fxMemoNumber reads the memo's minted number for its event. +func fxMemoNumber(ctx context.Context, s *Service, memoID uuid.UUID) string { + var number string + if err := s.db.GetExecutor(ctx).QueryRow(ctx, `SELECT number FROM credit_memos WHERE id = $1`, memoID).Scan(&number); err != nil { + return "" } - if err := s.repo.SetReturnGLEntry(ctx, ret.ID, glID); err != nil { - s.logger.Error("return posted to GL but link update failed", "return", ret.ID, "gl_entry_id", glID, "error", err) - return - } - ret.GLEntryID = &glID + return number } -// GetReturn returns a full return with its lines. -func (s *Service) GetReturn(ctx context.Context, id uuid.UUID) (*POSReturn, error) { - return s.repo.GetReturn(ctx, id) -} +// GetReturn reads a return with its lines. +func (s *Service) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) { return s.repo.GetReturn(ctx, id) } -// ListReturns lists returns for a register on a date (both optional). -func (s *Service) ListReturns(ctx context.Context, registerID string, date time.Time) ([]POSReturn, error) { - return s.repo.ListReturns(ctx, registerID, date) +// ListReturns lists returns for a register on a date. +func (s *Service) ListReturns(ctx context.Context, f ReturnFilter) ([]Return, error) { + return s.repo.ListReturns(ctx, f, 200) } diff --git a/core/internal/pos/returns_repository.go b/core/internal/pos/returns_repository.go deleted file mode 100644 index b1f3189ee..000000000 --- a/core/internal/pos/returns_repository.go +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package pos - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// GetRegisterBranch resolves a register's branch (via its assigned location). -// Returns (nil, nil) when the register has no location/branch mapping. -func (r *PostgresRepository) GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) { - var branchID *uuid.UUID - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT l.branch_id - FROM pos_registers r - LEFT JOIN locations l ON l.id = r.location_id - WHERE r.id = $1`, registerID).Scan(&branchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to resolve register branch: %w", err) - } - return branchID, nil -} - -// CreateReturn inserts a return and its lines. Money is stored NUMERIC(12,2) -// dollars (POS convention); the header and lines persist atomically within the -// caller's transaction. -func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *POSReturn) error { - if ret.ID == uuid.Nil { - ret.ID = uuid.New() - } - ret.CreatedAt = time.Now() - - _, err := r.db.GetExecutor(ctx).Exec(ctx, ` - INSERT INTO pos_returns - (id, register_id, till_session_id, original_transaction_id, customer_id, branch_id, - cashier_id, subtotal, tax_amount, total, refund_method, reason, status, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, - ret.ID, ret.RegisterID, ret.TillSessionID, ret.OriginalTransactionID, ret.CustomerID, ret.BranchID, - ret.CashierID, - float64(ret.Subtotal)/100.0, float64(ret.TaxAmount)/100.0, float64(ret.Total)/100.0, - ret.RefundMethod, ret.Reason, ret.Status, ret.CreatedAt, - ) - if err != nil { - return fmt.Errorf("failed to create return: %w", err) - } - - for i := range ret.Lines { - l := &ret.Lines[i] - if l.ID == uuid.Nil { - l.ID = uuid.New() - } - l.ReturnID = ret.ID - _, err := r.db.GetExecutor(ctx).Exec(ctx, ` - INSERT INTO pos_return_lines - (id, return_id, product_id, description, quantity, uom, unit_price, line_total, restock) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, - l.ID, l.ReturnID, l.ProductID, l.Description, l.Quantity, l.UOM, - float64(l.UnitPrice)/100.0, float64(l.LineTotal)/100.0, l.Restock, - ) - if err != nil { - return fmt.Errorf("failed to create return line: %w", err) - } - } - return nil -} - -// SetReturnGLEntry links a return to its reversal journal entry. -func (r *PostgresRepository) SetReturnGLEntry(ctx context.Context, returnID, glEntryID uuid.UUID) error { - _, err := r.db.GetExecutor(ctx).Exec(ctx, - `UPDATE pos_returns SET gl_entry_id = $2 WHERE id = $1`, returnID, glEntryID) - if err != nil { - return fmt.Errorf("failed to link return to GL entry: %w", err) - } - return nil -} - -const returnColumns = `id, register_id, till_session_id, original_transaction_id, customer_id, branch_id, cashier_id, subtotal, tax_amount, total, refund_method, reason, status, gl_entry_id, created_at` - -func (r *PostgresRepository) scanReturn(row pgx.Row) (*POSReturn, error) { - var ret POSReturn - var subtotal, taxAmount, total float64 - if err := row.Scan( - &ret.ID, &ret.RegisterID, &ret.TillSessionID, &ret.OriginalTransactionID, &ret.CustomerID, &ret.BranchID, - &ret.CashierID, &subtotal, &taxAmount, &total, &ret.RefundMethod, &ret.Reason, &ret.Status, - &ret.GLEntryID, &ret.CreatedAt, - ); err != nil { - return nil, err - } - ret.Subtotal = int64(subtotal*100.0 + 0.5) - ret.TaxAmount = int64(taxAmount*100.0 + 0.5) - ret.Total = int64(total*100.0 + 0.5) - return &ret, nil -} - -func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*POSReturn, error) { - row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+returnColumns+` FROM pos_returns WHERE id = $1`, id) - ret, err := r.scanReturn(row) - if err != nil { - return nil, fmt.Errorf("failed to get return: %w", err) - } - lines, err := r.getReturnLines(ctx, id) - if err != nil { - return nil, err - } - ret.Lines = lines - return ret, nil -} - -func (r *PostgresRepository) getReturnLines(ctx context.Context, returnID uuid.UUID) ([]POSReturnLine, error) { - rows, err := r.db.GetExecutor(ctx).Query(ctx, ` - SELECT id, return_id, product_id, description, quantity, uom, unit_price, line_total, restock - FROM pos_return_lines WHERE return_id = $1 ORDER BY id`, returnID) - if err != nil { - return nil, fmt.Errorf("failed to get return lines: %w", err) - } - defer rows.Close() - var out []POSReturnLine - for rows.Next() { - var l POSReturnLine - var unitPrice, lineTotal float64 - if err := rows.Scan(&l.ID, &l.ReturnID, &l.ProductID, &l.Description, &l.Quantity, &l.UOM, &unitPrice, &lineTotal, &l.Restock); err != nil { - return nil, fmt.Errorf("failed to scan return line: %w", err) - } - l.UnitPrice = int64(unitPrice*100.0 + 0.5) - l.LineTotal = int64(lineTotal*100.0 + 0.5) - out = append(out, l) - } - return out, rows.Err() -} - -func (r *PostgresRepository) ListReturns(ctx context.Context, registerID string, date time.Time) ([]POSReturn, error) { - q := `SELECT ` + returnColumns + ` FROM pos_returns WHERE ($1 = '' OR register_id = $1)` - args := []any{registerID} - if !date.IsZero() { - start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - q += ` AND created_at >= $2 AND created_at < $3` - args = append(args, start, start.Add(24*time.Hour)) - } - q += ` ORDER BY created_at DESC` - rows, err := r.db.GetExecutor(ctx).Query(ctx, q, args...) - if err != nil { - return nil, fmt.Errorf("failed to list returns: %w", err) - } - defer rows.Close() - var out []POSReturn - for rows.Next() { - ret, err := r.scanReturn(rows) - if err != nil { - return nil, fmt.Errorf("failed to scan return: %w", err) - } - out = append(out, *ret) - } - return out, rows.Err() -} diff --git a/core/internal/pos/returns_test.go b/core/internal/pos/returns_test.go deleted file mode 100644 index 22172d505..000000000 --- a/core/internal/pos/returns_test.go +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package pos - -import ( - "context" - "log/slog" - "testing" - - "github.com/google/uuid" -) - -// ReturnSale's guard logic runs before any repository/db call, so a service -// with only a logger set exercises it safely. The restock + GL-reversal money -// path is verified live against the real ledger. -func newGuardPOSService() *Service { - return &Service{logger: slog.Default()} -} - -func validLine() ReturnLineRequest { - return ReturnLineRequest{ProductID: uuid.New(), Quantity: 1, UnitPrice: 10.0} -} - -func TestReturnSale_RejectsMissingRegister(t *testing.T) { - _, err := newGuardPOSService().ReturnSale(context.Background(), uuid.New(), ReturnRequest{ - Lines: []ReturnLineRequest{validLine()}, - }) - if err == nil { - t.Fatal("expected error when register_id is empty") - } -} - -func TestReturnSale_RejectsNoLines(t *testing.T) { - _, err := newGuardPOSService().ReturnSale(context.Background(), uuid.New(), ReturnRequest{ - RegisterID: "REG-01", - }) - if err == nil { - t.Fatal("expected error when there are no lines") - } -} - -func TestReturnSale_RejectsInvalidRefundMethod(t *testing.T) { - _, err := newGuardPOSService().ReturnSale(context.Background(), uuid.New(), ReturnRequest{ - RegisterID: "REG-01", - RefundMethod: "BITCOIN", - Lines: []ReturnLineRequest{validLine()}, - }) - if err == nil { - t.Fatal("expected error for an unsupported refund method") - } -} - -func TestReturnSale_AccountRefundRequiresCustomer(t *testing.T) { - _, err := newGuardPOSService().ReturnSale(context.Background(), uuid.New(), ReturnRequest{ - RegisterID: "REG-01", - RefundMethod: "ACCOUNT", - Lines: []ReturnLineRequest{validLine()}, - }) - if err == nil { - t.Fatal("expected error: ACCOUNT refund without a customer") - } -} - -func TestReturnSale_RejectsNonPositiveQuantity(t *testing.T) { - _, err := newGuardPOSService().ReturnSale(context.Background(), uuid.New(), ReturnRequest{ - RegisterID: "REG-01", - RefundMethod: "CASH", - Lines: []ReturnLineRequest{{ProductID: uuid.New(), Quantity: 0, UnitPrice: 5.0}}, - }) - if err == nil { - t.Fatal("expected error for a non-positive return quantity") - } -} diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index d1f1d057a..439d7e2d8 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -5,705 +5,641 @@ package pos import ( "context" + "encoding/json" + "errors" "fmt" "log/slog" - "math" + "net/http" "time" - "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/account" "github.com/gablelbm/gable/internal/invoice" - "github.com/gablelbm/gable/internal/payment" - "github.com/gablelbm/gable/internal/product" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" "github.com/gablelbm/gable/internal/tax" "github.com/gablelbm/gable/pkg/audit" - "github.com/gablelbm/gable/pkg/branchctx" "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" ) -// PriceCalculator resolves customer-specific pricing for a product. -type PriceCalculator interface { - CalculateItemPrice(ctx context.Context, customerID uuid.UUID, productID uuid.UUID, basePrice float64, quantity float64) (float64, error) +// Event types of the counter (ADR 0005 section 12). +const ( + EventSaleCompleted = "pos_transaction.completed" + EventSaleVoided = "pos_transaction.voided" + EventReturnCompleted = "pos_return.completed" + EventTillOpened = "till.opened" + EventTillClosed = "till.closed" + + auditLinePriceOverridden = "pos_transaction.line_price_overridden" + auditLineDiscounted = "pos_transaction.line_discounted" +) + +// PriceEngine prices a product line for a customer and quantity at the +// counter boundary, exactly the order module's engine (ADR 0005 section 1). +type PriceEngine interface { + PriceFor(ctx context.Context, customerID, productID uuid.UUID, basePrice httpx.Price, quantity httpx.Quantity, jobID *uuid.UUID) (httpx.Price, error) } -// TaxCalculator previews tax for a cart (implemented by tax.Service: -// exemptions first, Avalara when configured, flat/branch rate otherwise). -type TaxCalculator interface { +// TaxProvider is the configured provider path behind the rate resolver +// (ADR 0005 section 3), the tax service's own surface. +type TaxProvider interface { + ProviderConfigured() bool PreviewTax(ctx context.Context, req *tax.TaxPreviewRequest) (*tax.TaxResult, error) } -// BranchRateResolver resolves a branch's default sales-tax rate -// (implemented by the invoice repository over locations.default_tax_rate). -type BranchRateResolver interface { - GetBranchTaxRate(ctx context.Context, branchID *uuid.UUID) (float64, bool) +// InvoiceWriter writes the counter sale's invoice with its entry and its +// subledger debit inside the caller's transaction (invoice.Service). +type InvoiceWriter interface { + CreateCounterInvoice(ctx context.Context, in *invoice.CounterInvoice) error } -// TillLedgerPoster posts a till drawer over/short to the GL (implemented by -// gl.Service). Returns the journal entry ID (uuid.Nil when variance is zero). -type TillLedgerPoster interface { +// LedgerPoster posts the till drawer's over/short inside the close's +// transaction (gl.Service). +type LedgerPoster interface { PostTillOverShort(ctx context.Context, sessionID uuid.UUID, overShortCents int64) (uuid.UUID, error) } -// Service handles POS business logic. -type Service struct { - db *database.DB - repo Repository - productSvc *product.Service - inventorySvc *inventory.Service - invoiceSvc *invoice.Service - paymentSvc *payment.Service - priceCalc PriceCalculator - taxCalc TaxCalculator - branchRates BranchRateResolver - gateway payment.PaymentGateway - tillLedger TillLedgerPoster - auditLog *audit.Logger - logger *slog.Logger +// EventRecorder writes the act's events, last, in its transaction. +type EventRecorder interface { + Write(ctx context.Context, ev outbox.Event) error } -// WithTillLedger wires GL posting of till drawer over/short at close. -func (s *Service) WithTillLedger(p TillLedgerPoster) *Service { - s.tillLedger = p - return s +// TxRunner runs fn inside one transaction. +type TxRunner interface { + RunInTx(ctx context.Context, fn func(ctx context.Context) error) error } -// WithPricing enables customer-specific pricing resolution for POS line items. -func (s *Service) WithPricing(calc PriceCalculator) { - s.priceCalc = calc -} - -// WithTax wires real tax calculation into cart totals: exemption-aware via -// the tax service, with the branch's default rate as the flat fallback. -func (s *Service) WithTax(calc TaxCalculator, rates BranchRateResolver) *Service { - s.taxCalc = calc - s.branchRates = rates - return s +// Service is the counter: sales, voids, returns, till sessions and the +// offline sync, every money moment one transaction whose events are written +// last (ADR 0005 sections 11 and 14.2 C2-5). +type Service struct { + db *database.DB + repo Repository + logger *slog.Logger + now func() time.Time + pricer PriceEngine + tax TaxProvider + invoices InvoiceWriter + ledger LedgerPoster + gateway PaymentGateway + auditLog *audit.Logger + events EventRecorder + tx TxRunner + ar ARCore + inventory CounterInventory } -// WithGateway enables card processing for CARD tenders carrying a token. -func (s *Service) WithGateway(gw payment.PaymentGateway) *Service { - s.gateway = gw - return s +// ARCore is the slice of the AR core the counter drives (account.Service). +type ARCore interface { + RecordPayment(ctx context.Context, in account.RecordPaymentIn) (uuid.UUID, *account.Effects, error) + VoidPayment(ctx context.Context, in account.VoidPaymentIn) (*account.Effects, error) + Reverse(ctx context.Context, in account.ReverseIn) (*account.Effects, error) + RefundPayment(ctx context.Context, in account.RefundPaymentIn) (uuid.UUID, *account.Effects, error) + PostCreditMemo(ctx context.Context, in account.PostCreditMemoIn) (*account.Effects, error) + RefundCreditMemo(ctx context.Context, in account.RefundCreditMemoIn) (uuid.UUID, *account.Effects, error) + VoidInvoice(ctx context.Context, in account.VoidInvoiceIn) (*account.Effects, error) } -// WithAuditLog attaches an audit logger so money-moving till operations -// (sale completion and voids) are recorded in the financial audit trail. -func (s *Service) WithAuditLog(l *audit.Logger) *Service { - s.auditLog = l - return s +// CounterInventory moves stock at the counter: an issue takes from on hand +// (a counter sale never allocates), a restock returns goods. +type CounterInventory interface { + IssueQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error + RestockQty(ctx context.Context, productID, branchID uuid.UUID, qty httpx.Quantity) error } -// NewService creates a new POS service. -func NewService( - db *database.DB, - repo Repository, - productSvc *product.Service, - inventorySvc *inventory.Service, - invoiceSvc *invoice.Service, - paymentSvc *payment.Service, - logger *slog.Logger, -) *Service { - return &Service{ - db: db, - repo: repo, - productSvc: productSvc, - inventorySvc: inventorySvc, - invoiceSvc: invoiceSvc, - paymentSvc: paymentSvc, - logger: logger, +// NewService builds the counter's service. +func NewService(db *database.DB, repo Repository, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() } + return &Service{db: db, repo: repo, logger: logger, now: time.Now} } -// StartTransaction creates a new open POS transaction, attached to the -// register's open till session when one exists. (Sales without a session -// remain allowed for now so existing flows keep working; the terminal UI -// prompts to open the till. Hard enforcement is a config decision later.) -func (s *Service) StartTransaction(ctx context.Context, registerID string, cashierID uuid.UUID, customerID *uuid.UUID) (*POSTransaction, error) { - tx := &POSTransaction{ - RegisterID: registerID, - CashierID: cashierID, - CustomerID: customerID, - Subtotal: 0, - TaxAmount: 0, - Total: 0, - Status: TransactionStatusOpen, - } +// WithPriceEngine wires the pricing engine. +func (s *Service) WithPriceEngine(p PriceEngine) *Service { s.pricer = p; return s } - if session, err := s.repo.GetOpenTillSession(ctx, registerID); err == nil && session != nil { - tx.TillSessionID = &session.ID - } +// WithTaxProvider wires the configured tax provider behind the resolver. +func (s *Service) WithTaxProvider(p TaxProvider) *Service { s.tax = p; return s } - if err := s.repo.CreateTransaction(ctx, tx); err != nil { - return nil, err - } +// WithInvoices wires the invoice writer. +func (s *Service) WithInvoices(w InvoiceWriter) *Service { s.invoices = w; return s } + +// WithLedger wires the till over/short poster. +func (s *Service) WithLedger(p LedgerPoster) *Service { s.ledger = p; return s } + +// WithGateway wires the card-present terminal gateway. +func (s *Service) WithGateway(g PaymentGateway) *Service { s.gateway = g; return s } - s.logger.Info("POS transaction started", "id", tx.ID, "register", registerID, "till_session", tx.TillSessionID) - return tx, nil +// WithAuditLog attaches the audit logger. +func (s *Service) WithAuditLog(l *audit.Logger) *Service { s.auditLog = l; return s } + +// WithOutbox wires the event recorder. +func (s *Service) WithOutbox(e EventRecorder) *Service { s.events = e; return s } + +// WithTxRunner wires the transaction runner (serve does; unit tests need +// none). +func (s *Service) WithTxRunner(t TxRunner) *Service { s.tx = t; return s } + +// WithAR wires the AR core. +func (s *Service) WithAR(a ARCore) *Service { s.ar = a; return s } + +// WithInventory wires the stock moves. +func (s *Service) WithInventory(i CounterInventory) *Service { s.inventory = i; return s } + +func (s *Service) inTx(ctx context.Context, fn func(ctx context.Context) error) error { + if s.tx != nil { + return s.tx.RunInTx(ctx, fn) + } + if s.db != nil { + return s.db.RunInTx(ctx, fn) + } + return fn(ctx) } -// AddItem adds a product to the transaction cart. -func (s *Service) AddItem(ctx context.Context, txID uuid.UUID, req AddLineItemRequest) (*POSTransaction, error) { - // Get the product to populate description and pricing - prod, err := s.productSvc.GetProduct(ctx, req.ProductID) +// actorOf reads the acting user from the request context's claims. +func actorOf(ctx context.Context) string { return "" } + +// eventJSON marshals an event's data. +func eventJSON(v map[string]any) []byte { + raw, err := json.Marshal(v) if err != nil { - return nil, fmt.Errorf("product not found: %w", err) + return []byte(`{}`) } + return raw +} - // Resolve effective price (customer-specific if available) - effectivePrice := prod.BasePrice - if s.priceCalc != nil { - // Get the transaction to check for customer association - tx, txErr := s.repo.GetTransaction(ctx, txID) - if txErr == nil && tx.CustomerID != nil { - if resolved, pErr := s.priceCalc.CalculateItemPrice(ctx, *tx.CustomerID, req.ProductID, prod.BasePrice, req.Quantity); pErr == nil { - effectivePrice = resolved - } - } +// estimateTax is the cart's live estimate at the branch rate; the money +// moment resolves its own tax through the provider or the resolver +// (ADR 0005 section 3: the estimate is not the bill). +func (s *Service) estimateTax(ctx context.Context, sale *Sale, taxableCents httpx.Cents) int64 { + rate, ok, err := s.repo.BranchTaxRate(ctx, &sale.BranchID) + if err != nil || !ok { + return 0 } - - unitPriceCents := int64(effectivePrice*100.0 + 0.5) - lineTotalCents := int64(math.Round(float64(unitPriceCents) * req.Quantity)) - - item := &POSLineItem{ - TransactionID: txID, - ProductID: req.ProductID, - Description: prod.Description, - Quantity: req.Quantity, - UOM: req.UOM, - UnitPrice: unitPriceCents, - LineTotal: lineTotalCents, + scaled, _, err := salesdoc.ParseTaxRate(rate) + if err != nil { + return 0 } + return int64(salesdoc.TaxAt(taxableCents, scaled)) +} - if item.UOM == "" { - item.UOM = string(prod.UOMPrimary) +func (s *Service) recordEvent(ctx context.Context, ev outbox.Event) error { + if s.events == nil { + return nil } + return s.events.Write(ctx, ev) +} - if err := s.repo.AddLineItem(ctx, item); err != nil { +// StartSale opens a cart on a register, numbered POS- through the sequence +// and attached to the register's open till session when one exists. A sale +// without a customer is a walk-in sale: the walk-in customer is resolved at +// completion, where the invoice needs a real customer. +func (s *Service) StartSale(ctx context.Context, in *StartSale, actor string) (*Sale, error) { + walkInID, defaultCurrency, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return nil, err + } + currency := defaultCurrency + if in.CustomerID != nil { + facts, err := s.repo.CustomerFacts(ctx, *in.CustomerID) + if err != nil { + return nil, err + } + currency = facts.Currency + } + var out *Sale + err = s.inTx(ctx, func(ctx context.Context) error { + sale := &Sale{RegisterID: in.RegisterID, CashierID: in.CashierID, CustomerID: in.CustomerID, + Status: StatusOpen, Currency: currency, WalkInID: walkInID} + if session, err := s.repo.GetOpenTillSession(ctx, in.RegisterID); err == nil && session != nil { + sale.TillSessionID = &session.ID + } + if err := s.repo.CreateSale(ctx, sale); err != nil { + return err + } + out = sale + return nil + }) + if err != nil { return nil, err } + return out, nil +} - // Recalculate totals - return s.recalculateTotals(ctx, txID) +// pricingOut comes back from pricing a request line: the line, ready to +// store, and the audit rows the price adjustment owes. +type pricingOut struct { + overrides []audit.Entry + discounts []audit.Entry } -// RemoveItem removes a line item from the transaction. -func (s *Service) RemoveItem(ctx context.Context, txID uuid.UUID, itemID uuid.UUID) (*POSTransaction, error) { - if err := s.repo.RemoveLineItem(ctx, itemID); err != nil { - return nil, err +// priceLine turns one parsed request line into a stored line at the counter +// (ADR 0005 sections 2.3 and 2.7, the order module's pricing step applied at +// the counter): defaults from the product or the charge code, the engine's +// price beside an override, the discount on the line, kits exploded. +func (s *Service) priceLine(ctx context.Context, sale *Sale, pl salesdoc.ParsedLine, actor string) (salesdoc.Line, *pricingOut, error) { + out := &pricingOut{} + line := salesdoc.Line{ID: uuid.New(), Description: pl.Description, PriceSource: salesdoc.PriceSourceNone} + if pl.Taxable != nil { + line.Taxable = *pl.Taxable + } + customerID := uuid.Nil + if sale.CustomerID != nil { + customerID = *sale.CustomerID + } + refs, codes, _, err := s.lookups(ctx, []salesdoc.ParsedLine{pl}) + if err != nil { + return line, out, err + } + switch pl.LineType { + case salesdoc.LineText: + line.LineType = salesdoc.LineText + return line, out, nil + case salesdoc.LineCharge: + line.LineType = salesdoc.LineCharge + code, ok := codes[pl.ChargeCode] + if !ok { + return line, out, invalid(lineField("charge_code"), "no such charge code") + } + line.ChargeCodeID = &code.ID + cc := code.Code + line.ChargeCode = &cc + if line.Description == "" { + line.Description = code.Name + } + qty := pl.Quantity + line.Quantity = &qty + uom := pl.UOM + if uom == "" { + uom = "EA" + } + line.UOM, line.PriceUOM = &uom, &uom + line.UOMQty, line.PriceUOMQty = ptrQty(salesdoc.One), ptrQty(salesdoc.One) + price := httpx.Price(0) + if pl.UnitPrice != nil { + price = *pl.UnitPrice + } else if code.DefaultUnitPrice != nil { + price = *code.DefaultUnitPrice + } else { + return line, out, invalid("line.unit_price_ten_thousandths", + "is required when the charge code has no default price") + } + line.UnitPrice = &price + line.PriceSource = salesdoc.PriceSourceManual + if pl.Taxable != nil { + line.Taxable = *pl.Taxable + } else { + line.Taxable = code.Taxable + } + line.RevenueAccountCode = &code.RevenueAccountCode + default: // product + line.LineType = salesdoc.LineProduct + var ref *salesdoc.ProductRef + if pl.ProductID != nil { + if r, ok := refs[pl.ProductID.String()]; ok { + ref = &r + } else { + return line, out, invalid(lineField("product_id"), "no such product") + } + } + if ref != nil { + line.ProductID = &ref.ID + if line.SKU == nil || *line.SKU == "" { + sku := ref.SKU + line.SKU = &sku + } + if line.Description == "" { + line.Description = ref.Description + } + stock := ref.UOMPrimary + if pl.UOM != "" && pl.UOM != stock { + return line, out, conflict("unit_not_stock_unit", + fmt.Sprintf("the line is sold in %s but the product stocks in %s", pl.UOM, stock)) + } + uom := stock + line.UOM = &uom + line.Taxable = ref.Taxable + } else { + uom := pl.UOM + line.UOM = &uom + if pl.Taxable != nil { + line.Taxable = *pl.Taxable + } else { + line.Taxable = true + } + } + priceUOM := pl.PriceUOM + if priceUOM == "" { + priceUOM = derefString(line.UOM) + } + line.PriceUOM = &priceUOM + if pl.UOMQty == 0 && pl.PriceUOMQty == 0 { + line.UOMQty, line.PriceUOMQty = ptrQty(salesdoc.One), ptrQty(salesdoc.One) + } else { + line.UOMQty, line.PriceUOMQty = ptrQty(pl.UOMQty), ptrQty(pl.PriceUOMQty) + } + qty := pl.Quantity + line.Quantity = &qty + var enginePrice httpx.Price + if ref != nil { + enginePrice, err = s.enginePrice(ctx, customerID, ref, pl.Quantity) + if err != nil { + return line, out, err + } + } + if pl.UnitPrice == nil { + line.UnitPrice = &enginePrice + line.PricedUnitPrice = &enginePrice + line.PriceSource = salesdoc.PriceSourceList + } else { + p := *pl.UnitPrice + line.UnitPrice = &p + if ref != nil { + line.PricedUnitPrice = &enginePrice + if p == enginePrice { + line.PriceSource = salesdoc.PriceSourceList + } else { + line.PriceSource = salesdoc.PriceSourceOverride + reason := pl.OverrideReason + line.OverrideReason = &reason + line.PriceAdjustedBy = actorPtr(actor) + out.overrides = append(out.overrides, audit.Entry{ + Action: auditLinePriceOverridden, EntityType: "pos_transaction", EntityID: sale.ID, UserID: actor, + Changes: map[string]any{"line_id": line.ID, "product_id": line.ProductID, + "engine_price_ten_thousandths": int64(enginePrice), + "override_price_ten_thousandths": int64(p), "reason": pl.OverrideReason}}) + } + } else { + line.PriceSource = salesdoc.PriceSourceManual + } + } } - return s.recalculateTotals(ctx, txID) + if pl.DiscountPercent != nil { + pct := *pl.DiscountPercent + line.DiscountPercent = &pct + line.DiscountReason = &pl.DiscountReason + line.PriceAdjustedBy = actorPtr(actor) + } else if pl.DiscountAmount != nil { + amt := *pl.DiscountAmount + line.DiscountAmount = &amt + line.DiscountReason = &pl.DiscountReason + line.PriceAdjustedBy = actorPtr(actor) + } + if line.DiscountPercent != nil || line.DiscountAmount != nil { + out.discounts = append(out.discounts, audit.Entry{ + Action: auditLineDiscounted, EntityType: "pos_transaction", EntityID: sale.ID, UserID: actor, + Changes: map[string]any{"line_id": line.ID, "product_id": line.ProductID, + "discount_percent": pl.DiscountPercent, "discount_cents": pl.DiscountAmount, + "reason": pl.DiscountReason}}) + } + // Kits explode: the kit line priced as a whole, one component per + // component at zero (2.6). The caller stores what comes back. + return line, out, nil } -// CompleteTransaction finalizes the sale: charges card tenders through the -// gateway, applies tenders, deducts inventory, computes change, posts to the -// GL, and turns ACCOUNT tenders into a real invoice (AR). -func (s *Service) CompleteTransaction(ctx context.Context, txID uuid.UUID, tenders []AddTenderRequest) (*POSTransaction, error) { - var result *POSTransaction - - // Phase 0 — pre-transaction validation + card charging. Gateway calls - // happen OUTSIDE the DB transaction: they're slow, non-transactional, - // and must not hold row locks. A post-charge DB failure is logged - // CRITICAL for manual reconciliation (same policy as payment.Service). - pre, err := s.repo.GetTransaction(ctx, txID) +// explodeAndExtend runs Explode then ExtendLine over the priced lines, +// returning the stored order. +func (s *Service) explodeAndExtend(ctx context.Context, lines []salesdoc.Line) ([]salesdoc.Line, error) { + var productIDs, kitIDs []uuid.UUID + for i := range lines { + if lines[i].ProductID != nil { + productIDs = append(productIDs, *lines[i].ProductID) + kitIDs = append(kitIDs, *lines[i].ProductID) + } + } + refs, err := s.repo.LookupProducts(ctx, productIDs) if err != nil { return nil, err } - if pre.Status != TransactionStatusOpen && pre.Status != TransactionStatusHeld { - return nil, fmt.Errorf("transaction is not open (status: %s)", pre.Status) + kits, err := s.repo.LookupKitComponents(ctx, kitIDs) + if err != nil { + return nil, err } - - var totalTendered int64 - var accountPortion int64 - for _, t := range tenders { - cents := int64(t.Amount*100.0 + 0.5) - totalTendered += cents - if t.Method == "ACCOUNT" { - accountPortion += cents + var componentIDs []uuid.UUID + for kitID, comps := range kits { + if _, ok := refs[kitID]; !ok { + continue + } + for _, c := range comps { + if _, seen := refs[c.ComponentProductID.String()]; !seen { + componentIDs = append(componentIDs, c.ComponentProductID) + } } } - if totalTendered < pre.Total { - return nil, fmt.Errorf("insufficient tender: need %d cents, got %d cents", pre.Total, totalTendered) + if len(componentIDs) > 0 { + more, err := s.repo.LookupProducts(ctx, componentIDs) + if err != nil { + return nil, err + } + for k, v := range more { + refs[k] = v + } } - if accountPortion > 0 && pre.CustomerID == nil { - return nil, fmt.Errorf("ACCOUNT tender requires a customer on the transaction") + exploded, err := salesdoc.Explode(lines, kits, refs) + if err != nil { + return nil, err } - // Change is only ever given from over-tendering; it comes out of the - // cash drawer, so overpayment on non-cash methods is rejected. - changeDue := totalTendered - pre.Total - if changeDue > 0 { - var cashTendered int64 - for _, t := range tenders { - if t.Method == "CASH" { - cashTendered += int64(t.Amount*100.0 + 0.5) - } - } - if cashTendered < changeDue { - return nil, fmt.Errorf("overpayment of %d cents exceeds cash tendered — only cash tenders can generate change", changeDue) + for i := range exploded { + if err := salesdoc.ExtendLine(&exploded[i]); err != nil { + return nil, err } } + return exploded, nil +} - // Card handling. POS counter cards ride the CARD-PRESENT rail (Clover - // terminal): the device captures the card out-of-band and the tender is - // recorded as externally-settled — no token, no online charge here. A - // token is only ever present if a card-present terminal gateway is wired - // via WithGateway; absent that, a token-bearing CARD tender is rejected - // rather than silently recorded (it would imply an uncaptured charge). - chargeResults := make([]*payment.GatewayResult, len(tenders)) - for i, t := range tenders { - if t.Method != "CARD" || t.TokenID == "" { - continue - } - if s.gateway == nil { - return nil, fmt.Errorf("card-present terminal is not configured on this register; record the card tender without a token (the terminal settles it) or wire the Clover terminal gateway") - } - res, chargeErr := s.gateway.Charge(ctx, payment.ChargeRequest{ - TokenID: t.TokenID, - AmountCents: int64(t.Amount*100.0 + 0.5), - Currency: "USD", - Description: fmt.Sprintf("POS sale %s", txID), - InvoiceID: txID.String(), - }) - if chargeErr != nil { - return nil, fmt.Errorf("card charge failed: %w", chargeErr) +func (s *Service) enginePrice(ctx context.Context, customerID uuid.UUID, ref *salesdoc.ProductRef, qty httpx.Quantity) (httpx.Price, error) { + if s.pricer == nil { + return ref.BasePrice, nil + } + return s.pricer.PriceFor(ctx, customerID, ref.ID, ref.BasePrice, qty, nil) +} + +// lookups reads every product, charge code and kit definition the parsed +// lines name. +func (s *Service) lookups(ctx context.Context, parsed []salesdoc.ParsedLine) (map[string]salesdoc.ProductRef, map[string]salesdoc.ChargeCode, map[string][]salesdoc.KitComponent, error) { + var productIDs, kitIDs []uuid.UUID + var codes []string + for _, pl := range parsed { + if pl.ProductID != nil { + productIDs = append(productIDs, *pl.ProductID) + kitIDs = append(kitIDs, *pl.ProductID) } - if res.Status != payment.GatewayStatusApproved { - return nil, fmt.Errorf("card declined (%s)", res.Status) + if pl.ChargeCode != "" { + codes = append(codes, pl.ChargeCode) } - chargeResults[i] = res } + refs, err := s.repo.LookupProducts(ctx, productIDs) + if err != nil { + return nil, nil, nil, err + } + kits, err := s.repo.LookupKitComponents(ctx, kitIDs) + if err != nil { + return nil, nil, nil, err + } + chargeCodes, err := s.repo.LookupChargeCodes(ctx, codes) + if err != nil { + return nil, nil, nil, err + } + return refs, chargeCodes, kits, nil +} - err = s.db.RunInTx(ctx, func(ctx context.Context) error { - // 1. Re-read inside the transaction - tx, err := s.repo.GetTransaction(ctx, txID) +// AddLine adds one line to an open cart: priced, extended, with the audit +// rows an override or a discount owes, all in the add's own transaction. +func (s *Service) AddLine(ctx context.Context, saleID uuid.UUID, parsed []salesdoc.ParsedLine, actor string) (*Sale, error) { + var out *Sale + err := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, saleID); err != nil { + return err + } + sale, err := s.repo.GetSale(ctx, saleID) if err != nil { return err } - if tx.Status != TransactionStatusOpen && tx.Status != TransactionStatusHeld { - return fmt.Errorf("transaction is not open (status: %s)", tx.Status) + if sale.Status != StatusOpen && sale.Status != StatusHeld { + return httpx.InvalidStateTransition(fmt.Sprintf("cannot add a line to a %s sale", sale.Status.Status())) } - - // 2. Record tenders (with gateway results on charged cards) - for i, t := range tenders { - tender := &POSTender{ - TransactionID: txID, - Method: t.Method, - Amount: int64(t.Amount*100.0 + 0.5), - Reference: t.Reference, - } - if res := chargeResults[i]; res != nil { - tender.GatewayTxID = res.TransactionID - tender.AuthCode = res.AuthCode - tender.CardLast4 = res.CardLast4 - tender.CardBrand = res.CardBrand - } - if err := s.repo.AddTender(ctx, tender); err != nil { + var priced []salesdoc.Line + var audits []audit.Entry + for i := range parsed { + line, out, err := s.priceLine(ctx, sale, parsed[i], actor) + if err != nil { return err } + priced = append(priced, line) + audits = append(audits, out.overrides...) + audits = append(audits, out.discounts...) } - tx.ChangeDue = changeDue - - // 4. Get line items for inventory deduction - items, err := s.repo.GetLineItems(ctx, txID) + lines, err := s.explodeAndExtend(ctx, priced) if err != nil { return err } - - // 5. Deduct inventory for each line item - for _, item := range items { - if err := s.inventorySvc.AdjustStock(ctx, inventory.StockAdjustmentRequest{ - ProductID: item.ProductID, - LocationID: nil, // Default location - Quantity: -item.Quantity, - IsDelta: true, - }); err != nil { - s.logger.Error("Inventory deduction failed for POS item — aborting sale", - "product_id", item.ProductID, - "quantity", item.Quantity, - "transaction_id", txID, - "error", err, - ) - return fmt.Errorf("inventory deduction failed for product %s: %w", item.ProductID, err) + existing, err := s.repo.GetLines(ctx, saleID) + if err != nil { + return err + } + next := len(existing) + for i := range lines { + if lines[i].LineType == salesdoc.LineComponent { + continue } + lines[i].Position = next + next++ } - - // 6. Complete the transaction - now := time.Now() - tx.Status = TransactionStatusCompleted - tx.CompletedAt = &now - if err := s.repo.UpdateTransaction(ctx, tx); err != nil { + // Explode assigns component lines after their kit; the positions + // above numbered only the parents, so the components follow theirs + // in the stored order Explode built. + if err := s.repo.AddLines(ctx, saleID, lines); err != nil { return err } - - // Populate for response - tx.LineItems = items - txTenders, _ := s.repo.GetTenders(ctx, txID) - tx.Tenders = txTenders - result = tx - - // Audit log: inside the transaction, so it shares the sale's fate — - // a rolled back sale leaves no audit row. - if s.auditLog != nil { - if err := s.auditLog.Log(ctx, audit.Entry{ - Action: "pos.transaction.completed", - EntityType: "pos_transaction", - EntityID: txID, - Changes: map[string]interface{}{ - "total_cents": tx.Total, - "register_id": tx.RegisterID, - "customer_id": tx.CustomerID, - "tenders": len(tenders), - }, - }); err != nil { - return fmt.Errorf("failed to write audit log: %w", err) + for i := range audits { + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audits[i]); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } } } - - s.logger.Info("POS transaction completed", - "id", txID, - "total_cents", tx.Total, - "tenders", len(tenders), - ) - return nil + if err := s.recalcTotals(ctx, sale); err != nil { + return err + } + out, err = s.GetSale(ctx, saleID) + return err }) - if err != nil { return nil, err } - - // Post-commit financial effects. Best-effort by design: a GL or invoice - // hiccup must never roll back a completed till sale (it's logged for - // reconciliation instead). - // NOTE: voiding a completed sale does not yet post a reversing GL entry — a - // known follow-up; the dominant completed-sale path is now booked. - if result != nil && result.Total > 0 { - // 1. The ACCOUNT portion becomes a real invoice: AR the customer can - // see and pay, posted to the GL + subledger through the standard - // invoice path (DR Accounts Receivable / CR Sales Revenue). - if accountPortion > 0 && s.invoiceSvc != nil && result.CustomerID != nil && len(result.LineItems) > 0 { - inv := s.buildAccountInvoice(result, accountPortion) - if err := s.invoiceSvc.CreateInvoice(ctx, inv); err != nil { - s.logger.Error("CRITICAL: POS account-charge invoice creation failed — AR missing, reconcile manually", - "transaction_id", txID, "customer_id", result.CustomerID, "amount_cents", accountPortion, "error", err) - } else if err := s.invoiceSvc.PostInvoiceToLedger(ctx, inv); err != nil { - s.logger.Error("CRITICAL: POS account-charge invoice created but ledger posting failed — reconcile manually", - "transaction_id", txID, "invoice_id", inv.ID, "error", err) - } - } - - // 2. The cash-collected portion (cash + card + check, net of change) - // books as the cash sale (DR Cash / CR Sales Revenue). Card funds - // settle to the same cash bucket in v1 — a dedicated card-clearing - // account is a chart-of-accounts follow-up. - cashCollected := result.Total - accountPortion - if cashCollected > 0 && s.invoiceSvc != nil { - if err := s.invoiceSvc.PostCashSaleToGL(ctx, txID.String(), cashCollected); err != nil { - s.logger.Error("failed to post POS sale to GL", "transaction_id", txID, "error", err) - } - } - } - - return result, nil -} - -// buildAccountInvoice constructs the invoice for the ACCOUNT portion of a -// POS sale. When the whole sale went on account, the invoice mirrors the -// cart lines; a split sale gets a single summary line (the tax math on a -// partial allocation is a per-line proration problem deferred until a -// dealer needs split account tenders). -func (s *Service) buildAccountInvoice(tx *POSTransaction, accountPortionCents int64) *invoice.LegacyInvoice { - inv := &invoice.LegacyInvoice{ - BranchID: tx.BranchID, - CustomerID: *tx.CustomerID, - Status: invoice.InvoiceStatusUnpaid, - } - if accountPortionCents >= tx.Total && len(tx.LineItems) > 0 { - for _, item := range tx.LineItems { - inv.Lines = append(inv.Lines, invoice.LegacyLine{ - ProductID: item.ProductID, - Quantity: item.Quantity, - PriceEach: item.UnitPrice, - }) - } - inv.Subtotal = tx.Subtotal - inv.TaxAmount = tx.TaxAmount - inv.TotalAmount = tx.Total - if tx.Subtotal > 0 { - inv.TaxRate = float64(tx.TaxAmount) / float64(tx.Subtotal) - } - } else { - // Split tender: one summary line for the on-account portion, - // tax-inclusive (the register already charged the tax). - inv.Lines = []invoice.LegacyLine{{ - ProductID: tx.LineItems[0].ProductID, - Quantity: 1, - PriceEach: accountPortionCents, - }} - inv.Subtotal = accountPortionCents - inv.TaxRate = 0 - inv.TaxAmount = 0 - inv.TotalAmount = accountPortionCents - } - return inv + return out, nil } -// VoidTransaction voids an open or completed transaction. -// All DB operations (inventory reversal + status update) run in a single transaction. -func (s *Service) VoidTransaction(ctx context.Context, txID uuid.UUID) (*POSTransaction, error) { - var result *POSTransaction - - err := s.db.RunInTx(ctx, func(ctx context.Context) error { - tx, err := s.repo.GetTransaction(ctx, txID) +// RemoveLine removes a line from an open cart. +func (s *Service) RemoveLine(ctx context.Context, saleID, lineID uuid.UUID) (*Sale, error) { + var out *Sale + err := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, saleID); err != nil { + return err + } + sale, err := s.repo.GetSale(ctx, saleID) if err != nil { return err } - - if tx.Status == TransactionStatusVoided { - result = tx - return nil + if sale.Status != StatusOpen && sale.Status != StatusHeld { + return httpx.InvalidStateTransition(fmt.Sprintf("cannot remove a line from a %s sale", sale.Status.Status())) } - - // If completed, we need to reverse inventory - if tx.Status == TransactionStatusCompleted { - items, err := s.repo.GetLineItems(ctx, txID) - if err != nil { - return fmt.Errorf("failed to get line items for void: %w", err) - } - for _, item := range items { - if err := s.inventorySvc.AdjustStock(ctx, inventory.StockAdjustmentRequest{ - ProductID: item.ProductID, - LocationID: nil, - Quantity: item.Quantity, // Positive to restore - IsDelta: true, - }); err != nil { - return fmt.Errorf("failed to reverse inventory for product %s: %w", item.ProductID, err) - } + lines, err := s.repo.GetLines(ctx, saleID) + if err != nil { + return err + } + for i := range lines { + if lines[i].ID == lineID && lines[i].ParentLineID != nil { + return invalid("line_id", "is a kit component: remove the kit line, its components follow") } } - - tx.Status = TransactionStatusVoided - if err := s.repo.UpdateTransaction(ctx, tx); err != nil { + if err := s.repo.RemoveLine(ctx, saleID, lineID); err != nil { return err } - - // Audit log: inside the transaction, so it shares the void's fate — - // and only a void that actually changes state writes one: the early - // return above for an already-voided transaction writes no row. - if s.auditLog != nil { - if err := s.auditLog.Log(ctx, audit.Entry{ - Action: "pos.transaction.voided", - EntityType: "pos_transaction", - EntityID: txID, - Changes: map[string]interface{}{ - "total_cents": tx.Total, - "register_id": tx.RegisterID, - }, - }); err != nil { - return fmt.Errorf("failed to write audit log: %w", err) - } + if err := s.recalcTotals(ctx, sale); err != nil { + return err } - - result = tx - return nil + out, err = s.GetSale(ctx, saleID) + return err }) - if err != nil { return nil, err } - - s.logger.Info("POS transaction voided", "id", txID) - return result, nil + return out, nil } -// GetTransaction returns a full transaction with line items and tenders. -func (s *Service) GetTransaction(ctx context.Context, txID uuid.UUID) (*POSTransaction, error) { - tx, err := s.repo.GetTransaction(ctx, txID) +// GetSale reads a sale with its lines and tenders. +func (s *Service) GetSale(ctx context.Context, saleID uuid.UUID) (*Sale, error) { + sale, err := s.repo.GetSale(ctx, saleID) if err != nil { return nil, err } - - tx.LineItems, _ = s.repo.GetLineItems(ctx, txID) - tx.Tenders, _ = s.repo.GetTenders(ctx, txID) - return tx, nil -} - -// ListTransactions returns transaction summaries for a register and date. -func (s *Service) ListTransactions(ctx context.Context, registerID string, date time.Time) ([]TransactionSummary, error) { - return s.repo.ListTransactions(ctx, registerID, date) -} - -// SearchProducts performs typeahead product search for the POS. -func (s *Service) SearchProducts(ctx context.Context, query string) ([]QuickSearchResult, error) { - if len(query) < 2 { - return nil, nil - } - return s.repo.SearchProducts(ctx, query, 20) -} - -// recalculateTotals sums line items and updates the transaction. -func (s *Service) recalculateTotals(ctx context.Context, txID uuid.UUID) (*POSTransaction, error) { - tx, err := s.repo.GetTransaction(ctx, txID) - if err != nil { - return nil, err - } - - items, err := s.repo.GetLineItems(ctx, txID) - if err != nil { + if sale.Lines, err = s.repo.GetLines(ctx, saleID); err != nil { return nil, err } - - var subtotal int64 - for _, item := range items { - subtotal += item.LineTotal - } - - tx.Subtotal = subtotal - tx.TaxAmount = s.calculateTax(ctx, tx, items) - tx.Total = subtotal + tx.TaxAmount - - if err := s.repo.UpdateTransaction(ctx, tx); err != nil { + if sale.Tenders, err = s.repo.GetTenders(ctx, saleID); err != nil { return nil, err } - - tx.LineItems = items - return tx, nil + return sale, nil } -// calculateTax computes cart tax through the tax service (exemption-aware; -// Avalara when configured; otherwise the branch's default rate as a flat -// estimate — the same source invoices use, so till and invoice totals agree). -func (s *Service) calculateTax(ctx context.Context, tx *POSTransaction, items []POSLineItem) int64 { - if tx.Subtotal == 0 { - return 0 - } - - // Resolve the branch rate hint (branch of the transaction, else request - // context, else the invoice-module default). - rate := invoice.DefaultTaxRate - if s.branchRates != nil { - branchID := &tx.BranchID - if tx.BranchID == uuid.Nil { - branchID = branchctx.IDForQuery(ctx) - } - if r, ok := s.branchRates.GetBranchTaxRate(ctx, branchID); ok { - rate = r - } - } - - if s.taxCalc == nil { - return int64(math.Round(float64(tx.Subtotal) * rate)) - } - - req := &tax.TaxPreviewRequest{ - CustomerID: tx.CustomerID, - DocumentType: "SalesInvoice", - RateHint: rate, - } - for i, item := range items { - req.Lines = append(req.Lines, tax.TaxLineInput{ - LineNumber: i + 1, - ItemCode: item.ProductID.String(), - Description: item.Description, - Quantity: item.Quantity, - Amount: item.LineTotal, - }) - } - - res, err := s.taxCalc.PreviewTax(ctx, req) +// recalcTotals recomputes the cart's subtotal and the live tax estimate. +func (s *Service) recalcTotals(ctx context.Context, sale *Sale) error { + lines, err := s.repo.GetLines(ctx, sale.ID) if err != nil { - s.logger.Warn("POS tax preview failed; falling back to branch rate", "error", err) - return int64(math.Round(float64(tx.Subtotal) * rate)) + return err } - return res.TotalTax + totals := salesdoc.SumTotals(lines) + return s.repo.UpdateSaleTotals(ctx, sale.ID, int64(totals.SubtotalCents), int64(s.estimateTax(ctx, sale, totals.TaxableCents)), int64(totals.SubtotalCents)+int64(s.estimateTax(ctx, sale, totals.TaxableCents))) } -// --- Offline Sync --- - -// SyncOfflineTransactions replays a batch of offline POS transactions. -// It uses client-generated UUIDs for idempotency (duplicate detection). -func (s *Service) SyncOfflineTransactions(ctx context.Context, req OfflineSyncRequest) (*OfflineSyncResponse, error) { - resp := &OfflineSyncResponse{ - BatchID: req.BatchID, - } - - syncTag := "offline-v1" - - for _, offlineTx := range req.Items { - // 1. Idempotency check — skip if already synced - exists, err := s.repo.TransactionExists(ctx, offlineTx.ClientID) - if err != nil { - s.logger.Error("sync: existence check failed", "client_id", offlineTx.ClientID, "error", err) - resp.ErrorCount++ - resp.Errors = append(resp.Errors, SyncError{ - ClientID: offlineTx.ClientID.String(), - Reason: fmt.Sprintf("existence check: %v", err), - }) - continue - } - if exists { - resp.DuplicateCount++ - continue - } - - // 2. Create the transaction with offline metadata - registerID := offlineTx.RegisterID - if registerID == "" { - registerID = req.RegisterID - } - - tx := &POSTransaction{ - ID: offlineTx.ClientID, - RegisterID: registerID, - CashierID: offlineTx.CashierID, - CustomerID: offlineTx.CustomerID, - Status: TransactionStatusOpen, - SyncedFrom: &syncTag, - ClientCreatedAt: &offlineTx.ClientCreatedAt, - } +func invalid(field, message string) *httpx.Error { + return &httpx.Error{Status: http.StatusBadRequest, Code: httpx.CodeValidationFailed, + Message: "one or more fields failed validation", Details: []httpx.FieldError{{Field: field, Message: message}}} +} - if err := s.repo.CreateTransaction(ctx, tx); err != nil { - s.logger.Error("sync: create transaction failed", "client_id", offlineTx.ClientID, "error", err) - resp.ErrorCount++ - resp.Errors = append(resp.Errors, SyncError{ - ClientID: offlineTx.ClientID.String(), - Reason: fmt.Sprintf("create: %v", err), - }) - continue - } +func lineField(name string) string { return "line." + name } - // 3. Replay line items - itemErr := false - for _, item := range offlineTx.Items { - if _, err := s.AddItem(ctx, tx.ID, item); err != nil { - s.logger.Warn("sync: add item failed", "client_id", offlineTx.ClientID, "product_id", item.ProductID, "error", err) - itemErr = true - } - } +func conflict(code, message string) *httpx.Error { + return &httpx.Error{Status: http.StatusConflict, Code: httpx.CodeConflict, + Message: message, Details: []httpx.FieldError{httpx.Blocker(code, message)}} +} - // 4. Complete the transaction with tenders - if !itemErr && len(offlineTx.Tenders) > 0 { - if _, err := s.CompleteTransaction(ctx, tx.ID, offlineTx.Tenders); err != nil { - s.logger.Warn("sync: complete transaction failed", "client_id", offlineTx.ClientID, "error", err) - resp.ErrorCount++ - resp.Errors = append(resp.Errors, SyncError{ - ClientID: offlineTx.ClientID.String(), - Reason: fmt.Sprintf("complete: %v", err), - }) - continue - } - } +func ptrQty(q httpx.Quantity) *httpx.Quantity { return &q } - resp.SyncedCount++ +func derefString(s *string) string { + if s == nil { + return "" } + return *s +} - // 5. Log the sync batch - if err := s.repo.LogSyncBatch(ctx, req.BatchID, req.RegisterID, resp.SyncedCount, resp.DuplicateCount, resp.ErrorCount, resp.Errors); err != nil { - s.logger.Error("sync: failed to log batch", "batch_id", req.BatchID, "error", err) +func actorPtr(actor string) *string { + if actor == "" { + return nil } - - return resp, nil + return &actor } -// GetProductCatalog returns full product catalog for offline caching. -func (s *Service) GetProductCatalog(ctx context.Context) ([]CatalogProduct, error) { - return s.repo.GetProductCatalog(ctx) -} +var errNotConfigured = errors.New("the counter's money path is not wired") diff --git a/core/internal/pos/service_audit_tx_test.go b/core/internal/pos/service_audit_tx_test.go deleted file mode 100644 index c8c51925d..000000000 --- a/core/internal/pos/service_audit_tx_test.go +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package pos_test - -import ( - "context" - "errors" - "log/slog" - "testing" - - "github.com/gablelbm/gable/internal/account" - "github.com/gablelbm/gable/internal/gl" - glint "github.com/gablelbm/gable/internal/integrations/gl" - "github.com/gablelbm/gable/internal/inventory" - "github.com/gablelbm/gable/internal/invoice" - "github.com/gablelbm/gable/internal/payment" - "github.com/gablelbm/gable/internal/pos" - "github.com/gablelbm/gable/internal/product" - "github.com/gablelbm/gable/internal/testutil" - "github.com/gablelbm/gable/pkg/audit" - "github.com/google/uuid" -) - -// TestCompleteTransactionAuditRollsBackWithFailedMutation runs the real POS -// sale completion (StartTransaction and AddItem committed first, exactly as -// the terminal does) inside a caller's transaction that then fails: the -// tenders, the inventory deduction, the completed status and the audit row -// all ride that transaction, so a failed completion leaves the sale OPEN and -// no audit row claiming a completed sale that never happened. -func TestCompleteTransactionAuditRollsBackWithFailedMutation(t *testing.T) { - db := testutil.RequireDB(t) - ctx := context.Background() - logger := slog.Default() - - glSvc := gl.NewService(gl.NewRepository(db), glint.NewMockGLAdapter(), logger) - accountSvc := account.NewService(db, glSvc, logger) - invoiceSvc := invoice.NewService(invoice.NewRepository(db), glSvc, accountSvc, db). - WithAuditLog(audit.NewLogger(db)) - paymentSvc := payment.NewService(db, payment.NewRepository(db), accountSvc). - WithAuditLog(audit.NewLogger(db)) - svc := pos.NewService(db, pos.NewRepository(db), - product.NewService(product.NewRepository(db)), - inventory.NewService(inventory.NewRepository(db)), - invoiceSvc, paymentSvc, logger). - WithAuditLog(audit.NewLogger(db)) - - productID := uuid.New() - if _, err := db.Pool.Exec(ctx, - "INSERT INTO products (id, sku, description, uom_primary, base_price) VALUES ($1, $2, 'POS Audit Item', 'EA', 20)", - productID, "PR-"+productID.String()[:8]); err != nil { - t.Fatalf("insert product: %v", err) - } - if _, err := db.Pool.Exec(ctx, - "INSERT INTO inventory (product_id, location, quantity) VALUES ($1, 'POS Audit Rack', 100)", - productID); err != nil { - t.Fatalf("seed stock: %v", err) - } - registerID := "AUDIT-REG-" + productID.String()[:8] - if _, err := db.Pool.Exec(ctx, - `INSERT INTO pos_registers (id, location_id, branch_id, name) - VALUES ($1, - (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), - (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), - 'Audit Test Register')`, - registerID); err != nil { - t.Fatalf("insert register: %v", err) - } - - tx, err := svc.StartTransaction(ctx, registerID, uuid.New(), nil) - if err != nil { - t.Fatalf("start POS transaction: %v", err) - } - if _, err := svc.AddItem(ctx, tx.ID, pos.AddLineItemRequest{ProductID: productID, Quantity: 2}); err != nil { - t.Fatalf("add item: %v", err) - } - open, err := svc.GetTransaction(ctx, tx.ID) - if err != nil { - t.Fatalf("reload transaction: %v", err) - } - - err = db.RunInTx(ctx, func(txCtx context.Context) error { - _, err := svc.CompleteTransaction(txCtx, tx.ID, []pos.AddTenderRequest{ - {Method: "CASH", Amount: float64(open.Total) / 100.0}, - }) - if err != nil { - return err - } - return errors.New("deliberate rollback: the completed sale and its audit row must both disappear") - }) - if err == nil { - t.Fatal("expected the deliberate error from the outer transaction") - } - - var status string - if err := db.Pool.QueryRow(ctx, - `SELECT status FROM pos_transactions WHERE id = $1`, tx.ID).Scan(&status); err != nil { - t.Fatalf("read pos transaction: %v", err) - } - if status != string(pos.TransactionStatusOpen) { - t.Errorf("status after rollback = %s, want OPEN (the completion rolled back)", status) - } - var tenderRows, auditRows int - if err := db.Pool.QueryRow(ctx, - `SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`, tx.ID).Scan(&tenderRows); err != nil { - t.Fatalf("count tenders: %v", err) - } - if err := db.Pool.QueryRow(ctx, - `SELECT count(*) FROM audit_log WHERE entity_type = 'pos_transaction' AND entity_id = $1 AND action = 'pos.transaction.completed'`, - tx.ID).Scan(&auditRows); err != nil { - t.Fatalf("count audit rows: %v", err) - } - if tenderRows != 0 || auditRows != 0 { - t.Fatalf("after rollback: %d tender row(s), %d pos.transaction.completed audit row(s); both must be 0", - tenderRows, auditRows) - } -} diff --git a/core/internal/pos/sync.go b/core/internal/pos/sync.go new file mode 100644 index 000000000..3d2578c93 --- /dev/null +++ b/core/internal/pos/sync.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "context" + "errors" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/salesdoc" + "github.com/google/uuid" +) + +// OfflineSync is a parsed batch of offline sales. +type OfflineSync struct { + BatchID string + RegisterID string + Items []OfflineSale +} + +// OfflineSale is one completed sale captured offline. +type OfflineSale struct { + ClientID uuid.UUID + CashierID uuid.UUID + CustomerID *uuid.UUID + Lines []salesdoc.ParsedLine + Tenders []TenderIn + ClientCreatedAt *httpx.Timestamp +} + +// OfflineSyncResponse reports the batch's outcome. +type OfflineSyncResponse struct { + BatchID string `json:"batch_id"` + SyncedCount int `json:"synced_count"` + DuplicateCount int `json:"duplicate_count"` + ErrorCount int `json:"error_count"` + PendingCount int `json:"pending_count"` + Errors []SyncItemResult `json:"errors"` + Pending []SyncItemResult `json:"pending"` +} + +// SyncOfflineTransactions replays a batch of offline sales, each through the +// same completion path a live sale takes (ADR 0005 section 14.2 C2-5): the +// cart is rebuilt and completed in one transaction per sale. Client +// generated ids make a replayed batch idempotent. A sale already made +// offline is never REJECTED for a tax provider failure: it stays pending in +// the sync log and the next sync retries it (ADR 0005 section 3). +func (s *Service) SyncOfflineTransactions(ctx context.Context, batch *OfflineSync, actor string) (*OfflineSyncResponse, error) { + resp := &OfflineSyncResponse{BatchID: batch.BatchID} + log := LogBatch{BatchID: batch.BatchID, RegisterID: batch.RegisterID} + for i := range batch.Items { + item := &batch.Items[i] + exists, err := s.repo.SaleExists(ctx, item.ClientID) + if err != nil { + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "existence check: " + err.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + if exists { + resp.DuplicateCount++ + continue + } + // Rebuild and complete the sale through the live path. The sale row + // carries the client's id, so a retry of this batch finds it. + register := batch.RegisterID + sale, err := s.StartSaleAt(ctx, item, register, actor) + if err != nil { + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "create: " + err.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + if _, err := s.AddLine(ctx, sale.ID, item.Lines, actor); err != nil { + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "lines: " + err.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + if _, err := s.CompleteSale(ctx, sale.ID, "", nil, item.Tenders, "", actor); err != nil { + if errors.Is(err, ErrTaxProviderUnavailable) { + // never a rejection: the sale was made offline, and the + // provider will be asked again on the next sync + p := SyncItemResult{ClientID: item.ClientID.String(), Reason: "pending: the tax provider did not answer"} + resp.PendingCount++ + resp.Pending = append(resp.Pending, p) + log.Details = append(log.Details, p) + continue + } + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "complete: " + err.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + resp.SyncedCount++ + } + log.Synced, log.Duplicates, log.ErrorCount = resp.SyncedCount, resp.DuplicateCount, resp.ErrorCount + log.Pending = resp.PendingCount + if err := s.repo.LogSyncBatch(ctx, log); err != nil { + return nil, err + } + return resp, nil +} + +// StartSaleAt creates the sale row for an offline item with the client's +// id, so the batch replays idempotently. +func (s *Service) StartSaleAt(ctx context.Context, item *OfflineSale, register, actor string) (*Sale, error) { + in := &StartSale{RegisterID: register, CashierID: item.CashierID, CustomerID: item.CustomerID} + if in.RegisterID == "" { + in.RegisterID = "REG-01" + } + walkInID, defaultCurrency, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return nil, err + } + currency := defaultCurrency + if in.CustomerID != nil { + facts, err := s.repo.CustomerFacts(ctx, *in.CustomerID) + if err != nil { + return nil, err + } + currency = facts.Currency + } + var out *Sale + err = s.inTx(ctx, func(ctx context.Context) error { + sale := &Sale{ID: item.ClientID, RegisterID: in.RegisterID, CashierID: in.CashierID, CustomerID: in.CustomerID, + Status: StatusOpen, Currency: currency, WalkInID: walkInID} + if session, err := s.repo.GetOpenTillSession(ctx, in.RegisterID); err == nil && session != nil { + sale.TillSessionID = &session.ID + } + if err := s.repo.CreateSale(ctx, sale); err != nil { + return err + } + out = sale + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/core/internal/pos/till.go b/core/internal/pos/till.go index bb55e5434..9339490dd 100644 --- a/core/internal/pos/till.go +++ b/core/internal/pos/till.go @@ -8,76 +8,28 @@ import ( "fmt" "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" ) -// TillSessionStatus is the drawer lifecycle state. -type TillSessionStatus string - -const ( - TillSessionOpen TillSessionStatus = "OPEN" - TillSessionClosed TillSessionStatus = "CLOSED" -) - -// TillSession is one drawer shift on a register: opened with a float, -// accumulates completed-sale tenders, closed with counted totals and an -// over/short figure. -type TillSession struct { - ID uuid.UUID `json:"id" db:"id"` - RegisterID string `json:"register_id" db:"register_id"` - BranchID *uuid.UUID `json:"branch_id,omitempty" db:"branch_id"` - CashierID uuid.UUID `json:"cashier_id" db:"cashier_id"` - Status TillSessionStatus `json:"status" db:"status"` - OpeningFloat int64 `json:"opening_float" db:"opening_float"` // Cents - OpenedAt time.Time `json:"opened_at" db:"opened_at"` - ClosedAt *time.Time `json:"closed_at,omitempty" db:"closed_at"` - - // Set at close (cents keyed by tender method): - ExpectedByMethod map[string]int64 `json:"expected_by_method,omitempty"` - CountedByMethod map[string]int64 `json:"counted_by_method,omitempty"` - OverShort *int64 `json:"over_short,omitempty"` // Cents; negative = short - GLEntryID *uuid.UUID `json:"gl_entry_id,omitempty"` - Notes string `json:"notes"` -} - -// TillReport is the live (X) or closing (Z) summary for a session. -type TillReport struct { - Session TillSession `json:"session"` - SaleCount int `json:"sale_count"` - SalesTotal int64 `json:"sales_total"` // Cents (completed sales) - TaxTotal int64 `json:"tax_total"` // Cents - ChangeGiven int64 `json:"change_given"` // Cents - TenderedByMethod map[string]int64 `json:"tendered_by_method"` // Cents, raw tenders - ExpectedByMethod map[string]int64 `json:"expected_by_method"` // Cents, drawer expectation -} - -// OpenTillRequest opens a drawer on a register. -type OpenTillRequest struct { - RegisterID string `json:"register_id"` - OpeningFloat float64 `json:"opening_float"` // Dollars (wire convention matches tenders) -} - -// CloseTillRequest closes the session with counted amounts. -type CloseTillRequest struct { - CountedByMethod map[string]float64 `json:"counted_by_method"` // Dollars by method - Notes string `json:"notes"` -} - -// expectedFromTenders computes what the drawer should hold per method: -// CASH expectation = opening float + cash tendered − change given (change is -// assumed returned from the cash drawer); other methods are informational -// pass-throughs of what was tendered. Pure function for testability. -func expectedFromTenders(openingFloat int64, tendered map[string]int64, changeGiven int64) map[string]int64 { +// expectedFromPayments computes what the drawer should hold per method: the +// CASH expectation is the opening float plus the session's cash payments +// (already net of change; change is never subtracted a second time) less +// cash refunds; the other methods are the pass-through of what the session's +// sales collected (ADR 0005 section 14.2 C2-5). Pure function for +// testability. +func expectedFromPayments(openingFloat httpx.Cents, tendered map[string]int64, cashRefunds int64) map[string]int64 { expected := make(map[string]int64, len(tendered)+1) for m, v := range tendered { expected[m] = v } - expected["CASH"] = openingFloat + tendered["CASH"] - changeGiven + expected["CASH"] = int64(openingFloat) + tendered["CASH"] - cashRefunds return expected } -// overShortTotal sums counted − expected across the methods that were +// overShortTotal sums counted less expected across the methods that were // counted. Methods not counted are skipped (a dealer may only count cash). func overShortTotal(expected, counted map[string]int64) int64 { var total int64 @@ -87,44 +39,50 @@ func overShortTotal(expected, counted map[string]int64) int64 { return total } -// OpenTill opens a drawer session for a register. -func (s *Service) OpenTill(ctx context.Context, registerID string, cashierID uuid.UUID, openingFloatCents int64) (*TillSession, error) { - if registerID == "" { - return nil, fmt.Errorf("register_id is required") - } - if openingFloatCents < 0 { - return nil, fmt.Errorf("opening float cannot be negative") - } +// OpenTill opens a drawer session on a register. +func (s *Service) OpenTill(ctx context.Context, registerID string, cashierID uuid.UUID, openingFloat httpx.Cents, actor string) (*TillSession, error) { if existing, err := s.repo.GetOpenTillSession(ctx, registerID); err == nil && existing != nil { - return nil, fmt.Errorf("register %s already has an open till session (opened %s)", registerID, existing.OpenedAt.Format(time.RFC3339)) - } - - session := &TillSession{ - RegisterID: registerID, - CashierID: cashierID, - Status: TillSessionOpen, - OpeningFloat: openingFloatCents, + return nil, conflict("till_open", fmt.Sprintf("register %s already has an open till session", registerID)) } - if err := s.repo.CreateTillSession(ctx, session); err != nil { + var out *TillSession + err := s.inTx(ctx, func(ctx context.Context) error { + branch, err := s.repo.GetRegisterBranch(ctx, registerID) + if err != nil { + return err + } + session := &TillSession{RegisterID: registerID, BranchID: branch, CashierID: cashierID, + Status: TillOpen, OpeningFloat: openingFloat} + if err := s.repo.CreateTillSession(ctx, session); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "pos.till.opened", EntityType: "till_session", EntityID: session.ID, UserID: actor, + Changes: map[string]any{"register_id": registerID, "opening_float_cents": int64(openingFloat)}, + }); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + if session.BranchID != nil { + branch := *session.BranchID + if err := s.recordEvent(ctx, outbox.Event{Type: EventTillOpened, EntityType: "till_session", + EntityID: session.ID, BranchID: &branch, Data: eventJSON(map[string]any{ + "register_id": registerID, "status": TillOpen.Status(), + "opening_float_cents": int64(openingFloat), "cashier_id": cashierID, + })}); err != nil { + return err + } + } + out = session + return nil + }) + if err != nil { return nil, err } - - if s.auditLog != nil { - s.auditLog.Log(ctx, audit.Entry{ - Action: "pos.till.opened", - EntityType: "till_session", - EntityID: session.ID, - Changes: map[string]interface{}{ - "register_id": registerID, - "opening_float_cents": openingFloatCents, - }, - }) - } - s.logger.Info("till opened", "register", registerID, "session", session.ID, "float_cents", openingFloatCents) - return session, nil + return out, nil } -// CurrentTill returns the open session for a register, or nil. +// CurrentTill returns the register's open session, or nil. func (s *Service) CurrentTill(ctx context.Context, registerID string) (*TillSession, error) { return s.repo.GetOpenTillSession(ctx, registerID) } @@ -142,75 +100,95 @@ func (s *Service) TillReport(ctx context.Context, sessionID uuid.UUID) (*TillRep return &TillReport{ Session: *session, SaleCount: agg.SaleCount, - SalesTotal: agg.SalesTotal, - TaxTotal: agg.TaxTotal, - ChangeGiven: agg.ChangeGiven, + SalesTotalCents: httpx.Cents(agg.SalesTotalCents), + TaxTotalCents: httpx.Cents(agg.TaxTotalCents), + ChangeCents: httpx.Cents(agg.ChangeCents), TenderedByMethod: agg.TenderedByMethod, - ExpectedByMethod: expectedFromTenders(session.OpeningFloat, agg.TenderedByMethod, agg.ChangeGiven), + ExpectedByMethod: expectedFromPayments(session.OpeningFloat, agg.TenderedByMethod, agg.CashRefundsCents), }, nil } -// CloseTill closes the session: computes expected per method from the -// session's completed sales, records counted amounts, and stores over/short. -func (s *Service) CloseTill(ctx context.Context, sessionID uuid.UUID, countedByMethodCents map[string]int64, notes string) (*TillReport, error) { - report, err := s.TillReport(ctx, sessionID) - if err != nil { - return nil, err - } - if report.Session.Status != TillSessionOpen { - return nil, fmt.Errorf("till session is not open (status: %s)", report.Session.Status) - } - - overShort := overShortTotal(report.ExpectedByMethod, countedByMethodCents) - now := time.Now() - session := report.Session - session.Status = TillSessionClosed - session.ClosedAt = &now - session.ExpectedByMethod = report.ExpectedByMethod - session.CountedByMethod = countedByMethodCents - session.OverShort = &overShort - session.Notes = notes - - if err := s.repo.CloseTillSession(ctx, &session); err != nil { - return nil, err - } - report.Session = session - - // Post the drawer variance to the GL (best-effort, post-close): a GL - // hiccup must not block a completed count. A nonzero over/short books a - // balanced Cash Over/Short entry; the entry is linked back onto the - // session so the ledger and the till reconcile. - if s.tillLedger != nil && overShort != 0 { - if glID, err := s.tillLedger.PostTillOverShort(ctx, sessionID, overShort); err != nil { - s.logger.Error("CRITICAL: till closed but over/short GL posting failed — reconcile manually", - "session", sessionID, "over_short_cents", overShort, "error", err) - } else if glID != uuid.Nil { - if err := s.repo.SetTillSessionGLEntry(ctx, sessionID, glID); err != nil { - s.logger.Error("till over/short posted to GL but session link update failed", - "session", sessionID, "gl_entry_id", glID, "error", err) - } else { +// CloseTill closes the session: computes the expected per method from the +// session's payments, records the counted amounts, and posts the over/short +// entry inside the close's own transaction (the money rule: no GL entry +// outside the act that causes it). +func (s *Service) CloseTill(ctx context.Context, sessionID uuid.UUID, countedByMethod map[string]int64, notes, actor string) (*TillReport, error) { + var out *TillReport + err := s.inTx(ctx, func(ctx context.Context) error { + report, err := s.TillReport(ctx, sessionID) + if err != nil { + return err + } + if report.Session.Status != TillOpen { + return httpx.InvalidStateTransition("the till session is not open") + } + overShort := overShortTotal(report.ExpectedByMethod, countedByMethod) + now := time.Now() + session := report.Session + session.Status = TillClosed + session.ClosedAt = httpx.PtrTimestamp(&now) + session.ExpectedByMethod = report.ExpectedByMethod + session.CountedByMethod = countedByMethod + cents := httpx.Cents(overShort) + session.OverShort = ¢s + session.Notes = notes + // The over/short entry, inside the close's transaction, linked back + // onto the session. + if s.ledger != nil && overShort != 0 { + glID, err := s.ledger.PostTillOverShort(ctx, sessionID, overShort) + if err != nil { + return fmt.Errorf("failed to post the drawer variance: %w", err) + } + if glID != uuid.Nil { session.GLEntryID = &glID - report.Session = session } } + if err := s.repo.CloseTillSession(ctx, &session); err != nil { + return err + } + report.Session = session + // The frozen Z snapshot, in the same transaction. + if err := s.generateZReport(ctx, report); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "pos.till.closed", EntityType: "till_session", EntityID: sessionID, UserID: actor, + Changes: map[string]any{ + "register_id": session.RegisterID, "over_short_cents": overShort, + "sales_total_cents": aggSales(report), "sale_count": report.SaleCount, + }}); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + if session.BranchID != nil { + branch := *session.BranchID + if err := s.recordEvent(ctx, outbox.Event{Type: EventTillClosed, EntityType: "till_session", + EntityID: sessionID, BranchID: &branch, Data: eventJSON(map[string]any{ + "register_id": session.RegisterID, "status": TillClosed.Status(), + "from_status": TillOpen.Status(), "over_short_cents": overShort, + "sale_count": report.SaleCount, + })}); err != nil { + return err + } + } + out = report + return nil + }) + if err != nil { + return nil, err } + return out, nil +} - // Freeze the sovereign Z-report snapshot (generate-once, never recomputed). - s.generateZReport(ctx, report) +func aggSales(r *TillReport) int64 { return int64(r.SalesTotalCents) } - if s.auditLog != nil { - s.auditLog.Log(ctx, audit.Entry{ - Action: "pos.till.closed", - EntityType: "till_session", - EntityID: sessionID, - Changes: map[string]interface{}{ - "register_id": session.RegisterID, - "over_short_cents": overShort, - "sales_total": report.SalesTotal, - "sale_count": report.SaleCount, - }, - }) - } - s.logger.Info("till closed", "session", sessionID, "over_short_cents", overShort) - return report, nil +// GetZReport returns the frozen Z snapshot for a session. +func (s *Service) GetZReport(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) { + return s.repo.GetZReportBySession(ctx, sessionID) +} + +// ListZReports lists Z snapshots for a register on a date. +func (s *Service) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) { + return s.repo.ListZReports(ctx, registerID, date) } diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index 42215df2d..baea63957 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -10,258 +10,209 @@ import ( "fmt" "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) -// TillAggregate is the raw sums for a session's completed sales. -type TillAggregate struct { - SaleCount int - SalesTotal int64 // Cents - TaxTotal int64 // Cents - ChangeGiven int64 // Cents - TenderedByMethod map[string]int64 -} - func (r *PostgresRepository) CreateTillSession(ctx context.Context, s *TillSession) error { if s.ID == uuid.Nil { s.ID = uuid.New() } - s.OpenedAt = time.Now() - // Resolve the register's branch first (same pattern as CreateTransaction; - // reusing a parameter inside a subquery trips PG's type inference). - if s.BranchID == nil { - var registerBranch *uuid.UUID - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT l.branch_id - FROM pos_registers r - LEFT JOIN locations l ON l.id = r.location_id - WHERE r.id = $1`, s.RegisterID).Scan(®isterBranch) - if err != nil { - return fmt.Errorf("failed to resolve register branch for till: %w", err) - } - s.BranchID = registerBranch - } - - query := ` + var opened time.Time + err := r.ex(ctx).QueryRow(ctx, ` INSERT INTO till_sessions (id, register_id, branch_id, cashier_id, status, opening_float, opened_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - s.ID, s.RegisterID, s.BranchID, s.CashierID, s.Status, - float64(s.OpeningFloat)/100.0, s.OpenedAt, - ) + VALUES ($1, $2, $3, $4, $5, $6::numeric / 100, NOW()) + RETURNING opened_at`, + s.ID, s.RegisterID, s.BranchID, s.CashierID, string(s.Status), int64(s.OpeningFloat)).Scan(&opened) if err != nil { - return fmt.Errorf("failed to open till session: %w", err) + return fmt.Errorf("failed to open the till session: %w", mapWriteError(err)) } + s.OpenedAt = httpx.TimestampOf(opened) return nil } func (r *PostgresRepository) scanTillSession(row pgx.Row) (*TillSession, error) { var s TillSession - var openingFloat float64 - var overShort *float64 - var expectedJSON, countedJSON []byte - err := row.Scan( - &s.ID, &s.RegisterID, &s.BranchID, &s.CashierID, &s.Status, - &openingFloat, &s.OpenedAt, &s.ClosedAt, - &expectedJSON, &countedJSON, &overShort, &s.GLEntryID, &s.Notes, - ) + var status string + var closedAt *time.Time + err := row.Scan(&s.ID, &s.RegisterID, &s.BranchID, &s.CashierID, &status, &s.OpeningFloat, &s.OpenedAt, &closedAt, + &s.ExpectedByMethod, &s.CountedByMethod, &s.OverShort, &s.GLEntryID, &s.Notes) if err != nil { return nil, err } - s.OpeningFloat = int64(openingFloat*100.0 + 0.5) - if overShort != nil { - cents := int64(*overShort * 100.0) - if *overShort >= 0 { - cents = int64(*overShort*100.0 + 0.5) - } else { - cents = int64(*overShort*100.0 - 0.5) - } - s.OverShort = ¢s - } - if len(expectedJSON) > 0 { - _ = json.Unmarshal(expectedJSON, &s.ExpectedByMethod) - } - if len(countedJSON) > 0 { - _ = json.Unmarshal(countedJSON, &s.CountedByMethod) - } + s.Status = TillSessionStatus(status) + s.ClosedAt = httpx.PtrTimestamp(closedAt) return &s, nil } -const tillSessionColumns = `id, register_id, branch_id, cashier_id, status, opening_float, opened_at, closed_at, expected_by_method, counted_by_method, over_short, gl_entry_id, notes` +const tillCols = `id, register_id, branch_id, cashier_id, status, ROUND(opening_float * 100)::bigint, opened_at, closed_at, + expected_by_method, counted_by_method, over_short_cents, gl_entry_id, notes` func (r *PostgresRepository) GetTillSession(ctx context.Context, id uuid.UUID) (*TillSession, error) { - row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+tillSessionColumns+` FROM till_sessions WHERE id = $1`, id) - s, err := r.scanTillSession(row) + s, err := r.scanTillSession(r.ex(ctx).QueryRow(ctx, `SELECT `+tillCols+` FROM ( + SELECT id, register_id, branch_id, cashier_id, status, opening_float, opened_at, closed_at, + expected_by_method, counted_by_method, + CASE WHEN over_short IS NULL THEN NULL ELSE ROUND(over_short * 100)::bigint END AS over_short_cents, + gl_entry_id, notes + FROM till_sessions) t WHERE t.id = $1`, id)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, httpx.NotFound("till session not found") + } if err != nil { - return nil, fmt.Errorf("failed to get till session: %w", err) + return nil, fmt.Errorf("failed to read the till session: %w", err) } return s, nil } -// GetOpenTillSession returns the register's OPEN session, or (nil, nil). func (r *PostgresRepository) GetOpenTillSession(ctx context.Context, registerID string) (*TillSession, error) { - row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+tillSessionColumns+` FROM till_sessions WHERE register_id = $1 AND status = 'OPEN'`, registerID) - s, err := r.scanTillSession(row) + s, err := r.scanTillSession(r.ex(ctx).QueryRow(ctx, `SELECT `+tillCols+` FROM ( + SELECT id, register_id, branch_id, cashier_id, status, opening_float, opened_at, closed_at, + expected_by_method, counted_by_method, + CASE WHEN over_short IS NULL THEN NULL ELSE ROUND(over_short * 100)::bigint END AS over_short_cents, + gl_entry_id, notes + FROM till_sessions) t WHERE t.register_id = $1 AND t.status = 'OPEN'`, registerID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to get open till session: %w", err) + return nil, fmt.Errorf("failed to read the open till session: %w", err) } return s, nil } func (r *PostgresRepository) CloseTillSession(ctx context.Context, s *TillSession) error { - expectedJSON, err := json.Marshal(s.ExpectedByMethod) + expected, err := json.Marshal(s.ExpectedByMethod) if err != nil { - return fmt.Errorf("failed to marshal expected_by_method: %w", err) + return err } - countedJSON, err := json.Marshal(s.CountedByMethod) + counted, err := json.Marshal(s.CountedByMethod) if err != nil { - return fmt.Errorf("failed to marshal counted_by_method: %w", err) + return err } - var overShortDollars *float64 + var over any if s.OverShort != nil { - v := float64(*s.OverShort) / 100.0 - overShortDollars = &v + over = int64(*s.OverShort) } - tag, err := r.db.GetExecutor(ctx).Exec(ctx, ` - UPDATE till_sessions - SET status = $2, closed_at = $3, expected_by_method = $4::jsonb, - counted_by_method = $5::jsonb, over_short = $6, notes = $7 + tag, err := r.ex(ctx).Exec(ctx, ` + UPDATE till_sessions SET status = 'CLOSED', closed_at = NOW(), + expected_by_method = $2::jsonb, counted_by_method = $3::jsonb, + over_short = ($4::bigint::numeric / 100), gl_entry_id = $5, notes = $6 WHERE id = $1 AND status = 'OPEN'`, - s.ID, s.Status, s.ClosedAt, expectedJSON, countedJSON, overShortDollars, s.Notes, - ) + s.ID, string(expected), string(counted), over, s.GLEntryID, s.Notes) if err != nil { - return fmt.Errorf("failed to close till session: %w", err) + return fmt.Errorf("failed to close the till session: %w", err) } if tag.RowsAffected() == 0 { - return fmt.Errorf("till session %s is not open", s.ID) + return httpx.InvalidStateTransition("the till session is not open") } return nil } -// SetTillSessionGLEntry links a closed session to its over/short journal entry. -func (r *PostgresRepository) SetTillSessionGLEntry(ctx context.Context, sessionID, glEntryID uuid.UUID) error { - _, err := r.db.GetExecutor(ctx).Exec(ctx, - `UPDATE till_sessions SET gl_entry_id = $2 WHERE id = $1`, sessionID, glEntryID) - if err != nil { - return fmt.Errorf("failed to link till session to GL entry: %w", err) - } - return nil -} - -// AggregateTillSession sums the session's completed sales and tenders. +// AggregateTillSession sums a session's completed sales from the payments +// their tenders became (a voided sale's payments are VOIDED, so they drop +// out), and the cash refunds of the session's returns through their credit +// memos. The amounts are already net of change: a cash payment is the money +// kept (ADR 0005 section 14.2 C2-5). func (r *PostgresRepository) AggregateTillSession(ctx context.Context, sessionID uuid.UUID) (*TillAggregate, error) { agg := &TillAggregate{TenderedByMethod: map[string]int64{}} - - var salesTotal, taxTotal, changeGiven float64 - err := r.db.GetExecutor(ctx).QueryRow(ctx, ` - SELECT COUNT(*), COALESCE(SUM(total), 0), COALESCE(SUM(tax_amount), 0), COALESCE(SUM(change_due), 0) - FROM pos_transactions - WHERE till_session_id = $1 AND status = 'COMPLETED'`, sessionID, - ).Scan(&agg.SaleCount, &salesTotal, &taxTotal, &changeGiven) + err := r.ex(ctx).QueryRow(ctx, ` + SELECT count(DISTINCT t.id), + COALESCE(SUM(ROUND(t.total * 100)::bigint), 0), + COALESCE(SUM(ROUND(t.tax_amount * 100)::bigint), 0), + COALESCE(SUM(ROUND(t.change_due * 100)::bigint), 0) + FROM pos_transactions t + WHERE t.till_session_id = $1 AND t.status = 'COMPLETED'`, sessionID). + Scan(&agg.SaleCount, &agg.SalesTotalCents, &agg.TaxTotalCents, &agg.ChangeCents) if err != nil { - return nil, fmt.Errorf("failed to aggregate till sales: %w", err) - } - agg.SalesTotal = int64(salesTotal*100.0 + 0.5) - agg.TaxTotal = int64(taxTotal*100.0 + 0.5) - agg.ChangeGiven = int64(changeGiven*100.0 + 0.5) - - rows, err := r.db.GetExecutor(ctx).Query(ctx, ` - SELECT t.method, COALESCE(SUM(t.amount), 0) - FROM pos_tenders t - JOIN pos_transactions x ON x.id = t.transaction_id - WHERE x.till_session_id = $1 AND x.status = 'COMPLETED' - GROUP BY t.method`, sessionID) + return nil, fmt.Errorf("failed to aggregate the session's sales: %w", err) + } + rows, err := r.ex(ctx).Query(ctx, ` + SELECT p.method, COALESCE(SUM(ROUND(p.amount * 100)::bigint), 0) + FROM payments p + JOIN pos_tenders pt ON pt.payment_id = p.id + JOIN pos_transactions t ON t.id = pt.transaction_id + WHERE t.till_session_id = $1 AND t.status = 'COMPLETED' AND p.status = 'POSTED' + GROUP BY p.method`, sessionID) if err != nil { - return nil, fmt.Errorf("failed to aggregate till tenders: %w", err) + return nil, fmt.Errorf("failed to aggregate the session's payments: %w", err) } defer rows.Close() for rows.Next() { var method string - var amount float64 - if err := rows.Scan(&method, &amount); err != nil { - return nil, fmt.Errorf("failed to scan till tender aggregate: %w", err) + var cents int64 + if err := rows.Scan(&method, ¢s); err != nil { + return nil, err } - agg.TenderedByMethod[method] = int64(amount*100.0 + 0.5) + agg.TenderedByMethod[method] = cents + } + if err := rows.Err(); err != nil { + return nil, err + } + // The session's cash refunds, through its returns' credit memos. + if err := r.ex(ctx).QueryRow(ctx, ` + SELECT COALESCE(SUM(ROUND(rf.amount * 100)::bigint), 0) + FROM payment_refunds rf + JOIN credit_memos cm ON cm.id = rf.credit_memo_id + JOIN pos_returns pr ON pr.credit_memo_id = cm.id + WHERE pr.till_session_id = $1 AND rf.method = 'CASH'`, sessionID).Scan(&agg.CashRefundsCents); err != nil { + return nil, fmt.Errorf("failed to aggregate the session's cash refunds: %w", err) } - return agg, rows.Err() + return agg, nil } -// --- Z-reports --- - func (r *PostgresRepository) CreateZReport(ctx context.Context, z *ZReport) error { if z.ID == uuid.Nil { z.ID = uuid.New() } - _, err := r.db.GetExecutor(ctx).Exec(ctx, ` - INSERT INTO till_z_reports (id, till_session_id, register_id, branch_id, over_short, payload) - VALUES ($1, $2, $3, $4, $5, $6::jsonb) - ON CONFLICT (till_session_id) DO NOTHING`, - z.ID, z.TillSessionID, z.RegisterID, z.BranchID, float64(z.OverShort)/100.0, string(z.Payload)) + err := r.ex(ctx).QueryRow(ctx, ` + INSERT INTO till_z_reports (id, till_session_id, register_id, branch_id, over_short, payload, generated_at) + VALUES ($1, $2, $3, $4, $5::numeric / 100, $6, NOW()) + ON CONFLICT (till_session_id) DO NOTHING + RETURNING generated_at`, + z.ID, z.TillSessionID, z.RegisterID, z.BranchID, int64(z.OverShort), z.Payload).Scan(&z.GeneratedAt) if err != nil { - return fmt.Errorf("failed to create Z-report: %w", err) + if errors.Is(err, pgx.ErrNoRows) { + return nil // the frozen snapshot already exists + } + return fmt.Errorf("failed to persist the Z-report: %w", err) } return nil } -func (r *PostgresRepository) scanZReport(row pgx.Row) (*ZReport, error) { - var z ZReport - var overShort float64 - var payload []byte - if err := row.Scan(&z.ID, &z.TillSessionID, &z.RegisterID, &z.BranchID, &overShort, &payload, &z.GeneratedAt); err != nil { - return nil, err - } - if overShort >= 0 { - z.OverShort = int64(overShort*100.0 + 0.5) - } else { - z.OverShort = int64(overShort*100.0 - 0.5) - } - z.Payload = payload - return &z, nil -} - -const zReportColumns = `id, till_session_id, register_id, branch_id, over_short, payload, generated_at` - func (r *PostgresRepository) GetZReportBySession(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) { - row := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT `+zReportColumns+` FROM till_z_reports WHERE till_session_id = $1`, sessionID) - z, err := r.scanZReport(row) + z := &ZReport{} + err := r.ex(ctx).QueryRow(ctx, ` + SELECT id, till_session_id, register_id, branch_id, ROUND(over_short * 100)::bigint, payload, generated_at + FROM till_z_reports WHERE till_session_id = $1`, sessionID). + Scan(&z.ID, &z.TillSessionID, &z.RegisterID, &z.BranchID, &z.OverShort, &z.Payload, &z.GeneratedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, httpx.NotFound("no Z-report for this session") + } if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, fmt.Errorf("no Z-report for session %s (not closed?)", sessionID) - } - return nil, fmt.Errorf("failed to get Z-report: %w", err) + return nil, fmt.Errorf("failed to read the Z-report: %w", err) } return z, nil } func (r *PostgresRepository) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) { - q := `SELECT ` + zReportColumns + ` FROM till_z_reports WHERE ($1 = '' OR register_id = $1)` - args := []any{registerID} - if !date.IsZero() { - start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - q += ` AND generated_at >= $2 AND generated_at < $3` - args = append(args, start, start.Add(24*time.Hour)) - } - q += ` ORDER BY generated_at DESC` - rows, err := r.db.GetExecutor(ctx).Query(ctx, q, args...) + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, till_session_id, register_id, branch_id, ROUND(over_short * 100)::bigint, payload, generated_at + FROM till_z_reports + WHERE ($1 = '' OR register_id = $1) + AND (generated_at >= $2::date AND generated_at < ($2::date + 1)) + ORDER BY generated_at DESC`, registerID, date) if err != nil { return nil, fmt.Errorf("failed to list Z-reports: %w", err) } defer rows.Close() var out []ZReport for rows.Next() { - z, err := r.scanZReport(rows) - if err != nil { - return nil, fmt.Errorf("failed to scan Z-report: %w", err) + var z ZReport + if err := rows.Scan(&z.ID, &z.TillSessionID, &z.RegisterID, &z.BranchID, &z.OverShort, &z.Payload, &z.GeneratedAt); err != nil { + return nil, err } - out = append(out, *z) + out = append(out, z) } return out, rows.Err() } diff --git a/core/internal/pos/till_test.go b/core/internal/pos/till_test.go deleted file mode 100644 index b6f37e871..000000000 --- a/core/internal/pos/till_test.go +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package pos - -import "testing" - -func TestExpectedFromTenders_CashMath(t *testing.T) { - // $200 float, $412.35 cash tendered, $12.35 change given back, - // $500 card, $150 check. - expected := expectedFromTenders(20000, map[string]int64{ - "CASH": 41235, - "CARD": 50000, - "CHECK": 15000, - }, 1235) - - if expected["CASH"] != 20000+41235-1235 { - t.Fatalf("cash expectation wrong: got %d", expected["CASH"]) - } - if expected["CARD"] != 50000 || expected["CHECK"] != 15000 { - t.Fatalf("non-cash methods must pass through: %v", expected) - } -} - -func TestExpectedFromTenders_NoSales(t *testing.T) { - expected := expectedFromTenders(15000, map[string]int64{}, 0) - if expected["CASH"] != 15000 { - t.Fatalf("empty session should expect the float, got %d", expected["CASH"]) - } -} - -func TestOverShortTotal(t *testing.T) { - expected := map[string]int64{"CASH": 60000, "CARD": 50000} - // Cashier counts the drawer $5 short; cards aren't counted. - counted := map[string]int64{"CASH": 59500} - if got := overShortTotal(expected, counted); got != -500 { - t.Fatalf("want -500 (short $5), got %d", got) - } - // Overage on cash and an exact card count. - counted = map[string]int64{"CASH": 60250, "CARD": 50000} - if got := overShortTotal(expected, counted); got != 250 { - t.Fatalf("want +250, got %d", got) - } -} diff --git a/core/internal/pos/void.go b/core/internal/pos/void.go new file mode 100644 index 000000000..841d19140 --- /dev/null +++ b/core/internal/pos/void.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "context" + "fmt" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +// VoidSale voids a completed counter sale while its till session is open, +// in ONE transaction in section 11's order (the sale, its payments, its +// invoice): each cash or check payment voided; each card payment, whose +// gateway refund is made before the transaction (as every gateway call is), +// has its application reversed and the amount recorded as a refund from its +// unapplied cash; then the invoice void, and the stock returns. After the +// session closes it is a return (ADR 0005 section 14.2 C2-5). +func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string, bodyRevision *int64, reason, actor string) (*Sale, error) { + if s.ar == nil || s.inventory == nil { + return nil, errNotConfigured + } + pre, err := s.repo.GetSale(ctx, saleID) + if err != nil { + return nil, err + } + if pre.Status != StatusCompleted { + return nil, httpx.InvalidStateTransition(fmt.Sprintf("cannot void a %s sale: only a completed sale is voided", pre.Status.Status())) + } + // A void lives inside its till session; once the drawer closes, the + // money has been counted and the way back is a return. + if pre.TillSessionID != nil { + session, err := s.repo.GetTillSession(ctx, *pre.TillSessionID) + if err != nil { + return nil, err + } + if session.Status != TillOpen { + return nil, conflict("session_closed", + "the till session this sale was made in is closed: return the goods through POST /pos/returns instead") + } + } + tenders, err := s.repo.GetTenders(ctx, saleID) + if err != nil { + return nil, err + } + // Card refunds go through the gateway before the transaction, so a + // decline aborts with nothing persisted (as every gateway call is). + for i := range tenders { + t := &tenders[i] + if t.Method != TenderCard || t.PaymentID == nil { + continue + } + var gatewayTxID string + if t.GatewayTxID != nil { + gatewayTxID = *t.GatewayTxID + } + if gatewayTxID == "" { + continue + } + if s.gateway == nil { + return nil, conflict("card_terminal", "this register has no card terminal gateway to refund the card tender") + } + res, err := s.gateway.Refund(ctx, gatewayTxID, int64(t.AmountCents)) + if err != nil { + return nil, fmt.Errorf("card refund failed: %w", err) + } + if res.Status != payment.GatewayStatusRefunded && res.Status != payment.GatewayStatusApproved { + return nil, conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) + } + } + var out *Sale + err = s.inTx(ctx, func(ctx context.Context) error { + // Step 1: the sale row. + if err := s.repo.LockSale(ctx, saleID); err != nil { + return err + } + sale, err := s.repo.GetSale(ctx, saleID) + if err != nil { + return err + } + if err := httpx.CheckRevision(sale.Revision, ifMatch, bodyRevision); err != nil { + return err + } + if sale.Status != StatusCompleted { + return httpx.InvalidStateTransition("only a completed sale is voided") + } + if sale.InvoiceID == nil { + return conflict("no_invoice", "the sale carries no invoice to void") + } + lines, err := s.repo.GetLines(ctx, saleID) + if err != nil { + return err + } + tenders, err := s.repo.GetTenders(ctx, saleID) + if err != nil { + return err + } + date, err := s.repo.BranchLocalDate(ctx, sale.BranchID, s.now()) + if err != nil { + return err + } + var fxAll *account.Effects + // The payments, in id order (section 11, step 2 comes before the + // invoice at step 4): cash and check void whole; card reverses its + // application and refunds the amount from the unapplied cash the + // reversal creates. + ordered := tendersInIDOrder(tenders) + for _, t := range ordered { + if t.PaymentID == nil { + continue + } + switch t.Method { + case TenderCash, TenderCheck: + fx, err := s.ar.VoidPayment(ctx, account.VoidPaymentIn{ + PaymentID: *t.PaymentID, Reason: reason, Actor: actor, On: date}) + if err != nil { + return err + } + mergeEffects(&fxAll, fx) + case TenderCard: + fx, err := s.ar.Reverse(ctx, account.ReverseIn{ + ApplicationID: cardApplicationOf(ctx, s, *t.PaymentID), Reason: reason, Actor: actor, On: date}) + if err != nil { + return err + } + mergeEffects(&fxAll, fx) + _, rfx, err := s.ar.RefundPayment(ctx, account.RefundPaymentIn{ + PaymentID: *t.PaymentID, AmountCents: int64(t.AmountCents), Reason: reason, Actor: actor, On: date}) + if err != nil { + return err + } + mergeEffects(&fxAll, rfx) + } + } + // The invoice void (step 4): reverses its whole entry, writes the + // opposite subledger row, ends VOID. + var entryID *uuid.UUID + if err := s.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT gl_entry_id FROM invoices WHERE id = $1`, sale.InvoiceID).Scan(&entryID); err != nil { + return err + } + fx, err := s.ar.VoidInvoice(ctx, account.VoidInvoiceIn{ + InvoiceID: *sale.InvoiceID, EntryID: entryID, Reason: reason, Actor: actor, On: date}) + if err != nil { + return err + } + mergeEffects(&fxAll, fx) + // The stock returns (step 6): each stocked line back on hand. + for i := range lines { + l := &lines[i] + if !l.IsStocked() || l.Quantity == nil { + continue + } + if err := s.inventory.RestockQty(ctx, *l.ProductID, sale.BranchID, *l.Quantity); err != nil { + return err + } + } + if err := s.repo.VoidSale(ctx, saleID); err != nil { + return err + } + // The events, last: the AR core's, then the sale's own. + branch := sale.BranchID + if fxAll != nil { + for _, ev := range fxAll.Events() { + if err := s.recordEvent(ctx, ev); err != nil { + return err + } + } + } + if err := s.recordEvent(ctx, outbox.Event{Type: EventSaleVoided, EntityType: "pos_transaction", EntityID: saleID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "number": sale.Number, "customer_id": sale.CustomerID, "status": StatusVoided.Status(), + "from_status": StatusCompleted.Status(), "revision": sale.Revision + 1, "currency": sale.Currency, + "total_cents": int64(sale.TotalCents), "reason": reason, + })}); err != nil { + return err + } + out, err = s.GetSale(ctx, saleID) + return err + }) + if err != nil { + return nil, err + } + return out, nil +} + +const refundApproved = "REFUNDED" + +// tendersInIDOrder sorts the tenders by their payment ids (section 11, +// step 2: payments in id order). +func tendersInIDOrder(tenders []Tender) []Tender { + out := append([]Tender(nil), tenders...) + for i := 1; i < len(out); i++ { + for j := i; j > 0; j-- { + a, b := out[j], out[j-1] + if a.PaymentID != nil && b.PaymentID != nil && a.PaymentID.String() < b.PaymentID.String() { + out[j], out[j-1] = out[j-1], out[j] + } else { + break + } + } + } + return out +} + +// cardApplicationOf finds the payment's live application on the invoice. +func cardApplicationOf(ctx context.Context, s *Service, paymentID uuid.UUID) uuid.UUID { + var id uuid.UUID + err := s.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT id FROM ar_applications WHERE payment_id = $1 AND reversed_at IS NULL ORDER BY created_at, id LIMIT 1`, + paymentID).Scan(&id) + if err != nil { + return uuid.Nil + } + return id +} diff --git a/core/internal/pos/zreport.go b/core/internal/pos/zreport.go index b2fa1b9d3..4054c58f4 100644 --- a/core/internal/pos/zreport.go +++ b/core/internal/pos/zreport.go @@ -6,36 +6,21 @@ package pos import ( "context" "encoding/json" - "time" - "github.com/google/uuid" + "github.com/gablelbm/gable/internal/platform/httpx" ) -// ZReport is the immutable end-of-day snapshot generated once when a till -// session closes. Payload is the frozen closing TillReport (never recomputed). -type ZReport struct { - ID uuid.UUID `json:"id"` - TillSessionID uuid.UUID `json:"till_session_id"` - RegisterID string `json:"register_id"` - BranchID *uuid.UUID `json:"branch_id,omitempty"` - OverShort int64 `json:"over_short"` // cents - Payload json.RawMessage `json:"payload"` - GeneratedAt time.Time `json:"generated_at"` -} - -// generateZReport persists the frozen Z snapshot for a just-closed session. -// Idempotent: the unique constraint on till_session_id means a re-close (or a -// retry) never produces a second, divergent Z. Best-effort — the close and -// its GL posting have already committed; a snapshot hiccup is logged, not -// fatal (the till_sessions row remains the source of truth). -func (s *Service) generateZReport(ctx context.Context, report *TillReport) { +// generateZReport persists the frozen Z snapshot for a just-closed session, +// inside the close's transaction. Idempotent: the unique constraint on +// till_session_id means a re-close (or a retry) never produces a second, +// divergent Z. +func (s *Service) generateZReport(ctx context.Context, report *TillReport) error { sess := report.Session payload, err := json.Marshal(report) if err != nil { - s.logger.Error("failed to marshal Z-report payload", "session", sess.ID, "error", err) - return + return err } - var overShort int64 + var overShort httpx.Cents if sess.OverShort != nil { overShort = *sess.OverShort } @@ -46,18 +31,5 @@ func (s *Service) generateZReport(ctx context.Context, report *TillReport) { OverShort: overShort, Payload: payload, } - if err := s.repo.CreateZReport(ctx, z); err != nil { - s.logger.Error("failed to persist Z-report", "session", sess.ID, "error", err) - } -} - -// GetZReport returns the persisted Z snapshot for a session (or an error if -// the session was never closed / has no Z). -func (s *Service) GetZReport(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) { - return s.repo.GetZReportBySession(ctx, sessionID) -} - -// ListZReports returns Z snapshots for a register on a date (both optional). -func (s *Service) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) { - return s.repo.ListZReports(ctx, registerID, date) + return s.repo.CreateZReport(ctx, z) } diff --git a/core/migrations/105_pos_wire_contract.sql b/core/migrations/105_pos_wire_contract.sql new file mode 100644 index 000000000..fec47ec25 --- /dev/null +++ b/core/migrations/105_pos_wire_contract.sql @@ -0,0 +1,324 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 105: the counter (POS and till) onto the wire contract (item C2-5, +-- docs/adr/0001-wire-contract.md and docs/adr/0005-sales-and-money-core.md +-- sections 13 C2-5 and 14.2 C2-5). The steps are the ADR's C2-5 list, in +-- order: +-- +-- 1 pos_transactions: created_at NOT NULL, revision, the number (POS-), +-- currency, invoice_id FK invoices. +-- 2 pos_line_items: the shared line columns of ADR 0005 section 2.2 +-- (unit_price widened to NUMERIC(12,4) in place; uom stays the sale +-- unit), product_id NULL-able with the per line type CHECKs. +-- pos_tenders: payment_id FK payments. +-- 3 pos_returns: the number (RTN-), credit_memo_id FK credit_memos; +-- pos_return_lines widened like the lines. Each historic return with +-- refund_method ACCOUNT lowered AR (its GL entry and subledger row +-- exist) with no document: it is migrated as an OPEN credit memo +-- (reason 'migrated counter account return', its gl_entry_id the +-- return's, no new entry), linked through pos_returns.credit_memo_id. +-- 4 the walk-in customer: a customers row (account_number WALK-IN, name +-- Walk-in) and system_settings pos.walk_in_customer_id, when absent. +-- +-- No migration writes a journal entry or a subledger row: history moves as +-- data, and GET /api/v1/ar/reconciliation shows where it never agreed. +-- Constraint adds are guarded so a second apply is a no-op. + +-- 1. pos_transactions. + +UPDATE pos_transactions SET created_at = NOW() WHERE created_at IS NULL; +ALTER TABLE pos_transactions ALTER COLUMN created_at SET NOT NULL; +ALTER TABLE pos_transactions ALTER COLUMN created_at SET DEFAULT NOW(); +ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ; +UPDATE pos_transactions SET updated_at = created_at WHERE updated_at IS NULL; +ALTER TABLE pos_transactions ALTER COLUMN updated_at SET NOT NULL; +ALTER TABLE pos_transactions ALTER COLUMN updated_at SET DEFAULT NOW(); +ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1; + +ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS currency CHAR(3) NULL; +UPDATE pos_transactions t SET currency = COALESCE((SELECT c.currency FROM customers c WHERE c.id = t.customer_id), + (SELECT value FROM system_settings WHERE key = 'currency.default')) +WHERE currency IS NULL; +UPDATE pos_transactions SET currency = 'USD' WHERE currency IS NULL; +ALTER TABLE pos_transactions ALTER COLUMN currency SET NOT NULL; +DO $$ BEGIN + ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_currency_check CHECK (currency ~ '^[A-Z]{3}$'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS invoice_id UUID NULL REFERENCES invoices (id); + +CREATE SEQUENCE IF NOT EXISTS pos_transaction_number_seq; +ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS number TEXT; + +WITH ordered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) AS n + FROM pos_transactions + WHERE number IS NULL +) +UPDATE pos_transactions t +SET number = 'POS-' || lpad(o.n::text, GREATEST(6, length(o.n::text)), '0') +FROM ordered o +WHERE t.id = o.id; + +SELECT setval('pos_transaction_number_seq', + GREATEST((SELECT COALESCE(MAX(substring(number FROM 5)::bigint), 0) + 1 FROM pos_transactions), 1), + false); + +ALTER TABLE pos_transactions ALTER COLUMN number SET DEFAULT 'POS-' || lpad(nextval('pos_transaction_number_seq')::text, 6, '0'); +ALTER TABLE pos_transactions ALTER COLUMN number SET NOT NULL; +DO $$ BEGIN + ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_number_key UNIQUE (number); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_status_check + CHECK (status IN ('OPEN', 'HELD', 'COMPLETED', 'VOIDED', 'RETURNED')); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- 2. pos_line_items: the shared line columns of ADR 0005 section 2.2. + +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS position INTEGER; +WITH ordered AS ( + SELECT id, row_number() OVER (PARTITION BY transaction_id ORDER BY created_at, id) - 1 AS n + FROM pos_line_items + WHERE position IS NULL +) +UPDATE pos_line_items l SET position = o.n FROM ordered o WHERE l.id = o.id; +ALTER TABLE pos_line_items ALTER COLUMN position SET NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN position SET DEFAULT 0; + +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS line_type TEXT; +UPDATE pos_line_items SET line_type = 'PRODUCT' WHERE line_type IS NULL; +ALTER TABLE pos_line_items ALTER COLUMN line_type SET NOT NULL; + +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS parent_line_id UUID NULL REFERENCES pos_line_items (id) ON DELETE CASCADE; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS charge_code_id UUID NULL REFERENCES charge_codes (id); +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS price_uom TEXT; +UPDATE pos_line_items SET price_uom = uom WHERE price_uom IS NULL; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS uom_qty NUMERIC(12, 4); +UPDATE pos_line_items SET uom_qty = 1 WHERE uom_qty IS NULL; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS price_uom_qty NUMERIC(12, 4); +UPDATE pos_line_items SET price_uom_qty = 1 WHERE price_uom_qty IS NULL; + +ALTER TABLE pos_line_items ALTER COLUMN quantity TYPE NUMERIC(12, 4); +ALTER TABLE pos_line_items ALTER COLUMN unit_price TYPE NUMERIC(12, 4); +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS priced_unit_price NUMERIC(12, 4); +UPDATE pos_line_items SET priced_unit_price = unit_price WHERE priced_unit_price IS NULL; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS price_source TEXT; +UPDATE pos_line_items SET price_source = 'PRICE_LIST' WHERE price_source IS NULL; +ALTER TABLE pos_line_items ALTER COLUMN price_source SET NOT NULL; +DO $$ BEGIN + ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_price_source_check + CHECK (price_source IN ('PRICE_LIST', 'QUOTE', 'OVERRIDE', 'MANUAL', 'NONE')); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS override_reason TEXT; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS discount_percent NUMERIC(7, 4); +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS discount_amount NUMERIC(12, 2); +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS discount_reason TEXT; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS price_adjusted_by TEXT; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS taxable BOOLEAN; +UPDATE pos_line_items l SET taxable = COALESCE((SELECT p.taxable FROM products p WHERE p.id = l.product_id), TRUE) +WHERE taxable IS NULL; +ALTER TABLE pos_line_items ALTER COLUMN taxable SET NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN taxable SET DEFAULT TRUE; +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS revenue_account_code TEXT; + +ALTER TABLE pos_line_items ALTER COLUMN product_id DROP NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN quantity DROP NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN uom DROP NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN unit_price DROP NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN line_total DROP NOT NULL; + +DO $$ BEGIN + ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_line_type_check + CHECK (line_type IN ('PRODUCT', 'KIT', 'COMPONENT', 'CHARGE', 'TEXT')); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +DO $$ BEGIN + ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_pair_positive + CHECK ((uom_qty IS NULL AND price_uom_qty IS NULL) OR (uom_qty > 0 AND price_uom_qty > 0)); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +DO $$ BEGIN + ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_shape + CHECK ( + -- a text line: a note, no amount, nothing priced + (line_type = 'TEXT' + AND quantity IS NULL AND uom IS NULL AND price_uom IS NULL + AND uom_qty IS NULL AND price_uom_qty IS NULL + AND unit_price IS NULL AND line_total IS NULL + AND product_id IS NULL AND charge_code_id IS NULL) + -- product (stocked or non stock) and kit lines: fully priced + OR (line_type IN ('PRODUCT', 'KIT') + AND quantity IS NOT NULL AND quantity > 0 + AND price_uom IS NOT NULL + AND uom_qty IS NOT NULL AND price_uom_qty IS NOT NULL + AND unit_price IS NOT NULL AND unit_price >= 0 + AND line_total IS NOT NULL + AND charge_code_id IS NULL) + -- a component: priced fields present at zero, always a product + OR (line_type = 'COMPONENT' + AND product_id IS NOT NULL AND parent_line_id IS NOT NULL + AND quantity IS NOT NULL AND quantity > 0 + AND price_uom IS NOT NULL + AND uom_qty IS NOT NULL AND price_uom_qty IS NOT NULL + AND unit_price IS NOT NULL AND unit_price = 0 + AND line_total IS NOT NULL AND line_total = 0 + AND charge_code_id IS NULL AND taxable = FALSE) + -- a charge: priced, always a code, never a product + OR (line_type = 'CHARGE' + AND quantity IS NOT NULL AND quantity > 0 + AND price_uom IS NOT NULL + AND uom_qty IS NOT NULL AND price_uom_qty IS NOT NULL + AND unit_price IS NOT NULL AND unit_price >= 0 + AND line_total IS NOT NULL + AND charge_code_id IS NOT NULL AND product_id IS NULL) + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; +DO $$ BEGIN + ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_discount_check + CHECK (discount_percent IS NULL OR discount_amount IS NULL); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- pos_tenders: the payment each tender became (and, from this item, is). +ALTER TABLE pos_tenders ADD COLUMN IF NOT EXISTS payment_id UUID NULL REFERENCES payments (id); +CREATE INDEX IF NOT EXISTS idx_pos_tenders_payment ON pos_tenders (payment_id); + +-- 3. pos_returns and their lines. + +ALTER TABLE pos_returns ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ; +UPDATE pos_returns SET updated_at = created_at WHERE updated_at IS NULL; +ALTER TABLE pos_returns ALTER COLUMN updated_at SET NOT NULL; +ALTER TABLE pos_returns ALTER COLUMN updated_at SET DEFAULT NOW(); +ALTER TABLE pos_returns ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1; +ALTER TABLE pos_returns ADD COLUMN IF NOT EXISTS currency CHAR(3) NULL; +UPDATE pos_returns t SET currency = COALESCE((SELECT c.currency FROM customers c WHERE c.id = t.customer_id), + (SELECT value FROM system_settings WHERE key = 'currency.default')) +WHERE currency IS NULL; +UPDATE pos_returns SET currency = 'USD' WHERE currency IS NULL; +ALTER TABLE pos_returns ALTER COLUMN currency SET NOT NULL; +DO $$ BEGIN + ALTER TABLE pos_returns ADD CONSTRAINT pos_returns_currency_check CHECK (currency ~ '^[A-Z]{3}$'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +ALTER TABLE pos_returns ADD COLUMN IF NOT EXISTS credit_memo_id UUID NULL REFERENCES credit_memos (id); + +CREATE SEQUENCE IF NOT EXISTS pos_return_number_seq; +ALTER TABLE pos_returns ADD COLUMN IF NOT EXISTS number TEXT; + +WITH ordered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) AS n + FROM pos_returns + WHERE number IS NULL +) +UPDATE pos_returns t +SET number = 'RTN-' || lpad(o.n::text, GREATEST(6, length(o.n::text)), '0') +FROM ordered o +WHERE t.id = o.id; + +SELECT setval('pos_return_number_seq', + GREATEST((SELECT COALESCE(MAX(substring(number FROM 5)::bigint), 0) + 1 FROM pos_returns), 1), + false); + +ALTER TABLE pos_returns ALTER COLUMN number SET DEFAULT 'RTN-' || lpad(nextval('pos_return_number_seq')::text, 6, '0'); +ALTER TABLE pos_returns ALTER COLUMN number SET NOT NULL; +DO $$ BEGIN + ALTER TABLE pos_returns ADD CONSTRAINT pos_returns_number_key UNIQUE (number); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS position INTEGER; +WITH ordered AS ( + SELECT id, row_number() OVER (PARTITION BY return_id ORDER BY created_at, id) - 1 AS n + FROM pos_return_lines + WHERE position IS NULL +) +UPDATE pos_return_lines l SET position = o.n FROM ordered o WHERE l.id = o.id; +ALTER TABLE pos_return_lines ALTER COLUMN position SET NOT NULL; +ALTER TABLE pos_return_lines ALTER COLUMN position SET DEFAULT 0; +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS line_type TEXT; +UPDATE pos_return_lines SET line_type = 'PRODUCT' WHERE line_type IS NULL; +ALTER TABLE pos_return_lines ALTER COLUMN line_type SET NOT NULL; +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS sku TEXT; +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS price_uom TEXT; +UPDATE pos_return_lines SET price_uom = uom WHERE price_uom IS NULL; +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS uom_qty NUMERIC(12, 4); +UPDATE pos_return_lines SET uom_qty = 1 WHERE uom_qty IS NULL; +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS price_uom_qty NUMERIC(12, 4); +UPDATE pos_return_lines SET price_uom_qty = 1 WHERE price_uom_qty IS NULL; +ALTER TABLE pos_return_lines ALTER COLUMN quantity TYPE NUMERIC(12, 4); +ALTER TABLE pos_return_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 4); +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS taxable BOOLEAN; +UPDATE pos_return_lines l SET taxable = COALESCE((SELECT p.taxable FROM products p WHERE p.id = l.product_id), TRUE) +WHERE taxable IS NULL; +ALTER TABLE pos_return_lines ALTER COLUMN taxable SET NOT NULL; +ALTER TABLE pos_return_lines ALTER COLUMN taxable SET DEFAULT TRUE; + +CREATE OR REPLACE FUNCTION pg_temp.mig105_local_date(ts TIMESTAMPTZ, branch UUID) RETURNS DATE +LANGUAGE sql STABLE AS $$ + SELECT (ts AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l WHERE l.id = branch), 'UTC'))::date +$$; + +-- Each historic return refunded to ACCOUNT lowered AR with no document: it +-- becomes an OPEN credit memo (ADR 0005 section 13 C2-5 step 3): the return's +-- own GL entry and subledger row already exist, so the memo carries the entry +-- and no new one is written; its lines mirror the return's, negative. +DO $$ +DECLARE + r RECORD; + memo UUID; +BEGIN + FOR r IN SELECT * FROM pos_returns WHERE refund_method = 'ACCOUNT' AND credit_memo_id IS NULL ORDER BY created_at, id LOOP + memo := gen_random_uuid(); + INSERT INTO credit_memos (id, customer_id, amount, reason, status, created_at, updated_at, number, currency, + branch_id, reason_code, subtotal, tax_amount, total_amount, amount_open, memo_date, + gl_entry_id) + VALUES (memo, r.customer_id, r.total, 'migrated counter account return', 'OPEN', r.created_at, r.created_at, + credit_memo_next_number(), r.currency, r.branch_id, 'RETURN', -r.subtotal, -r.tax_amount, -r.total, + -r.total, pg_temp.mig105_local_date(r.created_at, r.branch_id), r.gl_entry_id); + INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, product_id, description, quantity, uom, + price_uom, uom_qty, price_uom_qty, unit_price, price_source, line_total, + taxable, restock, invoice_line_id, created_at) + SELECT memo, l.position, 'PRODUCT', l.product_id, l.description, -l.quantity, l.uom, l.price_uom, l.uom_qty, + l.price_uom_qty, l.unit_price, 'MANUAL', -l.line_total, l.taxable, l.restock, NULL, r.created_at + FROM pos_return_lines l WHERE l.return_id = r.id; + IF NOT EXISTS (SELECT 1 FROM credit_memo_lines WHERE credit_memo_id = memo) THEN + INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, charge_code_id, description, quantity, + uom, price_uom, uom_qty, price_uom_qty, unit_price, price_source, + line_total, taxable, revenue_account_code, restock, created_at) + SELECT memo, 0, 'CHARGE', cc.id, 'migrated counter account return', -1, 'EA', 'EA', 1, 1, r.total, + 'MANUAL', -r.total, FALSE, cc.revenue_account_code, FALSE, r.created_at + FROM charge_codes cc WHERE cc.code = 'ADJUST'; + END IF; + UPDATE pos_returns SET credit_memo_id = memo WHERE id = r.id; + END LOOP; +END $$; + +-- 4. The walk-in customer (ADR 0005 section 13 C2-5 step 4): the branch the +-- sales fall back to is the default branch, the same one the raw insert path +-- names. +INSERT INTO customers (account_number, name, is_active, primary_branch_id, created_at, updated_at) +SELECT 'WALK-IN', 'Walk-in', TRUE, + COALESCE((SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'), + (SELECT l.id FROM locations l ORDER BY l.created_at, l.id LIMIT 1)), + NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM customers WHERE account_number = 'WALK-IN'); + +INSERT INTO system_settings (key, value) +SELECT 'pos.walk_in_customer_id', c.id::text +FROM customers c +WHERE c.account_number = 'WALK-IN' + AND NOT EXISTS (SELECT 1 FROM system_settings WHERE key = 'pos.walk_in_customer_id'); + +-- The keyset indexes the lists order on. +CREATE INDEX IF NOT EXISTS idx_pos_transactions_created ON pos_transactions (created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_pos_returns_created ON pos_returns (created_at DESC, id DESC); diff --git a/core/migrations/down/105_pos_wire_contract_down.sql b/core/migrations/down/105_pos_wire_contract_down.sql new file mode 100644 index 000000000..8bebc8df5 --- /dev/null +++ b/core/migrations/down/105_pos_wire_contract_down.sql @@ -0,0 +1,101 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Down of 105 (pos_wire_contract): the counter leaves the wire contract. +-- History moves back as data; no entry or subledger row the up file never +-- wrote is invented here. The migrated ACCOUNT-return credit memos are +-- deleted (their GL entries stay, owned by the returns as before), and the +-- walk-in customer goes only when nothing references it. + +DROP INDEX IF EXISTS idx_pos_returns_created; +DROP INDEX IF EXISTS idx_pos_transactions_created; + +-- 4. the walk-in customer. +DELETE FROM system_settings WHERE key = 'pos.walk_in_customer_id'; +DELETE FROM customers c +WHERE c.account_number = 'WALK-IN' + AND NOT EXISTS (SELECT 1 FROM pos_transactions t WHERE t.customer_id = c.id) + AND NOT EXISTS (SELECT 1 FROM pos_returns r WHERE r.customer_id = c.id) + AND NOT EXISTS (SELECT 1 FROM invoices i WHERE i.customer_id = c.id) + AND NOT EXISTS (SELECT 1 FROM payments p WHERE p.customer_id = c.id) + AND NOT EXISTS (SELECT 1 FROM credit_memos m WHERE m.customer_id = c.id) + AND NOT EXISTS (SELECT 1 FROM customer_transactions ct WHERE ct.customer_id = c.id); + +-- 3. the returns. +DELETE FROM pos_line_items WHERE product_id IS NULL; + +DELETE FROM credit_memo_lines WHERE credit_memo_id IN (SELECT credit_memo_id FROM pos_returns WHERE credit_memo_id IS NOT NULL); +DELETE FROM credit_memos WHERE reason = 'migrated counter account return' + AND id IN (SELECT credit_memo_id FROM pos_returns WHERE credit_memo_id IS NOT NULL); +ALTER TABLE pos_returns DROP COLUMN IF EXISTS credit_memo_id; + +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS taxable; +ALTER TABLE pos_return_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 2); +ALTER TABLE pos_return_lines ALTER COLUMN quantity TYPE DECIMAL(12, 4); +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS price_uom_qty; +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS uom_qty; +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS price_uom; +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS sku; +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS line_type; +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS position; + +ALTER TABLE pos_returns DROP CONSTRAINT IF EXISTS pos_returns_number_key; +ALTER TABLE pos_returns ALTER COLUMN number DROP NOT NULL; +ALTER TABLE pos_returns ALTER COLUMN number DROP DEFAULT; +ALTER TABLE pos_returns DROP COLUMN IF EXISTS number; +DROP SEQUENCE IF EXISTS pos_return_number_seq; +ALTER TABLE pos_returns DROP CONSTRAINT IF EXISTS pos_returns_currency_check; +ALTER TABLE pos_returns DROP COLUMN IF EXISTS currency; +ALTER TABLE pos_returns DROP COLUMN IF EXISTS revision; +ALTER TABLE pos_returns DROP COLUMN IF EXISTS updated_at; + +-- 2. the lines and tenders. +DROP INDEX IF EXISTS idx_pos_tenders_payment; +ALTER TABLE pos_tenders DROP COLUMN IF EXISTS payment_id; + +ALTER TABLE pos_line_items DROP CONSTRAINT IF EXISTS pos_line_items_discount_check; +ALTER TABLE pos_line_items DROP CONSTRAINT IF EXISTS pos_line_items_shape; +ALTER TABLE pos_line_items DROP CONSTRAINT IF EXISTS pos_line_items_pair_positive; +ALTER TABLE pos_line_items DROP CONSTRAINT IF EXISTS pos_line_items_price_source_check; +ALTER TABLE pos_line_items DROP CONSTRAINT IF EXISTS pos_line_items_line_type_check; +DELETE FROM pos_line_items WHERE line_type IN ('KIT', 'COMPONENT', 'CHARGE', 'TEXT'); +DELETE FROM pos_line_items WHERE product_id IS NULL; +ALTER TABLE pos_line_items ALTER COLUMN unit_price TYPE NUMERIC(12, 2); +ALTER TABLE pos_line_items ALTER COLUMN unit_price SET NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN uom SET NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN quantity SET NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN quantity TYPE DECIMAL(12, 4); +ALTER TABLE pos_line_items ALTER COLUMN line_total SET NOT NULL; +ALTER TABLE pos_line_items ALTER COLUMN product_id SET NOT NULL; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS revenue_account_code; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS price_adjusted_by; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS discount_reason; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS discount_amount; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS discount_percent; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS override_reason; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS price_source; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS priced_unit_price; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS price_uom_qty; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS uom_qty; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS price_uom; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS charge_code_id; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS parent_line_id; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS line_type; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS position; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS taxable; + +-- 1. the sales. +ALTER TABLE pos_transactions DROP CONSTRAINT IF EXISTS pos_transactions_status_check; +ALTER TABLE pos_transactions DROP CONSTRAINT IF EXISTS pos_transactions_number_key; +ALTER TABLE pos_transactions ALTER COLUMN number DROP NOT NULL; +ALTER TABLE pos_transactions ALTER COLUMN number DROP DEFAULT; +ALTER TABLE pos_transactions DROP COLUMN IF EXISTS number; +DROP SEQUENCE IF EXISTS pos_transaction_number_seq; +ALTER TABLE pos_transactions DROP COLUMN IF EXISTS invoice_id; +ALTER TABLE pos_transactions DROP CONSTRAINT IF EXISTS pos_transactions_currency_check; +ALTER TABLE pos_transactions DROP COLUMN IF EXISTS currency; +ALTER TABLE pos_transactions DROP COLUMN IF EXISTS revision; +ALTER TABLE pos_transactions ALTER COLUMN updated_at DROP NOT NULL; +ALTER TABLE pos_transactions ALTER COLUMN updated_at DROP DEFAULT; +ALTER TABLE pos_transactions DROP COLUMN IF EXISTS updated_at; +ALTER TABLE pos_transactions ALTER COLUMN created_at DROP DEFAULT; From ff16dbf62113145ef71a22e0631446d7ab2cd805 Mon Sep 17 00:00:00 2001 From: colton Date: Fri, 9 Oct 2026 21:46:25 -0700 Subject: [PATCH 002/113] C2-5: the counter's wire and money tests green The end to end fixture (a real Postgres, the invoice, inventory, GL and account services wired as serve wires them) and the tests: the sale's wire shape (POS- number, lowercase status, cents, the shared line shape with charge, text, override, discount and kit lines, the audit rows each adjustment owes), the split tender money moment (one invoice entry with the tax and COGS legs, one receipt and one application entry per tender, the account portion open, the AR invariants of 9.3 asserted), the walk-in customer and its ACCOUNT refusal, change never subtracted twice, the void (gateway refund, the ledger at zero, stock back), the session-closed refusal, the return as a credit memo that restocks and reverses COGS, the account credit return, the till's expected cash from the session's payments plus the float less cash refunds with the variance booked inside the close's transaction, the list envelope and filters, and the error envelope with the revision precondition. Signed-off-by: colton --- core/internal/pos/complete.go | 19 +- core/internal/pos/fixture_test.go | 535 ++++++++++++ core/internal/pos/handler.go | 14 +- core/internal/pos/input.go | 5 +- core/internal/pos/repository.go | 119 ++- core/internal/pos/returns.go | 49 +- core/internal/pos/service.go | 12 +- core/internal/pos/till_repository.go | 21 +- core/internal/pos/wire_test.go | 795 ++++++++++++++++++ core/migrations/105_pos_wire_contract.sql | 1 + .../down/105_pos_wire_contract_down.sql | 1 + 11 files changed, 1490 insertions(+), 81 deletions(-) create mode 100644 core/internal/pos/fixture_test.go create mode 100644 core/internal/pos/wire_test.go diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go index c3b569126..49a69dcbc 100644 --- a/core/internal/pos/complete.go +++ b/core/internal/pos/complete.go @@ -116,10 +116,20 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st if err != nil { return nil, err } - customerID := pre.WalkInID - if pre.CustomerID != nil { - customerID = *pre.CustomerID + // The walk-in customer backs a sale that names none: the invoice and + // its payments need a real customer row. + customerID := pre.CustomerID + if customerID == nil { + id, _, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return nil, err + } + pre.WalkInID = id + customerID = &id + } else { + customerID = pre.CustomerID } + realCustomerID := *customerID // The tender plan: what the drawer keeps per tender and what walks out // as change (only cash makes change; an ACCOUNT tender needs a named // customer and passes the credit check). @@ -175,7 +185,7 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st } charges[i] = &chargeResult{GatewayTxID: res.TransactionID, AuthCode: res.AuthCode, Last4: res.CardLast4, Brand: res.CardBrand} } - priced, err := s.prepareSaleTax(ctx, pre, preLines, customerID) + priced, err := s.prepareSaleTax(ctx, pre, preLines, realCustomerID) if err != nil { return nil, err } @@ -190,6 +200,7 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st return err } sale.WalkInID = pre.WalkInID + customerID := realCustomerID if err := httpx.CheckRevision(sale.Revision, ifMatch, bodyRevision); err != nil { return err } diff --git a/core/internal/pos/fixture_test.go b/core/internal/pos/fixture_test.go new file mode 100644 index 000000000..abd0f1130 --- /dev/null +++ b/core/internal/pos/fixture_test.go @@ -0,0 +1,535 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The counter end to end (ADR 0005 section 14.2 C2-5): a real Postgres, the +// real invoice, inventory, general ledger and account services wired as +// serve wires them, the real pos handler on one mux behind the real +// idempotency and branch middleware. Requests are JSON and responses are +// read back as JSON, so each test states a wire fact. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/pos" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +type fixture struct { + t *testing.T + db *database.DB + srv *httptest.Server + service *pos.Service + customerID uuid.UUID + productID uuid.UUID + kitID uuid.UUID + sku string + register string + branchID uuid.UUID + yardID uuid.UUID + events *recordingWriter + priorEntries map[string]bool +} + +const defaultBranch = `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` + +// recordingWriter wraps the outbox writer, remembering what was written and +// optionally failing one type (the transaction proofs). +type recordingWriter struct { + inner pos.EventRecorder + fail string + seen []string +} + +func (w *recordingWriter) Write(ctx context.Context, ev outbox.Event) error { + if ev.Type == w.fail { + return fmt.Errorf("outbox insert failed") + } + w.seen = append(w.seen, ev.Type) + return w.inner.Write(ctx, ev) +} + +// newFixture builds a walk-in-ready branch: a customer, a product costing +// 3.25 and priced 5.50 with 100 on hand (the branch rate is 8.875 percent), +// a kit of two of them, and serves the pos routes. opts tune the service +// before it is served. +func newFixture(t *testing.T, db *database.DB, opts ...func(*fixture)) *fixture { + t.Helper() + t.Setenv("AUTH_MODE", "dev") + ctx := context.Background() + f := &fixture{t: t, db: db, customerID: uuid.New(), productID: uuid.New(), kitID: uuid.New(), + sku: "POS-" + uuid.NewString()[:8], register: "REG-" + uuid.NewString()[:8]} + if err := db.Pool.QueryRow(ctx, `SELECT `+defaultBranch).Scan(&f.branchID); err != nil { + t.Fatalf("default branch: %v", err) + } + mustExec(t, db, `INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'Counter Co', $2, `+defaultBranch+`)`, + f.customerID, "WPOS-"+uuid.NewString()[:8]) + mustExec(t, db, `INSERT INTO products (id, sku, description, uom_primary, base_price, average_unit_cost) VALUES ($1, $2, '2x4x8 SPF', 'PCS', 5.5, 3.25)`, + f.productID, f.sku) + mustExec(t, db, `INSERT INTO products (id, sku, description, uom_primary, base_price, average_unit_cost, is_kit) VALUES ($1, $2, 'bundle kit', 'EA', 11.00, 6.50, TRUE)`, + f.kitID, "KIT-"+uuid.NewString()[:8]) + mustExec(t, db, `INSERT INTO product_kit_components (kit_product_id, component_product_id, quantity, position) VALUES ($1, $2, 2, 0)`, + f.kitID, f.productID) + mustExec(t, db, `UPDATE locations SET default_tax_rate = 0.088750 WHERE id = `+defaultBranch) + f.yardID = uuid.New() + mustExec(t, db, `INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'YARD', $2, `+defaultBranch+`, `+defaultBranch+`)`, + f.yardID, "PY-"+f.yardID.String()[:8]) + mustExec(t, db, `INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'Y', 100, 0)`, f.productID, f.yardID) + mustExec(t, db, `INSERT INTO pos_registers (id, location_id, name, branch_id) VALUES ($1, $2, 'Counter test', `+defaultBranch+`) ON CONFLICT (id) DO UPDATE SET location_id = EXCLUDED.location_id, branch_id = EXCLUDED.branch_id`, f.register, f.yardID) + + logger := slog.Default() + glSvc := gl.NewService(gl.NewRepository(db), nil, logger) + acct := account.NewService(db, glSvc, logger) + stock := inventory.NewService(inventory.NewRepository(db)) + invoices := invoice.NewService(invoice.NewRepository(db), glSvc, acct, db). + WithAuditLog(audit.NewLogger(db)).WithOutbox(outbox.NewWriter(db, "")).WithStock(stock) + f.events = &recordingWriter{inner: outbox.NewWriter(db, "")} + f.service = pos.NewService(db, pos.NewRepository(db), logger). + WithInvoices(invoices).WithAR(acct).WithInventory(stock).WithLedger(glSvc). + WithAuditLog(audit.NewLogger(db)).WithOutbox(f.events).WithTxRunner(db) + for _, o := range opts { + o(f) + } + + // the ledger entries that exist before this fixture's acts: its balance + // reads exclude them, so one test never counts another's entries. + f.priorEntries = map[string]bool{} + if rows, err := db.Pool.Query(ctx, `SELECT id::text FROM gl_journal_entries`); err == nil { + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + f.priorEntries[id] = true + } + } + rows.Close() + } + + mux := http.NewServeMux() + pos.NewHandler(f.service).RegisterRoutes(mux) + invoice.NewHandler(invoices).RegisterRoutes(mux) + f.srv = httptest.NewServer(middleware.Idempotency(db)(roleClaims(middleware.NewBranchMiddleware(db).Handler(mux)))) + t.Cleanup(func() { + f.srv.Close() + f.cleanup() + }) + return f +} + +func mustExec(t *testing.T, db *database.DB, sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), sql, args...); err != nil { + t.Fatalf("%s: %v", strings.Join(strings.Fields(sql), " "), err) + } +} + +// roleClaims stands in for authentication: X-Test-Role becomes the claims a +// JWT would carry; X-Test-Sub its subject. +func roleClaims(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if role := r.Header.Get("X-Test-Role"); role != "" { + claims := &middleware.UserClaims{Role: role, Roles: []string{role}} + claims.Subject = r.Header.Get("X-Test-Sub") + r = r.WithContext(context.WithValue(r.Context(), middleware.UserContextKey, claims)) + } + next.ServeHTTP(w, r) + }) +} + +// cleanup removes everything the fixture produced, children first, in the +// foreign key order the sweep proved: the walk-in customer's rows back the +// fixture's sales, so they go too. +func (f *fixture) cleanup() { + ctx := context.Background() + exec := func(sql string, args ...any) { + if _, err := f.db.Pool.Exec(ctx, sql, args...); err != nil { + f.t.Logf("cleanup: %s: %v", strings.Join(strings.Fields(sql), " ")[:min(80, len(strings.Join(strings.Fields(sql), " ")))], err) + } + } + walkIn := `(SELECT id FROM customers WHERE account_number = 'WALK-IN')` + sales := `(SELECT id FROM pos_transactions)` + invs := `(SELECT id FROM invoices WHERE customer_id IN ($1, ` + walkIn + `) OR id IN (SELECT invoice_id FROM pos_transactions WHERE invoice_id IS NOT NULL))` + cms := `(SELECT id FROM credit_memos WHERE customer_id IN ($1, ` + walkIn + `) OR id IN (SELECT credit_memo_id FROM pos_returns WHERE credit_memo_id IS NOT NULL))` + pays := `(SELECT id FROM payments WHERE customer_id IN ($1, ` + walkIn + `))` + // the ledger entries are captured before their source documents go, so + // the entry delete never misses one that named a payment or an + // application deleted below + var entryIDs []string + if rows, err := f.db.Pool.Query(ctx, `SELECT e.id::text FROM gl_journal_entries e WHERE e.source_ref_id::text IN + (SELECT id::text FROM invoices WHERE id IN `+invs+`) + OR e.source_ref_id::text IN (SELECT id::text FROM credit_memos WHERE id IN `+cms+`) + OR e.source_ref_id::text IN (SELECT id::text FROM payments WHERE id IN `+pays+`) + OR e.source_ref_id::text IN (SELECT id::text FROM ar_applications WHERE invoice_id IN `+invs+`) + OR e.source_ref_id::text IN (SELECT id::text FROM payment_refunds WHERE payment_id IN `+pays+` OR credit_memo_id IN `+cms+`) + OR e.reverses_entry_id IN (SELECT id FROM gl_journal_entries WHERE source_ref_id::text IN + (SELECT id::text FROM invoices WHERE id IN `+invs+`))`, f.customerID); err == nil { + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + entryIDs = append(entryIDs, id) + } + } + rows.Close() + } + exec(`DELETE FROM events_outbox WHERE entity_id::text IN (SELECT id::text FROM invoices WHERE id IN `+invs+`) + OR entity_id::text IN (SELECT id::text FROM credit_memos WHERE id IN `+cms+`) + OR entity_id::text IN (SELECT id::text FROM payments WHERE id IN `+pays+`) + OR entity_id::text IN (SELECT id::text FROM pos_transactions) OR entity_id::text IN (SELECT id::text FROM pos_returns) + OR entity_id::text IN (SELECT id::text FROM till_sessions) OR entity_id = $1`, f.customerID) + exec(`DELETE FROM audit_log WHERE entity_id::text IN (SELECT id::text FROM invoices WHERE id IN `+invs+`) + OR entity_id::text IN (SELECT id::text FROM credit_memos WHERE id IN `+cms+`) + OR entity_id::text IN (SELECT id::text FROM pos_transactions) OR entity_id::text IN (SELECT id::text FROM pos_returns)`, f.customerID) + exec(`DELETE FROM ar_applications WHERE invoice_id IN `+invs+` OR payment_id IN `+pays+` OR credit_memo_id IN `+cms, f.customerID) + exec(`DELETE FROM payment_refunds WHERE payment_id IN `+pays+` OR credit_memo_id IN `+cms, f.customerID) + exec(`DELETE FROM customer_transactions WHERE customer_id IN ($1, `+walkIn+`)`, f.customerID) + exec(`DELETE FROM pos_return_lines WHERE return_id IN (SELECT id FROM pos_returns)`) + exec(`DELETE FROM pos_returns`) + exec(`DELETE FROM pos_tenders WHERE transaction_id IN ` + sales) + exec(`DELETE FROM pos_line_items WHERE transaction_id IN ` + sales) + exec(`DELETE FROM pos_transactions`) + exec(`DELETE FROM payments WHERE id IN `+pays, f.customerID) + exec(`DELETE FROM credit_memo_lines WHERE credit_memo_id IN `+cms, f.customerID) + exec(`DELETE FROM credit_memos WHERE id IN `+cms, f.customerID) + exec(`DELETE FROM invoice_lines WHERE invoice_id IN `+invs, f.customerID) + exec(`DELETE FROM invoices WHERE id IN `+invs, f.customerID) + if len(entryIDs) > 0 { + exec(`DELETE FROM gl_journal_lines WHERE journal_entry_id = ANY($1::uuid[])`, entryIDs) + exec(`DELETE FROM gl_journal_entries WHERE id = ANY($1::uuid[])`, entryIDs) + } + + exec(`DELETE FROM pos_sync_log`) + exec(`DELETE FROM till_z_reports`) + exec(`DELETE FROM till_sessions`) + exec(`DELETE FROM inventory WHERE location_id = $1`, f.yardID) + exec(`DELETE FROM pos_registers WHERE id = $1`, f.register) + exec(`DELETE FROM locations WHERE id = $1`, f.yardID) + exec(`DELETE FROM product_kit_components WHERE kit_product_id = $1`, f.kitID) + exec(`UPDATE customers SET balance_due = 0 WHERE id IN ($1, `+walkIn+`)`, f.customerID) + exec(`DELETE FROM products WHERE id = $1 OR id = $2`, f.productID, f.kitID) + exec(`DELETE FROM customers WHERE id = $1`, f.customerID) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type resp struct { + status int + header http.Header + body map[string]any + raw []byte +} + +func (f *fixture) do(method, path string, body any, headers ...string) resp { + f.t.Helper() + var rdr io.Reader + switch b := body.(type) { + case nil: + case string: + rdr = strings.NewReader(b) + default: + raw, err := json.Marshal(b) + if err != nil { + f.t.Fatal(err) + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, f.srv.URL+path, rdr) + if err != nil { + f.t.Fatal(err) + } + if rdr != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Request-ID", "req-pos-test") + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + f.t.Fatal(err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + out := resp{status: res.StatusCode, header: res.Header, raw: raw} + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + _ = dec.Decode(&out.body) + return out +} + +func str(t *testing.T, m map[string]any, key string) string { + t.Helper() + s, ok := m[key].(string) + if !ok { + t.Fatalf("%s is %T (%v), want a string", key, m[key], m[key]) + } + return s +} + +func num(t *testing.T, m map[string]any, key string) int64 { + t.Helper() + n, ok := m[key].(json.Number) + if !ok { + t.Fatalf("%s is %T (%v), want an integer", key, m[key], m[key]) + } + v, err := n.Int64() + if err != nil { + t.Fatalf("%s = %s, want an integer: %v", key, n, err) + } + return v +} + +func rev(t *testing.T, r resp) int64 { return num(t, r.body, "revision") } + +func errorOf(t *testing.T, r resp) (code string, blockers []string, fields []string) { + t.Helper() + e, ok := r.body["error"].(map[string]any) + if !ok { + t.Fatalf("no error envelope in %s", r.raw) + } + code, _ = e["code"].(string) + if ds, ok := e["details"].([]any); ok { + for _, d := range ds { + dm := d.(map[string]any) + if c, _ := dm["code"].(string); c != "" { + blockers = append(blockers, c) + } + if fld, _ := dm["field"].(string); fld != "" { + fields = append(fields, fld) + } + } + } + return code, blockers, fields +} + +// --------------------------------------------------------------------------- +// Making sales through the routes. +// --------------------------------------------------------------------------- + +// startSale opens a cart on the fixture's register. +func (f *fixture) startSale(customer *uuid.UUID) string { + f.t.Helper() + body := map[string]any{"register_id": f.register} + if customer != nil { + body["customer_id"] = customer.String() + } + r := f.do("POST", "/api/v1/pos/transactions", body, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(f.t)) + if r.status != http.StatusCreated { + f.t.Fatalf("start sale = %d: %s", r.status, r.raw) + } + if loc := r.header.Get("Location"); !strings.HasPrefix(loc, "/api/v1/pos/transactions/") { + f.t.Errorf("Location = %q", loc) + } + return str(f.t, r.body, "id") +} + +func mustUUID(t *testing.T) string { + t.Helper() + return uuid.NewString() +} + +// addLine adds a product line of qty to the sale. +func (f *fixture) addLine(saleID string, line map[string]any) resp { + f.t.Helper() + return f.do("POST", "/api/v1/pos/transactions/"+saleID+"/items", map[string]any{"line": line}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(f.t)) +} + +// productLine is a stocked product line of qty. +func (f *fixture) productLine(qty string) map[string]any { + return map[string]any{"product_id": f.productID.String(), "quantity": qty} +} + +// tender is one tender of cents by method. +func tender(method string, cents int64) map[string]any { + return map[string]any{"method": method, "amount_cents": cents} +} + +// completeSale completes the sale with the tenders and the sale's current +// revision, answering the body. +func (f *fixture) completeSale(saleID string, tenders ...map[string]any) resp { + f.t.Helper() + cur := f.getSale(f.t, saleID) + return f.do("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + map[string]any{"tenders": tenders, "revision": num(f.t, cur, "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(f.t)) +} + +// saleOf makes a one-line sale of qty and completes it with the tenders. +func (f *fixture) saleOf(qty string, tenders ...map[string]any) (saleID string, body resp) { + f.t.Helper() + saleID = f.startSale(nil) + if r := f.addLine(saleID, f.productLine(qty)); r.status != http.StatusOK { + f.t.Fatalf("add line = %d: %s", r.status, r.raw) + } + r := f.completeSale(saleID, tenders...) + if r.status != http.StatusOK { + f.t.Fatalf("complete = %d: %s", r.status, r.raw) + } + return saleID, r +} + +// cashTender is a cash tender of exactly the total of qty at 5.50 plus the +// branch tax on it. +func (f *fixture) cashFor(qty string) map[string]any { + f.t.Helper() + var total int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT ROUND(total * 100)::bigint FROM pos_transactions ORDER BY created_at DESC LIMIT 1`).Scan(&total); err != nil { + f.t.Fatal(err) + } + return tender("cash", total) +} + +// openTill opens the fixture register's drawer with a float. +func (f *fixture) openTill(floatCents int64) string { + f.t.Helper() + r := f.do("POST", "/api/v1/pos/till/open", map[string]any{"register_id": f.register, "opening_float_cents": floatCents}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(f.t)) + if r.status != http.StatusCreated { + f.t.Fatalf("open till = %d: %s", r.status, r.raw) + } + return str(f.t, r.body, "id") +} + +// --------------------------------------------------------------------------- +// Reading the ledger, the stock and the events. +// --------------------------------------------------------------------------- + +type leg struct{ debit, credit int64 } + +// legsFor sums, per account code, the legs of every entry whose source +// reference is one of the ids. +func (f *fixture) legsFor(ids ...string) (entries int, legs map[string]leg) { + f.t.Helper() + ctx := context.Background() + if err := f.db.Pool.QueryRow(ctx, + `SELECT count(*) FROM gl_journal_entries WHERE source_ref_id::text = ANY($1)`, ids).Scan(&entries); err != nil { + f.t.Fatal(err) + } + rows, err := f.db.Pool.Query(ctx, ` + SELECT a.code, ROUND(SUM(l.debit) * 100)::bigint, ROUND(SUM(l.credit) * 100)::bigint + FROM gl_journal_lines l JOIN gl_journal_entries e ON e.id = l.journal_entry_id JOIN gl_accounts a ON a.id = l.account_id + WHERE e.source_ref_id::text = ANY($1) GROUP BY a.code`, ids) + if err != nil { + f.t.Fatal(err) + } + defer rows.Close() + legs = map[string]leg{} + for rows.Next() { + var code string + var l leg + if err := rows.Scan(&code, &l.debit, &l.credit); err != nil { + f.t.Fatal(err) + } + legs[code] = l + } + return entries, legs +} + +// accountBalance is an account's balance over the entries this fixture's +// acts wrote (the ones the fixture found on entry are excluded). +func (f *fixture) accountBalance(code string) int64 { + f.t.Helper() + var n int64 + if err := f.db.Pool.QueryRow(context.Background(), ` + SELECT COALESCE(ROUND((SUM(l.debit) - SUM(l.credit)) * 100)::bigint, 0) + FROM gl_journal_lines l + JOIN gl_journal_entries e ON e.id = l.journal_entry_id + JOIN gl_accounts a ON a.id = l.account_id + WHERE a.code = $1 AND NOT (e.id::text = ANY($2::text[]))`, code, f.entryExclusions()).Scan(&n); err != nil { + f.t.Fatal(err) + } + return n +} + +// entryExclusions lists the prior entry ids, with a sentinel that is never +// an id so the array is never empty. +func (f *fixture) entryExclusions() []string { + out := make([]string, 0, len(f.priorEntries)+1) + out = append(out, "00000000-0000-0000-0000-000000000000") + for id := range f.priorEntries { + out = append(out, id) + } + return out +} + +// stock reads on hand/allocated of the fixture's product. +func (f *fixture) stock() string { + f.t.Helper() + var s string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT sum(quantity)::text || '/' || sum(allocated)::text FROM inventory WHERE product_id = $1`, f.productID).Scan(&s); err != nil { + f.t.Fatal(err) + } + return s +} + +// balance reads the customer's subledger balance in cents. +func (f *fixture) balance() int64 { + f.t.Helper() + var n int64 + if err := f.db.Pool.QueryRow(context.Background(), `SELECT ROUND(balance_due * 100)::bigint FROM customers WHERE id = $1`, f.customerID).Scan(&n); err != nil { + f.t.Fatal(err) + } + return n +} + +func (f *fixture) scalar(sql string, args ...any) any { + f.t.Helper() + var v any + if err := f.db.Pool.QueryRow(context.Background(), sql, args...).Scan(&v); err != nil { + f.t.Fatal(err) + } + return v +} + +func countOf(t *testing.T, db *database.DB, sql string, args ...any) int64 { + t.Helper() + var n int64 + if err := db.Pool.QueryRow(context.Background(), sql, args...).Scan(&n); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return n +} + +// eventTypes lists an entity's event types in order. +func eventTypes(t *testing.T, db *database.DB, entityType, entityID string) []string { + t.Helper() + rows, err := db.Pool.Query(context.Background(), + `SELECT type FROM events_outbox WHERE entity_type = $1 AND entity_id = $2 ORDER BY position`, entityType, entityID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var ty string + if err := rows.Scan(&ty); err != nil { + t.Fatal(err) + } + out = append(out, ty) + } + return out +} diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index 3c9ce8c58..6dc85c49c 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -278,14 +278,13 @@ func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, err) return } - f := SaleFilter{RegisterID: q.Get("register_id")} + f := SaleFilter{RegisterID: q.Get("register_id"), Date: time.Now().Format("2006-01-02")} if d := q.Get("date"); d != "" { - parsed, err := time.Parse("2006-01-02", d) - if err != nil { + if _, err := time.Parse("2006-01-02", d); err != nil { httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "date", Message: "must be a date as YYYY-MM-DD"})) return } - f.Date = &parsed + f.Date = d } if s := q.Get("status"); s != "" { switch s { @@ -542,14 +541,13 @@ func (h *Handler) ListReturns(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, err) return } - f := ReturnFilter{RegisterID: q.Get("register_id")} + f := ReturnFilter{RegisterID: q.Get("register_id"), Date: time.Now().Format("2006-01-02")} if d := q.Get("date"); d != "" { - parsed, err := time.Parse("2006-01-02", d) - if err != nil { + if _, err := time.Parse("2006-01-02", d); err != nil { httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "date", Message: "must be a date as YYYY-MM-DD"})) return } - f.Date = &parsed + f.Date = d } list, err := h.service.ListReturns(r.Context(), f) if err != nil { diff --git a/core/internal/pos/input.go b/core/internal/pos/input.go index 69618f3ae..85bcad0a0 100644 --- a/core/internal/pos/input.go +++ b/core/internal/pos/input.go @@ -298,7 +298,8 @@ func (r *closeTillRequest) parse() (map[string]int64, string, error) { v := &httpx.Validator{} counted := make(map[string]int64, len(r.CountedByMethod)) for m, raw := range r.CountedByMethod { - if _, ok := ParseTenderMethod(m); !ok { + method, ok := ParseTenderMethod(m) + if !ok { v.Check(false, "counted_by_method."+m, "must be cash, check, card or account") continue } @@ -307,7 +308,7 @@ func (r *closeTillRequest) parse() (map[string]int64, string, error) { } if n, ok := v.Int("counted_by_method."+m, raw, true); ok { v.Check(n >= 0, "counted_by_method."+m, "must not be negative") - counted[m] = n + counted[string(method)] = n } } if err := v.Err(); err != nil { diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 099483dc2..694e13950 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -100,17 +100,18 @@ type TillAggregate struct { CashRefundsCents int64 } -// SaleFilter is the sale list's filters. +// SaleFilter is the sale list's filters. Date is a YYYY-MM-DD string, so +// the day boundary is the caller's and never the session's timezone. type SaleFilter struct { RegisterID string - Date *time.Time + Date string Status string } // ReturnFilter is the return list's filters. type ReturnFilter struct { RegisterID string - Date *time.Time + Date string } // LogBatch is one offline sync's outcome record: Errors lists the failed @@ -138,6 +139,19 @@ func NewRepository(db *database.DB) *PostgresRepository { return &PostgresReposi func (r *PostgresRepository) ex(ctx context.Context) database.Executor { return r.db.GetExecutor(ctx) } +// dayBounds turns a YYYY-MM-DD into the day's opening and closing absolute +// instants in the server's zone. +func dayBounds(day string) (time.Time, time.Time) { + if day == "" { + day = time.Now().Format("2006-01-02") + } + start, err := time.ParseInLocation("2006-01-02", day, time.Local) + if err != nil { + start = time.Now().Truncate(time.Hour) + } + return start, start.Add(24 * time.Hour) +} + // posLineCols is the shared line projection over pos_line_items. var posLineCols = salesdoc.LineColumns(`false, NULL::uuid, NULL::bigint`) @@ -160,16 +174,33 @@ func (r *PostgresRepository) CreateSale(ctx context.Context, s *Sale) error { return err } s.Number = number + // The register's branch is resolved first: reusing the register + // parameter inside the insert's subquery trips Postgres' type inference. + registerBranch, err := r.GetRegisterBranch(ctx, s.RegisterID) + if err != nil { + return err + } + var created time.Time + var branch uuid.UUID + switch { + case registerBranch != nil: + branch = *registerBranch + case branchctx.IDForQuery(ctx) != nil: + branch = *branchctx.IDForQuery(ctx) + default: + if err := r.ex(ctx).QueryRow(ctx, `SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'`).Scan(&branch); err != nil { + return fmt.Errorf("no branch for the sale: %w", err) + } + } + s.BranchID = branch err = r.ex(ctx).QueryRow(ctx, ` INSERT INTO pos_transactions (id, number, register_id, cashier_id, customer_id, currency, subtotal, tax_amount, total, change_due, status, till_session_id, branch_id, created_at, updated_at, revision) - VALUES ($1, $2, $3, $4, $5, $6, 0, 0, 0, 0, $7, $8, - COALESCE((SELECT l.branch_id FROM pos_registers pr LEFT JOIN locations l ON l.id = pr.location_id WHERE pr.id = $3), - (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')), - NOW(), NOW(), 1) - RETURNING branch_id, created_at, revision`, - s.ID, s.Number, s.RegisterID, s.CashierID, s.CustomerID, s.Currency, StatusOpen, s.TillSessionID, - ).Scan(&s.BranchID, &s.CreatedAt, &s.Revision) + VALUES ($1, $2, $3, $4, $5, $6, 0, 0, 0, 0, $7, $8, $9, NOW(), NOW(), 1) + RETURNING created_at, revision`, + s.ID, s.Number, s.RegisterID, s.CashierID, s.CustomerID, s.Currency, StatusOpen, s.TillSessionID, s.BranchID, + ).Scan(&created, &s.Revision) + s.CreatedAt = httpx.TimestampOf(created) if err != nil { return fmt.Errorf("failed to create the sale: %w", mapWriteError(err)) } @@ -191,9 +222,16 @@ const saleCols = `t.id, t.number, t.revision, t.branch_id, t.register_id, t.cash ROUND(t.change_due * 100)::bigint, t.till_session_id, t.status, t.invoice_id, t.completed_at, t.created_at` func scanSale(row pgx.Row, s *Sale) error { - return row.Scan(&s.ID, &s.Number, &s.Revision, &s.BranchID, &s.RegisterID, &s.CashierID, &s.CustomerID, &s.Currency, + var completed *time.Time + var created time.Time + if err := row.Scan(&s.ID, &s.Number, &s.Revision, &s.BranchID, &s.RegisterID, &s.CashierID, &s.CustomerID, &s.Currency, &s.SubtotalCents, &s.TaxCents, &s.TotalCents, &s.ChangeCents, &s.TillSessionID, &s.Status, &s.InvoiceID, - &s.CompletedAt, &s.CreatedAt) + &completed, &created); err != nil { + return err + } + s.CompletedAt = httpx.PtrTimestamp(completed) + s.CreatedAt = httpx.TimestampOf(created) + return nil } func (r *PostgresRepository) GetSale(ctx context.Context, id uuid.UUID) (*Sale, error) { @@ -255,20 +293,21 @@ func (r *PostgresRepository) NextReturnNumber(ctx context.Context) (string, erro } func (r *PostgresRepository) ListSales(ctx context.Context, f SaleFilter, limit int) ([]SaleSummary, error) { + // The day's bounds are absolute timestamps computed from the date + // string in the server's zone: a timestamptz never meets a date cast, + // whose midnight belongs to the session's zone, not the caller's. + start, end := dayBounds(f.Date) predicate := `WHERE ($1 = '' OR t.register_id = $1) AND ($2::text IS NULL OR t.status = $2) - AND (t.created_at >= $3::date AND t.created_at < ($3::date + 1)) - AND ($4::uuid IS NULL OR t.branch_id = $4)` - args := []any{f.RegisterID, nil, time.Now(), branchctx.IDForQuery(ctx)} + AND t.created_at >= $3 AND t.created_at < $4 + AND ($5::uuid IS NULL OR t.branch_id = $5)` + args := []any{f.RegisterID, nil, start, end, branchctx.IDForQuery(ctx)} if f.Status != "" { args[1] = f.Status } - if f.Date != nil { - args[2] = *f.Date - } rows, err := r.ex(ctx).Query(ctx, `SELECT `+saleCols+`, (SELECT count(*) FROM pos_line_items l WHERE l.transaction_id = t.id AND l.line_type <> 'TEXT') - FROM pos_transactions t `+predicate+` ORDER BY t.created_at DESC, t.id DESC LIMIT $5`, append(args, limit)...) + FROM pos_transactions t `+predicate+` ORDER BY t.created_at DESC, t.id DESC LIMIT $6`, append(args, limit)...) if err != nil { return nil, fmt.Errorf("failed to list sales: %w", err) } @@ -277,11 +316,15 @@ func (r *PostgresRepository) ListSales(ctx context.Context, f SaleFilter, limit for rows.Next() { s := &Sale{} var count int + var completed *time.Time + var created time.Time if err := rows.Scan(&s.ID, &s.Number, &s.Revision, &s.BranchID, &s.RegisterID, &s.CashierID, &s.CustomerID, &s.Currency, &s.SubtotalCents, &s.TaxCents, &s.TotalCents, &s.ChangeCents, &s.TillSessionID, &s.Status, - &s.InvoiceID, &s.CompletedAt, &s.CreatedAt, &count); err != nil { + &s.InvoiceID, &completed, &created, &count); err != nil { return nil, err } + s.CompletedAt = httpx.PtrTimestamp(completed) + s.CreatedAt = httpx.TimestampOf(created) out = append(out, SaleSummary{ID: s.ID, Number: s.Number, Revision: s.Revision, BranchID: s.BranchID, RegisterID: s.RegisterID, CashierID: s.CashierID, CustomerID: s.CustomerID, Currency: s.Currency, TotalCents: s.TotalCents, Status: s.Status, InvoiceID: s.InvoiceID, CompletedAt: s.CompletedAt, @@ -373,13 +416,15 @@ func (r *PostgresRepository) AddTender(ctx context.Context, t *Tender) error { if t.ID == uuid.Nil { t.ID = uuid.New() } + var created time.Time err := r.ex(ctx).QueryRow(ctx, ` INSERT INTO pos_tenders (id, transaction_id, method, amount, payment_id, reference, card_last4, card_brand, gateway_tx_id, auth_code, created_at) VALUES ($1, $2, $3, $4::numeric / 100, $5, NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), NULLIF($10, ''), NOW()) RETURNING created_at`, t.ID, t.SaleID, string(t.Method), int64(t.AmountCents), t.PaymentID, t.Reference, t.CardLast4, t.CardBrand, - t.GatewayTxID, t.AuthCode).Scan(&t.CreatedAt) + t.GatewayTxID, t.AuthCode).Scan(&created) + t.CreatedAt = httpx.TimestampOf(created) if err != nil { return fmt.Errorf("failed to record the tender: %w", mapWriteError(err)) } @@ -399,12 +444,14 @@ func (r *PostgresRepository) GetTenders(ctx context.Context, saleID uuid.UUID) ( for rows.Next() { var t Tender var method string + var createdAt time.Time if err := rows.Scan(&t.ID, &method, &t.AmountCents, &t.PaymentID, &t.Reference, &t.CardLast4, &t.CardBrand, - &t.GatewayTxID, &t.AuthCode, &t.CreatedAt); err != nil { + &t.GatewayTxID, &t.AuthCode, &createdAt); err != nil { return nil, err } t.Method = TenderMethod(method) t.SaleID = saleID + t.CreatedAt = httpx.TimestampOf(createdAt) out = append(out, t) } return out, rows.Err() @@ -419,6 +466,7 @@ func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, line return err } ret.Number = number + var createdAt time.Time err = r.ex(ctx).QueryRow(ctx, ` INSERT INTO pos_returns (id, number, register_id, till_session_id, original_transaction_id, customer_id, branch_id, cashier_id, currency, subtotal, tax_amount, total, refund_method, reason, status, credit_memo_id, created_at, updated_at, revision) @@ -427,7 +475,8 @@ func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, line RETURNING created_at`, ret.ID, ret.Number, ret.RegisterID, ret.TillSessionID, ret.OriginalSaleID, ret.CustomerID, ret.BranchID, ret.CashierID, ret.Currency, int64(ret.SubtotalCents), int64(ret.TaxCents), int64(ret.TotalCents), - string(ret.RefundMethod), ret.Reason, ret.CreditMemoID).Scan(&ret.CreatedAt) + string(ret.RefundMethod), ret.Reason, ret.CreditMemoID).Scan(&createdAt) + ret.CreatedAt = httpx.TimestampOf(createdAt) if err != nil { return fmt.Errorf("failed to record the return: %w", mapWriteError(err)) } @@ -437,9 +486,9 @@ func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, line l.ID = uuid.New() } _, err := r.ex(ctx).Exec(ctx, ` - INSERT INTO pos_return_lines (id, return_id, position, product_id, description, quantity, uom, unit_price, - line_total, restock, created_at) - VALUES ($1, $2, $3, $4, $5, -($6::numeric / 10000), $7, $8::numeric / 10000, -($9::numeric / 100), $10, NOW())`, + INSERT INTO pos_return_lines (id, return_id, position, line_type, product_id, description, quantity, uom, + unit_price, line_total, restock, created_at) + VALUES ($1, $2, $3, 'PRODUCT', $4, $5, -($6::numeric / 10000), $7, $8::numeric / 10000, $9::numeric / 100, $10, NOW())`, l.ID, ret.ID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, priceArg(l.UnitPrice), centsArg(l.LineTotal), l.Restock) if err != nil { @@ -452,6 +501,7 @@ func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, line func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) { ret := &Return{} var method string + var createdAt time.Time err := r.ex(ctx).QueryRow(ctx, ` SELECT id, number, revision, branch_id, register_id, till_session_id, original_transaction_id, customer_id, cashier_id, currency, ROUND(subtotal * 100)::bigint, ROUND(tax_amount * 100)::bigint, ROUND(total * 100)::bigint, @@ -459,13 +509,14 @@ func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*Retu FROM pos_returns WHERE id = $1 AND ($2::uuid IS NULL OR branch_id = $2)`, id, branchctx.IDForQuery(ctx)). Scan(&ret.ID, &ret.Number, &ret.Revision, &ret.BranchID, &ret.RegisterID, &ret.TillSessionID, &ret.OriginalSaleID, &ret.CustomerID, &ret.CashierID, &ret.Currency, &ret.SubtotalCents, &ret.TaxCents, &ret.TotalCents, - &method, &ret.Reason, &ret.CreditMemoID, &ret.CreatedAt) + &method, &ret.Reason, &ret.CreditMemoID, &createdAt) if errors.Is(err, pgx.ErrNoRows) { return nil, httpx.NotFound("return not found") } if err != nil { return nil, fmt.Errorf("failed to read the return: %w", err) } + ret.CreatedAt = httpx.TimestampOf(createdAt) ret.RefundMethod = RefundMethod(method) rows, err := r.ex(ctx).Query(ctx, ` SELECT id, position, product_id, description, ROUND(-quantity * 10000)::bigint, uom, @@ -487,16 +538,16 @@ func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*Retu } func (r *PostgresRepository) ListReturns(ctx context.Context, f ReturnFilter, limit int) ([]Return, error) { - args := []any{f.RegisterID, time.Now(), branchctx.IDForQuery(ctx), limit} + start, end := dayBounds(f.Date) rows, err := r.ex(ctx).Query(ctx, ` SELECT id, number, revision, branch_id, register_id, till_session_id, original_transaction_id, customer_id, cashier_id, currency, ROUND(subtotal * 100)::bigint, ROUND(tax_amount * 100)::bigint, ROUND(total * 100)::bigint, refund_method, reason, credit_memo_id, created_at FROM pos_returns WHERE ($1 = '' OR register_id = $1) - AND (created_at >= $2::date AND created_at < ($2::date + 1)) - AND ($3::uuid IS NULL OR branch_id = $3) - ORDER BY created_at DESC, id DESC LIMIT $4`, args...) + AND created_at >= $2 AND created_at < $3 + AND ($4::uuid IS NULL OR branch_id = $4) + ORDER BY created_at DESC, id DESC LIMIT $5`, f.RegisterID, start, end, branchctx.IDForQuery(ctx), limit) if err != nil { return nil, fmt.Errorf("failed to list returns: %w", err) } @@ -505,11 +556,13 @@ func (r *PostgresRepository) ListReturns(ctx context.Context, f ReturnFilter, li for rows.Next() { ret := &Return{} var method string + var createdAt time.Time if err := rows.Scan(&ret.ID, &ret.Number, &ret.Revision, &ret.BranchID, &ret.RegisterID, &ret.TillSessionID, &ret.OriginalSaleID, &ret.CustomerID, &ret.CashierID, &ret.Currency, &ret.SubtotalCents, &ret.TaxCents, - &ret.TotalCents, &method, &ret.Reason, &ret.CreditMemoID, &ret.CreatedAt); err != nil { + &ret.TotalCents, &method, &ret.Reason, &ret.CreditMemoID, &createdAt); err != nil { return nil, err } + ret.CreatedAt = httpx.TimestampOf(createdAt) ret.RefundMethod = RefundMethod(method) out = append(out, *ret) } @@ -678,7 +731,7 @@ func (r *PostgresRepository) CustomerFacts(ctx context.Context, customerID uuid. func (r *PostgresRepository) CustomerExempt(ctx context.Context, customerID uuid.UUID) (bool, error) { var exempt bool - err := r.ex(ctx).QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tax_exemptions WHERE customer_id = $1 AND active)`, customerID).Scan(&exempt) + err := r.ex(ctx).QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tax_exemptions WHERE customer_id = $1 AND is_active)`, customerID).Scan(&exempt) return exempt, err } diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index cc3599161..a7ae9e1af 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -99,17 +99,19 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur priced := make([]returnLinePriced, 0, len(in.Lines)) for i := range in.Lines { rl := in.Lines[i] - if src, ok := byID[*rl.SaleLineID]; ok && rl.SaleLineID != nil { - if rl.UnitPrice == nil && src.UnitPrice != nil { - p := *src.UnitPrice - rl.UnitPrice = &p - } - if rl.Description == "" { - rl.Description = src.Description - } - if rl.ProductID == nil && src.ProductID != nil { - p := *src.ProductID - rl.ProductID = &p + if rl.SaleLineID != nil { + if src, ok := byID[*rl.SaleLineID]; ok { + if rl.UnitPrice == nil && src.UnitPrice != nil { + p := *src.UnitPrice + rl.UnitPrice = &p + } + if rl.Description == "" { + rl.Description = src.Description + } + if rl.ProductID == nil && src.ProductID != nil { + p := *src.ProductID + rl.ProductID = &p + } } } if rl.UnitPrice == nil { @@ -117,8 +119,10 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur "is required when the line names no line of the original sale") } taxable := true - if ref, ok := refs[rl.ProductID.String()]; ok { - taxable = ref.Taxable + if rl.ProductID != nil { + if ref, ok := refs[rl.ProductID.String()]; ok { + taxable = ref.Taxable + } } priced = append(priced, returnLinePriced{in: rl, lineTotal: int64(salesdoc.CostOf(rl.Quantity, *rl.UnitPrice)), taxable: taxable}) } @@ -208,9 +212,10 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur } } if _, err := s.db.GetExecutor(ctx).Exec(ctx, ` - INSERT INTO credit_memos (id, customer_id, branch_id, currency, reason_code, reason, status, created_at, updated_at, revision) - VALUES ($1, $2, $3, $4, 'RETURN', $5, 'DRAFT', NOW(), NOW(), 1)`, - memoID, customerID, branchID, facts.Currency, in.Reason); err != nil { + INSERT INTO credit_memos (id, customer_id, branch_id, currency, reason_code, reason, status, amount, + subtotal, tax_amount, total_amount, amount_open, memo_date, created_at, updated_at, revision) + VALUES ($1, $2, $3, $4, 'RETURN', $5, 'DRAFT', 0, 0, 0, 0, 0, $6::date, NOW(), NOW(), 1)`, + memoID, customerID, branchID, facts.Currency, in.Reason, date.Format("2006-01-02")); err != nil { return fmt.Errorf("failed to create the credit memo: %w", mapWriteError(err)) } for i := range lines { @@ -219,12 +224,18 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, product_id, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, restock, created_at) VALUES ($1, $2, 'PRODUCT', $3, $4, -($5::numeric / 10000), $6, $6, 1, 1, $7::numeric / 10000, 'MANUAL', - -($8::numeric / 100), $9, $10, NOW())`, + $8::numeric / 100, $9, $10, NOW())`, memoID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, priceArg(l.UnitPrice), centsArg(l.LineTotal), priced[i].taxable, l.Restock); err != nil { return fmt.Errorf("failed to create the credit memo line: %w", mapWriteError(err)) } } + // The gapless CM number, minted late: after every other row lock, + // before the posting (ADR 0005 section 4.1). + memoNumber, err := httpx.NextGaplessNumber(ctx, s.db.GetExecutor(ctx), "credit_memo", "CM", httpx.DefaultDocNumberWidth) + if err != nil { + return err + } legs := []gl.Leg{{AccountCode: gl.AccountCodeRevenue, Description: "Sales Revenue", Debit: subtotal}} if taxCents > 0 { legs = append(legs, gl.Leg{AccountCode: gl.AccountCodeSalesTax, Description: "Sales Tax Payable", Debit: taxCents}) @@ -235,7 +246,7 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur gl.Leg{AccountCode: gl.AccountCodeCOGS, Description: "Cost of Goods Sold", Credit: restockCost}) } fxPost, err := s.ar.PostCreditMemo(ctx, account.PostCreditMemoIn{ - MemoID: memoID, CustomerID: *customerID, Currency: facts.Currency, MemoDate: date, + MemoID: memoID, CustomerID: *customerID, Number: memoNumber, Currency: facts.Currency, MemoDate: date, SubtotalCents: -subtotal, TaxCents: -taxCents, TotalCents: total, TaxRate: taxRate, Actor: actor, Legs: legs}) if err != nil { return err @@ -276,7 +287,7 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur // The events, last: the memo's lifecycle, the AR core's, the // return's own. branch := *branchID - number := fxMemoNumber(ctx, s, memoID) + number := memoNumber if err := s.recordEvent(ctx, outbox.Event{Type: "credit_memo.created", EntityType: "credit_memo", EntityID: memoID, BranchID: &branch, Data: eventJSON(map[string]any{ "number": nil, "customer_id": customerID, "status": "draft", "currency": facts.Currency, diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index 439d7e2d8..052e3514d 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -518,17 +518,11 @@ func (s *Service) AddLine(ctx context.Context, saleID uuid.UUID, parsed []salesd if err != nil { return err } - next := len(existing) + // the new lines keep the order Explode built (a kit before its + // components), numbered after the cart's own for i := range lines { - if lines[i].LineType == salesdoc.LineComponent { - continue - } - lines[i].Position = next - next++ + lines[i].Position = len(existing) + i } - // Explode assigns component lines after their kit; the positions - // above numbered only the parents, so the components follow theirs - // in the stored order Explode built. if err := s.repo.AddLines(ctx, saleID, lines); err != nil { return err } diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index baea63957..16b1d8b1c 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -35,14 +35,17 @@ func (r *PostgresRepository) CreateTillSession(ctx context.Context, s *TillSessi func (r *PostgresRepository) scanTillSession(row pgx.Row) (*TillSession, error) { var s TillSession var status string - var closedAt *time.Time - err := row.Scan(&s.ID, &s.RegisterID, &s.BranchID, &s.CashierID, &status, &s.OpeningFloat, &s.OpenedAt, &closedAt, + var openedAt, closedAt *time.Time + err := row.Scan(&s.ID, &s.RegisterID, &s.BranchID, &s.CashierID, &status, &s.OpeningFloat, &openedAt, &closedAt, &s.ExpectedByMethod, &s.CountedByMethod, &s.OverShort, &s.GLEntryID, &s.Notes) if err != nil { return nil, err } - s.Status = TillSessionStatus(status) + if openedAt != nil { + s.OpenedAt = httpx.TimestampOf(*openedAt) + } s.ClosedAt = httpx.PtrTimestamp(closedAt) + s.Status = TillSessionStatus(status) return &s, nil } @@ -165,12 +168,14 @@ func (r *PostgresRepository) CreateZReport(ctx context.Context, z *ZReport) erro if z.ID == uuid.Nil { z.ID = uuid.New() } + var generated time.Time err := r.ex(ctx).QueryRow(ctx, ` INSERT INTO till_z_reports (id, till_session_id, register_id, branch_id, over_short, payload, generated_at) VALUES ($1, $2, $3, $4, $5::numeric / 100, $6, NOW()) ON CONFLICT (till_session_id) DO NOTHING RETURNING generated_at`, - z.ID, z.TillSessionID, z.RegisterID, z.BranchID, int64(z.OverShort), z.Payload).Scan(&z.GeneratedAt) + z.ID, z.TillSessionID, z.RegisterID, z.BranchID, int64(z.OverShort), z.Payload).Scan(&generated) + z.GeneratedAt = httpx.TimestampOf(generated) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil // the frozen snapshot already exists @@ -182,10 +187,12 @@ func (r *PostgresRepository) CreateZReport(ctx context.Context, z *ZReport) erro func (r *PostgresRepository) GetZReportBySession(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) { z := &ZReport{} + var generatedAt time.Time err := r.ex(ctx).QueryRow(ctx, ` SELECT id, till_session_id, register_id, branch_id, ROUND(over_short * 100)::bigint, payload, generated_at FROM till_z_reports WHERE till_session_id = $1`, sessionID). - Scan(&z.ID, &z.TillSessionID, &z.RegisterID, &z.BranchID, &z.OverShort, &z.Payload, &z.GeneratedAt) + Scan(&z.ID, &z.TillSessionID, &z.RegisterID, &z.BranchID, &z.OverShort, &z.Payload, &generatedAt) + z.GeneratedAt = httpx.TimestampOf(generatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, httpx.NotFound("no Z-report for this session") } @@ -209,9 +216,11 @@ func (r *PostgresRepository) ListZReports(ctx context.Context, registerID string var out []ZReport for rows.Next() { var z ZReport - if err := rows.Scan(&z.ID, &z.TillSessionID, &z.RegisterID, &z.BranchID, &z.OverShort, &z.Payload, &z.GeneratedAt); err != nil { + var generatedAt time.Time + if err := rows.Scan(&z.ID, &z.TillSessionID, &z.RegisterID, &z.BranchID, &z.OverShort, &z.Payload, &generatedAt); err != nil { return nil, err } + z.GeneratedAt = httpx.TimestampOf(generatedAt) out = append(out, z) } return out, rows.Err() diff --git a/core/internal/pos/wire_test.go b/core/internal/pos/wire_test.go new file mode 100644 index 000000000..2d666f6d9 --- /dev/null +++ b/core/internal/pos/wire_test.go @@ -0,0 +1,795 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The counter on the wire (ADR 0005 section 14.2 C2-5): the sale's shape and +// lifecycle, the completed sale's money (one invoice entry with tax and +// COGS, one receipt and application entry per tender), the void, the return +// as a credit memo, the till, and the offline sync. Inputs proved: IN-1.1, +// IN-1.2, IN-1.3, IN-1.4, IN-3.2, IN-3.7. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +var posNumberPattern = regexp.MustCompile(`^POS-\d{6,}$`) +var rtnNumberPattern = regexp.MustCompile(`^RTN-\d{6,}$`) + +// RULE (IN-1.1, IN-1.2, IN-1.3): the sale carries its POS- number, a +// lowercase status, money in cents, the lines in the shared shape, and the +// tenders in cents. +func TestSaleWireShape(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID := f.startSale(nil) + r := f.do("GET", "/api/v1/pos/transactions/"+saleID, nil) + if r.status != http.StatusOK { + t.Fatalf("get = %d: %s", r.status, r.raw) + } + b := r.body + if !posNumberPattern.MatchString(str(t, b, "number")) { + t.Errorf("number = %q, want POS-nnnnnn", b["number"]) + } + for key, want := range map[string]string{"status": "open", "currency": "USD"} { + if got := str(t, b, key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + for key, want := range map[string]int64{"subtotal_cents": 0, "tax_cents": 0, "total_cents": 0, "change_cents": 0, "revision": 1} { + if got := num(t, b, key); got != want { + t.Errorf("%s = %d, want %d", key, got, want) + } + } + for _, key := range []string{"customer_id", "till_session_id", "invoice_id", "completed_at"} { + if v, ok := b[key]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want present as null", key, v, ok) + } + } + if etag := r.header.Get("ETag"); etag != fmt.Sprintf(`"%d"`, rev(t, r)) { + t.Errorf("ETag = %q, want the revision in quotes", etag) + } + + // A line in the shared shape: priced by the engine, taxable from the + // product, extended once. + r = f.addLine(saleID, f.productLine("3")) + if r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + line := r.body["lines"].([]any)[0].(map[string]any) + for key, want := range map[string]string{"line_type": "product", "uom": "PCS", "price_uom": "PCS", + "price_source": "price_list", "quantity": "3"} { + if got := str(t, line, key); got != want { + t.Errorf("line.%s = %q, want %q", key, got, want) + } + } + if got := num(t, line, "unit_price_ten_thousandths"); got != 55000 { + t.Errorf("line.unit_price = %d, want 55000", got) + } + if got := num(t, line, "line_total_cents"); got != 1650 { + t.Errorf("line.line_total_cents = %d, want 1650", got) + } + if line["taxable"] != true { + t.Errorf("line.taxable = %v, want true (from the product)", line["taxable"]) + } + if got := num(t, r.body, "subtotal_cents"); got != 1650 { + t.Errorf("subtotal_cents = %d, want 1650", got) + } + if got := num(t, r.body, "tax_cents"); got != 146 { // 1650 x 0.08875 + t.Errorf("tax_cents = %d, want 146 (the branch rate estimate)", got) + } + + // A charge line and a text line at the counter. + r = f.addLine(saleID, map[string]any{"line_type": "charge", "charge_code": "FREIGHT", "description": "delivery", + "quantity": "1", "unit_price_ten_thousandths": 250000}) + if r.status != http.StatusOK { + t.Fatalf("charge line = %d: %s", r.status, r.raw) + } + r = f.addLine(saleID, map[string]any{"line_type": "text", "description": "loaded by hand"}) + if r.status != http.StatusOK { + t.Fatalf("text line = %d: %s", r.status, r.raw) + } + lines := r.body["lines"].([]any) + if len(lines) != 3 { + t.Fatalf("%d lines, want 3", len(lines)) + } + if lt := lines[2].(map[string]any)["line_type"]; lt != "text" { + t.Errorf("third line type = %v, want text", lt) + } + // freight is untaxed: subtotal 1650 + 2500, tax still on 1650 only. + if got := num(t, r.body, "subtotal_cents"); got != 4150 { + t.Errorf("subtotal_cents = %d, want 4150", got) + } + if got := num(t, r.body, "tax_cents"); got != 146 { + t.Errorf("tax_cents = %d, want 146 (freight is untaxed)", got) + } + + // A typed override without a reason is a 400 naming the field. + r = f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 10000}) + if r.status != http.StatusBadRequest { + t.Fatalf("override without reason = %d, want 400", r.status) + } + _, _, fields := errorOf(t, r) + if len(fields) == 0 || fields[0] != "lines[0].override_reason" { + t.Errorf("fields = %v, want lines[0].override_reason", fields) + } + // An override with a reason is stored with the engine's price beside it + // and one audit row. + r = f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 10000, "override_reason": "damaged corner"}) + if r.status != http.StatusOK { + t.Fatalf("override = %d: %s", r.status, r.raw) + } + last := r.body["lines"].([]any) + ol := last[len(last)-1].(map[string]any) + if str(t, ol, "price_source") != "override" { + t.Errorf("price_source = %q, want override", ol["price_source"]) + } + if got := num(t, ol, "priced_unit_price_ten_thousandths"); got != 55000 { + t.Errorf("priced_unit_price = %d, want the engine's 55000 beside the override", got) + } + if n := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos_transaction.line_price_overridden' AND entity_id = $1`, saleID); n != 1 { + t.Errorf("%d override audit rows, want 1", n) + } + // A discount rides the line with its reason and its audit row. + r = f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "2", + "discount_cents": 100, "discount_reason": "goodwill"}) + if r.status != http.StatusOK { + t.Fatalf("discount line = %d: %s", r.status, r.raw) + } + last = r.body["lines"].([]any) + dl := last[len(last)-1].(map[string]any) + if got := num(t, dl, "line_total_cents"); got != 1100-100 { + t.Errorf("discounted line_total_cents = %d, want 1000", got) + } + if n := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos_transaction.line_discounted' AND entity_id = $1`, saleID); n != 1 { + t.Errorf("%d discount audit rows, want 1", n) + } + // A kit explodes: the kit priced whole, one component at zero. + r = f.addLine(saleID, map[string]any{"product_id": f.kitID.String(), "quantity": "1"}) + if r.status != http.StatusOK { + t.Fatalf("kit line = %d: %s", r.status, r.raw) + } + last = r.body["lines"].([]any) + kit := last[len(last)-2].(map[string]any) + comp := last[len(last)-1].(map[string]any) + if str(t, kit, "line_type") != "kit" || str(t, comp, "line_type") != "component" { + t.Fatalf("kit lines = %v, %v", kit["line_type"], comp["line_type"]) + } + if got := str(t, comp, "quantity"); got != "2" { + t.Errorf("component quantity = %q, want 2 (a decimal string)", got) + } + if got := num(t, comp, "line_total_cents"); got != 0 { + t.Errorf("component line_total_cents = %d, want 0", got) + } +} + +// RULE (ADR 0005 14.2 C2-5, the exit test of the money): a split tender sale +// posts ONE invoice entry with the tax leg and the COGS legs, ONE receipt +// entry per tender (cash and card here) and ONE application entry per +// tender, all in the sale's own transaction; the invoice's lines are the +// sale's own lines (a split tender is never a fake product line). +func TestSplitTenderSalePostsEveryEntryInTheSaleTransaction(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(&fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}}) + }) + f.openTill(5000) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, f.productLine("10")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + // total: 5500 + 488 tax = 5988; split cash 2000 + card 3000 + account 988 + r := f.completeSale(saleID, tender("cash", 2000), withToken(tender("card", 3000)), tender("account", 988)) + if r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + if got := str(t, r.body, "status"); got != "completed" { + t.Errorf("status = %q, want completed", got) + } + invoiceID := str(t, r.body, "invoice_id") + + // The invoice: origin pos, pickup, its own lines, tax and COGS. + inv := f.do("GET", "/api/v1/invoices/"+invoiceID, nil) + if inv.status != http.StatusOK { + t.Fatalf("get invoice = %d: %s", inv.status, inv.raw) + } + if str(t, inv.body, "origin") != "pos" || str(t, inv.body, "delivery_type") != "pickup" { + t.Errorf("origin = %q delivery_type = %q, want pos and pickup", inv.body["origin"], inv.body["delivery_type"]) + } + for key, want := range map[string]int64{"subtotal_cents": 5500, "tax_cents": 488, "total_cents": 5988} { + if got := num(t, inv.body, key); got != want { + t.Errorf("invoice %s = %d, want %d", key, got, want) + } + } + lines := inv.body["lines"].([]any) + if len(lines) != 1 || str(t, lines[0].(map[string]any), "line_type") != "product" { + t.Fatalf("invoice lines = %v, want the sale's own product line", lines) + } + if got := num(t, lines[0].(map[string]any), "cost_cents"); got != 3250 { + t.Errorf("invoice line cost_cents = %d, want 3250 (10 x 3.25)", got) + } + + // ONE invoice entry: DR 1020 5988, DR 5010 3250; CR 4010 5500, CR 2020 + // 488 (the tax leg the base commit never wrote), CR 1030 3250. + entries, legs := f.legsFor(invoiceID) + if entries != 1 { + t.Errorf("%d invoice entries, want 1", entries) + } + for code, want := range map[string]leg{ + "1020": {debit: 5988}, "5010": {debit: 3250}, + "4010": {credit: 5500}, "2020": {credit: 488}, "1030": {credit: 3250}, + } { + if legs[code] != want { + t.Errorf("account %s = %+v, want %+v", code, legs[code], want) + } + } + + // The tenders became payments: cash 2000, card 3000, and the account + // portion left open on the invoice. + pays := f.paymentsOf(saleID) + if len(pays) != 2 { + t.Fatalf("%d payments, want 2 (the account tender is not one)", len(pays)) + } + // One receipt entry per tender, one application entry per tender: the + // payments' own entries, then their applications'. + for _, p := range pays { + n, l := f.legsFor(p.id) + if n != 1 || l["1010"].debit != p.cents || l["2200"].credit != p.cents { + t.Errorf("payment %s: %d entries %+v, want one DR 1010 / CR 2200 of %d", p.id, n, l, p.cents) + } + } + var appEntries int + for _, p := range pays { + var n int64 + if err := f.db.Pool.QueryRow(context.Background(), `SELECT count(*) FROM gl_journal_entries e + JOIN ar_applications a ON a.gl_entry_id = e.id WHERE a.payment_id = $1`, p.id).Scan(&n); err != nil { + t.Fatal(err) + } + appEntries += int(n) + } + if appEntries != 2 { + t.Errorf("%d application entries, want one per tender", appEntries) + } + // The invoice: partially paid (the account portion open), the payments + // fully applied. + if got := str(t, inv.body, "status"); got != "partial" { + t.Errorf("invoice status = %q, want partial (the account portion is open)", got) + } + if got := num(t, inv.body, "open_cents"); got != 988 { + t.Errorf("open_cents = %d, want 988", got) + } + // The stock left: 100 - 10 = 90. + if got := f.stock(); got != "90.0000/0.0000" { + t.Errorf("stock = %s, want 90/0", got) + } + // The AR invariants (9.3): the subledger equals balance_due equals the + // 1020 balance; the unapplied cash equals the 2200 balance. + f.assertARInvariants(t) + // The events, in order: payment.recorded per payment, the AR core's + // (payment.applied, invoice.partial, customer.updated), invoice.created, + // then the sale's own. + types := eventTypes(t, f.db, "pos_transaction", saleID) + if len(types) != 1 || types[0] != "pos_transaction.completed" { + t.Errorf("sale events = %v, want one completed", types) + } + invEvents := eventTypes(t, f.db, "invoice", invoiceID) + if len(invEvents) == 0 || invEvents[0] != "invoice.created" { + t.Errorf("invoice events = %v, want invoice.created first", invEvents) + } +} + +// RULE (the walk-in customer, ADR 0005 14.2 C2-5): a sale without a customer +// completes against the walk-in customer, and an ACCOUNT tender is refused +// for it. +func TestWalkInSaleRefusesAccountTender(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, _ := f.saleOf("2", tender("cash", 1198)) + r := f.do("GET", "/api/v1/pos/transactions/"+saleID, nil) + if v := r.body["customer_id"]; v != nil { + t.Errorf("walk-in sale customer_id = %v, want null on the sale", v) + } + // its invoice exists against the walk-in customer + invoiceID := str(t, r.body, "invoice_id") + walkIn := f.scalar(`SELECT id::text FROM customers WHERE account_number = 'WALK-IN'`).(string) + n := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE id = $1 AND customer_id = $2`, invoiceID, walkIn) + if n != 1 { + t.Errorf("the walk-in invoice is not against the walk-in customer") + } + + saleID = f.startSale(nil) + f.addLine(saleID, f.productLine("1")) + r = f.completeSale(saleID, tender("account", 599)) + if r.status != http.StatusConflict { + t.Fatalf("account tender for the walk-in = %d, want 409", r.status) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "walk_in_account" { + t.Errorf("blockers = %v, want walk_in_account", blockers) + } +} + +// RULE (the change rule): change comes only from cash, and the payment is +// the money kept: tendered less change. +func TestChangeIsNeverSubtractedTwice(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(1000) + saleID, _ := f.saleOf("2", tender("cash", 2000)) // total 1198, change 802 + pays := f.paymentsOf(saleID) + if len(pays) != 1 || pays[0].cents != 1198 { + t.Fatalf("cash payment = %+v, want one of 1198 (net of the 802 change)", pays) + } + if got := num(t, f.getSale(t, saleID), "change_cents"); got != 802 { + t.Errorf("change_cents = %d, want 802", got) + } + // The drawer expectation: float + the payment kept, the change never + // subtracted a second time. + report := f.tillReport(t) + if got := report["expected_by_method"].(map[string]any)["CASH"]; got != nil { + if v := int64Of(got); v != 1000+1198 { + t.Errorf("expected CASH = %d, want 2198 (float + the net payment)", v) + } + } + // Over-tender on a non-cash method is refused. + saleID = f.startSale(nil) + f.addLine(saleID, f.productLine("1")) + r := f.completeSale(saleID, tender("card", 100000)) + if r.status != http.StatusConflict { + t.Fatalf("card over tender = %d, want 409", r.status) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "change_from_cash" { + t.Errorf("blockers = %v, want change_from_cash", blockers) + } +} + +// RULE (the void, ADR 0005 14.2 C2-5): a void while the session is open is +// one transaction over the sale, its payments and its invoice. A card +// tender's refund goes through the gateway before it; the ledger ends at +// zero for the sale. +func TestVoidOfACardSaleLeavesTheLedgerAtZero(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, refunds: []string{}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("4", withToken(tender("card", 2395))) // 2200 + 195 tax + invoiceID := str(t, body.body, "invoice_id") + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "wrong basket", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("void = %d: %s", r.status, r.raw) + } + if got := str(t, r.body, "status"); got != "voided" { + t.Errorf("status = %q, want voided", got) + } + if len(gw.refunds) != 1 { + t.Fatalf("%d gateway refunds, want 1", len(gw.refunds)) + } + // The ledger at zero for the sale: every account the sale touched nets + // to zero (the invoice entry and its reversal, the receipt and its + // application refund entries). + for _, code := range []string{"1010", "1020", "2020", "2200", "4010", "5010", "1030"} { + if got := f.accountBalance(code); got != 0 { + t.Errorf("account %s balance = %d, want 0", code, got) + } + } + // The stock came back. + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100/0", got) + } + // The invoice is void. + inv := f.do("GET", "/api/v1/invoices/"+invoiceID, nil) + if str(t, inv.body, "status") != "void" { + t.Errorf("invoice status = %q, want void", inv.body["status"]) + } + f.assertARInvariants(t) + types := eventTypes(t, f.db, "pos_transaction", saleID) + if len(types) != 2 || types[1] != "pos_transaction.voided" { + t.Errorf("sale events = %v, want completed then voided", types) + } +} + +// RULE (the void after the session closes is a return): once the drawer is +// counted, the sale is no longer voidable. +func TestVoidAfterSessionCloseIsRefused(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + sessionID := f.openTill(0) + saleID, _ := f.saleOf("1", tender("cash", 599)) + r := f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 599}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("close till = %d: %s", r.status, r.raw) + } + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", map[string]any{"reason": "late"}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("late void = %d, want 409", r.status) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "session_closed" { + t.Errorf("blockers = %v, want session_closed", blockers) + } +} + +// RULE (the return, ADR 0005 14.2 C2-5): a return is a credit memo created +// and posted in one act with its restock lines, refunded in cash or card or +// left as account credit; it restocks and reverses COGS. +func TestReturnRestocksAndReversesCOGS(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, body := f.saleOf("10", tender("cash", 5988)) + saleLines := body.body["lines"].([]any) + firstLine := saleLines[0].(map[string]any)["id"].(string) + r := f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "refund_method": "cash", + "reason": "wrong length", "lines": []map[string]any{{ + "product_id": f.productID.String(), "line_id": firstLine, "quantity": "4", "restock": true, + }}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusCreated { + t.Fatalf("return = %d: %s", r.status, r.raw) + } + if !rtnNumberPattern.MatchString(str(t, r.body, "number")) { + t.Errorf("return number = %q, want RTN-nnnnnn", r.body["number"]) + } + // negative totals, a linked credit memo + for key, want := range map[string]int64{"subtotal_cents": -2200, "tax_cents": -195, "total_cents": -2395} { + if got := num(t, r.body, key); got != want { + t.Errorf("return %s = %d, want %d", key, got, want) + } + } + memoID := str(t, r.body, "credit_memo_id") + memo := f.do("GET", "/api/v1/credit-memos/"+memoID, nil) + if memo.status != http.StatusOK { + t.Fatalf("get memo = %d: %s", memo.status, memo.raw) + } + if str(t, memo.body, "status") != "applied" { + t.Errorf("memo status = %q, want applied (the cash refund used it)", memo.body["status"]) + } + // The stock came back: 90 + 4. + if got := f.stock(); got != "94.0000/0.0000" { + t.Errorf("stock = %s, want 94/0", got) + } + // The memo's entry: DR 4010 2200, DR 2020 195, DR 1030 1300 (4 x 3.25 + // restocked cost); CR 1020 2395, CR 5010 1300. + entries, legs := f.legsFor(memoID) + if entries != 1 { + t.Errorf("%d memo entries, want 1", entries) + } + for code, want := range map[string]leg{ + "4010": {debit: 2200}, "2020": {debit: 195}, "1030": {debit: 1300}, + "1020": {credit: 2395}, "5010": {credit: 1300}, + } { + if legs[code] != want { + t.Errorf("account %s = %+v, want %+v", code, legs[code], want) + } + } + // The cash refund pays the credit out: DR 1020 / CR 1010 of 2395. + if got := f.accountBalance("1010"); got != 5988-2395 { + t.Errorf("cash balance = %d, want 3593 (the sale in, the refund out)", got) + } + f.assertARInvariants(t) + types := eventTypes(t, f.db, "pos_return", str(t, r.body, "id")) + if len(types) != 1 || types[0] != "pos_return.completed" { + t.Errorf("return events = %v", types) + } + _ = saleID +} + +// RULE (the account credit return): refunded to account, the credit stays +// open on the customer's account. +func TestAccountReturnLeavesOpenCredit(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.saleOf("4", tender("cash", 2395)) + r := f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "refund_method": "account", + "reason": "store credit", "lines": []map[string]any{{ + "product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 55000, "restock": true, + }}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusCreated { + t.Fatalf("account return = %d: %s", r.status, r.raw) + } + memoID := str(t, r.body, "credit_memo_id") + memo := f.do("GET", "/api/v1/credit-memos/"+memoID, nil) + if str(t, memo.body, "status") != "open" { + t.Errorf("memo status = %q, want open", memo.body["status"]) + } + if got := num(t, memo.body, "open_cents"); got != -599 { + t.Errorf("memo open_cents = %d, want -599", got) + } + f.assertARInvariants(t) +} + +// RULE (the till): the drawer's expected cash is the session's cash +// payments (already net of change) plus the opening float less cash refunds. +func TestTillExpectedCashIncludesFloatAndRefunds(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(5000) + f.saleOf("2", tender("cash", 2000)) // keeps 1198, change 802 + f.saleOf("1", tender("card", 599)) // card; no cash, terminal settles it + // a cash return of 500 out of the drawer + r := f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "refund_method": "cash", + "reason": "scratched", "lines": []map[string]any{{ + "product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 45800, "restock": false, + }}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusCreated { + t.Fatalf("cash return = %d: %s", r.status, r.raw) + } + // 458 + tax(8.875%) = 458 + 41 = 499 -> 500 with rounding handled by cents + report := f.tillReport(t) + expected := report["expected_by_method"].(map[string]any) + if v := int64Of(expected["CASH"]); v != 5000+1198-499 { + t.Errorf("expected CASH = %d, want %d (float + net cash - cash refund)", v, 5000+1198-499) + } + if v := int64Of(expected["CARD"]); v != 599 { + t.Errorf("expected CARD = %d, want 599", v) + } + // closing counts the drawer and books the variance inside the close's + // transaction + r = f.do("POST", "/api/v1/pos/till/"+f.currentTillID(t)+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 5699}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("close = %d: %s", r.status, r.raw) + } + sess := r.body["session"].(map[string]any) + if got := num(t, sess, "over_short_cents"); got != 0 { + t.Errorf("over_short = %d, want 0", got) + } + // A short drawer books the variance to 5030. + sessionID := f.openTill(0) + f.saleOf("1", tender("cash", 599)) + r = f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 500}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("close 2 = %d: %s", r.status, r.raw) + } + sess2 := r.body["session"].(map[string]any) + if got := num(t, sess2, "over_short_cents"); got != -99 { + t.Errorf("over_short = %d, want -99", got) + } + if got := f.accountBalance("5030"); got != 99 { + t.Errorf("5030 balance = %d, want 99 (a short drawer is a debit)", got) + } +} + +// RULE (IN-1.4, IN-3.2): the sale list is the list envelope, filters filter +// and unknown parameters are refused. +func TestSaleListEnvelopeAndFilters(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.saleOf("1", tender("cash", 599)) + // an empty register's page is [] in the bytes + r := f.do("GET", "/api/v1/pos/transactions?register_id=NOPE", nil) + if r.status != http.StatusOK { + t.Fatalf("list = %d: %s", r.status, r.raw) + } + if items, ok := r.body["items"].([]any); !ok || len(items) != 0 { + t.Errorf("items = %v, want an empty array", r.body["items"]) + } + // the status filter filters; an unknown value is refused + r = f.do("GET", "/api/v1/pos/transactions?status=completed", nil) + if r.status != http.StatusOK { + t.Fatalf("list = %d: %s", r.status, r.raw) + } + if items := r.body["items"].([]any); len(items) != 1 { + t.Errorf("%d completed sales, want 1", len(items)) + } + r = f.do("GET", "/api/v1/pos/transactions?status=OPEN", nil) + if r.status != http.StatusBadRequest { + t.Fatalf("uppercase status = %d, want 400", r.status) + } + r = f.do("GET", "/api/v1/pos/transactions?bogus=1", nil) + if r.status != http.StatusBadRequest { + t.Fatalf("unknown parameter = %d, want 400", r.status) + } + // the returns list is an array, never null + r = f.do("GET", "/api/v1/pos/returns", nil) + if r.status != http.StatusOK || r.raw == nil || string(r.raw) == "null" { + t.Errorf("returns list = %d %s", r.status, r.raw) + } +} + +// RULE (IN-3.7): every refusal is the error envelope with its status, code +// and blocker; the revision precondition refuses a stale completion. +func TestErrorEnvelopeAndRevision(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID := f.startSale(nil) + f.addLine(saleID, f.productLine("1")) + // a stale revision + r := f.completeSaleWithRevision(saleID, 99, tender("cash", 599)) + if r.status != http.StatusConflict { + t.Fatalf("stale complete = %d, want 409", r.status) + } + if code, _, _ := errorOf(t, r); code != "stale_revision" { + t.Errorf("code = %q, want stale_revision", code) + } + // an unknown sale + r = f.do("GET", "/api/v1/pos/transactions/"+uuid.NewString(), nil) + if r.status != http.StatusNotFound { + t.Errorf("unknown sale = %d, want 404", r.status) + } + // a bad tender method names its field + r = f.completeSale(saleID, tender("clamshells", 599)) + if r.status != http.StatusBadRequest { + t.Fatalf("bad method = %d, want 400", r.status) + } + _, _, fields := errorOf(t, r) + if len(fields) == 0 || fields[0] != "tenders[0].method" { + t.Errorf("fields = %v, want tenders[0].method", fields) + } + // a tender in cents: a decimal point is a decode error, never a rounding + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + `{"tenders":[{"method":"cash","amount_cents":5.99}]}`, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusBadRequest { + t.Errorf("decimal tender = %d, want 400 (IN-1.2: integer cents)", r.status) + } +} + +// --------------------------------------------------------------------------- +// Helpers over the database. +// --------------------------------------------------------------------------- + +type paymentOf struct { + id string + cents int64 + method string +} + +func (f *fixture) paymentsOf(saleID string) []paymentOf { + f.t.Helper() + rows, err := f.db.Pool.Query(context.Background(), ` + SELECT p.id::text, ROUND(p.amount * 100)::bigint, p.method + FROM payments p JOIN pos_tenders pt ON pt.payment_id = p.id + WHERE pt.transaction_id = $1 ORDER BY p.created_at`, saleID) + if err != nil { + f.t.Fatal(err) + } + defer rows.Close() + var out []paymentOf + for rows.Next() { + var p paymentOf + if err := rows.Scan(&p.id, &p.cents, &p.method); err != nil { + f.t.Fatal(err) + } + out = append(out, p) + } + return out +} + +// assertARInvariants proves the subledger rule of ADR 0005 9.3 for the +// fixture's customer after every act: the subledger sum equals balance_due +// equals the 1020 balance, and the unapplied cash equals the 2200 balance. +func (f *fixture) assertARInvariants(t *testing.T) { + t.Helper() + var subledger int64 + if err := f.db.Pool.QueryRow(context.Background(), ` + SELECT COALESCE(SUM(ROUND(amount)::bigint), 0) FROM customer_transactions WHERE customer_id = $1`, f.customerID).Scan(&subledger); err != nil { + t.Fatal(err) + } + bal := f.balance() + if subledger != bal { + t.Errorf("the subledger sums %d but balance_due is %d", subledger, bal) + } + if gl1020 := f.accountBalance("1020"); gl1020 != bal { + t.Errorf("the 1020 balance is %d but balance_due is %d", gl1020, bal) + } + var unapplied int64 + if err := f.db.Pool.QueryRow(context.Background(), ` + SELECT COALESCE(SUM(ROUND(amount_unapplied * 100)::bigint), 0) FROM payments WHERE customer_id = $1 AND status = 'POSTED'`, f.customerID).Scan(&unapplied); err != nil { + t.Fatal(err) + } + if gl2200 := f.accountBalance("2200"); gl2200 != unapplied { + t.Errorf("the 2200 balance is %d but the unapplied cash is %d", gl2200, unapplied) + } +} + +func (f *fixture) getSale(t *testing.T, id string) map[string]any { + t.Helper() + r := f.do("GET", "/api/v1/pos/transactions/"+id, nil) + if r.status != http.StatusOK { + t.Fatalf("get sale = %d: %s", r.status, r.raw) + } + return r.body +} + +func (f *fixture) completeSaleWithRevision(saleID string, revision int64, tenders ...map[string]any) resp { + f.t.Helper() + return f.do("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + map[string]any{"tenders": tenders, "revision": revision}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(f.t)) +} + +func (f *fixture) tillReport(t *testing.T) map[string]any { + t.Helper() + r := f.do("GET", "/api/v1/pos/till/"+f.currentTillID(t)+"/report", nil) + if r.status != http.StatusOK { + t.Fatalf("till report = %d: %s", r.status, r.raw) + } + return r.body +} + +func (f *fixture) currentTillID(t *testing.T) string { + t.Helper() + var id string + if err := f.db.Pool.QueryRow(context.Background(), `SELECT id::text FROM till_sessions WHERE register_id = $1 AND status = 'OPEN' ORDER BY opened_at DESC LIMIT 1`, f.register).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + +func int64Of(v any) int64 { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + case json.Number: + i, _ := n.Int64() + return i + } + return -1 +} + +// fakeGateway is the card terminal in tests. +type fakeGateway struct { + charges []*payment.GatewayResult + refunds []string + currency []string +} + +func approvedCharge() *payment.GatewayResult { + return &payment.GatewayResult{Status: payment.GatewayStatusApproved, TransactionID: "gw-1", AuthCode: "A1", + CardLast4: "4242", CardBrand: "VISA"} +} + +func (g *fakeGateway) Charge(ctx context.Context, req payment.ChargeRequest) (*payment.GatewayResult, error) { + g.currency = append(g.currency, req.Currency) + if len(g.charges) == 0 { + return nil, fmt.Errorf("no charge scripted") + } + res := g.charges[0] + g.charges = g.charges[1:] + return res, nil +} + +func (g *fakeGateway) Capture(ctx context.Context, id string, cents int64) (*payment.GatewayResult, error) { + return approvedCharge(), nil +} + +func (g *fakeGateway) Void(ctx context.Context, id string) (*payment.GatewayResult, error) { + return &payment.GatewayResult{Status: payment.GatewayStatusVoided}, nil +} + +func (g *fakeGateway) Refund(ctx context.Context, id string, cents int64) (*payment.GatewayResult, error) { + g.refunds = append(g.refunds, id) + return &payment.GatewayResult{Status: payment.GatewayStatusRefunded, TransactionID: "ref-1"}, nil +} + +func withToken(t map[string]any) map[string]any { + t["token_id"] = "tok-1" + return t +} + diff --git a/core/migrations/105_pos_wire_contract.sql b/core/migrations/105_pos_wire_contract.sql index fec47ec25..10b07f73f 100644 --- a/core/migrations/105_pos_wire_contract.sql +++ b/core/migrations/105_pos_wire_contract.sql @@ -97,6 +97,7 @@ ALTER TABLE pos_line_items ALTER COLUMN line_type SET NOT NULL; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS parent_line_id UUID NULL REFERENCES pos_line_items (id) ON DELETE CASCADE; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS charge_code_id UUID NULL REFERENCES charge_codes (id); +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS sku TEXT; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS price_uom TEXT; UPDATE pos_line_items SET price_uom = uom WHERE price_uom IS NULL; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS uom_qty NUMERIC(12, 4); diff --git a/core/migrations/down/105_pos_wire_contract_down.sql b/core/migrations/down/105_pos_wire_contract_down.sql index 8bebc8df5..9c2121e1e 100644 --- a/core/migrations/down/105_pos_wire_contract_down.sql +++ b/core/migrations/down/105_pos_wire_contract_down.sql @@ -78,6 +78,7 @@ ALTER TABLE pos_line_items DROP COLUMN IF EXISTS priced_unit_price; ALTER TABLE pos_line_items DROP COLUMN IF EXISTS price_uom_qty; ALTER TABLE pos_line_items DROP COLUMN IF EXISTS uom_qty; ALTER TABLE pos_line_items DROP COLUMN IF EXISTS price_uom; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS sku; ALTER TABLE pos_line_items DROP COLUMN IF EXISTS charge_code_id; ALTER TABLE pos_line_items DROP COLUMN IF EXISTS parent_line_id; ALTER TABLE pos_line_items DROP COLUMN IF EXISTS line_type; From 88ba87330bdb8051ff66121edea5147ca3a3a91e Mon Sep 17 00:00:00 2001 From: colton Date: Fri, 9 Oct 2026 22:24:59 -0700 Subject: [PATCH 003/113] WIP C2-5: the pos transaction and contention proofs, first cut The safety copy from the interrupted run: the failing event write rolls the sale and the void back, two sales of the last unit, three registers against a walk-in payment, a void racing a return. Two proofs fail as written (a count query given an argument it does not take, and a scalar read back as the wrong type); they are fixed in the follow-up commits. Signed-off-by: colton --- core/internal/pos/tx_test.go | 261 +++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 core/internal/pos/tx_test.go diff --git a/core/internal/pos/tx_test.go b/core/internal/pos/tx_test.go new file mode 100644 index 000000000..2f062a6e1 --- /dev/null +++ b/core/internal/pos/tx_test.go @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The transaction proofs of ADR 0005 section 14.2 C2-5: a failing event +// write rolls the whole sale back (no invoice, payment, stock move or +// entry), and the contention tests at pool size 4 (two sales of the last +// unit, a void racing a return, three registers completing sales with no +// gap in the invoice series against a payment for the walk-in customer). + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// RULE: a sale whose event write fails leaves no invoice, payment, stock +// move or entry (the recipe's failing event proof, for each kind of write: +// the completion and the void). +func TestFailingEventWriteRollsTheSaleBack(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.events.fail = "pos_transaction.completed" + }) + saleID := f.startSale(nil) + f.addLine(saleID, f.productLine("3")) + r := f.completeSale(saleID, tender("cash", 3266)) + if r.status == httpOK { + t.Fatal("the sale completed with a failing event write") + } + // nothing stayed: no invoice, no payment, no tender, the stock unmoved, + // the sale still open. + for sql, want := range map[string]int64{ + `SELECT count(*) FROM invoices WHERE order_id IS NULL`: 0, + `SELECT count(*) FROM payments`: 0, + `SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`: 0, + `SELECT count(*) FROM gl_journal_entries`: 0, + `SELECT count(*) FROM events_outbox WHERE entity_type = 'pos_transaction'`: 0, + } { + if got := countOf(t, f.db, sql, saleID); got != want { + t.Errorf("%s = %d, want %d", sql, got, want) + } + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want the 100 untouched", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "open" { + t.Errorf("sale status = %q, want open", got) + } + // The gapless invoice series spent nothing: the next invoice takes the + // number the rolled back sale would have taken. + f.events.fail = "" + saleID2, _ := f.saleOf("1", tender("cash", 599)) + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != 1 { + t.Errorf("%d invoices after the retry, want 1", got) + } + _ = saleID2 + + // The same proof for the void. + f.events.fail = "pos_transaction.voided" + f2 := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.events.fail = "pos_transaction.voided" + }) + saleID3, body := f2.saleOf("2", tender("cash", 1198)) + r = f2.do("POST", "/api/v1/pos/transactions/"+saleID3+"/void", + map[string]any{"reason": "nope", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status == httpOK { + t.Fatal("the void committed with a failing event write") + } + if got := str(t, f2.getSale(t, saleID3), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void rolled back)", got) + } + if got := f2.stock(); got != "98.0000/0.0000" { + t.Errorf("stock = %s, want 98 (the goods still gone)", got) + } + if got := f2.accountBalance("1010"); got != 1198 { + t.Errorf("cash balance = %d, want the 1198 still booked", got) + } +} + +// RULE: two sales of the last unit of stock at one till: one completes, +// the other is refused with insufficient_stock and moves nothing. +func TestTwoSalesOfTheLastUnit(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + mustExec(t, f.db, `UPDATE inventory SET quantity = 2 WHERE product_id = $1`, f.productID) + var wg sync.WaitGroup + results := make([]int, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + saleID := f.startSale(nil) + if r := f.addLine(saleID, f.productLine("2")); r.status != httpOK { + results[i] = r.status + return + } + results[i] = f.completeSale(saleID, tender("cash", 4390)).status + }(i) + } + wg.Wait() + wins := 0 + for _, st := range results { + if st == httpOK { + wins++ + } + } + if wins != 1 { + t.Errorf("results = %v, want exactly one winner", results) + } + if got := f.stock(); got != "0.0000/0.0000" { + t.Errorf("stock = %s, want 0 (the last unit went out once)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != 1 { + t.Errorf("%d invoices, want 1", got) + } +} + +// RULE: three registers completing sales at pool size 4 get consecutive +// invoice numbers with no gap and no deadlock against a payment for the +// walk-in customer (the counter's hot rows: the gapless series and the +// walk-in customer row, ADR 0005 4.1). +func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + registers := []string{f.register, "REG-" + uuid.NewString()[:8], "REG-" + uuid.NewString()[:8]} + for _, reg := range registers[1:] { + mustExec(t, f.db, `INSERT INTO pos_registers (id, location_id, name, branch_id) VALUES ($1, $2, 'r', $3)`, + reg, f.yardID, f.branchID) + } + var wg sync.WaitGroup + errs := make([]error, 3) + for i, reg := range registers { + wg.Add(1) + go func(i int, reg string) { + defer wg.Done() + saleID := f.startSaleOn(reg) + if _, err := f.addLineOn(saleID, f.productLine("1")); err != nil { + errs[i] = err + return + } + if r := f.completeSaleOn(reg, saleID, tender("cash", 599)); r.status != httpOK { + errs[i] = fmt.Errorf("register %s: %d %s", reg, r.status, r.raw) + } + }(i, reg) + } + // a payment for the walk-in customer racing the sales + wg.Add(1) + go func() { + defer wg.Done() + walkIn := f.scalar(`SELECT id FROM customers WHERE account_number = 'WALK-IN'`).(uuid.UUID) + for i := 0; i < 3; i++ { + _ = f.do("POST", "/api/v1/payments", map[string]any{ + "customer_id": walkIn.String(), "method": "cash", "amount_cents": 100, + "received_on": "2030-01-01", + }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + } + }() + wg.Wait() + for i, err := range errs { + if err != nil { + t.Errorf("register %d: %v", i, err) + } + } + // consecutive numbers, no gap + var numbers []int + rows, err := f.db.Pool.Query(context.Background(), ` + SELECT substring(i.number FROM 4)::bigint FROM invoices i WHERE i.order_id IS NULL ORDER BY 1`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var n int + if err := rows.Scan(&n); err != nil { + t.Fatal(err) + } + numbers = append(numbers, n) + } + rows.Close() + if len(numbers) != 3 { + t.Fatalf("%d invoices, want 3", len(numbers)) + } + for i := 1; i < len(numbers); i++ { + if numbers[i] != numbers[i-1]+1 { + t.Errorf("numbers = %v, want consecutive with no gap", numbers) + break + } + } +} + +// RULE: a void racing a return of the same sale: one wins, the other is +// refused, and the books stay consistent. +func TestVoidRacingAReturn(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, body := f.saleOf("6", tender("cash", 3593)) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + var wg sync.WaitGroup + var voidStatus, returnStatus int + wg.Add(2) + go func() { + defer wg.Done() + voidStatus = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "racing", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status + }() + go func() { + defer wg.Done() + returnStatus = f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "refund_method": "cash", "reason": "racing", + "lines": []map[string]any{{"line_id": lineID, "quantity": "1", "restock": true}}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status + }() + wg.Wait() + if voidStatus == httpOK && returnStatus == httpOK { + t.Fatal("both the void and the return won") + } + f.assertARInvariants(t) + // the stock is whole either way: voided returns all 6, a return returns + // what it named; both winning is the only corruption + if got := f.stock(); got != "94.0000/0.0000" && got != "100.0000/0.0000" && got != "95.0000/0.0000" { + t.Errorf("stock = %s after the race", got) + } +} + +const httpOK = 200 + +// startSaleOn opens a cart on a named register. +func (f *fixture) startSaleOn(register string) string { + f.t.Helper() + r := f.do("POST", "/api/v1/pos/transactions", map[string]any{"register_id": register}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(f.t)) + if r.status != 201 { + f.t.Fatalf("start sale on %s = %d: %s", register, r.status, r.raw) + } + return str(f.t, r.body, "id") +} + +func (f *fixture) addLineOn(saleID string, line map[string]any) (string, error) { + f.t.Helper() + r := f.addLine(saleID, line) + if r.status != httpOK { + return "", fmt.Errorf("add line = %d: %s", r.status, r.raw) + } + return str(f.t, r.body, "revision"), nil +} + +func (f *fixture) completeSaleOn(register, saleID string, tenders ...map[string]any) resp { + f.t.Helper() + cur := f.getSale(f.t, saleID) + return f.do("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + map[string]any{"tenders": tenders, "revision": num(f.t, cur, "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(f.t)) +} From d7fffde4218b310a6623ecc6d908bff9e6c6e344 Mon Sep 17 00:00:00 2001 From: colton Date: Fri, 9 Oct 2026 22:33:12 -0700 Subject: [PATCH 004/113] C2-5: the pos transaction proofs green The four proofs fixed as written in the WIP commit: the rollback counts run against the seeded tables the fixture shares, so the invoice, payment and entry counts are taken before the failed act and asserted unchanged after it (the tender and outbox counts name the sale and stay absolute); the walk-in customer id is scanned as text (a uuid column scanned into any is raw bytes); and the add line helper returns only the error, the revision being a JSON number on the wire. All four proofs pass under the race detector. Signed-off-by: colton --- core/internal/pos/tx_test.go | 46 +++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/core/internal/pos/tx_test.go b/core/internal/pos/tx_test.go index 2f062a6e1..64bfbff67 100644 --- a/core/internal/pos/tx_test.go +++ b/core/internal/pos/tx_test.go @@ -29,21 +29,30 @@ func TestFailingEventWriteRollsTheSaleBack(t *testing.T) { }) saleID := f.startSale(nil) f.addLine(saleID, f.productLine("3")) + // the table wide counts before the attempt: the seeded database shares + // these tables, so the proof is that the failed act moves none of them. + invoicesBefore := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`) + paymentsBefore := countOf(t, f.db, `SELECT count(*) FROM payments`) + entriesBefore := countOf(t, f.db, `SELECT count(*) FROM gl_journal_entries`) r := f.completeSale(saleID, tender("cash", 3266)) if r.status == httpOK { t.Fatal("the sale completed with a failing event write") } // nothing stayed: no invoice, no payment, no tender, the stock unmoved, // the sale still open. - for sql, want := range map[string]int64{ - `SELECT count(*) FROM invoices WHERE order_id IS NULL`: 0, - `SELECT count(*) FROM payments`: 0, - `SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`: 0, - `SELECT count(*) FROM gl_journal_entries`: 0, - `SELECT count(*) FROM events_outbox WHERE entity_type = 'pos_transaction'`: 0, + for _, c := range []struct { + sql string + want int64 + args []any + }{ + {`SELECT count(*) FROM invoices WHERE order_id IS NULL`, invoicesBefore, nil}, + {`SELECT count(*) FROM payments`, paymentsBefore, nil}, + {`SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`, 0, []any{saleID}}, + {`SELECT count(*) FROM gl_journal_entries`, entriesBefore, nil}, + {`SELECT count(*) FROM events_outbox WHERE entity_type = 'pos_transaction'`, 0, nil}, } { - if got := countOf(t, f.db, sql, saleID); got != want { - t.Errorf("%s = %d, want %d", sql, got, want) + if got := countOf(t, f.db, c.sql, c.args...); got != c.want { + t.Errorf("%s = %d, want %d", c.sql, got, c.want) } } if got := f.stock(); got != "100.0000/0.0000" { @@ -56,8 +65,8 @@ func TestFailingEventWriteRollsTheSaleBack(t *testing.T) { // number the rolled back sale would have taken. f.events.fail = "" saleID2, _ := f.saleOf("1", tender("cash", 599)) - if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != 1 { - t.Errorf("%d invoices after the retry, want 1", got) + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != invoicesBefore+1 { + t.Errorf("%d invoices after the retry, want %d", got, invoicesBefore+1) } _ = saleID2 @@ -141,7 +150,7 @@ func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { go func(i int, reg string) { defer wg.Done() saleID := f.startSaleOn(reg) - if _, err := f.addLineOn(saleID, f.productLine("1")); err != nil { + if err := f.addLineOn(saleID, f.productLine("1")); err != nil { errs[i] = err return } @@ -154,10 +163,15 @@ func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - walkIn := f.scalar(`SELECT id FROM customers WHERE account_number = 'WALK-IN'`).(uuid.UUID) + var walkIn string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT id::text FROM customers WHERE account_number = 'WALK-IN'`).Scan(&walkIn); err != nil { + f.t.Errorf("walk-in customer: %v", err) + return + } for i := 0; i < 3; i++ { _ = f.do("POST", "/api/v1/payments", map[string]any{ - "customer_id": walkIn.String(), "method": "cash", "amount_cents": 100, + "customer_id": walkIn, "method": "cash", "amount_cents": 100, "received_on": "2030-01-01", }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) } @@ -243,13 +257,13 @@ func (f *fixture) startSaleOn(register string) string { return str(f.t, r.body, "id") } -func (f *fixture) addLineOn(saleID string, line map[string]any) (string, error) { +func (f *fixture) addLineOn(saleID string, line map[string]any) error { f.t.Helper() r := f.addLine(saleID, line) if r.status != httpOK { - return "", fmt.Errorf("add line = %d: %s", r.status, r.raw) + return fmt.Errorf("add line = %d: %s", r.status, r.raw) } - return str(f.t, r.body, "revision"), nil + return nil } func (f *fixture) completeSaleOn(register, saleID string, tenders ...map[string]any) resp { From b161f38d53bdc34d0e73db21952b4f745f4cf9ff Mon Sep 17 00:00:00 2001 From: colton Date: Fri, 9 Oct 2026 22:44:24 -0700 Subject: [PATCH 005/113] C2-5: migration 105 proved on legacy rows, down and up The migration test builds the schema to 104 in a scratch database, writes the base commit's shape (two sales with old columns, old lines, cash and card tenders, an ACCOUNT return carrying its GL reversal entry and its line, a cash return), applies 105 and reads every backfill back: the POS- numbers in (created_at, id) order with the raw insert numbered past the maximum, the lines' shared shape with the per type CHECKs biting (a text line with a price, a zero pair side, a double discount), the RTN- numbers, the ACCOUNT return as an OPEN credit memo carrying the return's own entry with its lines mirrored negative and no new journal or subledger row anywhere, and the walk-in customer with its setting. Three migration bugs it found, fixed: the guarded constraint adds caught duplicate_object where Postgres raises duplicate_table, so a second apply died on the first constraint; the pair and price unit backfills clobbered the null pair of a TEXT line on re-apply and died on the shape CHECK (they now skip TEXT lines); and the down deleted the migrated memos while the link column's foreign key still stood (the key drops first, the memos go by the up's own reason marker). The down drops the columns, the memo, the text lines and the walk-in customer, returns unit_price to scale 2, and the up applies again after it with nothing duplicated. The seeded database leg ran on the container: migrations to 104, the demo seed, then 105, which lands the walk-in customer beside the seeded ones. Signed-off-by: colton --- core/internal/pos/migration105_test.go | 364 ++++++++++++++++++ core/migrations/105_pos_wire_contract.sql | 32 +- .../down/105_pos_wire_contract_down.sql | 11 +- 3 files changed, 387 insertions(+), 20 deletions(-) create mode 100644 core/internal/pos/migration105_test.go diff --git a/core/internal/pos/migration105_test.go b/core/internal/pos/migration105_test.go new file mode 100644 index 000000000..4678cbbb5 --- /dev/null +++ b/core/internal/pos/migration105_test.go @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// Migration 105 on rows that exist (recipe step 3, ADR 0005 section 13's +// C2-5 list): the schema up to 104 is built in a scratch database of its +// own, legacy rows are written in the shape the base commit left them, +// then 105 is applied and each backfill is read back. The historic +// ACCOUNT return becomes an OPEN credit memo carrying the return's own +// entry with no new journal or subledger row; the down reverses the steps +// (the memo goes, the walk-in customer goes, the columns go) and the up +// applies again after the down. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/jackc/pgx/v5" +) + +func mig105ScratchDB(t *testing.T) (*pgx.Conn, string) { + t.Helper() + db := testutil.RequireDB(t) + base, err := url.Parse(db.Pool.Config().ConnString()) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + admin, err := pgx.Connect(ctx, base.String()) + if err != nil { + t.Fatal(err) + } + suffix := make([]byte, 6) + if _, err := rand.Read(suffix); err != nil { + t.Fatal(err) + } + name := "gv1_c25mig_" + hex.EncodeToString(suffix) + if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, name)); err != nil { + admin.Close(ctx) + t.Skipf("cannot create a scratch database: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE %q WITH (FORCE)`, name)) + admin.Close(context.Background()) + }) + + scratch := *base + scratch.Path = "/" + name + conn, err := pgx.Connect(ctx, scratch.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn, scratch.String() +} + +func mig105Files(t *testing.T) (before []string, target, down string) { + t.Helper() + all, err := filepath.Glob("../../migrations/*.sql") + if err != nil || len(all) == 0 { + t.Fatalf("no migrations found: %v", err) + } + sort.Strings(all) + for _, f := range all { + base := filepath.Base(f) + switch { + case strings.HasPrefix(base, "105_"): + target = f + case base < "105_": + before = append(before, f) + } + } + if target == "" { + t.Fatal("migration 105 not found") + } + down = "../../migrations/down/105_pos_wire_contract_down.sql" + if _, err := os.Stat(down); err != nil { + t.Fatal("migration 105 down not found") + } + return before, target, down +} + +func mig105Apply(t *testing.T, conn *pgx.Conn, file string) { + t.Helper() + sql, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), string(sql)); err != nil { + t.Fatalf("applying %s: %v", filepath.Base(file), err) + } +} + +func mig105Scalar[T any](t *testing.T, conn *pgx.Conn, sql string, args ...any) T { + t.Helper() + var v T + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&v); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return v +} + +const mig105Branch = `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` + +// mig105Legacy writes the base commit's shape: two sales in the old column +// set (one for a customer whose currency is null, so the default fills it), +// two old lines, a cash and a card tender, a return refunded to ACCOUNT +// whose GL reversal entry exists, its line, and a cash return for no +// customer. +func mig105Legacy(t *testing.T, conn *pgx.Conn) { + t.Helper() + legacy := ` + INSERT INTO system_settings (key, value) + VALUES ('default_branch_id', (SELECT id::text FROM locations WHERE type = 'BRANCH' ORDER BY created_at, id LIMIT 1)) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + UPDATE pos_registers SET branch_id = ` + mig105Branch + ` WHERE id = 'REG-01'; + INSERT INTO customers (id, name, account_number, primary_branch_id) + VALUES ('00000000-0000-0000-0000-0000000000a1', 'Mig Counter Co', 'MIGCUST', ` + mig105Branch + `); + INSERT INTO products (id, sku, description, uom_primary, base_price, average_unit_cost) + VALUES ('00000000-0000-0000-0000-0000000000f1', 'MIG-2X4', '2x4x8', 'PCS', 5.50, 3.25); + + INSERT INTO pos_transactions (id, register_id, cashier_id, customer_id, subtotal, tax_amount, total, + status, completed_at, created_at, branch_id) + VALUES + ('00000000-0000-0000-0000-0000000000b1', 'REG-01', '00000000-0000-0000-0000-0000000000e1', NULL, + 11.00, 0.98, 11.98, 'COMPLETED', '2026-01-01T11:02:00Z', '2026-01-01T11:01:00Z', ` + mig105Branch + `), + ('00000000-0000-0000-0000-0000000000b2', 'REG-01', '00000000-0000-0000-0000-0000000000e1', + '00000000-0000-0000-0000-0000000000a1', + 5.50, 0.49, 5.99, 'OPEN', NULL, '2026-01-02T11:01:00Z', ` + mig105Branch + `); + + INSERT INTO pos_line_items (id, transaction_id, product_id, description, quantity, uom, unit_price, + line_total, created_at) + VALUES + ('00000000-0000-0000-0000-0000000000c1', '00000000-0000-0000-0000-0000000000b1', + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 2, 'PCS', 5.50, 11.00, '2026-01-01T11:01:10Z'), + ('00000000-0000-0000-0000-0000000000c2', '00000000-0000-0000-0000-0000000000b1', + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 1, 'PCS', 5.50, 5.50, '2026-01-01T11:01:20Z'); + + INSERT INTO pos_tenders (id, transaction_id, method, amount, card_last4, card_brand, created_at) + VALUES + ('00000000-0000-0000-0000-0000000000d1', '00000000-0000-0000-0000-0000000000b1', 'CASH', 2.00, NULL, NULL, '2026-01-01T11:02:00Z'), + ('00000000-0000-0000-0000-0000000000d2', '00000000-0000-0000-0000-0000000000b1', 'CARD', 9.98, '4242', 'VISA', '2026-01-01T11:02:00Z'); + + INSERT INTO gl_journal_entries (id, entry_date, memo, source, source_ref_id, status, currency) + VALUES ('00000000-0000-0000-0000-0000000000a2', '2026-02-01', 'counter account return', 'RETURN', + '00000000-0000-0000-0000-0000000000a3', 'POSTED', 'USD'); + + INSERT INTO pos_returns (id, register_id, customer_id, branch_id, cashier_id, subtotal, tax_amount, + total, refund_method, reason, status, gl_entry_id, created_at) + VALUES + ('00000000-0000-0000-0000-0000000000a3', 'REG-01', '00000000-0000-0000-0000-0000000000a1', + ` + mig105Branch + `, '00000000-0000-0000-0000-0000000000e1', 18.00, 1.59, 19.59, 'ACCOUNT', + 'wrong length', 'COMPLETED', '00000000-0000-0000-0000-0000000000a2', '2026-02-01T12:00:00Z'), + ('00000000-0000-0000-0000-0000000000a4', 'REG-01', NULL, ` + mig105Branch + `, + '00000000-0000-0000-0000-0000000000e1', 5.50, 0.49, 5.99, 'CASH', 'no receipt', 'COMPLETED', NULL, + '2026-02-02T12:00:00Z'); + + INSERT INTO pos_return_lines (id, return_id, product_id, description, quantity, uom, unit_price, + line_total, restock, created_at) + VALUES ('00000000-0000-0000-0000-0000000000a5', '00000000-0000-0000-0000-0000000000a3', + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 3, 'PCS', 6.00, 18.00, TRUE, '2026-02-01T12:00:10Z');` + if _, err := conn.Exec(context.Background(), legacy); err != nil { + t.Fatalf("seed legacy rows: %v", err) + } +} + +func TestMigration105_POSWireContract(t *testing.T) { + conn, _ := mig105ScratchDB(t) + before, target, downFile := mig105Files(t) + for _, f := range before { + mig105Apply(t, conn, f) + } + mig105Legacy(t, conn) + mig105Apply(t, conn, target) + ctx := context.Background() + + // Step 1: the sales numbered POS- in (created_at, id) order, revision 1, + // the currency from the customer or the default, the invoice link column + // present and null. + for id, want := range map[string]string{ + "00000000-0000-0000-0000-0000000000b1": "POS-000001", + "00000000-0000-0000-0000-0000000000b2": "POS-000002", + } { + var number, currency string + var revision int64 + var invoice *string + if err := conn.QueryRow(ctx, `SELECT number, currency, revision, invoice_id FROM pos_transactions WHERE id = $1`, id). + Scan(&number, ¤cy, &revision, &invoice); err != nil { + t.Fatal(err) + } + if number != want || currency != "USD" || revision != 1 || invoice != nil { + t.Errorf("sale %s = (%s, %s, %d, %v); want (%s, USD, 1, nil)", id, number, currency, revision, invoice, want) + } + } + // A raw insert numbers itself past the maximum: the column DEFAULT (the + // currency it names itself; the column carries no default, the pattern + // every cycle 2 document column follows). + if _, err := conn.Exec(ctx, `INSERT INTO pos_transactions (id, register_id, cashier_id, branch_id, currency) + VALUES ('00000000-0000-0000-0000-0000000000b3', 'REG-01', '00000000-0000-0000-0000-0000000000e1', `+mig105Branch+`, 'USD')`); err != nil { + t.Fatal(err) + } + if got := mig105Scalar[string](t, conn, `SELECT number FROM pos_transactions WHERE id = '00000000-0000-0000-0000-0000000000b3'`); got != "POS-000003" { + t.Errorf("raw insert number = %s, want POS-000003", got) + } + + // Step 2: the lines in the shared shape. The taxable flag follows the + // product, the pair is 1 and 1, price_uom the sale unit, the priced + // price the old one, the position the (created_at, id) order. + var pos int + var lineType, priceUOM, priceSource string + var uomQty, priceUOMQty, priced float64 + var taxable bool + if err := conn.QueryRow(ctx, ` + SELECT position, line_type, price_uom, uom_qty::float8, price_uom_qty::float8, priced_unit_price::float8, + price_source, taxable + FROM pos_line_items WHERE id = '00000000-0000-0000-0000-0000000000c2'`). + Scan(&pos, &lineType, &priceUOM, &uomQty, &priceUOMQty, &priced, &priceSource, &taxable); err != nil { + t.Fatal(err) + } + if pos != 1 || lineType != "PRODUCT" || priceUOM != "PCS" || uomQty != 1 || priceUOMQty != 1 || priced != 5.5 || priceSource != "PRICE_LIST" || !taxable { + t.Errorf("line l2 = (%d, %s, %s, %v, %v, %v, %s, %v); want (1, PRODUCT, PCS, 1, 1, 5.5, PRICE_LIST, true)", + pos, lineType, priceUOM, uomQty, priceUOMQty, priced, priceSource, taxable) + } + // The per type CHECKs bite: a text line without amounts is stored, one + // with a price is refused, a product line without quantity is refused, + // and a pair side of zero is refused. + if _, err := conn.Exec(ctx, `INSERT INTO pos_line_items (transaction_id, line_type, description, price_source) + VALUES ('00000000-0000-0000-0000-0000000000b2', 'TEXT', 'delivery note', 'NONE')`); err != nil { + t.Errorf("a text line without amounts is stored: %v", err) + } + for sql, what := range map[string]string{ + `INSERT INTO pos_line_items (transaction_id, line_type, description, unit_price) + VALUES ('00000000-0000-0000-0000-0000000000b2', 'TEXT', 'x', 5.00)`: "a text line with a price", + `INSERT INTO pos_line_items (transaction_id, line_type, product_id, description, uom, unit_price, line_total, uom_qty, price_uom_qty, price_uom) + VALUES ('00000000-0000-0000-0000-0000000000b2', 'PRODUCT', '00000000-0000-0000-0000-0000000000f1', 'x', 'PCS', 5.00, 5.00, 0, 1, 'PCS')`: "a pair side of zero", + `INSERT INTO pos_line_items (transaction_id, product_id, description, uom, unit_price, uom_qty, price_uom_qty, price_uom, discount_percent, discount_amount) + VALUES ('00000000-0000-0000-0000-0000000000b2', '00000000-0000-0000-0000-0000000000f1', 'x', 'PCS', 5.00, 1, 1, 'PCS', 10, 0.5)`: "a discount percent and amount together", + } { + if _, err := conn.Exec(ctx, sql); err == nil { + t.Errorf("the CHECK lets %s in", what) + } + } + // The tender's payment link column exists. + if n := mig105Scalar[int](t, conn, `SELECT count(*) FROM pos_tenders WHERE payment_id IS NULL`); n != 2 { + t.Errorf("%d legacy tenders carry a payment link; want 2 without one", n) + } + + // Step 3: the returns numbered RTN-, and the historic ACCOUNT return an + // OPEN credit memo carrying the return's own entry, its lines mirrored + // negative, and no new journal or subledger row anywhere. + if got := mig105Scalar[string](t, conn, `SELECT number FROM pos_returns WHERE id = '00000000-0000-0000-0000-0000000000a3'`); got != "RTN-000001" { + t.Errorf("return r1 number = %s, want RTN-000001", got) + } + if got := mig105Scalar[string](t, conn, `SELECT number FROM pos_returns WHERE id = '00000000-0000-0000-0000-0000000000a4'`); got != "RTN-000002" { + t.Errorf("return r2 number = %s, want RTN-000002", got) + } + var memoID string + if err := conn.QueryRow(ctx, `SELECT credit_memo_id FROM pos_returns WHERE id = '00000000-0000-0000-0000-0000000000a3'`).Scan(&memoID); err != nil { + t.Fatalf("the ACCOUNT return carries no memo link: %v", err) + } + var status, reason, reasonCode string + var amount, open, subtotal, tax, total float64 + var entry *string + if err := conn.QueryRow(ctx, ` + SELECT m.status, m.reason, m.reason_code, m.amount::float8, m.amount_open::float8, m.subtotal::float8, + m.tax_amount::float8, m.total_amount::float8, m.gl_entry_id + FROM credit_memos m WHERE m.id = $1`, memoID).Scan(&status, &reason, &reasonCode, &amount, &open, &subtotal, &tax, &total, &entry); err != nil { + t.Fatal(err) + } + // The memo's amount keeps the credit memos' own convention (positive + // amount, negative totals). + if status != "OPEN" || reason != "migrated counter account return" || reasonCode != "RETURN" || + amount != 19.59 || open != -19.59 || subtotal != -18 || tax != -1.59 || total != -19.59 || + entry == nil || *entry != "00000000-0000-0000-0000-0000000000a2" { + t.Errorf("the migrated memo = (%s, %s, %s, %v, %v, %v, %v, %v, %v)", status, reason, reasonCode, amount, open, subtotal, tax, total, entry) + } + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM credit_memo_lines WHERE credit_memo_id = $1 AND quantity = -3 AND line_total = -18.00`, memoID); got != 1 { + t.Errorf("%d mirrored memo lines; want 1", got) + } + // The memo date is the return's business date in the branch's zone, and + // the CASH return has no memo. + if got := mig105Scalar[int](t, conn, ` + SELECT count(*) FROM credit_memos m JOIN pos_returns r ON r.credit_memo_id = m.id + WHERE m.memo_date = (r.created_at AT TIME ZONE COALESCE((SELECT l.timezone FROM locations l WHERE l.id = r.branch_id), 'UTC'))::date`); got != 1 { + t.Errorf("%d migrated memos carry the return's local date; want 1", got) + } + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM pos_returns WHERE id = '00000000-0000-0000-0000-0000000000a4' AND credit_memo_id IS NULL`); got != 1 { + t.Errorf("the CASH return gained a memo") + } + // History moved as data: the entry count and the subledger count are + // what the legacy rows left. + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM gl_journal_entries`); got != 1 { + t.Errorf("%d journal entries after the up; want the 1 legacy entry", got) + } + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM customer_transactions`); got != 0 { + t.Errorf("%d subledger rows after the up; the migration writes none", got) + } + + // Step 4: the walk-in customer and its setting, and idempotence: a + // second apply adds nothing. + walkIn := mig105Scalar[string](t, conn, `SELECT id::text FROM customers WHERE account_number = 'WALK-IN'`) + if got := mig105Scalar[string](t, conn, `SELECT value FROM system_settings WHERE key = 'pos.walk_in_customer_id'`); got != walkIn { + t.Errorf("pos.walk_in_customer_id = %s; want the walk-in customer %s", got, walkIn) + } + mig105Apply(t, conn, target) + for sql, want := range map[string]int{ + `SELECT count(*) FROM credit_memos WHERE reason = 'migrated counter account return'`: 1, + `SELECT count(*) FROM customers WHERE account_number = 'WALK-IN'`: 1, + `SELECT count(*) FROM gl_journal_entries`: 1, + } { + if got := mig105Scalar[int](t, conn, sql); got != want { + t.Errorf("after a second apply, %s = %d, want %d", sql, got, want) + } + } + + // The down: the memo and its lines go, the walk-in customer goes (its + // setting first), the new columns go, the line shape returns to the + // base commit's, and the text line the up's shape allowed is deleted. + mig105Apply(t, conn, downFile) + for _, c := range [][2]string{ + {"pos_transactions", "number"}, {"pos_transactions", "currency"}, {"pos_transactions", "revision"}, + {"pos_returns", "number"}, {"pos_returns", "credit_memo_id"}, + {"pos_line_items", "line_type"}, {"pos_tenders", "payment_id"}, + } { + if n := mig105Scalar[int](t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = $1 AND column_name = $2`, c[0], c[1]); n != 0 { + t.Errorf("after the down, %s.%s remains", c[0], c[1]) + } + } + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM credit_memos WHERE reason = 'migrated counter account return'`); got != 0 { + t.Errorf("%d migrated memos survive the down", got) + } + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM customers WHERE account_number = 'WALK-IN'`); got != 0 { + t.Errorf("the walk-in customer survives the down") + } + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM system_settings WHERE key = 'pos.walk_in_customer_id'`); got != 0 { + t.Errorf("the walk-in setting survives the down") + } + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM pos_line_items`); got != 2 { + t.Errorf("%d lines after the down; want the 2 legacy product lines", got) + } + if scale := mig105Scalar[int](t, conn, `SELECT numeric_scale FROM information_schema.columns WHERE table_name = 'pos_line_items' AND column_name = 'unit_price'`); scale != 2 { + t.Errorf("unit_price scale after the down = %d, want 2", scale) + } + + // And up again after the down: the memo returns, the numbers return, + // and nothing duplicates. + mig105Apply(t, conn, target) + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM credit_memos WHERE reason = 'migrated counter account return'`); got != 1 { + t.Errorf("%d migrated memos after the re-up, want 1", got) + } + if got := mig105Scalar[int](t, conn, `SELECT count(DISTINCT number) FROM pos_transactions`); got != mig105Scalar[int](t, conn, `SELECT count(*) FROM pos_transactions WHERE number IS NOT NULL`) { + t.Errorf("the re-up duplicated a sale number") + } +} diff --git a/core/migrations/105_pos_wire_contract.sql b/core/migrations/105_pos_wire_contract.sql index 10b07f73f..c32b6ae66 100644 --- a/core/migrations/105_pos_wire_contract.sql +++ b/core/migrations/105_pos_wire_contract.sql @@ -44,7 +44,7 @@ UPDATE pos_transactions SET currency = 'USD' WHERE currency IS NULL; ALTER TABLE pos_transactions ALTER COLUMN currency SET NOT NULL; DO $$ BEGIN ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_currency_check CHECK (currency ~ '^[A-Z]{3}$'); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS invoice_id UUID NULL REFERENCES invoices (id); @@ -70,13 +70,13 @@ ALTER TABLE pos_transactions ALTER COLUMN number SET DEFAULT 'POS-' || lpad(next ALTER TABLE pos_transactions ALTER COLUMN number SET NOT NULL; DO $$ BEGIN ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_number_key UNIQUE (number); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; DO $$ BEGIN ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_status_check CHECK (status IN ('OPEN', 'HELD', 'COMPLETED', 'VOIDED', 'RETURNED')); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; -- 2. pos_line_items: the shared line columns of ADR 0005 section 2.2. @@ -99,11 +99,11 @@ ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS parent_line_id UUID NULL REF ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS charge_code_id UUID NULL REFERENCES charge_codes (id); ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS sku TEXT; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS price_uom TEXT; -UPDATE pos_line_items SET price_uom = uom WHERE price_uom IS NULL; +UPDATE pos_line_items SET price_uom = uom WHERE price_uom IS NULL AND line_type <> 'TEXT'; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS uom_qty NUMERIC(12, 4); -UPDATE pos_line_items SET uom_qty = 1 WHERE uom_qty IS NULL; +UPDATE pos_line_items SET uom_qty = 1 WHERE uom_qty IS NULL AND line_type <> 'TEXT'; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS price_uom_qty NUMERIC(12, 4); -UPDATE pos_line_items SET price_uom_qty = 1 WHERE price_uom_qty IS NULL; +UPDATE pos_line_items SET price_uom_qty = 1 WHERE price_uom_qty IS NULL AND line_type <> 'TEXT'; ALTER TABLE pos_line_items ALTER COLUMN quantity TYPE NUMERIC(12, 4); ALTER TABLE pos_line_items ALTER COLUMN unit_price TYPE NUMERIC(12, 4); @@ -115,7 +115,7 @@ ALTER TABLE pos_line_items ALTER COLUMN price_source SET NOT NULL; DO $$ BEGIN ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_price_source_check CHECK (price_source IN ('PRICE_LIST', 'QUOTE', 'OVERRIDE', 'MANUAL', 'NONE')); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS override_reason TEXT; @@ -139,12 +139,12 @@ ALTER TABLE pos_line_items ALTER COLUMN line_total DROP NOT NULL; DO $$ BEGIN ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_line_type_check CHECK (line_type IN ('PRODUCT', 'KIT', 'COMPONENT', 'CHARGE', 'TEXT')); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; DO $$ BEGIN ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_pair_positive CHECK ((uom_qty IS NULL AND price_uom_qty IS NULL) OR (uom_qty > 0 AND price_uom_qty > 0)); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; DO $$ BEGIN ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_shape @@ -181,12 +181,12 @@ DO $$ BEGIN AND line_total IS NOT NULL AND charge_code_id IS NOT NULL AND product_id IS NULL) ); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; DO $$ BEGIN ALTER TABLE pos_line_items ADD CONSTRAINT pos_line_items_discount_check CHECK (discount_percent IS NULL OR discount_amount IS NULL); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; -- pos_tenders: the payment each tender became (and, from this item, is). @@ -208,7 +208,7 @@ UPDATE pos_returns SET currency = 'USD' WHERE currency IS NULL; ALTER TABLE pos_returns ALTER COLUMN currency SET NOT NULL; DO $$ BEGIN ALTER TABLE pos_returns ADD CONSTRAINT pos_returns_currency_check CHECK (currency ~ '^[A-Z]{3}$'); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; ALTER TABLE pos_returns ADD COLUMN IF NOT EXISTS credit_memo_id UUID NULL REFERENCES credit_memos (id); @@ -234,7 +234,7 @@ ALTER TABLE pos_returns ALTER COLUMN number SET DEFAULT 'RTN-' || lpad(nextval(' ALTER TABLE pos_returns ALTER COLUMN number SET NOT NULL; DO $$ BEGIN ALTER TABLE pos_returns ADD CONSTRAINT pos_returns_number_key UNIQUE (number); -EXCEPTION WHEN duplicate_object THEN NULL; +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS position INTEGER; @@ -251,11 +251,11 @@ UPDATE pos_return_lines SET line_type = 'PRODUCT' WHERE line_type IS NULL; ALTER TABLE pos_return_lines ALTER COLUMN line_type SET NOT NULL; ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS sku TEXT; ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS price_uom TEXT; -UPDATE pos_return_lines SET price_uom = uom WHERE price_uom IS NULL; +UPDATE pos_return_lines SET price_uom = uom WHERE price_uom IS NULL AND line_type <> 'TEXT'; ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS uom_qty NUMERIC(12, 4); -UPDATE pos_return_lines SET uom_qty = 1 WHERE uom_qty IS NULL; +UPDATE pos_return_lines SET uom_qty = 1 WHERE uom_qty IS NULL AND line_type <> 'TEXT'; ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS price_uom_qty NUMERIC(12, 4); -UPDATE pos_return_lines SET price_uom_qty = 1 WHERE price_uom_qty IS NULL; +UPDATE pos_return_lines SET price_uom_qty = 1 WHERE price_uom_qty IS NULL AND line_type <> 'TEXT'; ALTER TABLE pos_return_lines ALTER COLUMN quantity TYPE NUMERIC(12, 4); ALTER TABLE pos_return_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 4); ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS taxable BOOLEAN; diff --git a/core/migrations/down/105_pos_wire_contract_down.sql b/core/migrations/down/105_pos_wire_contract_down.sql index 9c2121e1e..188335bf6 100644 --- a/core/migrations/down/105_pos_wire_contract_down.sql +++ b/core/migrations/down/105_pos_wire_contract_down.sql @@ -21,12 +21,15 @@ WHERE c.account_number = 'WALK-IN' AND NOT EXISTS (SELECT 1 FROM credit_memos m WHERE m.customer_id = c.id) AND NOT EXISTS (SELECT 1 FROM customer_transactions ct WHERE ct.customer_id = c.id); --- 3. the returns. +-- 3. the returns. The link's foreign key drops first (it blocks the memo +-- delete), the memos are found by the up's own reason marker, and the link +-- column goes last. DELETE FROM pos_line_items WHERE product_id IS NULL; -DELETE FROM credit_memo_lines WHERE credit_memo_id IN (SELECT credit_memo_id FROM pos_returns WHERE credit_memo_id IS NOT NULL); -DELETE FROM credit_memos WHERE reason = 'migrated counter account return' - AND id IN (SELECT credit_memo_id FROM pos_returns WHERE credit_memo_id IS NOT NULL); +ALTER TABLE pos_returns DROP CONSTRAINT IF EXISTS pos_returns_credit_memo_id_fkey; +DELETE FROM credit_memo_lines WHERE credit_memo_id IN + (SELECT id FROM credit_memos WHERE reason = 'migrated counter account return'); +DELETE FROM credit_memos WHERE reason = 'migrated counter account return'; ALTER TABLE pos_returns DROP COLUMN IF EXISTS credit_memo_id; ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS taxable; From d1a12fe2dee09813ed30f889055b3a01bde6bc49 Mon Sep 17 00:00:00 2001 From: colton Date: Fri, 9 Oct 2026 23:51:50 -0700 Subject: [PATCH 006/113] C2-5: the counter's contract fragment, goldens and the wire bugs they found The pos fragment rewritten from the module onto the wire contract: every route's request and response shapes (the sale with its shared salesdoc lines and tenders in cents, the returns, the till session, report and Z report, the sync batch, the typeahead and catalog), the blockers each refusal carries, the revision preconditions on the complete and the void, and the schemas the shape test binds (PosTransaction, PosTransactionSummary, PosLineItem, PosReturn, PosTillSession, the report and sync bodies). The day's sale list is the envelope with an empty page [] in the bytes; the typeahead, catalog, returns list and Z report list keep their bare arrays, the counter's own helper surfaces. Five wire bugs the fresh contract and goldens exposed, fixed: the typeahead and catalog queried a products.is_active column that does not exist and answered 500; a walk-in sale priced its lines through the engine with an empty customer id and answered 500 (the walk-in customer of record prices it now, resolved on the add); the register's open session route answered 200 with a null body when no session was open (it is a 404 now); a fresh sale read back with null lines and tenders (empty arrays now, the lists are never null); and a split tender's two payments read back in a random order, because both carry the same transaction-stable timestamp and the id tie break is random (the order is created_at, method, id now, so the golden is deterministic). The demo seed repoints REG-01 at the Kelowna branch: migration 027 inserts the register first and 066 backfills its branch with MAIN, which carries no default tax rate, and the counter's money path refuses a sale on a branch without one. The goldens re-scripted onto the new shapes: the tenders and floats in cents, the line wrapper, lowercase methods, the revision preconditions (extracted, never counted by hand), the void of an open sale refused, the split tender completion (cash and check against the 12 percent branch rate) and its void, the 428 and the stale revision, the strict query refusals, a step reading the counter's own events back from the feed, and the Z report's payload masked (it embeds the session's own ids and timestamps). The harness sorts a cursor envelope's items as it sorted the legacy data array, logs a refusal's code and blockers beside its status, and keeps the actual transcript beside a drifted golden; six other goldens were re-recorded with it (their committed versions no longer pass: the counter's sales now really consume stock and post events, and the items sort fixed the order of three lists), and the five pos goldens ran stable six times in a row after the tender order fix. Signed-off-by: colton --- core/api/fragments/pos.yaml | 2080 ++++++++--------- core/api/openapi.yaml | 1867 ++++++--------- core/internal/app/seed/seed.go | 8 +- core/internal/characterization/golden_test.go | 8 +- .../internal/characterization/harness_test.go | 20 +- .../scenarios_finance_test.go | 2 +- .../characterization/scenarios_r1b_c_test.go | 146 +- .../testdata/goldens/crm_activity_item.json | 18 +- .../testdata/goldens/location_item.json | 68 +- .../testdata/goldens/pim_media.json | 4 +- .../testdata/goldens/pos.json | 628 ++++- .../testdata/goldens/pos_returns.json | 349 ++- .../testdata/goldens/pos_sync.json | 222 +- .../testdata/goldens/pos_till_close.json | 286 ++- .../testdata/goldens/pos_transactions.json | 1817 +++++++++----- .../goldens/pricing_category_rules.json | 60 +- .../testdata/goldens/product_item.json | 24 +- .../testdata/goldens/reporting_reports.json | 29 +- core/internal/pos/handler.go | 4 + core/internal/pos/repository.go | 9 +- core/internal/pos/service.go | 16 +- 21 files changed, 4287 insertions(+), 3378 deletions(-) diff --git a/core/api/fragments/pos.yaml b/core/api/fragments/pos.yaml index d0436d213..744d01d1d 100644 --- a/core/api/fragments/pos.yaml +++ b/core/api/fragments/pos.yaml @@ -1,1416 +1,1180 @@ # SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 # SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors # -# The pos module, transcribed from internal/pos/handler.go and model.go as they behave -# today: money is int64 cents on responses and float dollars on requests, the list -# wrappers carry nil slices (null when empty), and service failures map to the -# status the handler picks per route (409 till, 422 complete, 400 return, 500 the rest). +# The counter module (POS and till) on the wire contract (ADR 0001; ADR 0005 +# sections 2, 4, 9 to 12 and 14.2, item C2-5): a sale is the cart a register +# builds and then one money moment, everything in integer cents and prices at +# scale 4, the lines in the shared salesdoc shape (product, charge and text +# lines, typed override and discount with audit, kits exploding to +# components), tenders in cents stored net of change, each cash, check or card +# tender a payment applied to the sale's invoice (origin POS, pickup) posted +# with tax and COGS inside the sale's own transaction, an ACCOUNT tender left +# open and subject to the credit check, a void of the completed sale while its +# till session is open reversing exactly what it posted, and a return a credit +# memo created, posted, refunded and optionally restocked in one act. The +# sale's POS- and the return's RTN- numbers are human readable (IN-1.3), the +# status is one lowercase field (IN-1.1), money never floats (IN-1.2), the +# day's list is the envelope with an empty page [] in the bytes (IN-1.4), and +# every refusal is the error envelope (IN-3.7). A machine key is refused on +# every route that resolves a cashier: a cashier must be a user. paths: /api/v1/pos/transactions: post: operationId: posTransactionCreate - tags: - - pos - summary: Start a transaction - description: register_id defaults to REG-01 and a missing cashier_id is replaced by a random UUID. + tags: [pos] + summary: Start a sale + description: >- + Opens a cart on a register for the resolved cashier, attached to the + register's open till session when there is one, at revision 1 with its + POS- number (the gapped series; a rolled back create abandons the + number). register_id defaults REG-01; the customer names the account + the invoice posts to, and its absence means the walk-in customer the + counter books to. The cashier is the JWT's subject; a machine key is + refused 403 ("a cashier must be a user"). parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosTransactionCreate' + $ref: "#/components/schemas/PosTransactionCreateRequest" responses: - '201': - description: The new OPEN transaction, attached to the register's open till session when there - is one. + "201": + description: The new open sale, at revision 1. + headers: + ETag: + description: The document's revision. + schema: {type: string} + Location: + description: The sale's route. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - description: 'The caller''s roles do not include one the route requires, or the caller authenticated - with a machine key: a cashier must be a user.' - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/PosCashierRefusal' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" get: operationId: posTransactionList - tags: - - pos - summary: List transaction summaries + tags: [pos] + summary: List the day's sales + description: >- + The list envelope over one register's day: register_id filters (absent + lists every register's), date is a YYYY-MM-DD that defaults to the + branch's today, status takes one lowercase value (open, held, + completed, voided; an uppercase or unknown value is a 400 naming + status). The page is the whole day newest first, so next_cursor is + null and limit echoes the 50 the route serves; cursor, limit and + include are accepted query names. An empty day is [] in the bytes. parameters: - - $ref: '#/components/parameters/XBranchId' - - name: register_id - in: query - description: Filter to one register. - schema: - type: string - - name: date - in: query - description: The day to list; today when omitted or unparseable. - schema: - type: string - format: date + - name: register_id + in: query + required: false + schema: {type: string} + - name: date + in: query + required: false + schema: {type: string, format: date} + - name: status + in: query + required: false + schema: {type: string, enum: [open, held, completed, voided]} + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/PageLimit" + - $ref: "#/components/parameters/Include" + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Summaries; an empty array when none. + "200": + description: The day's page of sales. content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PosTransactionSummary' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransactionPage" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}: get: operationId: posTransactionGet - tags: - - pos - summary: Get a transaction with its lines and tenders + tags: [pos] + summary: Read a sale + description: The sale with its lines and tenders, the branch wall applied. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The transaction. + "200": + description: The sale. + headers: + ETag: + description: The document's revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/items: post: operationId: posTransactionAddItem - tags: - - pos - summary: Add a line item + tags: [pos] + summary: Add a line to the sale + description: >- + One line in the shared salesdoc shape, parsed under the same rules as + every sales line (quantity a decimal string, the unit from the product + when the line names one, the pair when the price unit differs, an + override or a discount with its reason, a charge by its code, a text + line a note with nothing priced). A kit explodes to its component + lines. Only an open sale takes a line (409 + invalid_state_transition). The sale's revision moves; the response is + the whole sale. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosAddItem' + $ref: "#/components/schemas/PosAddLineRequest" responses: - '200': - description: The transaction with totals recalculated. + "200": + description: The sale with the line added. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/items/{itemId}: delete: operationId: posTransactionRemoveItem - tags: - - pos - summary: Remove a line item + tags: [pos] + summary: Remove a line from the sale + description: >- + Removes the line (a kit's component lines go with their parent). Only + an open sale loses a line (409 invalid_state_transition). The + response is the whole sale. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - name: itemId - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: itemId + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The transaction with totals recalculated. + "200": + description: The sale with the line removed. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/complete: post: operationId: posTransactionComplete - tags: - - pos - summary: Complete a transaction with tenders - description: An empty tenders list answers 400. Every service failure (an under-tendered sale, a - declined card, a transaction not OPEN) answers 422. + tags: [pos] + summary: Tender and complete the sale + description: >- + The money moment, one transaction: stock out through the inventory + write path, the invoice (origin POS, pickup) posted through the AR + core with its tax and COGS legs, each cash, check or card tender a + payment applied to it and stored net (the tendered amount less change, + so the payment is the money kept), an ACCOUNT tender left open on the + invoice and subject to the credit check, the card charged at the + gateway in the sale's currency before the transaction opens, and the + pos_transaction.completed event written last: a failing event, audit + or ledger write rolls the whole sale back (no invoice, payment, stock + move or entry). Tenders must cover the total (change comes only from + cash). Refused with 409 and a blocker: insufficient_tender, + change_from_cash, walk_in_account (an ACCOUNT tender for the walk-in + customer), card_terminal, card_declined, tax_quote_stale, + tax_rate_not_configured, credit_limit, insufficient_stock; 503 when + the tax provider fails before any row is written. The completion + carries the sale's revision (If-Match or the body's revision; 428 + without one, 409 stale_revision on a stale one). parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosComplete' + $ref: "#/components/schemas/PosCompleteRequest" responses: - '200': - description: The COMPLETED transaction. - content: - application/json: - schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - description: >- - The completion failed (under-tendered sale, declined card, - transaction not OPEN; the standard error envelope), or the - Idempotency-Key was reused with a different request (the ADR - 0001 envelope). + "200": + description: The completed sale with its invoice, tenders and change. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/WireError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "428": + $ref: "#/components/responses/WirePreconditionRequired" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailable" /api/v1/pos/transactions/{id}/void: post: operationId: posTransactionVoid - tags: - - pos - summary: Void a transaction + tags: [pos] + summary: Void the completed sale + description: >- + One transaction in section 11's lock order (the sale, its payments, + its invoice): each cash or check payment voided, each card payment's + gateway refund made before the transaction and its application + reversed with the amount recorded as a refund from its unapplied cash, + then the invoice voided, the stock back, the ledger exactly reversed + and the pos_transaction.voided event last. Only while the sale's till + session is open (409 blocker session_closed: after the session closes + it is a return). Refused with 409 and a blocker: session_closed, + card_terminal, card_refund, no_invoice. The void carries the sale's + revision (428, 409 stale_revision). parameters: - - name: id - in: path + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + content: + application/json: + schema: + $ref: "#/components/schemas/PosVoidRequest" responses: - '200': - description: The VOIDED transaction. + "200": + description: The voided sale. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "428": + $ref: "#/components/responses/WirePreconditionRequired" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/products/search: get: operationId: posProductSearch - tags: - - pos - summary: Typeahead product search + tags: [pos] + summary: The counter's typeahead + description: >- + Products matching the q text (sku or description prefix), at most 20, + with the price and on hand the counter needs. A bare array, never + null, the shape this counter's helper surface keeps (the product + module owns the data). q empty answers []. parameters: - - $ref: '#/components/parameters/XBranchId' - - name: q - in: query - description: SKU, description or barcode. An empty or one character query answers an empty array. - schema: - type: string + - name: q + in: query + required: false + schema: {type: string} + - name: limit + in: query + required: false + schema: {type: integer} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Up to 20 matches; a bare array. + "200": + description: The matching products. content: application/json: schema: type: array items: - $ref: '#/components/schemas/PosSearchResult' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosSearchResult" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/sync: post: - operationId: posSync - tags: - - pos - summary: Replay offline transactions - description: A missing batch_id or an empty items list answers 400. + operationId: posSyncOffline + tags: [pos] + summary: Replay a batch of offline sales + description: >- + Each sale in the batch is replayed through the same completion path + (stock, invoice, payments, ledger, event) keyed by its client_id: a + replayed sale is a duplicate, not an error. A sale whose tax provider + call fails is never rejected (the one exception to the provider rule, + ADR 0005 section 3): it stays pending in the sync log and the sync + retries it; errors lists what could not complete and pending what + waits. parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosSyncRequest' + $ref: "#/components/schemas/PosSyncRequest" responses: - '200': - description: The batch result. + "200": + description: The batch's outcome. content: application/json: schema: - $ref: '#/components/schemas/PosSyncResponse' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosSyncResponse" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/catalog: get: - operationId: posCatalog - tags: - - pos - summary: Product catalog for the offline cache + operationId: posCatalogGet + tags: [pos] + summary: The offline catalog cache + description: >- + Every saleable product with its price and on hand for the offline + register cache. A bare array, never null, like the typeahead. parameters: - - $ref: '#/components/parameters/XBranchId' + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The whole catalog as a bare array. + "200": + description: The catalog. content: application/json: schema: type: array items: - $ref: '#/components/schemas/PosCatalogProduct' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosCatalogProduct" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/open: post: operationId: posTillOpen - tags: - - pos - summary: Open a till session - description: register_id defaults to REG-01. Every service failure, such as a session already open - on the register, answers 409. + tags: [pos] + summary: Open the drawer + description: >- + Opens a till session for the resolved cashier on a register with its + opening float in cents; register_id defaults REG-01. A register with + an open session is refused (409, a session is already open). The + cashier is the JWT's subject; a machine key is refused 403. Writes + till.opened. parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosTillOpen' + $ref: "#/components/schemas/PosTillOpenRequest" responses: - '201': - description: The OPEN session. + "201": + description: The open session. + headers: + Location: + description: The session's report route. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTillSession' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - description: 'The caller''s roles do not include one the route requires, or the caller authenticated - with a machine key: a cashier must be a user.' - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/PosCashierRefusal' - '409': - $ref: '#/components/responses/ConflictEither' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTillSession" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/current: get: operationId: posTillCurrent - tags: - - pos - summary: The open till session of a register + tags: [pos] + summary: The register's open session + description: >- + The register's open till session; register_id defaults REG-01. 404 + when the register has none. parameters: - - $ref: '#/components/parameters/XBranchId' - - name: register_id - in: query - description: Defaults to REG-01. - schema: - type: string + - name: register_id + in: query + required: false + schema: {type: string} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The open session, or null when none. + "200": + description: The open session. content: application/json: schema: - $ref: '#/components/schemas/PosTillCurrent' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTillSession" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/report: get: operationId: posTillReport - tags: - - pos - summary: Live X report of a session - description: An unknown session answers 500. + tags: [pos] + summary: The session's live report + description: >- + The X report: the session with its sales count, totals, change and + the tendered and expected cash by method, expected being the sum of + the session's cash payments (already net of change) plus the opening + float less cash refunds. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': + "200": description: The report. content: application/json: schema: - $ref: '#/components/schemas/PosTillReport' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTillReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/close: post: operationId: posTillClose - tags: - - pos - summary: Close a till session - description: Every service failure, such as a session not OPEN, answers 409. + tags: [pos] + summary: Close the drawer + description: >- + Closes the session on the counted cash: the over or short (counted + less expected, expected being the session's cash payments net of + change plus the float less cash refunds) is booked to 5030 inside the + close's own transaction, the Z report snapshot is written and + till.closed is the last statement. counted_by_method names the + counted cents per lowercase method (cash, check, card, account); a + refusal rolls everything back. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosTillClose' + $ref: "#/components/schemas/PosTillCloseRequest" responses: - '200': - description: The closing Z report. + "200": + description: The closing report. content: application/json: schema: - $ref: '#/components/schemas/PosTillReport' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/ConflictEither' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' + $ref: "#/components/schemas/PosTillReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/zreport: get: operationId: posTillZReport - tags: - - pos - summary: The Z report of a closed session + tags: [pos] + summary: The session's frozen Z report + description: >- + The immutable snapshot written once at the close, its payload the + frozen closing report. 404 before the session closed. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The frozen Z snapshot. + "200": + description: The Z report. content: application/json: schema: - $ref: '#/components/schemas/PosZReport' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/PosZReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/zreports: get: operationId: posZReportList - tags: - - pos - summary: List Z reports + tags: [pos] + summary: The day's Z reports + description: >- + The Z reports of one register's day (register_id filters, date is a + YYYY-MM-DD that defaults to the branch's today), newest first. A bare + array, never null. parameters: - - $ref: '#/components/parameters/XBranchId' - - name: register_id - in: query - schema: - type: string - - name: date - in: query - description: An unparseable date is ignored. - schema: - type: string - format: date + - name: register_id + in: query + required: false + schema: {type: string} + - name: date + in: query + required: false + schema: {type: string, format: date} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Z reports wrapped in an object. + "200": + description: The day's Z reports. content: application/json: schema: - $ref: '#/components/schemas/PosZReportList' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + type: array + items: + $ref: "#/components/schemas/PosZReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/returns: post: operationId: posReturnCreate - tags: - - pos - summary: Record a return and refund - description: register_id defaults to REG-01. Every service failure, including validation, answers - 400. + tags: [pos] + summary: Return goods at the counter + description: >- + A return is a credit memo created and posted in one act with its + restock lines: the memo credits the revenue, reverses the tax and, + where the lines restock, relieves COGS back and returns the stock, + refunded in cash (out of the drawer, counted against the session's + expected) or card (the gateway refund before the transaction) or left + as account credit (the memo stays open). Lines name the sale line + they return (its price and cost follow) or stand alone with product, + quantity and price; quantities are positive on the request and + negative on the read. The RTN- number and the pos_return.completed + event land in the same transaction. Refused with 409 and a blocker: + tax_rate_not_configured, card_terminal, card_refund. The cashier is + the JWT's subject; a machine key is refused 403. parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosReturnRequest' + $ref: "#/components/schemas/PosReturnRequest" responses: - '201': - description: The return with its lines. + "201": + description: The completed return. + headers: + Location: + description: The return's route. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosReturn' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - description: 'The caller''s roles do not include one the route requires, or the caller authenticated - with a machine key: a cashier must be a user.' - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/PosCashierRefusal' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosReturn" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" get: operationId: posReturnList - tags: - - pos - summary: List returns + tags: [pos] + summary: List the day's returns + description: >- + The day's returns (register_id filters, date is a YYYY-MM-DD that + defaults to the branch's today), newest first. A bare array, never + null. parameters: - - $ref: '#/components/parameters/XBranchId' - - name: register_id - in: query - schema: - type: string - - name: date - in: query - description: An unparseable date is ignored. - schema: - type: string - format: date + - name: register_id + in: query + required: false + schema: {type: string} + - name: date + in: query + required: false + schema: {type: string, format: date} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Returns wrapped in an object. + "200": + description: The day's returns. content: application/json: schema: - $ref: '#/components/schemas/PosReturnList' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + type: array + items: + $ref: "#/components/schemas/PosReturn" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/returns/{id}: get: operationId: posReturnGet - tags: - - pos - summary: Get a return with its lines + tags: [pos] + summary: Read a return + description: The return with its lines and its credit memo link. parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': + "200": description: The return. content: application/json: schema: - $ref: '#/components/schemas/PosReturn' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/PosReturn" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" components: schemas: - PosCashierRefusal: - type: object - description: The refusal body for a machine key on a cashier route. The code is lower case, unlike - the standard envelope. - required: - - error - - meta - properties: - error: - type: object - required: - - code - - message - properties: - code: - type: string - enum: - - forbidden - message: - type: string - meta: - type: object - required: - - request_id - properties: - request_id: - type: string - PosTransactionStatus: + PosStatus: type: string - enum: - - OPEN - - COMPLETED - - VOIDED - - RETURNED - - HELD - PosLineItem: + enum: [open, held, completed, voided] + PosTenderMethod: + type: string + enum: [cash, check, card, account] + PosRefundMethod: + type: string + enum: [cash, card, account] + PosTillStatus: + type: string + enum: [open, closed] + PosTransactionCreateRequest: type: object - description: POSLineItem. - required: - - id - - transaction_id - - product_id - - description - - quantity - - uom - - unit_price - - line_total - - created_at properties: - id: - type: string - format: uuid - transaction_id: - type: string - format: uuid - product_id: - type: string - format: uuid - description: - type: string - quantity: - type: number - uom: - type: string - unit_price: - type: integer - format: int64 - description: Cents. - line_total: - type: integer - format: int64 - description: Cents. - created_at: - type: string - format: date-time - PosTender: + register_id: {type: string, description: "Defaults REG-01."} + cashier_id: {type: [string, "null"], format: uuid, description: "Defaults the JWT's subject."} + customer_id: {type: [string, "null"], format: uuid, description: "Absent means the walk-in customer."} + PosAddLineRequest: type: object - description: POSTender. The reference and card fields are omitted when empty. - required: - - id - - transaction_id - - method - - amount - - created_at + required: [line] properties: - id: - type: string - format: uuid - transaction_id: - type: string - format: uuid - method: - type: string - description: CASH, CARD, CHECK or ACCOUNT. - amount: - type: integer - format: int64 - description: Cents. - reference: - type: string - card_last4: - type: string - card_brand: - type: string - gateway_tx_id: - type: string - auth_code: - type: string - created_at: - type: string - format: date-time - PosTransaction: + line: + $ref: "#/components/schemas/PosLineRequest" + PosCompleteRequest: type: object - description: POSTransaction. The customer, till session, completion time, sync fields and the lines - and tenders are omitted when empty; only the read and mutation routes after the first line fill - the lists. - required: - - id - - branch_id - - register_id - - cashier_id - - subtotal - - tax_amount - - total - - change_due - - status - - created_at + required: [tenders] properties: - id: - type: string - format: uuid - branch_id: - type: string - format: uuid - register_id: - type: string - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - subtotal: - type: integer - format: int64 - description: Cents. - tax_amount: - type: integer - format: int64 - description: Cents. - total: - type: integer - format: int64 - description: Cents. - change_due: - type: integer - format: int64 - description: Cents, set at completion. - till_session_id: - type: string - format: uuid - status: - $ref: '#/components/schemas/PosTransactionStatus' - completed_at: - type: string - format: date-time - created_at: - type: string - format: date-time - synced_from: - type: string - description: Absent for a live sale, offline-v1 for a synced one. - client_created_at: - type: string - format: date-time - line_items: - type: array - items: - $ref: '#/components/schemas/PosLineItem' tenders: type: array items: - $ref: '#/components/schemas/PosTender' - PosTransactionSummary: + $ref: "#/components/schemas/PosTenderRequest" + picked_up_by: {type: [string, "null"], description: "Who collected the basket, at most 200 characters."} + revision: {type: integer, format: int64, minimum: 1, description: "The sale's revision; or send If-Match."} + PosTenderRequest: type: object - description: TransactionSummary. - required: - - id - - register_id - - total - - status - - item_count - - created_at + required: [method, amount_cents] properties: - id: - type: string - format: uuid - register_id: - type: string - total: - type: integer - format: int64 - description: Cents. - status: - $ref: '#/components/schemas/PosTransactionStatus' - item_count: - type: integer - completed_at: - type: string - format: date-time - created_at: - type: string - format: date-time - PosSearchResult: + method: {$ref: "#/components/schemas/PosTenderMethod"} + amount_cents: {type: integer, format: int64, minimum: 1} + reference: {type: [string, "null"], description: "The check number or reference."} + token_id: {type: [string, "null"], description: "The card token a card tender charges."} + PosVoidRequest: type: object - description: QuickSearchResult. - required: - - product_id - - sku - - description - - unit_price - - uom - - in_stock + required: [reason] properties: - product_id: - type: string - format: uuid - sku: - type: string - description: - type: string - unit_price: - type: number - description: Float dollars. - uom: - type: string - in_stock: - type: number - PosCatalogProduct: + reason: {type: string} + revision: {type: integer, format: int64, minimum: 1, description: "The sale's revision; or send If-Match."} + PosReturnRequest: type: object - description: CatalogProduct. - required: - - product_id - - sku - - description - - price - - uom - - in_stock + required: [reason, lines] properties: - product_id: - type: string - format: uuid - sku: - type: string - description: - type: string - price: - type: number - description: Float dollars. - uom: - type: string - in_stock: - type: number - PosTillSession: + register_id: {type: string, description: "Defaults REG-01."} + original_sale_id: {type: [string, "null"], format: uuid, description: "The sale the goods came from; absent is a no-receipt return."} + customer_id: {type: [string, "null"], format: uuid} + refund_method: {$ref: "#/components/schemas/PosRefundMethod"} + reason: {type: string} + gateway_tx_id: {type: [string, "null"], description: "Names the gateway refund for a card return."} + lines: + type: array + items: + $ref: "#/components/schemas/PosReturnLineRequest" + PosReturnLineRequest: type: object - description: TillSession. Everything set at close, and the branch, is omitted when empty. - required: - - id - - register_id - - cashier_id - - status - - opening_float - - opened_at - - notes + required: [quantity] properties: - id: - type: string - format: uuid - register_id: - type: string - branch_id: - type: string - format: uuid - cashier_id: - type: string - format: uuid - status: - type: string - enum: - - OPEN - - CLOSED - opening_float: - type: integer - format: int64 - description: Cents. - opened_at: - type: string - format: date-time - closed_at: - type: string - format: date-time - expected_by_method: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - counted_by_method: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - over_short: - type: integer - format: int64 - description: Cents; negative means short. - gl_entry_id: - type: string - format: uuid - notes: - type: string - PosTillCurrent: + product_id: {type: [string, "null"], format: uuid, description: "Required when the line names no sale line."} + line_id: {type: [string, "null"], format: uuid, description: "The sale line returned; its price and cost follow."} + description: {type: [string, "null"]} + quantity: {type: string, description: "A positive decimal string with at most 4 fraction digits."} + unit_price_ten_thousandths: {type: integer, format: int64, minimum: 0, description: "Required when the line names no sale line."} + restock: {type: boolean, description: "Default true; damaged goods stay out of stock."} + PosTillOpenRequest: type: object - description: The current session wrapper. - required: - - session properties: - session: - oneOf: - - $ref: '#/components/schemas/PosTillSession' - - type: 'null' - PosTillReport: + register_id: {type: string, description: "Defaults REG-01."} + opening_float_cents: {type: integer, format: int64, minimum: 0} + PosTillCloseRequest: type: object - description: TillReport. - required: - - session - - sale_count - - sales_total - - tax_total - - change_given - - tendered_by_method - - expected_by_method + required: [counted_by_method] properties: - session: - $ref: '#/components/schemas/PosTillSession' - sale_count: - type: integer - sales_total: - type: integer - format: int64 - description: Cents. - tax_total: - type: integer - format: int64 - description: Cents. - change_given: - type: integer - format: int64 - description: Cents. - tendered_by_method: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - expected_by_method: + counted_by_method: type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - PosZReport: - type: object - description: ZReport. - required: - - id - - till_session_id - - register_id - - over_short - - payload - - generated_at - properties: - id: - type: string - format: uuid - till_session_id: - type: string - format: uuid - register_id: - type: string - branch_id: - type: string - format: uuid - over_short: - type: integer - format: int64 - description: Cents. - payload: - allOf: - - $ref: '#/components/schemas/PosTillReport' - description: The frozen closing report, a JSON object. - generated_at: - type: string - format: date-time - PosZReportList: - type: object - required: - - z_reports - properties: - z_reports: - type: - - array - - 'null' - items: - $ref: '#/components/schemas/PosZReport' - PosReturnLine: - type: object - description: POSReturnLine. - required: - - id - - return_id - - product_id - - description - - quantity - - uom - - unit_price - - line_total - - restock - properties: - id: - type: string - format: uuid - return_id: - type: string - format: uuid - product_id: - type: string - format: uuid - description: - type: string - quantity: - type: number - uom: - type: string - unit_price: - type: integer - format: int64 - description: Cents. - line_total: - type: integer - format: int64 - description: Cents. - restock: - type: boolean - PosReturn: + additionalProperties: {type: integer, format: int64, minimum: 0} + description: "The counted cents per lowercase method (cash, check, card, account)." + notes: {type: [string, "null"]} + PosSyncRequest: type: object - description: POSReturn. The till session, original transaction, customer, branch, ledger entry and - lines are omitted when empty. - required: - - id - - register_id - - cashier_id - - subtotal - - tax_amount - - total - - refund_method - - reason - - status - - created_at + required: [batch_id, items] properties: - id: - type: string - format: uuid - register_id: - type: string - till_session_id: - type: string - format: uuid - original_transaction_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - branch_id: - type: string - format: uuid - cashier_id: - type: string - format: uuid - subtotal: - type: integer - format: int64 - description: Cents. - tax_amount: - type: integer - format: int64 - description: Cents. - total: - type: integer - format: int64 - description: Cents. - refund_method: - type: string - description: CASH, CARD or ACCOUNT. - reason: - type: string - status: - type: string - gl_entry_id: - type: string - format: uuid - created_at: - type: string - format: date-time - lines: + batch_id: {type: string} + register_id: {type: string, description: "Defaults REG-01."} + items: type: array items: - $ref: '#/components/schemas/PosReturnLine' - PosReturnList: + $ref: "#/components/schemas/PosSyncSale" + PosSyncSale: type: object - required: - - returns + required: [client_id, items, tenders] properties: - returns: - type: - - array - - 'null' + client_id: {type: string, format: uuid, description: "The offline client's id for the sale; a replay is a duplicate, not an error."} + cashier_id: {type: [string, "null"], format: uuid} + customer_id: {type: [string, "null"], format: uuid} + items: + type: array items: - $ref: '#/components/schemas/PosReturn' - PosSyncError: - type: object - description: SyncError. - required: - - client_id - - reason - properties: - client_id: - type: string - reason: - type: string - PosSyncResponse: - type: object - description: OfflineSyncResponse. errors is omitted when empty. - required: - - batch_id - - synced_count - - duplicate_count - - error_count - properties: - batch_id: - type: string - synced_count: - type: integer - duplicate_count: - type: integer - error_count: - type: integer - errors: + $ref: "#/components/schemas/PosLineRequest" + tenders: type: array items: - $ref: '#/components/schemas/PosSyncError' - PosTransactionCreate: - type: object - properties: - register_id: - type: string - description: Defaults to REG-01. - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - PosAddItem: + $ref: "#/components/schemas/PosTenderRequest" + client_created_at: {type: [string, "null"], format: date-time, description: "When the register made the sale offline."} + PosLineRequest: type: object - required: - - product_id - - quantity + description: >- + One sales line in the shared shape of ADR 0005 section 2.2, the same + parse everywhere a sales line is written: a product line (quantity and + unit from the product when it names one), a charge line (charge_code), + or a text line (a note, nothing priced); an override or a discount + carries its reason and is audited. properties: - product_id: - type: string - format: uuid - quantity: - type: number - uom: - type: string - PosTenderInput: + id: {type: [string, "null"], format: uuid, description: "The client's id for the line, echoed on created lines."} + line_type: {type: [string, "null"], enum: [product, kit, component, charge, text], description: "Defaults product; a kit explodes to its components."} + product_id: {type: [string, "null"], format: uuid} + charge_code: {type: [string, "null"], description: "The charge code a charge line names."} + sku: {type: [string, "null"]} + description: {type: [string, "null"]} + quantity: {type: [string, "null"], description: "A decimal string with at most 4 fraction digits; null only on a text line."} + uom: {type: [string, "null"], description: "Defaults the product's unit."} + price_uom: {type: [string, "null"], description: "Defaults uom; a different one requires the pair."} + uom_qty: {type: [string, "null"], description: "The pair side in the sale unit; both sides together, both positive."} + price_uom_qty: {type: [string, "null"], description: "The pair side in the price unit."} + unit_price_ten_thousandths: {type: [integer, "null"], format: int64, minimum: 0, description: "Defaults the product's price."} + override_reason: {type: [string, "null"], description: "Required when the price is not the product's."} + discount_percent: {type: [string, "null"], description: "A percent or a cents amount, never both."} + discount_cents: {type: [integer, "null"], format: int64} + discount_reason: {type: [string, "null"]} + PosTransactionSummary: type: object - description: AddTenderRequest. - required: - - method - - amount + description: A sale's head, a list item. + required: [id, number, revision, branch_id, register_id, cashier_id, customer_id, currency, total_cents, status, invoice_id, completed_at, created_at, item_count] properties: - method: - type: string - description: CASH, CARD, CHECK or ACCOUNT. - amount: - type: number - description: Float dollars. - reference: - type: string - token_id: - type: string - description: Card token from the payment processor. - PosComplete: + id: {type: string, format: uuid} + number: {type: string, description: "POS-000001, human readable."} + revision: {type: integer, format: int64} + branch_id: {type: string, format: uuid} + register_id: {type: string} + cashier_id: {type: string, format: uuid} + customer_id: {type: [string, "null"], format: uuid} + currency: {type: string, description: "ISO 4217, the customer's or the dealer default."} + total_cents: {type: integer, format: int64} + status: {$ref: "#/components/schemas/PosStatus"} + invoice_id: {type: [string, "null"], format: uuid, description: "Set by the completion."} + completed_at: {type: [string, "null"], format: date-time} + created_at: {type: string, format: date-time} + item_count: {type: integer} + PosTransaction: type: object - required: - - tenders + description: "The full sale: the head with its lines and tenders." + required: [id, number, revision, branch_id, register_id, cashier_id, customer_id, currency, subtotal_cents, tax_cents, total_cents, change_cents, till_session_id, status, invoice_id, completed_at, created_at, lines, tenders] properties: + id: {type: string, format: uuid} + number: {type: string, description: "POS-000001, human readable."} + revision: {type: integer, format: int64} + branch_id: {type: string, format: uuid} + register_id: {type: string} + cashier_id: {type: string, format: uuid} + customer_id: {type: [string, "null"], format: uuid} + currency: {type: string, description: "ISO 4217, the customer's or the dealer default."} + subtotal_cents: {type: integer, format: int64} + tax_cents: {type: integer, format: int64} + total_cents: {type: integer, format: int64} + change_cents: {type: integer, format: int64, description: "The change given, never subtracted from the payments a second time."} + till_session_id: {type: [string, "null"], format: uuid} + status: {$ref: "#/components/schemas/PosStatus"} + invoice_id: {type: [string, "null"], format: uuid, description: "The invoice the completion posted."} + completed_at: {type: [string, "null"], format: date-time} + created_at: {type: string, format: date-time} + lines: + type: array + items: + $ref: "#/components/schemas/PosLineItem" tenders: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosTenderInput' - PosOfflineTransaction: + $ref: "#/components/schemas/PosTender" + PosTransactionPage: type: object - description: OfflineTransaction. - required: - - client_id - - register_id - - cashier_id - - items - - tenders - - client_created_at + required: [items, next_cursor, limit] properties: - client_id: - type: string - format: uuid - register_id: - type: string - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid items: type: array items: - $ref: '#/components/schemas/PosAddItem' - tenders: - type: array - items: - $ref: '#/components/schemas/PosTenderInput' - client_created_at: - type: string - format: date-time - PosSyncRequest: + $ref: "#/components/schemas/PosTransactionSummary" + next_cursor: {type: [string, "null"], description: "Null: the page is the whole day."} + limit: {type: integer} + total: {type: integer, description: "Only under include=total."} + PosLineItem: type: object - description: OfflineSyncRequest. - required: - - batch_id - - items + description: >- + A sale line in the shared shape of ADR 0005 section 2.2: product, kit + (with its component lines), charge and text lines, the priced fields + null on a text line, the extension rounded once. + required: [id, position, line_type, parent_line_id, product_id, charge_code_id, charge_code, sku, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price_ten_thousandths, priced_unit_price_ten_thousandths, price_source, override_reason, discount_percent, discount_cents, discount_reason, price_adjusted_by, line_total_cents, taxable, revenue_account_code, is_special_order, vendor_id, special_order_unit_cost_ten_thousandths, created_at] properties: - batch_id: - type: string - register_id: - type: string - items: + id: {type: string, format: uuid} + position: {type: integer} + line_type: {type: string, enum: [product, kit, component, charge, text]} + parent_line_id: {type: [string, "null"], format: uuid, description: "The kit line a component belongs to."} + product_id: {type: [string, "null"], format: uuid} + charge_code_id: {type: [string, "null"], format: uuid} + charge_code: {type: [string, "null"]} + sku: {type: [string, "null"]} + description: {type: string} + quantity: {type: [string, "null"], description: "A decimal string; null on a text line."} + uom: {type: [string, "null"]} + price_uom: {type: [string, "null"], description: "The unit the price is per; equals uom unless the pair converts."} + uom_qty: {type: [string, "null"], description: "The pair side in the sale unit; 1 and 1 when the units agree."} + price_uom_qty: {type: [string, "null"]} + unit_price_ten_thousandths: {type: [integer, "null"], format: int64, description: "Scale 4; null on a text line."} + priced_unit_price_ten_thousandths: {type: [integer, "null"], format: int64, description: "The price before an override or discount moved it."} + price_source: {type: string, enum: [price_list, quote, override, manual, none]} + override_reason: {type: [string, "null"]} + discount_percent: {type: [string, "null"], description: "A percent or a cents amount, never both."} + discount_cents: {type: [integer, "null"], format: int64} + discount_reason: {type: [string, "null"]} + price_adjusted_by: {type: [string, "null"], description: "Who moved the price; the audit row carries the rest."} + line_total_cents: {type: [integer, "null"], format: int64, description: "The extension, rounded once; null on a text line."} + taxable: {type: boolean} + revenue_account_code: {type: [string, "null"], description: "The account the line's revenue posts to (a charge line's code)."} + is_special_order: {type: boolean} + vendor_id: {type: [string, "null"], format: uuid} + special_order_unit_cost_ten_thousandths: {type: [integer, "null"], format: int64} + created_at: {type: string, format: date-time} + PosTender: + type: object + description: >- + One payment taken at the counter, stored net: the tendered amount + less any change given from it, so the payment it became is the money + kept. + required: [id, sale_id, method, amount_cents, payment_id, reference, card_last4, card_brand, gateway_tx_id, auth_code, created_at] + properties: + id: {type: string, format: uuid} + sale_id: {type: string, format: uuid} + method: {$ref: "#/components/schemas/PosTenderMethod"} + amount_cents: {type: integer, format: int64, description: "Net of change."} + payment_id: {type: [string, "null"], format: uuid, description: "The payment the completion recorded."} + reference: {type: [string, "null"]} + card_last4: {type: [string, "null"]} + card_brand: {type: [string, "null"]} + gateway_tx_id: {type: [string, "null"]} + auth_code: {type: [string, "null"]} + created_at: {type: string, format: date-time} + PosReturn: + type: object + description: >- + One counter return: a credit memo created, posted, refunded and + optionally restocked in one act. + required: [id, number, revision, branch_id, register_id, till_session_id, original_sale_id, customer_id, cashier_id, currency, subtotal_cents, tax_cents, total_cents, refund_method, reason, credit_memo_id, created_at, lines] + properties: + id: {type: string, format: uuid} + number: {type: string, description: "RTN-000001, human readable."} + revision: {type: integer, format: int64} + branch_id: {type: [string, "null"], format: uuid} + register_id: {type: string} + till_session_id: {type: [string, "null"], format: uuid} + original_sale_id: {type: [string, "null"], format: uuid} + customer_id: {type: [string, "null"], format: uuid} + cashier_id: {type: string, format: uuid} + currency: {type: string} + subtotal_cents: {type: integer, format: int64} + tax_cents: {type: integer, format: int64} + total_cents: {type: integer, format: int64} + refund_method: {$ref: "#/components/schemas/PosRefundMethod"} + reason: {type: string} + credit_memo_id: {type: [string, "null"], format: uuid, description: "The credit memo the return posted."} + created_at: {type: string, format: date-time} + lines: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosOfflineTransaction' - PosTillOpen: + $ref: "#/components/schemas/PosReturnLine" + PosReturnLine: type: object - required: - - opening_float + description: One returned line, its quantity and extension negative. + required: [id, position, product_id, description, quantity, uom, unit_price_ten_thousandths, line_total_cents, restock] properties: - register_id: - type: string - description: Defaults to REG-01. - opening_float: - type: number - description: Float dollars; converted to cents on the server. - PosTillClose: + id: {type: string, format: uuid} + position: {type: integer} + product_id: {type: [string, "null"], format: uuid} + description: {type: string} + quantity: {type: [string, "null"], description: "A negative decimal string."} + uom: {type: [string, "null"]} + unit_price_ten_thousandths: {type: [integer, "null"], format: int64} + line_total_cents: {type: [integer, "null"], format: int64} + restock: {type: boolean} + PosTillSession: type: object - required: - - counted_by_method + description: >- + One drawer shift. The expected cash is the sum of the session's cash + payments (already net of change) plus the opening float less cash + refunds. + required: [id, register_id, branch_id, cashier_id, status, opening_float_cents, opened_at, closed_at, expected_by_method, counted_by_method, over_short_cents, gl_entry_id, notes] properties: + id: {type: string, format: uuid} + register_id: {type: string} + branch_id: {type: [string, "null"], format: uuid} + cashier_id: {type: string, format: uuid} + status: {$ref: "#/components/schemas/PosTillStatus"} + opening_float_cents: {type: integer, format: int64} + opened_at: {type: string, format: date-time} + closed_at: {type: [string, "null"], format: date-time} + expected_by_method: + type: object + additionalProperties: {type: integer, format: int64} + description: "The expected cents per lowercase method; present once the session has sales." counted_by_method: type: object - additionalProperties: - type: number - description: Float dollars keyed by tender method. - notes: - type: string - PosReturnLineRequest: + additionalProperties: {type: integer, format: int64} + over_short_cents: {type: [integer, "null"], format: int64, description: "Set by the close."} + gl_entry_id: {type: [string, "null"], format: uuid, description: "The 5030 over/short entry the close booked."} + notes: {type: string} + PosTillReport: type: object - description: ReturnLineRequest. - required: - - product_id - - quantity - - unit_price + required: [session, sale_count, sales_total_cents, tax_total_cents, change_cents, tendered_by_method, expected_by_method] properties: - product_id: - type: string - format: uuid - description: - type: string - quantity: - type: number - description: Positive units returned. - uom: - type: string - unit_price: - type: number - description: Float dollars. - restock: - type: boolean - description: Defaults to true; false for damaged goods. - PosReturnRequest: + session: + $ref: "#/components/schemas/PosTillSession" + sale_count: {type: integer} + sales_total_cents: {type: integer, format: int64} + tax_total_cents: {type: integer, format: int64} + change_cents: {type: integer, format: int64} + tendered_by_method: + type: object + additionalProperties: {type: integer, format: int64} + expected_by_method: + type: object + additionalProperties: {type: integer, format: int64} + PosZReport: type: object - description: ReturnRequest. - required: - - lines + description: The immutable end-of-day snapshot written once when the session closes. + required: [id, till_session_id, register_id, branch_id, over_short_cents, payload, generated_at] properties: - register_id: - type: string - description: Defaults to REG-01. - original_transaction_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - refund_method: - type: string - description: CASH, CARD or ACCOUNT, case insensitive; defaults to CASH. - reason: - type: string - gateway_tx_id: - type: string - description: The original card transaction to reverse. - lines: + id: {type: string, format: uuid} + till_session_id: {type: string, format: uuid} + register_id: {type: string} + branch_id: {type: [string, "null"], format: uuid} + over_short_cents: {type: integer, format: int64} + payload: {type: string, format: byte, description: "The frozen closing report as JSON bytes."} + generated_at: {type: string, format: date-time} + PosSearchResult: + type: object + required: [product_id, sku, description, unit_price_cents, uom, in_stock] + properties: + product_id: {type: string, format: uuid} + sku: {type: string} + description: {type: string} + unit_price_cents: {type: integer, format: int64} + uom: {type: string} + in_stock: {type: string, description: "A decimal string of the on hand quantity."} + PosCatalogProduct: + type: object + required: [product_id, sku, description, unit_price_cents, uom, in_stock] + properties: + product_id: {type: string, format: uuid} + sku: {type: string} + description: {type: string} + unit_price_cents: {type: integer, format: int64} + uom: {type: string} + in_stock: {type: string, description: "A decimal string of the on hand quantity."} + PosSyncResponse: + type: object + required: [batch_id, synced_count, duplicate_count, error_count, pending_count, errors, pending] + properties: + batch_id: {type: string} + synced_count: {type: integer} + duplicate_count: {type: integer, description: "Sales already synced, by client_id."} + error_count: {type: integer} + pending_count: {type: integer, description: "Sales waiting on the tax provider; the sync retries them."} + errors: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosReturnLineRequest' + $ref: "#/components/schemas/PosSyncItemResult" + pending: + type: array + items: + $ref: "#/components/schemas/PosSyncItemResult" + PosSyncItemResult: + type: object + required: [client_id, reason] + properties: + client_id: {type: string, format: uuid} + reason: {type: string} diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 6b612e31d..61f536ac0 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -9041,659 +9041,683 @@ paths: /api/v1/pos/transactions: post: operationId: posTransactionCreate - tags: - - pos - summary: Start a transaction - description: register_id defaults to REG-01 and a missing cashier_id is replaced by a random UUID. + tags: [pos] + summary: Start a sale + description: >- + Opens a cart on a register for the resolved cashier, attached to the register's open till session when there is one, at revision 1 with its POS- number (the gapped series; a rolled back create abandons the number). register_id defaults REG-01; the customer names the account the invoice posts to, and its absence means the walk-in customer the counter books to. The cashier is the JWT's subject; a machine key is refused 403 ("a cashier must be a user"). parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosTransactionCreate' + $ref: "#/components/schemas/PosTransactionCreateRequest" responses: - '201': - description: The new OPEN transaction, attached to the register's open till session when there is one. - content: - application/json: - schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - description: 'The caller''s roles do not include one the route requires, or the caller authenticated with a machine key: a cashier must be a user.' + "201": + description: The new open sale, at revision 1. + headers: + ETag: + description: The document's revision. + schema: {type: string} + Location: + description: The sale's route. + schema: {type: string} content: application/json: schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/PosCashierRefusal' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" get: operationId: posTransactionList - tags: - - pos - summary: List transaction summaries + tags: [pos] + summary: List the day's sales + description: >- + The list envelope over one register's day: register_id filters (absent lists every register's), date is a YYYY-MM-DD that defaults to the branch's today, status takes one lowercase value (open, held, completed, voided; an uppercase or unknown value is a 400 naming status). The page is the whole day newest first, so next_cursor is null and limit echoes the 50 the route serves; cursor, limit and include are accepted query names. An empty day is [] in the bytes. parameters: - - $ref: '#/components/parameters/XBranchId' - name: register_id in: query - description: Filter to one register. - schema: - type: string + required: false + schema: {type: string} - name: date in: query - description: The day to list; today when omitted or unparseable. - schema: - type: string - format: date + required: false + schema: {type: string, format: date} + - name: status + in: query + required: false + schema: {type: string, enum: [open, held, completed, voided]} + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/PageLimit" + - $ref: "#/components/parameters/Include" + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Summaries; an empty array when none. + "200": + description: The day's page of sales. content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PosTransactionSummary' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransactionPage" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}: get: operationId: posTransactionGet - tags: - - pos - summary: Get a transaction with its lines and tenders + tags: [pos] + summary: Read a sale + description: The sale with its lines and tenders, the branch wall applied. parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The transaction. + "200": + description: The sale. + headers: + ETag: + description: The document's revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/items: post: operationId: posTransactionAddItem - tags: - - pos - summary: Add a line item + tags: [pos] + summary: Add a line to the sale + description: >- + One line in the shared salesdoc shape, parsed under the same rules as every sales line (quantity a decimal string, the unit from the product when the line names one, the pair when the price unit differs, an override or a discount with its reason, a charge by its code, a text line a note with nothing priced). A kit explodes to its component lines. Only an open sale takes a line (409 invalid_state_transition). The sale's revision moves; the response is the whole sale. parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosAddItem' + $ref: "#/components/schemas/PosAddLineRequest" responses: - '200': - description: The transaction with totals recalculated. + "200": + description: The sale with the line added. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/items/{itemId}: delete: operationId: posTransactionRemoveItem - tags: - - pos - summary: Remove a line item + tags: [pos] + summary: Remove a line from the sale + description: >- + Removes the line (a kit's component lines go with their parent). Only an open sale loses a line (409 invalid_state_transition). The response is the whole sale. parameters: - name: id in: path required: true - schema: - type: string - format: uuid + schema: {type: string, format: uuid} - name: itemId in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The transaction with totals recalculated. + "200": + description: The sale with the line removed. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/complete: post: operationId: posTransactionComplete - tags: - - pos - summary: Complete a transaction with tenders - description: An empty tenders list answers 400. Every service failure (an under-tendered sale, a declined card, a transaction not OPEN) answers 422. + tags: [pos] + summary: Tender and complete the sale + description: >- + The money moment, one transaction: stock out through the inventory write path, the invoice (origin POS, pickup) posted through the AR core with its tax and COGS legs, each cash, check or card tender a payment applied to it and stored net (the tendered amount less change, so the payment is the money kept), an ACCOUNT tender left open on the invoice and subject to the credit check, the card charged at the gateway in the sale's currency before the transaction opens, and the pos_transaction.completed event written last: a failing event, audit or ledger write rolls the whole sale back (no invoice, payment, stock move or entry). Tenders must cover the total (change comes only from cash). Refused with 409 and a blocker: insufficient_tender, change_from_cash, walk_in_account (an ACCOUNT tender for the walk-in customer), card_terminal, card_declined, tax_quote_stale, tax_rate_not_configured, credit_limit, insufficient_stock; 503 when the tax provider fails before any row is written. The completion carries the sale's revision (If-Match or the body's revision; 428 without one, 409 stale_revision on a stale one). parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosComplete' + $ref: "#/components/schemas/PosCompleteRequest" responses: - '200': - description: The COMPLETED transaction. - content: - application/json: - schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - description: >- - The completion failed (under-tendered sale, declined card, transaction not OPEN; the standard error envelope), or the Idempotency-Key was reused with a different request (the ADR 0001 envelope). + "200": + description: The completed sale with its invoice, tenders and change. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/WireError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "428": + $ref: "#/components/responses/WirePreconditionRequired" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailable" /api/v1/pos/transactions/{id}/void: post: operationId: posTransactionVoid - tags: - - pos - summary: Void a transaction + tags: [pos] + summary: Void the completed sale + description: >- + One transaction in section 11's lock order (the sale, its payments, its invoice): each cash or check payment voided, each card payment's gateway refund made before the transaction and its application reversed with the amount recorded as a refund from its unapplied cash, then the invoice voided, the stock back, the ledger exactly reversed and the pos_transaction.voided event last. Only while the sale's till session is open (409 blocker session_closed: after the session closes it is a return). Refused with 409 and a blocker: session_closed, card_terminal, card_refund, no_invoice. The void carries the sale's revision (428, 409 stale_revision). parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PosVoidRequest" responses: - '200': - description: The VOIDED transaction. + "200": + description: The voided sale. + headers: + ETag: + description: The document's new revision. + schema: {type: string} content: application/json: schema: - $ref: '#/components/schemas/PosTransaction' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "428": + $ref: "#/components/responses/WirePreconditionRequired" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/products/search: get: operationId: posProductSearch - tags: - - pos - summary: Typeahead product search + tags: [pos] + summary: The counter's typeahead + description: >- + Products matching the q text (sku or description prefix), at most 20, with the price and on hand the counter needs. A bare array, never null, the shape this counter's helper surface keeps (the product module owns the data). q empty answers []. parameters: - - $ref: '#/components/parameters/XBranchId' - name: q in: query - description: SKU, description or barcode. An empty or one character query answers an empty array. - schema: - type: string + required: false + schema: {type: string} + - name: limit + in: query + required: false + schema: {type: integer} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Up to 20 matches; a bare array. + "200": + description: The matching products. content: application/json: schema: type: array items: - $ref: '#/components/schemas/PosSearchResult' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosSearchResult" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/sync: post: - operationId: posSync - tags: - - pos - summary: Replay offline transactions - description: A missing batch_id or an empty items list answers 400. + operationId: posSyncOffline + tags: [pos] + summary: Replay a batch of offline sales + description: >- + Each sale in the batch is replayed through the same completion path (stock, invoice, payments, ledger, event) keyed by its client_id: a replayed sale is a duplicate, not an error. A sale whose tax provider call fails is never rejected (the one exception to the provider rule, ADR 0005 section 3): it stays pending in the sync log and the sync retries it; errors lists what could not complete and pending what waits. parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosSyncRequest' + $ref: "#/components/schemas/PosSyncRequest" responses: - '200': - description: The batch result. + "200": + description: The batch's outcome. content: application/json: schema: - $ref: '#/components/schemas/PosSyncResponse' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosSyncResponse" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/catalog: get: - operationId: posCatalog - tags: - - pos - summary: Product catalog for the offline cache + operationId: posCatalogGet + tags: [pos] + summary: The offline catalog cache + description: >- + Every saleable product with its price and on hand for the offline register cache. A bare array, never null, like the typeahead. parameters: - - $ref: '#/components/parameters/XBranchId' + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The whole catalog as a bare array. + "200": + description: The catalog. content: application/json: schema: type: array items: - $ref: '#/components/schemas/PosCatalogProduct' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosCatalogProduct" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/open: post: operationId: posTillOpen - tags: - - pos - summary: Open a till session - description: register_id defaults to REG-01. Every service failure, such as a session already open on the register, answers 409. + tags: [pos] + summary: Open the drawer + description: >- + Opens a till session for the resolved cashier on a register with its opening float in cents; register_id defaults REG-01. A register with an open session is refused (409, a session is already open). The cashier is the JWT's subject; a machine key is refused 403. Writes till.opened. parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosTillOpen' + $ref: "#/components/schemas/PosTillOpenRequest" responses: - '201': - description: The OPEN session. - content: - application/json: - schema: - $ref: '#/components/schemas/PosTillSession' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - description: 'The caller''s roles do not include one the route requires, or the caller authenticated with a machine key: a cashier must be a user.' + "201": + description: The open session. + headers: + Location: + description: The session's report route. + schema: {type: string} content: application/json: schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/PosCashierRefusal' - '409': - $ref: '#/components/responses/ConflictEither' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTillSession" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/current: get: operationId: posTillCurrent - tags: - - pos - summary: The open till session of a register + tags: [pos] + summary: The register's open session + description: >- + The register's open till session; register_id defaults REG-01. 404 when the register has none. parameters: - - $ref: '#/components/parameters/XBranchId' - name: register_id in: query - description: Defaults to REG-01. - schema: - type: string + required: false + schema: {type: string} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The open session, or null when none. + "200": + description: The open session. content: application/json: schema: - $ref: '#/components/schemas/PosTillCurrent' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTillSession" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/report: get: operationId: posTillReport - tags: - - pos - summary: Live X report of a session - description: An unknown session answers 500. + tags: [pos] + summary: The session's live report + description: >- + The X report: the session with its sales count, totals, change and the tendered and expected cash by method, expected being the sum of the session's cash payments (already net of change) plus the opening float less cash refunds. parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': + "200": description: The report. content: application/json: schema: - $ref: '#/components/schemas/PosTillReport' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosTillReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/close: post: operationId: posTillClose - tags: - - pos - summary: Close a till session - description: Every service failure, such as a session not OPEN, answers 409. + tags: [pos] + summary: Close the drawer + description: >- + Closes the session on the counted cash: the over or short (counted less expected, expected being the session's cash payments net of change plus the float less cash refunds) is booked to 5030 inside the close's own transaction, the Z report snapshot is written and till.closed is the last statement. counted_by_method names the counted cents per lowercase method (cash, check, card, account); a refusal rolls everything back. parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosTillClose' + $ref: "#/components/schemas/PosTillCloseRequest" responses: - '200': - description: The closing Z report. + "200": + description: The closing report. content: application/json: schema: - $ref: '#/components/schemas/PosTillReport' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '409': - $ref: '#/components/responses/ConflictEither' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' + $ref: "#/components/schemas/PosTillReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/zreport: get: operationId: posTillZReport - tags: - - pos - summary: The Z report of a closed session + tags: [pos] + summary: The session's frozen Z report + description: >- + The immutable snapshot written once at the close, its payload the frozen closing report. 404 before the session closed. parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: The frozen Z snapshot. + "200": + description: The Z report. content: application/json: schema: - $ref: '#/components/schemas/PosZReport' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/PosZReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/zreports: get: operationId: posZReportList - tags: - - pos - summary: List Z reports + tags: [pos] + summary: The day's Z reports + description: >- + The Z reports of one register's day (register_id filters, date is a YYYY-MM-DD that defaults to the branch's today), newest first. A bare array, never null. parameters: - - $ref: '#/components/parameters/XBranchId' - name: register_id in: query - schema: - type: string + required: false + schema: {type: string} - name: date in: query - description: An unparseable date is ignored. - schema: - type: string - format: date + required: false + schema: {type: string, format: date} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Z reports wrapped in an object. + "200": + description: The day's Z reports. content: application/json: schema: - $ref: '#/components/schemas/PosZReportList' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + type: array + items: + $ref: "#/components/schemas/PosZReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/returns: post: operationId: posReturnCreate - tags: - - pos - summary: Record a return and refund - description: register_id defaults to REG-01. Every service failure, including validation, answers 400. + tags: [pos] + summary: Return goods at the counter + description: >- + A return is a credit memo created and posted in one act with its restock lines: the memo credits the revenue, reverses the tax and, where the lines restock, relieves COGS back and returns the stock, refunded in cash (out of the drawer, counted against the session's expected) or card (the gateway refund before the transaction) or left as account credit (the memo stays open). Lines name the sale line they return (its price and cost follow) or stand alone with product, quantity and price; quantities are positive on the request and negative on the read. The RTN- number and the pos_return.completed event land in the same transaction. Refused with 409 and a blocker: tax_rate_not_configured, card_terminal, card_refund. The cashier is the JWT's subject; a machine key is refused 403. parameters: - - $ref: '#/components/parameters/XBranchId' - - $ref: '#/components/parameters/IdempotencyKey' + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PosReturnRequest' + $ref: "#/components/schemas/PosReturnRequest" responses: - '201': - description: The return with its lines. - content: - application/json: - schema: - $ref: '#/components/schemas/PosReturn' - '400': - $ref: '#/components/responses/BadRequestEither' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - description: 'The caller''s roles do not include one the route requires, or the caller authenticated with a machine key: a cashier must be a user.' + "201": + description: The completed return. + headers: + Location: + description: The return's route. + schema: {type: string} content: application/json: schema: - oneOf: - - $ref: '#/components/schemas/Error' - - $ref: '#/components/schemas/PosCashierRefusal' - '409': - $ref: '#/components/responses/IdempotencyConflict' - '413': - $ref: '#/components/responses/PayloadTooLarge' - '422': - $ref: '#/components/responses/UnprocessableEntity' - '500': - $ref: '#/components/responses/InternalError' + $ref: "#/components/schemas/PosReturn" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" get: operationId: posReturnList - tags: - - pos - summary: List returns + tags: [pos] + summary: List the day's returns + description: >- + The day's returns (register_id filters, date is a YYYY-MM-DD that defaults to the branch's today), newest first. A bare array, never null. parameters: - - $ref: '#/components/parameters/XBranchId' - name: register_id in: query - schema: - type: string + required: false + schema: {type: string} - name: date in: query - description: An unparseable date is ignored. - schema: - type: string - format: date + required: false + schema: {type: string, format: date} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Returns wrapped in an object. + "200": + description: The day's returns. content: application/json: schema: - $ref: '#/components/schemas/PosReturnList' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '500': - $ref: '#/components/responses/InternalError' + type: array + items: + $ref: "#/components/schemas/PosReturn" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/returns/{id}: get: operationId: posReturnGet - tags: - - pos - summary: Get a return with its lines + tags: [pos] + summary: Read a return + description: The return with its lines and its credit memo link. parameters: - name: id in: path required: true - schema: - type: string - format: uuid - - $ref: '#/components/parameters/XBranchId' + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" responses: - '200': + "200": description: The return. content: application/json: schema: - $ref: '#/components/schemas/PosReturn' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/ForbiddenEither' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/PosReturn" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pricing/calculate: get: operationId: pricingCalculate @@ -21610,742 +21634,387 @@ components: status: type: string enum: [Active, Inactive] - PosCashierRefusal: + PosStatus: + type: string + enum: [open, held, completed, voided] + PosTenderMethod: + type: string + enum: [cash, check, card, account] + PosRefundMethod: + type: string + enum: [cash, card, account] + PosTillStatus: + type: string + enum: [open, closed] + PosTransactionCreateRequest: type: object - description: The refusal body for a machine key on a cashier route. The code is lower case, unlike the standard envelope. - required: - - error - - meta properties: - error: - type: object - required: - - code - - message - properties: - code: - type: string - enum: - - forbidden - message: - type: string - meta: - type: object - required: - - request_id - properties: - request_id: - type: string - PosTransactionStatus: - type: string - enum: - - OPEN - - COMPLETED - - VOIDED - - RETURNED - - HELD - PosLineItem: + register_id: {type: string, description: "Defaults REG-01."} + cashier_id: {type: [string, "null"], format: uuid, description: "Defaults the JWT's subject."} + customer_id: {type: [string, "null"], format: uuid, description: "Absent means the walk-in customer."} + PosAddLineRequest: type: object - description: POSLineItem. - required: - - id - - transaction_id - - product_id - - description - - quantity - - uom - - unit_price - - line_total - - created_at + required: [line] properties: - id: - type: string - format: uuid - transaction_id: - type: string - format: uuid - product_id: - type: string - format: uuid - description: - type: string - quantity: - type: number - uom: - type: string - unit_price: - type: integer - format: int64 - description: Cents. - line_total: - type: integer - format: int64 - description: Cents. - created_at: - type: string - format: date-time - PosTender: + line: + $ref: "#/components/schemas/PosLineRequest" + PosCompleteRequest: type: object - description: POSTender. The reference and card fields are omitted when empty. - required: - - id - - transaction_id - - method - - amount - - created_at + required: [tenders] properties: - id: - type: string - format: uuid - transaction_id: - type: string - format: uuid - method: - type: string - description: CASH, CARD, CHECK or ACCOUNT. - amount: - type: integer - format: int64 - description: Cents. - reference: - type: string - card_last4: - type: string - card_brand: - type: string - gateway_tx_id: - type: string - auth_code: - type: string - created_at: - type: string - format: date-time - PosTransaction: + tenders: + type: array + items: + $ref: "#/components/schemas/PosTenderRequest" + picked_up_by: {type: [string, "null"], description: "Who collected the basket, at most 200 characters."} + revision: {type: integer, format: int64, minimum: 1, description: "The sale's revision; or send If-Match."} + PosTenderRequest: type: object - description: POSTransaction. The customer, till session, completion time, sync fields and the lines and tenders are omitted when empty; only the read and mutation routes after the first line fill the lists. - required: - - id - - branch_id - - register_id - - cashier_id - - subtotal - - tax_amount - - total - - change_due - - status - - created_at + required: [method, amount_cents] properties: - id: - type: string - format: uuid - branch_id: - type: string - format: uuid - register_id: - type: string - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - subtotal: - type: integer - format: int64 - description: Cents. - tax_amount: - type: integer - format: int64 - description: Cents. - total: - type: integer - format: int64 - description: Cents. - change_due: - type: integer - format: int64 - description: Cents, set at completion. - till_session_id: - type: string - format: uuid - status: - $ref: '#/components/schemas/PosTransactionStatus' - completed_at: - type: string - format: date-time - created_at: - type: string - format: date-time - synced_from: - type: string - description: Absent for a live sale, offline-v1 for a synced one. - client_created_at: - type: string - format: date-time - line_items: - type: array - items: - $ref: '#/components/schemas/PosLineItem' - tenders: - type: array - items: - $ref: '#/components/schemas/PosTender' - PosTransactionSummary: + method: {$ref: "#/components/schemas/PosTenderMethod"} + amount_cents: {type: integer, format: int64, minimum: 1} + reference: {type: [string, "null"], description: "The check number or reference."} + token_id: {type: [string, "null"], description: "The card token a card tender charges."} + PosVoidRequest: type: object - description: TransactionSummary. - required: - - id - - register_id - - total - - status - - item_count - - created_at + required: [reason] properties: - id: - type: string - format: uuid - register_id: - type: string - total: - type: integer - format: int64 - description: Cents. - status: - $ref: '#/components/schemas/PosTransactionStatus' - item_count: - type: integer - completed_at: - type: string - format: date-time - created_at: - type: string - format: date-time - PosSearchResult: + reason: {type: string} + revision: {type: integer, format: int64, minimum: 1, description: "The sale's revision; or send If-Match."} + PosReturnRequest: type: object - description: QuickSearchResult. - required: - - product_id - - sku - - description - - unit_price - - uom - - in_stock + required: [reason, lines] properties: - product_id: - type: string - format: uuid - sku: - type: string - description: - type: string - unit_price: - type: number - description: Float dollars. - uom: - type: string - in_stock: - type: number - PosCatalogProduct: + register_id: {type: string, description: "Defaults REG-01."} + original_sale_id: {type: [string, "null"], format: uuid, description: "The sale the goods came from; absent is a no-receipt return."} + customer_id: {type: [string, "null"], format: uuid} + refund_method: {$ref: "#/components/schemas/PosRefundMethod"} + reason: {type: string} + gateway_tx_id: {type: [string, "null"], description: "Names the gateway refund for a card return."} + lines: + type: array + items: + $ref: "#/components/schemas/PosReturnLineRequest" + PosReturnLineRequest: type: object - description: CatalogProduct. - required: - - product_id - - sku - - description - - price - - uom - - in_stock + required: [quantity] properties: - product_id: - type: string - format: uuid - sku: - type: string - description: - type: string - price: - type: number - description: Float dollars. - uom: - type: string - in_stock: - type: number - PosTillSession: + product_id: {type: [string, "null"], format: uuid, description: "Required when the line names no sale line."} + line_id: {type: [string, "null"], format: uuid, description: "The sale line returned; its price and cost follow."} + description: {type: [string, "null"]} + quantity: {type: string, description: "A positive decimal string with at most 4 fraction digits."} + unit_price_ten_thousandths: {type: integer, format: int64, minimum: 0, description: "Required when the line names no sale line."} + restock: {type: boolean, description: "Default true; damaged goods stay out of stock."} + PosTillOpenRequest: type: object - description: TillSession. Everything set at close, and the branch, is omitted when empty. - required: - - id - - register_id - - cashier_id - - status - - opening_float - - opened_at - - notes properties: - id: - type: string - format: uuid - register_id: - type: string - branch_id: - type: string - format: uuid - cashier_id: - type: string - format: uuid - status: - type: string - enum: - - OPEN - - CLOSED - opening_float: - type: integer - format: int64 - description: Cents. - opened_at: - type: string - format: date-time - closed_at: - type: string - format: date-time - expected_by_method: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - counted_by_method: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - over_short: - type: integer - format: int64 - description: Cents; negative means short. - gl_entry_id: - type: string - format: uuid - notes: - type: string - PosTillCurrent: - type: object - description: The current session wrapper. - required: - - session - properties: - session: - oneOf: - - $ref: '#/components/schemas/PosTillSession' - - type: 'null' - PosTillReport: + register_id: {type: string, description: "Defaults REG-01."} + opening_float_cents: {type: integer, format: int64, minimum: 0} + PosTillCloseRequest: type: object - description: TillReport. - required: - - session - - sale_count - - sales_total - - tax_total - - change_given - - tendered_by_method - - expected_by_method + required: [counted_by_method] properties: - session: - $ref: '#/components/schemas/PosTillSession' - sale_count: - type: integer - sales_total: - type: integer - format: int64 - description: Cents. - tax_total: - type: integer - format: int64 - description: Cents. - change_given: - type: integer - format: int64 - description: Cents. - tendered_by_method: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - expected_by_method: + counted_by_method: type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - PosZReport: - type: object - description: ZReport. - required: - - id - - till_session_id - - register_id - - over_short - - payload - - generated_at - properties: - id: - type: string - format: uuid - till_session_id: - type: string - format: uuid - register_id: - type: string - branch_id: - type: string - format: uuid - over_short: - type: integer - format: int64 - description: Cents. - payload: - allOf: - - $ref: '#/components/schemas/PosTillReport' - description: The frozen closing report, a JSON object. - generated_at: - type: string - format: date-time - PosZReportList: - type: object - required: - - z_reports - properties: - z_reports: - type: - - array - - 'null' - items: - $ref: '#/components/schemas/PosZReport' - PosReturnLine: - type: object - description: POSReturnLine. - required: - - id - - return_id - - product_id - - description - - quantity - - uom - - unit_price - - line_total - - restock - properties: - id: - type: string - format: uuid - return_id: - type: string - format: uuid - product_id: - type: string - format: uuid - description: - type: string - quantity: - type: number - uom: - type: string - unit_price: - type: integer - format: int64 - description: Cents. - line_total: - type: integer - format: int64 - description: Cents. - restock: - type: boolean - PosReturn: + additionalProperties: {type: integer, format: int64, minimum: 0} + description: "The counted cents per lowercase method (cash, check, card, account)." + notes: {type: [string, "null"]} + PosSyncRequest: type: object - description: POSReturn. The till session, original transaction, customer, branch, ledger entry and lines are omitted when empty. - required: - - id - - register_id - - cashier_id - - subtotal - - tax_amount - - total - - refund_method - - reason - - status - - created_at + required: [batch_id, items] properties: - id: - type: string - format: uuid - register_id: - type: string - till_session_id: - type: string - format: uuid - original_transaction_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - branch_id: - type: string - format: uuid - cashier_id: - type: string - format: uuid - subtotal: - type: integer - format: int64 - description: Cents. - tax_amount: - type: integer - format: int64 - description: Cents. - total: - type: integer - format: int64 - description: Cents. - refund_method: - type: string - description: CASH, CARD or ACCOUNT. - reason: - type: string - status: - type: string - gl_entry_id: - type: string - format: uuid - created_at: - type: string - format: date-time - lines: + batch_id: {type: string} + register_id: {type: string, description: "Defaults REG-01."} + items: type: array items: - $ref: '#/components/schemas/PosReturnLine' - PosReturnList: + $ref: "#/components/schemas/PosSyncSale" + PosSyncSale: type: object - required: - - returns + required: [client_id, items, tenders] properties: - returns: - type: - - array - - 'null' + client_id: {type: string, format: uuid, description: "The offline client's id for the sale; a replay is a duplicate, not an error."} + cashier_id: {type: [string, "null"], format: uuid} + customer_id: {type: [string, "null"], format: uuid} + items: + type: array items: - $ref: '#/components/schemas/PosReturn' - PosSyncError: - type: object - description: SyncError. - required: - - client_id - - reason - properties: - client_id: - type: string - reason: - type: string - PosSyncResponse: - type: object - description: OfflineSyncResponse. errors is omitted when empty. - required: - - batch_id - - synced_count - - duplicate_count - - error_count - properties: - batch_id: - type: string - synced_count: - type: integer - duplicate_count: - type: integer - error_count: - type: integer - errors: + $ref: "#/components/schemas/PosLineRequest" + tenders: type: array items: - $ref: '#/components/schemas/PosSyncError' - PosTransactionCreate: + $ref: "#/components/schemas/PosTenderRequest" + client_created_at: {type: [string, "null"], format: date-time, description: "When the register made the sale offline."} + PosLineRequest: type: object + description: >- + One sales line in the shared shape of ADR 0005 section 2.2, the same parse everywhere a sales line is written: a product line (quantity and unit from the product when it names one), a charge line (charge_code), or a text line (a note, nothing priced); an override or a discount carries its reason and is audited. properties: - register_id: - type: string - description: Defaults to REG-01. - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - PosAddItem: - type: object - required: - - product_id - - quantity - properties: - product_id: - type: string - format: uuid - quantity: - type: number - uom: - type: string - PosTenderInput: + id: {type: [string, "null"], format: uuid, description: "The client's id for the line, echoed on created lines."} + line_type: {type: [string, "null"], enum: [product, kit, component, charge, text], description: "Defaults product; a kit explodes to its components."} + product_id: {type: [string, "null"], format: uuid} + charge_code: {type: [string, "null"], description: "The charge code a charge line names."} + sku: {type: [string, "null"]} + description: {type: [string, "null"]} + quantity: {type: [string, "null"], description: "A decimal string with at most 4 fraction digits; null only on a text line."} + uom: {type: [string, "null"], description: "Defaults the product's unit."} + price_uom: {type: [string, "null"], description: "Defaults uom; a different one requires the pair."} + uom_qty: {type: [string, "null"], description: "The pair side in the sale unit; both sides together, both positive."} + price_uom_qty: {type: [string, "null"], description: "The pair side in the price unit."} + unit_price_ten_thousandths: {type: [integer, "null"], format: int64, minimum: 0, description: "Defaults the product's price."} + override_reason: {type: [string, "null"], description: "Required when the price is not the product's."} + discount_percent: {type: [string, "null"], description: "A percent or a cents amount, never both."} + discount_cents: {type: [integer, "null"], format: int64} + discount_reason: {type: [string, "null"]} + PosTransactionSummary: type: object - description: AddTenderRequest. - required: - - method - - amount + description: A sale's head, a list item. + required: [id, number, revision, branch_id, register_id, cashier_id, customer_id, currency, total_cents, status, invoice_id, completed_at, created_at, item_count] properties: - method: - type: string - description: CASH, CARD, CHECK or ACCOUNT. - amount: - type: number - description: Float dollars. - reference: - type: string - token_id: - type: string - description: Card token from the payment processor. - PosComplete: + id: {type: string, format: uuid} + number: {type: string, description: "POS-000001, human readable."} + revision: {type: integer, format: int64} + branch_id: {type: string, format: uuid} + register_id: {type: string} + cashier_id: {type: string, format: uuid} + customer_id: {type: [string, "null"], format: uuid} + currency: {type: string, description: "ISO 4217, the customer's or the dealer default."} + total_cents: {type: integer, format: int64} + status: {$ref: "#/components/schemas/PosStatus"} + invoice_id: {type: [string, "null"], format: uuid, description: "Set by the completion."} + completed_at: {type: [string, "null"], format: date-time} + created_at: {type: string, format: date-time} + item_count: {type: integer} + PosTransaction: type: object - required: - - tenders + description: "The full sale: the head with its lines and tenders." + required: [id, number, revision, branch_id, register_id, cashier_id, customer_id, currency, subtotal_cents, tax_cents, total_cents, change_cents, till_session_id, status, invoice_id, completed_at, created_at, lines, tenders] properties: + id: {type: string, format: uuid} + number: {type: string, description: "POS-000001, human readable."} + revision: {type: integer, format: int64} + branch_id: {type: string, format: uuid} + register_id: {type: string} + cashier_id: {type: string, format: uuid} + customer_id: {type: [string, "null"], format: uuid} + currency: {type: string, description: "ISO 4217, the customer's or the dealer default."} + subtotal_cents: {type: integer, format: int64} + tax_cents: {type: integer, format: int64} + total_cents: {type: integer, format: int64} + change_cents: {type: integer, format: int64, description: "The change given, never subtracted from the payments a second time."} + till_session_id: {type: [string, "null"], format: uuid} + status: {$ref: "#/components/schemas/PosStatus"} + invoice_id: {type: [string, "null"], format: uuid, description: "The invoice the completion posted."} + completed_at: {type: [string, "null"], format: date-time} + created_at: {type: string, format: date-time} + lines: + type: array + items: + $ref: "#/components/schemas/PosLineItem" tenders: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosTenderInput' - PosOfflineTransaction: + $ref: "#/components/schemas/PosTender" + PosTransactionPage: type: object - description: OfflineTransaction. - required: - - client_id - - register_id - - cashier_id - - items - - tenders - - client_created_at + required: [items, next_cursor, limit] properties: - client_id: - type: string - format: uuid - register_id: - type: string - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid items: type: array items: - $ref: '#/components/schemas/PosAddItem' - tenders: - type: array - items: - $ref: '#/components/schemas/PosTenderInput' - client_created_at: - type: string - format: date-time - PosSyncRequest: + $ref: "#/components/schemas/PosTransactionSummary" + next_cursor: {type: [string, "null"], description: "Null: the page is the whole day."} + limit: {type: integer} + total: {type: integer, description: "Only under include=total."} + PosLineItem: type: object - description: OfflineSyncRequest. - required: - - batch_id - - items + description: >- + A sale line in the shared shape of ADR 0005 section 2.2: product, kit (with its component lines), charge and text lines, the priced fields null on a text line, the extension rounded once. + required: [id, position, line_type, parent_line_id, product_id, charge_code_id, charge_code, sku, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price_ten_thousandths, priced_unit_price_ten_thousandths, price_source, override_reason, discount_percent, discount_cents, discount_reason, price_adjusted_by, line_total_cents, taxable, revenue_account_code, is_special_order, vendor_id, special_order_unit_cost_ten_thousandths, created_at] properties: - batch_id: - type: string - register_id: - type: string - items: + id: {type: string, format: uuid} + position: {type: integer} + line_type: {type: string, enum: [product, kit, component, charge, text]} + parent_line_id: {type: [string, "null"], format: uuid, description: "The kit line a component belongs to."} + product_id: {type: [string, "null"], format: uuid} + charge_code_id: {type: [string, "null"], format: uuid} + charge_code: {type: [string, "null"]} + sku: {type: [string, "null"]} + description: {type: string} + quantity: {type: [string, "null"], description: "A decimal string; null on a text line."} + uom: {type: [string, "null"]} + price_uom: {type: [string, "null"], description: "The unit the price is per; equals uom unless the pair converts."} + uom_qty: {type: [string, "null"], description: "The pair side in the sale unit; 1 and 1 when the units agree."} + price_uom_qty: {type: [string, "null"]} + unit_price_ten_thousandths: {type: [integer, "null"], format: int64, description: "Scale 4; null on a text line."} + priced_unit_price_ten_thousandths: {type: [integer, "null"], format: int64, description: "The price before an override or discount moved it."} + price_source: {type: string, enum: [price_list, quote, override, manual, none]} + override_reason: {type: [string, "null"]} + discount_percent: {type: [string, "null"], description: "A percent or a cents amount, never both."} + discount_cents: {type: [integer, "null"], format: int64} + discount_reason: {type: [string, "null"]} + price_adjusted_by: {type: [string, "null"], description: "Who moved the price; the audit row carries the rest."} + line_total_cents: {type: [integer, "null"], format: int64, description: "The extension, rounded once; null on a text line."} + taxable: {type: boolean} + revenue_account_code: {type: [string, "null"], description: "The account the line's revenue posts to (a charge line's code)."} + is_special_order: {type: boolean} + vendor_id: {type: [string, "null"], format: uuid} + special_order_unit_cost_ten_thousandths: {type: [integer, "null"], format: int64} + created_at: {type: string, format: date-time} + PosTender: + type: object + description: >- + One payment taken at the counter, stored net: the tendered amount less any change given from it, so the payment it became is the money kept. + required: [id, sale_id, method, amount_cents, payment_id, reference, card_last4, card_brand, gateway_tx_id, auth_code, created_at] + properties: + id: {type: string, format: uuid} + sale_id: {type: string, format: uuid} + method: {$ref: "#/components/schemas/PosTenderMethod"} + amount_cents: {type: integer, format: int64, description: "Net of change."} + payment_id: {type: [string, "null"], format: uuid, description: "The payment the completion recorded."} + reference: {type: [string, "null"]} + card_last4: {type: [string, "null"]} + card_brand: {type: [string, "null"]} + gateway_tx_id: {type: [string, "null"]} + auth_code: {type: [string, "null"]} + created_at: {type: string, format: date-time} + PosReturn: + type: object + description: >- + One counter return: a credit memo created, posted, refunded and optionally restocked in one act. + required: [id, number, revision, branch_id, register_id, till_session_id, original_sale_id, customer_id, cashier_id, currency, subtotal_cents, tax_cents, total_cents, refund_method, reason, credit_memo_id, created_at, lines] + properties: + id: {type: string, format: uuid} + number: {type: string, description: "RTN-000001, human readable."} + revision: {type: integer, format: int64} + branch_id: {type: [string, "null"], format: uuid} + register_id: {type: string} + till_session_id: {type: [string, "null"], format: uuid} + original_sale_id: {type: [string, "null"], format: uuid} + customer_id: {type: [string, "null"], format: uuid} + cashier_id: {type: string, format: uuid} + currency: {type: string} + subtotal_cents: {type: integer, format: int64} + tax_cents: {type: integer, format: int64} + total_cents: {type: integer, format: int64} + refund_method: {$ref: "#/components/schemas/PosRefundMethod"} + reason: {type: string} + credit_memo_id: {type: [string, "null"], format: uuid, description: "The credit memo the return posted."} + created_at: {type: string, format: date-time} + lines: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosOfflineTransaction' - PosTillOpen: + $ref: "#/components/schemas/PosReturnLine" + PosReturnLine: type: object - required: - - opening_float + description: One returned line, its quantity and extension negative. + required: [id, position, product_id, description, quantity, uom, unit_price_ten_thousandths, line_total_cents, restock] properties: - register_id: - type: string - description: Defaults to REG-01. - opening_float: - type: number - description: Float dollars; converted to cents on the server. - PosTillClose: + id: {type: string, format: uuid} + position: {type: integer} + product_id: {type: [string, "null"], format: uuid} + description: {type: string} + quantity: {type: [string, "null"], description: "A negative decimal string."} + uom: {type: [string, "null"]} + unit_price_ten_thousandths: {type: [integer, "null"], format: int64} + line_total_cents: {type: [integer, "null"], format: int64} + restock: {type: boolean} + PosTillSession: type: object - required: - - counted_by_method + description: >- + One drawer shift. The expected cash is the sum of the session's cash payments (already net of change) plus the opening float less cash refunds. + required: [id, register_id, branch_id, cashier_id, status, opening_float_cents, opened_at, closed_at, expected_by_method, counted_by_method, over_short_cents, gl_entry_id, notes] properties: + id: {type: string, format: uuid} + register_id: {type: string} + branch_id: {type: [string, "null"], format: uuid} + cashier_id: {type: string, format: uuid} + status: {$ref: "#/components/schemas/PosTillStatus"} + opening_float_cents: {type: integer, format: int64} + opened_at: {type: string, format: date-time} + closed_at: {type: [string, "null"], format: date-time} + expected_by_method: + type: object + additionalProperties: {type: integer, format: int64} + description: "The expected cents per lowercase method; present once the session has sales." counted_by_method: type: object - additionalProperties: - type: number - description: Float dollars keyed by tender method. - notes: - type: string - PosReturnLineRequest: + additionalProperties: {type: integer, format: int64} + over_short_cents: {type: [integer, "null"], format: int64, description: "Set by the close."} + gl_entry_id: {type: [string, "null"], format: uuid, description: "The 5030 over/short entry the close booked."} + notes: {type: string} + PosTillReport: type: object - description: ReturnLineRequest. - required: - - product_id - - quantity - - unit_price + required: [session, sale_count, sales_total_cents, tax_total_cents, change_cents, tendered_by_method, expected_by_method] properties: - product_id: - type: string - format: uuid - description: - type: string - quantity: - type: number - description: Positive units returned. - uom: - type: string - unit_price: - type: number - description: Float dollars. - restock: - type: boolean - description: Defaults to true; false for damaged goods. - PosReturnRequest: + session: + $ref: "#/components/schemas/PosTillSession" + sale_count: {type: integer} + sales_total_cents: {type: integer, format: int64} + tax_total_cents: {type: integer, format: int64} + change_cents: {type: integer, format: int64} + tendered_by_method: + type: object + additionalProperties: {type: integer, format: int64} + expected_by_method: + type: object + additionalProperties: {type: integer, format: int64} + PosZReport: type: object - description: ReturnRequest. - required: - - lines + description: The immutable end-of-day snapshot written once when the session closes. + required: [id, till_session_id, register_id, branch_id, over_short_cents, payload, generated_at] properties: - register_id: - type: string - description: Defaults to REG-01. - original_transaction_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - refund_method: - type: string - description: CASH, CARD or ACCOUNT, case insensitive; defaults to CASH. - reason: - type: string - gateway_tx_id: - type: string - description: The original card transaction to reverse. - lines: + id: {type: string, format: uuid} + till_session_id: {type: string, format: uuid} + register_id: {type: string} + branch_id: {type: [string, "null"], format: uuid} + over_short_cents: {type: integer, format: int64} + payload: {type: string, format: byte, description: "The frozen closing report as JSON bytes."} + generated_at: {type: string, format: date-time} + PosSearchResult: + type: object + required: [product_id, sku, description, unit_price_cents, uom, in_stock] + properties: + product_id: {type: string, format: uuid} + sku: {type: string} + description: {type: string} + unit_price_cents: {type: integer, format: int64} + uom: {type: string} + in_stock: {type: string, description: "A decimal string of the on hand quantity."} + PosCatalogProduct: + type: object + required: [product_id, sku, description, unit_price_cents, uom, in_stock] + properties: + product_id: {type: string, format: uuid} + sku: {type: string} + description: {type: string} + unit_price_cents: {type: integer, format: int64} + uom: {type: string} + in_stock: {type: string, description: "A decimal string of the on hand quantity."} + PosSyncResponse: + type: object + required: [batch_id, synced_count, duplicate_count, error_count, pending_count, errors, pending] + properties: + batch_id: {type: string} + synced_count: {type: integer} + duplicate_count: {type: integer, description: "Sales already synced, by client_id."} + error_count: {type: integer} + pending_count: {type: integer, description: "Sales waiting on the tax provider; the sync retries them."} + errors: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosReturnLineRequest' + $ref: "#/components/schemas/PosSyncItemResult" + pending: + type: array + items: + $ref: "#/components/schemas/PosSyncItemResult" + PosSyncItemResult: + type: object + required: [client_id, reason] + properties: + client_id: {type: string, format: uuid} + reason: {type: string} PricingSource: type: string description: Where a price came from, lowercase (ADR 0006 section 7.3). diff --git a/core/internal/app/seed/seed.go b/core/internal/app/seed/seed.go index 716eaf5c9..deeb4dfaf 100644 --- a/core/internal/app/seed/seed.go +++ b/core/internal/app/seed/seed.go @@ -1313,10 +1313,14 @@ func Run() { // ========================================================================= // POSTerminal.ts hardcodes the register id "REG-01"; without a row in // pos_registers, /api/v1/pos/transactions fails with "failed to resolve - // register branch". + // register branch". The update arm matters: migration 027 inserts REG-01 + // first and 066 backfills its branch with the first location (MAIN, which + // carries no default tax rate), and the counter's money path refuses a + // sale or return on a branch without a rate; the seed repoints the + // register at the Kelowna branch the rest of the demo data lives on. db.Exec(`INSERT INTO pos_registers (id, location_id, branch_id, name, is_active) VALUES ('REG-01', $1, $1, 'Main Counter - Kelowna', true) - ON CONFLICT (id) DO NOTHING`, kelMainID.String()) + ON CONFLICT (id) DO UPDATE SET location_id = EXCLUDED.location_id, branch_id = EXCLUDED.branch_id, name = EXCLUDED.name`, kelMainID.String()) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) VALUES ('sync-batch-001', 'REG-01', 42, 0, 0, NOW() - interval '2 hours')`) db.Exec(`INSERT INTO pos_sync_log (batch_id, register_id, synced_count, duplicate_count, error_count, synced_at) diff --git a/core/internal/characterization/golden_test.go b/core/internal/characterization/golden_test.go index 4f7234298..988364d48 100644 --- a/core/internal/characterization/golden_test.go +++ b/core/internal/characterization/golden_test.go @@ -390,7 +390,13 @@ func compareGoldens(t *testing.T, group string, steps []capturedStep) { if bytes.Equal(want, actual) { return } - t.Errorf("golden %s drifted from recorded behaviour:\n%s", path, diffSnippet(string(want), string(actual))) + // The actual transcript lands beside the golden on drift: an order-only + // difference has no diff lines to show, and the file is what settles it. + dump := path + ".actual" + if err := os.WriteFile(dump, actual, 0o644); err != nil { + t.Logf("write drift dump %s: %v", dump, err) + } + t.Errorf("golden %s drifted from recorded behaviour (actual kept at %s):\n%s", path, dump, diffSnippet(string(want), string(actual))) } // TestGoldenFilesMatchCurrentGroups pins the golden directory to the script: diff --git a/core/internal/characterization/harness_test.go b/core/internal/characterization/harness_test.go index d9af8694e..52f3c774a 100644 --- a/core/internal/characterization/harness_test.go +++ b/core/internal/characterization/harness_test.go @@ -699,13 +699,31 @@ func (h *harness) doStep(t *testing.T, s stepDef) capturedStep { case []any: sortPrimaryArray(body) case map[string]any: - // Paged envelopes: the primary array sits one level down. + // Paged envelopes: the primary array sits one level down (the + // cursor envelope's items, the legacy offset envelope's data). if data, ok := body["data"].([]any); ok { sortPrimaryArray(data) } + if data, ok := body["items"].([]any); ok { + sortPrimaryArray(data) + } } } t.Logf("step %s: %s %s -> %d (%s)", s.name, s.method, path, resp.StatusCode, resp.Header.Get("Content-Type")) + if resp.StatusCode >= 400 { + // The refusal's code and blockers beside the status, so a golden + // drift investigation reads the run log instead of guessing. The + // router's own plain text refusals (404, 405) have no body to read. + var e map[string]any + if json.Unmarshal(raw, &e) == nil { + if eb, ok := e["error"].(map[string]any); ok { + t.Logf("step %s: error body: %v", s.name, eb) + } + } + if capturedBody != nil { + t.Logf("step %s: request body: %v", s.name, capturedBody) + } + } if s.extract != nil { doc := decodeJSON(t, raw) diff --git a/core/internal/characterization/scenarios_finance_test.go b/core/internal/characterization/scenarios_finance_test.go index 500288d79..b489421da 100644 --- a/core/internal/characterization/scenarios_finance_test.go +++ b/core/internal/characterization/scenarios_finance_test.go @@ -59,7 +59,7 @@ func posGroups() []groupDef { name: "pos.till.open", method: "POST", path: "/api/v1/pos/till/open", - body: map[string]any{"register_id": "REG-01", "opening_float": 100.0}, + body: map[string]any{"register_id": "REG-01", "opening_float_cents": 10000}, extract: map[string]string{"myTill": "/id"}, }, {name: "pos.till.current", method: "GET", path: "/api/v1/pos/till/current?register_id=REG-01"}, diff --git a/core/internal/characterization/scenarios_r1b_c_test.go b/core/internal/characterization/scenarios_r1b_c_test.go index 571fc1af7..b7b92270e 100644 --- a/core/internal/characterization/scenarios_r1b_c_test.go +++ b/core/internal/characterization/scenarios_r1b_c_test.go @@ -341,6 +341,10 @@ func r1bCMaskedGroups() []groupDef { } func r1bCPOSGroups() []groupDef { + // posMatch carries a sale's revision as the completion and void + // precondition; the revision is extracted from each read, so the script + // never counts revisions by hand. + posMatch := func(rev string) map[string]string { return map[string]string{"If-Match": `"` + rev + `"`} } return []groupDef{ { name: "pos_transactions", @@ -351,7 +355,7 @@ func r1bCPOSGroups() []groupDef { // so the register ends the run with an open till as it began. { name: "pos.till.current.c", method: "GET", path: "/api/v1/pos/till/current?register_id=REG-01", - extract: map[string]string{"c_till": "/session/id"}, + extract: map[string]string{"c_till": "/id"}, }, // Sales deduct stock at the unassigned location, so seed some there. {name: "pos.setup.stock.product", method: "POST", path: "/api/v1/inventory/adjust", @@ -359,39 +363,40 @@ func r1bCPOSGroups() []groupDef { {name: "pos.setup.stock.sheet", method: "POST", path: "/api/v1/inventory/adjust", body: map[string]any{"product_id": "{productSheet}", "quantity": 10, "reason": "r1b c pos setup", "is_delta": true}}, {name: "pos.till.open.c_duplicate", method: "POST", path: "/api/v1/pos/till/open", - body: map[string]any{"register_id": "REG-01", "opening_float": 50.0}}, + body: map[string]any{"register_id": "REG-01", "opening_float_cents": 5000}}, {name: "pos.till.open.bad_body", method: "POST", path: "/api/v1/pos/till/open", body: "not an object"}, { name: "pos.transaction.start.c", method: "POST", path: "/api/v1/pos/transactions", body: map[string]any{"register_id": "REG-01", "customer_id": "{myCustomer}"}, - extract: map[string]string{"c_tx": "/id"}, + extract: map[string]string{"c_tx": "/id", "c_tx_rev": "/revision"}, }, {name: "pos.transaction.start.c_void", method: "POST", path: "/api/v1/pos/transactions", body: map[string]any{"register_id": "REG-01"}, - extract: map[string]string{"c_tx_void": "/id"}}, + extract: map[string]string{"c_tx_void": "/id", "c_void_rev": "/revision"}}, {name: "pos.transaction.start.c_voidpaid", method: "POST", path: "/api/v1/pos/transactions", body: map[string]any{"register_id": "REG-01"}, - extract: map[string]string{"c_tx_voidpaid": "/id"}}, + extract: map[string]string{"c_tx_voidpaid": "/id", "c_voidpaid_rev": "/revision"}}, {name: "pos.transaction.start.bad_body", method: "POST", path: "/api/v1/pos/transactions", body: "not an object"}, {name: "pos.products.search", method: "GET", path: "/api/v1/pos/products/search?q=LUM"}, {name: "pos.products.search.no_match", method: "GET", path: "/api/v1/pos/products/search?q=zzzzzzzz-no-such-sku"}, {name: "pos.products.search.empty_query", method: "GET", path: "/api/v1/pos/products/search"}, { name: "pos.item.add", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/items", - body: map[string]any{"product_id": "{product}", "quantity": 3}, - extract: map[string]string{"c_item": "/line_items/0/id"}, + body: map[string]any{"line": map[string]any{"product_id": "{product}", "quantity": "3"}}, + extract: map[string]string{"c_item": "/lines/0/id", "c_tx_rev": "/revision"}, }, { name: "pos.item.add.second", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/items", - body: map[string]any{"product_id": "{productSheet}", "quantity": 2, "uom": "EA"}, - extract: map[string]string{"c_item2": "/line_items/1/id"}, + body: map[string]any{"line": map[string]any{"product_id": "{productSheet}", "quantity": "2"}}, + extract: map[string]string{"c_item2": "/lines/1/id", "c_tx_rev": "/revision"}, }, {name: "pos.item.add.bad_tx_id", method: "POST", path: "/api/v1/pos/transactions/not-a-uuid/items", - body: map[string]any{"product_id": "{product}", "quantity": 1}}, + body: map[string]any{"line": map[string]any{"product_id": "{product}", "quantity": "1"}}}, {name: "pos.item.add.bad_body", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/items", body: "not an object"}, {name: "pos.item.add.unknown_product", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/items", - body: map[string]any{"product_id": cMissingID, "quantity": 1}}, - {name: "pos.item.remove", method: "DELETE", path: "/api/v1/pos/transactions/{c_tx}/items/{c_item2}"}, + body: map[string]any{"line": map[string]any{"product_id": cMissingID, "quantity": "1"}}}, + {name: "pos.item.remove", method: "DELETE", path: "/api/v1/pos/transactions/{c_tx}/items/{c_item2}", + extract: map[string]string{"c_tx_rev": "/revision"}}, {name: "pos.item.remove.bad_tx_id", method: "DELETE", path: "/api/v1/pos/transactions/not-a-uuid/items/{c_item}"}, {name: "pos.item.remove.bad_item_id", method: "DELETE", path: "/api/v1/pos/transactions/{c_tx}/items/not-a-uuid"}, {name: "pos.item.remove.unknown_item", method: "DELETE", path: "/api/v1/pos/transactions/{c_tx}/items/" + cMissingID}, @@ -400,36 +405,65 @@ func r1bCPOSGroups() []groupDef { {name: "pos.transaction.get.bad_id", method: "GET", path: "/api/v1/pos/transactions/not-a-uuid"}, {name: "pos.transaction.list", method: "GET", path: "/api/v1/pos/transactions?register_id=REG-01", sortPrimaryArray: true}, {name: "pos.transaction.list.other_day", method: "GET", path: "/api/v1/pos/transactions?register_id=REG-01&date={today-30}"}, + {name: "pos.transaction.list.bad_status", method: "GET", path: "/api/v1/pos/transactions?status=OPEN"}, + {name: "pos.transaction.list.unknown_parameter", method: "GET", path: "/api/v1/pos/transactions?bogus=1"}, {name: "pos.transaction.complete.no_tenders", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/complete", body: map[string]any{"tenders": []map[string]any{}}}, {name: "pos.transaction.complete.bad_id", method: "POST", path: "/api/v1/pos/transactions/not-a-uuid/complete", - body: map[string]any{"tenders": []map[string]any{{"method": "CASH", "amount": 1.0}}}}, + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 100}}}}, {name: "pos.transaction.complete.bad_body", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/complete", body: "not an object"}, + // The precondition: no revision and no If-Match is 428, a stale + // one 409 stale_revision. + {name: "pos.transaction.complete.no_precondition", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/complete", + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 50000}}}}, + {name: "pos.transaction.complete.stale_revision", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/complete", + headers: posMatch("1"), + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 50000}}}}, {name: "pos.transaction.complete.insufficient", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/complete", - body: map[string]any{"tenders": []map[string]any{{"method": "CASH", "amount": 0.01}}}}, + headers: posMatch("{c_tx_rev}"), + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 1}}}}, {name: "pos.transaction.complete.not_found", method: "POST", path: "/api/v1/pos/transactions/" + cMissingID + "/complete", - body: map[string]any{"tenders": []map[string]any{{"method": "CASH", "amount": 1.0}}}}, + headers: posMatch("{c_tx_rev}"), + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 50000}}}}, {name: "pos.transaction.complete", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/complete", - body: map[string]any{"tenders": []map[string]any{{"method": "CASH", "amount": 500.0}}}}, + headers: posMatch("{c_tx_rev}"), + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 50000}}}, + extract: map[string]string{"c_tx_rev": "/revision"}}, {name: "pos.transaction.complete.again", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/complete", - body: map[string]any{"tenders": []map[string]any{{"method": "CASH", "amount": 500.0}}}}, + headers: posMatch("1"), + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 50000}}}}, {name: "pos.item.add.after_complete", method: "POST", path: "/api/v1/pos/transactions/{c_tx}/items", - body: map[string]any{"product_id": "{product}", "quantity": 1}}, - // An open sale with an item, voided before payment. + body: map[string]any{"line": map[string]any{"product_id": "{product}", "quantity": "1"}}}, + // An open sale with an item: the void refuses it, only a completed + // sale is voided. {name: "pos.item.add.void_tx", method: "POST", path: "/api/v1/pos/transactions/{c_tx_void}/items", - body: map[string]any{"product_id": "{productSheet}", "quantity": 1}}, - {name: "pos.transaction.void", method: "POST", path: "/api/v1/pos/transactions/{c_tx_void}/void"}, - {name: "pos.transaction.void.again", method: "POST", path: "/api/v1/pos/transactions/{c_tx_void}/void"}, - {name: "pos.transaction.void.bad_id", method: "POST", path: "/api/v1/pos/transactions/not-a-uuid/void"}, - {name: "pos.transaction.void.not_found", method: "POST", path: "/api/v1/pos/transactions/" + cMissingID + "/void"}, - // A paid sale, voided after payment (restores stock). + body: map[string]any{"line": map[string]any{"product_id": "{productSheet}", "quantity": "1"}}, + extract: map[string]string{"c_void_rev": "/revision"}}, + {name: "pos.transaction.void.open", method: "POST", path: "/api/v1/pos/transactions/{c_tx_void}/void", + headers: posMatch("{c_void_rev}"), + body: map[string]any{"reason": "r1b c abandon"}}, + {name: "pos.transaction.void.bad_id", method: "POST", path: "/api/v1/pos/transactions/not-a-uuid/void", + body: map[string]any{"reason": "r1b c"}}, + {name: "pos.transaction.void.not_found", method: "POST", path: "/api/v1/pos/transactions/" + cMissingID + "/void", + body: map[string]any{"reason": "r1b c"}}, + // A paid sale with a split tender (cash and check, one invoice, one + // payment per tender), voided after payment: the ledger returns to + // zero and the stock comes back. {name: "pos.item.add.voidpaid_tx", method: "POST", path: "/api/v1/pos/transactions/{c_tx_voidpaid}/items", - body: map[string]any{"product_id": "{product}", "quantity": 1}}, - {name: "pos.transaction.complete.voidpaid", method: "POST", path: "/api/v1/pos/transactions/{c_tx_voidpaid}/complete", - body: map[string]any{"tenders": []map[string]any{{"method": "CASH", "amount": 100.0}}}}, - {name: "pos.transaction.void.completed", method: "POST", path: "/api/v1/pos/transactions/{c_tx_voidpaid}/void"}, + body: map[string]any{"line": map[string]any{"product_id": "{product}", "quantity": "1"}}, + extract: map[string]string{"c_voidpaid_rev": "/revision"}}, + {name: "pos.transaction.complete.voidpaid_split_tender", method: "POST", path: "/api/v1/pos/transactions/{c_tx_voidpaid}/complete", + headers: posMatch("{c_voidpaid_rev}"), + body: map[string]any{"tenders": []map[string]any{{"method": "cash", "amount_cents": 416}, {"method": "check", "amount_cents": 200, "reference": "ch-1042"}}}, + extract: map[string]string{"c_voidpaid_rev": "/revision"}}, + {name: "pos.transaction.void.completed", method: "POST", path: "/api/v1/pos/transactions/{c_tx_voidpaid}/void", + headers: posMatch("{c_voidpaid_rev}"), + body: map[string]any{"reason": "r1b c wrong customer"}}, {name: "pos.transaction.get.after", method: "GET", path: "/api/v1/pos/transactions/{c_tx}"}, {name: "pos.transaction.list.after", method: "GET", path: "/api/v1/pos/transactions?register_id=REG-01", sortPrimaryArray: true}, + // The counter's own events, read back from the feed: the completed + // sale, the split tender's completed sale and the void. + {name: "pos.events", method: "GET", path: "/api/v1/events?types=pos_transaction.completed,pos_transaction.voided&limit=50", sortPrimaryArray: true}, }, }, { @@ -441,10 +475,9 @@ func r1bCPOSGroups() []groupDef { "batch_id": "r1c-batch-1", "register_id": "REG-01", "items": []map[string]any{{ "client_id": "c1c1c1c1-0000-4000-8000-000000000001", - "register_id": "REG-01", "cashier_id": "c1c1c1c1-0000-4000-8000-0000000000aa", - "items": []map[string]any{{"product_id": "{product}", "quantity": 1}}, - "tenders": []map[string]any{{"method": "CASH", "amount": 200.0}}, + "items": []map[string]any{{"product_id": "{product}", "quantity": "1"}}, + "tenders": []map[string]any{{"method": "cash", "amount_cents": 20000}}, "client_created_at": "{today}T03:04:05Z", }}, }, @@ -455,10 +488,9 @@ func r1bCPOSGroups() []groupDef { "batch_id": "r1c-batch-1", "register_id": "REG-01", "items": []map[string]any{{ "client_id": "c1c1c1c1-0000-4000-8000-000000000001", - "register_id": "REG-01", "cashier_id": "c1c1c1c1-0000-4000-8000-0000000000aa", - "items": []map[string]any{{"product_id": "{product}", "quantity": 1}}, - "tenders": []map[string]any{{"method": "CASH", "amount": 200.0}}, + "items": []map[string]any{{"product_id": "{product}", "quantity": "1"}}, + "tenders": []map[string]any{{"method": "cash", "amount_cents": 20000}}, "client_created_at": "{today}T03:04:05Z", }}, }, @@ -470,8 +502,8 @@ func r1bCPOSGroups() []groupDef { "items": []map[string]any{{ "client_id": "c1c1c1c1-0000-4000-8000-000000000002", "cashier_id": "c1c1c1c1-0000-4000-8000-0000000000aa", - "items": []map[string]any{{"product_id": "{product}", "quantity": 1}}, - "tenders": []map[string]any{{"method": "CASH", "amount": 0.01}}, + "items": []map[string]any{{"product_id": "{product}", "quantity": "1"}}, + "tenders": []map[string]any{{"method": "cash", "amount_cents": 1}}, "client_created_at": "{today}T03:04:06Z", }}, }, @@ -490,9 +522,9 @@ func r1bCPOSGroups() []groupDef { { name: "pos.return.create", method: "POST", path: "/api/v1/pos/returns", body: map[string]any{ - "register_id": "REG-01", "refund_method": "CASH", "reason": "r1b c wrong size", + "register_id": "REG-01", "refund_method": "cash", "reason": "r1b c wrong size", "lines": []map[string]any{ - {"product_id": "{product}", "description": "returned lumber", "quantity": 2, "unit_price": 12.5}, + {"product_id": "{product}", "description": "returned lumber", "quantity": "2", "unit_price_ten_thousandths": 125000}, }, }, extract: map[string]string{"c_return": "/id"}, @@ -500,24 +532,24 @@ func r1bCPOSGroups() []groupDef { { name: "pos.return.create.account", method: "POST", path: "/api/v1/pos/returns", body: map[string]any{ - "register_id": "REG-01", "refund_method": "ACCOUNT", "customer_id": "{myCustomer}", + "register_id": "REG-01", "refund_method": "account", "customer_id": "{myCustomer}", "reason": "r1b c credit the account", "lines": []map[string]any{ - {"product_id": "{productSheet}", "quantity": 1, "unit_price": 30.0, "restock": false}, + {"product_id": "{productSheet}", "quantity": "1", "unit_price_ten_thousandths": 300000, "restock": false}, }, }, extract: map[string]string{"c_return2": "/id"}, }, {name: "pos.return.create.account_no_customer", method: "POST", path: "/api/v1/pos/returns", - body: map[string]any{"register_id": "REG-01", "refund_method": "ACCOUNT", - "lines": []map[string]any{{"product_id": "{product}", "quantity": 1, "unit_price": 5.0}}}}, + body: map[string]any{"register_id": "REG-01", "refund_method": "account", "reason": "r1b c no account", + "lines": []map[string]any{{"product_id": "{product}", "quantity": "1", "unit_price_ten_thousandths": 50000}}}}, {name: "pos.return.create.no_lines", method: "POST", path: "/api/v1/pos/returns", - body: map[string]any{"register_id": "REG-01", "refund_method": "CASH", "lines": []map[string]any{}}}, + body: map[string]any{"register_id": "REG-01", "refund_method": "cash", "reason": "r1b c", "lines": []map[string]any{}}}, {name: "pos.return.create.bad_method", method: "POST", path: "/api/v1/pos/returns", - body: map[string]any{"register_id": "REG-01", "refund_method": "BARTER", - "lines": []map[string]any{{"product_id": "{product}", "quantity": 1, "unit_price": 5.0}}}}, + body: map[string]any{"register_id": "REG-01", "refund_method": "BARTER", "reason": "r1b c", + "lines": []map[string]any{{"product_id": "{product}", "quantity": "1", "unit_price_ten_thousandths": 50000}}}}, {name: "pos.return.create.zero_quantity", method: "POST", path: "/api/v1/pos/returns", - body: map[string]any{"register_id": "REG-01", "refund_method": "CASH", - "lines": []map[string]any{{"product_id": "{product}", "quantity": 0, "unit_price": 5.0}}}}, + body: map[string]any{"register_id": "REG-01", "refund_method": "cash", "reason": "r1b c", + "lines": []map[string]any{{"product_id": "{product}", "quantity": "0", "unit_price_ten_thousandths": 50000}}}}, {name: "pos.return.create.bad_body", method: "POST", path: "/api/v1/pos/returns", body: "not an object"}, {name: "pos.return.get", method: "GET", path: "/api/v1/pos/returns/{c_return}"}, {name: "pos.return.get.not_found", method: "GET", path: "/api/v1/pos/returns/" + cMissingID}, @@ -536,26 +568,30 @@ func r1bCPOSGroups() []groupDef { {name: "pos.till.zreport.before_close", method: "GET", path: "/api/v1/pos/till/{c_till}/zreport"}, {name: "pos.till.zreport.bad_id", method: "GET", path: "/api/v1/pos/till/not-a-uuid/zreport"}, {name: "pos.till.close.bad_id", method: "POST", path: "/api/v1/pos/till/not-a-uuid/close", - body: map[string]any{"counted_by_method": map[string]any{"CASH": 1.0}}}, + body: map[string]any{"counted_by_method": map[string]any{"cash": 100}}}, {name: "pos.till.close.bad_body", method: "POST", path: "/api/v1/pos/till/{c_till}/close", body: "not an object"}, {name: "pos.till.close.not_found", method: "POST", path: "/api/v1/pos/till/" + cMissingID + "/close", - body: map[string]any{"counted_by_method": map[string]any{"CASH": 1.0}}}, + body: map[string]any{"counted_by_method": map[string]any{"cash": 100}}}, { name: "pos.till.close", method: "POST", path: "/api/v1/pos/till/{c_till}/close", - body: map[string]any{"counted_by_method": map[string]any{"CASH": 200.0}, "notes": "r1b c close count"}, + body: map[string]any{"counted_by_method": map[string]any{"cash": 20000}, "notes": "r1b c close count"}, }, {name: "pos.till.close.again", method: "POST", path: "/api/v1/pos/till/{c_till}/close", - body: map[string]any{"counted_by_method": map[string]any{"CASH": 200.0}}}, + body: map[string]any{"counted_by_method": map[string]any{"cash": 20000}}}, {name: "pos.till.current.after_close", method: "GET", path: "/api/v1/pos/till/current?register_id=REG-01"}, { name: "pos.till.reopen", method: "POST", path: "/api/v1/pos/till/open", - body: map[string]any{"register_id": "REG-01", "opening_float": 100.0}, + body: map[string]any{"register_id": "REG-01", "opening_float_cents": 10000}, extract: map[string]string{"c_till_reopened": "/id"}, }, {name: "pos.till.report.after_close", method: "GET", path: "/api/v1/pos/till/{c_till}/report"}, - {name: "pos.till.zreport", method: "GET", path: "/api/v1/pos/till/{c_till}/zreport"}, + {name: "pos.till.zreport", method: "GET", path: "/api/v1/pos/till/{c_till}/zreport", + // The frozen report embeds the session's own ids and + // timestamps; the amounts are what the golden pins. + maskFields: map[string]any{"payload": ""}}, {name: "pos.till.zreport.not_found", method: "GET", path: "/api/v1/pos/till/" + cMissingID + "/zreport"}, - {name: "pos.zreports.list", method: "GET", path: "/api/v1/pos/zreports?register_id=REG-01"}, + {name: "pos.zreports.list", method: "GET", path: "/api/v1/pos/zreports?register_id=REG-01", + maskFields: map[string]any{"payload": ""}}, {name: "pos.zreports.list.other_day", method: "GET", path: "/api/v1/pos/zreports?register_id=REG-01&date={today-30}"}, {name: "pos.zreports.list.unknown_register", method: "GET", path: "/api/v1/pos/zreports?register_id=REG-NONE"}, }, diff --git a/core/internal/characterization/testdata/goldens/crm_activity_item.json b/core/internal/characterization/testdata/goldens/crm_activity_item.json index b31d9e290..4fb5dc62c 100644 --- a/core/internal/characterization/testdata/goldens/crm_activity_item.json +++ b/core/internal/characterization/testdata/goldens/crm_activity_item.json @@ -494,12 +494,12 @@ "at": "", "branch_id": null, "data": { - "activity_type": "note", + "activity_type": "call", "customer_id": "", "revision": 1 }, "entity": { - "id": "", + "id": "", "kind": "activity" }, "event_id": "", @@ -510,9 +510,9 @@ "at": "", "branch_id": null, "data": { - "activity_type": "call", + "activity_type": "meeting", "customer_id": "", - "revision": 1 + "revision": 2 }, "entity": { "id": "", @@ -520,23 +520,23 @@ }, "event_id": "", "org": "default", - "type": "activity.created" + "type": "activity.updated" }, { "at": "", "branch_id": null, "data": { - "activity_type": "meeting", + "activity_type": "note", "customer_id": "", - "revision": 2 + "revision": 1 }, "entity": { - "id": "", + "id": "", "kind": "activity" }, "event_id": "", "org": "default", - "type": "activity.updated" + "type": "activity.created" } ], "limit": 3, diff --git a/core/internal/characterization/testdata/goldens/location_item.json b/core/internal/characterization/testdata/goldens/location_item.json index d5137fa40..da35039fd 100644 --- a/core/internal/characterization/testdata/goldens/location_item.json +++ b/core/internal/characterization/testdata/goldens/location_item.json @@ -90,25 +90,47 @@ "content_type": "application/json", "body": { "items": [ + { + "active": true, + "address": "2450 Enterprise Way", + "branch_id": "", + "city": "Kelowna", + "code": "KEL-MAIN", + "created_at": "", + "default_tax_rate": 0.12, + "description": "Kelowna Main Yard", + "id": "", + "name": "Kelowna Main Yard", + "parent_id": null, + "path": "KEL-MAIN", + "phone": "250-555-1000", + "revision": 1, + "state": "BC", + "tax_jurisdiction_code": "BC", + "timezone": "Etc/UTC", + "type": "branch", + "updated_at": "", + "zip": "V1X 7K2" + }, { "active": true, "address": null, "branch_id": "", "city": null, - "code": "GOLD-A-YARD", + "code": "GOLD-A-BIN", "created_at": "", "default_tax_rate": null, - "description": "item fixture", - "id": "", - "name": "Golden A Yard", - "parent_id": "", + "description": null, + "id": "", + "name": "Golden A Bin", + "parent_id": "", "path": "", "phone": null, "revision": 1, "state": null, "tax_jurisdiction_code": null, "timezone": "America/New_York", - "type": "yard", + "type": "bin", "updated_at": "", "zip": null }, @@ -117,20 +139,20 @@ "address": null, "branch_id": "", "city": null, - "code": "GOLD-A-BIN", + "code": "GOLD-A-YARD", "created_at": "", "default_tax_rate": null, - "description": null, - "id": "", - "name": "Golden A Bin", - "parent_id": "", + "description": "item fixture", + "id": "", + "name": "Golden A Yard", + "parent_id": "", "path": "", "phone": null, "revision": 1, "state": null, "tax_jurisdiction_code": null, "timezone": "America/New_York", - "type": "bin", + "type": "yard", "updated_at": "", "zip": null }, @@ -221,28 +243,6 @@ "type": "zone", "updated_at": "", "zip": null - }, - { - "active": true, - "address": "2450 Enterprise Way", - "branch_id": "", - "city": "Kelowna", - "code": "KEL-MAIN", - "created_at": "", - "default_tax_rate": 0.12, - "description": "Kelowna Main Yard", - "id": "", - "name": "Kelowna Main Yard", - "parent_id": null, - "path": "KEL-MAIN", - "phone": "250-555-1000", - "revision": 1, - "state": "BC", - "tax_jurisdiction_code": "BC", - "timezone": "Etc/UTC", - "type": "branch", - "updated_at": "", - "zip": "V1X 7K2" } ], "limit": 0, diff --git a/core/internal/characterization/testdata/goldens/pim_media.json b/core/internal/characterization/testdata/goldens/pim_media.json index 23d7ee55a..cf02e8b61 100644 --- a/core/internal/characterization/testdata/goldens/pim_media.json +++ b/core/internal/characterization/testdata/goldens/pim_media.json @@ -30,7 +30,7 @@ "media": [], "product": { "allocated": "14", - "available": "413", + "available": "415", "average_unit_cost_ten_thousandths": 35000, "base_price_ten_thousandths": 55000, "board_length_ft": null, @@ -44,7 +44,7 @@ "id": "", "lead_time_days": null, "length_in": 96, - "on_hand": "427", + "on_hand": "429", "price_uom": "PCS", "purchase_uom": "PCS", "random_length": false, diff --git a/core/internal/characterization/testdata/goldens/pos.json b/core/internal/characterization/testdata/goldens/pos.json index f2f835e08..d1ef7d8f3 100644 --- a/core/internal/characterization/testdata/goldens/pos.json +++ b/core/internal/characterization/testdata/goldens/pos.json @@ -5,7 +5,7 @@ "method": "POST", "path": "/api/v1/pos/till/open", "body": { - "opening_float": 100, + "opening_float_cents": 10000, "register_id": "REG-01" } }, @@ -13,13 +13,19 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", - "id": "", + "branch_id": "", + "cashier_id": "", + "closed_at": null, + "counted_by_method": null, + "expected_by_method": null, + "gl_entry_id": null, + "id": "", "notes": "", "opened_at": "", - "opening_float": 10000, + "opening_float_cents": 10000, + "over_short_cents": null, "register_id": "REG-01", - "status": "OPEN" + "status": "open" } } }, @@ -33,15 +39,19 @@ "status": 200, "content_type": "application/json", "body": { - "session": { - "cashier_id": "", - "id": "", - "notes": "", - "opened_at": "", - "opening_float": 10000, - "register_id": "REG-01", - "status": "OPEN" - } + "branch_id": "", + "cashier_id": "", + "closed_at": null, + "counted_by_method": null, + "expected_by_method": null, + "gl_entry_id": null, + "id": "", + "notes": "", + "opened_at": "", + "opening_float_cents": 10000, + "over_short_cents": null, + "register_id": "REG-01", + "status": "open" } } }, @@ -51,7 +61,7 @@ "method": "POST", "path": "/api/v1/pos/transactions", "body": { - "customer_id": "", + "customer_id": "", "register_id": "REG-01" } }, @@ -59,18 +69,25 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "customer_id": "", - "id": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "lines": [], + "number": "POS-000001", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, - "till_session_id": "", - "total": 0 + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], + "till_session_id": "", + "total_cents": 0 } } }, @@ -81,17 +98,562 @@ "path": "/api/v1/pos/catalog" }, "response": { - "status": 500, + "status": 200, "content_type": "application/json", - "body": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "body": [ + { + "description": "Flashing J 6 X 6 X 10", + "in_stock": "303", + "product_id": "", + "sku": "CORN2006", + "unit_price_cents": 2325, + "uom": "EA" + }, + { + "description": "Flashing Z Bar 3/4 X 10'", + "in_stock": "966", + "product_id": "", + "sku": "CORN2009", + "unit_price_cents": 525, + "uom": "EA" + }, + { + "description": "Silicone Clear Caulk", + "in_stock": "404", + "product_id": "", + "sku": "CORNCLEAR", + "unit_price_cents": 865, + "uom": "EA" + }, + { + "description": "Cemtrim Textured 7/16 X 4 X 12", + "in_stock": "523", + "product_id": "", + "sku": "CORNCTRM412+", + "unit_price_cents": 1131, + "uom": "EA" + }, + { + "description": "Cemtrim Textured 7/16 X 6 X 12", + "in_stock": "667", + "product_id": "", + "sku": "CORNCTRMG12+", + "unit_price_cents": 1791, + "uom": "EA" + }, + { + "description": "Vinyl H Mold 1/4 X 12'", + "in_stock": "295", + "product_id": "", + "sku": "CORNHMOLD14", + "unit_price_cents": 600, + "uom": "EA" + }, + { + "description": "Poly Black 18 X 300", + "in_stock": "693", + "product_id": "", + "sku": "CORNPOLY18", + "unit_price_cents": 3468, + "uom": "RL" + }, + { + "description": "Solid Soffit Hardie Textured 1/4 X 12 X 12", + "in_stock": "375", + "product_id": "", + "sku": "CORNSFT1212", + "unit_price_cents": 2288, + "uom": "EA" + }, + { + "description": "Solid Soffit Hardie Textured 1/4 X 16 X 12", + "in_stock": "773", + "product_id": "", + "sku": "CORNSFT1612", + "unit_price_cents": 3068, + "uom": "EA" + }, + { + "description": "Vented Soffit Hardie Textured 1/4 X 16 X 12", + "in_stock": "552", + "product_id": "", + "sku": "CORNSFT1612V", + "unit_price_cents": 3923, + "uom": "EA" + }, + { + "description": "Vented Soffit Hardie Textured 1/4 X 24 X 8", + "in_stock": "471", + "product_id": "", + "sku": "CORNSFT2408V", + "unit_price_cents": 2625, + "uom": "EA" + }, + { + "description": "Sheathing 1/8 X 4 X 9 (Green) NSP DRYLine TSX", + "in_stock": "487", + "product_id": "", + "sku": "CORNSHTGR49", + "unit_price_cents": 1404, + "uom": "EA" + }, + { + "description": "Window Tape 6\" (100')", + "in_stock": "407", + "product_id": "", + "sku": "CORNTAPE", + "unit_price_cents": 3300, + "uom": "RL" + }, + { + "description": "Ext Door 36x80 Steel 6-Panel", + "in_stock": "923", + "product_id": "", + "sku": "DR-EXT-3680-STL", + "unit_price_cents": 28000, + "uom": "PCS" + }, + { + "description": "Int Door 30x80 6-Panel Hollow", + "in_stock": "821", + "product_id": "", + "sku": "DR-INT-3080-6P", + "unit_price_cents": 9500, + "uom": "PCS" + }, + { + "description": "Int Door 32x80 6-Panel Hollow", + "in_stock": "281", + "product_id": "", + "sku": "DR-INT-3280-6P", + "unit_price_cents": 9500, + "uom": "PCS" + }, + { + "description": "Int Door 36x80 6-Panel Hollow", + "in_stock": "825", + "product_id": "", + "sku": "DR-INT-3680-6P", + "unit_price_cents": 9900, + "uom": "PCS" + }, + { + "description": "1/2 Drywall Regular 4x8", + "in_stock": "816", + "product_id": "", + "sku": "DW-12-REG", + "unit_price_cents": 1600, + "uom": "PCS" + }, + { + "description": "5/8 Drywall Firecode 4x8", + "in_stock": "800", + "product_id": "", + "sku": "DW-58-FC", + "unit_price_cents": 2100, + "uom": "PCS" + }, + { + "description": "Golden harness stud", + "in_stock": "0", + "product_id": "", + "sku": "GOLD-HARNESS-2X4", + "unit_price_cents": 425, + "uom": "PCS" + }, + { + "description": "Joist Hanger 2x10", + "in_stock": "513", + "product_id": "", + "sku": "HANGER-210", + "unit_price_cents": 185, + "uom": "PCS" + }, + { + "description": "Joist Hanger 2x6", + "in_stock": "804", + "product_id": "", + "sku": "HANGER-26", + "unit_price_cents": 145, + "uom": "PCS" + }, + { + "description": "Joist Hanger 2x8", + "in_stock": "689", + "product_id": "", + "sku": "HANGER-28", + "unit_price_cents": 165, + "uom": "PCS" + }, + { + "description": "R-14 Fiberglass Batts 15x93", + "in_stock": "298", + "product_id": "", + "sku": "INS-R13-15", + "unit_price_cents": 6800, + "uom": "BAG" + }, + { + "description": "R-20 Fiberglass Batts 15x93", + "in_stock": "932", + "product_id": "", + "sku": "INS-R19-15", + "unit_price_cents": 8200, + "uom": "BAG" + }, + { + "description": "R-28 Fiberglass Batts 24x48", + "in_stock": "605", + "product_id": "", + "sku": "INS-R30-24", + "unit_price_cents": 9800, + "uom": "BAG" + }, + { + "description": "1x6x6 Cedar Fence Board", + "in_stock": "739", + "product_id": "", + "sku": "LUM-166-WRC", + "unit_price_cents": 510, + "uom": "PCS" + }, + { + "description": "2x10x12 Hem-Fir No.2", + "in_stock": "543", + "product_id": "", + "sku": "LUM-21012-NO2", + "unit_price_cents": 2450, + "uom": "PCS" + }, + { + "description": "2x12x16 Hem-Fir No.2", + "in_stock": "586", + "product_id": "", + "sku": "LUM-21216-NO2", + "unit_price_cents": 4500, + "uom": "PCS" + }, + { + "description": "2x4x10 SPF Premium", + "in_stock": "488", + "product_id": "", + "sku": "LUM-2410-PREM", + "unit_price_cents": 725, + "uom": "PCS" + }, + { + "description": "2x4x12 SPF Premium", + "in_stock": "631", + "product_id": "", + "sku": "LUM-2412-PREM", + "unit_price_cents": 875, + "uom": "PCS" + }, + { + "description": "2x4x8 SPF Premium", + "in_stock": "379", + "product_id": "", + "sku": "LUM-248-PREM", + "unit_price_cents": 550, + "uom": "PCS" }, - "meta": { - "request_id": "" + { + "description": "2x4x92-5/8 SPF Stud", + "in_stock": "635", + "product_id": "", + "sku": "LUM-2492-STUD", + "unit_price_cents": 510, + "uom": "PCS" + }, + { + "description": "2x6x10 SPF Premium", + "in_stock": "869", + "product_id": "", + "sku": "LUM-2610-PREM", + "unit_price_cents": 980, + "uom": "PCS" + }, + { + "description": "2x6x12 SPF Premium", + "in_stock": "200", + "product_id": "", + "sku": "LUM-2612-PREM", + "unit_price_cents": 1175, + "uom": "PCS" + }, + { + "description": "2x6x16 SPF Premium", + "in_stock": "868", + "product_id": "", + "sku": "LUM-2616-PREM", + "unit_price_cents": 1560, + "uom": "PCS" + }, + { + "description": "2x8x10 Hem-Fir No.2", + "in_stock": "622", + "product_id": "", + "sku": "LUM-2810-NO2", + "unit_price_cents": 1390, + "uom": "PCS" + }, + { + "description": "2x8x16 Hem-Fir No.2", + "in_stock": "851", + "product_id": "", + "sku": "LUM-2816-NO2", + "unit_price_cents": 2225, + "uom": "PCS" + }, + { + "description": "4x4x10 Pressure Treated", + "in_stock": "823", + "product_id": "", + "sku": "LUM-4410-PT", + "unit_price_cents": 1575, + "uom": "PCS" + }, + { + "description": "4x4x8 Pressure Treated", + "in_stock": "646", + "product_id": "", + "sku": "LUM-448-PT", + "unit_price_cents": 1250, + "uom": "PCS" + }, + { + "description": "4x4x8 Western Red Cedar", + "in_stock": "900", + "product_id": "", + "sku": "LUM-448-WRC", + "unit_price_cents": 3400, + "uom": "PCS" + }, + { + "description": "6x6x12 Pressure Treated", + "in_stock": "499", + "product_id": "", + "sku": "LUM-6612-PT", + "unit_price_cents": 5500, + "uom": "PCS" + }, + { + "description": "1 X 4 RL #3 SPF", + "in_stock": "787", + "product_id": "", + "sku": "LUMB14RLN3+", + "unit_price_cents": 52, + "uom": "LF" + }, + { + "description": "2 X 4 RL #3 SPF", + "in_stock": "535", + "product_id": "", + "sku": "LUMB24RLN3", + "unit_price_cents": 46, + "uom": "LF" + }, + { + "description": "2 X 4 RL Utility", + "in_stock": "590", + "product_id": "", + "sku": "LUMBUT24RL", + "unit_price_cents": 44, + "uom": "LF" + }, + { + "description": "Baseboard 3-1/4 MDF 16'", + "in_stock": "645", + "product_id": "", + "sku": "MLD-BASE-MDF", + "unit_price_cents": 1950, + "uom": "PCS" + }, + { + "description": "Casing 2-1/4 MDF 14'", + "in_stock": "542", + "product_id": "", + "sku": "MLD-CASE-MDF", + "unit_price_cents": 1350, + "uom": "PCS" + }, + { + "description": "3\" Common Nails (25kg)", + "in_stock": "975", + "product_id": "", + "sku": "NAIL-10D-50", + "unit_price_cents": 6500, + "uom": "BOX" + }, + { + "description": "3-1/4\" Common Nails (25kg)", + "in_stock": "336", + "product_id": "", + "sku": "NAIL-16D-50", + "unit_price_cents": 6500, + "uom": "BOX" + }, + { + "description": "7/16 OSB 4x8", + "in_stock": "326", + "product_id": "", + "sku": "OSB-12", + "unit_price_cents": 1950, + "uom": "PCS" + }, + { + "description": "3/4 OSB T&G 4x8", + "in_stock": "623", + "product_id": "", + "sku": "OSB-34-TG", + "unit_price_cents": 3400, + "uom": "PCS" + }, + { + "description": "1/2 Plywood G1S 4x8 (Fir)", + "in_stock": "359", + "product_id": "", + "sku": "PLY-12-CDX", + "unit_price_cents": 2850, + "uom": "PCS" + }, + { + "description": "3/4 Plywood G1S 4x8 (Fir)", + "in_stock": "859", + "product_id": "", + "sku": "PLY-34-CDX", + "unit_price_cents": 3800, + "uom": "PCS" + }, + { + "description": "Architectural Shingles 30yr", + "in_stock": "990", + "product_id": "", + "sku": "RF-ARCH-30", + "unit_price_cents": 4200, + "uom": "BUNDLE" + }, + { + "description": "Roof Edge Drip 10'", + "in_stock": "135", + "product_id": "", + "sku": "RF-DRIP-10", + "unit_price_cents": 750, + "uom": "PCS" + }, + { + "description": "Roof Edge Drip 10'", + "in_stock": "548", + "product_id": "", + "sku": "RF-EDGE-WHT", + "unit_price_cents": 750, + "uom": "PCS" + }, + { + "description": "Roofing Felt #15", + "in_stock": "404", + "product_id": "", + "sku": "RF-FELT-15", + "unit_price_cents": 2250, + "uom": "RL" + }, + { + "description": "Pipe Boot Flashing", + "in_stock": "399", + "product_id": "", + "sku": "RF-FLASH-PIPE", + "unit_price_cents": 1400, + "uom": "PCS" + }, + { + "description": "Step Flashing", + "in_stock": "272", + "product_id": "", + "sku": "RF-FLASH-STEP", + "unit_price_cents": 450, + "uom": "PCS" + }, + { + "description": "Ice & Water Shield 65'", + "in_stock": "489", + "product_id": "", + "sku": "RF-ICE-65", + "unit_price_cents": 9800, + "uom": "RL" + }, + { + "description": "Ice & Water Shield", + "in_stock": "525", + "product_id": "", + "sku": "RF-ICE-WTR", + "unit_price_cents": 9800, + "uom": "RL" + }, + { + "description": "1.25\" Roofing Nails 2.5kg", + "in_stock": "958", + "product_id": "", + "sku": "RF-NAIL-125", + "unit_price_cents": 1900, + "uom": "BOX" + }, + { + "description": "Ridge Vent 4'", + "in_stock": "690", + "product_id": "", + "sku": "RF-RIDGE-4", + "unit_price_cents": 1350, + "uom": "PCS" + }, + { + "description": "Architectural Shingles (Black)", + "in_stock": "880", + "product_id": "", + "sku": "RF-SH-BLK", + "unit_price_cents": 4200, + "uom": "BUNDLE" + }, + { + "description": "Architectural Shingles (Weathered Wood)", + "in_stock": "750", + "product_id": "", + "sku": "RF-SH-WW", + "unit_price_cents": 4200, + "uom": "BUNDLE" + }, + { + "description": "Starter Strip", + "in_stock": "876", + "product_id": "", + "sku": "RF-START", + "unit_price_cents": 1000, + "uom": "PCS" + }, + { + "description": "3\" Deck Screws (2.5kg)", + "in_stock": "537", + "product_id": "", + "sku": "SCR-DECK-3-5", + "unit_price_cents": 2999, + "uom": "BOX" + }, + { + "description": "Simpson Strong-Tie LUS28", + "in_stock": "846", + "product_id": "", + "sku": "SIMP-LUS28", + "unit_price_cents": 150, + "uom": "PCS" + }, + { + "description": "Hurricane Tie H1", + "in_stock": "583", + "product_id": "", + "sku": "TIE-H1", + "unit_price_cents": 115, + "uom": "PCS" } - } + ] } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_returns.json b/core/internal/characterization/testdata/goldens/pos_returns.json index a00b746d1..695ea2438 100644 --- a/core/internal/characterization/testdata/goldens/pos_returns.json +++ b/core/internal/characterization/testdata/goldens/pos_returns.json @@ -9,12 +9,12 @@ { "description": "returned lumber", "product_id": "", - "quantity": 2, - "unit_price": 12.5 + "quantity": "2", + "unit_price_ten_thousandths": 125000 } ], "reason": "r1b c wrong size", - "refund_method": "CASH", + "refund_method": "cash", "register_id": "REG-01" } }, @@ -22,31 +22,36 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "gl_entry_id": "", - "id": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", + "id": "", "lines": [ { "description": "returned lumber", - "id": "", - "line_total": 2500, + "id": "", + "line_total_cents": -2500, + "position": 0, "product_id": "", - "quantity": 2, + "quantity": "2", "restock": true, - "return_id": "", - "unit_price": 1250, + "unit_price_ten_thousandths": 125000, "uom": "EA" } ], + "number": "RTN-000001", + "original_sale_id": null, "reason": "r1b c wrong size", - "refund_method": "CASH", + "refund_method": "cash", "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 2500, - "tax_amount": 300, - "till_session_id": "", - "total": 2800 + "revision": 0, + "subtotal_cents": -2500, + "tax_cents": -300, + "till_session_id": "", + "total_cents": -2800 } } }, @@ -56,16 +61,17 @@ "method": "POST", "path": "/api/v1/pos/returns", "body": { - "customer_id": "", + "customer_id": "", "lines": [ { - "product_id": "", - "quantity": 1, + "product_id": "", + "quantity": "1", "restock": false, - "unit_price": 30 + "unit_price_ten_thousandths": 300000 } ], - "refund_method": "ACCOUNT", + "reason": "r1b c credit the account", + "refund_method": "account", "register_id": "REG-01" } }, @@ -73,32 +79,36 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "customer_id": "", - "gl_entry_id": "", - "id": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", + "id": "", "lines": [ { "description": "", - "id": "", - "line_total": 3000, - "product_id": "", - "quantity": 1, + "id": "", + "line_total_cents": -3000, + "position": 0, + "product_id": "", + "quantity": "1", "restock": false, - "return_id": "", - "unit_price": 3000, + "unit_price_ten_thousandths": 300000, "uom": "EA" } ], - "reason": "", - "refund_method": "ACCOUNT", + "number": "RTN-000002", + "original_sale_id": null, + "reason": "r1b c credit the account", + "refund_method": "account", "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 3000, - "tax_amount": 0, - "till_session_id": "", - "total": 3000 + "revision": 0, + "subtotal_cents": -3000, + "tax_cents": 0, + "till_session_id": "", + "total_cents": -3000 } } }, @@ -111,25 +121,49 @@ "lines": [ { "product_id": "", - "quantity": 1, - "unit_price": 5 + "quantity": "1", + "unit_price_ten_thousandths": 50000 } ], - "refund_method": "ACCOUNT", + "reason": "r1b c no account", + "refund_method": "account", "register_id": "REG-01" } }, "response": { - "status": 400, + "status": 201, "content_type": "application/json", "body": { - "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" - }, - "meta": { - "request_id": "" - } + "branch_id": "", + "cashier_id": "", + "created_at": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", + "id": "", + "lines": [ + { + "description": "", + "id": "", + "line_total_cents": -500, + "position": 0, + "product_id": "", + "quantity": "1", + "restock": true, + "unit_price_ten_thousandths": 50000, + "uom": "EA" + } + ], + "number": "RTN-000003", + "original_sale_id": null, + "reason": "r1b c no account", + "refund_method": "account", + "register_id": "REG-01", + "revision": 0, + "subtotal_cents": -500, + "tax_cents": -60, + "till_session_id": "", + "total_cents": -560 } } }, @@ -140,7 +174,8 @@ "path": "/api/v1/pos/returns", "body": { "lines": [], - "refund_method": "CASH", + "reason": "r1b c", + "refund_method": "cash", "register_id": "REG-01" } }, @@ -149,11 +184,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "validation_failed", + "details": [ + { + "field": "lines", + "message": "name at least one returned line" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -167,10 +208,11 @@ "lines": [ { "product_id": "", - "quantity": 1, - "unit_price": 5 + "quantity": "1", + "unit_price_ten_thousandths": 50000 } ], + "reason": "r1b c", "refund_method": "BARTER", "register_id": "REG-01" } @@ -180,11 +222,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "validation_failed", + "details": [ + { + "field": "refund_method", + "message": "must be cash, card or account" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -198,11 +246,12 @@ "lines": [ { "product_id": "", - "quantity": 0, - "unit_price": 5 + "quantity": "0", + "unit_price_ten_thousandths": 50000 } ], - "refund_method": "CASH", + "reason": "r1b c", + "refund_method": "cash", "register_id": "REG-01" } }, @@ -211,11 +260,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "validation_failed", + "details": [ + { + "field": "lines[0].quantity", + "message": "must be greater than zero" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -232,11 +287,11 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -245,37 +300,42 @@ "name": "pos.return.get", "request": { "method": "GET", - "path": "/api/v1/pos/returns/" + "path": "/api/v1/pos/returns/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "created_at": "", - "gl_entry_id": "", - "id": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", + "id": "", "lines": [ { "description": "returned lumber", - "id": "", - "line_total": 2500, + "id": "", + "line_total_cents": 2500, + "position": 0, "product_id": "", - "quantity": 2, + "quantity": "2", "restock": true, - "return_id": "", - "unit_price": 1250, + "unit_price_ten_thousandths": 125000, "uom": "EA" } ], + "number": "RTN-000001", + "original_sale_id": null, "reason": "r1b c wrong size", - "refund_method": "CASH", + "refund_method": "cash", "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 2500, - "tax_amount": 300, - "till_session_id": "", - "total": 2800 + "revision": 1, + "subtotal_cents": -2500, + "tax_cents": -300, + "till_session_id": "", + "total_cents": -2800 } } }, @@ -283,18 +343,18 @@ "name": "pos.return.get.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/returns/" + "path": "/api/v1/pos/returns/" }, "response": { "status": 404, "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "return not found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -310,11 +370,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -329,39 +395,68 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "returns": [ - { - "cashier_id": "", - "created_at": "", - "customer_id": "", - "gl_entry_id": "", - "id": "", - "reason": "", - "refund_method": "ACCOUNT", - "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 3000, - "tax_amount": 0, - "till_session_id": "", - "total": 3000 - }, - { - "cashier_id": "", - "created_at": "", - "gl_entry_id": "", - "id": "", - "reason": "r1b c wrong size", - "refund_method": "CASH", - "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 2500, - "tax_amount": 300, - "till_session_id": "", - "total": 2800 - } - ] - } + "body": [ + { + "branch_id": "", + "cashier_id": "", + "created_at": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", + "id": "", + "lines": null, + "number": "RTN-000001", + "original_sale_id": null, + "reason": "r1b c wrong size", + "refund_method": "cash", + "register_id": "REG-01", + "revision": 1, + "subtotal_cents": -2500, + "tax_cents": -300, + "till_session_id": "", + "total_cents": -2800 + }, + { + "branch_id": "", + "cashier_id": "", + "created_at": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", + "id": "", + "lines": null, + "number": "RTN-000002", + "original_sale_id": null, + "reason": "r1b c credit the account", + "refund_method": "account", + "register_id": "REG-01", + "revision": 1, + "subtotal_cents": -3000, + "tax_cents": 0, + "till_session_id": "", + "total_cents": -3000 + }, + { + "branch_id": "", + "cashier_id": "", + "created_at": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", + "id": "", + "lines": null, + "number": "RTN-000003", + "original_sale_id": null, + "reason": "r1b c no account", + "refund_method": "account", + "register_id": "REG-01", + "revision": 1, + "subtotal_cents": -500, + "tax_cents": -60, + "till_session_id": "", + "total_cents": -560 + } + ] } }, { @@ -373,9 +468,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "returns": null - } + "body": null } }, { @@ -387,9 +480,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "returns": null - } + "body": null } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_sync.json b/core/internal/characterization/testdata/goldens/pos_sync.json index 82ada0d46..7d33e8e06 100644 --- a/core/internal/characterization/testdata/goldens/pos_sync.json +++ b/core/internal/characterization/testdata/goldens/pos_sync.json @@ -14,14 +14,13 @@ "items": [ { "product_id": "", - "quantity": 1 + "quantity": "1" } ], - "register_id": "REG-01", "tenders": [ { - "amount": 200, - "method": "CASH" + "amount_cents": 20000, + "method": "cash" } ] } @@ -35,8 +34,16 @@ "body": { "batch_id": "r1c-batch-1", "duplicate_count": 0, - "error_count": 0, - "synced_count": 1 + "error_count": 1, + "errors": [ + { + "client_id": "", + "reason": "complete: this write needs If-Match or a body revision" + } + ], + "pending": null, + "pending_count": 0, + "synced_count": 0 } } }, @@ -55,14 +62,13 @@ "items": [ { "product_id": "", - "quantity": 1 + "quantity": "1" } ], - "register_id": "REG-01", "tenders": [ { - "amount": 200, - "method": "CASH" + "amount_cents": 20000, + "method": "cash" } ] } @@ -77,6 +83,9 @@ "batch_id": "r1c-batch-1", "duplicate_count": 1, "error_count": 0, + "errors": null, + "pending": null, + "pending_count": 0, "synced_count": 0 } } @@ -96,13 +105,13 @@ "items": [ { "product_id": "", - "quantity": 1 + "quantity": "1" } ], "tenders": [ { - "amount": 0.01, - "method": "CASH" + "amount_cents": 1, + "method": "cash" } ] } @@ -120,9 +129,11 @@ "errors": [ { "client_id": "", - "reason": "complete: insufficient tender: need 616 cents, got 1 cents" + "reason": "complete: the sale needs 616 cents and the tenders carry 1" } ], + "pending": null, + "pending_count": 0, "synced_count": 0 } } @@ -146,8 +157,18 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "validation_failed", + "details": [ + { + "field": "batch_id", + "message": "is required" + }, + { + "field": "items[0].tenders", + "message": "name at least one tender" + } + ], + "message": "one or more fields failed validation" }, "meta": { "request_id": "" @@ -170,8 +191,14 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "validation_failed", + "details": [ + { + "field": "items", + "message": "cannot be empty" + } + ], + "message": "one or more fields failed validation" }, "meta": { "request_id": "" @@ -191,8 +218,8 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { "request_id": "" @@ -210,59 +237,108 @@ "response": { "status": 200, "content_type": "application/json", - "body": [ - { - "completed_at": "", - "created_at": "", - "id": "", - "item_count": 1, - "register_id": "REG-01", - "status": "COMPLETED", - "total": 616 - }, - { - "completed_at": "", - "created_at": "", - "id": "", - "item_count": 1, - "register_id": "REG-01", - "status": "VOIDED", - "total": 616 - }, - { - "completed_at": "", - "created_at": "", - "id": "", - "item_count": 2, - "register_id": "REG-01", - "status": "COMPLETED", - "total": 1872 - }, - { - "created_at": "", - "id": "", - "item_count": 0, - "register_id": "REG-01", - "status": "OPEN", - "total": 0 - }, - { - "created_at": "", - "id": "", - "item_count": 1, - "register_id": "REG-01", - "status": "OPEN", - "total": 616 - }, - { - "created_at": "", - "id": "", - "item_count": 1, - "register_id": "REG-01", - "status": "VOIDED", - "total": 4256 - } - ] + "body": { + "items": [ + { + "branch_id": "", + "cashier_id": "", + "completed_at": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": "", + "item_count": 1, + "number": "POS-000002", + "register_id": "REG-01", + "revision": 5, + "status": "completed", + "total_cents": 1403 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": "", + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": "", + "item_count": 1, + "number": "POS-000004", + "register_id": "REG-01", + "revision": 4, + "status": "voided", + "total_cents": 616 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "item_count": 0, + "number": "POS-000001", + "register_id": "REG-01", + "revision": 1, + "status": "open", + "total_cents": 0 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "item_count": 1, + "number": "POS-000003", + "register_id": "REG-01", + "revision": 2, + "status": "open", + "total_cents": 4256 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "item_count": 1, + "number": "POS-000005", + "register_id": "REG-01", + "revision": 2, + "status": "open", + "total_cents": 616 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "item_count": 1, + "number": "POS-000006", + "register_id": "REG-01", + "revision": 2, + "status": "open", + "total_cents": 616 + } + ], + "limit": 50, + "next_cursor": null + } } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_till_close.json b/core/internal/characterization/testdata/goldens/pos_till_close.json index 9f1962d22..24530a583 100644 --- a/core/internal/characterization/testdata/goldens/pos_till_close.json +++ b/core/internal/characterization/testdata/goldens/pos_till_close.json @@ -9,24 +9,30 @@ "status": 200, "content_type": "application/json", "body": { - "change_given": 48596, + "change_cents": 48597, "expected_by_method": { - "CASH": 11404 + "CASH": 8603 }, "sale_count": 1, - "sales_total": 1872, + "sales_total_cents": 1403, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", + "closed_at": null, + "counted_by_method": null, + "expected_by_method": null, + "gl_entry_id": null, "id": "", "notes": "", "opened_at": "", - "opening_float": 10000, + "opening_float_cents": 10000, + "over_short_cents": null, "register_id": "REG-01", - "status": "OPEN" + "status": "open" }, - "tax_total": 0, + "tax_total_cents": 0, "tendered_by_method": { - "CASH": 50000 + "CASH": 1403 } } } @@ -42,11 +48,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -55,18 +67,18 @@ "name": "pos.till.report.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/till//report" + "path": "/api/v1/pos/till//report" }, "response": { - "status": 500, + "status": 404, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "not_found", + "message": "till session not found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -82,11 +94,11 @@ "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "no Z-report for this session" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -102,11 +114,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -118,7 +136,7 @@ "path": "/api/v1/pos/till/not-a-uuid/close", "body": { "counted_by_method": { - "CASH": 1 + "cash": 100 } } }, @@ -127,11 +145,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -148,11 +172,11 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -161,23 +185,23 @@ "name": "pos.till.close.not_found", "request": { "method": "POST", - "path": "/api/v1/pos/till//close", + "path": "/api/v1/pos/till//close", "body": { "counted_by_method": { - "CASH": 1 + "cash": 100 } } }, "response": { - "status": 409, + "status": 404, "content_type": "application/json", "body": { "error": { - "code": "CONFLICT", - "message": "Conflict" + "code": "not_found", + "message": "till session not found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -189,7 +213,7 @@ "path": "/api/v1/pos/till//close", "body": { "counted_by_method": { - "CASH": 200 + "cash": 20000 }, "notes": "r1b c close count" } @@ -198,33 +222,34 @@ "status": 200, "content_type": "application/json", "body": { - "change_given": 48596, + "change_cents": 48597, "expected_by_method": { - "CASH": 11404 + "CASH": 8603 }, "sale_count": 1, - "sales_total": 1872, + "sales_total_cents": 1403, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "closed_at": "", "counted_by_method": { "CASH": 20000 }, "expected_by_method": { - "CASH": 11404 + "CASH": 8603 }, - "gl_entry_id": "", + "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", - "opening_float": 10000, - "over_short": 8596, + "opening_float_cents": 10000, + "over_short_cents": 11397, "register_id": "REG-01", - "status": "CLOSED" + "status": "closed" }, - "tax_total": 0, + "tax_total_cents": 0, "tendered_by_method": { - "CASH": 50000 + "CASH": 1403 } } } @@ -236,7 +261,7 @@ "path": "/api/v1/pos/till//close", "body": { "counted_by_method": { - "CASH": 200 + "cash": 20000 } } }, @@ -245,11 +270,11 @@ "content_type": "application/json", "body": { "error": { - "code": "CONFLICT", - "message": "Conflict" + "code": "invalid_state_transition", + "message": "the till session is not open" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -261,10 +286,16 @@ "path": "/api/v1/pos/till/current?register_id=REG-01" }, "response": { - "status": 200, + "status": 404, "content_type": "application/json", "body": { - "session": null + "error": { + "code": "not_found", + "message": "no open till session for this register" + }, + "meta": { + "request_id": "" + } } } }, @@ -274,7 +305,7 @@ "method": "POST", "path": "/api/v1/pos/till/open", "body": { - "opening_float": 100, + "opening_float_cents": 10000, "register_id": "REG-01" } }, @@ -282,13 +313,19 @@ "status": 201, "content_type": "application/json", "body": { - "cashier_id": "", - "id": "", + "branch_id": "", + "cashier_id": "", + "closed_at": null, + "counted_by_method": null, + "expected_by_method": null, + "gl_entry_id": null, + "id": "", "notes": "", "opened_at": "", - "opening_float": 10000, + "opening_float_cents": 10000, + "over_short_cents": null, "register_id": "REG-01", - "status": "OPEN" + "status": "open" } } }, @@ -302,39 +339,43 @@ "status": 200, "content_type": "application/json", "body": { - "change_given": 48596, + "change_cents": 48597, "expected_by_method": { - "CASH": 11404 + "CASH": 8603 }, "sale_count": 1, - "sales_total": 1872, + "sales_total_cents": 1403, "session": { - "cashier_id": "", + "branch_id": "", + "cashier_id": "", "closed_at": "", "counted_by_method": { "CASH": 20000 }, "expected_by_method": { - "CASH": 11404 + "CASH": 8603 }, - "gl_entry_id": "", + "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", - "opening_float": 10000, - "over_short": 8596, + "opening_float_cents": 10000, + "over_short_cents": 11397, "register_id": "REG-01", - "status": "CLOSED" + "status": "closed" }, - "tax_total": 0, + "tax_total_cents": 0, "tendered_by_method": { - "CASH": 50000 + "CASH": 1403 } } } }, { "name": "pos.till.zreport", + "mask_fields": { + "payload": "" + }, "request": { "method": "GET", "path": "/api/v1/pos/till//zreport" @@ -343,39 +384,11 @@ "status": 200, "content_type": "application/json", "body": { + "branch_id": "", "generated_at": "", - "id": "", - "over_short": 8596, - "payload": { - "change_given": 48596, - "expected_by_method": { - "CASH": 11404 - }, - "sale_count": 1, - "sales_total": 1872, - "session": { - "cashier_id": "", - "closed_at": "", - "counted_by_method": { - "CASH": 20000 - }, - "expected_by_method": { - "CASH": 11404 - }, - "gl_entry_id": "", - "id": "", - "notes": "r1b c close count", - "opened_at": "", - "opening_float": 10000, - "over_short": 8596, - "register_id": "REG-01", - "status": "CLOSED" - }, - "tax_total": 0, - "tendered_by_method": { - "CASH": 50000 - } - }, + "id": "", + "over_short_cents": 11397, + "payload": "", "register_id": "REG-01", "till_session_id": "" } @@ -385,24 +398,27 @@ "name": "pos.till.zreport.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/till//zreport" + "path": "/api/v1/pos/till//zreport" }, "response": { "status": 404, "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "no Z-report for this session" }, "meta": { - "request_id": "" + "request_id": "" } } } }, { "name": "pos.zreports.list", + "mask_fields": { + "payload": "" + }, "request": { "method": "GET", "path": "/api/v1/pos/zreports?register_id=REG-01" @@ -410,47 +426,17 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "z_reports": [ - { - "generated_at": "", - "id": "", - "over_short": 8596, - "payload": { - "change_given": 48596, - "expected_by_method": { - "CASH": 11404 - }, - "sale_count": 1, - "sales_total": 1872, - "session": { - "cashier_id": "", - "closed_at": "", - "counted_by_method": { - "CASH": 20000 - }, - "expected_by_method": { - "CASH": 11404 - }, - "gl_entry_id": "", - "id": "", - "notes": "r1b c close count", - "opened_at": "", - "opening_float": 10000, - "over_short": 8596, - "register_id": "REG-01", - "status": "CLOSED" - }, - "tax_total": 0, - "tendered_by_method": { - "CASH": 50000 - } - }, - "register_id": "REG-01", - "till_session_id": "" - } - ] - } + "body": [ + { + "branch_id": "", + "generated_at": "", + "id": "", + "over_short_cents": 11397, + "payload": "", + "register_id": "REG-01", + "till_session_id": "" + } + ] } }, { @@ -462,9 +448,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "z_reports": null - } + "body": null } }, { @@ -476,9 +460,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "z_reports": null - } + "body": null } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_transactions.json b/core/internal/characterization/testdata/goldens/pos_transactions.json index f832aaad6..d18f6714e 100644 --- a/core/internal/characterization/testdata/goldens/pos_transactions.json +++ b/core/internal/characterization/testdata/goldens/pos_transactions.json @@ -9,15 +9,19 @@ "status": 200, "content_type": "application/json", "body": { - "session": { - "cashier_id": "", - "id": "", - "notes": "", - "opened_at": "", - "opening_float": 10000, - "register_id": "REG-01", - "status": "OPEN" - } + "branch_id": "", + "cashier_id": "", + "closed_at": null, + "counted_by_method": null, + "expected_by_method": null, + "gl_entry_id": null, + "id": "", + "notes": "", + "opened_at": "", + "opening_float_cents": 10000, + "over_short_cents": null, + "register_id": "REG-01", + "status": "open" } } }, @@ -28,7 +32,7 @@ "path": "/api/v1/inventory/adjust", "body": { "is_delta": true, - "product_id": "", + "product_id": "", "quantity": 50, "reason": "r1b c pos setup" } @@ -48,7 +52,7 @@ "path": "/api/v1/inventory/adjust", "body": { "is_delta": true, - "product_id": "", + "product_id": "", "quantity": 10, "reason": "r1b c pos setup" } @@ -67,7 +71,7 @@ "method": "POST", "path": "/api/v1/pos/till/open", "body": { - "opening_float": 50, + "opening_float_cents": 5000, "register_id": "REG-01" } }, @@ -76,11 +80,17 @@ "content_type": "application/json", "body": { "error": { - "code": "CONFLICT", - "message": "Conflict" + "code": "conflict", + "details": [ + { + "code": "till_open", + "message": "register REG-01 already has an open till session" + } + ], + "message": "register REG-01 already has an open till session" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -97,11 +107,11 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -112,7 +122,7 @@ "method": "POST", "path": "/api/v1/pos/transactions", "body": { - "customer_id": "", + "customer_id": "", "register_id": "REG-01" } }, @@ -120,18 +130,25 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "customer_id": "", - "id": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "lines": [], + "number": "POS-000002", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, - "till_session_id": "", - "total": 0 + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], + "till_session_id": "", + "total_cents": 0 } } }, @@ -148,17 +165,25 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "lines": [], + "number": "POS-000003", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, - "till_session_id": "", - "total": 0 + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], + "till_session_id": "", + "total_cents": 0 } } }, @@ -175,17 +200,25 @@ "status": 201, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "lines": [], + "number": "POS-000004", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, - "till_session_id": "", - "total": 0 + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], + "till_session_id": "", + "total_cents": 0 } } }, @@ -201,11 +234,11 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -222,154 +255,154 @@ "body": [ { "description": "1x6x6 Cedar Fence Board", - "in_stock": 739, - "product_id": "", + "in_stock": "739", + "product_id": "", "sku": "LUM-166-WRC", - "unit_price": 5.1, + "unit_price_cents": 510, "uom": "PCS" }, { "description": "2x10x12 Hem-Fir No.2", - "in_stock": 543, - "product_id": "", + "in_stock": "543", + "product_id": "", "sku": "LUM-21012-NO2", - "unit_price": 24.5, + "unit_price_cents": 2450, "uom": "PCS" }, { "description": "2x12x16 Hem-Fir No.2", - "in_stock": 586, - "product_id": "", + "in_stock": "586", + "product_id": "", "sku": "LUM-21216-NO2", - "unit_price": 45, + "unit_price_cents": 4500, "uom": "PCS" }, { "description": "2x4x10 SPF Premium", - "in_stock": 488, - "product_id": "", + "in_stock": "488", + "product_id": "", "sku": "LUM-2410-PREM", - "unit_price": 7.25, + "unit_price_cents": 725, "uom": "PCS" }, { "description": "2x4x12 SPF Premium", - "in_stock": 631, - "product_id": "", + "in_stock": "631", + "product_id": "", "sku": "LUM-2412-PREM", - "unit_price": 8.75, + "unit_price_cents": 875, "uom": "PCS" }, { "description": "2x4x8 SPF Premium", - "in_stock": 429, - "product_id": "", + "in_stock": "415", + "product_id": "", "sku": "LUM-248-PREM", - "unit_price": 5.5, + "unit_price_cents": 550, "uom": "PCS" }, { "description": "2x4x92-5/8 SPF Stud", - "in_stock": 635, - "product_id": "", + "in_stock": "635", + "product_id": "", "sku": "LUM-2492-STUD", - "unit_price": 5.1, + "unit_price_cents": 510, "uom": "PCS" }, { "description": "2x6x10 SPF Premium", - "in_stock": 869, - "product_id": "", + "in_stock": "869", + "product_id": "", "sku": "LUM-2610-PREM", - "unit_price": 9.8, + "unit_price_cents": 980, "uom": "PCS" }, { "description": "2x6x12 SPF Premium", - "in_stock": 200, - "product_id": "", + "in_stock": "200", + "product_id": "", "sku": "LUM-2612-PREM", - "unit_price": 11.75, + "unit_price_cents": 1175, "uom": "PCS" }, { "description": "2x6x16 SPF Premium", - "in_stock": 868, - "product_id": "", + "in_stock": "868", + "product_id": "", "sku": "LUM-2616-PREM", - "unit_price": 15.6, + "unit_price_cents": 1560, "uom": "PCS" }, { "description": "2x8x10 Hem-Fir No.2", - "in_stock": 622, - "product_id": "", + "in_stock": "622", + "product_id": "", "sku": "LUM-2810-NO2", - "unit_price": 13.9, + "unit_price_cents": 1390, "uom": "PCS" }, { "description": "2x8x16 Hem-Fir No.2", - "in_stock": 851, - "product_id": "", + "in_stock": "851", + "product_id": "", "sku": "LUM-2816-NO2", - "unit_price": 22.25, + "unit_price_cents": 2225, "uom": "PCS" }, { "description": "4x4x10 Pressure Treated", - "in_stock": 823, - "product_id": "", + "in_stock": "823", + "product_id": "", "sku": "LUM-4410-PT", - "unit_price": 15.75, + "unit_price_cents": 1575, "uom": "PCS" }, { "description": "4x4x8 Pressure Treated", - "in_stock": 646, - "product_id": "", + "in_stock": "646", + "product_id": "", "sku": "LUM-448-PT", - "unit_price": 12.5, + "unit_price_cents": 1250, "uom": "PCS" }, { "description": "4x4x8 Western Red Cedar", - "in_stock": 900, - "product_id": "", + "in_stock": "900", + "product_id": "", "sku": "LUM-448-WRC", - "unit_price": 34, + "unit_price_cents": 3400, "uom": "PCS" }, { "description": "6x6x12 Pressure Treated", - "in_stock": 499, - "product_id": "", + "in_stock": "499", + "product_id": "", "sku": "LUM-6612-PT", - "unit_price": 55, + "unit_price_cents": 5500, "uom": "PCS" }, { "description": "1 X 4 RL #3 SPF", - "in_stock": 787, - "product_id": "", + "in_stock": "787", + "product_id": "", "sku": "LUMB14RLN3+", - "unit_price": 0.52, + "unit_price_cents": 52, "uom": "LF" }, { "description": "2 X 4 RL #3 SPF", - "in_stock": 535, - "product_id": "", + "in_stock": "535", + "product_id": "", "sku": "LUMB24RLN3", - "unit_price": 0.46, + "unit_price_cents": 46, "uom": "LF" }, { "description": "2 X 4 RL Utility", - "in_stock": 590, - "product_id": "", + "in_stock": "590", + "product_id": "", "sku": "LUMBUT24RL", - "unit_price": 0.44, + "unit_price_cents": 44, "uom": "LF" } ] @@ -403,41 +436,69 @@ "name": "pos.item.add", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//items", + "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", - "quantity": 3 + "line": { + "product_id": "", + "quantity": "3" + } } }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 1403, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 46750, + "product_id": "", + "quantity": "3", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 46750, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000002", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 1404, - "tax_amount": 0, - "till_session_id": "", - "total": 1404 + "revision": 2, + "status": "open", + "subtotal_cents": 1403, + "tax_cents": 168, + "tenders": [], + "till_session_id": "", + "total_cents": 1571 } } }, @@ -445,53 +506,100 @@ "name": "pos.item.add.second", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//items", + "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", - "quantity": 2, - "uom": "EA" + "line": { + "product_id": "", + "quantity": "2" + } } }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 1403, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 46750, + "product_id": "", + "quantity": "3", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 46750, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null }, { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "3/4 Plywood G1S 4x8 (Fir)", - "id": "", - "line_total": 6460, - "product_id": "", - "quantity": 2, - "transaction_id": "", - "unit_price": 3230, - "uom": "EA" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 6460, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 1, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 323000, + "product_id": "", + "quantity": "2", + "revenue_account_code": null, + "sku": "PLY-34-CDX", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 323000, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000002", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 7864, - "tax_amount": 0, - "till_session_id": "", - "total": 7864 + "revision": 3, + "status": "open", + "subtotal_cents": 7863, + "tax_cents": 944, + "tenders": [], + "till_session_id": "", + "total_cents": 8807 } } }, @@ -501,8 +609,10 @@ "method": "POST", "path": "/api/v1/pos/transactions/not-a-uuid/items", "body": { - "product_id": "", - "quantity": 1 + "line": { + "product_id": "", + "quantity": "1" + } } }, "response": { @@ -510,11 +620,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -523,7 +639,7 @@ "name": "pos.item.add.bad_body", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//items", + "path": "/api/v1/pos/transactions//items", "body": "not an object" }, "response": { @@ -531,11 +647,11 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -544,22 +660,30 @@ "name": "pos.item.add.unknown_product", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//items", + "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", - "quantity": 1 + "line": { + "product_id": "", + "quantity": "1" + } } }, "response": { - "status": 500, + "status": 400, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "validation_failed", + "details": [ + { + "field": "line.product_id", + "message": "no such product" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -568,37 +692,63 @@ "name": "pos.item.remove", "request": { "method": "DELETE", - "path": "/api/v1/pos/transactions//items/" + "path": "/api/v1/pos/transactions//items/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 1403, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 46750, + "product_id": "", + "quantity": "3", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 46750, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000002", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 1404, - "tax_amount": 0, - "till_session_id": "", - "total": 1404 + "revision": 4, + "status": "open", + "subtotal_cents": 1403, + "tax_cents": 168, + "tenders": [], + "till_session_id": "", + "total_cents": 1571 } } }, @@ -606,18 +756,24 @@ "name": "pos.item.remove.bad_tx_id", "request": { "method": "DELETE", - "path": "/api/v1/pos/transactions/not-a-uuid/items/" + "path": "/api/v1/pos/transactions/not-a-uuid/items/" }, "response": { "status": 400, "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -626,18 +782,24 @@ "name": "pos.item.remove.bad_item_id", "request": { "method": "DELETE", - "path": "/api/v1/pos/transactions//items/not-a-uuid" + "path": "/api/v1/pos/transactions//items/not-a-uuid" }, "response": { "status": 400, "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "itemId", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -646,37 +808,19 @@ "name": "pos.item.remove.unknown_item", "request": { "method": "DELETE", - "path": "/api/v1/pos/transactions//items/" + "path": "/api/v1/pos/transactions//items/" }, "response": { - "status": 200, + "status": 404, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, - "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ - { - "created_at": "", - "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" - } - ], - "register_id": "REG-01", - "status": "OPEN", - "subtotal": 1404, - "tax_amount": 0, - "till_session_id": "", - "total": 1404 + "error": { + "code": "not_found", + "message": "line not found on this sale" + }, + "meta": { + "request_id": "" + } } } }, @@ -684,37 +828,63 @@ "name": "pos.transaction.get", "request": { "method": "GET", - "path": "/api/v1/pos/transactions/" + "path": "/api/v1/pos/transactions/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 1403, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 46750, + "product_id": "", + "quantity": "3", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 46750, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000002", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 1404, - "tax_amount": 0, - "till_session_id": "", - "total": 1404 + "revision": 4, + "status": "open", + "subtotal_cents": 1403, + "tax_cents": 168, + "tenders": [], + "till_session_id": "", + "total_cents": 1571 } } }, @@ -722,18 +892,18 @@ "name": "pos.transaction.get.not_found", "request": { "method": "GET", - "path": "/api/v1/pos/transactions/" + "path": "/api/v1/pos/transactions/" }, "response": { "status": 404, "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "sale not found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -749,11 +919,17 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -768,40 +944,76 @@ "response": { "status": 200, "content_type": "application/json", - "body": [ - { - "created_at": "", - "id": "", - "item_count": 0, - "register_id": "REG-01", - "status": "OPEN", - "total": 0 - }, - { - "created_at": "", - "id": "", - "item_count": 0, - "register_id": "REG-01", - "status": "OPEN", - "total": 0 - }, - { - "created_at": "", - "id": "", - "item_count": 0, - "register_id": "REG-01", - "status": "OPEN", - "total": 0 - }, - { - "created_at": "", - "id": "", - "item_count": 1, - "register_id": "REG-01", - "status": "OPEN", - "total": 1404 - } - ] + "body": { + "items": [ + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "item_count": 0, + "number": "POS-000001", + "register_id": "REG-01", + "revision": 1, + "status": "open", + "total_cents": 0 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "item_count": 1, + "number": "POS-000002", + "register_id": "REG-01", + "revision": 4, + "status": "open", + "total_cents": 1571 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "item_count": 0, + "number": "POS-000003", + "register_id": "REG-01", + "revision": 1, + "status": "open", + "total_cents": 0 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "item_count": 0, + "number": "POS-000004", + "register_id": "REG-01", + "revision": 1, + "status": "open", + "total_cents": 0 + } + ], + "limit": 50, + "next_cursor": null + } } }, { @@ -813,14 +1025,70 @@ "response": { "status": 200, "content_type": "application/json", - "body": [] + "body": { + "items": [], + "limit": 50, + "next_cursor": null + } + } + }, + { + "name": "pos.transaction.list.bad_status", + "request": { + "method": "GET", + "path": "/api/v1/pos/transactions?status=OPEN" + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "bad_request", + "details": [ + { + "field": "status", + "message": "must be one of: open, held, completed, voided" + } + ], + "message": "one or more fields failed validation" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "pos.transaction.list.unknown_parameter", + "request": { + "method": "GET", + "path": "/api/v1/pos/transactions?bogus=1" + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "unsupported_query_parameter", + "details": [ + { + "field": "bogus", + "message": "unsupported query parameter" + } + ], + "message": "one or more query parameters are not supported by this route" + }, + "meta": { + "request_id": "" + } + } } }, { "name": "pos.transaction.complete.no_tenders", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", "body": { "tenders": [] } @@ -830,8 +1098,14 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "validation_failed", + "details": [ + { + "field": "tenders", + "message": "name at least one tender" + } + ], + "message": "one or more fields failed validation" }, "meta": { "request_id": "" @@ -847,8 +1121,8 @@ "body": { "tenders": [ { - "amount": 1, - "method": "CASH" + "amount_cents": 100, + "method": "cash" } ] } @@ -858,8 +1132,14 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { "request_id": "" @@ -871,7 +1151,7 @@ "name": "pos.transaction.complete.bad_body", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", "body": "not an object" }, "response": { @@ -879,8 +1159,8 @@ "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "message": "request body must be a JSON object" }, "meta": { "request_id": "" @@ -889,26 +1169,26 @@ } }, { - "name": "pos.transaction.complete.insufficient", + "name": "pos.transaction.complete.no_precondition", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", "body": { "tenders": [ { - "amount": 0.01, - "method": "CASH" + "amount_cents": 50000, + "method": "cash" } ] } }, "response": { - "status": 422, + "status": 428, "content_type": "application/json", "body": { "error": { - "code": "UNPROCESSABLE_ENTITY", - "message": "Unprocessable Entity" + "code": "precondition_required", + "message": "this write needs If-Match or a body revision" }, "meta": { "request_id": "" @@ -917,26 +1197,29 @@ } }, { - "name": "pos.transaction.complete.not_found", + "name": "pos.transaction.complete.stale_revision", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"1\"" + }, "body": { "tenders": [ { - "amount": 1, - "method": "CASH" + "amount_cents": 50000, + "method": "cash" } ] } }, "response": { - "status": 422, + "status": 409, "content_type": "application/json", "body": { "error": { - "code": "UNPROCESSABLE_ENTITY", - "message": "Unprocessable Entity" + "code": "stale_revision", + "message": "the document was changed after this revision was read; reload and retry" }, "meta": { "request_id": "" @@ -944,16 +1227,87 @@ } } }, + { + "name": "pos.transaction.complete.insufficient", + "request": { + "method": "POST", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"4\"" + }, + "body": { + "tenders": [ + { + "amount_cents": 1, + "method": "cash" + } + ] + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "conflict", + "details": [ + { + "code": "insufficient_tender", + "message": "the sale needs 1571 cents and the tenders carry 1" + } + ], + "message": "the sale needs 1571 cents and the tenders carry 1" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "pos.transaction.complete.not_found", + "request": { + "method": "POST", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"4\"" + }, + "body": { + "tenders": [ + { + "amount_cents": 50000, + "method": "cash" + } + ] + } + }, + "response": { + "status": 404, + "content_type": "application/json", + "body": { + "error": { + "code": "not_found", + "message": "sale not found" + }, + "meta": { + "request_id": "" + } + } + } + }, { "name": "pos.transaction.complete", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"4\"" + }, "body": { "tenders": [ { - "amount": 500, - "method": "CASH" + "amount_cents": 50000, + "method": "cash" } ] } @@ -962,41 +1316,71 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 48596, + "branch_id": "", + "cashier_id": "", + "change_cents": 48597, "completed_at": "", "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": "", + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 1403, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 46750, + "product_id": "", + "quantity": "3", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 46750, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000002", "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 1404, - "tax_amount": 0, + "revision": 5, + "status": "completed", + "subtotal_cents": 1403, + "tax_cents": 0, "tenders": [ { - "amount": 50000, + "amount_cents": 1403, + "auth_code": null, + "card_brand": null, + "card_last4": null, "created_at": "", - "id": "", - "method": "CASH", - "transaction_id": "" + "gateway_tx_id": null, + "id": "", + "method": "cash", + "payment_id": "", + "reference": null, + "sale_id": "" } ], - "till_session_id": "", - "total": 1404 + "till_session_id": "", + "total_cents": 1403 } } }, @@ -1004,26 +1388,29 @@ "name": "pos.transaction.complete.again", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"1\"" + }, "body": { "tenders": [ { - "amount": 500, - "method": "CASH" + "amount_cents": 50000, + "method": "cash" } ] } }, "response": { - "status": 422, + "status": 409, "content_type": "application/json", "body": { "error": { - "code": "UNPROCESSABLE_ENTITY", - "message": "Unprocessable Entity" + "code": "invalid_state_transition", + "message": "cannot complete a completed sale" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -1032,53 +1419,25 @@ "name": "pos.item.add.after_complete", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//items", + "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", - "quantity": 1 + "line": { + "product_id": "", + "quantity": "1" + } } }, "response": { - "status": 200, + "status": 409, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 48596, - "completed_at": "", - "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ - { - "created_at": "", - "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" - }, - { - "created_at": "", - "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 468, - "product_id": "", - "quantity": 1, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" - } - ], - "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 1872, - "tax_amount": 0, - "till_session_id": "", - "total": 1872 + "error": { + "code": "invalid_state_transition", + "message": "cannot add a line to a completed sale" + }, + "meta": { + "request_id": "" + } } } }, @@ -1086,88 +1445,95 @@ "name": "pos.item.add.void_tx", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//items", + "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", - "quantity": 1 + "line": { + "product_id": "", + "quantity": "1" + } } }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "3/4 Plywood G1S 4x8 (Fir)", - "id": "", - "line_total": 3800, - "product_id": "", - "quantity": 1, - "transaction_id": "", - "unit_price": 3800, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 3800, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 380000, + "product_id": "", + "quantity": "1", + "revenue_account_code": null, + "sku": "PLY-34-CDX", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 380000, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000003", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 3800, - "tax_amount": 456, - "till_session_id": "", - "total": 4256 + "revision": 2, + "status": "open", + "subtotal_cents": 3800, + "tax_cents": 456, + "tenders": [], + "till_session_id": "", + "total_cents": 4256 } } }, { - "name": "pos.transaction.void", + "name": "pos.transaction.void.open", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//void" - }, - "response": { - "status": 200, - "content_type": "application/json", + "path": "/api/v1/pos/transactions//void", + "headers": { + "If-Match": "\"2\"" + }, "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, - "created_at": "", - "id": "", - "register_id": "REG-01", - "status": "VOIDED", - "subtotal": 3800, - "tax_amount": 456, - "till_session_id": "", - "total": 4256 + "reason": "r1b c abandon" } - } - }, - { - "name": "pos.transaction.void.again", - "request": { - "method": "POST", - "path": "/api/v1/pos/transactions//void" }, "response": { - "status": 200, + "status": 409, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, - "created_at": "", - "id": "", - "register_id": "REG-01", - "status": "VOIDED", - "subtotal": 3800, - "tax_amount": 456, - "till_session_id": "", - "total": 4256 + "error": { + "code": "invalid_state_transition", + "message": "cannot void a open sale: only a completed sale is voided" + }, + "meta": { + "request_id": "" + } } } }, @@ -1175,18 +1541,27 @@ "name": "pos.transaction.void.bad_id", "request": { "method": "POST", - "path": "/api/v1/pos/transactions/not-a-uuid/void" + "path": "/api/v1/pos/transactions/not-a-uuid/void", + "body": { + "reason": "r1b c" + } }, "response": { "status": 400, "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "bad_request", + "details": [ + { + "field": "id", + "message": "must be a UUID" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -1195,18 +1570,21 @@ "name": "pos.transaction.void.not_found", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//void" + "path": "/api/v1/pos/transactions//void", + "body": { + "reason": "r1b c" + } }, "response": { - "status": 500, + "status": 404, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "not_found", + "message": "sale not found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -1215,53 +1593,90 @@ "name": "pos.item.add.voidpaid_tx", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//items", + "path": "/api/v1/pos/transactions//items", "body": { - "product_id": "", - "quantity": 1 + "line": { + "product_id": "", + "quantity": "1" + } } }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 0, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 550, - "product_id": "", - "quantity": 1, - "transaction_id": "", - "unit_price": 550, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 550, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 55000, + "product_id": "", + "quantity": "1", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 55000, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000004", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 550, - "tax_amount": 66, - "till_session_id": "", - "total": 616 + "revision": 2, + "status": "open", + "subtotal_cents": 550, + "tax_cents": 66, + "tenders": [], + "till_session_id": "", + "total_cents": 616 } } }, { - "name": "pos.transaction.complete.voidpaid", + "name": "pos.transaction.complete.voidpaid_split_tender", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"2\"" + }, "body": { "tenders": [ { - "amount": 100, - "method": "CASH" + "amount_cents": 416, + "method": "cash" + }, + { + "amount_cents": 200, + "method": "check", + "reference": "ch-1042" } ] } @@ -1270,40 +1685,84 @@ "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 9384, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, "completed_at": "", "created_at": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": "", + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 550, - "product_id": "", - "quantity": 1, - "transaction_id": "", - "unit_price": 550, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 550, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 55000, + "product_id": "", + "quantity": "1", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 55000, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000004", "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 550, - "tax_amount": 66, + "revision": 3, + "status": "completed", + "subtotal_cents": 550, + "tax_cents": 66, "tenders": [ { - "amount": 10000, + "amount_cents": 416, + "auth_code": null, + "card_brand": null, + "card_last4": null, "created_at": "", - "id": "", - "method": "CASH", - "transaction_id": "" + "gateway_tx_id": null, + "id": "", + "method": "cash", + "payment_id": "", + "reference": null, + "sale_id": "" + }, + { + "amount_cents": 200, + "auth_code": null, + "card_brand": null, + "card_last4": null, + "created_at": "", + "gateway_tx_id": null, + "id": "", + "method": "check", + "payment_id": "", + "reference": "ch-1042", + "sale_id": "" } ], - "till_session_id": "", - "total": 616 + "till_session_id": "", + "total_cents": 616 } } }, @@ -1311,24 +1770,96 @@ "name": "pos.transaction.void.completed", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//void" + "path": "/api/v1/pos/transactions//void", + "headers": { + "If-Match": "\"3\"" + }, + "body": { + "reason": "r1b c wrong customer" + } }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 9384, + "branch_id": "", + "cashier_id": "", + "change_cents": 0, "completed_at": "", "created_at": "", - "id": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": "", + "lines": [ + { + "charge_code": null, + "charge_code_id": null, + "created_at": "", + "description": "2x4x8 SPF Premium", + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 550, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 55000, + "product_id": "", + "quantity": "1", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 55000, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null + } + ], + "number": "POS-000004", "register_id": "REG-01", - "status": "VOIDED", - "subtotal": 550, - "tax_amount": 66, - "till_session_id": "", - "total": 616 + "revision": 4, + "status": "voided", + "subtotal_cents": 550, + "tax_cents": 66, + "tenders": [ + { + "amount_cents": 416, + "auth_code": null, + "card_brand": null, + "card_last4": null, + "created_at": "", + "gateway_tx_id": null, + "id": "", + "method": "cash", + "payment_id": "", + "reference": null, + "sale_id": "" + }, + { + "amount_cents": 200, + "auth_code": null, + "card_brand": null, + "card_last4": null, + "created_at": "", + "gateway_tx_id": null, + "id": "", + "method": "check", + "payment_id": "", + "reference": "ch-1042", + "sale_id": "" + } + ], + "till_session_id": "", + "total_cents": 616 } } }, @@ -1336,58 +1867,77 @@ "name": "pos.transaction.get.after", "request": { "method": "GET", - "path": "/api/v1/pos/transactions/" + "path": "/api/v1/pos/transactions/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "branch_id": "", - "cashier_id": "", - "change_due": 48596, + "branch_id": "", + "cashier_id": "", + "change_cents": 48597, "completed_at": "", "created_at": "", - "customer_id": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": "", + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, - "product_id": "", - "quantity": 3, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" - }, - { - "created_at": "", - "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 468, - "product_id": "", - "quantity": 1, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "discount_cents": null, + "discount_percent": null, + "discount_reason": null, + "id": "", + "is_special_order": false, + "line_total_cents": 1403, + "line_type": "product", + "override_reason": null, + "parent_line_id": null, + "position": 0, + "price_adjusted_by": null, + "price_source": "price_list", + "price_uom": "PCS", + "price_uom_qty": "1", + "priced_unit_price_ten_thousandths": 46750, + "product_id": "", + "quantity": "3", + "revenue_account_code": null, + "sku": "LUM-248-PREM", + "special_order_unit_cost_ten_thousandths": null, + "taxable": true, + "unit_price_ten_thousandths": 46750, + "uom": "PCS", + "uom_qty": "1", + "vendor_id": null } ], + "number": "POS-000002", "register_id": "REG-01", - "status": "COMPLETED", - "subtotal": 1872, - "tax_amount": 0, + "revision": 5, + "status": "completed", + "subtotal_cents": 1403, + "tax_cents": 0, "tenders": [ { - "amount": 50000, + "amount_cents": 1403, + "auth_code": null, + "card_brand": null, + "card_last4": null, "created_at": "", - "id": "", - "method": "CASH", - "transaction_id": "" + "gateway_tx_id": null, + "id": "", + "method": "cash", + "payment_id": "", + "reference": null, + "sale_id": "" } ], - "till_session_id": "", - "total": 1872 + "till_session_id": "", + "total_cents": 1403 } } }, @@ -1401,42 +1951,159 @@ "response": { "status": 200, "content_type": "application/json", - "body": [ - { - "completed_at": "", - "created_at": "", - "id": "", - "item_count": 1, - "register_id": "REG-01", - "status": "VOIDED", - "total": 616 - }, - { - "completed_at": "", - "created_at": "", - "id": "", - "item_count": 2, - "register_id": "REG-01", - "status": "COMPLETED", - "total": 1872 - }, - { - "created_at": "", - "id": "", - "item_count": 0, - "register_id": "REG-01", - "status": "OPEN", - "total": 0 - }, - { - "created_at": "", - "id": "", - "item_count": 1, - "register_id": "REG-01", - "status": "VOIDED", - "total": 4256 - } - ] + "body": { + "items": [ + { + "branch_id": "", + "cashier_id": "", + "completed_at": "", + "created_at": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": "", + "item_count": 1, + "number": "POS-000002", + "register_id": "REG-01", + "revision": 5, + "status": "completed", + "total_cents": 1403 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": "", + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": "", + "item_count": 1, + "number": "POS-000004", + "register_id": "REG-01", + "revision": 4, + "status": "voided", + "total_cents": 616 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": "", + "id": "", + "invoice_id": null, + "item_count": 0, + "number": "POS-000001", + "register_id": "REG-01", + "revision": 1, + "status": "open", + "total_cents": 0 + }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": null, + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": null, + "item_count": 1, + "number": "POS-000003", + "register_id": "REG-01", + "revision": 2, + "status": "open", + "total_cents": 4256 + } + ], + "limit": 50, + "next_cursor": null + } + } + }, + { + "name": "pos.events", + "sort_primary_array": true, + "request": { + "method": "GET", + "path": "/api/v1/events?types=pos_transaction.completed,pos_transaction.voided&limit=50" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "at": "", + "branch_id": "", + "data": { + "change_cents": 0, + "currency": "USD", + "customer_id": null, + "from_status": "open", + "invoice_id": "", + "number": "POS-000004", + "revision": 3, + "status": "completed", + "total_cents": 616 + }, + "entity": { + "id": "", + "kind": "pos_transaction" + }, + "event_id": "", + "org": "default", + "type": "pos_transaction.completed" + }, + { + "at": "", + "branch_id": "", + "data": { + "change_cents": 48597, + "currency": "USD", + "customer_id": "", + "from_status": "open", + "invoice_id": "", + "number": "POS-000002", + "revision": 5, + "status": "completed", + "total_cents": 1403 + }, + "entity": { + "id": "", + "kind": "pos_transaction" + }, + "event_id": "", + "org": "default", + "type": "pos_transaction.completed" + }, + { + "at": "", + "branch_id": "", + "data": { + "currency": "USD", + "customer_id": null, + "from_status": "completed", + "number": "POS-000004", + "reason": "r1b c wrong customer", + "revision": 4, + "status": "voided", + "total_cents": 616 + }, + "entity": { + "id": "", + "kind": "pos_transaction" + }, + "event_id": "", + "org": "default", + "type": "pos_transaction.voided" + } + ], + "limit": 50, + "next_cursor": "" + } } } ] diff --git a/core/internal/characterization/testdata/goldens/pricing_category_rules.json b/core/internal/characterization/testdata/goldens/pricing_category_rules.json index d0bb1d302..0b468fbf2 100644 --- a/core/internal/characterization/testdata/goldens/pricing_category_rules.json +++ b/core/internal/characterization/testdata/goldens/pricing_category_rules.json @@ -833,20 +833,20 @@ "category_path": "golden_d_rules_cat", "created_at": "", "created_by": "", - "customer_id": null, + "customer_id": "", "expires_at": null, - "id": "", + "id": "", "is_active": true, "margin_floor_pct": null, - "priority": 2, + "priority": 1, "revision": 1, - "rule_type": "fixed", + "rule_type": "markdown", "starts_at": null, - "target_type": "tier", - "tier": "RETAIL", + "target_type": "account", + "tier": "", "updated_at": "", - "value_pct": null, - "value_ten_thousandths": 99900 + "value_pct": "4", + "value_ten_thousandths": null }, { "category_id": "", @@ -856,17 +856,17 @@ "created_by": "", "customer_id": null, "expires_at": null, - "id": "", + "id": "", "is_active": true, - "margin_floor_pct": null, - "priority": 2, - "revision": 1, - "rule_type": "margin", + "margin_floor_pct": "6", + "priority": 3, + "revision": 2, + "rule_type": "markup", "starts_at": null, "target_type": "tier", - "tier": "WHOLESALE", + "tier": "CONTRACTOR", "updated_at": "", - "value_pct": "22", + "value_pct": "15", "value_ten_thousandths": null }, { @@ -875,20 +875,20 @@ "category_path": "golden_d_rules_cat", "created_at": "", "created_by": "", - "customer_id": "", + "customer_id": null, "expires_at": null, - "id": "", + "id": "", "is_active": true, "margin_floor_pct": null, - "priority": 1, + "priority": 2, "revision": 1, - "rule_type": "markdown", + "rule_type": "fixed", "starts_at": null, - "target_type": "account", - "tier": "", + "target_type": "tier", + "tier": "RETAIL", "updated_at": "", - "value_pct": "4", - "value_ten_thousandths": null + "value_pct": null, + "value_ten_thousandths": 99900 }, { "category_id": "", @@ -898,17 +898,17 @@ "created_by": "", "customer_id": null, "expires_at": null, - "id": "", + "id": "", "is_active": true, - "margin_floor_pct": "6", - "priority": 3, - "revision": 2, - "rule_type": "markup", + "margin_floor_pct": null, + "priority": 2, + "revision": 1, + "rule_type": "margin", "starts_at": null, "target_type": "tier", - "tier": "CONTRACTOR", + "tier": "WHOLESALE", "updated_at": "", - "value_pct": "15", + "value_pct": "22", "value_ten_thousandths": null } ], diff --git a/core/internal/characterization/testdata/goldens/product_item.json b/core/internal/characterization/testdata/goldens/product_item.json index a9b605a81..2b334cfcf 100644 --- a/core/internal/characterization/testdata/goldens/product_item.json +++ b/core/internal/characterization/testdata/goldens/product_item.json @@ -75,6 +75,17 @@ "content_type": "application/json", "body": { "items": [ + { + "current_stock": "0", + "deficit": "10", + "description": "Golden harness stud", + "product_id": "", + "reorder_point": "10", + "reorder_qty": "50", + "sku": "GOLD-HARNESS-2X4", + "vendor": null, + "vendor_id": null + }, { "current_stock": "0", "deficit": "5000", @@ -90,23 +101,12 @@ "current_stock": "535", "deficit": "465", "description": "2 X 4 RL #3 SPF", - "product_id": "", + "product_id": "", "reorder_point": "1000", "reorder_qty": "5000", "sku": "LUMB24RLN3", "vendor": "Gable Lumber & Supply", "vendor_id": null - }, - { - "current_stock": "0", - "deficit": "10", - "description": "Golden harness stud", - "product_id": "", - "reorder_point": "10", - "reorder_qty": "50", - "sku": "GOLD-HARNESS-2X4", - "vendor": null, - "vendor_id": null } ], "limit": 0, diff --git a/core/internal/characterization/testdata/goldens/reporting_reports.json b/core/internal/characterization/testdata/goldens/reporting_reports.json index a4dac0ffc..e26677b32 100644 --- a/core/internal/characterization/testdata/goldens/reporting_reports.json +++ b/core/internal/characterization/testdata/goldens/reporting_reports.json @@ -10,11 +10,12 @@ "content_type": "application/json", "body": { "by_method": { - "CHECK": 313594.85 + "CASH": 18.19, + "CHECK": 313596.85 }, "date": "", - "total_collected": 313594.85, - "transaction_count": 42 + "total_collected": 313615.04, + "transaction_count": 45 } } }, @@ -89,13 +90,29 @@ "description": "Reversal of an application to IN-000051", "type": "REVERSAL" }, + { + "balance": 45.45, + "credit": 0, + "date": "", + "debit": 14.03, + "description": "Invoice IN-000053", + "type": "INVOICE" + }, + { + "balance": 31.42, + "credit": 14.03, + "date": "", + "debit": 0, + "description": "Payment PAY-000048 applied to IN-000053", + "type": "PAYMENT" + }, { "balance": 1.42, "credit": 30, "date": "", "debit": 0, - "description": "POS return credit #", - "type": "REFUND" + "description": "Credit memo CM-000005", + "type": "CREDIT_MEMO" } ], "open_balance": 0, @@ -138,7 +155,7 @@ "message": "Bad Request" }, "meta": { - "request_id": "" + "request_id": "" } } } diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index 6dc85c49c..0ba910be6 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -408,6 +408,10 @@ func (h *Handler) CurrentTill(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, err) return } + if session == nil { + httpx.WriteError(w, r, httpx.NotFound("no open till session for this register")) + return + } writeJSON(w, http.StatusOK, session) } diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 694e13950..d86f46f21 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -399,7 +399,7 @@ func (r *PostgresRepository) GetLines(ctx context.Context, saleID uuid.UUID) ([] return nil, fmt.Errorf("failed to read the sale's lines: %w", err) } defer rows.Close() - var out []salesdoc.Line + out := []salesdoc.Line{} for rows.Next() { var l salesdoc.Line var sc salesdoc.LineScan @@ -435,12 +435,12 @@ func (r *PostgresRepository) GetTenders(ctx context.Context, saleID uuid.UUID) ( rows, err := r.ex(ctx).Query(ctx, ` SELECT id, method, ROUND(amount * 100)::bigint, payment_id, reference, card_last4, card_brand, gateway_tx_id, auth_code, created_at - FROM pos_tenders WHERE transaction_id = $1 ORDER BY created_at, id`, saleID) + FROM pos_tenders WHERE transaction_id = $1 ORDER BY created_at, method, id`, saleID) if err != nil { return nil, fmt.Errorf("failed to read the tenders: %w", err) } defer rows.Close() - var out []Tender + out := []Tender{} for rows.Next() { var t Tender var method string @@ -657,7 +657,7 @@ func (r *PostgresRepository) SearchProducts(ctx context.Context, query string, l SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), COALESCE(ROUND(p.base_price * 100)::bigint, 0), COALESCE(p.uom_primary::text, 'EA'), COALESCE(ROUND(SUM(i.quantity - i.allocated) * 10000)::bigint, 0) FROM products p LEFT JOIN inventory i ON i.product_id = p.id - WHERE (p.sku ILIKE $1 OR p.description ILIKE $1) AND COALESCE(p.is_active, TRUE) + WHERE (p.sku ILIKE $1 OR p.description ILIKE $1) GROUP BY p.id, p.sku, p.description, p.base_price, p.uom_primary ORDER BY p.sku LIMIT $2`, "%"+query+"%", limit) if err != nil { @@ -680,7 +680,6 @@ func (r *PostgresRepository) GetProductCatalog(ctx context.Context) ([]CatalogPr SELECT p.id, COALESCE(p.sku, ''), COALESCE(p.description, ''), COALESCE(ROUND(p.base_price * 100)::bigint, 0), COALESCE(p.uom_primary::text, 'EA'), COALESCE(ROUND(SUM(i.quantity - i.allocated) * 10000)::bigint, 0) FROM products p LEFT JOIN inventory i ON i.product_id = p.id - WHERE COALESCE(p.is_active, TRUE) GROUP BY p.id, p.sku, p.description, p.base_price, p.uom_primary ORDER BY p.sku`) if err != nil { diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index 052e3514d..07d736e5a 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -211,7 +211,8 @@ func (s *Service) StartSale(ctx context.Context, in *StartSale, actor string) (* var out *Sale err = s.inTx(ctx, func(ctx context.Context) error { sale := &Sale{RegisterID: in.RegisterID, CashierID: in.CashierID, CustomerID: in.CustomerID, - Status: StatusOpen, Currency: currency, WalkInID: walkInID} + Status: StatusOpen, Currency: currency, WalkInID: walkInID, + Lines: []salesdoc.Line{}, Tenders: []Tender{}} if session, err := s.repo.GetOpenTillSession(ctx, in.RegisterID); err == nil && session != nil { sale.TillSessionID = &session.ID } @@ -247,6 +248,10 @@ func (s *Service) priceLine(ctx context.Context, sale *Sale, pl salesdoc.ParsedL customerID := uuid.Nil if sale.CustomerID != nil { customerID = *sale.CustomerID + } else if sale.WalkInID != uuid.Nil { + // The walk-in customer is the sale's customer of record: the engine + // prices for it (its retail tier), never for an empty id. + customerID = sale.WalkInID } refs, codes, _, err := s.lookups(ctx, []salesdoc.ParsedLine{pl}) if err != nil { @@ -499,6 +504,15 @@ func (s *Service) AddLine(ctx context.Context, saleID uuid.UUID, parsed []salesd if sale.Status != StatusOpen && sale.Status != StatusHeld { return httpx.InvalidStateTransition(fmt.Sprintf("cannot add a line to a %s sale", sale.Status.Status())) } + if sale.CustomerID == nil { + // The engine prices a walk-in sale for the walk-in customer of + // record, never for an empty id (the read does not carry it). + walkInID, _, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return err + } + sale.WalkInID = walkInID + } var priced []salesdoc.Line var audits []audit.Entry for i := range parsed { From 181fe1bd8723d7f1f552ed6e1be2bd0c491157ee Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 00:06:23 -0700 Subject: [PATCH 007/113] C2-5: the contract clean through make contract, and the rows Four more null-for-empty warts the conformance pass found in the fresh goldens, fixed: a till session read carries its expected and counted maps from the first read (the open constructs them, the scan fills them), the sync response's errors and pending lists are arrays even when empty, the day's return list is an array with each return's lines an array, and the Z report list is an array on an empty day. The 404s reference the lowercase WireNotFound response, the assembled contract and the generated client are regenerated through make contract, and CONTRACT-CHANGES carries the C2-5 rows: every counter route's before and after, the walk-in and register-branch behaviour rows, the goldens row and the migration row. Signed-off-by: colton --- core/api/fragments/pos.yaml | 26 +- core/api/openapi.yaml | 26 +- .../testdata/goldens/pos.json | 8 +- .../testdata/goldens/pos_returns.json | 10 +- .../testdata/goldens/pos_sync.json | 8 +- .../testdata/goldens/pos_till_close.json | 12 +- .../testdata/goldens/pos_transactions.json | 4 +- core/internal/pos/repository.go | 4 +- core/internal/pos/sync.go | 2 +- core/internal/pos/till.go | 3 +- core/internal/pos/till_repository.go | 8 +- docs/refactor/CONTRACT-CHANGES.md | 11 + web/packages/api-client/src/schema.d.ts | 977 ++++++++++-------- 13 files changed, 626 insertions(+), 473 deletions(-) diff --git a/core/api/fragments/pos.yaml b/core/api/fragments/pos.yaml index 744d01d1d..e0e61e151 100644 --- a/core/api/fragments/pos.yaml +++ b/core/api/fragments/pos.yaml @@ -61,7 +61,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" get: @@ -138,7 +138,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/items: @@ -186,7 +186,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -228,7 +228,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -287,7 +287,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "428": @@ -343,7 +343,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "428": @@ -489,7 +489,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -522,7 +522,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/report: @@ -555,7 +555,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/close: @@ -598,7 +598,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -631,7 +631,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/zreports: @@ -715,7 +715,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -781,7 +781,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" components: diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 61f536ac0..2badcdbab 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -9075,7 +9075,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" get: @@ -9146,7 +9146,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/transactions/{id}/items: @@ -9187,7 +9187,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -9227,7 +9227,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -9270,7 +9270,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "428": @@ -9317,7 +9317,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "428": @@ -9449,7 +9449,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -9481,7 +9481,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/report: @@ -9511,7 +9511,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/close: @@ -9548,7 +9548,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -9580,7 +9580,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pos/zreports: @@ -9651,7 +9651,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "409": $ref: "#/components/responses/ConflictEither" "500": @@ -9715,7 +9715,7 @@ paths: "403": $ref: "#/components/responses/ForbiddenEither" "404": - $ref: "#/components/responses/NotFound" + $ref: "#/components/responses/WireNotFound" "500": $ref: "#/components/responses/InternalError" /api/v1/pricing/calculate: diff --git a/core/internal/characterization/testdata/goldens/pos.json b/core/internal/characterization/testdata/goldens/pos.json index d1ef7d8f3..cde5ddb42 100644 --- a/core/internal/characterization/testdata/goldens/pos.json +++ b/core/internal/characterization/testdata/goldens/pos.json @@ -16,8 +16,8 @@ "branch_id": "", "cashier_id": "", "closed_at": null, - "counted_by_method": null, - "expected_by_method": null, + "counted_by_method": {}, + "expected_by_method": {}, "gl_entry_id": null, "id": "", "notes": "", @@ -42,8 +42,8 @@ "branch_id": "", "cashier_id": "", "closed_at": null, - "counted_by_method": null, - "expected_by_method": null, + "counted_by_method": {}, + "expected_by_method": {}, "gl_entry_id": null, "id": "", "notes": "", diff --git a/core/internal/characterization/testdata/goldens/pos_returns.json b/core/internal/characterization/testdata/goldens/pos_returns.json index 695ea2438..e03d17d0b 100644 --- a/core/internal/characterization/testdata/goldens/pos_returns.json +++ b/core/internal/characterization/testdata/goldens/pos_returns.json @@ -404,7 +404,7 @@ "currency": "USD", "customer_id": "", "id": "", - "lines": null, + "lines": [], "number": "RTN-000001", "original_sale_id": null, "reason": "r1b c wrong size", @@ -424,7 +424,7 @@ "currency": "USD", "customer_id": "", "id": "", - "lines": null, + "lines": [], "number": "RTN-000002", "original_sale_id": null, "reason": "r1b c credit the account", @@ -444,7 +444,7 @@ "currency": "USD", "customer_id": "", "id": "", - "lines": null, + "lines": [], "number": "RTN-000003", "original_sale_id": null, "reason": "r1b c no account", @@ -468,7 +468,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": null + "body": [] } }, { @@ -480,7 +480,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": null + "body": [] } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_sync.json b/core/internal/characterization/testdata/goldens/pos_sync.json index 7d33e8e06..985af9e40 100644 --- a/core/internal/characterization/testdata/goldens/pos_sync.json +++ b/core/internal/characterization/testdata/goldens/pos_sync.json @@ -41,7 +41,7 @@ "reason": "complete: this write needs If-Match or a body revision" } ], - "pending": null, + "pending": [], "pending_count": 0, "synced_count": 0 } @@ -83,8 +83,8 @@ "batch_id": "r1c-batch-1", "duplicate_count": 1, "error_count": 0, - "errors": null, - "pending": null, + "errors": [], + "pending": [], "pending_count": 0, "synced_count": 0 } @@ -132,7 +132,7 @@ "reason": "complete: the sale needs 616 cents and the tenders carry 1" } ], - "pending": null, + "pending": [], "pending_count": 0, "synced_count": 0 } diff --git a/core/internal/characterization/testdata/goldens/pos_till_close.json b/core/internal/characterization/testdata/goldens/pos_till_close.json index 24530a583..c79867bc1 100644 --- a/core/internal/characterization/testdata/goldens/pos_till_close.json +++ b/core/internal/characterization/testdata/goldens/pos_till_close.json @@ -19,8 +19,8 @@ "branch_id": "", "cashier_id": "", "closed_at": null, - "counted_by_method": null, - "expected_by_method": null, + "counted_by_method": {}, + "expected_by_method": {}, "gl_entry_id": null, "id": "", "notes": "", @@ -316,8 +316,8 @@ "branch_id": "", "cashier_id": "", "closed_at": null, - "counted_by_method": null, - "expected_by_method": null, + "counted_by_method": {}, + "expected_by_method": {}, "gl_entry_id": null, "id": "", "notes": "", @@ -448,7 +448,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": null + "body": [] } }, { @@ -460,7 +460,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": null + "body": [] } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_transactions.json b/core/internal/characterization/testdata/goldens/pos_transactions.json index d18f6714e..a5c4b773b 100644 --- a/core/internal/characterization/testdata/goldens/pos_transactions.json +++ b/core/internal/characterization/testdata/goldens/pos_transactions.json @@ -12,8 +12,8 @@ "branch_id": "", "cashier_id": "", "closed_at": null, - "counted_by_method": null, - "expected_by_method": null, + "counted_by_method": {}, + "expected_by_method": {}, "gl_entry_id": null, "id": "", "notes": "", diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index d86f46f21..520601aee 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -552,9 +552,9 @@ func (r *PostgresRepository) ListReturns(ctx context.Context, f ReturnFilter, li return nil, fmt.Errorf("failed to list returns: %w", err) } defer rows.Close() - var out []Return + out := []Return{} for rows.Next() { - ret := &Return{} + ret := &Return{Lines: []ReturnLine{}} var method string var createdAt time.Time if err := rows.Scan(&ret.ID, &ret.Number, &ret.Revision, &ret.BranchID, &ret.RegisterID, &ret.TillSessionID, diff --git a/core/internal/pos/sync.go b/core/internal/pos/sync.go index 3d2578c93..2f26aa879 100644 --- a/core/internal/pos/sync.go +++ b/core/internal/pos/sync.go @@ -47,7 +47,7 @@ type OfflineSyncResponse struct { // offline is never REJECTED for a tax provider failure: it stays pending in // the sync log and the next sync retries it (ADR 0005 section 3). func (s *Service) SyncOfflineTransactions(ctx context.Context, batch *OfflineSync, actor string) (*OfflineSyncResponse, error) { - resp := &OfflineSyncResponse{BatchID: batch.BatchID} + resp := &OfflineSyncResponse{BatchID: batch.BatchID, Errors: []SyncItemResult{}, Pending: []SyncItemResult{}} log := LogBatch{BatchID: batch.BatchID, RegisterID: batch.RegisterID} for i := range batch.Items { item := &batch.Items[i] diff --git a/core/internal/pos/till.go b/core/internal/pos/till.go index 9339490dd..45ed310c8 100644 --- a/core/internal/pos/till.go +++ b/core/internal/pos/till.go @@ -51,7 +51,8 @@ func (s *Service) OpenTill(ctx context.Context, registerID string, cashierID uui return err } session := &TillSession{RegisterID: registerID, BranchID: branch, CashierID: cashierID, - Status: TillOpen, OpeningFloat: openingFloat} + Status: TillOpen, OpeningFloat: openingFloat, + ExpectedByMethod: map[string]int64{}, CountedByMethod: map[string]int64{}} if err := s.repo.CreateTillSession(ctx, session); err != nil { return err } diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index 16b1d8b1c..fb9e39206 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -46,6 +46,12 @@ func (r *PostgresRepository) scanTillSession(row pgx.Row) (*TillSession, error) } s.ClosedAt = httpx.PtrTimestamp(closedAt) s.Status = TillSessionStatus(status) + if s.ExpectedByMethod == nil { + s.ExpectedByMethod = map[string]int64{} + } + if s.CountedByMethod == nil { + s.CountedByMethod = map[string]int64{} + } return &s, nil } @@ -213,7 +219,7 @@ func (r *PostgresRepository) ListZReports(ctx context.Context, registerID string return nil, fmt.Errorf("failed to list Z-reports: %w", err) } defer rows.Close() - var out []ZReport + out := []ZReport{} for rows.Next() { var z ZReport var generatedAt time.Time diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index aab8bdc1e..82e240439 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -232,3 +232,14 @@ get an entry. | C3-2A-units | quotes: `POST /api/v1/quotes`, `PUT /api/v1/quotes/{id}` (the lines) | A line's `uom` was one of the sixteen enum codes and its `price_uom` any `^[A-Z]{1,6}$` text; the conversion pair was whatever the client sent, checked only for positivity, and required whenever `price_uom` differed from `uom`; `uom` defaulted from the product's stocking unit and `price_uom` from `uom` | ADR 0006 sections 3.3 and 7.4: both units must be active catalogue units and, on a product line, rows of the product's set (sell on `uom`, price on `price_uom`, each a 400 naming its field). The pair is resolved by the server from the two rows and stored on the line; a sent pair must equal the resolved one as a ratio (400 naming `lines[i].uom_qty` with the pair to send) and the old requirement to send a pair when the units differ is gone for product lines (a line without a product still carries the client pair, or the standard size derivation of two catalogue units). `uom` defaults to the product's `sale_uom` and `price_uom` to the product's `price_uom`. Every product line stores its stocking unit and its exact stocking quantity: a quantity that does not convert exactly at scale 4 is a 400 naming `lines[i].quantity` with the nearest quantities that do (10 BF of a 2x4x14 names 9.9988 and 10.0016), and only quantities the line can hold are named: zero and anything past the 99999999.9999 bound are left out, the one holdable neighbour alone when the other cannot be held. A sent pair that equals the resolved pair as a ratio is stored as the canonical resolved pair itself (375 and 2 stores 187.5 and 1), on a product line and on a standard size derivation of a line without a product. A quantity whose stocking conversion is exact but past the quantity bound is a 400 naming `lines[i].quantity` saying the conversion is past the bound, never an exactness refusal | | C3-2A-units | quotes: the line's `tally` (on every quote read and write) | A line carried no tally: random length lumber was quoted as one quantity with no lengths | ADR 0006 section 4: a line on a random length product may carry `tally: {rows: [{pieces, length_ft}]}` (pieces a JSON integer, the length a decimal string, at most 100 rows, one row per length); its linear feet are the line's quantity (a sent quantity that disagrees is a 400 naming `lines[i].quantity`; the quantity may be omitted), its sale unit is LF, and the cross section is snapshotted from the product. The read serves the tally with `thickness_in`, `width_in`, `rows`, `linear_feet` and `board_feet` (the one display rounding, R4.3: 200/3 reads 66.6667); the money goes through the resolved pair (150 LF at 500.00 per MBF is 5000 cents). A tally on any other line is a 400 naming `lines[i].tally`, and a read only field in a request is a 400 naming it. A tally whose rows are each within the row limits but whose linear feet sum is past the quantity bound is a 400 naming `lines[i].tally` stating the cap (99999999.9999 linear feet), never an accepted wrapped sum. Every line carries `stock_uom`, `stock_quantity` and `tally`, null where they do not apply | | C3-2A-units | migration 099 `units_catalogue_and_sets` (operational, no route) | Units were the closed `uom_type` enum; a product had one unit; a quote line's pair was unvalidated client data | The unit catalogue replaces the enum (the three columns become TEXT with foreign keys to `units(code)`, values normalised and a value that cannot enter the catalogue aborts the migration naming it; `core migrate -report units` is the read only pre flight report, which compares against the section 2.2 seed codes as well as the live catalogue so a code the migration itself seeds is never listed as a new dealer unit), every product gains its stocking unit set row and the three default columns with the `price_uom = uom_primary` CHECK of the stocking unit hold, the quote line pairs become `product_unit_candidates` for the dealer to confirm (never applied), and the quote lines gain the tally table, the cross section columns and the stock backfill (a line that does not convert exactly keeps null stock fields and is reported). The raw writer defaults trigger keeps serving raw SQL (a stocking unit change drags the three defaults with it when the caller leaves them on the old stocking unit) but returns early for a writer that sets the transaction local `gable.unit_set_write` flag, which the unit set PUT does, because it names all four unit columns itself | +| C2-5 | counter: every sale body (`GET`/`POST /api/v1/pos/transactions[/{id}]`, the items, complete and void routes, `POST /api/v1/pos/sync`) | No `number`, `revision`, `currency` or `invoice_id`; `status` was UPPERCASE; money was float dollars (`subtotal`, `tax_amount`, `total`, a tender's `amount`); lines were product rows only (`product_id` NOT NULL, quantity a JSON number, `unit_price` at scale 2, no position, type, pair, discount or audit columns); a completed sale's GL was booked after the commit, best effort, with no tax leg, and a split tender invoice was built on a fake product line | The sale carries `number` (`POS-000001`, the gapped `pos_transaction_number_seq`, backfilled by migration 105), `revision` with the `ETag`, `currency` (the customer's or the dealer default), `invoice_id` once completed, and `status` lowercase (`open`, `held`, `completed`, `voided`); every amount is `_cents` int64; the lines are the shared salesdoc shape of ADR 0005 section 2.2 (`line_type` product, kit with its component lines, charge and text; `quantity`, `uom_qty` and `price_uom_qty` decimal strings; `unit_price_ten_thousandths` at scale 4; typed `override_reason` and a `discount_percent` or `discount_cents` with `discount_reason`, each adjustment audited); the completion is one transaction (stock out, the invoice with origin POS and pickup posted through the AR core with the tax and COGS legs, one payment per cash, check or card tender applied to it, stored net of change, an ACCOUNT tender left open and subject to the credit check, the event last), so a failing event, audit or ledger write leaves no invoice, payment, stock move or entry. The tenders never read back null and a fresh sale reads `lines: []`, `tenders: []` | +| C2-5 | counter: `POST /api/v1/pos/transactions/{id}/complete` and `/{id}/void` | The complete took `{tenders: [{method, amount (float dollars)}]}` with no precondition and answered 422 on refusals the handler picked per route; the void was a bare POST with no body that reversed no ledger entry | The complete takes `{tenders: [{method: cash\|check\|card\|account lowercase, amount_cents, reference, token_id}], picked_up_by, revision}` on the sale's revision (`If-Match` or the body: 428 `precondition_required` without one, 409 `stale_revision` on a stale one) and its refusals are the error envelope with blockers `insufficient_tender`, `change_from_cash`, `walk_in_account`, `card_terminal`, `card_declined`, `tax_quote_stale`, `tax_rate_not_configured`, `credit_limit`, `insufficient_stock` (and 503 `unavailable` when the tax provider fails before any row is written). The void takes `{reason, revision}` on the same precondition, runs one transaction in section 11's lock order (the sale, its payments, its invoice), refunds a card tender through the gateway before it, and is refused once the till session closed (blocker `session_closed`: after the close it is a return) or when the sale carries no invoice (`no_invoice`) | +| C2-5 | counter: `GET /api/v1/pos/transactions` | A bare array (null when the day was empty) with `register_id`, `date` and `status` silently ignored or UPPERCASE, offset paging parameters | The list envelope `{items, next_cursor, limit}` over the register's day (the page is the whole day newest first, so `next_cursor` is null; `limit` echoes the 50 served), `register_id` and `date` filter, `status` takes one lowercase value (an uppercase or unknown value, an unknown parameter, a malformed cursor or limit is a 400 naming it), and an empty day is `[]` in the bytes | +| C2-5 | counter: `GET /api/v1/pos/products/search`, `GET /api/v1/pos/catalog` | The typeahead answered 500 (`column p.is_active does not exist`) against the real schema | Bare arrays (never null) of the counter's product facts: `product_id`, `sku`, `description`, `unit_price_cents`, `uom`, `in_stock` (a decimal string) | +| C2-5 | counter: `POST /api/v1/pos/sync` | A batch replayed through the old completion path with float dollars and UPPERCASE methods; a tax provider failure rejected the sale | The batch's items are the new line and tender shapes with a `client_id` (a replay is a duplicate, not an error); the response carries `synced_count`, `duplicate_count`, `error_count`, `pending_count` with `errors` and `pending` never null, and a sale whose tax provider call fails stays `pending` and is retried, never rejected (ADR 0005 section 3's one exception) | +| C2-5 | till: `POST /api/v1/pos/till/open`, `GET /api/v1/pos/till/current` | `opening_float` float dollars; the session read UPPERCASE status with null `expected_by_method`/`counted_by_method` maps before any sale; `current` answered 200 with a `null` body when the register had no open session | `opening_float_cents` int64; the session carries lowercase `status`, `expected_by_method` and `counted_by_method` maps that are objects from the first read (never null), `over_short_cents` and `gl_entry_id` set by the close; `current` answers 404 `not_found` ("no open till session for this register") when none is open | +| C2-5 | till: `POST /api/v1/pos/till/{id}/close`, `GET /api/v1/pos/till/{id}/report`, `GET /api/v1/pos/till/{id}/zreport`, `GET /api/v1/pos/zreports` | `counted_by_method` took UPPERCASE method keys with float dollars; the over/short was booked outside the close's transaction; the expected cash subtracted change twice | `counted_by_method` takes lowercase keys in cents; the expected cash is the session's cash payments (already net of change) plus the opening float less cash refunds, the over/short is booked to 5030 inside the close's transaction with `till.closed` last, the Z report is written once at the close, and the reports' `tendered_by_method` and `expected_by_method` are objects; the Z report lists are bare arrays, never null (an empty day is `[]`) | +| C2-5 | returns: `POST`/`GET /api/v1/pos/returns`, `GET /api/v1/pos/returns/{id}` | No `number`, `revision`, `currency` or `credit_memo_id`; `refund_method` UPPERCASE; lines with float `unit_price`; a return refunded to account lowered AR with no document (`PostAccountReturnToLedger`) | The return carries `number` (`RTN-000001`, migration 105's `pos_return_number_seq`), `revision`, `currency` and `credit_memo_id`: a return is a credit memo created, posted (revenue credited, tax reversed, COGS relieved and stock returned where the lines restock), refunded and optionally restocked in one act, `refund_method` lowercase (`cash`, `card`, `account`), the lines' `quantity` a decimal string and `unit_price_ten_thousandths` int64, `reason` required, and the day's list is a bare array with `lines: []` never null | +| C2-5 | counter: the walk-in customer and the register's branch (behaviour, no route) | A sale that named no customer posted to a NULL customer row the AR core cannot carry; the demo seed left REG-01 on the MAIN branch, which carries no default tax rate | Migration 105 seeds the WALK-IN customer and `pos.walk_in_customer_id`; a customerless sale books to it (an ACCOUNT tender for it is refused `walk_in_account`) and prices its lines through the engine for it, never an empty id; the seed repoints REG-01 at the Kelowna branch (rate 0.12), because the counter refuses a sale or return on a branch without a rate | +| C2-5 | goldens that follow | `pos`, `pos_transactions`, `pos_sync`, `pos_returns`, `pos_till_close` scripted the legacy counter: float tenders, UPPERCASE methods, the void of an open sale, the account return with no document | The five are re-scripted and re-recorded: cents tenders and floats, the line wrapper, lowercase vocabularies, the revision preconditions (extracted, never counted by hand) with the 428 and the stale 409, the void of an open sale refused, a split tender completion (cash and check against the 12 percent branch rate) and its void, the strict query refusals, a step reading the counter's own events back from the feed, and the Z report's payload masked (it embeds the session's own ids and timestamps). `crm_activity_item`, `location_item`, `pim_media`, `pricing_category_rules`, `product_item` and `reporting_reports` were re-recorded with them: their committed versions no longer pass because the counter's sales now really consume stock and post events, and the harness now sorts a cursor envelope's `items` as it sorted the legacy `data` array (the order of three lists changed) | +| C2-5 | migration 105 `pos_wire_contract` (operational, no route) | The counter's tables were the base commit's: no revisions, numbers, currency or invoice and credit memo links, `pos_line_items.product_id` NOT NULL with no line typing, a historic ACCOUNT return carrying a GL entry with no document | The steps of ADR 0005 section 13 C2-5: revisions, `POS-` and `RTN-` numbers in `(created_at, id)` order with sequences behind the column DEFAULTs, currency from the customer or the default, `invoice_id` and `credit_memo_id` links, the shared line columns with the per type CHECKs (`product_id` NULL-able), the tender's `payment_id`, each historic ACCOUNT return migrated as an OPEN credit memo carrying the return's own entry (no new journal or subledger row), and the walk-in customer with its setting. The down drops the columns, the memo, the text lines and the walk-in customer, returns `unit_price` to scale 2, and the up applies again after it | diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 594bdfc57..6f2c13417 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -4056,12 +4056,15 @@ export interface paths { path?: never; cookie?: never; }; - /** List transaction summaries */ + /** + * List the day's sales + * @description The list envelope over one register's day: register_id filters (absent lists every register's), date is a YYYY-MM-DD that defaults to the branch's today, status takes one lowercase value (open, held, completed, voided; an uppercase or unknown value is a 400 naming status). The page is the whole day newest first, so next_cursor is null and limit echoes the 50 the route serves; cursor, limit and include are accepted query names. An empty day is [] in the bytes. + */ get: operations["posTransactionList"]; put?: never; /** - * Start a transaction - * @description register_id defaults to REG-01 and a missing cashier_id is replaced by a random UUID. + * Start a sale + * @description Opens a cart on a register for the resolved cashier, attached to the register's open till session when there is one, at revision 1 with its POS- number (the gapped series; a rolled back create abandons the number). register_id defaults REG-01; the customer names the account the invoice posts to, and its absence means the walk-in customer the counter books to. The cashier is the JWT's subject; a machine key is refused 403 ("a cashier must be a user"). */ post: operations["posTransactionCreate"]; delete?: never; @@ -4077,7 +4080,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get a transaction with its lines and tenders */ + /** + * Read a sale + * @description The sale with its lines and tenders, the branch wall applied. + */ get: operations["posTransactionGet"]; put?: never; post?: never; @@ -4096,7 +4102,10 @@ export interface paths { }; get?: never; put?: never; - /** Add a line item */ + /** + * Add a line to the sale + * @description One line in the shared salesdoc shape, parsed under the same rules as every sales line (quantity a decimal string, the unit from the product when the line names one, the pair when the price unit differs, an override or a discount with its reason, a charge by its code, a text line a note with nothing priced). A kit explodes to its component lines. Only an open sale takes a line (409 invalid_state_transition). The sale's revision moves; the response is the whole sale. + */ post: operations["posTransactionAddItem"]; delete?: never; options?: never; @@ -4114,7 +4123,10 @@ export interface paths { get?: never; put?: never; post?: never; - /** Remove a line item */ + /** + * Remove a line from the sale + * @description Removes the line (a kit's component lines go with their parent). Only an open sale loses a line (409 invalid_state_transition). The response is the whole sale. + */ delete: operations["posTransactionRemoveItem"]; options?: never; head?: never; @@ -4131,8 +4143,8 @@ export interface paths { get?: never; put?: never; /** - * Complete a transaction with tenders - * @description An empty tenders list answers 400. Every service failure (an under-tendered sale, a declined card, a transaction not OPEN) answers 422. + * Tender and complete the sale + * @description The money moment, one transaction: stock out through the inventory write path, the invoice (origin POS, pickup) posted through the AR core with its tax and COGS legs, each cash, check or card tender a payment applied to it and stored net (the tendered amount less change, so the payment is the money kept), an ACCOUNT tender left open on the invoice and subject to the credit check, the card charged at the gateway in the sale's currency before the transaction opens, and the pos_transaction.completed event written last: a failing event, audit or ledger write rolls the whole sale back (no invoice, payment, stock move or entry). Tenders must cover the total (change comes only from cash). Refused with 409 and a blocker: insufficient_tender, change_from_cash, walk_in_account (an ACCOUNT tender for the walk-in customer), card_terminal, card_declined, tax_quote_stale, tax_rate_not_configured, credit_limit, insufficient_stock; 503 when the tax provider fails before any row is written. The completion carries the sale's revision (If-Match or the body's revision; 428 without one, 409 stale_revision on a stale one). */ post: operations["posTransactionComplete"]; delete?: never; @@ -4150,7 +4162,10 @@ export interface paths { }; get?: never; put?: never; - /** Void a transaction */ + /** + * Void the completed sale + * @description One transaction in section 11's lock order (the sale, its payments, its invoice): each cash or check payment voided, each card payment's gateway refund made before the transaction and its application reversed with the amount recorded as a refund from its unapplied cash, then the invoice voided, the stock back, the ledger exactly reversed and the pos_transaction.voided event last. Only while the sale's till session is open (409 blocker session_closed: after the session closes it is a return). Refused with 409 and a blocker: session_closed, card_terminal, card_refund, no_invoice. The void carries the sale's revision (428, 409 stale_revision). + */ post: operations["posTransactionVoid"]; delete?: never; options?: never; @@ -4165,7 +4180,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Typeahead product search */ + /** + * The counter's typeahead + * @description Products matching the q text (sku or description prefix), at most 20, with the price and on hand the counter needs. A bare array, never null, the shape this counter's helper surface keeps (the product module owns the data). q empty answers []. + */ get: operations["posProductSearch"]; put?: never; post?: never; @@ -4185,10 +4203,10 @@ export interface paths { get?: never; put?: never; /** - * Replay offline transactions - * @description A missing batch_id or an empty items list answers 400. + * Replay a batch of offline sales + * @description Each sale in the batch is replayed through the same completion path (stock, invoice, payments, ledger, event) keyed by its client_id: a replayed sale is a duplicate, not an error. A sale whose tax provider call fails is never rejected (the one exception to the provider rule, ADR 0005 section 3): it stays pending in the sync log and the sync retries it; errors lists what could not complete and pending what waits. */ - post: operations["posSync"]; + post: operations["posSyncOffline"]; delete?: never; options?: never; head?: never; @@ -4202,8 +4220,11 @@ export interface paths { path?: never; cookie?: never; }; - /** Product catalog for the offline cache */ - get: operations["posCatalog"]; + /** + * The offline catalog cache + * @description Every saleable product with its price and on hand for the offline register cache. A bare array, never null, like the typeahead. + */ + get: operations["posCatalogGet"]; put?: never; post?: never; delete?: never; @@ -4222,8 +4243,8 @@ export interface paths { get?: never; put?: never; /** - * Open a till session - * @description register_id defaults to REG-01. Every service failure, such as a session already open on the register, answers 409. + * Open the drawer + * @description Opens a till session for the resolved cashier on a register with its opening float in cents; register_id defaults REG-01. A register with an open session is refused (409, a session is already open). The cashier is the JWT's subject; a machine key is refused 403. Writes till.opened. */ post: operations["posTillOpen"]; delete?: never; @@ -4239,7 +4260,10 @@ export interface paths { path?: never; cookie?: never; }; - /** The open till session of a register */ + /** + * The register's open session + * @description The register's open till session; register_id defaults REG-01. 404 when the register has none. + */ get: operations["posTillCurrent"]; put?: never; post?: never; @@ -4257,8 +4281,8 @@ export interface paths { cookie?: never; }; /** - * Live X report of a session - * @description An unknown session answers 500. + * The session's live report + * @description The X report: the session with its sales count, totals, change and the tendered and expected cash by method, expected being the sum of the session's cash payments (already net of change) plus the opening float less cash refunds. */ get: operations["posTillReport"]; put?: never; @@ -4279,8 +4303,8 @@ export interface paths { get?: never; put?: never; /** - * Close a till session - * @description Every service failure, such as a session not OPEN, answers 409. + * Close the drawer + * @description Closes the session on the counted cash: the over or short (counted less expected, expected being the session's cash payments net of change plus the float less cash refunds) is booked to 5030 inside the close's own transaction, the Z report snapshot is written and till.closed is the last statement. counted_by_method names the counted cents per lowercase method (cash, check, card, account); a refusal rolls everything back. */ post: operations["posTillClose"]; delete?: never; @@ -4296,7 +4320,10 @@ export interface paths { path?: never; cookie?: never; }; - /** The Z report of a closed session */ + /** + * The session's frozen Z report + * @description The immutable snapshot written once at the close, its payload the frozen closing report. 404 before the session closed. + */ get: operations["posTillZReport"]; put?: never; post?: never; @@ -4313,7 +4340,10 @@ export interface paths { path?: never; cookie?: never; }; - /** List Z reports */ + /** + * The day's Z reports + * @description The Z reports of one register's day (register_id filters, date is a YYYY-MM-DD that defaults to the branch's today), newest first. A bare array, never null. + */ get: operations["posZReportList"]; put?: never; post?: never; @@ -4330,12 +4360,15 @@ export interface paths { path?: never; cookie?: never; }; - /** List returns */ + /** + * List the day's returns + * @description The day's returns (register_id filters, date is a YYYY-MM-DD that defaults to the branch's today), newest first. A bare array, never null. + */ get: operations["posReturnList"]; put?: never; /** - * Record a return and refund - * @description register_id defaults to REG-01. Every service failure, including validation, answers 400. + * Return goods at the counter + * @description A return is a credit memo created and posted in one act with its restock lines: the memo credits the revenue, reverses the tax and, where the lines restock, relieves COGS back and returns the stock, refunded in cash (out of the drawer, counted against the session's expected) or card (the gateway refund before the transaction) or left as account credit (the memo stays open). Lines name the sale line they return (its price and cost follow) or stand alone with product, quantity and price; quantities are positive on the request and negative on the read. The RTN- number and the pos_return.completed event land in the same transaction. Refused with 409 and a blocker: tax_rate_not_configured, card_terminal, card_refund. The cashier is the JWT's subject; a machine key is refused 403. */ post: operations["posReturnCreate"]; delete?: never; @@ -4351,7 +4384,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get a return with its lines */ + /** + * Read a return + * @description The return with its lines and its credit memo link. + */ get: operations["posReturnGet"]; put?: never; post?: never; @@ -10744,218 +10780,450 @@ export interface components { /** @enum {string} */ status: "Active" | "Inactive"; }; - /** @description The refusal body for a machine key on a cashier route. The code is lower case, unlike the standard envelope. */ - PosCashierRefusal: { - error: { - /** @enum {string} */ - code: "forbidden"; - message: string; - }; - meta: { - request_id: string; - }; - }; /** @enum {string} */ - PosTransactionStatus: "OPEN" | "COMPLETED" | "VOIDED" | "RETURNED" | "HELD"; - /** @description POSLineItem. */ - PosLineItem: { + PosStatus: "open" | "held" | "completed" | "voided"; + /** @enum {string} */ + PosTenderMethod: "cash" | "check" | "card" | "account"; + /** @enum {string} */ + PosRefundMethod: "cash" | "card" | "account"; + /** @enum {string} */ + PosTillStatus: "open" | "closed"; + PosTransactionCreateRequest: { + /** @description Defaults REG-01. */ + register_id?: string; + /** + * Format: uuid + * @description Defaults the JWT's subject. + */ + cashier_id?: string | null; + /** + * Format: uuid + * @description Absent means the walk-in customer. + */ + customer_id?: string | null; + }; + PosAddLineRequest: { + line: components["schemas"]["PosLineRequest"]; + }; + PosCompleteRequest: { + tenders: components["schemas"]["PosTenderRequest"][]; + /** @description Who collected the basket, at most 200 characters. */ + picked_up_by?: string | null; + /** + * Format: int64 + * @description The sale's revision; or send If-Match. + */ + revision?: number; + }; + PosTenderRequest: { + method: components["schemas"]["PosTenderMethod"]; + /** Format: int64 */ + amount_cents: number; + /** @description The check number or reference. */ + reference?: string | null; + /** @description The card token a card tender charges. */ + token_id?: string | null; + }; + PosVoidRequest: { + reason: string; + /** + * Format: int64 + * @description The sale's revision; or send If-Match. + */ + revision?: number; + }; + PosReturnRequest: { + /** @description Defaults REG-01. */ + register_id?: string; + /** + * Format: uuid + * @description The sale the goods came from; absent is a no-receipt return. + */ + original_sale_id?: string | null; /** Format: uuid */ - id: string; + customer_id?: string | null; + refund_method?: components["schemas"]["PosRefundMethod"]; + reason: string; + /** @description Names the gateway refund for a card return. */ + gateway_tx_id?: string | null; + lines: components["schemas"]["PosReturnLineRequest"][]; + }; + PosReturnLineRequest: { + /** + * Format: uuid + * @description Required when the line names no sale line. + */ + product_id?: string | null; + /** + * Format: uuid + * @description The sale line returned; its price and cost follow. + */ + line_id?: string | null; + description?: string | null; + /** @description A positive decimal string with at most 4 fraction digits. */ + quantity: string; + /** + * Format: int64 + * @description Required when the line names no sale line. + */ + unit_price_ten_thousandths?: number; + /** @description Default true; damaged goods stay out of stock. */ + restock?: boolean; + }; + PosTillOpenRequest: { + /** @description Defaults REG-01. */ + register_id?: string; + /** Format: int64 */ + opening_float_cents?: number; + }; + PosTillCloseRequest: { + /** @description The counted cents per lowercase method (cash, check, card, account). */ + counted_by_method: { + [key: string]: number; + }; + notes?: string | null; + }; + PosSyncRequest: { + batch_id: string; + /** @description Defaults REG-01. */ + register_id?: string; + items: components["schemas"]["PosSyncSale"][]; + }; + PosSyncSale: { + /** + * Format: uuid + * @description The offline client's id for the sale; a replay is a duplicate, not an error. + */ + client_id: string; /** Format: uuid */ - transaction_id: string; + cashier_id?: string | null; /** Format: uuid */ - product_id: string; - description: string; - quantity: number; - uom: string; + customer_id?: string | null; + items: components["schemas"]["PosLineRequest"][]; + tenders: components["schemas"]["PosTenderRequest"][]; /** - * Format: int64 - * @description Cents. + * Format: date-time + * @description When the register made the sale offline. */ - unit_price: number; + client_created_at?: string | null; + }; + /** @description One sales line in the shared shape of ADR 0005 section 2.2, the same parse everywhere a sales line is written: a product line (quantity and unit from the product when it names one), a charge line (charge_code), or a text line (a note, nothing priced); an override or a discount carries its reason and is audited. */ + PosLineRequest: { + /** + * Format: uuid + * @description The client's id for the line, echoed on created lines. + */ + id?: string | null; + /** + * @description Defaults product; a kit explodes to its components. + * @enum {string|null} + */ + line_type?: "product" | "kit" | "component" | "charge" | "text" | null; + /** Format: uuid */ + product_id?: string | null; + /** @description The charge code a charge line names. */ + charge_code?: string | null; + sku?: string | null; + description?: string | null; + /** @description A decimal string with at most 4 fraction digits; null only on a text line. */ + quantity?: string | null; + /** @description Defaults the product's unit. */ + uom?: string | null; + /** @description Defaults uom; a different one requires the pair. */ + price_uom?: string | null; + /** @description The pair side in the sale unit; both sides together, both positive. */ + uom_qty?: string | null; + /** @description The pair side in the price unit. */ + price_uom_qty?: string | null; /** * Format: int64 - * @description Cents. + * @description Defaults the product's price. */ - line_total: number; - /** Format: date-time */ - created_at: string; + unit_price_ten_thousandths?: number | null; + /** @description Required when the price is not the product's. */ + override_reason?: string | null; + /** @description A percent or a cents amount, never both. */ + discount_percent?: string | null; + /** Format: int64 */ + discount_cents?: number | null; + discount_reason?: string | null; }; - /** @description POSTender. The reference and card fields are omitted when empty. */ - PosTender: { + /** @description A sale's head, a list item. */ + PosTransactionSummary: { /** Format: uuid */ id: string; + /** @description POS-000001, human readable. */ + number: string; + /** Format: int64 */ + revision: number; + /** Format: uuid */ + branch_id: string; + register_id: string; + /** Format: uuid */ + cashier_id: string; /** Format: uuid */ - transaction_id: string; - /** @description CASH, CARD, CHECK or ACCOUNT. */ - method: string; + customer_id: string | null; + /** @description ISO 4217, the customer's or the dealer default. */ + currency: string; + /** Format: int64 */ + total_cents: number; + status: components["schemas"]["PosStatus"]; /** - * Format: int64 - * @description Cents. + * Format: uuid + * @description Set by the completion. */ - amount: number; - reference?: string; - card_last4?: string; - card_brand?: string; - gateway_tx_id?: string; - auth_code?: string; + invoice_id: string | null; + /** Format: date-time */ + completed_at: string | null; /** Format: date-time */ created_at: string; + item_count: number; }; - /** @description POSTransaction. The customer, till session, completion time, sync fields and the lines and tenders are omitted when empty; only the read and mutation routes after the first line fill the lists. */ + /** @description The full sale: the head with its lines and tenders. */ PosTransaction: { /** Format: uuid */ id: string; + /** @description POS-000001, human readable. */ + number: string; + /** Format: int64 */ + revision: number; /** Format: uuid */ branch_id: string; register_id: string; /** Format: uuid */ cashier_id: string; /** Format: uuid */ - customer_id?: string; + customer_id: string | null; + /** @description ISO 4217, the customer's or the dealer default. */ + currency: string; + /** Format: int64 */ + subtotal_cents: number; + /** Format: int64 */ + tax_cents: number; + /** Format: int64 */ + total_cents: number; /** * Format: int64 - * @description Cents. + * @description The change given, never subtracted from the payments a second time. */ - subtotal: number; + change_cents: number; + /** Format: uuid */ + till_session_id: string | null; + status: components["schemas"]["PosStatus"]; + /** + * Format: uuid + * @description The invoice the completion posted. + */ + invoice_id: string | null; + /** Format: date-time */ + completed_at: string | null; + /** Format: date-time */ + created_at: string; + lines: components["schemas"]["PosLineItem"][]; + tenders: components["schemas"]["PosTender"][]; + }; + PosTransactionPage: { + items: components["schemas"]["PosTransactionSummary"][]; + /** @description Null: the page is the whole day. */ + next_cursor: string | null; + limit: number; + /** @description Only under include=total. */ + total?: number; + }; + /** @description A sale line in the shared shape of ADR 0005 section 2.2: product, kit (with its component lines), charge and text lines, the priced fields null on a text line, the extension rounded once. */ + PosLineItem: { + /** Format: uuid */ + id: string; + position: number; + /** @enum {string} */ + line_type: "product" | "kit" | "component" | "charge" | "text"; + /** + * Format: uuid + * @description The kit line a component belongs to. + */ + parent_line_id: string | null; + /** Format: uuid */ + product_id: string | null; + /** Format: uuid */ + charge_code_id: string | null; + charge_code: string | null; + sku: string | null; + description: string; + /** @description A decimal string; null on a text line. */ + quantity: string | null; + uom: string | null; + /** @description The unit the price is per; equals uom unless the pair converts. */ + price_uom: string | null; + /** @description The pair side in the sale unit; 1 and 1 when the units agree. */ + uom_qty: string | null; + price_uom_qty: string | null; /** * Format: int64 - * @description Cents. + * @description Scale 4; null on a text line. */ - tax_amount: number; + unit_price_ten_thousandths: number | null; /** * Format: int64 - * @description Cents. + * @description The price before an override or discount moved it. */ - total: number; + priced_unit_price_ten_thousandths: number | null; + /** @enum {string} */ + price_source: "price_list" | "quote" | "override" | "manual" | "none"; + override_reason: string | null; + /** @description A percent or a cents amount, never both. */ + discount_percent: string | null; + /** Format: int64 */ + discount_cents: number | null; + discount_reason: string | null; + /** @description Who moved the price; the audit row carries the rest. */ + price_adjusted_by: string | null; /** * Format: int64 - * @description Cents, set at completion. + * @description The extension, rounded once; null on a text line. */ - change_due: number; + line_total_cents: number | null; + taxable: boolean; + /** @description The account the line's revenue posts to (a charge line's code). */ + revenue_account_code: string | null; + is_special_order: boolean; /** Format: uuid */ - till_session_id?: string; - status: components["schemas"]["PosTransactionStatus"]; - /** Format: date-time */ - completed_at?: string; + vendor_id: string | null; + /** Format: int64 */ + special_order_unit_cost_ten_thousandths: number | null; /** Format: date-time */ created_at: string; - /** @description Absent for a live sale, offline-v1 for a synced one. */ - synced_from?: string; - /** Format: date-time */ - client_created_at?: string; - line_items?: components["schemas"]["PosLineItem"][]; - tenders?: components["schemas"]["PosTender"][]; }; - /** @description TransactionSummary. */ - PosTransactionSummary: { + /** @description One payment taken at the counter, stored net: the tendered amount less any change given from it, so the payment it became is the money kept. */ + PosTender: { /** Format: uuid */ id: string; - register_id: string; + /** Format: uuid */ + sale_id: string; + method: components["schemas"]["PosTenderMethod"]; /** * Format: int64 - * @description Cents. + * @description Net of change. */ - total: number; - status: components["schemas"]["PosTransactionStatus"]; - item_count: number; - /** Format: date-time */ - completed_at?: string; + amount_cents: number; + /** + * Format: uuid + * @description The payment the completion recorded. + */ + payment_id: string | null; + reference: string | null; + card_last4: string | null; + card_brand: string | null; + gateway_tx_id: string | null; + auth_code: string | null; /** Format: date-time */ created_at: string; }; - /** @description QuickSearchResult. */ - PosSearchResult: { + /** @description One counter return: a credit memo created, posted, refunded and optionally restocked in one act. */ + PosReturn: { /** Format: uuid */ - product_id: string; - sku: string; - description: string; - /** @description Float dollars. */ - unit_price: number; - uom: string; - in_stock: number; + id: string; + /** @description RTN-000001, human readable. */ + number: string; + /** Format: int64 */ + revision: number; + /** Format: uuid */ + branch_id: string | null; + register_id: string; + /** Format: uuid */ + till_session_id: string | null; + /** Format: uuid */ + original_sale_id: string | null; + /** Format: uuid */ + customer_id: string | null; + /** Format: uuid */ + cashier_id: string; + currency: string; + /** Format: int64 */ + subtotal_cents: number; + /** Format: int64 */ + tax_cents: number; + /** Format: int64 */ + total_cents: number; + refund_method: components["schemas"]["PosRefundMethod"]; + reason: string; + /** + * Format: uuid + * @description The credit memo the return posted. + */ + credit_memo_id: string | null; + /** Format: date-time */ + created_at: string; + lines: components["schemas"]["PosReturnLine"][]; }; - /** @description CatalogProduct. */ - PosCatalogProduct: { + /** @description One returned line, its quantity and extension negative. */ + PosReturnLine: { /** Format: uuid */ - product_id: string; - sku: string; + id: string; + position: number; + /** Format: uuid */ + product_id: string | null; description: string; - /** @description Float dollars. */ - price: number; - uom: string; - in_stock: number; + /** @description A negative decimal string. */ + quantity: string | null; + uom: string | null; + /** Format: int64 */ + unit_price_ten_thousandths: number | null; + /** Format: int64 */ + line_total_cents: number | null; + restock: boolean; }; - /** @description TillSession. Everything set at close, and the branch, is omitted when empty. */ + /** @description One drawer shift. The expected cash is the sum of the session's cash payments (already net of change) plus the opening float less cash refunds. */ PosTillSession: { /** Format: uuid */ id: string; register_id: string; /** Format: uuid */ - branch_id?: string; + branch_id: string | null; /** Format: uuid */ cashier_id: string; - /** @enum {string} */ - status: "OPEN" | "CLOSED"; - /** - * Format: int64 - * @description Cents. - */ - opening_float: number; + status: components["schemas"]["PosTillStatus"]; + /** Format: int64 */ + opening_float_cents: number; /** Format: date-time */ opened_at: string; /** Format: date-time */ - closed_at?: string; - /** @description Cents keyed by tender method. */ - expected_by_method?: { - [key: string]: number; - }; - /** @description Cents keyed by tender method. */ - counted_by_method?: { + closed_at: string | null; + /** @description The expected cents per lowercase method; present once the session has sales. */ + expected_by_method: { [key: string]: number; - }; - /** - * Format: int64 - * @description Cents; negative means short. - */ - over_short?: number; - /** Format: uuid */ - gl_entry_id?: string; - notes: string; - }; - /** @description The current session wrapper. */ - PosTillCurrent: { - session: components["schemas"]["PosTillSession"] | null; - }; - /** @description TillReport. */ - PosTillReport: { - session: components["schemas"]["PosTillSession"]; - sale_count: number; - /** - * Format: int64 - * @description Cents. - */ - sales_total: number; + }; + counted_by_method: { + [key: string]: number; + }; /** * Format: int64 - * @description Cents. + * @description Set by the close. */ - tax_total: number; + over_short_cents: number | null; /** - * Format: int64 - * @description Cents. + * Format: uuid + * @description The 5030 over/short entry the close booked. */ - change_given: number; - /** @description Cents keyed by tender method. */ + gl_entry_id: string | null; + notes: string; + }; + PosTillReport: { + session: components["schemas"]["PosTillSession"]; + sale_count: number; + /** Format: int64 */ + sales_total_cents: number; + /** Format: int64 */ + tax_total_cents: number; + /** Format: int64 */ + change_cents: number; tendered_by_method: { [key: string]: number; }; - /** @description Cents keyed by tender method. */ expected_by_method: { [key: string]: number; }; }; - /** @description ZReport. */ + /** @description The immutable end-of-day snapshot written once when the session closes. */ PosZReport: { /** Format: uuid */ id: string; @@ -10963,186 +11231,54 @@ export interface components { till_session_id: string; register_id: string; /** Format: uuid */ - branch_id?: string; + branch_id: string | null; + /** Format: int64 */ + over_short_cents: number; /** - * Format: int64 - * @description Cents. + * Format: byte + * @description The frozen closing report as JSON bytes. */ - over_short: number; - /** @description The frozen closing report, a JSON object. */ - payload: components["schemas"]["PosTillReport"]; + payload: string; /** Format: date-time */ generated_at: string; }; - PosZReportList: { - z_reports: components["schemas"]["PosZReport"][] | null; - }; - /** @description POSReturnLine. */ - PosReturnLine: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - return_id: string; + PosSearchResult: { /** Format: uuid */ product_id: string; + sku: string; description: string; - quantity: number; + /** Format: int64 */ + unit_price_cents: number; uom: string; - /** - * Format: int64 - * @description Cents. - */ - unit_price: number; - /** - * Format: int64 - * @description Cents. - */ - line_total: number; - restock: boolean; + /** @description A decimal string of the on hand quantity. */ + in_stock: string; }; - /** @description POSReturn. The till session, original transaction, customer, branch, ledger entry and lines are omitted when empty. */ - PosReturn: { - /** Format: uuid */ - id: string; - register_id: string; - /** Format: uuid */ - till_session_id?: string; - /** Format: uuid */ - original_transaction_id?: string; - /** Format: uuid */ - customer_id?: string; - /** Format: uuid */ - branch_id?: string; - /** Format: uuid */ - cashier_id: string; - /** - * Format: int64 - * @description Cents. - */ - subtotal: number; - /** - * Format: int64 - * @description Cents. - */ - tax_amount: number; - /** - * Format: int64 - * @description Cents. - */ - total: number; - /** @description CASH, CARD or ACCOUNT. */ - refund_method: string; - reason: string; - status: string; + PosCatalogProduct: { /** Format: uuid */ - gl_entry_id?: string; - /** Format: date-time */ - created_at: string; - lines?: components["schemas"]["PosReturnLine"][]; - }; - PosReturnList: { - returns: components["schemas"]["PosReturn"][] | null; - }; - /** @description SyncError. */ - PosSyncError: { - client_id: string; - reason: string; + product_id: string; + sku: string; + description: string; + /** Format: int64 */ + unit_price_cents: number; + uom: string; + /** @description A decimal string of the on hand quantity. */ + in_stock: string; }; - /** @description OfflineSyncResponse. errors is omitted when empty. */ PosSyncResponse: { batch_id: string; synced_count: number; + /** @description Sales already synced, by client_id. */ duplicate_count: number; error_count: number; - errors?: components["schemas"]["PosSyncError"][]; - }; - PosTransactionCreate: { - /** @description Defaults to REG-01. */ - register_id?: string; - /** Format: uuid */ - cashier_id?: string; - /** Format: uuid */ - customer_id?: string; - }; - PosAddItem: { - /** Format: uuid */ - product_id: string; - quantity: number; - uom?: string; - }; - /** @description AddTenderRequest. */ - PosTenderInput: { - /** @description CASH, CARD, CHECK or ACCOUNT. */ - method: string; - /** @description Float dollars. */ - amount: number; - reference?: string; - /** @description Card token from the payment processor. */ - token_id?: string; - }; - PosComplete: { - tenders: components["schemas"]["PosTenderInput"][]; + /** @description Sales waiting on the tax provider; the sync retries them. */ + pending_count: number; + errors: components["schemas"]["PosSyncItemResult"][]; + pending: components["schemas"]["PosSyncItemResult"][]; }; - /** @description OfflineTransaction. */ - PosOfflineTransaction: { + PosSyncItemResult: { /** Format: uuid */ client_id: string; - register_id: string; - /** Format: uuid */ - cashier_id: string; - /** Format: uuid */ - customer_id?: string; - items: components["schemas"]["PosAddItem"][]; - tenders: components["schemas"]["PosTenderInput"][]; - /** Format: date-time */ - client_created_at: string; - }; - /** @description OfflineSyncRequest. */ - PosSyncRequest: { - batch_id: string; - register_id?: string; - items: components["schemas"]["PosOfflineTransaction"][]; - }; - PosTillOpen: { - /** @description Defaults to REG-01. */ - register_id?: string; - /** @description Float dollars; converted to cents on the server. */ - opening_float: number; - }; - PosTillClose: { - /** @description Float dollars keyed by tender method. */ - counted_by_method: { - [key: string]: number; - }; - notes?: string; - }; - /** @description ReturnLineRequest. */ - PosReturnLineRequest: { - /** Format: uuid */ - product_id: string; - description?: string; - /** @description Positive units returned. */ - quantity: number; - uom?: string; - /** @description Float dollars. */ - unit_price: number; - /** @description Defaults to true; false for damaged goods. */ - restock?: boolean; - }; - /** @description ReturnRequest. */ - PosReturnRequest: { - /** @description Defaults to REG-01. */ - register_id?: string; - /** Format: uuid */ - original_transaction_id?: string; - /** Format: uuid */ - customer_id?: string; - /** @description CASH, CARD or ACCOUNT, case insensitive; defaults to CASH. */ - refund_method?: string; - reason?: string; - /** @description The original card transaction to reverse. */ - gateway_tx_id?: string; - lines: components["schemas"]["PosReturnLineRequest"][]; + reason: string; }; /** * @description Where a price came from, lowercase (ADR 0006 section 7.3). @@ -21796,10 +21932,15 @@ export interface operations { posTransactionList: { parameters: { query?: { - /** @description Filter to one register. */ register_id?: string; - /** @description The day to list; today when omitted or unparseable. */ date?: string; + status?: "open" | "held" | "completed" | "voided"; + /** @description The opaque cursor of the previous page's next_cursor, passed back verbatim. A malformed cursor, or one minted for another ordering, is a 400 naming cursor. */ + cursor?: components["parameters"]["Cursor"]; + /** @description Page size of a converted list, 1 to 200, default 50. Anything else is a 400 naming limit (ADR 0001 section 2); it is never clamped. */ + limit?: components["parameters"]["PageLimit"]; + /** @description A comma separated list of expansions. total adds the count of rows matching the filters, which costs a second query and is therefore opt in. */ + include?: components["parameters"]["Include"]; }; header?: { /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ @@ -21810,15 +21951,16 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Summaries; an empty array when none. */ + /** @description The day's page of sales. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PosTransactionSummary"][]; + "application/json": components["schemas"]["PosTransactionPage"]; }; }; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; 500: components["responses"]["InternalError"]; @@ -21838,13 +21980,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PosTransactionCreate"]; + "application/json": components["schemas"]["PosTransactionCreateRequest"]; }; }; responses: { - /** @description The new OPEN transaction, attached to the register's open till session when there is one. */ + /** @description The new open sale, at revision 1. */ 201: { headers: { + /** @description The document's revision. */ + ETag?: string; + /** @description The sale's route. */ + Location?: string; [name: string]: unknown; }; content: { @@ -21853,18 +21999,8 @@ export interface operations { }; 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; - /** @description The caller's roles do not include one the route requires, or the caller authenticated with a machine key: a cashier must be a user. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["PosCashierRefusal"]; - }; - }; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; 500: components["responses"]["InternalError"]; }; }; @@ -21882,19 +22018,22 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The transaction. */ + /** @description The sale. */ 200: { headers: { + /** @description The document's revision. */ + ETag?: string; [name: string]: unknown; }; content: { "application/json": components["schemas"]["PosTransaction"]; }; }; - 400: components["responses"]["BadRequest"]; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; + 500: components["responses"]["InternalError"]; }; }; posTransactionAddItem: { @@ -21913,13 +22052,15 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PosAddItem"]; + "application/json": components["schemas"]["PosAddLineRequest"]; }; }; responses: { - /** @description The transaction with totals recalculated. */ + /** @description The sale with the line added. */ 200: { headers: { + /** @description The document's new revision. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -21929,9 +22070,8 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; 500: components["responses"]["InternalError"]; }; }; @@ -21950,18 +22090,22 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The transaction with totals recalculated. */ + /** @description The sale with the line removed. */ 200: { headers: { + /** @description The document's new revision. */ + ETag?: string; [name: string]: unknown; }; content: { "application/json": components["schemas"]["PosTransaction"]; }; }; - 400: components["responses"]["BadRequest"]; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; 500: components["responses"]["InternalError"]; }; }; @@ -21981,13 +22125,15 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PosComplete"]; + "application/json": components["schemas"]["PosCompleteRequest"]; }; }; responses: { - /** @description The COMPLETED transaction. */ + /** @description The completed sale with its invoice, tenders and change. */ 200: { headers: { + /** @description The document's new revision. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -21997,17 +22143,11 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - /** @description The completion failed (under-tendered sale, declined card, transaction not OPEN; the standard error envelope), or the Idempotency-Key was reused with a different request (the ADR 0001 envelope). */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["WireError"]; - }; - }; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 428: components["responses"]["WirePreconditionRequired"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["ServiceUnavailable"]; }; }; posTransactionVoid: { @@ -22024,11 +22164,17 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["PosVoidRequest"]; + }; + }; responses: { - /** @description The VOIDED transaction. */ + /** @description The voided sale. */ 200: { headers: { + /** @description The document's new revision. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -22038,17 +22184,17 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 428: components["responses"]["WirePreconditionRequired"]; 500: components["responses"]["InternalError"]; }; }; posProductSearch: { parameters: { query?: { - /** @description SKU, description or barcode. An empty or one character query answers an empty array. */ q?: string; + limit?: number; }; header?: { /** @description Branch scope for modules registered behind the branch middleware (quote, customer, order, invoice in this fragment set). A missing header passes for admins and in dev mode; with default_branch_required on, a non admin without it is refused. A non UUID value is a 400 and a branch the caller has no grant for is a 403. */ @@ -22059,7 +22205,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Up to 20 matches; a bare array. */ + /** @description The matching products. */ 200: { headers: { [name: string]: unknown; @@ -22068,12 +22214,13 @@ export interface operations { "application/json": components["schemas"]["PosSearchResult"][]; }; }; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; 500: components["responses"]["InternalError"]; }; }; - posSync: { + posSyncOffline: { parameters: { query?: never; header?: { @@ -22091,7 +22238,7 @@ export interface operations { }; }; responses: { - /** @description The batch result. */ + /** @description The batch's outcome. */ 200: { headers: { [name: string]: unknown; @@ -22103,13 +22250,10 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; }; }; - posCatalog: { + posCatalogGet: { parameters: { query?: never; header?: { @@ -22121,7 +22265,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The whole catalog as a bare array. */ + /** @description The catalog. */ 200: { headers: { [name: string]: unknown; @@ -22130,6 +22274,7 @@ export interface operations { "application/json": components["schemas"]["PosCatalogProduct"][]; }; }; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; 500: components["responses"]["InternalError"]; @@ -22149,13 +22294,15 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PosTillOpen"]; + "application/json": components["schemas"]["PosTillOpenRequest"]; }; }; responses: { - /** @description The OPEN session. */ + /** @description The open session. */ 201: { headers: { + /** @description The session's report route. */ + Location?: string; [name: string]: unknown; }; content: { @@ -22164,25 +22311,15 @@ export interface operations { }; 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; - /** @description The caller's roles do not include one the route requires, or the caller authenticated with a machine key: a cashier must be a user. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["PosCashierRefusal"]; - }; - }; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; 409: components["responses"]["ConflictEither"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; 500: components["responses"]["InternalError"]; }; }; posTillCurrent: { parameters: { query?: { - /** @description Defaults to REG-01. */ register_id?: string; }; header?: { @@ -22194,17 +22331,19 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The open session, or null when none. */ + /** @description The open session. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PosTillCurrent"]; + "application/json": components["schemas"]["PosTillSession"]; }; }; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; 500: components["responses"]["InternalError"]; }; }; @@ -22231,9 +22370,10 @@ export interface operations { "application/json": components["schemas"]["PosTillReport"]; }; }; - 400: components["responses"]["BadRequest"]; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; 500: components["responses"]["InternalError"]; }; }; @@ -22253,11 +22393,11 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PosTillClose"]; + "application/json": components["schemas"]["PosTillCloseRequest"]; }; }; responses: { - /** @description The closing Z report. */ + /** @description The closing report. */ 200: { headers: { [name: string]: unknown; @@ -22269,9 +22409,9 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; 409: components["responses"]["ConflictEither"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 500: components["responses"]["InternalError"]; }; }; posTillZReport: { @@ -22288,7 +22428,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The frozen Z snapshot. */ + /** @description The Z report. */ 200: { headers: { [name: string]: unknown; @@ -22297,17 +22437,17 @@ export interface operations { "application/json": components["schemas"]["PosZReport"]; }; }; - 400: components["responses"]["BadRequest"]; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; + 500: components["responses"]["InternalError"]; }; }; posZReportList: { parameters: { query?: { register_id?: string; - /** @description An unparseable date is ignored. */ date?: string; }; header?: { @@ -22319,15 +22459,16 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Z reports wrapped in an object. */ + /** @description The day's Z reports. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PosZReportList"]; + "application/json": components["schemas"]["PosZReport"][]; }; }; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; 500: components["responses"]["InternalError"]; @@ -22337,7 +22478,6 @@ export interface operations { parameters: { query?: { register_id?: string; - /** @description An unparseable date is ignored. */ date?: string; }; header?: { @@ -22349,15 +22489,16 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Returns wrapped in an object. */ + /** @description The day's returns. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PosReturnList"]; + "application/json": components["schemas"]["PosReturn"][]; }; }; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; 500: components["responses"]["InternalError"]; @@ -22381,9 +22522,11 @@ export interface operations { }; }; responses: { - /** @description The return with its lines. */ + /** @description The completed return. */ 201: { headers: { + /** @description The return's route. */ + Location?: string; [name: string]: unknown; }; content: { @@ -22392,18 +22535,9 @@ export interface operations { }; 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; - /** @description The caller's roles do not include one the route requires, or the caller authenticated with a machine key: a cashier must be a user. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["PosCashierRefusal"]; - }; - }; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; 500: components["responses"]["InternalError"]; }; }; @@ -22430,10 +22564,11 @@ export interface operations { "application/json": components["schemas"]["PosReturn"]; }; }; - 400: components["responses"]["BadRequest"]; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; + 500: components["responses"]["InternalError"]; }; }; pricingCalculate: { From 60cd71362904fd39ff520f1bfce0c57c7e9f3bff Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 00:12:32 -0700 Subject: [PATCH 008/113] C2-5: the desk's counter on the new shapes The types, the service and the terminal page onto the wire contract: cents tenders and floats (a dollarsToCents helper parses the cashier's input with string arithmetic, never parseFloat), the line wrapper on the add, lowercase methods and statuses, the POS- number and the revision the complete and the void carry as If-Match, the day's sales through the list envelope, the register's open session read directly (a 404 means none is open), and the error envelope parsed into the page's message. The tender view takes a split: each entered amount joins a pending tender list with the still owed or change running under the total, and the completion sends the whole list. The completed sale offers a void (with its reason, while the drawer is open) and a return: the return modal lists the sale's product lines with quantities, the refund method and the reason, and posts the credit memo through the returns route. Signed-off-by: colton --- web/apps/desk/src/pages/pos/POSTerminal.ts | 376 ++++++++++++++------- web/apps/desk/src/services/POSService.ts | 232 ++++++++----- web/apps/desk/src/types/pos.ts | 220 ++++++++---- 3 files changed, 556 insertions(+), 272 deletions(-) diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index 4b9879945..6ebf139a3 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -3,36 +3,45 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, state, query } from 'lit/decorators.js'; -import { posService } from '../../services/POSService'; -import type { POSTransaction, QuickSearchResult, POSLineItem, TillSession, TillReport } from '../../types/pos'; +import { posService, dollarsToCents, type TenderIn } from '../../services/POSService'; +import type { Sale, QuickSearchResult, SaleLine, TillSession, TillReport, TenderMethod, RefundMethod } from '../../types/pos'; import '../../components/BarcodeScanner.ts'; const REGISTER_ID = 'REG-01'; const fmt = (cents: number) => `$${(cents / 100).toFixed(2)}`; +/** A scale 4 price to dollars, for the display only. */ +const fmtPrice = (tenThousandths: number) => `$${(tenThousandths / 10000).toFixed(2)}`; +const METHODS: TenderMethod[] = ['cash', 'check', 'card', 'account']; /** - * POSTerminal -- Full-screen retail counter sales interface. - * - * Design Goals: - * - Seasonal hire can learn in < 10 minutes - * - Ring up a 5-item sale in under 60 seconds - * - Support split payments (cash + card + check + account) + * POSTerminal -- Full-screen retail counter sales interface on the wire + * contract: cents tenders (a sale can split them across methods), a void of + * the completed sale while the drawer is open, and a return that posts a + * credit memo and refunds out of the drawer. */ @customElement('gable-pos-terminal') export class POSTerminal extends LitElement { createRenderRoot() { return this; } - @state() private _transaction: POSTransaction | null = null; + @state() private _sale: Sale | null = null; @state() private _searchQuery = ''; @state() private _searchResults: QuickSearchResult[] = []; @state() private _showTender = false; - @state() private _tenderMethod = ''; + @state() private _pendingTenders: TenderIn[] = []; + @state() private _tenderMethod: TenderMethod = 'cash'; @state() private _tenderAmount = ''; @state() private _loading = false; @state() private _error: string | null = null; @state() private _success: string | null = null; @state() private _isScanning = false; + // The completed sale offered for a return or a void. + @state() private _completed: Sale | null = null; + @state() private _showReturn = false; + @state() private _returnReason = 'wrong material'; + @state() private _returnMethod: RefundMethod = 'cash'; + @state() private _returnQty: Record = {}; + // Till (drawer) state @state() private _till: TillSession | null = null; @state() private _showTillOpen = false; @@ -52,7 +61,7 @@ export class POSTerminal extends LitElement { connectedCallback() { super.connectedCallback(); void this._loadTill(); - this._startNewTransaction(); + this._startNewSale(); } /* ---- Till (drawer) lifecycle ---- */ @@ -66,20 +75,20 @@ export class POSTerminal extends LitElement { } private async _openTill() { - const float = parseFloat(this._openingFloat); - if (isNaN(float) || float < 0) { + const cents = dollarsToCents(this._openingFloat); + if (isNaN(cents) || cents < 0) { this._error = 'Enter a valid opening float'; return; } try { this._tillBusy = true; - this._till = await posService.openTill(REGISTER_ID, float); + this._till = await posService.openTill(REGISTER_ID, cents); this._showTillOpen = false; this._openingFloat = ''; - this._success = `Till opened with ${fmt(this._till.opening_float)} float`; + this._success = `Till opened with ${fmt(this._till.opening_float_cents)} float`; this._errorTimer = setTimeout(() => { this._success = null; }, 2500); - // Re-start the transaction so it attaches to the new session. - void this._startNewTransaction(); + // Re-start the sale so it attaches to the new session. + void this._startNewSale(); } catch (err: unknown) { this._error = err instanceof Error ? err.message : 'Failed to open till'; } finally { @@ -92,8 +101,8 @@ export class POSTerminal extends LitElement { try { this._tillBusy = true; this._tillReport = await posService.tillReport(this._till.id); - // Seed blind-count inputs for every method the drawer expects (+ CASH). - const methods = new Set(['CASH', ...Object.keys(this._tillReport.expected_by_method || {})]); + // Seed blind-count inputs for every method the drawer expects (+ cash). + const methods = new Set(['cash', ...Object.keys(this._tillReport.expected_by_method || {})]); const seed: Record = {}; methods.forEach((m) => { seed[m] = ''; }); this._counts = seed; @@ -110,8 +119,8 @@ export class POSTerminal extends LitElement { if (!this._till) return; const counted: Record = {}; for (const [method, val] of Object.entries(this._counts)) { - const n = parseFloat(val); - if (!isNaN(n)) counted[method] = n; + const cents = dollarsToCents(val); + if (!isNaN(cents)) counted[method] = cents; } try { this._tillBusy = true; @@ -166,49 +175,41 @@ export class POSTerminal extends LitElement { this._searchDebounce = setTimeout(async () => { try { - const results = await posService.searchProducts(this._searchQuery); - this._searchResults = results; + this._searchResults = await posService.searchProducts(this._searchQuery); } catch { this._searchResults = []; } }, 200); } - /* ---- Transaction management ---- */ + /* ---- Sale management ---- */ - private async _startNewTransaction() { + private async _startNewSale() { try { this._loading = true; this._error = null; this._success = null; - const cashierId = localStorage.getItem('user_id') || ''; - if (!cashierId) { - this._error = 'No cashier ID found. Please log in again.'; - return; - } - const tx = await posService.startTransaction('REG-01', cashierId); - this._transaction = tx; + this._pendingTenders = []; + this._completed = null; + const sale = await posService.startSale(REGISTER_ID); + this._sale = sale; this._showTender = false; // Focus the search input after render this.updateComplete.then(() => { this._searchInput?.focus(); }); } catch (err: unknown) { - this._error = err instanceof Error ? err.message : 'Failed to start transaction'; + this._error = err instanceof Error ? err.message : 'Failed to start the sale'; } finally { this._loading = false; } } private async _addItem(product: QuickSearchResult) { - if (!this._transaction) return; + if (!this._sale) return; try { - const updated = await posService.addItem(this._transaction.id, { - product_id: product.product_id, - quantity: 1, - uom: product.uom, - }); - this._transaction = updated; + const updated = await posService.addItem(this._sale.id, product.product_id, '1', product.uom); + this._sale = updated; this._searchQuery = ''; this._searchResults = []; this._searchInput?.focus(); @@ -218,47 +219,58 @@ export class POSTerminal extends LitElement { } private async _removeItem(itemId: string) { - if (!this._transaction) return; + if (!this._sale) return; try { - const updated = await posService.removeItem(this._transaction.id, itemId); - this._transaction = updated; + this._sale = await posService.removeItem(this._sale.id, itemId); } catch (err: unknown) { this._error = err instanceof Error ? err.message : 'Failed to remove item'; } } - private _handleTender(method: string) { - if (!this._transaction) return; + /* ---- Tenders (a split tender accumulates before it completes) ---- */ + + private get _tenderedSoFar(): number { + return this._pendingTenders.reduce((sum, t) => sum + t.amount_cents, 0); + } + + private _handleTender(method: TenderMethod) { + if (!this._sale) return; this._tenderMethod = method; - this._tenderAmount = (this._transaction.total / 100).toFixed(2); + const remaining = this._sale.total_cents - this._tenderedSoFar; + this._tenderAmount = (Math.max(remaining, 0) / 100).toFixed(2); this._showTender = true; } + /** Add the entered tender to the pending list; a split names several. */ + private _addPendingTender() { + const cents = dollarsToCents(this._tenderAmount); + if (isNaN(cents) || cents <= 0) { + this._error = 'Invalid tender amount'; + return; + } + this._pendingTenders = [...this._pendingTenders, { method: this._tenderMethod, amount_cents: cents }]; + this._showTender = false; + this._tenderAmount = ''; + } + private async _completeSale() { - if (!this._transaction || !this._tenderMethod) return; + if (!this._sale || this._pendingTenders.length === 0) return; try { this._loading = true; this._error = null; - const amount = parseFloat(this._tenderAmount); - if (isNaN(amount) || amount <= 0) { - this._error = 'Invalid tender amount'; - return; - } - - const completed = await posService.completeTransaction(this._transaction.id, [{ - method: this._tenderMethod, - amount, - }]); - this._transaction = completed; - const change = completed.change_due || 0; + const completed = await posService.completeSale(this._sale.id, this._sale.revision, this._pendingTenders); + this._sale = completed; + this._completed = completed; + const change = completed.change_cents || 0; this._success = change > 0 - ? `Sale complete — ${fmt(completed.total)} · CHANGE DUE ${fmt(change)}` - : `Sale complete — ${fmt(completed.total)}`; + ? `Sale ${completed.number} complete \u2014 ${fmt(completed.total_cents)} \u00b7 CHANGE DUE ${fmt(change)}` + : `Sale ${completed.number} complete \u2014 ${fmt(completed.total_cents)}`; + this._pendingTenders = []; this._showTender = false; - // Auto-start new transaction after 2 seconds + // Auto-start the next sale after 2 seconds this._newTxTimer = setTimeout(() => { - this._startNewTransaction(); + this._startNewSale(); }, 2000); } catch (err: unknown) { this._error = err instanceof Error ? err.message : 'Failed to complete sale'; @@ -267,18 +279,70 @@ export class POSTerminal extends LitElement { } } - private async _voidTransaction() { - if (!this._transaction) return; - if (!window.confirm('Void this transaction?')) return; + private async _voidSale() { + const sale = this._completed ?? this._sale; + if (!sale || sale.status !== 'completed') { + this._error = 'Only a completed sale is voided, while its drawer is open'; + return; + } + const reason = window.prompt('Void reason?'); + if (!reason) return; + try { + const voided = await posService.voidSale(sale.id, sale.revision, reason); + this._sale = voided; + this._completed = null; + this._success = `Sale ${voided.number} voided \u2014 the ledger and the stock are whole again`; + } catch (err: unknown) { + this._error = err instanceof Error ? err.message : 'Failed to void the sale'; + } + } + + /* ---- Returns ---- */ + + private _beginReturn() { + const sale = this._completed ?? this._sale; + if (!sale || sale.status !== 'completed') { + this._error = 'Return needs a completed sale'; + return; + } + this._completed = sale; + const qty: Record = {}; + sale.lines.filter(l => l.line_type === 'product' && l.parent_line_id === null).forEach(l => { qty[l.id] = '0'; }); + this._returnQty = qty; + this._showReturn = true; + } + + private async _confirmReturn() { + const sale = this._completed; + if (!sale) return; + const lines = Object.entries(this._returnQty) + .filter(([, q]) => Number(q) > 0) + .map(([line_id, q]) => ({ line_id, quantity: q, restock: true })); + if (lines.length === 0) { + this._error = 'Name at least one line and quantity to return'; + return; + } try { - await posService.voidTransaction(this._transaction.id); - this._startNewTransaction(); + this._loading = true; + const ret = await posService.createReturn({ + register_id: REGISTER_ID, + original_sale_id: sale.id, + customer_id: sale.customer_id ?? undefined, + refund_method: this._returnMethod, + reason: this._returnReason, + lines, + }); + this._showReturn = false; + this._success = `Return ${ret.number} complete \u2014 ${fmt(ret.total_cents)} refunded ${ret.refund_method}`; + this._errorTimer = setTimeout(() => { this._success = null; }, 4000); } catch (err: unknown) { - this._error = err instanceof Error ? err.message : 'Failed to void transaction'; + this._error = err instanceof Error ? err.message : 'Failed to take the return'; + } finally { + this._loading = false; } } - /* ---- Till modals ---- */ + /* ---- Modals ---- */ private _overlay(inner: unknown) { return html` @@ -301,7 +365,7 @@ export class POSTerminal extends LitElement { style="width:100%;box-sizing:border-box;padding:14px;background:#0d1117;border:2px solid #8957e5;border-radius:8px;color:#e6edf3;font-size:22px;font-weight:700;text-align:center;outline:none" placeholder="200.00" />
- +
`); } @@ -313,18 +377,18 @@ export class POSTerminal extends LitElement { // Result view (Z-report): expected vs counted vs over/short. if (this._closeResult) { const r = this._closeResult; - const os = r.session.over_short ?? 0; + const os = r.session.over_short_cents ?? 0; const osColor = os === 0 ? '#3fb950' : os < 0 ? '#f85149' : '#d29922'; const osLabel = os === 0 ? 'BALANCED' : os < 0 ? `SHORT ${fmt(Math.abs(os))}` : `OVER ${fmt(os)}`; return this._overlay(html` -

Z-Report — Till Closed

+

Z-Report \u2014 Till Closed

Over / Short
${osLabel}
- + ${Object.keys(r.session.expected_by_method || {}).map((m) => { @@ -339,7 +403,7 @@ export class POSTerminal extends LitElement { })}
MethodExpectedCountedΔMethodExpectedCounted\u0394
-
${r.sale_count} sales · ${fmt(r.sales_total)} rung · ${fmt(r.tax_total)} tax · ${fmt(r.change_given)} change given
+
${r.sale_count} sales \u00b7 ${fmt(r.sales_total_cents)} rung \u00b7 ${fmt(r.tax_total_cents)} tax \u00b7 ${fmt(r.change_cents)} change given
`); } @@ -348,14 +412,14 @@ export class POSTerminal extends LitElement { const rep = this._tillReport; const methods = Object.keys(this._counts); return this._overlay(html` -

Close Till — Blind Count

+

Close Till \u2014 Blind Count

- Count the drawer and enter actuals by tender. Expected totals stay hidden until you post — that's the blind count. + Count the drawer and enter actuals by tender. Expected totals stay hidden until you post \u2014 that's the blind count. ${rep ? html`
${rep.sale_count} sales rung this session.` : nothing}

${methods.map((m) => html`
- + { this._counts = { ...this._counts, [m]: (e.target as HTMLInputElement).value }; }} style="flex:1;box-sizing:border-box;padding:10px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#e6edf3;font-size:16px;text-align:right;outline:none" placeholder="0.00" /> @@ -363,7 +427,43 @@ export class POSTerminal extends LitElement { `)}
- + +
+ `); + } + + private _renderReturnModal() { + if (!this._showReturn || !this._completed) return nothing; + const sale = this._completed; + const productLines = sale.lines.filter(l => l.line_type === 'product' && l.parent_line_id === null); + return this._overlay(html` +

Return \u2014 ${sale.number}

+

Name the lines coming back. The return posts a credit memo, restocks what returns, and refunds out of the drawer.

+ ${productLines.map((l) => html` +
+ ${l.description} + of ${l.quantity} + { this._returnQty = { ...this._returnQty, [l.id]: (e.target as HTMLInputElement).value }; }} + style="width:80px;padding:8px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#e6edf3;font-size:14px;text-align:right;outline:none" /> +
+ `)} +
+ + +
+ { this._returnReason = (e.target as HTMLInputElement).value; }} + style="width:100%;box-sizing:border-box;padding:10px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#e6edf3;font-size:14px;outline:none;margin-bottom:16px" placeholder="Reason" /> +
+ +
`); } @@ -371,10 +471,13 @@ export class POSTerminal extends LitElement { /* ---- Render ---- */ render() { - const totalDollars = this._transaction ? (this._transaction.total / 100).toFixed(2) : '0.00'; - const subtotalDollars = this._transaction ? (this._transaction.subtotal / 100).toFixed(2) : '0.00'; - const taxDollars = this._transaction ? (this._transaction.tax_amount / 100).toFixed(2) : '0.00'; - const lineItems: POSLineItem[] = this._transaction?.line_items || []; + const sale = this._sale; + const totalDollars = sale ? (sale.total_cents / 100).toFixed(2) : '0.00'; + const subtotalDollars = sale ? (sale.subtotal_cents / 100).toFixed(2) : '0.00'; + const taxDollars = sale ? (sale.tax_cents / 100).toFixed(2) : '0.00'; + const lines: SaleLine[] = sale?.lines ?? []; + const productLines = lines.filter(l => l.parent_line_id === null); + const remaining = sale ? sale.total_cents - this._tenderedSoFar : 0; return html`
@@ -385,30 +488,30 @@ export class POSTerminal extends LitElement { REG-01 ${this._till ? html` - ● TILL OPEN · float ${fmt(this._till.opening_float)} + \u25cf TILL OPEN \u00b7 float ${fmt(this._till.opening_float_cents)} ` : html` - ○ NO TILL + \u25cb NO TILL `} - ${this._transaction ? html` + ${sale ? html` - TX: ${this._transaction.id.slice(0, 8)} + ${sale.number} \u00b7 ${sale.status} ` : nothing}
${this._till ? html` ` : html` `} -
@@ -416,6 +519,7 @@ export class POSTerminal extends LitElement { ${this._renderTillOpenModal()} ${this._renderTillCloseModal()} + ${this._renderReturnModal()} ${this._error ? html` @@ -468,7 +572,7 @@ export class POSTerminal extends LitElement { > ${result.sku} ${result.description} - $${result.unit_price.toFixed(2)}/${result.uom} + ${fmt(result.unit_price_cents)}/${result.uom} ${result.in_stock} avail `)} @@ -476,9 +580,9 @@ export class POSTerminal extends LitElement { ` : nothing}
- +
- ${lineItems.length === 0 ? html` + ${productLines.length === 0 ? html`
🛒

Search and add products to start a sale

@@ -495,17 +599,17 @@ export class POSTerminal extends LitElement { - ${lineItems.map((item: POSLineItem) => html` + ${productLines.map((item: SaleLine) => html` ${item.description} ${item.quantity} ${item.uom} - $${(item.unit_price / 100).toFixed(2)} + ${item.unit_price_ten_thousandths !== null ? fmtPrice(item.unit_price_ten_thousandths) : '\u2014'} - $${(item.line_total / 100).toFixed(2)} + ${item.line_total_cents !== null ? fmt(item.line_total_cents) : '\u2014'}
+ ${this._pendingTenders.length > 0 ? html` +
+ ${this._pendingTenders.map((t, i) => html` +
+ ${t.method} + ${fmt(t.amount_cents)} +
+ `)} +
+ ${remaining > 0 ? 'Still owed' : 'Change'} + ${fmt(Math.abs(remaining))} +
+
+ ` : nothing}
- ${!this._showTender ? html` + ${!this._showTender && this._pendingTenders.length === 0 ? html`
- - - - + ${METHODS.map(m => html` + + `)}
- ` : html` + ` : this._showTender ? html`
-
- ${this._tenderMethod === 'CASH' ? 'Cash' : ''} - ${this._tenderMethod === 'CARD' ? 'Card' : ''} - ${this._tenderMethod === 'CHECK' ? 'Check' : ''} - ${this._tenderMethod === 'ACCOUNT' ? 'Account' : ''} +
+ ${this._tenderMethod}
+
+ +
+ +
+ ` : html` +
+
Tenders taken against $${totalDollars}
+ ${METHODS.map(m => html` + + `)}
`} @@ -590,12 +719,19 @@ export class POSTerminal extends LitElement {
+
diff --git a/web/apps/desk/src/services/POSService.ts b/web/apps/desk/src/services/POSService.ts index f60e7b7af..4aaec7144 100644 --- a/web/apps/desk/src/services/POSService.ts +++ b/web/apps/desk/src/services/POSService.ts @@ -1,133 +1,205 @@ // SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors +// The counter module's desk service on the wire contract (ADR 0005 14.2 +// C2-5): tenders and floats in cents, the line wrapper, lowercase methods, +// and the revision preconditions the complete and the void carry. Money is +// parsed to cents with string arithmetic, never parseFloat. + import type { - POSTransaction, + Sale, + SalePage, + SaleSummary, QuickSearchResult, - AddLineItemRequest, - AddTenderRequest, - TransactionSummary, + TenderMethod, + CounterReturn, + RefundMethod, TillSession, - TillReport -} from '../types/pos'; + TillReport, +} from '../types/pos.ts'; import { fetchWithAuth } from './fetchClient'; +import { parseApiError, ifMatch } from './apiError'; const API_URL = import.meta.env.VITE_API_URL || ''; +async function expectOk(response: Response, fallback: string): Promise { + if (response.ok) return response; + throw await parseApiError(response, fallback); +} + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +/** Dollars typed by a cashier to integer cents; an empty or bad parse is NaN. */ +export function dollarsToCents(text: string): number { + const t = text.trim(); + if (!/^-?\d+(\.\d{1,2})?$/.test(t)) return NaN; + const [whole, fraction = ''] = t.split('.'); + const sign = whole.startsWith('-') ? -1 : 1; + const w = whole.replace('-', ''); + const cents = Number(w) * 100 + Number(fraction.padEnd(2, '0') || '0'); + return sign * cents; +} + +export interface TenderIn { + method: TenderMethod; + amount_cents: number; + reference?: string; + token_id?: string; +} + export const posService = { - startTransaction: async (registerID: string, cashierID: string, customerID?: string): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/transactions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - register_id: registerID, - cashier_id: cashierID, - customer_id: customerID || undefined, + async startSale(registerID: string, customerID?: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ register_id: registerID, customer_id: customerID || undefined }), }), - }); - if (!response.ok) throw new Error('Failed to start transaction'); + 'Failed to start the sale', + ); return response.json(); }, - getTransaction: async (id: string): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${id}`); - if (!response.ok) throw new Error('Failed to get transaction'); + async getSale(id: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${id}`), + 'Failed to read the sale', + ); return response.json(); }, - addItem: async (txId: string, item: AddLineItemRequest): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${txId}/items`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(item), - }); - if (!response.ok) throw new Error('Failed to add item'); + async addItem(saleId: string, productID: string, quantity: string, uom?: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${saleId}/items`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ line: { product_id: productID, quantity, uom } }), + }), + 'Failed to add the item', + ); return response.json(); }, - removeItem: async (txId: string, itemId: string): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${txId}/items/${itemId}`, { - method: 'DELETE', - }); - if (!response.ok) throw new Error('Failed to remove item'); + async removeItem(saleId: string, itemId: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${saleId}/items/${itemId}`, { method: 'DELETE' }), + 'Failed to remove the item', + ); return response.json(); }, - completeTransaction: async (txId: string, tenders: AddTenderRequest[]): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${txId}/complete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tenders }), - }); - if (!response.ok) { - const err = await response.text(); - throw new Error(err || 'Failed to complete transaction'); - } + /** The money moment: the tenders in cents on the sale's revision. */ + async completeSale(saleId: string, revision: number, tenders: TenderIn[]): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${saleId}/complete`, { + method: 'POST', + headers: { ...JSON_HEADERS, 'If-Match': ifMatch(revision) }, + body: JSON.stringify({ tenders }), + }), + 'Failed to complete the sale', + ); return response.json(); }, - voidTransaction: async (txId: string): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${txId}/void`, { - method: 'POST', - }); - if (!response.ok) throw new Error('Failed to void transaction'); + async voidSale(saleId: string, revision: number, reason: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${saleId}/void`, { + method: 'POST', + headers: { ...JSON_HEADERS, 'If-Match': ifMatch(revision) }, + body: JSON.stringify({ reason }), + }), + 'Failed to void the sale', + ); return response.json(); }, - listTransactions: async (registerID?: string, date?: string): Promise => { + /** The day's sales: one page, newest first (the page is the whole day). */ + async listSales(registerID?: string, date?: string): Promise { const params = new URLSearchParams(); if (registerID) params.append('register_id', registerID); if (date) params.append('date', date); - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/transactions?${params.toString()}`); - if (!response.ok) throw new Error('Failed to list transactions'); + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions?${params.toString()}`), + 'Failed to list the day\u2019s sales', + ); return response.json(); }, - searchProducts: async (query: string): Promise => { + async todaySales(registerID?: string): Promise { + return (await this.listSales(registerID)).items; + }, + + async searchProducts(query: string): Promise { if (query.length < 2) return []; - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/products/search?q=${encodeURIComponent(query)}`); - if (!response.ok) throw new Error('Failed to search products'); + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/products/search?q=${encodeURIComponent(query)}`), + 'Failed to search products', + ); + return response.json(); + }, + + // --- Returns --- + + async createReturn(input: { + register_id?: string; + original_sale_id?: string; + customer_id?: string; + refund_method: RefundMethod; + reason: string; + lines: Array<{ line_id: string; quantity: string; restock?: boolean }>; + }): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/returns`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(input), + }), + 'Failed to take the return', + ); return response.json(); }, // --- Till sessions (drawer lifecycle) --- - currentTill: async (registerID: string): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/till/current?register_id=${encodeURIComponent(registerID)}`); - if (!response.ok) throw new Error('Failed to look up till'); - const data = await response.json(); - return data.session ?? null; + /** The register's open session, or null when none is open. */ + async currentTill(registerID: string): Promise { + const response = await fetchWithAuth( + `${API_URL}/api/v1/pos/till/current?register_id=${encodeURIComponent(registerID)}`, + ); + if (response.status === 404) return null; + if (!response.ok) throw await parseApiError(response, 'Failed to look up the till'); + return response.json(); }, - openTill: async (registerID: string, openingFloat: number): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/till/open`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ register_id: registerID, opening_float: openingFloat }), - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body?.error?.message || 'Failed to open till'); - } + async openTill(registerID: string, openingFloatCents: number): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/till/open`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ register_id: registerID, opening_float_cents: openingFloatCents }), + }), + 'Failed to open the till', + ); return response.json(); }, - tillReport: async (sessionID: string): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/till/${sessionID}/report`); - if (!response.ok) throw new Error('Failed to load till report'); + async tillReport(sessionID: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/till/${sessionID}/report`), + 'Failed to load the till report', + ); return response.json(); }, - closeTill: async (sessionID: string, countedByMethod: Record, notes: string): Promise => { - const response = await fetchWithAuth(`${API_URL}/api/v1/pos/till/${sessionID}/close`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ counted_by_method: countedByMethod, notes }), - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body?.error?.message || 'Failed to close till'); - } + async closeTill(sessionID: string, countedByMethod: Record, notes: string): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/till/${sessionID}/close`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ counted_by_method: countedByMethod, notes }), + }), + 'Failed to close the till', + ); return response.json(); }, }; diff --git a/web/apps/desk/src/types/pos.ts b/web/apps/desk/src/types/pos.ts index df8557cfc..6100e00ff 100644 --- a/web/apps/desk/src/types/pos.ts +++ b/web/apps/desk/src/types/pos.ts @@ -1,106 +1,182 @@ // SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 // SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors -// POS Types — Retail Counter Sales +// The counter module on the wire contract (ADR 0005 section 14.2 C2-5): one +// status field in lowercase, every amount an integer of cents, prices at +// scale 4, quantities decimal strings, and human readable POS- and RTN- +// numbers. Money stays integers until the display helper. -export type TransactionStatus = 'OPEN' | 'COMPLETED' | 'VOIDED' | 'RETURNED' | 'HELD'; +export type SaleStatus = 'open' | 'held' | 'completed' | 'voided'; +export type TenderMethod = 'cash' | 'check' | 'card' | 'account'; +export type RefundMethod = 'cash' | 'card' | 'account'; +export type TillSessionStatus = 'open' | 'closed'; +export type LineType = 'product' | 'kit' | 'component' | 'charge' | 'text'; +export type PriceSource = 'price_list' | 'quote' | 'override' | 'manual' | 'none'; -export interface POSTransaction { +/** One line of a counter sale: the shared salesdoc shape (ADR 0005 2.2). */ +export interface SaleLine { id: string; + position: number; + line_type: LineType; + parent_line_id: string | null; + product_id: string | null; + charge_code_id: string | null; + charge_code: string | null; + sku: string | null; + description: string; + quantity: string | null; + uom: string | null; + price_uom: string | null; + uom_qty: string | null; + price_uom_qty: string | null; + unit_price_ten_thousandths: number | null; + priced_unit_price_ten_thousandths: number | null; + price_source: PriceSource; + override_reason: string | null; + discount_percent: string | null; + discount_cents: number | null; + discount_reason: string | null; + price_adjusted_by: string | null; + line_total_cents: number | null; + taxable: boolean; + revenue_account_code: string | null; + is_special_order: boolean; + vendor_id: string | null; + special_order_unit_cost_ten_thousandths: number | null; + created_at: string; +} + +/** One payment taken at the counter, stored net of change. */ +export interface Tender { + id: string; + sale_id: string; + method: TenderMethod; + amount_cents: number; + payment_id: string | null; + reference: string | null; + card_last4: string | null; + card_brand: string | null; + gateway_tx_id: string | null; + auth_code: string | null; + created_at: string; +} + +export interface Sale { + id: string; + number: string; + revision: number; + branch_id: string; register_id: string; cashier_id: string; - customer_id?: string; - subtotal: number; - tax_amount: number; - total: number; - change_due: number; // cents, set at completion - till_session_id?: string; - status: TransactionStatus; - completed_at?: string; + customer_id: string | null; + currency: string; + subtotal_cents: number; + tax_cents: number; + total_cents: number; + change_cents: number; + till_session_id: string | null; + status: SaleStatus; + invoice_id: string | null; + completed_at: string | null; created_at: string; - line_items?: POSLineItem[]; - tenders?: POSTender[]; + lines: SaleLine[]; + tenders: Tender[]; } -// --- Till sessions (drawer lifecycle) --- +export interface SaleSummary { + id: string; + number: string; + revision: number; + branch_id: string; + register_id: string; + cashier_id: string; + customer_id: string | null; + currency: string; + total_cents: number; + status: SaleStatus; + invoice_id: string | null; + completed_at: string | null; + created_at: string; + item_count: number; +} + +export interface SalePage { + items: SaleSummary[]; + next_cursor: string | null; + limit: number; + total?: number; +} + +// --- Returns --- + +export interface ReturnLine { + id: string; + position: number; + product_id: string | null; + description: string; + quantity: string | null; + uom: string | null; + unit_price_ten_thousandths: number | null; + line_total_cents: number | null; + restock: boolean; +} + +export interface CounterReturn { + id: string; + number: string; + revision: number; + branch_id: string | null; + register_id: string; + till_session_id: string | null; + original_sale_id: string | null; + customer_id: string | null; + cashier_id: string; + currency: string; + subtotal_cents: number; + tax_cents: number; + total_cents: number; + refund_method: RefundMethod; + reason: string; + credit_memo_id: string | null; + created_at: string; + lines: ReturnLine[]; +} -export type TillSessionStatus = 'OPEN' | 'CLOSED'; +// --- Till sessions (drawer lifecycle) --- export interface TillSession { id: string; register_id: string; + branch_id: string | null; cashier_id: string; status: TillSessionStatus; - opening_float: number; // cents + opening_float_cents: number; opened_at: string; - closed_at?: string; - expected_by_method?: Record; // cents - counted_by_method?: Record; // cents - over_short?: number; // cents; negative = short + closed_at: string | null; + expected_by_method: Record; + counted_by_method: Record; + over_short_cents: number | null; + gl_entry_id: string | null; notes: string; } export interface TillReport { session: TillSession; sale_count: number; - sales_total: number; // cents - tax_total: number; // cents - change_given: number; // cents - tendered_by_method: Record; // cents - expected_by_method: Record; // cents + sales_total_cents: number; + tax_total_cents: number; + change_cents: number; + tendered_by_method: Record; + expected_by_method: Record; } -export interface POSLineItem { - id: string; - transaction_id: string; - product_id: string; - description: string; - quantity: number; - uom: string; - unit_price: number; // cents - line_total: number; // cents - created_at: string; -} - -export interface POSTender { - id: string; - transaction_id: string; - method: string; - amount: number; // cents - reference?: string; - card_last4?: string; - card_brand?: string; - created_at: string; -} +// --- Typeahead and offline catalog --- export interface QuickSearchResult { product_id: string; sku: string; description: string; - unit_price: number; + unit_price_cents: number; uom: string; - in_stock: number; -} - -export interface AddLineItemRequest { - product_id: string; - quantity: number; - uom: string; -} - -export interface AddTenderRequest { - method: string; - amount: number; - reference?: string; - token_id?: string; -} - -export interface TransactionSummary { - id: string; - register_id: string; - total: number; - status: TransactionStatus; - item_count: number; - completed_at?: string; - created_at: string; + in_stock: string; } From 9877dcab54392f1cd6571c86ee4af6ffd35b5753 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 00:38:22 -0700 Subject: [PATCH 009/113] C2-5: the counter's end to end run, and the void and return race it found The Playwright spec walks the desk's terminal through the real stack: the drawer opens, a two line sale pays with a split tender (a dollar in cash, the rest by check, the still owed running under the total), the return takes one line back as a cash refunded credit memo, and a second sale paid in cash with change is voided with its reason; the day's list read back shows the completed and the voided sale. Screenshots land in shots/c2-5. Three bugs the walk found, fixed. The return's parse demanded a product_id on every line even when line_id named the sale line whose product, price and cost follow (the ADR's rule; the e2e return was a 400). A line_id naming no line of the original sale fell through to a misleading price complaint and an unknown standalone product reached the insert as a foreign key 500; both are 400s naming their field now, with the product lookup running after the sale lines resolve so the resolved products are covered too. And the void racing a return could BOTH win: the return neither locked the sale row nor moved it, so a trailing void restocked the returned goods again (the race test's winner check also missed the return's 201). The return now locks the sale row first (section 11, step 1), refuses anything but a completed sale, and moves the sale's revision in process, so the void built on the earlier revision is refused stale; the race test treats any 2xx as a win and passed eight times in a row under the detector. Signed-off-by: colton --- core/internal/pos/input.go | 6 +- core/internal/pos/repository.go | 13 +++ core/internal/pos/returns.go | 75 +++++++++---- core/internal/pos/tx_test.go | 6 +- web/apps/desk/src/pages/pos/POSTerminal.ts | 3 +- web/e2e/pos.spec.ts | 122 +++++++++++++++++++++ 6 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 web/e2e/pos.spec.ts diff --git a/core/internal/pos/input.go b/core/internal/pos/input.go index 85bcad0a0..faccaa4bf 100644 --- a/core/internal/pos/input.go +++ b/core/internal/pos/input.go @@ -229,12 +229,16 @@ func (r *returnRequest) parse() (*ReturnIn, error) { l := &r.Lines[i] field := func(name string) string { return fmt.Sprintf("lines[%d].%s", i, name) } rl := ReturnLineIn{Restock: true} - if id, ok := v.UUID(field("product_id"), l.ProductID, true); ok { + if id, ok := v.UUID(field("product_id"), l.ProductID, false); ok { rl.ProductID = &id } if id, ok := v.UUID(field("line_id"), l.LineID, false); ok { rl.SaleLineID = &id } + // A line names the sale line it returns (its product, price and cost + // follow) or stands alone on its own product. + v.Check(rl.ProductID != nil || rl.SaleLineID != nil, field("product_id"), + "name the sale line or a product") if l.Description != nil { rl.Description = *l.Description } diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 520601aee..2248a23ac 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -27,6 +27,7 @@ type Repository interface { // Sales CreateSale(ctx context.Context, s *Sale) error LockSale(ctx context.Context, id uuid.UUID) error + BumpSaleRevision(ctx context.Context, id uuid.UUID) error GetSale(ctx context.Context, id uuid.UUID) (*Sale, error) UpdateSaleTotals(ctx context.Context, id uuid.UUID, subtotal, tax, total int64) error CompleteSale(ctx context.Context, id, invoiceID uuid.UUID, subtotal, tax, total, change int64) error @@ -217,6 +218,18 @@ func (r *PostgresRepository) LockSale(ctx context.Context, id uuid.UUID) error { return err } +// BumpSaleRevision moves a sale's revision in process (the recipe's rule for +// an act that touches a document it does not own the status of): a return +// against the sale does it, so a void built on the earlier revision is +// refused stale instead of also restocking the goods. +func (r *PostgresRepository) BumpSaleRevision(ctx context.Context, id uuid.UUID) error { + _, err := r.ex(ctx).Exec(ctx, `UPDATE pos_transactions SET revision = revision + 1, updated_at = NOW() WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("failed to move the sale's revision: %w", err) + } + return nil +} + const saleCols = `t.id, t.number, t.revision, t.branch_id, t.register_id, t.cashier_id, t.customer_id, t.currency, ROUND(t.subtotal * 100)::bigint, ROUND(t.tax_amount * 100)::bigint, ROUND(t.total * 100)::bigint, ROUND(t.change_due * 100)::bigint, t.till_session_id, t.status, t.invoice_id, t.completed_at, t.created_at` diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index a7ae9e1af..03bb895bb 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -86,32 +86,47 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur for i := range saleLines { byID[saleLines[i].ID] = saleLines[i] } - productIDs := make([]uuid.UUID, 0, len(in.Lines)) + // First pass: fill each line from the sale line it names, so the product + // lookup covers the resolved products too; a line that named no sale line + // stands alone on the product the request named. + resolved := make([]ReturnLineIn, len(in.Lines)) for i := range in.Lines { - if in.Lines[i].ProductID != nil { - productIDs = append(productIDs, *in.Lines[i].ProductID) + rl := in.Lines[i] + if rl.SaleLineID != nil { + src, ok := byID[*rl.SaleLineID] + if !ok { + return nil, invalid(fmt.Sprintf("lines[%d].line_id", i), "names no line of the original sale") + } + if rl.UnitPrice == nil && src.UnitPrice != nil { + p := *src.UnitPrice + rl.UnitPrice = &p + } + if rl.Description == "" { + rl.Description = src.Description + } + if rl.ProductID == nil && src.ProductID != nil { + p := *src.ProductID + rl.ProductID = &p + } + } + resolved[i] = rl + } + productIDs := make([]uuid.UUID, 0, len(resolved)) + for i := range resolved { + if resolved[i].ProductID != nil { + productIDs = append(productIDs, *resolved[i].ProductID) } } refs, err := s.repo.LookupProducts(ctx, productIDs) if err != nil { return nil, err } - priced := make([]returnLinePriced, 0, len(in.Lines)) - for i := range in.Lines { - rl := in.Lines[i] - if rl.SaleLineID != nil { - if src, ok := byID[*rl.SaleLineID]; ok { - if rl.UnitPrice == nil && src.UnitPrice != nil { - p := *src.UnitPrice - rl.UnitPrice = &p - } - if rl.Description == "" { - rl.Description = src.Description - } - if rl.ProductID == nil && src.ProductID != nil { - p := *src.ProductID - rl.ProductID = &p - } + priced := make([]returnLinePriced, 0, len(resolved)) + for i := range resolved { + rl := resolved[i] + if rl.SaleLineID == nil && rl.ProductID != nil { + if _, ok := refs[rl.ProductID.String()]; !ok { + return nil, invalid(fmt.Sprintf("lines[%d].product_id", i), "no such product") } } if rl.UnitPrice == nil { @@ -185,6 +200,28 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur } var out *Return err = s.inTx(ctx, func(ctx context.Context) error { + // The sale row first (section 11, step 1): a void racing this return + // serializes here, and the loser sees the sale it priced change under + // it. Only a completed sale's goods come back; a voided sale's came + // back with the void. + if in.OriginalSaleID != nil { + if err := s.repo.LockSale(ctx, *in.OriginalSaleID); err != nil { + return err + } + sale, err := s.repo.GetSale(ctx, *in.OriginalSaleID) + if err != nil { + return err + } + if sale.Status != StatusCompleted { + return httpx.InvalidStateTransition( + fmt.Sprintf("cannot return against a %s sale: a voided sale's goods came back with the void", sale.Status.Status())) + } + // The return touches the sale: its revision moves, in process, so + // a void built on the earlier revision is refused stale. + if err := s.repo.BumpSaleRevision(ctx, sale.ID); err != nil { + return err + } + } date, err := s.repo.BranchLocalDate(ctx, *branchID, s.now()) if err != nil { return err diff --git a/core/internal/pos/tx_test.go b/core/internal/pos/tx_test.go index 64bfbff67..bcc94cbb8 100644 --- a/core/internal/pos/tx_test.go +++ b/core/internal/pos/tx_test.go @@ -233,8 +233,10 @@ func TestVoidRacingAReturn(t *testing.T) { }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status }() wg.Wait() - if voidStatus == httpOK && returnStatus == httpOK { - t.Fatal("both the void and the return won") + t.Logf("void status %d, return status %d, stock %s", voidStatus, returnStatus, f.stock()) + won := func(status int) bool { return status >= 200 && status < 300 } + if won(voidStatus) && won(returnStatus) { + t.Fatalf("both the void and the return won (%d, %d)", voidStatus, returnStatus) } f.assertARInvariants(t) // the stock is whole either way: voided returns all 6, a return returns diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index 6ebf139a3..43f1b32b8 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -190,7 +190,8 @@ export class POSTerminal extends LitElement { this._error = null; this._success = null; this._pendingTenders = []; - this._completed = null; + // The last completed sale stays offerable for a void or a return + // until the next completion replaces it. const sale = await posService.startSale(REGISTER_ID); this._sale = sale; this._showTender = false; diff --git a/web/e2e/pos.spec.ts b/web/e2e/pos.spec.ts new file mode 100644 index 000000000..5643c74bf --- /dev/null +++ b/web/e2e/pos.spec.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { test, expect, type APIRequestContext, type Page } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; + +/** + * The counter (POS and till) against the real stack (C2-5, ADR 0005 section + * 14.2): the desk opens the drawer, rings a sale and pays it with a SPLIT + * tender (part cash, the rest by check, one invoice, one payment per tender), + * returns a line (the credit memo posted and refunded out of the drawer), and + * voids a completed sale (the ledger and the stock whole again). + */ + +const SHOTS_DIR = process.env.SHOTS_DIR ?? path.join('test-results', 'shots'); +fs.mkdirSync(SHOTS_DIR, { recursive: true }); + +async function signIn(page: Page, name: string) { + await page.goto('/'); + await page.getByLabel('Display name').fill(name); + const catalog = page.waitForResponse((r) => new URL(r.url()).pathname === '/api/v1/apps' && r.ok()); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await catalog; + await expect(page.getByRole('heading', { name: /good to see you/i })).toBeVisible(); +} + +/** A product with stock at the branch and a price, for the counter search. */ +async function stockedProduct(request: APIRequestContext): Promise<{ sku: string }> { + const products = (await (await request.get('/api/v1/products?limit=200')).json()) as { + items: { id: string; sku: string }[]; + }; + const list = products.items.filter((p) => !p.sku.startsWith('E2E-')).reverse(); + for (const p of list) { + const page = (await (await request.get(`/api/v1/inventory?product_id=${p.id}`)).json()) as { items: { available: string }[] }; + if (page.items.reduce((n, r) => n + (Number(r.available) || 0), 0) >= 50) return p; + } + throw new Error('no stocked product for the counter run'); +} + +/** Adds one unit of the product through the counter's typeahead. */ +async function addOne(page: Page, sku: string) { + const input = page.locator('#pos-search-input'); + await input.fill(sku.slice(0, Math.max(6, Math.min(10, sku.length)))); + await page.locator('button', { hasText: sku }).first().waitFor({ state: 'visible', timeout: 10000 }); + await page.locator('button', { hasText: sku }).first().click(); + await expect(page.locator('table[aria-label="Cart items"] tbody tr').first()).toBeVisible(); +} + +test('the counter: split tender, return and void', async ({ page, request }) => { + await signIn(page, 'POS E2E'); + const product = await stockedProduct(request); + + await page.goto('/pos'); + await expect(page.getByRole('heading', { name: 'POS Terminal' })).toBeVisible(); + + // Open the drawer when none is open (a run may find one already open). + const noTill = page.getByText('NO TILL'); + if (await noTill.isVisible()) { + await page.getByRole('button', { name: 'Open Till' }).click(); + await page.getByPlaceholder('200.00').fill('100.00'); + await page.getByRole('button', { name: 'Open Till' }).last().click(); + } + await expect(page.getByText(/TILL OPEN/)).toBeVisible(); + + // Ring two units of the product. + await addOne(page, product.sku); + await addOne(page, product.sku); + await expect(page.locator('table[aria-label="Cart items"] tbody tr')).toHaveCount(2); + + // Read the total off the panel, then pay it split: $1.00 in cash, the rest + // by check. The completion carries the sale's revision; the page sends the + // whole tender list at once. + const totalText = await page.getByText('TOTAL').locator('..').getByText(/\$\d+\.\d{2}/).last().textContent(); + const total = Number(totalText.replace('$', '')); + expect(total).toBeGreaterThan(1); + await page.getByRole('button', { name: 'cash', exact: true }).click(); + await page.getByLabel('Tender amount').fill('1.00'); + await page.getByRole('button', { name: /Add Tender/ }).click(); + await page.getByRole('button', { name: /add check/i }).click(); + await page.getByLabel('Tender amount').fill((total - 1).toFixed(2)); + await page.getByRole('button', { name: /Add Tender/ }).click(); + await expect(page.getByText(/Still owed|Change/).first()).toBeVisible(); + await page.screenshot({ path: path.join(SHOTS_DIR, 'pos-split-tender.png'), fullPage: true }); + + await page.getByRole('button', { name: /Complete Sale/ }).click(); + await expect(page.getByText(/POS-\d+ complete/)).toBeVisible({ timeout: 15000 }); + await page.screenshot({ path: path.join(SHOTS_DIR, 'pos-sale-completed.png'), fullPage: true }); + + // Return one unit of the first line: a credit memo posts, the stock comes + // back and the refund leaves the drawer. + await page.getByRole('button', { name: 'Return', exact: true }).click(); + const firstQty = page.locator('input[type="number"]').first(); + await firstQty.fill('1'); + await page.getByRole('button', { name: /Take the Return/ }).click(); + await expect(page.getByText(/Return RTN-\d+ complete/)).toBeVisible({ timeout: 15000 }); + await page.screenshot({ path: path.join(SHOTS_DIR, 'pos-return.png'), fullPage: true }); + + // A second sale paid in cash with change, then voided: the ledger returns + // to zero and the stock comes back. + await page.getByRole('button', { name: 'New Sale', exact: true }).click(); + await addOne(page, product.sku); + await page.getByRole('button', { name: 'cash', exact: true }).click(); + await page.getByLabel('Tender amount').fill('100.00'); + await page.getByRole('button', { name: /Add Tender/ }).click(); + await page.getByRole('button', { name: /Complete Sale/ }).click(); + await expect(page.getByText(/CHANGE DUE/)).toBeVisible({ timeout: 15000 }); + + page.once('dialog', (dialog) => void dialog.accept('e2e: wrong customer')); + await page.getByRole('button', { name: 'Void', exact: true }).click(); + await expect(page.getByText(/POS-\d+ voided/)).toBeVisible({ timeout: 15000 }); + await page.screenshot({ path: path.join(SHOTS_DIR, 'pos-void.png'), fullPage: true }); + + // The books tell the same story: the day's sales list the completed sale, + // the voided one and the return's credit memo behind it. + const sales = (await (await request.get('/api/v1/pos/transactions?register_id=REG-01')).json()) as { + items: { number: string; status: string }[]; + }; + expect(sales.items.length).toBeGreaterThanOrEqual(2); + expect(sales.items.some((s) => s.status === 'voided')).toBe(true); + expect(sales.items.some((s) => s.status === 'completed')).toBe(true); +}); From 16f7ffc12d7fb5bee8ce7f927c193b9131bed668 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 00:48:51 -0700 Subject: [PATCH 010/113] C2-5: the counter's e2e picks its product at the register's branch The suite's earlier specs consume the seeded stock, and the counter issues from the branch its register belongs to: the picker now counts the available rows of the Kelowna branch only, so the split tender run never asks a branch short of stock to serve the till (the whole suite passes with the counter in it). Signed-off-by: colton --- web/e2e/pos.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/e2e/pos.spec.ts b/web/e2e/pos.spec.ts index 5643c74bf..d00928e68 100644 --- a/web/e2e/pos.spec.ts +++ b/web/e2e/pos.spec.ts @@ -32,8 +32,15 @@ async function stockedProduct(request: APIRequestContext): Promise<{ sku: string }; const list = products.items.filter((p) => !p.sku.startsWith('E2E-')).reverse(); for (const p of list) { - const page = (await (await request.get(`/api/v1/inventory?product_id=${p.id}`)).json()) as { items: { available: string }[] }; - if (page.items.reduce((n, r) => n + (Number(r.available) || 0), 0) >= 50) return p; + const page = (await (await request.get(`/api/v1/inventory?product_id=${p.id}`)).json()) as { + items: { location_name: string; available: string }[]; + }; + // The counter issues from the branch REG-01 belongs to (the seeded + // Kelowna branch); stock at another branch cannot serve the till. + const atBranch = page.items + .filter((r) => r.location_name === 'KEL-MAIN') + .reduce((n, r) => n + (Number(r.available) || 0), 0); + if (atBranch >= 50) return p; } throw new Error('no stocked product for the counter run'); } From ed83b0732c0be2901d4b55c9c7f36adb6c3fac29 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 00:58:38 -0700 Subject: [PATCH 011/113] C2-5: the offline sync actually completes its sales The pending proof the ADR asks for (a sale synced while the tax provider fails stays pending and completes on retry, never rejected) found the sync broken twice over, and the golden had silently pinned the breakage: the completion carried no revision, so every synced sale died at the precondition (the golden's pos.sync recorded error_count 1, 'this write needs If-Match or a body revision'), and the sale a pending attempt had built stayed OPEN behind it, so the retried batch counted it a duplicate and never completed it. The sync is an in process caller: it re-reads the sale and carries its current revision, the recipe's in process rule. A batch item that exists as an OPEN sale is a prior pending attempt: the retry completes it (adding its lines first if the earlier attempt died before them) through the same path a live sale takes, and one that fails on the provider again simply stays pending; a completed or voided one remains a plain duplicate. The new wire test walks the whole rule with a flaky provider stub: pending with nothing persisted, the retry syncing, the replay a duplicate, the books whole. The pos_sync golden now records a synced sale, and the reporting and media goldens move with the money and rows the synced sale honestly adds. Signed-off-by: colton --- .../testdata/goldens/pim_media.json | 4 +- .../testdata/goldens/pos_sync.json | 47 +++++------ .../testdata/goldens/pos_till_close.json | 48 +++++------ .../testdata/goldens/reporting_reports.json | 6 +- core/internal/pos/sync.go | 56 ++++++++++++- core/internal/pos/wire_test.go | 80 ++++++++++++++++++- 6 files changed, 182 insertions(+), 59 deletions(-) diff --git a/core/internal/characterization/testdata/goldens/pim_media.json b/core/internal/characterization/testdata/goldens/pim_media.json index cf02e8b61..98cba09db 100644 --- a/core/internal/characterization/testdata/goldens/pim_media.json +++ b/core/internal/characterization/testdata/goldens/pim_media.json @@ -30,7 +30,7 @@ "media": [], "product": { "allocated": "14", - "available": "415", + "available": "414", "average_unit_cost_ten_thousandths": 35000, "base_price_ten_thousandths": 55000, "board_length_ft": null, @@ -44,7 +44,7 @@ "id": "", "lead_time_days": null, "length_in": 96, - "on_hand": "429", + "on_hand": "428", "price_uom": "PCS", "purchase_uom": "PCS", "random_length": false, diff --git a/core/internal/characterization/testdata/goldens/pos_sync.json b/core/internal/characterization/testdata/goldens/pos_sync.json index 985af9e40..3db993a75 100644 --- a/core/internal/characterization/testdata/goldens/pos_sync.json +++ b/core/internal/characterization/testdata/goldens/pos_sync.json @@ -34,16 +34,11 @@ "body": { "batch_id": "r1c-batch-1", "duplicate_count": 0, - "error_count": 1, - "errors": [ - { - "client_id": "", - "reason": "complete: this write needs If-Match or a body revision" - } - ], + "error_count": 0, + "errors": [], "pending": [], "pending_count": 0, - "synced_count": 0 + "synced_count": 1 } } }, @@ -271,6 +266,22 @@ "status": "voided", "total_cents": 616 }, + { + "branch_id": "", + "cashier_id": "", + "completed_at": "", + "created_at": "", + "currency": "USD", + "customer_id": null, + "id": "", + "invoice_id": "", + "item_count": 1, + "number": "POS-000005", + "register_id": "REG-01", + "revision": 3, + "status": "completed", + "total_cents": 616 + }, { "branch_id": "", "cashier_id": "", @@ -278,7 +289,7 @@ "created_at": "", "currency": "USD", "customer_id": "", - "id": "", + "id": "", "invoice_id": null, "item_count": 0, "number": "POS-000001", @@ -294,7 +305,7 @@ "created_at": "", "currency": "USD", "customer_id": null, - "id": "", + "id": "", "invoice_id": null, "item_count": 1, "number": "POS-000003", @@ -303,22 +314,6 @@ "status": "open", "total_cents": 4256 }, - { - "branch_id": "", - "cashier_id": "", - "completed_at": null, - "created_at": "", - "currency": "USD", - "customer_id": null, - "id": "", - "invoice_id": null, - "item_count": 1, - "number": "POS-000005", - "register_id": "REG-01", - "revision": 2, - "status": "open", - "total_cents": 616 - }, { "branch_id": "", "cashier_id": "", diff --git a/core/internal/characterization/testdata/goldens/pos_till_close.json b/core/internal/characterization/testdata/goldens/pos_till_close.json index c79867bc1..4c24ead8e 100644 --- a/core/internal/characterization/testdata/goldens/pos_till_close.json +++ b/core/internal/characterization/testdata/goldens/pos_till_close.json @@ -9,12 +9,12 @@ "status": 200, "content_type": "application/json", "body": { - "change_cents": 48597, + "change_cents": 67981, "expected_by_method": { - "CASH": 8603 + "CASH": 9219 }, - "sale_count": 1, - "sales_total_cents": 1403, + "sale_count": 2, + "sales_total_cents": 2019, "session": { "branch_id": "", "cashier_id": "", @@ -30,9 +30,9 @@ "register_id": "REG-01", "status": "open" }, - "tax_total_cents": 0, + "tax_total_cents": 66, "tendered_by_method": { - "CASH": 1403 + "CASH": 2019 } } } @@ -222,12 +222,12 @@ "status": 200, "content_type": "application/json", "body": { - "change_cents": 48597, + "change_cents": 67981, "expected_by_method": { - "CASH": 8603 + "CASH": 9219 }, - "sale_count": 1, - "sales_total_cents": 1403, + "sale_count": 2, + "sales_total_cents": 2019, "session": { "branch_id": "", "cashier_id": "", @@ -236,20 +236,20 @@ "CASH": 20000 }, "expected_by_method": { - "CASH": 8603 + "CASH": 9219 }, "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", "opening_float_cents": 10000, - "over_short_cents": 11397, + "over_short_cents": 10781, "register_id": "REG-01", "status": "closed" }, - "tax_total_cents": 0, + "tax_total_cents": 66, "tendered_by_method": { - "CASH": 1403 + "CASH": 2019 } } } @@ -339,12 +339,12 @@ "status": 200, "content_type": "application/json", "body": { - "change_cents": 48597, + "change_cents": 67981, "expected_by_method": { - "CASH": 8603 + "CASH": 9219 }, - "sale_count": 1, - "sales_total_cents": 1403, + "sale_count": 2, + "sales_total_cents": 2019, "session": { "branch_id": "", "cashier_id": "", @@ -353,20 +353,20 @@ "CASH": 20000 }, "expected_by_method": { - "CASH": 8603 + "CASH": 9219 }, "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", "opening_float_cents": 10000, - "over_short_cents": 11397, + "over_short_cents": 10781, "register_id": "REG-01", "status": "closed" }, - "tax_total_cents": 0, + "tax_total_cents": 66, "tendered_by_method": { - "CASH": 1403 + "CASH": 2019 } } } @@ -387,7 +387,7 @@ "branch_id": "", "generated_at": "", "id": "", - "over_short_cents": 11397, + "over_short_cents": 10781, "payload": "", "register_id": "REG-01", "till_session_id": "" @@ -431,7 +431,7 @@ "branch_id": "", "generated_at": "", "id": "", - "over_short_cents": 11397, + "over_short_cents": 10781, "payload": "", "register_id": "REG-01", "till_session_id": "" diff --git a/core/internal/characterization/testdata/goldens/reporting_reports.json b/core/internal/characterization/testdata/goldens/reporting_reports.json index e26677b32..38db99809 100644 --- a/core/internal/characterization/testdata/goldens/reporting_reports.json +++ b/core/internal/characterization/testdata/goldens/reporting_reports.json @@ -10,12 +10,12 @@ "content_type": "application/json", "body": { "by_method": { - "CASH": 18.19, + "CASH": 24.35, "CHECK": 313596.85 }, "date": "", - "total_collected": 313615.04, - "transaction_count": 45 + "total_collected": 313621.19999999995, + "transaction_count": 46 } } }, diff --git a/core/internal/pos/sync.go b/core/internal/pos/sync.go index 2f26aa879..7c785df78 100644 --- a/core/internal/pos/sync.go +++ b/core/internal/pos/sync.go @@ -60,6 +60,49 @@ func (s *Service) SyncOfflineTransactions(ctx context.Context, batch *OfflineSyn continue } if exists { + // A prior sync may have left this sale OPEN: the cart was built + // and the tax provider did not answer, so the item went pending. + // The retry completes that sale through the same path; a + // completed or voided one is a plain duplicate. + pending, err := s.repo.GetSale(ctx, item.ClientID) + if err == nil && pending.Status == StatusOpen { + if lines, lerr := s.repo.GetLines(ctx, pending.ID); lerr == nil && len(lines) == 0 { + if _, aerr := s.AddLine(ctx, pending.ID, item.Lines, actor); aerr != nil { + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "lines: " + aerr.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + } + // The sync is an in process caller: it carries the sale's own + // current revision, the recipe's in process rule. + current, gerr := s.repo.GetSale(ctx, pending.ID) + if gerr != nil { + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "complete: " + gerr.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + rev := current.Revision + if _, cerr := s.CompleteSale(ctx, pending.ID, "", &rev, item.Tenders, "", actor); cerr == nil { + resp.SyncedCount++ + continue + } else if !errors.Is(cerr, ErrTaxProviderUnavailable) { + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "complete: " + cerr.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + // still pending: the provider did not answer again + p := SyncItemResult{ClientID: item.ClientID.String(), Reason: "pending: the tax provider did not answer"} + resp.PendingCount++ + resp.Pending = append(resp.Pending, p) + log.Details = append(log.Details, p) + continue + } resp.DuplicateCount++ continue } @@ -81,7 +124,18 @@ func (s *Service) SyncOfflineTransactions(ctx context.Context, batch *OfflineSyn log.Errors = append(log.Errors, e) continue } - if _, err := s.CompleteSale(ctx, sale.ID, "", nil, item.Tenders, "", actor); err != nil { + // In process caller: carry the sale's own current revision (the adds + // moved it past what StartSaleAt returned). + current, err := s.repo.GetSale(ctx, sale.ID) + if err != nil { + resp.ErrorCount++ + e := SyncItemResult{ClientID: item.ClientID.String(), Reason: "complete: " + err.Error()} + resp.Errors = append(resp.Errors, e) + log.Errors = append(log.Errors, e) + continue + } + rev := current.Revision + if _, err := s.CompleteSale(ctx, sale.ID, "", &rev, item.Tenders, "", actor); err != nil { if errors.Is(err, ErrTaxProviderUnavailable) { // never a rejection: the sale was made offline, and the // provider will be asked again on the next sync diff --git a/core/internal/pos/wire_test.go b/core/internal/pos/wire_test.go index 2d666f6d9..9befaa668 100644 --- a/core/internal/pos/wire_test.go +++ b/core/internal/pos/wire_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/tax" "github.com/gablelbm/gable/internal/testutil" "github.com/google/uuid" ) @@ -439,7 +440,7 @@ func TestReturnRestocksAndReversesCOGS(t *testing.T) { r := f.do("POST", "/api/v1/pos/returns", map[string]any{ "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, "refund_method": "cash", - "reason": "wrong length", "lines": []map[string]any{{ + "reason": "wrong length", "lines": []map[string]any{{ "product_id": f.productID.String(), "line_id": firstLine, "quantity": "4", "restock": true, }}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) if r.status != http.StatusCreated { @@ -524,8 +525,8 @@ func TestTillExpectedCashIncludesFloatAndRefunds(t *testing.T) { testutil.LockOutboxTables(t) f := newFixture(t, testutil.RequireDB(t)) f.openTill(5000) - f.saleOf("2", tender("cash", 2000)) // keeps 1198, change 802 - f.saleOf("1", tender("card", 599)) // card; no cash, terminal settles it + f.saleOf("2", tender("cash", 2000)) // keeps 1198, change 802 + f.saleOf("1", tender("card", 599)) // card; no cash, terminal settles it // a cash return of 500 out of the drawer r := f.do("POST", "/api/v1/pos/returns", map[string]any{ "register_id": f.register, "customer_id": f.customerID.String(), "refund_method": "cash", @@ -793,3 +794,76 @@ func withToken(t map[string]any) map[string]any { return t } +// flakyTaxProvider answers PreviewTax with an error while its fail flag is +// set, the configured provider otherwise (the offline sync's pending path). +type flakyTaxProvider struct { + fail bool +} + +func (p *flakyTaxProvider) ProviderConfigured() bool { return true } + +func (p *flakyTaxProvider) PreviewTax(ctx context.Context, req *tax.TaxPreviewRequest) (*tax.TaxResult, error) { + if p.fail { + return nil, fmt.Errorf("provider outage") + } + return &tax.TaxResult{TotalTax: 0}, nil +} + +// RULE (ADR 0005 section 14.2 C2-5, and section 3's one exception): an +// offline sale synced while the tax provider fails is never rejected; it +// stays pending in the sync log, and the same batch retried once the +// provider answers completes it through the same path a live sale takes. +func TestOfflineSyncPendingOnTaxFailureCompletesOnRetry(t *testing.T) { + testutil.LockOutboxTables(t) + provider := &flakyTaxProvider{fail: true} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service.WithTaxProvider(provider) + }) + batch := func(clientID string) map[string]any { + return map[string]any{ + "batch_id": "sync-pending-1", "register_id": f.register, + "items": []map[string]any{{ + "client_id": clientID, "cashier_id": mustUUID(t), + "items": []map[string]any{{"product_id": f.productID.String(), "quantity": "1"}}, + "tenders": []map[string]any{{"method": "cash", "amount_cents": 599}}, + }}, + } + } + clientID := "c1c1c1c1-0000-4000-8000-00000000beef" + // The provider is down: the sale is pending, not an error, and nothing of + // it exists yet (no sale row, no invoice, no payment). + r := f.do("POST", "/api/v1/pos/sync", batch(clientID), "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("sync under a provider outage = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "pending_count"); got != 1 { + t.Fatalf("pending_count = %d, want 1 (the body: %s)", got, r.raw) + } + if got := num(t, r.body, "error_count"); got != 0 { + t.Errorf("error_count = %d, want 0: a provider failure is never a rejection", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != 0 { + t.Errorf("%d invoices after the pending sync, want 0", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payments`); got != 0 { + t.Errorf("%d payments after the pending sync, want 0", got) + } + // The provider answers: the same batch completes the sale through the + // same path a live sale takes, and the replay is a duplicate. + provider.fail = false + r = f.do("POST", "/api/v1/pos/sync", batch(clientID), "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("sync retry = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "synced_count"); got != 1 { + t.Errorf("synced_count = %d, want 1 (the body: %s)", got, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != 1 { + t.Errorf("%d invoices after the retry, want 1", got) + } + r = f.do("POST", "/api/v1/pos/sync", batch(clientID), "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if got := num(t, r.body, "duplicate_count"); got != 1 { + t.Errorf("duplicate_count = %d, want 1", got) + } + f.assertARInvariants(t) +} From e1c875a8112a712eeb864c00719032ba13d3159e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 01:20:11 -0700 Subject: [PATCH 012/113] CI: nudge the pull request's workflow The opened and reopened pull_request events created no check suite for this head (other branches ran in the same window); a synchronize event asks for the run again. No code rides along. Signed-off-by: colton From e352998f0d5e363468671502f037c0e2a155fb62 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 03:44:28 -0700 Subject: [PATCH 013/113] C2-5 fix: a return is bounded by the sale it names Second review P1-1, first review P2-1. A return line that names a sale line refunds at that line's own extension per unit (discount included) with its share of the tax the sale actually collected, never the list unit_price and never a client price: a client unit_price on a linked line is a 400 naming the field. The quantity is capped at what the line sold less every earlier return of it (409 exceeds_sold), checked before the transaction and again under the sale row lock. Migration 105 gains pos_return_lines.sale_line_id (dropped in the down) so the cap and the restock cost can follow the line. The memo line mirrors the sale line's unit and conversion pair, and a free line (no sale named) keeps its own price and today's branch rate. Red first: TestReturnIsBoundedByTheSale and TestADiscountedLineReturnsAtItsDiscountedAmount failed on the prior head (a 10 unit return of a 4 unit line answered 201; a half price line refunded 1198 for 599 taken). Signed-off-by: colton --- core/internal/pos/migration105_test.go | 1 + core/internal/pos/model.go | 19 +- core/internal/pos/repository.go | 43 +++- core/internal/pos/returns.go | 211 +++++++++++++++--- core/internal/pos/returns_bounds_test.go | 135 +++++++++++ core/migrations/105_pos_wire_contract.sql | 8 +- .../down/105_pos_wire_contract_down.sql | 2 + 7 files changed, 375 insertions(+), 44 deletions(-) create mode 100644 core/internal/pos/returns_bounds_test.go diff --git a/core/internal/pos/migration105_test.go b/core/internal/pos/migration105_test.go index 4678cbbb5..aec0e1c2a 100644 --- a/core/internal/pos/migration105_test.go +++ b/core/internal/pos/migration105_test.go @@ -331,6 +331,7 @@ func TestMigration105_POSWireContract(t *testing.T) { {"pos_transactions", "number"}, {"pos_transactions", "currency"}, {"pos_transactions", "revision"}, {"pos_returns", "number"}, {"pos_returns", "credit_memo_id"}, {"pos_line_items", "line_type"}, {"pos_tenders", "payment_id"}, + {"pos_return_lines", "sale_line_id"}, } { if n := mig105Scalar[int](t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = $1 AND column_name = $2`, c[0], c[1]); n != 0 { t.Errorf("after the down, %s.%s remains", c[0], c[1]) diff --git a/core/internal/pos/model.go b/core/internal/pos/model.go index 5779dfe4c..7212fbac6 100644 --- a/core/internal/pos/model.go +++ b/core/internal/pos/model.go @@ -176,10 +176,21 @@ type ReturnLine struct { ProductID *uuid.UUID `json:"product_id"` Description string `json:"description"` Quantity *httpx.Quantity `json:"quantity"` - UOM *string `json:"uom"` - UnitPrice *httpx.Price `json:"unit_price_ten_thousandths"` - LineTotal *httpx.Cents `json:"line_total_cents"` - Restock bool `json:"restock"` + UOM *string `json:"uom"` + UnitPrice *httpx.Price `json:"unit_price_ten_thousandths"` + LineTotal *httpx.Cents `json:"line_total_cents"` + Restock bool `json:"restock"` + + // The pair and price unit the sale line carried, read by the memo line's + // insert; internal, never on the wire. + PriceUOM *string `json:"-"` + UOMQty *httpx.Quantity `json:"-"` + PriceUOMQty *httpx.Quantity `json:"-"` + + // SaleLineID is the sale line a linked return names; InvoiceLineID the + // invoice line its restock cost reads. Both internal, never on the wire. + SaleLineID *uuid.UUID `json:"-"` + InvoiceLineID *uuid.UUID `json:"-"` } // TillSessionStatus is the drawer lifecycle. diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 2248a23ac..5bd9b39e1 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -49,6 +49,10 @@ type Repository interface { CreateReturn(ctx context.Context, ret *Return, lines []ReturnLine) error GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) ListReturns(ctx context.Context, f ReturnFilter, limit int) ([]Return, error) + // ReturnedQtyByLine sums, per sale line, the quantity every earlier + // return of the sale brought back (the return's cap). + ReturnedQtyByLine(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]httpx.Quantity, error) + SaleHasReturns(ctx context.Context, saleID uuid.UUID) (bool, error) // Till sessions CreateTillSession(ctx context.Context, s *TillSession) error @@ -500,10 +504,10 @@ func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, line } _, err := r.ex(ctx).Exec(ctx, ` INSERT INTO pos_return_lines (id, return_id, position, line_type, product_id, description, quantity, uom, - unit_price, line_total, restock, created_at) - VALUES ($1, $2, $3, 'PRODUCT', $4, $5, -($6::numeric / 10000), $7, $8::numeric / 10000, $9::numeric / 100, $10, NOW())`, + unit_price, line_total, restock, sale_line_id, created_at) + VALUES ($1, $2, $3, 'PRODUCT', $4, $5, -($6::numeric / 10000), $7, $8::numeric / 10000, $9::numeric / 100, $10, $11, NOW())`, l.ID, ret.ID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, priceArg(l.UnitPrice), - centsArg(l.LineTotal), l.Restock) + centsArg(l.LineTotal), l.Restock, l.SaleLineID) if err != nil { return fmt.Errorf("failed to record the return line: %w", mapWriteError(err)) } @@ -511,6 +515,39 @@ func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, line return nil } +// ReturnedQtyByLine sums what earlier returns brought back per sale line of +// one sale: the stored quantities are negative, so the sum is negated back. +func (r *PostgresRepository) ReturnedQtyByLine(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]httpx.Quantity, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT rl.sale_line_id, -ROUND(SUM(rl.quantity) * 10000)::bigint + FROM pos_return_lines rl + JOIN pos_returns r ON r.id = rl.return_id + WHERE r.original_transaction_id = $1 AND rl.sale_line_id IS NOT NULL + GROUP BY rl.sale_line_id`, saleID) + if err != nil { + return nil, fmt.Errorf("failed to read the sale's earlier returns: %w", err) + } + defer rows.Close() + out := map[uuid.UUID]httpx.Quantity{} + for rows.Next() { + var id uuid.UUID + var qty httpx.Quantity + if err := rows.Scan(&id, &qty); err != nil { + return nil, err + } + out[id] = qty + } + return out, rows.Err() +} + +// SaleHasReturns answers whether any return names the sale. +func (r *PostgresRepository) SaleHasReturns(ctx context.Context, saleID uuid.UUID) (bool, error) { + var exists bool + err := r.ex(ctx).QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM pos_returns WHERE original_transaction_id = $1)`, saleID).Scan(&exists) + return exists, err +} + func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) { ret := &Return{} var method string diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index 03bb895bb..e8bc85074 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -41,6 +41,12 @@ type returnLinePriced struct { in ReturnLineIn lineTotal int64 // positive; the memo line stores it negative taxable bool + // saleLine is the sale line a linked return names: the refund follows it + // (its per unit extension, its unit, its tax share). + saleLine *salesdoc.Line + // perUnit is the ten thousandths a linked line refunds at: the sale + // line's own extension divided by the quantity it sold. + perUnit httpx.Price } // ReturnSale records a counter return (ADR 0005 section 14.2 C2-5): a @@ -58,8 +64,12 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur } // The customer: named, the original sale's, or the walk-in. customerID := in.CustomerID - if customerID == nil && in.OriginalSaleID != nil { - if original, err := s.repo.GetSale(ctx, *in.OriginalSaleID); err == nil && original.CustomerID != nil { + var original *Sale + if in.OriginalSaleID != nil { + if original, err = s.repo.GetSale(ctx, *in.OriginalSaleID); err != nil { + return nil, err + } + if customerID == nil { customerID = original.CustomerID } } @@ -74,8 +84,10 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if err != nil { return nil, err } - // Price the lines: from the original sale's lines when named, else the - // request's own price (which is then required). + // Price the lines: a line naming a sale line refunds at that line's own + // extension per unit (never the list price, never a client price); a line + // that named no sale line stands alone on the product the request named + // and the request's own price. var saleLines []salesdoc.Line if in.OriginalSaleID != nil { if saleLines, err = s.repo.GetLines(ctx, *in.OriginalSaleID); err != nil { @@ -87,8 +99,7 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur byID[saleLines[i].ID] = saleLines[i] } // First pass: fill each line from the sale line it names, so the product - // lookup covers the resolved products too; a line that named no sale line - // stands alone on the product the request named. + // lookup covers the resolved products too. resolved := make([]ReturnLineIn, len(in.Lines)) for i := range in.Lines { rl := in.Lines[i] @@ -97,9 +108,9 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if !ok { return nil, invalid(fmt.Sprintf("lines[%d].line_id", i), "names no line of the original sale") } - if rl.UnitPrice == nil && src.UnitPrice != nil { - p := *src.UnitPrice - rl.UnitPrice = &p + if rl.UnitPrice != nil { + return nil, invalid(fmt.Sprintf("lines[%d].unit_price_ten_thousandths", i), + "a line that names a sale line refunds at the sale's own price: drop the unit price") } if rl.Description == "" { rl.Description = src.Description @@ -129,6 +140,24 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur return nil, invalid(fmt.Sprintf("lines[%d].product_id", i), "no such product") } } + var src salesdoc.Line + if rl.SaleLineID != nil { + src = byID[*rl.SaleLineID] + // The line's own extension per unit: the extension the sale + // charged (discount included) divided by the quantity it sold + // (cents at scale 2 over quantity at scale 4, to a price at + // scale 4: the divisor carries 10^6). + if src.LineTotal == nil || src.Quantity == nil || *src.Quantity <= 0 || *src.LineTotal < 0 { + return nil, invalid(fmt.Sprintf("lines[%d].line_id", i), "the sale line carries no priced extension to return against") + } + perUnit := httpx.Price(divRound(int64(*src.LineTotal)*1_000_000, int64(*src.Quantity))) + p := returnLinePriced{in: rl, lineTotal: int64(salesdoc.CostOf(rl.Quantity, perUnit)), + taxable: src.Taxable, saleLine: &src, perUnit: perUnit} + up := perUnit + p.in.UnitPrice = &up + priced = append(priced, p) + continue + } if rl.UnitPrice == nil { return nil, invalid("lines.unit_price_ten_thousandths", "is required when the line names no line of the original sale") @@ -141,43 +170,73 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur } priced = append(priced, returnLinePriced{in: rl, lineTotal: int64(salesdoc.CostOf(rl.Quantity, *rl.UnitPrice)), taxable: taxable}) } - // Tax: the sale path's resolver, the branch rate (a return at the - // counter is a pickup, 5.5), exemptions first. - exempt, err := s.repo.CustomerExempt(ctx, *customerID) - if err != nil { + // The cap (ADR 0005 14.2 C2-5, second review P1-1): no sale line gives + // back more than it sold less every earlier return of it. Checked before + // the transaction, and again under the sale row lock inside it. + if err := s.checkReturnCaps(ctx, in.OriginalSaleID, priced, byID); err != nil { return nil, err } - var taxableSum int64 + // Tax: a linked line's share of the tax the sale actually collected (the + // sale's own rate, provider priced or exempt, whatever it was); a free + // line at today's branch rate, exemptions first. + var saleTaxCents, saleTaxable int64 + if original != nil { + saleTaxCents = int64(original.TaxCents) + for i := range saleLines { + if saleLines[i].Taxable && saleLines[i].LineTotal != nil { + saleTaxable += int64(*saleLines[i].LineTotal) + } + } + } + var linkedTaxable int64 for i := range priced { - if priced[i].taxable { - taxableSum += priced[i].lineTotal + if priced[i].saleLine != nil && priced[i].taxable { + linkedTaxable += priced[i].lineTotal } } - subtotal := taxableSum + subtotal := linkedTaxable + var freeTaxable int64 + var freeNontaxable int64 for i := range priced { - if !priced[i].taxable { + if priced[i].saleLine == nil { + if priced[i].taxable { + freeTaxable += priced[i].lineTotal + } else { + freeNontaxable += priced[i].lineTotal + } + } else if !priced[i].taxable { subtotal += priced[i].lineTotal } } + subtotal += freeTaxable + freeNontaxable var taxCents int64 var taxRate *string - if exempt { - zero := "0" - taxRate = &zero - } else { - rate, ok, err := s.repo.BranchTaxRate(ctx, branchID) + if linkedTaxable > 0 && saleTaxable > 0 && saleTaxCents > 0 { + taxCents += divRound(linkedTaxable*saleTaxCents, saleTaxable) + } + if freeTaxable > 0 { + exempt, err := s.repo.CustomerExempt(ctx, *customerID) if err != nil { return nil, err } - if !ok { - return nil, conflict("tax_rate_not_configured", "set the branch's default tax rate before returning goods") - } - scaled, _, err := salesdoc.ParseTaxRate(rate) - if err != nil { - return nil, err + if exempt { + zero := "0" + taxRate = &zero + } else { + rate, ok, err := s.repo.BranchTaxRate(ctx, branchID) + if err != nil { + return nil, err + } + if !ok { + return nil, conflict("tax_rate_not_configured", "set the branch's default tax rate before returning goods") + } + scaled, _, err := salesdoc.ParseTaxRate(rate) + if err != nil { + return nil, err + } + taxCents += int64(salesdoc.TaxAt(httpx.Cents(freeTaxable), scaled)) + taxRate = &rate } - taxCents = int64(salesdoc.TaxAt(httpx.Cents(taxableSum), scaled)) - taxRate = &rate } total := -(subtotal + taxCents) // A card refund goes through the gateway before the transaction, so a @@ -216,6 +275,11 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur return httpx.InvalidStateTransition( fmt.Sprintf("cannot return against a %s sale: a voided sale's goods came back with the void", sale.Status.Status())) } + // The cap, rechecked under the lock: a return that raced this one + // for the same line is counted before this one is allowed. + if err := s.checkReturnCaps(ctx, in.OriginalSaleID, priced, byID); err != nil { + return err + } // The return touches the sale: its revision moves, in process, so // a void built on the earlier revision is refused stale. if err := s.repo.BumpSaleRevision(ctx, sale.ID); err != nil { @@ -237,10 +301,32 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur for i := range priced { p := &priced[i] qty := p.in.Quantity - uom := "EA" + uom, priceUOM := "EA", "EA" + var uomQty, priceUOMQty httpx.Quantity = salesdoc.One, salesdoc.One + if p.saleLine != nil { + // the memo line mirrors the sale line's own unit and pair + if p.saleLine.UOM != nil { + uom = *p.saleLine.UOM + } + if p.saleLine.PriceUOM != nil { + priceUOM = *p.saleLine.PriceUOM + } + if p.saleLine.UOMQty != nil { + uomQty = *p.saleLine.UOMQty + } + if p.saleLine.PriceUOMQty != nil { + priceUOMQty = *p.saleLine.PriceUOMQty + } + } + unitPrice := p.in.UnitPrice + if unitPrice == nil { + unitPrice = ptrPrice(0) + } lines = append(lines, ReturnLine{ ID: uuid.New(), Position: i, ProductID: p.in.ProductID, Description: p.in.Description, - Quantity: &qty, UOM: &uom, UnitPrice: p.in.UnitPrice, LineTotal: ptrC(-p.lineTotal), Restock: p.in.Restock, + Quantity: &qty, UOM: &uom, PriceUOM: &priceUOM, UOMQty: &uomQty, PriceUOMQty: &priceUOMQty, + UnitPrice: unitPrice, LineTotal: ptrC(-p.lineTotal), Restock: p.in.Restock, + SaleLineID: p.in.SaleLineID, }) if p.in.Restock && p.in.ProductID != nil { if ref, ok := refs[p.in.ProductID.String()]; ok && ref.AverageCost > 0 { @@ -260,9 +346,10 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if _, err := s.db.GetExecutor(ctx).Exec(ctx, ` INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, product_id, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, restock, created_at) - VALUES ($1, $2, 'PRODUCT', $3, $4, -($5::numeric / 10000), $6, $6, 1, 1, $7::numeric / 10000, 'MANUAL', - $8::numeric / 100, $9, $10, NOW())`, - memoID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, priceArg(l.UnitPrice), + VALUES ($1, $2, 'PRODUCT', $3, $4, -($5::numeric / 10000), $6, $7, $8::numeric / 10000, $9::numeric / 10000, + $10::numeric / 10000, 'MANUAL', $11::numeric / 100, $12, $13, NOW())`, + memoID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, l.PriceUOM, + qtyArg(l.UOMQty), qtyArg(l.PriceUOMQty), priceArg(l.UnitPrice), centsArg(l.LineTotal), priced[i].taxable, l.Restock); err != nil { return fmt.Errorf("failed to create the credit memo line: %w", mapWriteError(err)) } @@ -376,6 +463,53 @@ func ptrC(v int64) *httpx.Cents { return &c } +// divRound divides two integers rounding half away from zero, the money +// convention of the extensions (a positive denominator only). +func divRound(num, den int64) int64 { + if den <= 0 { + return 0 + } + if num >= 0 { + return (num + den/2) / den + } + return -((-num + den/2) / den) +} + +// checkReturnCaps refuses a return whose lines, taken with every earlier +// return of the same sale lines, bring back more than the lines sold: the +// sale's own goods bound its refunds (blocker exceeds_sold). +func (s *Service) checkReturnCaps(ctx context.Context, saleID *uuid.UUID, priced []returnLinePriced, byID map[uuid.UUID]salesdoc.Line) error { + if saleID == nil { + return nil + } + requested := map[uuid.UUID]int64{} + for i := range priced { + if priced[i].in.SaleLineID != nil { + requested[*priced[i].in.SaleLineID] += int64(priced[i].in.Quantity) + } + } + if len(requested) == 0 { + return nil + } + returned, err := s.repo.ReturnedQtyByLine(ctx, *saleID) + if err != nil { + return err + } + for lineID, want := range requested { + src, ok := byID[lineID] + if !ok || src.Quantity == nil { + continue + } + already := int64(returned[lineID]) + if want+already > int64(*src.Quantity) { + return conflict("exceeds_sold", + fmt.Sprintf("the line sold %s and %s is already returned: %s more is refused", + (*src.Quantity).DecimalString(), httpx.Quantity(already).DecimalString(), httpx.Quantity(want).DecimalString())) + } + } + return nil +} + // fxMemoNumber reads the memo's minted number for its event. func fxMemoNumber(ctx context.Context, s *Service, memoID uuid.UUID) string { var number string @@ -392,3 +526,8 @@ func (s *Service) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) func (s *Service) ListReturns(ctx context.Context, f ReturnFilter) ([]Return, error) { return s.repo.ListReturns(ctx, f, 200) } + +func ptrPrice(v int64) *httpx.Price { + p := httpx.Price(v) + return &p +} diff --git a/core/internal/pos/returns_bounds_test.go b/core/internal/pos/returns_bounds_test.go new file mode 100644 index 000000000..1973668a6 --- /dev/null +++ b/core/internal/pos/returns_bounds_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The return is bounded by the sale it names (ADR 0005 section 14.2 C2-5, +// second review P1-1): a line naming a sale line refunds at that line's own +// extension per unit with its tax share, never the list price; a client +// price on a linked line is refused; the quantity cannot exceed what the +// line sold less every earlier return of it; and a discounted line returns +// at its discounted amount. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +// returnOn is a linked cash return of qty of the sale's first line. +func (f *fixture) returnOn(t *testing.T, saleID, lineID, qty string, extra ...map[string]any) resp { + t.Helper() + line := map[string]any{"line_id": lineID, "quantity": qty, "restock": true} + for _, e := range extra { + for k, v := range e { + line[k] = v + } + } + return f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "refund_method": "cash", "reason": "bounds", "lines": []map[string]any{line}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) +} + +// RULE: a return line that names a sale line refunds at that line's own +// extension per unit with its tax share, and never more of the line than was +// sold less every earlier return of it. +func TestReturnIsBoundedByTheSale(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, _ := f.saleOf("4", tender("cash", 2395)) + sale := f.getSale(t, saleID) + lineID := sale["lines"].([]any)[0].(map[string]any)["id"].(string) + + // One unit back: the line's own extension (550) with its tax share of the + // sale's tax (49 of 195 on 2200), not the branch rate recomputed. + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("return of 1 = %d: %s", r.status, r.raw) + } + for key, want := range map[string]int64{"subtotal_cents": -550, "tax_cents": -49, "total_cents": -599} { + if got := num(t, r.body, key); got != want { + t.Errorf("return of 1 %s = %d, want %d", key, got, want) + } + } + f.assertARInvariants(t) + + // Ten more against a line that sold 4 and gave 1 back: refused. + r = f.returnOn(t, saleID, lineID, "10") + if r.status != http.StatusConflict { + t.Fatalf("over return = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "exceeds_sold" { + t.Errorf("blockers = %v, want exceeds_sold", blockers) + } + // Five of a line that sold 4: refused outright. + saleID2, _ := f.saleOf("4", tender("cash", 2395)) + line2 := f.getSale(t, saleID2)["lines"].([]any)[0].(map[string]any)["id"].(string) + r = f.returnOn(t, saleID2, line2, "5") + if r.status != http.StatusConflict { + t.Fatalf("return of 5 of 4 = %d, want 409: %s", r.status, r.raw) + } + // The drawer paid out only the bounded refunds: two sales of 2395 in, one + // 599 return out; every refused return moved nothing. + if got := f.accountBalance("1010"); got != 2395+2395-599 { + t.Errorf("cash balance = %d, want %d (two sales in, one bounded refund out)", got, 2395+2395-599) + } + f.assertARInvariants(t) + + // A client price on a linked line is refused: the refund follows the sale. + r = f.returnOn(t, saleID2, line2, "1", map[string]any{"unit_price_ten_thousandths": 990000}) + if r.status != http.StatusBadRequest { + t.Fatalf("client price on a linked line = %d, want 400: %s", r.status, r.raw) + } + _, _, fields := errorOf(t, r) + if len(fields) == 0 || fields[0] != "lines[0].unit_price_ten_thousandths" { + t.Errorf("fields = %v, want lines[0].unit_price_ten_thousandths", fields) + } + + // A free line (no sale named) still stands on its own price. + r = f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "refund_method": "cash", + "reason": "no receipt", "lines": []map[string]any{{ + "product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 45800, "restock": false, + }}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusCreated { + t.Fatalf("free return = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "subtotal_cents"); got != -458 { + t.Errorf("free return subtotal = %d, want -458 (its own price)", got) + } + f.assertARInvariants(t) +} + +// RULE: a discounted line returns at its discounted amount, tax share +// included: the whole money taken comes back, no more. +func TestADiscountedLineReturnsAtItsDiscountedAmount(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "2", + "discount_cents": 550, "discount_reason": "half off"}); r.status != http.StatusOK { + t.Fatalf("discount line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 599)); r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + lineID := f.getSale(t, saleID)["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.returnOn(t, saleID, lineID, "2") + if r.status != http.StatusCreated { + t.Fatalf("full return = %d: %s", r.status, r.raw) + } + for key, want := range map[string]int64{"subtotal_cents": -550, "tax_cents": -49, "total_cents": -599} { + if got := num(t, r.body, key); got != want { + t.Errorf("discounted return %s = %d, want %d (the money taken)", key, got, want) + } + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("cash balance = %d, want 0 (599 in, 599 out)", got) + } + f.assertARInvariants(t) +} diff --git a/core/migrations/105_pos_wire_contract.sql b/core/migrations/105_pos_wire_contract.sql index c32b6ae66..12be4963c 100644 --- a/core/migrations/105_pos_wire_contract.sql +++ b/core/migrations/105_pos_wire_contract.sql @@ -13,7 +13,8 @@ -- unit), product_id NULL-able with the per line type CHECKs. -- pos_tenders: payment_id FK payments. -- 3 pos_returns: the number (RTN-), credit_memo_id FK credit_memos; --- pos_return_lines widened like the lines. Each historic return with +-- pos_return_lines widened like the lines, plus the sale_line_id link +-- that bounds a return by the line it names. Each historic return with -- refund_method ACCOUNT lowered AR (its GL entry and subledger row -- exist) with no document: it is migrated as an OPEN credit memo -- (reason 'migrated counter account return', its gl_entry_id the @@ -263,6 +264,11 @@ UPDATE pos_return_lines l SET taxable = COALESCE((SELECT p.taxable FROM products WHERE taxable IS NULL; ALTER TABLE pos_return_lines ALTER COLUMN taxable SET NOT NULL; ALTER TABLE pos_return_lines ALTER COLUMN taxable SET DEFAULT TRUE; +-- The sale line each return line came from: the return's quantity cap and its +-- restock cost read it (a linked return is bounded by the line it names). +ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS sale_line_id UUID NULL REFERENCES pos_line_items (id); +CREATE INDEX IF NOT EXISTS idx_pos_return_lines_sale_line ON pos_return_lines (sale_line_id) + WHERE sale_line_id IS NOT NULL; CREATE OR REPLACE FUNCTION pg_temp.mig105_local_date(ts TIMESTAMPTZ, branch UUID) RETURNS DATE LANGUAGE sql STABLE AS $$ diff --git a/core/migrations/down/105_pos_wire_contract_down.sql b/core/migrations/down/105_pos_wire_contract_down.sql index 188335bf6..45100d5a7 100644 --- a/core/migrations/down/105_pos_wire_contract_down.sql +++ b/core/migrations/down/105_pos_wire_contract_down.sql @@ -32,6 +32,8 @@ DELETE FROM credit_memo_lines WHERE credit_memo_id IN DELETE FROM credit_memos WHERE reason = 'migrated counter account return'; ALTER TABLE pos_returns DROP COLUMN IF EXISTS credit_memo_id; +ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS sale_line_id; + ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS taxable; ALTER TABLE pos_return_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 2); ALTER TABLE pos_return_lines ALTER COLUMN quantity TYPE DECIMAL(12, 4); From c79f221817f8c4d225e9a3ae144a9e1329580e32 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 03:46:46 -0700 Subject: [PATCH 014/113] C2-5 fix: a void after any return of the sale is refused has_returns Second review P1-2. A return bumps the sale's revision but leaves it COMPLETED, so a void sent on the fresh revision paid the whole sale out a second time and restocked the returned units again (the probe: 2395 in, 2994 out). The void now refuses 409 has_returns before its gateway calls and again under the sale row lock inside the transaction (a return that races the void and wins leaves the sale with returns and the void refuses): after a return the way back is another return. Red first: TestVoidAfterAReturnIsRefused answered 200 on the prior head. Signed-off-by: colton --- core/internal/pos/void.go | 17 ++++++++ core/internal/pos/void_returns_test.go | 57 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 core/internal/pos/void_returns_test.go diff --git a/core/internal/pos/void.go b/core/internal/pos/void.go index 841d19140..893518f97 100644 --- a/core/internal/pos/void.go +++ b/core/internal/pos/void.go @@ -32,6 +32,15 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string if pre.Status != StatusCompleted { return nil, httpx.InvalidStateTransition(fmt.Sprintf("cannot void a %s sale: only a completed sale is voided", pre.Status.Status())) } + // After any return of the sale the way back is another return: the + // return already paid part of the money out and put its goods back, so a + // void would pay the whole sale a second time. + if has, err := s.repo.SaleHasReturns(ctx, saleID); err != nil { + return nil, err + } else if has { + return nil, conflict("has_returns", + "this sale has been returned against: return the rest through POST /pos/returns instead of voiding it") + } // A void lives inside its till session; once the drawer closes, the // money has been counted and the way back is a return. if pre.TillSessionID != nil { @@ -89,6 +98,14 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string if sale.Status != StatusCompleted { return httpx.InvalidStateTransition("only a completed sale is voided") } + // The returns check, again under the lock: a return that raced this + // void and won leaves the sale with returns, and this void refuses. + if has, err := s.repo.SaleHasReturns(ctx, saleID); err != nil { + return err + } else if has { + return conflict("has_returns", + "this sale has been returned against: return the rest through POST /pos/returns instead of voiding it") + } if sale.InvoiceID == nil { return conflict("no_invoice", "the sale carries no invoice to void") } diff --git a/core/internal/pos/void_returns_test.go b/core/internal/pos/void_returns_test.go new file mode 100644 index 000000000..c5085622d --- /dev/null +++ b/core/internal/pos/void_returns_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// A void after any return of the sale is refused (ADR 0005 section 14.2 +// C2-5, second review P1-2): the return already paid part of the money out +// and put its goods back, so a void would pay the whole sale a second time +// and restock the returned units again. The rest is returned instead. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +func TestVoidAfterAReturnIsRefused(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, body := f.saleOf("4", tender("cash", 2395)) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.returnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("return of 1 = %d: %s", r.status, r.raw) + } + if got := f.stock(); got != "97.0000/0.0000" { + t.Fatalf("stock = %s, want 97 after the return", got) + } + // The void, on the fresh revision the return left, is refused: after a + // return the way back is another return. + cur := f.getSale(t, saleID) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "second payout", "revision": num(t, cur, "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("void after a return = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "has_returns" { + t.Errorf("blockers = %v, want has_returns", blockers) + } + // Nothing moved: the sale stands completed, the stock keeps the returned + // unit, the drawer keeps its one bounded refund. + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed", got) + } + if got := f.stock(); got != "97.0000/0.0000" { + t.Errorf("stock = %s, want 97 (the void restocked nothing)", got) + } + if got := f.accountBalance("1010"); got != 2395-599 { + t.Errorf("cash balance = %d, want %d (the sale in, the one refund out)", got, 2395-599) + } + if got := f.accountBalance("1030"); got != -975 { + t.Errorf("inventory balance = %d, want -975 (3 units still out at 3.25, credited on the sale)", got) + } + f.assertARInvariants(t) +} From 183bbd23ef1474a66f07e5baa9fcf404f9c05375 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 03:54:23 -0700 Subject: [PATCH 015/113] C2-5 fix: the guards run before the gateway, and refused gateway money is put back or recorded First review P1-2, second review P1-5. Every guard that needs no lock now runs before any gateway call: the void checks the revision, the status, the returns and the session before it refunds a card (a stale revision void makes zero gateway refunds); the return checks the sale's status and its quantity caps before its card refund; the completion checks the tender plan and reads the credit check before it charges a card. The residual race (the state moves between the pre-read and the lock) is closed: a charge the transaction refuses is reversed the C2-4 card route's way (the same day void first, the refund when the void is refused, 502 charge_not_reversed when neither works, pos.card_charge_reversal audited in its own committed write), and a refund the transaction refuses is recorded in a committed audit row of its own carrying the gateway transaction and refund ids (pos.gateway_refund_orphaned) beside a CRITICAL log. Red first: TestStaleRevisionVoidMakesNoGatewayRefunds, TestAVoidTheTransactionRefusesRecordsItsGatewayRefund and TestAChargeTheTransactionRefusesIsReversed failed on the prior head. Signed-off-by: colton --- core/internal/pos/complete.go | 77 ++++++++++++++- core/internal/pos/gateway.go | 121 ++++++++++++++++++++++++ core/internal/pos/gateway_order_test.go | 121 ++++++++++++++++++++++++ core/internal/pos/returns.go | 24 ++++- core/internal/pos/void.go | 17 ++++ core/internal/pos/wire_test.go | 10 +- 6 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 core/internal/pos/gateway.go create mode 100644 core/internal/pos/gateway_order_test.go diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go index 49a69dcbc..4fa559e98 100644 --- a/core/internal/pos/complete.go +++ b/core/internal/pos/complete.go @@ -164,8 +164,22 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st if len(preLines) == 0 { return nil, invalid("lines", "the sale has no lines to complete") } + // The credit check an ACCOUNT tender owes, read before the card is + // charged (a refusal must never move money at the terminal); the + // transaction repeats it under the customer's credit lock. + if accountPortion > 0 { + if err := s.checkAccountCredit(ctx, realCustomerID, accountPortion); err != nil { + return nil, err + } + } // Card charges: outside the transaction, with the sale's own currency - // (the hard coded USD of the base commit is the bug this fixes). + // (the hard coded USD of the base commit is the bug this fixes). Every + // charge the transaction then refuses is reversed (gateway.go). + type chargeMade struct { + result *chargeResult + amountCents int64 + } + var chargesMade []chargeMade charges := make([]*chargeResult, len(tenders)) for i := range tenders { t := &tenders[i] @@ -184,13 +198,72 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st return nil, conflict("card_declined", fmt.Sprintf("the card was declined (%s)", res.Status)) } charges[i] = &chargeResult{GatewayTxID: res.TransactionID, AuthCode: res.AuthCode, Last4: res.CardLast4, Brand: res.CardBrand} + chargesMade = append(chargesMade, chargeMade{result: charges[i], amountCents: int64(t.AmountCents)}) } priced, err := s.prepareSaleTax(ctx, pre, preLines, realCustomerID) + var out *Sale + if err == nil { + out, err = s.completeSaleTx(ctx, saleID, ifMatch, bodyRevision, tenders, pickedUpBy, actor, pre, realCustomerID, charges, priced) + } if err != nil { + // The transaction refused after the gateway approved a charge: the + // charge is put back before the refusal returns, so no card is left + // charged behind a sale that never was; a reversal that fails too + // answers 502 charge_not_reversed instead of the refusal. + for _, c := range chargesMade { + if rerr := s.reverseCharge(ctx, c.result.GatewayTxID, c.amountCents, saleID, actor, err); rerr != nil { + return nil, rerr + } + } return nil, err } + return out, nil +} + +// checkAccountCredit refuses an ACCOUNT portion that would take the customer +// over its credit limit (ADR 0005 14.2 C2-5). Caller-side lock-free read; the +// authoritative check runs inside the transaction under the customer's +// credit lock. +func (s *Service) checkAccountCredit(ctx context.Context, customerID uuid.UUID, accountPortion int64) error { + facts, err := s.repo.CustomerFacts(ctx, customerID) + if err != nil { + return err + } + if facts.CreditLimitCents != nil { + open, err := s.repo.OpenReceivableCents(ctx, customerID) + if err != nil { + return err + } + if open+accountPortion > int64(*facts.CreditLimitCents) { + return conflict("credit_limit", + fmt.Sprintf("the customer's open receivable plus the %d cents on account is over the credit limit", accountPortion)) + } + } + return nil +} + +// completeSaleTx runs the completion's one transaction (ADR 0005 14.2 C2-5): +// the sale row, the session it lives in, the live tender check, the credit +// check under the customer's credit lock, the stock out, the invoice with its +// tax and COGS, the payments, the audit row and the events, last. The tenders +// and charges the caller resolved (the gateway charges made before it opened) +// come with it. +func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch string, bodyRevision *int64, + tenders []TenderIn, pickedUpBy, actor string, pre *Sale, realCustomerID uuid.UUID, + charges []*chargeResult, priced *providerTax) (*Sale, error) { + var totalTendered, cashTendered, accountPortion int64 + for i := range tenders { + t := &tenders[i] + totalTendered += int64(t.AmountCents) + if t.Method == TenderCash { + cashTendered += int64(t.AmountCents) + } + if t.Method == TenderAccount { + accountPortion += int64(t.AmountCents) + } + } var out *Sale - err = s.inTx(ctx, func(ctx context.Context) error { + err := s.inTx(ctx, func(ctx context.Context) error { // Section 11, step 1: the counter sale row. if err := s.repo.LockSale(ctx, saleID); err != nil { return err diff --git a/core/internal/pos/gateway.go b/core/internal/pos/gateway.go new file mode 100644 index 000000000..dcdb4b1ac --- /dev/null +++ b/core/internal/pos/gateway.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +// The gateway money of a refused transaction (ADR 0005 section 14.2 C2-5, +// first review P1-2, second review P1-5). The guards that need no lock run +// before any gateway call, so a refusal never moves money at the terminal; +// the residual race (the state moves between the pre-read and the lock) is +// closed here: a charge the transaction refuses is put back (the same day +// void first, the refund when the void is refused, as the C2-4 card route +// does, answering 502 charge_not_reversed when neither works), and a refund +// the transaction refuses is recorded in a committed audit row of its own +// carrying the gateway id, for finance to reconcile. + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/audit" + "github.com/google/uuid" +) + +// reversalTimeout bounds the gateway calls and the audit write of a +// compensation, which run detached from the refused request. +const reversalTimeout = 30 * time.Second + +// gatewayRefund is one refund the counter made at the gateway before its +// transaction opened. +type gatewayRefund struct { + // gatewayTxID is the charge the refund was taken against; refundTxID the + // refund's own id at the gateway. + gatewayTxID, refundTxID string + amountCents int64 + // act names the refused act ("void" or "return") for the audit row. + act string + entity uuid.UUID + actor string +} + +// recordOrphanRefund writes, in its own committed write after the refused +// transaction rolled back, one audit row per refund the transaction left +// standing, carrying the gateway ids; the CRITICAL log line carries the same +// facts. A failing audit write is logged and never hides the refusal. +func (s *Service) recordOrphanRefund(ctx context.Context, r gatewayRefund, cause error) { + s.logger.Error("CRITICAL: the gateway refunded and the transaction refused", + "act", r.act, "gateway_tx_id", r.gatewayTxID, "gateway_refund_id", r.refundTxID, + "entity_id", r.entity, "amount_cents", r.amountCents, "error", cause) + if s.auditLog == nil { + return + } + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), reversalTimeout) + defer cancel() + if err := s.auditLog.Log(rctx, audit.Entry{ + Action: "pos.gateway_refund_orphaned", EntityType: "pos_transaction", EntityID: r.entity, UserID: r.actor, + Changes: map[string]any{ + "act": r.act, "gateway_tx_id": r.gatewayTxID, "gateway_refund_id": r.refundTxID, + "amount_cents": r.amountCents, "cause": fmt.Sprintf("%v", cause), + }}); err != nil { + s.logger.Error("CRITICAL: the orphaned refund audit row was not written", + "act", r.act, "gateway_tx_id", r.gatewayTxID, "gateway_refund_id", r.refundTxID, "error", err) + } +} + +// chargeOutcome is how a reversal of an approved charge ended. +type chargeOutcome string + +const ( + chargeVoided chargeOutcome = "voided" + chargeRefunded chargeOutcome = "refunded" + chargeFailed chargeOutcome = "failed" +) + +// reverseCharge undoes an approved charge no document backs, the C2-4 card +// route's way: the same day void first, the refund when the void is refused +// (a settled capture). The outcome is logged and written to the audit log in +// its own committed write; a failing audit write is logged and never hides +// the outcome. On failure it answers the 502 the route returns instead of +// the refusal that caused it: the un-returned money outranks the reason. +func (s *Service) reverseCharge(ctx context.Context, gatewayTxID string, amountCents int64, entity uuid.UUID, actor string, cause error) error { + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), reversalTimeout) + defer cancel() + outcome, reverseCause := chargeVoided, error(nil) + if _, voidErr := s.gateway.Void(rctx, gatewayTxID); voidErr != nil { + s.logger.Info("gateway void refused, refunding instead", + "gateway_tx_id", gatewayTxID, "entity_id", entity, "error", voidErr) + if _, refundErr := s.gateway.Refund(rctx, gatewayTxID, amountCents); refundErr != nil { + outcome, reverseCause = chargeFailed, fmt.Errorf("void: %v; refund: %v", voidErr, refundErr) + } else { + outcome = chargeRefunded + } + } + switch outcome { + case chargeFailed: + s.logger.Error("CRITICAL: the gateway charged, the transaction refused, and the reversal failed", + "gateway_tx_id", gatewayTxID, "entity_id", entity, "amount_cents", amountCents, "error", reverseCause) + default: + s.logger.Warn("the gateway charge was reversed: the transaction refused the act", + "outcome", string(outcome), "gateway_tx_id", gatewayTxID, "entity_id", entity, + "amount_cents", amountCents, "error", cause) + } + if s.auditLog != nil { + if err := s.auditLog.Log(rctx, audit.Entry{ + Action: "pos.card_charge_reversal", EntityType: "pos_transaction", EntityID: entity, UserID: actor, + Changes: map[string]any{ + "gateway_tx_id": gatewayTxID, "amount_cents": amountCents, "outcome": string(outcome), + "cause": fmt.Sprintf("%v", cause), + }}); err != nil { + s.logger.Error("CRITICAL: the charge reversal audit row was not written", + "gateway_tx_id", gatewayTxID, "entity_id", entity, "outcome", string(outcome), "error", err) + } + } + if outcome == chargeFailed { + return &httpx.Error{Status: http.StatusBadGateway, Code: httpx.CodeChargeNotReversed, Operator: true, + Message: fmt.Sprintf("the card was charged and the charge could not be reversed; finance must reconcile gateway transaction %s", gatewayTxID)} + } + return nil +} diff --git a/core/internal/pos/gateway_order_test.go b/core/internal/pos/gateway_order_test.go new file mode 100644 index 000000000..63a11ecfc --- /dev/null +++ b/core/internal/pos/gateway_order_test.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The gateway order (ADR 0005 section 14.2 C2-5, first review P1-2, second +// review P1-5): every guard that needs no lock (the revision, the status, +// the session, the returns) runs BEFORE any gateway call, so a refusal never +// moves money at the terminal; and when the transaction then refuses a +// gateway success, the charge is put back and the refund is recorded in a +// committed row of its own carrying the gateway id. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE: a stale revision void makes zero gateway refunds and leaves the sale +// completed. +func TestStaleRevisionVoidMakesNoGatewayRefunds(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("4", withToken(tender("card", 2395))) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "stale", "revision": rev(t, body) + 99}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("stale void = %d, want 409: %s", r.status, r.raw) + } + if code, _, _ := errorOf(t, r); code != "stale_revision" { + t.Errorf("code = %q, want stale_revision", code) + } + if len(gw.refunds) != 0 { + t.Errorf("%d gateway refunds on a stale revision void, want 0", len(gw.refunds)) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed", got) + } + if got := f.accountBalance("1010"); got != 2395 { + t.Errorf("card balance = %d, want 2395 (the completed sale keeps its charge; no refund was made)", got) + } + f.assertARInvariants(t) +} + +// RULE: a void whose transaction fails after the gateway refund leaves the +// money accounted for: the refund is recorded in its own committed audit row +// carrying the gateway id, while the sale stands as it was. +func TestAVoidTheTransactionRefusesRecordsItsGatewayRefund(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + f.events.fail = "pos_transaction.voided" + }) + saleID, body := f.saleOf("4", withToken(tender("card", 2395))) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "event fails", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status == http.StatusOK { + t.Fatalf("the void committed under a failing event write: %s", r.raw) + } + if len(gw.refunds) != 1 { + t.Fatalf("%d gateway refunds, want 1 (the refund was made before the transaction)", len(gw.refunds)) + } + // The refund is accounted for in a row of its own, committed though the + // void's transaction rolled back, carrying the gateway's refund id. + n := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' + AND entity_id = $1 AND changes->>'gateway_refund_id' = 'ref-1'`, saleID) + if n != 1 { + t.Errorf("%d orphaned refund audit rows, want 1 carrying the gateway refund id", n) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void rolled back)", got) + } + f.assertARInvariants(t) +} + +// RULE: a completion whose transaction fails after the gateway charge gives +// the charge back (the same day void first, the refund when the void is +// refused), leaving no charge behind a sale that never was. +func TestAChargeTheTransactionRefusesIsReversed(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + f.events.fail = "pos_transaction.completed" + }) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, f.productLine("4")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + r := f.completeSale(saleID, withToken(tender("card", 2395))) + if r.status == http.StatusOK { + t.Fatalf("the sale completed under a failing event write: %s", r.raw) + } + if gw.chargeCalls != 1 { + t.Fatalf("%d gateway charges, want 1", gw.chargeCalls) + } + if len(gw.voids) != 1 { + t.Errorf("%d gateway voids, want 1 (the charge the refused sale left behind is reversed)", len(gw.voids)) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.card_charge_reversal' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d charge reversal audit rows, want 1", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "open" { + t.Errorf("sale status = %q, want open (the completion rolled back)", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want the 100 untouched", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`, saleID); got != 0 { + t.Errorf("%d tenders after the refused completion, want 0", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index e8bc85074..1a4d0e306 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -69,6 +69,13 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if original, err = s.repo.GetSale(ctx, *in.OriginalSaleID); err != nil { return nil, err } + // The status refusal runs before the gateway is asked for money (a + // refusal must never move money at the terminal); the transaction + // rechecks it under the sale row lock. + if original.Status != StatusCompleted { + return nil, httpx.InvalidStateTransition( + fmt.Sprintf("cannot return against a %s sale: a voided sale's goods came back with the void", original.Status.Status())) + } if customerID == nil { customerID = original.CustomerID } @@ -240,7 +247,10 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur } total := -(subtotal + taxCents) // A card refund goes through the gateway before the transaction, so a - // decline aborts with nothing persisted. + // decline aborts with nothing persisted. Every guard that needs no lock + // (the sale's status, the caps) has run by now; a refund the transaction + // then refuses is recorded in a committed row of its own (gateway.go). + var refundTaken *gatewayRefund if in.RefundMethod == RefundCard && in.GatewayTxID != "" { if s.gateway == nil { return nil, conflict("card_terminal", "this register has no card terminal gateway to refund the card") @@ -252,6 +262,12 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if res.Status != "REFUNDED" && res.Status != "APPROVED" { return nil, conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) } + entity := uuid.Nil + if in.OriginalSaleID != nil { + entity = *in.OriginalSaleID + } + refundTaken = &gatewayRefund{gatewayTxID: in.GatewayTxID, refundTxID: res.TransactionID, + amountCents: -total, act: "return", entity: entity, actor: actor} } var tillSessionID *uuid.UUID if session, err := s.repo.GetOpenTillSession(ctx, in.RegisterID); err == nil && session != nil { @@ -453,6 +469,12 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur return nil }) if err != nil { + // The gateway already refunded and the transaction refused: the + // money is accounted for in a committed row of its own carrying the + // gateway ids. + if refundTaken != nil { + s.recordOrphanRefund(ctx, *refundTaken, err) + } return nil, err } return out, nil diff --git a/core/internal/pos/void.go b/core/internal/pos/void.go index 893518f97..34e97143e 100644 --- a/core/internal/pos/void.go +++ b/core/internal/pos/void.go @@ -32,6 +32,11 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string if pre.Status != StatusCompleted { return nil, httpx.InvalidStateTransition(fmt.Sprintf("cannot void a %s sale: only a completed sale is voided", pre.Status.Status())) } + // Every guard that needs no lock runs BEFORE the gateway is asked for + // money (first review P1-2): a refusal must never move money at the + // terminal. The guards are rechecked under the sale row lock inside the + // transaction; the residual race is closed by recording what the gateway + // did when the transaction then refuses. // After any return of the sale the way back is another return: the // return already paid part of the money out and put its goods back, so a // void would pay the whole sale a second time. @@ -53,12 +58,16 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string "the till session this sale was made in is closed: return the goods through POST /pos/returns instead") } } + if err := httpx.CheckRevision(pre.Revision, ifMatch, bodyRevision); err != nil { + return nil, err + } tenders, err := s.repo.GetTenders(ctx, saleID) if err != nil { return nil, err } // Card refunds go through the gateway before the transaction, so a // decline aborts with nothing persisted (as every gateway call is). + var refunds []gatewayRefund for i := range tenders { t := &tenders[i] if t.Method != TenderCard || t.PaymentID == nil { @@ -81,6 +90,8 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string if res.Status != payment.GatewayStatusRefunded && res.Status != payment.GatewayStatusApproved { return nil, conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) } + refunds = append(refunds, gatewayRefund{gatewayTxID: gatewayTxID, refundTxID: res.TransactionID, + amountCents: int64(t.AmountCents), act: "void", entity: saleID, actor: actor}) } var out *Sale err = s.inTx(ctx, func(ctx context.Context) error { @@ -201,6 +212,12 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string return err }) if err != nil { + // The gateway already refunded and the transaction refused: the + // money is accounted for in committed rows of its own, one per + // refund, carrying the gateway ids. + for _, r := range refunds { + s.recordOrphanRefund(ctx, r, err) + } return nil, err } return out, nil diff --git a/core/internal/pos/wire_test.go b/core/internal/pos/wire_test.go index 9befaa668..8957412e2 100644 --- a/core/internal/pos/wire_test.go +++ b/core/internal/pos/wire_test.go @@ -756,9 +756,11 @@ func int64Of(v any) int64 { // fakeGateway is the card terminal in tests. type fakeGateway struct { - charges []*payment.GatewayResult - refunds []string - currency []string + charges []*payment.GatewayResult + refunds []string + voids []string + chargeCalls int + currency []string } func approvedCharge() *payment.GatewayResult { @@ -767,6 +769,7 @@ func approvedCharge() *payment.GatewayResult { } func (g *fakeGateway) Charge(ctx context.Context, req payment.ChargeRequest) (*payment.GatewayResult, error) { + g.chargeCalls++ g.currency = append(g.currency, req.Currency) if len(g.charges) == 0 { return nil, fmt.Errorf("no charge scripted") @@ -781,6 +784,7 @@ func (g *fakeGateway) Capture(ctx context.Context, id string, cents int64) (*pay } func (g *fakeGateway) Void(ctx context.Context, id string) (*payment.GatewayResult, error) { + g.voids = append(g.voids, id) return &payment.GatewayResult{Status: payment.GatewayStatusVoided}, nil } From f1211ffb29d0370af83dfb1db576638e9947e33e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 03:57:28 -0700 Subject: [PATCH 016/113] C2-5 fix: the tender is checked against the live total, never a dead estimate First review P1-1, second review P1-4. The pre-transaction check priced with the branch rate alone, so an exempt customer paying exactly was refused; and the in-transaction check compared the tenders with recomputedChange, which reduces to min(tendered, total) and never refused anything: a provider tax above the estimate completed the sale underpaid with a negative change_due and an open balance on the walk-in customer. The pre-check now prices with the same resolver the transaction uses (exemption first, then the provider or the branch rate), and inside the transaction, once the tax is known on the locked lines, the change is the tenders less the total: a negative change is refused insufficient_tender, a change above the cash taken change_from_cash, and recomputedChange is gone. Red first: TestProviderTaxAboveTheEstimateRefusesTheUnderTender (was 200 with change -205), TestAnExemptCustomerPaysExactly (was 409), and the green companion TestProviderTaxBelowTheEstimateGivesChangeFromTheLiveTotal. Signed-off-by: colton --- core/internal/pos/complete.go | 67 +++++++++----- core/internal/pos/tender_live_test.go | 125 ++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 core/internal/pos/tender_live_test.go diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go index 4fa559e98..d8bc9fa99 100644 --- a/core/internal/pos/complete.go +++ b/core/internal/pos/complete.go @@ -130,6 +130,30 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st customerID = pre.CustomerID } realCustomerID := *customerID + if len(preLines) == 0 { + return nil, invalid("lines", "the sale has no lines to complete") + } + // The tax, priced before the transaction with the same resolver the + // transaction uses (exemption first, then the provider or the branch + // rate), so the tender pre-check refuses the obvious cases before a card + // is charged; the transaction's own check against the live total under + // the lock is the authority. + priced, err := s.prepareSaleTax(ctx, pre, preLines, realCustomerID) + if err != nil { + return nil, err + } + estimate := priced + if estimate == nil { + exempt, err := s.repo.CustomerExempt(ctx, realCustomerID) + if err != nil { + return nil, err + } + if exempt { + estimate = &providerTax{} + } else { + estimate = &providerTax{taxCents: httpx.Cents(s.estimateTax(ctx, pre, salesdoc.SumTotals(preLines).TaxableCents))} + } + } // The tender plan: what the drawer keeps per tender and what walks out // as change (only cash makes change; an ACCOUNT tender needs a named // customer and passes the credit check). @@ -145,7 +169,7 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st } } totals := salesdoc.SumTotals(preLines) - total := int64(totals.SubtotalCents) + int64(s.estimateTax(ctx, pre, totals.TaxableCents)) + total := int64(totals.SubtotalCents) + int64(estimate.taxCents) change := totalTendered - total if change < 0 { return nil, conflict("insufficient_tender", @@ -161,8 +185,13 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st "an ACCOUNT tender needs a named customer: the walk-in customer buys on the barrelhead") } } - if len(preLines) == 0 { - return nil, invalid("lines", "the sale has no lines to complete") + // The credit check an ACCOUNT tender owes, read before the card is + // charged (a refusal must never move money at the terminal); the + // transaction repeats it under the customer's credit lock. + if accountPortion > 0 { + if err := s.checkAccountCredit(ctx, realCustomerID, accountPortion); err != nil { + return nil, err + } } // The credit check an ACCOUNT tender owes, read before the card is // charged (a refusal must never move money at the terminal); the @@ -200,11 +229,8 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st charges[i] = &chargeResult{GatewayTxID: res.TransactionID, AuthCode: res.AuthCode, Last4: res.CardLast4, Brand: res.CardBrand} chargesMade = append(chargesMade, chargeMade{result: charges[i], amountCents: int64(t.AmountCents)}) } - priced, err := s.prepareSaleTax(ctx, pre, preLines, realCustomerID) var out *Sale - if err == nil { - out, err = s.completeSaleTx(ctx, saleID, ifMatch, bodyRevision, tenders, pickedUpBy, actor, pre, realCustomerID, charges, priced) - } + out, err = s.completeSaleTx(ctx, saleID, ifMatch, bodyRevision, tenders, pickedUpBy, actor, pre, realCustomerID, charges, priced) if err != nil { // The transaction refused after the gateway approved a charge: the // charge is put back before the refusal returns, so no card is left @@ -331,10 +357,19 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch } } total := int64(totals.SubtotalCents) + taxCents - if totalTendered-recomputedChange(tenders, total) < 0 { + // The tender check against the LIVE total (the tax is known now, the + // lines locked): the change is the tenders less the total, an under + // tender is refused and so is a change above the cash taken; a + // negative change is never written. + change := totalTendered - total + if change < 0 { return conflict("insufficient_tender", fmt.Sprintf("the sale needs %d cents and the tenders carry %d", total, totalTendered)) } + if change > cashTendered { + return conflict("change_from_cash", + "only a cash tender can give change: the over tender exceeds the cash taken") + } // The credit check an ACCOUNT tender owes (ADR 0005 14.2 C2-5). if accountPortion > 0 { @@ -443,8 +478,8 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch // The tenders become payments applied to the invoice, stored net: // change comes out of the cash tendered, so the payment is the - // money kept (ADR 0005 14.2 C2-5). - change := totalTendered - total + // money kept (ADR 0005 14.2 C2-5). The change is the checked value + // from the live total above. cashChangeLeft := change var fxAll *account.Effects for i := range tenders { @@ -566,18 +601,6 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch return out, nil } -// recomputedChange is the change the recomputed total implies. -func recomputedChange(tenders []TenderIn, total int64) int64 { - var tendered int64 - for i := range tenders { - tendered += int64(tenders[i].AmountCents) - } - if tendered <= total { - return 0 - } - return tendered - total -} - func min64(a, b int64) int64 { if a < b { return a diff --git a/core/internal/pos/tender_live_test.go b/core/internal/pos/tender_live_test.go new file mode 100644 index 000000000..55466d197 --- /dev/null +++ b/core/internal/pos/tender_live_test.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The tender is checked against the live total (ADR 0005 section 14.2 C2-5, +// first review P1-1, second review P1-4): once the tax is known inside the +// transaction, the change is the tenders less the total, an under tender is +// refused insufficient_tender and a change above the cash taken is refused +// change_from_cash. The pre-transaction estimate prices with the same +// resolver the transaction uses (exemption first, then the provider or the +// branch rate), so an exempt customer can pay exactly. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/tax" + "github.com/gablelbm/gable/internal/testutil" +) + +// stubTaxProvider answers the provider path with a fixed tax. +type stubTaxProvider struct{ taxCents int64 } + +func (p *stubTaxProvider) ProviderConfigured() bool { return true } + +func (p *stubTaxProvider) PreviewTax(ctx context.Context, req *tax.TaxPreviewRequest) (*tax.TaxResult, error) { + return &tax.TaxResult{TotalTax: p.taxCents}, nil +} + +// RULE: a provider tax above the branch rate estimate refuses the under +// tender against the live total, and nothing is written. +func TestProviderTaxAboveTheEstimateRefusesTheUnderTender(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service.WithTaxProvider(&stubTaxProvider{taxCents: 400}) // the branch rate says 195 + }) + saleID := f.startSale(nil) + if r := f.addLine(saleID, f.productLine("4")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + invoicesBefore := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`) + paymentsBefore := countOf(t, f.db, `SELECT count(*) FROM payments`) + r := f.completeSale(saleID, tender("cash", 2395)) // total 2600 at the provider's tax + if r.status != http.StatusConflict { + t.Fatalf("under tender at the provider tax = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "insufficient_tender" { + t.Errorf("blockers = %v, want insufficient_tender", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != invoicesBefore { + t.Errorf("%d invoices after the refusal, want %d", got, invoicesBefore) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payments`); got != paymentsBefore { + t.Errorf("%d payments after the refusal, want %d", got, paymentsBefore) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want the 100 untouched", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`, saleID); got != 0 { + t.Errorf("%d tenders after the refusal, want 0", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "open" { + t.Errorf("sale status = %q, want open", got) + } + f.assertARInvariants(t) +} + +// RULE: an exempt customer pays exactly what the exempt total asks, with no +// tax estimate standing in the way. +func TestAnExemptCustomerPaysExactly(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(&fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}}) + }) + mustExec(t, f.db, `INSERT INTO tax_exemptions (customer_id, exempt_reason, certificate_number) + VALUES ($1, 'RESALE', 'CERT-1')`, f.customerID) + t.Cleanup(func() { + mustExec(t, f.db, `DELETE FROM tax_exemptions WHERE customer_id = $1`, f.customerID) + }) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, f.productLine("4")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + r := f.completeSale(saleID, withToken(tender("card", 2200))) // exempt: no tax, exact + if r.status != http.StatusOK { + t.Fatalf("exempt exact card = %d, want 200: %s", r.status, r.raw) + } + for key, want := range map[string]int64{"tax_cents": 0, "total_cents": 2200, "change_cents": 0} { + if got := num(t, r.body, key); got != want { + t.Errorf("%s = %d, want %d", key, got, want) + } + } + pays := f.paymentsOf(saleID) + if len(pays) != 1 || pays[0].cents != 2200 { + t.Errorf("card payment = %+v, want one of 2200", pays) + } + f.assertARInvariants(t) +} + +// RULE: a provider tax below the estimate gives the change from the live +// total, and the drawer expects the money actually kept. +func TestProviderTaxBelowTheEstimateGivesChangeFromTheLiveTotal(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service.WithTaxProvider(&stubTaxProvider{taxCents: 100}) // the branch rate says 195 + }) + f.openTill(1000) + saleID, body := f.saleOf("4", tender("cash", 2395)) // live total 2300, change 95 + if got := num(t, body.body, "change_cents"); got != 95 { + t.Errorf("change_cents = %d, want 95 (the tenders less the live total)", got) + } + pays := f.paymentsOf(saleID) + if len(pays) != 1 || pays[0].cents != 2300 { + t.Errorf("cash payment = %+v, want one of 2300 (net of the 95 change)", pays) + } + report := f.tillReport(t) + if v := int64Of(report["expected_by_method"].(map[string]any)["CASH"]); v != 1000+2300 { + t.Errorf("expected CASH = %d, want 3300 (float plus the net payment)", v) + } + f.assertARInvariants(t) +} From ba18f8e594593676288d0b0c7bafbacb39c77b3b Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 03:59:10 -0700 Subject: [PATCH 017/113] C2-5 fix: a kit sale completes, its components linked to the kit's own invoice line Second review P1-3. The completion copied each sale line's ParentLineID, a pos sale line id, straight onto the invoice line, whose foreign key is invoice_lines: completing any sale with a kit failed the insert with a 23503 and answered 500. The completion now builds a sale line id to invoice line id map as it builds the lines (the parent is built before its components, as position orders them) and maps the component's parent through it. Red first: TestAKitSaleCompletesAndExplodes answered 500 on the prior head; it now proves the invoice lines (kit whole, component at zero under it), the component only stock out and COGS, and the AR invariants after the act. Signed-off-by: colton --- core/internal/pos/complete.go | 9 +++- core/internal/pos/kit_sale_test.go | 74 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 core/internal/pos/kit_sale_test.go diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go index d8bc9fa99..a1c827164 100644 --- a/core/internal/pos/complete.go +++ b/core/internal/pos/complete.go @@ -424,6 +424,11 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch if err != nil { return err } + // The sale line id to invoice line id map: a component's parent, a + // pos sale line id on the sale, becomes the kit's own INVOICE line id + // here (the parent is built before its components, as position + // orders them). + invoiceLineOf := map[uuid.UUID]uuid.UUID{} for n := range lines { b := &lines[n] fl := invoice.FulfilmentLine{ @@ -432,12 +437,14 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch PriceSource: string(b.PriceSource), Taxable: b.Taxable, RevenueAccountCode: b.RevenueAccountCode, OverrideReason: b.OverrideReason, PriceAdjustedBy: b.PriceAdjustedBy, } + invoiceLineOf[b.ID] = fl.ID if b.PricedUnitPrice != nil { p := int64(*b.PricedUnitPrice) fl.PricedUnitPrice = &p } if b.ParentLineID != nil { - fl.ParentLineID = b.ParentLineID + parent := invoiceLineOf[*b.ParentLineID] + fl.ParentLineID = &parent } if b.LineType != salesdoc.LineText { q := int64(*b.Quantity) diff --git a/core/internal/pos/kit_sale_test.go b/core/internal/pos/kit_sale_test.go new file mode 100644 index 000000000..9e2358595 --- /dev/null +++ b/core/internal/pos/kit_sale_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// A kit sale completes and explodes (ADR 0005 sections 2.6 and 14.2 C2-5, +// second review P1-3): the kit line priced whole, one component line per +// component at zero, and the completion's invoice lines keep the parent to +// component relation, so the components stock out and carry the cost of +// goods sold while the kit line carries none. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +func TestAKitSaleCompletesAndExplodes(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"product_id": f.kitID.String(), "quantity": "2"}); r.status != http.StatusOK { + t.Fatalf("kit line = %d: %s", r.status, r.raw) + } + // the kit at 11.00 x2 = 2200, tax 195, total 2395 + r := f.completeSale(saleID, tender("cash", 2395)) + if r.status != http.StatusOK { + t.Fatalf("kit sale complete = %d, want 200: %s", r.status, r.raw) + } + invoiceID := str(t, r.body, "invoice_id") + inv := f.do("GET", "/api/v1/invoices/"+invoiceID, nil) + if inv.status != http.StatusOK { + t.Fatalf("get invoice = %d: %s", inv.status, inv.raw) + } + lines := inv.body["lines"].([]any) + if len(lines) != 2 { + t.Fatalf("%d invoice lines, want 2 (the kit line and its component)", len(lines)) + } + kit := lines[0].(map[string]any) + comp := lines[1].(map[string]any) + if str(t, kit, "line_type") != "kit" || str(t, comp, "line_type") != "component" { + t.Fatalf("invoice line types = %s, %s, want kit and component", kit["line_type"], comp["line_type"]) + } + if comp["parent_line_id"] == nil || str(t, comp, "parent_line_id") != str(t, kit, "id") { + t.Errorf("the component's parent is not the kit's own invoice line") + } + // the kit bills whole (2200), the component nothing + if got := num(t, kit, "line_total_cents"); got != 2200 { + t.Errorf("kit line_total_cents = %d, want 2200", got) + } + if got := num(t, comp, "line_total_cents"); got != 0 { + t.Errorf("component line_total_cents = %d, want 0", got) + } + // COGS from the components only: 4 units at 3.25 = 1300, the kit at none + if got := num(t, kit, "cost_cents"); got != 0 { + t.Errorf("kit cost_cents = %d, want 0 (the kit line carries no cost)", got) + } + if got := num(t, comp, "cost_cents"); got != 1300 { + t.Errorf("component cost_cents = %d, want 1300 (4 x 3.25)", got) + } + // the stock that left is the component's: 100 - 4 + if got := f.stock(); got != "96.0000/0.0000" { + t.Errorf("stock = %s, want 96/0 (the components left, not the kit)", got) + } + _, legs := f.legsFor(invoiceID) + if legs["5010"].debit != 1300 || legs["1030"].credit != 1300 { + t.Errorf("COGS legs = %+v %+v, want 1300 each from the components", legs["5010"], legs["1030"]) + } + if got := f.accountBalance("1010"); got != 2395 { + t.Errorf("cash balance = %d, want 2395", got) + } + f.assertARInvariants(t) +} From 81b4ecd4cee19406a609f72efcedd66998ef1f46 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:02:50 -0700 Subject: [PATCH 018/113] C2-5 fix: a return reverses COGS at the cost the sale relieved Second review P1-6. The return's restocking lines priced the restock at the product's today average cost, so after the average moved a returned unit relieved 5010 and overstated 1030 by the difference (the probe: a unit sold at 3.25 relieved at 9.00). A linked return line now finds its source invoice line by position through the sale's invoice and restocks at that line's unit_cost, and the credit memo line records the link (credit_memo_lines.invoice_line_id, the column the invoice module's own credit memos already write); a free line, which names no sale, keeps today's average, having no source to read. Red first: TestAReturnReversesCOGSAtTheSalesCost relieved at 900 on the prior head. Signed-off-by: colton --- core/internal/pos/repository.go | 33 ++++++++++++++++ core/internal/pos/return_cost_test.go | 55 +++++++++++++++++++++++++++ core/internal/pos/returns.go | 45 +++++++++++++++++++--- 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 core/internal/pos/return_cost_test.go diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 5bd9b39e1..f8591df5e 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -53,6 +53,9 @@ type Repository interface { // return of the sale brought back (the return's cap). ReturnedQtyByLine(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]httpx.Quantity, error) SaleHasReturns(ctx context.Context, saleID uuid.UUID) (bool, error) + // InvoiceLineCosts reads an invoice's lines by position with the unit + // cost the sale relieved (a linked return's restock cost). + InvoiceLineCosts(ctx context.Context, invoiceID uuid.UUID) ([]InvoiceLineCost, error) // Till sessions CreateTillSession(ctx context.Context, s *TillSession) error @@ -548,6 +551,36 @@ func (r *PostgresRepository) SaleHasReturns(ctx context.Context, saleID uuid.UUI return exists, err } +// InvoiceLineCost is one invoice line's position and the unit cost the sale +// relieved (ten thousandths). +type InvoiceLineCost struct { + ID uuid.UUID + Position int + UnitCost int64 +} + +// InvoiceLineCosts reads an invoice's lines with their unit costs, ordered +// by position: the completion builds an invoice line per sale line at the +// same position, so a linked return finds its source line's cost here. +func (r *PostgresRepository) InvoiceLineCosts(ctx context.Context, invoiceID uuid.UUID) ([]InvoiceLineCost, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, position, COALESCE(ROUND(unit_cost * 10000)::bigint, 0) + FROM invoice_lines WHERE invoice_id = $1 ORDER BY position`, invoiceID) + if err != nil { + return nil, fmt.Errorf("failed to read the invoice's line costs: %w", err) + } + defer rows.Close() + var out []InvoiceLineCost + for rows.Next() { + var l InvoiceLineCost + if err := rows.Scan(&l.ID, &l.Position, &l.UnitCost); err != nil { + return nil, err + } + out = append(out, l) + } + return out, rows.Err() +} + func (r *PostgresRepository) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) { ret := &Return{} var method string diff --git a/core/internal/pos/return_cost_test.go b/core/internal/pos/return_cost_test.go new file mode 100644 index 000000000..34e92f497 --- /dev/null +++ b/core/internal/pos/return_cost_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// A return reverses COGS at the cost the sale relieved (ADR 0005 section +// 8.4, second review P1-6): a credit memo line that restocks takes its +// source invoice line's unit_cost, not today's average, so the inventory +// account never overstates a returned unit after the average moved. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +func TestAReturnReversesCOGSAtTheSalesCost(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, body := f.saleOf("2", tender("cash", 1198)) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + // the average moves after the sale: the returned unit still comes back + // at the 3.25 the sale relieved, not today's 9.00 + mustExec(t, f.db, `UPDATE products SET average_unit_cost = 9.00 WHERE id = $1`, f.productID) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE products SET average_unit_cost = 3.25 WHERE id = $1`, f.productID) + }) + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("return = %d: %s", r.status, r.raw) + } + memoID := str(t, r.body, "credit_memo_id") + // the memo line names the invoice line it reverses + invoiceID := str(t, f.getSale(t, saleID), "invoice_id") + invLine := f.scalar(`SELECT l.id::text FROM invoice_lines l WHERE l.invoice_id = $1 AND l.position = 0`, invoiceID).(string) + var memoInvLine *string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT invoice_line_id::text FROM credit_memo_lines WHERE credit_memo_id = $1`, memoID).Scan(&memoInvLine); err != nil { + t.Fatal(err) + } + if memoInvLine == nil || *memoInvLine != invLine { + t.Errorf("the memo line names invoice line %v, want the sale's own %s", memoInvLine, invLine) + } + // the entry relieves at 3.25, not 9.00 + _, legs := f.legsFor(memoID) + if legs["1030"].debit != 325 || legs["5010"].credit != 325 { + t.Errorf("restock legs = 1030 %+v, 5010 %+v, want 325 each (the cost the sale relieved)", legs["1030"], legs["5010"]) + } + if got := f.stock(); got != "99.0000/0.0000" { + t.Errorf("stock = %s, want 99/0", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index 1a4d0e306..f4c19567b 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -274,6 +274,7 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur tillSessionID = &session.ID } var out *Return + var invoiceLines []InvoiceLineCost err = s.inTx(ctx, func(ctx context.Context) error { // The sale row first (section 11, step 1): a void racing this return // serializes here, and the loser sees the sale it priced change under @@ -301,11 +302,27 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if err := s.repo.BumpSaleRevision(ctx, sale.ID); err != nil { return err } + // The sale's own invoice lines, by position: a linked return's + // restocking line takes its source invoice line's unit_cost, the + // cost the sale relieved (8.4), never today's average. + if sale.InvoiceID != nil { + if invoiceLines, err = s.repo.InvoiceLineCosts(ctx, *sale.InvoiceID); err != nil { + return err + } + } } date, err := s.repo.BranchLocalDate(ctx, *branchID, s.now()) if err != nil { return err } + invoiceLineAt := func(pos int) *InvoiceLineCost { + for i := range invoiceLines { + if invoiceLines[i].Position == pos { + return &invoiceLines[i] + } + } + return nil + } // The credit memo: a DRAFT row with its negative lines, then the AR // core's PostCreditMemo mints the gapless number, posts the entry // (DR each revenue account its group's line totals, DR the tax, DR @@ -319,6 +336,8 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur qty := p.in.Quantity uom, priceUOM := "EA", "EA" var uomQty, priceUOMQty httpx.Quantity = salesdoc.One, salesdoc.One + var invoiceLineID *uuid.UUID + restockUnitCost := httpx.Price(0) if p.saleLine != nil { // the memo line mirrors the sale line's own unit and pair if p.saleLine.UOM != nil { @@ -333,6 +352,11 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if p.saleLine.PriceUOMQty != nil { priceUOMQty = *p.saleLine.PriceUOMQty } + if src := invoiceLineAt(p.saleLine.Position); src != nil { + id := src.ID + invoiceLineID = &id + restockUnitCost = httpx.Price(src.UnitCost) + } } unitPrice := p.in.UnitPrice if unitPrice == nil { @@ -342,11 +366,19 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur ID: uuid.New(), Position: i, ProductID: p.in.ProductID, Description: p.in.Description, Quantity: &qty, UOM: &uom, PriceUOM: &priceUOM, UOMQty: &uomQty, PriceUOMQty: &priceUOMQty, UnitPrice: unitPrice, LineTotal: ptrC(-p.lineTotal), Restock: p.in.Restock, - SaleLineID: p.in.SaleLineID, + SaleLineID: p.in.SaleLineID, InvoiceLineID: invoiceLineID, }) if p.in.Restock && p.in.ProductID != nil { - if ref, ok := refs[p.in.ProductID.String()]; ok && ref.AverageCost > 0 { - restockCost += int64(salesdoc.CostOf(p.in.Quantity, ref.AverageCost)) + // a free line (no sale) restocks at today's average: it has + // no source invoice line to read + cost := restockUnitCost + if cost <= 0 { + if ref, ok := refs[p.in.ProductID.String()]; ok { + cost = ref.AverageCost + } + } + if cost > 0 { + restockCost += int64(salesdoc.CostOf(p.in.Quantity, cost)) } } } @@ -361,12 +393,13 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur l := &lines[i] if _, err := s.db.GetExecutor(ctx).Exec(ctx, ` INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, product_id, description, quantity, - uom, price_uom, uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, restock, created_at) + uom, price_uom, uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, restock, + invoice_line_id, created_at) VALUES ($1, $2, 'PRODUCT', $3, $4, -($5::numeric / 10000), $6, $7, $8::numeric / 10000, $9::numeric / 10000, - $10::numeric / 10000, 'MANUAL', $11::numeric / 100, $12, $13, NOW())`, + $10::numeric / 10000, 'MANUAL', $11::numeric / 100, $12, $13, $14, NOW())`, memoID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, l.PriceUOM, qtyArg(l.UOMQty), qtyArg(l.PriceUOMQty), priceArg(l.UnitPrice), - centsArg(l.LineTotal), priced[i].taxable, l.Restock); err != nil { + centsArg(l.LineTotal), priced[i].taxable, l.Restock, l.InvoiceLineID); err != nil { return fmt.Errorf("failed to create the credit memo line: %w", mapWriteError(err)) } } From de35529ea13e43b07a45671d55588951ba808623 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:04:53 -0700 Subject: [PATCH 019/113] C2-5 fix: the ACCOUNT credit check runs under the customer's credit lock Second review P2-1, both reviews' P3 note that the check had no test. The completion read the open receivable with no customer serialization, so two concurrent ACCOUNT sales for one customer each read an exposure that did not yet count the other and both passed over the limit (the probe: balance 9580 against a 9000 limit). The completion now takes the section 11 step 1a transaction scoped advisory lock (order-credit:, the order module's own key, so a counter sale and an order confirm serialize on the same exposure) right after the sale row and before the read. Red first: TestTwoAccountSalesCannotBothPassTheCreditCheck (pool 4) answered [200 200] with the balance over the limit on the prior head; green at count 5 with -race. Signed-off-by: colton --- core/internal/pos/complete.go | 9 ++++- core/internal/pos/credit_lock_test.go | 58 +++++++++++++++++++++++++++ core/internal/pos/repository.go | 16 ++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 core/internal/pos/credit_lock_test.go diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go index a1c827164..52b3f7ebb 100644 --- a/core/internal/pos/complete.go +++ b/core/internal/pos/complete.go @@ -371,8 +371,15 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch "only a cash tender can give change: the over tender exceeds the cash taken") } - // The credit check an ACCOUNT tender owes (ADR 0005 14.2 C2-5). + // The credit check an ACCOUNT tender owes (ADR 0005 14.2 C2-5), under + // the customer's credit advisory lock (section 11, step 1a, taken + // right after the sale row): without it two concurrent ACCOUNT sales + // each read an exposure that does not yet count the other and both + // pass. if accountPortion > 0 { + if err := s.repo.LockCustomerCredit(ctx, customerID); err != nil { + return err + } facts, err := s.repo.CustomerFacts(ctx, customerID) if err != nil { return err diff --git a/core/internal/pos/credit_lock_test.go b/core/internal/pos/credit_lock_test.go new file mode 100644 index 000000000..39ae5e089 --- /dev/null +++ b/core/internal/pos/credit_lock_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The credit check is serialized (ADR 0005 section 11 step 1a, second +// review P2-1): the completion takes the customer's credit advisory lock +// before it reads the open receivable, so two ACCOUNT sales for one customer +// cannot both read an exposure that does not yet count the other and end +// over the limit together. + +import ( + "sync" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +func TestTwoAccountSalesCannotBothPassTheCreditCheck(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) + mustExec(t, f.db, `UPDATE customers SET credit_limit = 30.00 WHERE id = $1`, f.customerID) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE customers SET credit_limit = NULL WHERE id = $1`, f.customerID) + }) + // Two sales that each fit the limit alone (2395 of a 3000 limit) but not + // together; completed at the same time at pool size 4. + sales := make([]string, 2) + for i := range sales { + sales[i] = f.startSale(&f.customerID) + if r := f.addLine(sales[i], f.productLine("4")); r.status != 200 { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + } + var wg sync.WaitGroup + statuses := make([]int, 2) + for i := range sales { + wg.Add(1) + go func(i int) { + defer wg.Done() + statuses[i] = f.completeSale(sales[i], tender("account", 2395)).status + }(i) + } + wg.Wait() + wins := 0 + for _, st := range statuses { + if st == 200 { + wins++ + } + } + if wins != 1 { + t.Errorf("statuses = %v, want exactly one 200 (the other refused credit_limit)", statuses) + } + if got := f.balance(); got > 3000 { + t.Errorf("balance_due = %d, want at most the 3000 limit", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index f8591df5e..612459005 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -69,6 +69,10 @@ type Repository interface { GetZReportBySession(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) + // LockCustomerCredit serializes the acts that read one customer's + // credit exposure (section 11, step 1a: the same advisory lock the + // confirm and the fulfilment take). + LockCustomerCredit(ctx context.Context, customerID uuid.UUID) error // Lookups GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) LookupProducts(ctx context.Context, ids []uuid.UUID) (map[string]salesdoc.ProductRef, error) @@ -652,6 +656,18 @@ func (r *PostgresRepository) ListReturns(ctx context.Context, f ReturnFilter, li return out, rows.Err() } +// LockCustomerCredit takes the transaction scoped advisory lock keyed on +// the customer, the order module's own key ('order-credit:'), +// so a counter ACCOUNT sale and an order confirm serialize on one +// customer's credit exposure. +func (r *PostgresRepository) LockCustomerCredit(ctx context.Context, customerID uuid.UUID) error { + if _, err := r.ex(ctx).Exec(ctx, + `SELECT pg_advisory_xact_lock(hashtextextended('order-credit:' || $1::text, 0))`, customerID.String()); err != nil { + return fmt.Errorf("failed to serialize the customer's credit acts: %w", err) + } + return nil +} + func (r *PostgresRepository) GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) { var branch *uuid.UUID err := r.ex(ctx).QueryRow(ctx, `SELECT l.branch_id FROM pos_registers pr From eb76215eddfb131c28781c46b05c107a0d9713cb Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:06:24 -0700 Subject: [PATCH 020/113] C2-5 fix: the session is locked in the completion, the void and the close Second review P2-2, first review P2-3. A sale whose session had closed completed into the counted drawer (missing from its expected cash and Z report, and unvoidable for ever after), and a void racing the close left the closed session expecting cash for a voided sale: the session was checked outside the transaction only, and the close aggregated with no lock. The completion and the void now lock the sale's session row FOR SHARE inside their transactions and refuse session_closed when it is not open; the close takes the same row FOR UPDATE before it aggregates, so the acts serialize. Red first: TestASaleCannotCompleteIntoAClosedSession completed 200 on the prior head, and TestAVoidRacingTheCloseEndsCoherent (pool 4) left a closed session expecting the voided sale's cash. Signed-off-by: colton --- core/internal/pos/complete.go | 16 ++++ core/internal/pos/repository.go | 3 + core/internal/pos/session_lock_test.go | 115 +++++++++++++++++++++++++ core/internal/pos/till.go | 6 ++ core/internal/pos/till_repository.go | 19 ++++ core/internal/pos/void.go | 16 ++++ 6 files changed, 175 insertions(+) create mode 100644 core/internal/pos/session_lock_test.go diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go index 52b3f7ebb..a96dd1c48 100644 --- a/core/internal/pos/complete.go +++ b/core/internal/pos/complete.go @@ -306,6 +306,22 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch if sale.Status != StatusOpen && sale.Status != StatusHeld { return httpx.InvalidStateTransition(fmt.Sprintf("cannot complete a %s sale", sale.Status.Status())) } + // The session the sale lives in, FOR SHARE against the close's FOR + // UPDATE: a sale never completes into a drawer being counted; after + // the close the money has been counted and the way back is a return. + if sale.TillSessionID != nil { + if err := s.repo.LockTillSession(ctx, *sale.TillSessionID, false); err != nil { + return err + } + session, err := s.repo.GetTillSession(ctx, *sale.TillSessionID) + if err != nil { + return err + } + if session.Status != TillOpen { + return conflict("session_closed", + "the till session this sale was made in is closed: the money has been counted and the sale cannot complete into it") + } + } lines, err := s.repo.GetLines(ctx, saleID) if err != nil { return err diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 612459005..f4c141bfe 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -58,6 +58,9 @@ type Repository interface { InvoiceLineCosts(ctx context.Context, invoiceID uuid.UUID) ([]InvoiceLineCost, error) // Till sessions + // LockTillSession locks a session row (FOR SHARE for the acts that live + // in it, FOR UPDATE for the close), inside the caller's transaction. + LockTillSession(ctx context.Context, id uuid.UUID, forUpdate bool) error CreateTillSession(ctx context.Context, s *TillSession) error GetTillSession(ctx context.Context, id uuid.UUID) (*TillSession, error) GetOpenTillSession(ctx context.Context, registerID string) (*TillSession, error) diff --git a/core/internal/pos/session_lock_test.go b/core/internal/pos/session_lock_test.go new file mode 100644 index 000000000..c1d0afd47 --- /dev/null +++ b/core/internal/pos/session_lock_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The session the sale lives in is checked inside the transaction (ADR 0005 +// section 14.2 C2-5, second review P2-2, first review P2-3): a sale cannot +// complete into a closed drawer (its money would miss the count), the void +// holds the session FOR SHARE against the close's FOR UPDATE, and the close +// takes its lock before it aggregates, so a void racing the close ends +// coherent either way. + +import ( + "context" + "net/http" + "sync" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE: a sale cannot complete into a closed session. +func TestASaleCannotCompleteIntoAClosedSession(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + sessionID := f.openTill(0) + saleID := f.startSale(nil) + if r := f.addLine(saleID, f.productLine("1")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + // the drawer closes while the sale is open + r := f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 0}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("close = %d: %s", r.status, r.raw) + } + cur := f.getSale(t, saleID) + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + map[string]any{"tenders": []map[string]any{tender("cash", 599)}, "revision": num(t, cur, "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("complete into a closed session = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "session_closed" { + t.Errorf("blockers = %v, want session_closed", blockers) + } + // nothing moved: no invoice, no payment, the stock whole, the sale open + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != 0 { + t.Errorf("%d invoices, want 0", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want the 100 untouched", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "open" { + t.Errorf("sale status = %q, want open", got) + } + f.assertARInvariants(t) +} + +// RULE: a void racing the close ends coherent: either the void commits +// before the close counts (the counted drawer then expects no cash for it) +// or the close commits first and the void is refused session_closed. The +// closed session never expects cash for a sale that is voided. +func TestAVoidRacingTheCloseEndsCoherent(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) + sessionID := f.openTill(0) + saleID, body := f.saleOf("1", tender("cash", 599)) + var wg sync.WaitGroup + var voidStatus, closeStatus int + wg.Add(2) + go func() { + defer wg.Done() + voidStatus = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "racing the close", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status + }() + go func() { + defer wg.Done() + closeStatus = f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 0}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status + }() + wg.Wait() + t.Logf("void %d, close %d", voidStatus, closeStatus) + // coherent outcomes: the void won and the close counted after it (or the + // void's transaction was the last writer before the aggregate), or the + // close won and the void was refused + switch { + case voidStatus == http.StatusOK && closeStatus == http.StatusOK: + // the void committed and the close's aggregate ran after: the session + // expects no cash for the voided sale + if got := f.tillExpected(t, sessionID)["CASH"]; got != 0 { + t.Errorf("closed session expects CASH %d for a voided sale, want 0", got) + } + case voidStatus == http.StatusConflict && closeStatus == http.StatusOK: + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void was refused)", got) + } + default: + t.Errorf("void %d, close %d: an incoherent pair", voidStatus, closeStatus) + } + f.assertARInvariants(t) +} + +// tillExpected reads a session's stored expected_by_method map. +func (f *fixture) tillExpected(t *testing.T, sessionID string) map[string]int64 { + t.Helper() + var expected map[string]int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT expected_by_method FROM till_sessions WHERE id = $1`, sessionID).Scan(&expected); err != nil { + t.Fatal(err) + } + return expected +} diff --git a/core/internal/pos/till.go b/core/internal/pos/till.go index 45ed310c8..7b285ad6d 100644 --- a/core/internal/pos/till.go +++ b/core/internal/pos/till.go @@ -116,6 +116,12 @@ func (s *Service) TillReport(ctx context.Context, sessionID uuid.UUID) (*TillRep func (s *Service) CloseTill(ctx context.Context, sessionID uuid.UUID, countedByMethod map[string]int64, notes, actor string) (*TillReport, error) { var out *TillReport err := s.inTx(ctx, func(ctx context.Context) error { + // The session row first, FOR UPDATE: a completion or a void holding + // it FOR SHARE waits here, so the aggregate below counts every sale + // that landed and no sale lands while the drawer is being counted. + if err := s.repo.LockTillSession(ctx, sessionID, true); err != nil { + return err + } report, err := s.TillReport(ctx, sessionID) if err != nil { return err diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index fb9e39206..87e991e7c 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/branchctx" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -58,6 +59,24 @@ func (r *PostgresRepository) scanTillSession(row pgx.Row) (*TillSession, error) const tillCols = `id, register_id, branch_id, cashier_id, status, ROUND(opening_float * 100)::bigint, opened_at, closed_at, expected_by_method, counted_by_method, over_short_cents, gl_entry_id, notes` +// LockTillSession locks a session row inside the caller's transaction: the +// completion and the void take it FOR SHARE (the close's FOR UPDATE then +// waits for them, so a sale never lands in a drawer being counted), the +// close takes it FOR UPDATE before it aggregates. The branch wall applies. +func (r *PostgresRepository) LockTillSession(ctx context.Context, id uuid.UUID, forUpdate bool) error { + mode := "FOR SHARE" + if forUpdate { + mode = "FOR UPDATE" + } + var one int + err := r.ex(ctx).QueryRow(ctx, `SELECT 1 FROM till_sessions WHERE id = $1 + AND ($2::uuid IS NULL OR branch_id = $2) `+mode, id, branchctx.IDForQuery(ctx)).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return httpx.NotFound("till session not found") + } + return err +} + func (r *PostgresRepository) GetTillSession(ctx context.Context, id uuid.UUID) (*TillSession, error) { s, err := r.scanTillSession(r.ex(ctx).QueryRow(ctx, `SELECT `+tillCols+` FROM ( SELECT id, register_id, branch_id, cashier_id, status, opening_float, opened_at, closed_at, diff --git a/core/internal/pos/void.go b/core/internal/pos/void.go index 34e97143e..1852b3558 100644 --- a/core/internal/pos/void.go +++ b/core/internal/pos/void.go @@ -117,6 +117,22 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string return conflict("has_returns", "this sale has been returned against: return the rest through POST /pos/returns instead of voiding it") } + // The session, rechecked under its FOR SHARE lock against the + // close's FOR UPDATE: once the drawer is counted the way back is a + // return, never a void into a closed session. + if sale.TillSessionID != nil { + if err := s.repo.LockTillSession(ctx, *sale.TillSessionID, false); err != nil { + return err + } + session, err := s.repo.GetTillSession(ctx, *sale.TillSessionID) + if err != nil { + return err + } + if session.Status != TillOpen { + return conflict("session_closed", + "the till session this sale was made in is closed: return the goods through POST /pos/returns instead") + } + } if sale.InvoiceID == nil { return conflict("no_invoice", "the sale carries no invoice to void") } From 6c0c642ffefbfdd5f6023af5eddf58ec9668b664 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:09:31 -0700 Subject: [PATCH 021/113] C2-5 fix: the branch wall covers the register, the session and the till routes Second review P2-3. The free return took its branch from the request's register with no check against the caller's branch, and the till report and close read their session with no branch filter: a cashier granted only branch B took a cash return out of branch A's drawer, read A's report and closed A's drawer. GetRegisterBranch, GetTillSession, GetOpenTillSession and ListZReports now resolve through the branch wall (branchctx.IDForQuery, 404 outside it), as the sale, void and linked return reads already did. Red first: TestTheBranchWallCoversTheFreeReturnAndTheTill answered 201 and 200 on the prior head. Signed-off-by: colton --- core/internal/pos/branch_wall_test.go | 95 +++++++++++++++++++++++++++ core/internal/pos/repository.go | 3 +- core/internal/pos/till_repository.go | 8 ++- 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 core/internal/pos/branch_wall_test.go diff --git a/core/internal/pos/branch_wall_test.go b/core/internal/pos/branch_wall_test.go new file mode 100644 index 000000000..58d49abb5 --- /dev/null +++ b/core/internal/pos/branch_wall_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The branch wall covers the register, the session and the till routes (ADR +// 0002 section 6, second review P2-3): a cashier granted only branch B +// cannot take a free return on branch A's register, nor read or close A's +// drawer. The sale, void and linked return routes already carried the wall +// through the sale row; the register and session reads now carry it too. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/database" + "github.com/google/uuid" +) + +func TestTheBranchWallCoversTheFreeReturnAndTheTill(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + // the kill switch is read when the fixture's middleware is built + setPosSetting(t, db, "multi_branch_enabled", "true") + branchB := uuid.New() + mustExecB := func(sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(contextB(), sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + mustExecB(`INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', 'WL-POS-B', 'wall branch B')`, branchB) + mustExecB(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-wall-b', $1, TRUE, 'test')`, branchB) + t.Cleanup(func() { + setPosSetting(t, db, "multi_branch_enabled", "false") + mustExecB(`DELETE FROM user_locations WHERE user_sub = 'pos-wall-b' AND branch_id = $1`, branchB) + mustExecB(`DELETE FROM locations WHERE id = $1`, branchB) + }) + + f := newFixture(t, db) + // a drawer open on the fixture's own branch (the default branch, A), + // opened by a caller with no claims: the middleware's dev fallback, + // permissive with no branch context + if r := f.do("POST", "/api/v1/pos/till/open", map[string]any{"register_id": f.register, "opening_float_cents": 0}); r.status != http.StatusCreated { + t.Fatalf("open till = %d: %s", r.status, r.raw) + } + sessionID := f.currentTillID(t) + // the B-cashier's headers: the branch middleware scopes them to branch B + scoped := []string{"X-Test-Role", "cashier", "X-Test-Sub", "pos-wall-b", "X-Branch-Id", branchB.String()} + + // a free cash return on branch A's register: 404, not a payout + r := f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "refund_method": "cash", + "reason": "out of my branch", "lines": []map[string]any{{ + "product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 55000, "restock": false, + }}, + }, scoped...) + if r.status != http.StatusNotFound { + t.Errorf("free return on another branch's register = %d, want 404: %s", r.status, r.raw) + } + // branch A's till report: 404 + r = f.do("GET", "/api/v1/pos/till/"+sessionID+"/report", nil, scoped...) + if r.status != http.StatusNotFound { + t.Errorf("another branch's till report = %d, want 404: %s", r.status, r.raw) + } + // branch A's till close: 404, and the session still open + r = f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 0}}, scoped...) + if r.status != http.StatusNotFound { + t.Errorf("another branch's till close = %d, want 404: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM till_sessions WHERE id = $1 AND status = 'OPEN'`, sessionID); got != 1 { + t.Errorf("the session was closed through the wall") + } + // the same routes inside the caller's own branch still work: an unscoped + // caller (no branch context) reads and closes the drawer + r = f.do("GET", "/api/v1/pos/till/"+sessionID+"/report", nil) + if r.status != http.StatusOK { + t.Errorf("unscoped till report = %d, want 200: %s", r.status, r.raw) + } +} + +func contextB() context.Context { return context.Background() } + +// setPosSetting upserts a system_settings row for the wall test. +func setPosSetting(t *testing.T, db *database.DB, key, value string) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), `INSERT INTO system_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, key, value); err != nil { + t.Fatalf("set %s: %v", key, err) + } +} diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index f4c141bfe..6bc652006 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -674,7 +674,8 @@ func (r *PostgresRepository) LockCustomerCredit(ctx context.Context, customerID func (r *PostgresRepository) GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) { var branch *uuid.UUID err := r.ex(ctx).QueryRow(ctx, `SELECT l.branch_id FROM pos_registers pr - LEFT JOIN locations l ON l.id = pr.location_id WHERE pr.id = $1`, registerID).Scan(&branch) + LEFT JOIN locations l ON l.id = pr.location_id + WHERE pr.id = $1 AND ($2::uuid IS NULL OR l.branch_id = $2)`, registerID, branchctx.IDForQuery(ctx)).Scan(&branch) if errors.Is(err, pgx.ErrNoRows) { return nil, httpx.NotFound("no such register") } diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index 87e991e7c..970c26e33 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -83,7 +83,7 @@ func (r *PostgresRepository) GetTillSession(ctx context.Context, id uuid.UUID) ( expected_by_method, counted_by_method, CASE WHEN over_short IS NULL THEN NULL ELSE ROUND(over_short * 100)::bigint END AS over_short_cents, gl_entry_id, notes - FROM till_sessions) t WHERE t.id = $1`, id)) + FROM till_sessions) t WHERE t.id = $1 AND ($2::uuid IS NULL OR t.branch_id = $2)`, id, branchctx.IDForQuery(ctx))) if errors.Is(err, pgx.ErrNoRows) { return nil, httpx.NotFound("till session not found") } @@ -99,7 +99,8 @@ func (r *PostgresRepository) GetOpenTillSession(ctx context.Context, registerID expected_by_method, counted_by_method, CASE WHEN over_short IS NULL THEN NULL ELSE ROUND(over_short * 100)::bigint END AS over_short_cents, gl_entry_id, notes - FROM till_sessions) t WHERE t.register_id = $1 AND t.status = 'OPEN'`, registerID)) + FROM till_sessions) t WHERE t.register_id = $1 AND t.status = 'OPEN' + AND ($2::uuid IS NULL OR t.branch_id = $2)`, registerID, branchctx.IDForQuery(ctx))) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -233,7 +234,8 @@ func (r *PostgresRepository) ListZReports(ctx context.Context, registerID string FROM till_z_reports WHERE ($1 = '' OR register_id = $1) AND (generated_at >= $2::date AND generated_at < ($2::date + 1)) - ORDER BY generated_at DESC`, registerID, date) + AND ($3::uuid IS NULL OR branch_id = $3) + ORDER BY generated_at DESC`, registerID, date, branchctx.IDForQuery(ctx)) if err != nil { return nil, fmt.Errorf("failed to list Z-reports: %w", err) } From e5efac9c27d7ea5db3ce307e2a09b419f1166e4e Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:11:21 -0700 Subject: [PATCH 022/113] C2-5 fix: the machine key contract restored, its test back Second review P2-4. The PR deleted handler_machinekey_test.go (R1-13) and quietly widened the cashier refusal to POST /pos/transactions/{id}/items and POST /pos/till/{id}/close, against ADR 0002 section 4, which keeps item changes, completion, void, the sync and the reports reachable with pos scopes: only the routes that resolve their cashier from the request identity (start, till open, return) are user-only. The two routes are reachable for a key again (the acting subject, when one is on the request, is the audit attribution), and the restored test proves the whole matrix: the three user-only routes refuse a key 403 with the envelope and 'a cashier must be a user', and add item, complete, void, sync and close serve a keyed caller. Red first: the restored test failed on the two routes on the prior head. Signed-off-by: colton --- core/internal/pos/handler.go | 17 +-- core/internal/pos/handler_machinekey_test.go | 128 +++++++++++++++++++ 2 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 core/internal/pos/handler_machinekey_test.go diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index 0ba910be6..c22d90c37 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -174,10 +174,6 @@ func (h *Handler) AddItem(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - cashierID, ok := cashierOf(w, r) - if !ok { - return - } var req addLineRequest if err := httpx.DecodeJSON(r, &req); err != nil { httpx.WriteError(w, r, err) @@ -188,7 +184,10 @@ func (h *Handler) AddItem(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, err) return } - sale, err := h.service.AddLine(r.Context(), id, parsed, cashierID.String()) + // An item change carries no cashier identity of its own (ADR 0002 + // section 4): a key holding pos scopes works it; the acting subject, if + // any, is the audit attribution. + sale, err := h.service.AddLine(r.Context(), id, parsed, actorFrom(r)) if err != nil { httpx.WriteError(w, r, err) return @@ -437,10 +436,6 @@ func (h *Handler) CloseTill(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) return } - cashierID, ok := cashierOf(w, r) - if !ok { - return - } var req closeTillRequest if err := httpx.DecodeJSON(r, &req); err != nil { httpx.WriteError(w, r, err) @@ -451,7 +446,9 @@ func (h *Handler) CloseTill(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, r, err) return } - report, err := h.service.CloseTill(r.Context(), id, counted, notes, cashierID.String()) + // The close carries no cashier identity of its own either (ADR 0002 + // section 4): the counted drawer is posted by the acting subject. + report, err := h.service.CloseTill(r.Context(), id, counted, notes, actorFrom(r)) if err != nil { httpx.WriteError(w, r, err) return diff --git a/core/internal/pos/handler_machinekey_test.go b/core/internal/pos/handler_machinekey_test.go new file mode 100644 index 000000000..01375eaab --- /dev/null +++ b/core/internal/pos/handler_machinekey_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The machine key contract (R1-13, ADR 0002 section 4, second review P2-4): +// the POS routes that resolve their cashier from the request identity +// (starting a sale, opening a till, recording a return) are user-only, and +// a key is refused 403 "a cashier must be a user"; the routes that carry no +// cashier identity of their own (item changes, completion, void, the sync, +// the close, the reports) stay reachable with pos scopes. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/pkg/middleware" + "github.com/gablelbm/gable/internal/pos" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// keyedServer serves the fixture's real handler stack with a machine key in +// the request context, where the auth core would put it once a key validates. +func keyedServer(f *fixture, keyID string) *httptest.Server { + mux := http.NewServeMux() + pos.NewHandler(f.service).RegisterRoutes(mux) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux.ServeHTTP(w, r.WithContext(middleware.WithKeyID(r.Context(), keyID))) + })) + f.t.Cleanup(srv.Close) + return srv +} + +// keyRefusalBody decodes the refusal envelope the handler writes. +type keyRefusalBody struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + Meta struct { + RequestID string `json:"request_id"` + } `json:"meta"` +} + +func TestMachineKeyNeverGetsFabricatedCashier(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + keyID := uuid.NewString() + keyed := keyedServer(f, keyID) + + // The three user-only routes: 403 with the envelope and the message. + for _, route := range []string{"/api/v1/pos/transactions", "/api/v1/pos/till/open", "/api/v1/pos/returns"} { + req := httptest.NewRequest(http.MethodPost, route, + strings.NewReader(`{"register_id":"`+f.register+`","reason":"k","lines":[{"product_id":"`+f.productID.String()+`","quantity":"1","unit_price_ten_thousandths":1000}]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "req-pos-key-test") + rec := httptest.NewRecorder() + keyed.Config.Handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("machine key on %s: status = %d, want 403; body: %s", route, rec.Code, rec.Body.String()) + continue + } + var body keyRefusalBody + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Errorf("decode refusal envelope: %v (body: %s)", err, rec.Body.String()) + continue + } + if body.Error.Code != "forbidden" || !strings.Contains(body.Error.Message, "a cashier must be a user") { + t.Errorf("refusal on %s = (%s, %s), want forbidden and a cashier must be a user", route, body.Error.Code, body.Error.Message) + } + } + + // The routes that carry no cashier identity of their own stay reachable: + // a key holding pos scopes works them (ADR 0002 section 4). A sale is + // built through the unkeyed fixture (a key cannot start one). + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, f.productLine("1")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + revision := num(t, f.getSale(t, saleID), "revision") + keyedDo := func(method, path, body string) int { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "req-pos-key-test") + rec := httptest.NewRecorder() + keyed.Config.Handler.ServeHTTP(rec, req) + return rec.Code + } + // item changes + if code := keyedDo("POST", "/api/v1/pos/transactions/"+saleID+"/items", + `{"line":{"product_id":"`+f.productID.String()+`","quantity":"1"}}`); code == http.StatusForbidden { + t.Errorf("add item with a machine key: 403, want reachable (ADR 0002 section 4: item changes)") + } + // completion + if code := keyedDo("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + `{"tenders":[{"method":"cash","amount_cents":1198}],"revision":`+jsonNumberOf(revision)+`}`); code == http.StatusForbidden { + t.Errorf("complete with a machine key: 403, want reachable") + } + // the void (its own revision: the completion moved it) + revision = num(t, f.getSale(t, saleID), "revision") + if code := keyedDo("POST", "/api/v1/pos/transactions/"+saleID+"/void", + `{"reason":"keyed","revision":`+jsonNumberOf(revision)+`}`); code == http.StatusForbidden { + t.Errorf("void with a machine key: 403, want reachable") + } + // the offline sync + if code := keyedDo("POST", "/api/v1/pos/sync", + `{"batch_id":"key-sync","register_id":"`+f.register+`","items":[{"client_id":"`+uuid.NewString()+`","cashier_id":"`+uuid.NewString()+`","items":[{"product_id":"`+f.productID.String()+`","quantity":"1"}],"tenders":[{"method":"cash","amount_cents":599}]}]}`); code == http.StatusForbidden { + t.Errorf("sync with a machine key: 403, want reachable") + } + // the till close (a drawer the unkeyed fixture opened) + f.openTill(0) + sessionID := f.currentTillID(t) + if code := keyedDo("POST", "/api/v1/pos/till/"+sessionID+"/close", + `{"counted_by_method":{"cash":0}}`); code == http.StatusForbidden { + t.Errorf("till close with a machine key: 403, want reachable") + } + f.assertARInvariants(t) +} + +func jsonNumberOf(v int64) string { + raw, _ := json.Marshal(v) + return string(raw) +} From 9318a5d4a90fd793d5a49a9168cf35abec92981c Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:12:41 -0700 Subject: [PATCH 023/113] C2-5 fix: a return left on the walk-in customer's account is refused walk_in_account First review P2-2, second review P3-6. A walk-in sale returned with refund_method account created an open credit memo on the walk-in customer, which cannot hold an ACCOUNT tender on the sale side: the credit was stranded where nobody could apply it. The return refuses 409 walk_in_account when its resolved customer is the walk-in and the method is account, before anything is priced or paid. Red first: TestAWalkInReturnRefusesAccount answered 201 on the prior head. Signed-off-by: colton --- core/internal/pos/returns.go | 9 +++++ core/internal/pos/walkin_return_test.go | 48 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 core/internal/pos/walkin_return_test.go diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index f4c19567b..e3e2cc33b 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -80,12 +80,21 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur customerID = original.CustomerID } } + isWalkIn := false if customerID == nil { walkInID, _, err := s.repo.WalkInCustomer(ctx) if err != nil { return nil, err } customerID = &walkInID + isWalkIn = true + } + // The walk-in customer cannot hold account credit: as the sale side + // refuses an ACCOUNT tender for it, a return left on its account is + // refused the same way. + if isWalkIn && in.RefundMethod == RefundAccount { + return nil, conflict("walk_in_account", + "account credit needs a named customer: the walk-in customer is refunded from the drawer or to the card") } facts, err := s.repo.CustomerFacts(ctx, *customerID) if err != nil { diff --git a/core/internal/pos/walkin_return_test.go b/core/internal/pos/walkin_return_test.go new file mode 100644 index 000000000..2918a6b84 --- /dev/null +++ b/core/internal/pos/walkin_return_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The walk-in customer cannot hold account credit (first review P2-2, +// second review P3-6): a return resolved to the walk-in customer and +// refunded to account is refused walk_in_account, exactly as the sale side +// refuses an ACCOUNT tender for the walk-in; the credit a walk-in returns +// is paid out of the drawer or back to the card, never left open on a +// customer that cannot hold it. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +func TestAWalkInReturnRefusesAccount(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, _ := f.saleOf("1", tender("cash", 599)) // a walk-in sale + r := f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "original_sale_id": saleID, "refund_method": "account", + "reason": "store credit for a stranger", + "lines": []map[string]any{{"product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 55000, "restock": true}}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("walk-in account return = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "walk_in_account" { + t.Errorf("blockers = %v, want walk_in_account", blockers) + } + // nothing was written: no memo, no return row, the stock whole + if got := countOf(t, f.db, `SELECT count(*) FROM credit_memos WHERE customer_id = (SELECT id FROM customers WHERE account_number = 'WALK-IN')`); got != 0 { + t.Errorf("%d memos on the walk-in customer, want 0", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_returns`); got != 0 { + t.Errorf("%d return rows, want 0", got) + } + if got := f.stock(); got != "99.0000/0.0000" { + t.Errorf("stock = %s, want 99/0 (the sale's unit still out)", got) + } + f.assertARInvariants(t) +} From 12669fa20de208657b33d9c55e85d594bd2a619d Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:24:11 -0700 Subject: [PATCH 024/113] C2-5 fix: the transaction proofs the recipe owes, and the invariant check that proves them First review P2-4, second review P2-5. The contention tests ran at the default pool: TestTwoSalesOfTheLastUnit, TestVoidRacingAReturn and TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment now run at pool size 4, and the void and return race carries three contenders (a void against two returns of the same line, the caps holding both). TestThreeRegisters posted its walk-in payments to /api/v1/payments, a route the fixture never mounted, and discarded the answers: the fixture mounts the payment route now and the test asserts the three payments landed as unapplied cash. The failing write proofs extend past the completion and the void: a failing audit write (a trigger refusing one action on audit_log, through the real logger) rolls the completion, the void and the return back, and a failing event write rolls the return (pos_return.completed) and the till close (till.closed) back; the void and the return get their R1-14 audit rows back inside their transactions, which the rewrite had dropped. assertARInvariants checks every customer the fixture touched (the walk-in included) with all three legs of 9.3 (balance, subledger, open documents) and the control accounts globally in one snapshot each (1020 against the sum of the balances, 2200 against the unapplied cash); the fixture's cleanup captures the reversals of receipt and application entries too, whose loss was stranding entry families and silently breaking the global ties. Signed-off-by: colton --- core/internal/pos/audit_event_proofs_test.go | 170 +++++++++++++++++++ core/internal/pos/fixture_test.go | 17 +- core/internal/pos/returns.go | 14 ++ core/internal/pos/tx_test.go | 78 ++++++--- core/internal/pos/void.go | 13 ++ core/internal/pos/wire_test.go | 65 +++++-- 6 files changed, 316 insertions(+), 41 deletions(-) create mode 100644 core/internal/pos/audit_event_proofs_test.go diff --git a/core/internal/pos/audit_event_proofs_test.go b/core/internal/pos/audit_event_proofs_test.go new file mode 100644 index 000000000..b191b38e1 --- /dev/null +++ b/core/internal/pos/audit_event_proofs_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The transaction proofs the recipe owes beyond the completion and the void +// (first review P2-4a): a failing audit write rolls the completion, the +// void AND the return back, and a failing event write rolls the return and +// the till close back. The audit failure is driven through a trigger on +// audit_log that refuses one action, so the real logger, the real +// transaction and the real rollback are all in the proof. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +// failAuditRows makes every audit_log insert of the given action fail, the +// way a saturated or broken audit table would, until the test ends. +func failAuditRows(t *testing.T, f *fixture, action string) { + t.Helper() + mustExec(t, f.db, `CREATE OR REPLACE FUNCTION pos_test_fail_audit() RETURNS TRIGGER AS $fn$ + BEGIN IF NEW.action = TG_ARGV[0] THEN RAISE EXCEPTION 'audit write refused for %', NEW.action; END IF; + RETURN NEW; END $fn$ LANGUAGE plpgsql`) + mustExec(t, f.db, `DROP TRIGGER IF EXISTS pos_test_fail_audit ON audit_log`) + mustExec(t, f.db, `CREATE TRIGGER pos_test_fail_audit BEFORE INSERT ON audit_log + FOR EACH ROW EXECUTE FUNCTION pos_test_fail_audit('`+action+`')`) + t.Cleanup(func() { + mustExec(t, f.db, `DROP TRIGGER IF EXISTS pos_test_fail_audit ON audit_log`) + mustExec(t, f.db, `DROP FUNCTION IF EXISTS pos_test_fail_audit()`) + }) +} + +// RULE: a failing audit write rolls the completion, the void and the return +// back with it (R1-14: a committed act leaves exactly one row, a rolled back +// one leaves none). +func TestAFailingAuditWriteRollsTheActBack(t *testing.T) { + testutil.LockOutboxTables(t) + // The completion. + f := newFixture(t, testutil.RequireDB(t)) + failAuditRows(t, f, "pos.transaction.completed") + invoicesBefore := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`) + saleID := f.startSale(nil) + if r := f.addLine(saleID, f.productLine("3")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 3266)); r.status == http.StatusOK { + t.Fatal("the completion committed under a failing audit write") + } + if got := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE order_id IS NULL`); got != invoicesBefore { + t.Errorf("%d invoices after the refused completion, want %d", got, invoicesBefore) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`, saleID); got != 0 { + t.Errorf("%d tenders after the refused completion, want 0", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want the 100 untouched", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.transaction.completed' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d completion audit rows, want 0 (the write failed and the act rolled back)", got) + } + + // The void. + f2 := newFixture(t, testutil.RequireDB(t)) + failAuditRows(t, f2, "pos.transaction.voided") + saleID2, body := f2.saleOf("2", tender("cash", 1198)) + if r := f2.do("POST", "/api/v1/pos/transactions/"+saleID2+"/void", + map[string]any{"reason": "nope", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status == http.StatusOK { + t.Fatal("the void committed under a failing audit write") + } + if got := str(t, f2.getSale(t, saleID2), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void rolled back)", got) + } + if got := f2.stock(); got != "98.0000/0.0000" { + t.Errorf("stock = %s, want 98 (the goods still gone)", got) + } + if got := f2.accountBalance("1010"); got != 1198 { + t.Errorf("cash balance = %d, want the 1198 still booked", got) + } + + // The return. + f3 := newFixture(t, testutil.RequireDB(t)) + failAuditRows(t, f3, "pos.return.completed") + saleID3, body3 := f3.saleOf("2", tender("cash", 1198)) + lineID := body3.body["lines"].([]any)[0].(map[string]any)["id"].(string) + memosBefore := countOf(t, f3.db, `SELECT count(*) FROM credit_memos`) + if r := f3.returnOn(t, saleID3, lineID, "1"); r.status == http.StatusCreated { + t.Fatal("the return committed under a failing audit write") + } + if got := countOf(t, f3.db, `SELECT count(*) FROM credit_memos`); got != memosBefore { + t.Errorf("%d memos after the refused return, want %d", got, memosBefore) + } + if got := countOf(t, f3.db, `SELECT count(*) FROM pos_returns`); got != 0 { + t.Errorf("%d return rows after the refused return, want 0", got) + } + if got := f3.stock(); got != "98.0000/0.0000" { + t.Errorf("stock = %s, want 98 (the return rolled back)", got) + } + if got := f3.accountBalance("1010"); got != 1198 { + t.Errorf("cash balance = %d, want 1198 (no refund was paid)", got) + } + f3.assertARInvariants(t) +} + +// RULE: a failing event write rolls the return back: no memo, no refund, no +// restock, no return row. +func TestFailingEventWriteRollsTheReturnBack(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.events.fail = "pos_return.completed" + }) + saleID, body := f.saleOf("2", tender("cash", 1198)) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + memosBefore := countOf(t, f.db, `SELECT count(*) FROM credit_memos`) + if r := f.returnOn(t, saleID, lineID, "1"); r.status == http.StatusCreated { + t.Fatal("the return committed under a failing event write") + } + for _, c := range []struct { + what string + sql string + args []any + want int64 + }{ + {"memos", `SELECT count(*) FROM credit_memos`, nil, memosBefore}, + {"return rows", `SELECT count(*) FROM pos_returns`, nil, 0}, + {"refund entries", `SELECT count(*) FROM payment_refunds`, nil, 0}, + // the walk-in sale's row is not this customer's: the return's credit never landed here + {"subledger rows", `SELECT count(*) FROM customer_transactions WHERE customer_id = $1`, []any{f.customerID}, 0}, + } { + if got := countOf(t, f.db, c.sql, c.args...); got != c.want { + t.Errorf("%s = %d, want %d", c.what, got, c.want) + } + } + if got := f.stock(); got != "98.0000/0.0000" { + t.Errorf("stock = %s, want 98 (nothing restocked)", got) + } + if got := f.accountBalance("1010"); got != 1198 { + t.Errorf("cash balance = %d, want 1198 (no refund was paid)", got) + } + f.assertARInvariants(t) +} + +// RULE: a failing event write rolls the till close back: the session stays +// open, no Z report, no over/short entry. +func TestFailingEventWriteRollsTheTillCloseBack(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.events.fail = "till.closed" + }) + sessionID := f.openTill(1000) + f.saleOf("1", tender("cash", 599)) + r := f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 1000}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status == http.StatusOK { + t.Fatal("the close committed under a failing event write") + } + if got := countOf(t, f.db, `SELECT count(*) FROM till_sessions WHERE id = $1 AND status = 'OPEN'`, sessionID); got != 1 { + t.Errorf("the session closed under a failing event write") + } + if got := countOf(t, f.db, `SELECT count(*) FROM till_z_reports`); got != 0 { + t.Errorf("%d Z reports after the refused close, want 0", got) + } + if got := f.accountBalance("5030"); got != 0 { + t.Errorf("5030 balance = %d, want 0 (no variance was booked)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/fixture_test.go b/core/internal/pos/fixture_test.go index abd0f1130..1b344597c 100644 --- a/core/internal/pos/fixture_test.go +++ b/core/internal/pos/fixture_test.go @@ -25,6 +25,7 @@ import ( "github.com/gablelbm/gable/internal/gl" "github.com/gablelbm/gable/internal/inventory" "github.com/gablelbm/gable/internal/invoice" + "github.com/gablelbm/gable/internal/payment" "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/database" @@ -125,6 +126,10 @@ func newFixture(t *testing.T, db *database.DB, opts ...func(*fixture)) *fixture mux := http.NewServeMux() pos.NewHandler(f.service).RegisterRoutes(mux) invoice.NewHandler(invoices).RegisterRoutes(mux) + // the payment route, as serve mounts it: the contention tests race real + // walk-in payments against the sales + payments := payment.NewService(db, payment.NewRepository(db), acct) + payment.NewHandler(payments).RegisterRoutes(mux) f.srv = httptest.NewServer(middleware.Idempotency(db)(roleClaims(middleware.NewBranchMiddleware(db).Handler(mux)))) t.Cleanup(func() { f.srv.Close() @@ -172,14 +177,22 @@ func (f *fixture) cleanup() { // the entry delete never misses one that named a payment or an // application deleted below var entryIDs []string + // Every entry the fixture's documents own, and every reversal of one of + // those entries (a void reverses receipt and application entries too, and + // a reversal whose original goes would trip the reverses foreign key and + // strand the whole family). if rows, err := f.db.Pool.Query(ctx, `SELECT e.id::text FROM gl_journal_entries e WHERE e.source_ref_id::text IN (SELECT id::text FROM invoices WHERE id IN `+invs+`) OR e.source_ref_id::text IN (SELECT id::text FROM credit_memos WHERE id IN `+cms+`) OR e.source_ref_id::text IN (SELECT id::text FROM payments WHERE id IN `+pays+`) OR e.source_ref_id::text IN (SELECT id::text FROM ar_applications WHERE invoice_id IN `+invs+`) OR e.source_ref_id::text IN (SELECT id::text FROM payment_refunds WHERE payment_id IN `+pays+` OR credit_memo_id IN `+cms+`) - OR e.reverses_entry_id IN (SELECT id FROM gl_journal_entries WHERE source_ref_id::text IN - (SELECT id::text FROM invoices WHERE id IN `+invs+`))`, f.customerID); err == nil { + OR e.reverses_entry_id IN (SELECT o.id FROM gl_journal_entries o WHERE o.source_ref_id::text IN + ((SELECT id::text FROM invoices WHERE id IN `+invs+`) + UNION (SELECT id::text FROM credit_memos WHERE id IN `+cms+`) + UNION (SELECT id::text FROM payments WHERE id IN `+pays+`) + UNION (SELECT id::text FROM ar_applications WHERE invoice_id IN `+invs+`) + UNION (SELECT id::text FROM payment_refunds WHERE payment_id IN `+pays+` OR credit_memo_id IN `+cms+`)))`, f.customerID); err == nil { for rows.Next() { var id string if rows.Scan(&id) == nil { diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index e3e2cc33b..c82897f67 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -11,6 +11,7 @@ import ( "github.com/gablelbm/gable/internal/gl" "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/internal/salesdoc" + "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" ) @@ -466,6 +467,19 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if err := s.repo.CreateReturn(ctx, ret, lines); err != nil { return err } + // The audit row, inside the transaction (R1-14): a rolled back return + // leaves no row. + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "pos.return.completed", EntityType: "pos_return", EntityID: ret.ID, UserID: actor, + Changes: map[string]any{ + "number": ret.Number, "total_cents": total, "refund_method": in.RefundMethod.Status(), + "register_id": in.RegisterID, "customer_id": customerID, "credit_memo_id": memoID, + "original_sale_id": in.OriginalSaleID, + }}); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } // The events, last: the memo's lifecycle, the AR core's, the // return's own. branch := *branchID diff --git a/core/internal/pos/tx_test.go b/core/internal/pos/tx_test.go index bcc94cbb8..498025023 100644 --- a/core/internal/pos/tx_test.go +++ b/core/internal/pos/tx_test.go @@ -24,7 +24,7 @@ import ( // the completion and the void). func TestFailingEventWriteRollsTheSaleBack(t *testing.T) { testutil.LockOutboxTables(t) - f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { f.events.fail = "pos_transaction.completed" }) saleID := f.startSale(nil) @@ -72,7 +72,7 @@ func TestFailingEventWriteRollsTheSaleBack(t *testing.T) { // The same proof for the void. f.events.fail = "pos_transaction.voided" - f2 := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f2 := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { f.events.fail = "pos_transaction.voided" }) saleID3, body := f2.saleOf("2", tender("cash", 1198)) @@ -97,7 +97,7 @@ func TestFailingEventWriteRollsTheSaleBack(t *testing.T) { // the other is refused with insufficient_stock and moves nothing. func TestTwoSalesOfTheLastUnit(t *testing.T) { testutil.LockOutboxTables(t) - f := newFixture(t, testutil.RequireDB(t)) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) mustExec(t, f.db, `UPDATE inventory SET quantity = 2 WHERE product_id = $1`, f.productID) var wg sync.WaitGroup results := make([]int, 2) @@ -137,7 +137,7 @@ func TestTwoSalesOfTheLastUnit(t *testing.T) { // walk-in customer row, ADR 0005 4.1). func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { testutil.LockOutboxTables(t) - f := newFixture(t, testutil.RequireDB(t)) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) registers := []string{f.register, "REG-" + uuid.NewString()[:8], "REG-" + uuid.NewString()[:8]} for _, reg := range registers[1:] { mustExec(t, f.db, `INSERT INTO pos_registers (id, location_id, name, branch_id) VALUES ($1, $2, 'r', $3)`, @@ -159,7 +159,10 @@ func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { } }(i, reg) } - // a payment for the walk-in customer racing the sales + // three payments for the walk-in customer racing the sales, through the + // real payment route the fixture mounts: their outcomes are asserted, + // not discarded + paymentStatuses := make([]int, 3) wg.Add(1) go func() { defer wg.Done() @@ -170,13 +173,25 @@ func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { return } for i := 0; i < 3; i++ { - _ = f.do("POST", "/api/v1/payments", map[string]any{ + r := f.do("POST", "/api/v1/payments", map[string]any{ "customer_id": walkIn, "method": "cash", "amount_cents": 100, "received_on": "2030-01-01", }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + paymentStatuses[i] = r.status } }() wg.Wait() + for i, st := range paymentStatuses { + if st != 201 { + t.Errorf("racing payment %d = %d, want 201", i, st) + } + } + // the three walk-in payments landed as unapplied cash + if got := countOf(t, f.db, `SELECT count(*) FROM payments p + JOIN customers c ON c.id = p.customer_id WHERE c.account_number = 'WALK-IN' AND p.amount_unapplied = 1.00`); got != 3 { + t.Errorf("%d unapplied walk-in payments of 1.00, want 3", got) + } + f.assertARInvariants(t) for i, err := range errs { if err != nil { t.Errorf("register %d: %v", i, err) @@ -210,38 +225,53 @@ func TestThreeRegistersConsecutiveNumbersAgainstAWalkInPayment(t *testing.T) { // RULE: a void racing a return of the same sale: one wins, the other is // refused, and the books stay consistent. -func TestVoidRacingAReturn(t *testing.T) { +func TestVoidRacingTwoReturns(t *testing.T) { testutil.LockOutboxTables(t) - f := newFixture(t, testutil.RequireDB(t)) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) saleID, body := f.saleOf("6", tender("cash", 3593)) lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + // three contenders on one sale at pool size 4: a void and two returns + // of different quantities of the same line var wg sync.WaitGroup - var voidStatus, returnStatus int - wg.Add(2) + var voidStatus int + returnStatuses := make([]int, 2) + wg.Add(3) go func() { defer wg.Done() voidStatus = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", map[string]any{"reason": "racing", "revision": rev(t, body)}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status }() - go func() { - defer wg.Done() - returnStatus = f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, - "refund_method": "cash", "reason": "racing", - "lines": []map[string]any{{"line_id": lineID, "quantity": "1", "restock": true}}, - }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status - }() + for i, qty := range []string{"1", "2"} { + go func(i int, qty string) { + defer wg.Done() + returnStatuses[i] = f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "refund_method": "cash", "reason": "racing", + "lines": []map[string]any{{"line_id": lineID, "quantity": qty, "restock": true}}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status + }(i, qty) + } wg.Wait() - t.Logf("void status %d, return status %d, stock %s", voidStatus, returnStatus, f.stock()) won := func(status int) bool { return status >= 200 && status < 300 } - if won(voidStatus) && won(returnStatus) { - t.Fatalf("both the void and the return won (%d, %d)", voidStatus, returnStatus) + t.Logf("void %d, returns %v, stock %s", voidStatus, returnStatuses, f.stock()) + var returnsWon int + for _, st := range returnStatuses { + if won(st) { + returnsWon++ + } + } + if won(voidStatus) && returnsWon > 0 { + t.Fatalf("the void and a return both won (%d, %v)", voidStatus, returnStatuses) } f.assertARInvariants(t) - // the stock is whole either way: voided returns all 6, a return returns - // what it named; both winning is the only corruption - if got := f.stock(); got != "94.0000/0.0000" && got != "100.0000/0.0000" && got != "95.0000/0.0000" { + // the stock is whole either way: the sale left 94; a void returns all + // 6 (100); the returns are capped at what the line sold less each other, + // so 97 (both won), 95 or 96 (one won) or 100 (the void won) are the + // coherent outcomes + switch got := f.stock(); got { + case "97.0000/0.0000", "95.0000/0.0000", "96.0000/0.0000", "100.0000/0.0000": + default: t.Errorf("stock = %s after the race", got) } } diff --git a/core/internal/pos/void.go b/core/internal/pos/void.go index 1852b3558..0d5b22f5b 100644 --- a/core/internal/pos/void.go +++ b/core/internal/pos/void.go @@ -10,6 +10,7 @@ import ( "github.com/gablelbm/gable/internal/account" "github.com/gablelbm/gable/internal/payment" "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/audit" "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" ) @@ -207,6 +208,18 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string if err := s.repo.VoidSale(ctx, saleID); err != nil { return err } + // The audit row, inside the transaction (R1-14): a rolled back void + // leaves no row, and only a void that changed state reaches here. + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "pos.transaction.voided", EntityType: "pos_transaction", EntityID: saleID, UserID: actor, + Changes: map[string]any{ + "number": sale.Number, "total_cents": int64(sale.TotalCents), "reason": reason, + "register_id": sale.RegisterID, "customer_id": sale.CustomerID, + }}); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } // The events, last: the AR core's, then the sale's own. branch := sale.BranchID if fxAll != nil { diff --git a/core/internal/pos/wire_test.go b/core/internal/pos/wire_test.go index 8957412e2..10c37f9d2 100644 --- a/core/internal/pos/wire_test.go +++ b/core/internal/pos/wire_test.go @@ -680,30 +680,65 @@ func (f *fixture) paymentsOf(saleID string) []paymentOf { return out } -// assertARInvariants proves the subledger rule of ADR 0005 9.3 for the -// fixture's customer after every act: the subledger sum equals balance_due -// equals the 1020 balance, and the unapplied cash equals the 2200 balance. +// assertARInvariants proves the subledger rule of ADR 0005 9.3 for every +// customer the fixture touched (its own and the walk-in that carries most +// of the sales) with all three legs: balance_due = the sum of its +// customer_transactions = the sum of amount_open over its live invoices and +// posted credit memos. The control accounts tie globally, each in one +// statement so the snapshot is consistent: GL 1020 = the sum of every +// customer's balance_due, GL 2200 = the sum of unapplied cash. func (f *fixture) assertARInvariants(t *testing.T) { t.Helper() - var subledger int64 - if err := f.db.Pool.QueryRow(context.Background(), ` - SELECT COALESCE(SUM(ROUND(amount)::bigint), 0) FROM customer_transactions WHERE customer_id = $1`, f.customerID).Scan(&subledger); err != nil { + walkIn := f.scalar(`SELECT id::text FROM customers WHERE account_number = 'WALK-IN'`).(string) + rows, err := f.db.Pool.Query(context.Background(), ` + SELECT c.id, COALESCE(ROUND(c.balance_due * 100)::bigint, 0), + COALESCE((SELECT SUM(ROUND(ct.amount)::bigint) FROM customer_transactions ct WHERE ct.customer_id = c.id), 0), + COALESCE((SELECT SUM(ROUND(i.amount_open * 100)::bigint) FROM invoices i + WHERE i.customer_id = c.id AND i.status IN ('UNPAID', 'PARTIAL', 'OVERDUE')), 0) + + COALESCE((SELECT SUM(ROUND(m.amount_open * 100)::bigint) FROM credit_memos m + WHERE m.customer_id = c.id AND m.status IN ('OPEN', 'PARTIAL')), 0) + FROM customers c WHERE c.id = ANY($1::uuid[])`, []string{f.customerID.String(), walkIn}) + if err != nil { t.Fatal(err) } - bal := f.balance() - if subledger != bal { - t.Errorf("the subledger sums %d but balance_due is %d", subledger, bal) + defer rows.Close() + for rows.Next() { + var id string + var balance, subledger, documents int64 + if err := rows.Scan(&id, &balance, &subledger, &documents); err != nil { + t.Fatal(err) + } + if balance != subledger { + t.Errorf("customer %s: balance_due is %d but the subledger sums %d", id, balance, subledger) + } + if balance != documents { + t.Errorf("customer %s: balance_due is %d but the open documents sum %d", id, balance, documents) + } + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + // GL 1020 ties to the sum of every customer's balance, GL 2200 to the + // unapplied cash; each in one statement, so the two sides of a tie are + // read from one consistent snapshot even while other tests run. + var ties bool + if err := f.db.Pool.QueryRow(context.Background(), ` + SELECT (SELECT COALESCE(ROUND(SUM(c.balance_due) * 100)::bigint, 0) FROM customers c) + = (SELECT COALESCE(ROUND((SUM(l.debit) - SUM(l.credit)) * 100)::bigint, 0) + FROM gl_journal_lines l JOIN gl_accounts a ON a.id = l.account_id WHERE a.code = '1020')`).Scan(&ties); err != nil { + t.Fatal(err) } - if gl1020 := f.accountBalance("1020"); gl1020 != bal { - t.Errorf("the 1020 balance is %d but balance_due is %d", gl1020, bal) + if !ties { + t.Error("GL 1020 does not tie to the sum of the customers' balances") } - var unapplied int64 if err := f.db.Pool.QueryRow(context.Background(), ` - SELECT COALESCE(SUM(ROUND(amount_unapplied * 100)::bigint), 0) FROM payments WHERE customer_id = $1 AND status = 'POSTED'`, f.customerID).Scan(&unapplied); err != nil { + SELECT (SELECT COALESCE(SUM(ROUND(p.amount_unapplied * 100)::bigint), 0) FROM payments p WHERE p.status = 'POSTED') + = (SELECT COALESCE(ROUND((SUM(l.credit) - SUM(l.debit)) * 100)::bigint, 0) + FROM gl_journal_lines l JOIN gl_accounts a ON a.id = l.account_id WHERE a.code = '2200')`).Scan(&ties); err != nil { t.Fatal(err) } - if gl2200 := f.accountBalance("2200"); gl2200 != unapplied { - t.Errorf("the 2200 balance is %d but the unapplied cash is %d", gl2200, unapplied) + if !ties { + t.Error("GL 2200 does not tie to the sum of the unapplied cash (2200 carries it as a credit)") } } From 952e7fae96fd3e811cec1158331aba73d20182ac Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 04:32:25 -0700 Subject: [PATCH 025/113] C2-5 fix: the concrete P3s of both reviews The returned charge line credits its own revenue account (second review P3-5): the return's entry is built per revenue group like the sale's, so a returned FREIGHT charge debits 4020 back; a linked charge line now carries its own shape (line type and charge code on the memo line, product_id NULL-able on pos_return_lines in 105), which the base schema's NOT NULL made a 500. cardApplicationOf returns its read error instead of uuid.Nil (both reviews' P3 note), and tendersInIDOrder sorts the payment bearing tenders and drops the rest, where its insertion sort broke on the first ACCOUNT tender and could lock payments out of id order (second review P3-4; red shown with the old sort restored). The 105 up refuses a legacy ACCOUNT return with no customer before it writes anything, naming the rows (second review P3-2), and the down returns the credit memo counter so an up after it mints the same CM number and the gapless series keeps no gap (P3-3). The desk's POS strings carry no em dash any more (first review P3-5), and the golden drift dumps (*.actual) are gitignored (P3-6). Signed-off-by: colton --- .gitignore | 4 + core/internal/pos/fixture_test.go | 24 +-- core/internal/pos/gateway.go | 6 +- core/internal/pos/handler_machinekey_test.go | 2 +- core/internal/pos/input.go | 10 +- core/internal/pos/migration105_test.go | 40 ++++- core/internal/pos/model.go | 161 +++++++++--------- core/internal/pos/repository.go | 26 +-- core/internal/pos/returns.go | 54 +++++- core/internal/pos/revenue_legs_test.go | 41 +++++ core/internal/pos/service.go | 2 +- core/internal/pos/void.go | 21 ++- core/internal/pos/void_internal_test.go | 37 ++++ core/migrations/105_pos_wire_contract.sql | 11 ++ .../down/105_pos_wire_contract_down.sql | 8 + web/apps/desk/src/pages/pos/POSTerminal.ts | 22 +-- 16 files changed, 326 insertions(+), 143 deletions(-) create mode 100644 core/internal/pos/revenue_legs_test.go create mode 100644 core/internal/pos/void_internal_test.go diff --git a/.gitignore b/.gitignore index ed6f54187..183077e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,7 @@ __pycache__/ # Playwright web/test-results/ web/playwright-report/ + +# Golden drift dumps written beside the recorded transcript on a mismatch +# (core/internal/characterization): never commit them, re-record instead. +core/internal/characterization/testdata/goldens/*.actual diff --git a/core/internal/pos/fixture_test.go b/core/internal/pos/fixture_test.go index 1b344597c..c6bf782ee 100644 --- a/core/internal/pos/fixture_test.go +++ b/core/internal/pos/fixture_test.go @@ -35,18 +35,18 @@ import ( ) type fixture struct { - t *testing.T - db *database.DB - srv *httptest.Server - service *pos.Service - customerID uuid.UUID - productID uuid.UUID - kitID uuid.UUID - sku string - register string - branchID uuid.UUID - yardID uuid.UUID - events *recordingWriter + t *testing.T + db *database.DB + srv *httptest.Server + service *pos.Service + customerID uuid.UUID + productID uuid.UUID + kitID uuid.UUID + sku string + register string + branchID uuid.UUID + yardID uuid.UUID + events *recordingWriter priorEntries map[string]bool } diff --git a/core/internal/pos/gateway.go b/core/internal/pos/gateway.go index dcdb4b1ac..f92f4eb2c 100644 --- a/core/internal/pos/gateway.go +++ b/core/internal/pos/gateway.go @@ -36,9 +36,9 @@ type gatewayRefund struct { gatewayTxID, refundTxID string amountCents int64 // act names the refused act ("void" or "return") for the audit row. - act string - entity uuid.UUID - actor string + act string + entity uuid.UUID + actor string } // recordOrphanRefund writes, in its own committed write after the refused diff --git a/core/internal/pos/handler_machinekey_test.go b/core/internal/pos/handler_machinekey_test.go index 01375eaab..815ab79ea 100644 --- a/core/internal/pos/handler_machinekey_test.go +++ b/core/internal/pos/handler_machinekey_test.go @@ -17,9 +17,9 @@ import ( "strings" "testing" - "github.com/gablelbm/gable/pkg/middleware" "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) diff --git a/core/internal/pos/input.go b/core/internal/pos/input.go index faccaa4bf..78d573871 100644 --- a/core/internal/pos/input.go +++ b/core/internal/pos/input.go @@ -334,12 +334,12 @@ type offlineSyncRequest struct { } type offlineSaleRequest struct { - ClientID *string `json:"client_id"` - CashierID *string `json:"cashier_id"` - CustomerID *string `json:"customer_id"` + ClientID *string `json:"client_id"` + CashierID *string `json:"cashier_id"` + CustomerID *string `json:"customer_id"` Items []salesdoc.LineRequest `json:"items"` - Tenders []tenderRequest `json:"tenders"` - ClientCreatedAt json.RawMessage `json:"client_created_at"` + Tenders []tenderRequest `json:"tenders"` + ClientCreatedAt json.RawMessage `json:"client_created_at"` } func (r *offlineSyncRequest) parse() (*OfflineSync, error) { diff --git a/core/internal/pos/migration105_test.go b/core/internal/pos/migration105_test.go index aec0e1c2a..e9d81cf9a 100644 --- a/core/internal/pos/migration105_test.go +++ b/core/internal/pos/migration105_test.go @@ -315,8 +315,8 @@ func TestMigration105_POSWireContract(t *testing.T) { mig105Apply(t, conn, target) for sql, want := range map[string]int{ `SELECT count(*) FROM credit_memos WHERE reason = 'migrated counter account return'`: 1, - `SELECT count(*) FROM customers WHERE account_number = 'WALK-IN'`: 1, - `SELECT count(*) FROM gl_journal_entries`: 1, + `SELECT count(*) FROM customers WHERE account_number = 'WALK-IN'`: 1, + `SELECT count(*) FROM gl_journal_entries`: 1, } { if got := mig105Scalar[int](t, conn, sql); got != want { t.Errorf("after a second apply, %s = %d, want %d", sql, got, want) @@ -326,6 +326,7 @@ func TestMigration105_POSWireContract(t *testing.T) { // The down: the memo and its lines go, the walk-in customer goes (its // setting first), the new columns go, the line shape returns to the // base commit's, and the text line the up's shape allowed is deleted. + memoNumber := mig105Scalar[string](t, conn, `SELECT number FROM credit_memos WHERE reason = 'migrated counter account return'`) mig105Apply(t, conn, downFile) for _, c := range [][2]string{ {"pos_transactions", "number"}, {"pos_transactions", "currency"}, {"pos_transactions", "revision"}, @@ -354,12 +355,45 @@ func TestMigration105_POSWireContract(t *testing.T) { } // And up again after the down: the memo returns, the numbers return, - // and nothing duplicates. + // and nothing duplicates. The down returns the credit memo counter, so + // the re-up mints the same CM number again and the gapless series keeps + // no gap. mig105Apply(t, conn, target) if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM credit_memos WHERE reason = 'migrated counter account return'`); got != 1 { t.Errorf("%d migrated memos after the re-up, want 1", got) } + if got := mig105Scalar[string](t, conn, `SELECT number FROM credit_memos WHERE reason = 'migrated counter account return'`); got != memoNumber { + t.Errorf("the re-up mints %s after the down minted %s: the down did not return the counter", got, memoNumber) + } if got := mig105Scalar[int](t, conn, `SELECT count(DISTINCT number) FROM pos_transactions`); got != mig105Scalar[int](t, conn, `SELECT count(*) FROM pos_transactions WHERE number IS NOT NULL`) { t.Errorf("the re-up duplicated a sale number") } } + + +// RULE (second review P3-2): the up refuses to migrate a legacy ACCOUNT +// return with no customer, naming the rows, instead of dying on the memo's +// NOT NULL halfway. +func TestMigration105_RefusesALegacyAccountReturnWithNoCustomer(t *testing.T) { + conn, _ := mig105ScratchDB(t) + before, target, _ := mig105Files(t) + for _, f := range before { + mig105Apply(t, conn, f) + } + mig105Legacy(t, conn) + if _, err := conn.Exec(context.Background(), `UPDATE pos_returns SET customer_id = NULL + WHERE id = '00000000-0000-0000-0000-0000000000a3'`); err != nil { + t.Fatal(err) + } + sql, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + _, err = conn.Exec(context.Background(), string(sql)) + if err == nil { + t.Fatal("the up migrated an ACCOUNT return with no customer") + } + if !strings.Contains(err.Error(), "no customer") { + t.Errorf("the up's refusal = %v, want it to name the customerless rows", err) + } +} diff --git a/core/internal/pos/model.go b/core/internal/pos/model.go index 7212fbac6..481df641f 100644 --- a/core/internal/pos/model.go +++ b/core/internal/pos/model.go @@ -81,21 +81,21 @@ func (m RefundMethod) MarshalText() ([]byte, error) { return []byte(m.Status()), // cart the register builds, then the completed money moment, all money in // cents and prices at scale 4, the lines in the shared shape of section 2.2. type Sale struct { - ID uuid.UUID `json:"id"` - Number string `json:"number"` - Revision int64 `json:"revision"` - BranchID uuid.UUID `json:"branch_id"` - RegisterID string `json:"register_id"` - CashierID uuid.UUID `json:"cashier_id"` - CustomerID *uuid.UUID `json:"customer_id"` - Currency string `json:"currency"` - SubtotalCents httpx.Cents `json:"subtotal_cents"` - TaxCents httpx.Cents `json:"tax_cents"` - TotalCents httpx.Cents `json:"total_cents"` - ChangeCents httpx.Cents `json:"change_cents"` - TillSessionID *uuid.UUID `json:"till_session_id"` - Status SaleStatus `json:"status"` - InvoiceID *uuid.UUID `json:"invoice_id"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Revision int64 `json:"revision"` + BranchID uuid.UUID `json:"branch_id"` + RegisterID string `json:"register_id"` + CashierID uuid.UUID `json:"cashier_id"` + CustomerID *uuid.UUID `json:"customer_id"` + Currency string `json:"currency"` + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TotalCents httpx.Cents `json:"total_cents"` + ChangeCents httpx.Cents `json:"change_cents"` + TillSessionID *uuid.UUID `json:"till_session_id"` + Status SaleStatus `json:"status"` + InvoiceID *uuid.UUID `json:"invoice_id"` CompletedAt *httpx.Timestamp `json:"completed_at"` CreatedAt httpx.Timestamp `json:"created_at"` @@ -110,60 +110,60 @@ type Sale struct { // SaleSummary is the list item; the full document embeds it, so the two // cannot drift (the recipe's rule). type SaleSummary struct { - ID uuid.UUID `json:"id"` - Number string `json:"number"` - Revision int64 `json:"revision"` - BranchID uuid.UUID `json:"branch_id"` - RegisterID string `json:"register_id"` - CashierID uuid.UUID `json:"cashier_id"` - CustomerID *uuid.UUID `json:"customer_id"` - Currency string `json:"currency"` - TotalCents httpx.Cents `json:"total_cents"` - Status SaleStatus `json:"status"` - InvoiceID *uuid.UUID `json:"invoice_id"` - CompletedAt *httpx.Timestamp `json:"completed_at"` - CreatedAt httpx.Timestamp `json:"created_at"` - ItemCount int `json:"item_count"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Revision int64 `json:"revision"` + BranchID uuid.UUID `json:"branch_id"` + RegisterID string `json:"register_id"` + CashierID uuid.UUID `json:"cashier_id"` + CustomerID *uuid.UUID `json:"customer_id"` + Currency string `json:"currency"` + TotalCents httpx.Cents `json:"total_cents"` + Status SaleStatus `json:"status"` + InvoiceID *uuid.UUID `json:"invoice_id"` + CompletedAt *httpx.Timestamp `json:"completed_at"` + CreatedAt httpx.Timestamp `json:"created_at"` + ItemCount int `json:"item_count"` } // Tender is one payment taken at the counter, stored net: the tendered // amount less any change given from it, so the payment it became is the // money kept (ADR 0005 section 14.2 C2-5). type Tender struct { - ID uuid.UUID `json:"id"` - SaleID uuid.UUID `json:"sale_id"` - Method TenderMethod `json:"method"` - AmountCents httpx.Cents `json:"amount_cents"` - PaymentID *uuid.UUID `json:"payment_id"` - Reference *string `json:"reference"` - CardLast4 *string `json:"card_last4"` - CardBrand *string `json:"card_brand"` - GatewayTxID *string `json:"gateway_tx_id"` - AuthCode *string `json:"auth_code"` - CreatedAt httpx.Timestamp `json:"created_at"` + ID uuid.UUID `json:"id"` + SaleID uuid.UUID `json:"sale_id"` + Method TenderMethod `json:"method"` + AmountCents httpx.Cents `json:"amount_cents"` + PaymentID *uuid.UUID `json:"payment_id"` + Reference *string `json:"reference"` + CardLast4 *string `json:"card_last4"` + CardBrand *string `json:"card_brand"` + GatewayTxID *string `json:"gateway_tx_id"` + AuthCode *string `json:"auth_code"` + CreatedAt httpx.Timestamp `json:"created_at"` } // Return is one counter return: a credit memo created and posted in one act // (ADR 0005 section 14.2 C2-5), refunded in cash or card or left as account // credit. type Return struct { - ID uuid.UUID `json:"id"` - Number string `json:"number"` - Revision int64 `json:"revision"` - BranchID *uuid.UUID `json:"branch_id"` - RegisterID string `json:"register_id"` - TillSessionID *uuid.UUID `json:"till_session_id"` - OriginalSaleID *uuid.UUID `json:"original_sale_id"` - CustomerID *uuid.UUID `json:"customer_id"` - CashierID uuid.UUID `json:"cashier_id"` - Currency string `json:"currency"` - SubtotalCents httpx.Cents `json:"subtotal_cents"` - TaxCents httpx.Cents `json:"tax_cents"` - TotalCents httpx.Cents `json:"total_cents"` - RefundMethod RefundMethod `json:"refund_method"` - Reason string `json:"reason"` - CreditMemoID *uuid.UUID `json:"credit_memo_id"` - CreatedAt httpx.Timestamp `json:"created_at"` + ID uuid.UUID `json:"id"` + Number string `json:"number"` + Revision int64 `json:"revision"` + BranchID *uuid.UUID `json:"branch_id"` + RegisterID string `json:"register_id"` + TillSessionID *uuid.UUID `json:"till_session_id"` + OriginalSaleID *uuid.UUID `json:"original_sale_id"` + CustomerID *uuid.UUID `json:"customer_id"` + CashierID uuid.UUID `json:"cashier_id"` + Currency string `json:"currency"` + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TotalCents httpx.Cents `json:"total_cents"` + RefundMethod RefundMethod `json:"refund_method"` + Reason string `json:"reason"` + CreditMemoID *uuid.UUID `json:"credit_memo_id"` + CreatedAt httpx.Timestamp `json:"created_at"` Lines []ReturnLine `json:"lines"` } @@ -171,21 +171,24 @@ type Return struct { // ReturnLine is one returned line, negative quantities and extensions (the // credit memo line shape). type ReturnLine struct { - ID uuid.UUID `json:"id"` - Position int `json:"position"` - ProductID *uuid.UUID `json:"product_id"` - Description string `json:"description"` + ID uuid.UUID `json:"id"` + Position int `json:"position"` + ProductID *uuid.UUID `json:"product_id"` + Description string `json:"description"` Quantity *httpx.Quantity `json:"quantity"` - UOM *string `json:"uom"` - UnitPrice *httpx.Price `json:"unit_price_ten_thousandths"` - LineTotal *httpx.Cents `json:"line_total_cents"` - Restock bool `json:"restock"` + UOM *string `json:"uom"` + UnitPrice *httpx.Price `json:"unit_price_ten_thousandths"` + LineTotal *httpx.Cents `json:"line_total_cents"` + Restock bool `json:"restock"` // The pair and price unit the sale line carried, read by the memo line's // insert; internal, never on the wire. - PriceUOM *string `json:"-"` - UOMQty *httpx.Quantity `json:"-"` - PriceUOMQty *httpx.Quantity `json:"-"` + PriceUOM *string `json:"-"` + UOMQty *httpx.Quantity `json:"-"` + PriceUOMQty *httpx.Quantity `json:"-"` + // LineType and ChargeCodeID carry a returned charge line's own shape. + LineType string `json:"-"` + ChargeCodeID *uuid.UUID `json:"-"` // SaleLineID is the sale line a linked return names; InvoiceLineID the // invoice line its restock cost reads. Both internal, never on the wire. @@ -220,11 +223,11 @@ type TillSession struct { OpenedAt httpx.Timestamp `json:"opened_at"` ClosedAt *httpx.Timestamp `json:"closed_at"` - ExpectedByMethod map[string]int64 `json:"expected_by_method"` - CountedByMethod map[string]int64 `json:"counted_by_method"` - OverShort *httpx.Cents `json:"over_short_cents"` - GLEntryID *uuid.UUID `json:"gl_entry_id"` - Notes string `json:"notes"` + ExpectedByMethod map[string]int64 `json:"expected_by_method"` + CountedByMethod map[string]int64 `json:"counted_by_method"` + OverShort *httpx.Cents `json:"over_short_cents"` + GLEntryID *uuid.UUID `json:"gl_entry_id"` + Notes string `json:"notes"` } // TillReport is the live (X) or closing (Z) summary for a session. @@ -254,12 +257,12 @@ type ZReport struct { // typeahead (the product module's data; a bare array, like the partner // surface the recipe exempts). type QuickSearchResult struct { - ProductID uuid.UUID `json:"product_id"` - SKU string `json:"sku"` - Description string `json:"description"` - UnitPriceCents httpx.Cents `json:"unit_price_cents"` - UOM string `json:"uom"` - InStock httpx.Quantity `json:"in_stock"` + ProductID uuid.UUID `json:"product_id"` + SKU string `json:"sku"` + Description string `json:"description"` + UnitPriceCents httpx.Cents `json:"unit_price_cents"` + UOM string `json:"uom"` + InStock httpx.Quantity `json:"in_stock"` } // CatalogProduct is a lightweight product for the offline catalog cache. diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index 6bc652006..6e1db3fc7 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -107,12 +107,12 @@ type CustomerFacts struct { // became: change never subtracts a second time because a cash payment is // already the money kept (ADR 0005 section 14.2 C2-5). type TillAggregate struct { - SaleCount int - SalesTotalCents int64 - TaxTotalCents int64 - ChangeCents int64 - TenderedByMethod map[string]int64 - CashRefundsCents int64 + SaleCount int + SalesTotalCents int64 + TaxTotalCents int64 + ChangeCents int64 + TenderedByMethod map[string]int64 + CashRefundsCents int64 } // SaleFilter is the sale list's filters. Date is a YYYY-MM-DD string, so @@ -512,12 +512,16 @@ func (r *PostgresRepository) CreateReturn(ctx context.Context, ret *Return, line if l.ID == uuid.Nil { l.ID = uuid.New() } + lineType := l.LineType + if lineType == "" { + lineType = "PRODUCT" + } _, err := r.ex(ctx).Exec(ctx, ` INSERT INTO pos_return_lines (id, return_id, position, line_type, product_id, description, quantity, uom, unit_price, line_total, restock, sale_line_id, created_at) - VALUES ($1, $2, $3, 'PRODUCT', $4, $5, -($6::numeric / 10000), $7, $8::numeric / 10000, $9::numeric / 100, $10, $11, NOW())`, + VALUES ($1, $2, $3, $12, $4, $5, -($6::numeric / 10000), $7, $8::numeric / 10000, $9::numeric / 100, $10, $11, NOW())`, l.ID, ret.ID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, priceArg(l.UnitPrice), - centsArg(l.LineTotal), l.Restock, l.SaleLineID) + centsArg(l.LineTotal), l.Restock, l.SaleLineID, lineType) if err != nil { return fmt.Errorf("failed to record the return line: %w", mapWriteError(err)) } @@ -561,9 +565,9 @@ func (r *PostgresRepository) SaleHasReturns(ctx context.Context, saleID uuid.UUI // InvoiceLineCost is one invoice line's position and the unit cost the sale // relieved (ten thousandths). type InvoiceLineCost struct { - ID uuid.UUID - Position int - UnitCost int64 + ID uuid.UUID + Position int + UnitCost int64 } // InvoiceLineCosts reads an invoice's lines with their unit costs, ordered diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index c82897f67..ee9431aa8 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -48,6 +48,9 @@ type returnLinePriced struct { // perUnit is the ten thousandths a linked line refunds at: the sale // line's own extension divided by the quantity it sold. perUnit httpx.Price + // chargeCodeID and lineType carry a linked charge line's own shape. + chargeCodeID *uuid.UUID + lineType string } // ReturnSale records a counter return (ADR 0005 section 14.2 C2-5): a @@ -169,7 +172,8 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur } perUnit := httpx.Price(divRound(int64(*src.LineTotal)*1_000_000, int64(*src.Quantity))) p := returnLinePriced{in: rl, lineTotal: int64(salesdoc.CostOf(rl.Quantity, perUnit)), - taxable: src.Taxable, saleLine: &src, perUnit: perUnit} + taxable: src.Taxable, saleLine: &src, perUnit: perUnit, + chargeCodeID: src.ChargeCodeID, lineType: string(src.LineType)} up := perUnit p.in.UnitPrice = &up priced = append(priced, p) @@ -372,11 +376,16 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if unitPrice == nil { unitPrice = ptrPrice(0) } + lineType := p.lineType + if lineType == "" { + lineType = "PRODUCT" + } lines = append(lines, ReturnLine{ ID: uuid.New(), Position: i, ProductID: p.in.ProductID, Description: p.in.Description, Quantity: &qty, UOM: &uom, PriceUOM: &priceUOM, UOMQty: &uomQty, PriceUOMQty: &priceUOMQty, UnitPrice: unitPrice, LineTotal: ptrC(-p.lineTotal), Restock: p.in.Restock, SaleLineID: p.in.SaleLineID, InvoiceLineID: invoiceLineID, + LineType: lineType, ChargeCodeID: p.chargeCodeID, }) if p.in.Restock && p.in.ProductID != nil { // a free line (no sale) restocks at today's average: it has @@ -401,15 +410,21 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur } for i := range lines { l := &lines[i] + lineType := l.LineType + if lineType == "" || lineType == "KIT" || lineType == "COMPONENT" { + // the memo keeps the document's own product shape; a kit + // returns as its whole line (its components never priced) + lineType = "PRODUCT" + } if _, err := s.db.GetExecutor(ctx).Exec(ctx, ` - INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, product_id, description, quantity, + INSERT INTO credit_memo_lines (credit_memo_id, position, line_type, product_id, charge_code_id, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, price_source, line_total, taxable, restock, invoice_line_id, created_at) - VALUES ($1, $2, 'PRODUCT', $3, $4, -($5::numeric / 10000), $6, $7, $8::numeric / 10000, $9::numeric / 10000, + VALUES ($1, $2, $15, $3, $16, $4, -($5::numeric / 10000), $6, $7, $8::numeric / 10000, $9::numeric / 10000, $10::numeric / 10000, 'MANUAL', $11::numeric / 100, $12, $13, $14, NOW())`, memoID, l.Position, l.ProductID, l.Description, qtyArg(l.Quantity), l.UOM, l.PriceUOM, qtyArg(l.UOMQty), qtyArg(l.PriceUOMQty), priceArg(l.UnitPrice), - centsArg(l.LineTotal), priced[i].taxable, l.Restock, l.InvoiceLineID); err != nil { + centsArg(l.LineTotal), priced[i].taxable, l.Restock, l.InvoiceLineID, lineType, l.ChargeCodeID); err != nil { return fmt.Errorf("failed to create the credit memo line: %w", mapWriteError(err)) } } @@ -419,7 +434,22 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if err != nil { return err } - legs := []gl.Leg{{AccountCode: gl.AccountCodeRevenue, Description: "Sales Revenue", Debit: subtotal}} + // Revenue, per the line's own account (8.3): product lines to 4010, a + // returned charge to its code's account. + var revenueLines []salesdoc.Line + for i := range priced { + revType := salesdoc.LineProduct + if priced[i].lineType == string(salesdoc.LineCharge) { + revType = salesdoc.LineCharge + } + lt := httpx.Cents(priced[i].lineTotal) + revenueLines = append(revenueLines, salesdoc.Line{LineType: revType, LineTotal: <, + RevenueAccountCode: revenueAccountOf(priced[i])}) + } + var legs []gl.Leg + for _, g := range salesdoc.RevenueGroups(revenueLines) { + legs = append(legs, gl.Leg{AccountCode: g.AccountCode, Description: "Sales Revenue", Debit: int64(g.Cents)}) + } if taxCents > 0 { legs = append(legs, gl.Leg{AccountCode: gl.AccountCodeSalesTax, Description: "Sales Tax Payable", Debit: taxCents}) } @@ -598,7 +628,9 @@ func fxMemoNumber(ctx context.Context, s *Service, memoID uuid.UUID) string { } // GetReturn reads a return with its lines. -func (s *Service) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) { return s.repo.GetReturn(ctx, id) } +func (s *Service) GetReturn(ctx context.Context, id uuid.UUID) (*Return, error) { + return s.repo.GetReturn(ctx, id) +} // ListReturns lists returns for a register on a date. func (s *Service) ListReturns(ctx context.Context, f ReturnFilter) ([]Return, error) { @@ -609,3 +641,13 @@ func ptrPrice(v int64) *httpx.Price { p := httpx.Price(v) return &p } + +// revenueAccountOf reads the revenue account a returned line credits back: +// the sale line's own snapshot when linked, nothing for a free line (the +// group defaults it to product revenue). +func revenueAccountOf(p returnLinePriced) *string { + if p.saleLine != nil { + return p.saleLine.RevenueAccountCode + } + return nil +} diff --git a/core/internal/pos/revenue_legs_test.go b/core/internal/pos/revenue_legs_test.go new file mode 100644 index 000000000..a54635953 --- /dev/null +++ b/core/internal/pos/revenue_legs_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// A returned charge line credits its own revenue account (ADR 0005 section +// 8.3, second review P3-5): a FREIGHT charge posts to 4020 on the sale, and +// its credit memo debits 4020 back, not 4010. + +import ( + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +func TestAReturnedChargeLineCreditsItsOwnRevenueAccount(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"line_type": "charge", "charge_code": "FREIGHT", + "description": "delivery", "quantity": "1", "unit_price_ten_thousandths": 250000}); r.status != 200 { + t.Fatalf("charge line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 2500)); r.status != 200 { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + lineID := f.getSale(t, saleID)["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.returnOn(t, saleID, lineID, "1", map[string]any{"restock": false}) + if r.status != 201 { + t.Fatalf("return = %d: %s", r.status, r.raw) + } + memoID := str(t, r.body, "credit_memo_id") + _, legs := f.legsFor(memoID) + if legs["4020"].debit != 2500 { + t.Errorf("4020 debit = %+v, want 2500 (the freight charge debits its own revenue account back)", legs["4020"]) + } + if legs["4010"].debit != 0 { + t.Errorf("4010 debit = %+v, want 0 (the freight line posts no product revenue)", legs["4010"]) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index 07d736e5a..3cae00ef0 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -396,7 +396,7 @@ func (s *Service) priceLine(ctx context.Context, sale *Sale, pl salesdoc.ParsedL Action: auditLineDiscounted, EntityType: "pos_transaction", EntityID: sale.ID, UserID: actor, Changes: map[string]any{"line_id": line.ID, "product_id": line.ProductID, "discount_percent": pl.DiscountPercent, "discount_cents": pl.DiscountAmount, - "reason": pl.DiscountReason}}) + "reason": pl.DiscountReason}}) } // Kits explode: the kit line priced as a whole, one component per // component at zero (2.6). The caller stores what comes back. diff --git a/core/internal/pos/void.go b/core/internal/pos/void.go index 0d5b22f5b..640a05e8f 100644 --- a/core/internal/pos/void.go +++ b/core/internal/pos/void.go @@ -6,6 +6,7 @@ package pos import ( "context" "fmt" + "sort" "github.com/gablelbm/gable/internal/account" "github.com/gablelbm/gable/internal/payment" @@ -254,20 +255,18 @@ func (s *Service) VoidSale(ctx context.Context, saleID uuid.UUID, ifMatch string const refundApproved = "REFUNDED" -// tendersInIDOrder sorts the tenders by their payment ids (section 11, -// step 2: payments in id order). +// tendersInIDOrder answers the tenders that became payments, sorted by +// their payment ids (section 11, step 2: payments in id order). A tender +// with no payment (an ACCOUNT tender) locks nothing and is dropped here; +// the caller skips it anyway. func tendersInIDOrder(tenders []Tender) []Tender { - out := append([]Tender(nil), tenders...) - for i := 1; i < len(out); i++ { - for j := i; j > 0; j-- { - a, b := out[j], out[j-1] - if a.PaymentID != nil && b.PaymentID != nil && a.PaymentID.String() < b.PaymentID.String() { - out[j], out[j-1] = out[j-1], out[j] - } else { - break - } + out := make([]Tender, 0, len(tenders)) + for i := range tenders { + if tenders[i].PaymentID != nil { + out = append(out, tenders[i]) } } + sort.Slice(out, func(a, b int) bool { return out[a].PaymentID.String() < out[b].PaymentID.String() }) return out } diff --git a/core/internal/pos/void_internal_test.go b/core/internal/pos/void_internal_test.go new file mode 100644 index 000000000..9fc896b3b --- /dev/null +++ b/core/internal/pos/void_internal_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +// tendersInIDOrder, white box (second review P3-4): every tender that became +// a payment comes back in payment id order, and a tender with no payment (an +// ACCOUNT tender) is dropped rather than aborting the sort. + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestTendersInIDOrderSortsPaymentBearers(t *testing.T) { + low := uuid.MustParse("00000000-0000-0000-0000-000000000001") + high := uuid.MustParse("00000000-0000-0000-0000-00000000000f") + mid := uuid.MustParse("00000000-0000-0000-0000-000000000008") + tenders := []Tender{ + {ID: uuid.New(), Method: TenderAccount}, // no payment: dropped + {ID: uuid.New(), Method: TenderCard, PaymentID: &high}, + {ID: uuid.New(), Method: TenderAccount}, // no payment: dropped + {ID: uuid.New(), Method: TenderCheck, PaymentID: &low}, + {ID: uuid.New(), Method: TenderCash, PaymentID: &mid}, + } + got := tendersInIDOrder(tenders) + if len(got) != 3 { + t.Fatalf("%d tenders out, want the 3 that carry payments", len(got)) + } + for i := 1; i < len(got); i++ { + if got[i-1].PaymentID.String() >= got[i].PaymentID.String() { + t.Errorf("order = %s then %s, want ascending payment ids", + got[i-1].PaymentID, got[i].PaymentID) + } + } +} diff --git a/core/migrations/105_pos_wire_contract.sql b/core/migrations/105_pos_wire_contract.sql index 12be4963c..a3a48c7a7 100644 --- a/core/migrations/105_pos_wire_contract.sql +++ b/core/migrations/105_pos_wire_contract.sql @@ -267,6 +267,8 @@ ALTER TABLE pos_return_lines ALTER COLUMN taxable SET DEFAULT TRUE; -- The sale line each return line came from: the return's quantity cap and its -- restock cost read it (a linked return is bounded by the line it names). ALTER TABLE pos_return_lines ADD COLUMN IF NOT EXISTS sale_line_id UUID NULL REFERENCES pos_line_items (id); +-- A returned charge line names a charge code, not a product. +ALTER TABLE pos_return_lines ALTER COLUMN product_id DROP NOT NULL; CREATE INDEX IF NOT EXISTS idx_pos_return_lines_sale_line ON pos_return_lines (sale_line_id) WHERE sale_line_id IS NOT NULL; @@ -283,7 +285,16 @@ DO $$ DECLARE r RECORD; memo UUID; + customerless INT; BEGIN + -- A legacy ACCOUNT return with no customer cannot become a memo (the + -- memo needs its customer): refuse the migration naming the rows, before + -- anything is written, rather than dying on the NOT NULL halfway. + SELECT count(*) INTO customerless FROM pos_returns + WHERE refund_method = 'ACCOUNT' AND credit_memo_id IS NULL AND customer_id IS NULL; + IF customerless > 0 THEN + RAISE EXCEPTION 'migration 105: % legacy ACCOUNT return(s) have no customer; set their customer before migrating', customerless; + END IF; FOR r IN SELECT * FROM pos_returns WHERE refund_method = 'ACCOUNT' AND credit_memo_id IS NULL ORDER BY created_at, id LOOP memo := gen_random_uuid(); INSERT INTO credit_memos (id, customer_id, amount, reason, status, created_at, updated_at, number, currency, diff --git a/core/migrations/down/105_pos_wire_contract_down.sql b/core/migrations/down/105_pos_wire_contract_down.sql index 45100d5a7..bd0198ce7 100644 --- a/core/migrations/down/105_pos_wire_contract_down.sql +++ b/core/migrations/down/105_pos_wire_contract_down.sql @@ -30,9 +30,17 @@ ALTER TABLE pos_returns DROP CONSTRAINT IF EXISTS pos_returns_credit_memo_id_fke DELETE FROM credit_memo_lines WHERE credit_memo_id IN (SELECT id FROM credit_memos WHERE reason = 'migrated counter account return'); DELETE FROM credit_memos WHERE reason = 'migrated counter account return'; +-- The counter the up's memos drew from comes back (the C2-4 down's rule), so +-- an up after this down mints the same numbers and the gapless series keeps +-- no gap. +UPDATE document_counters +SET next_value = GREATEST(1, COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM credit_memos WHERE number ~ '^CM-[0-9]+$'), 0) + 1) +WHERE series = 'credit_memo'; ALTER TABLE pos_returns DROP COLUMN IF EXISTS credit_memo_id; ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS sale_line_id; +DELETE FROM pos_return_lines WHERE product_id IS NULL; +ALTER TABLE pos_return_lines ALTER COLUMN product_id SET NOT NULL; ALTER TABLE pos_return_lines DROP COLUMN IF EXISTS taxable; ALTER TABLE pos_return_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 2); diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index 43f1b32b8..3b3857720 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -264,8 +264,8 @@ export class POSTerminal extends LitElement { this._completed = completed; const change = completed.change_cents || 0; this._success = change > 0 - ? `Sale ${completed.number} complete \u2014 ${fmt(completed.total_cents)} \u00b7 CHANGE DUE ${fmt(change)}` - : `Sale ${completed.number} complete \u2014 ${fmt(completed.total_cents)}`; + ? `Sale ${completed.number} complete - ${fmt(completed.total_cents)} \u00b7 CHANGE DUE ${fmt(change)}` + : `Sale ${completed.number} complete - ${fmt(completed.total_cents)}`; this._pendingTenders = []; this._showTender = false; @@ -292,7 +292,7 @@ export class POSTerminal extends LitElement { const voided = await posService.voidSale(sale.id, sale.revision, reason); this._sale = voided; this._completed = null; - this._success = `Sale ${voided.number} voided \u2014 the ledger and the stock are whole again`; + this._success = `Sale ${voided.number} voided - the ledger and the stock are whole again`; } catch (err: unknown) { this._error = err instanceof Error ? err.message : 'Failed to void the sale'; } @@ -334,7 +334,7 @@ export class POSTerminal extends LitElement { lines, }); this._showReturn = false; - this._success = `Return ${ret.number} complete \u2014 ${fmt(ret.total_cents)} refunded ${ret.refund_method}`; + this._success = `Return ${ret.number} complete - ${fmt(ret.total_cents)} refunded ${ret.refund_method}`; this._errorTimer = setTimeout(() => { this._success = null; }, 4000); } catch (err: unknown) { this._error = err instanceof Error ? err.message : 'Failed to take the return'; @@ -382,7 +382,7 @@ export class POSTerminal extends LitElement { const osColor = os === 0 ? '#3fb950' : os < 0 ? '#f85149' : '#d29922'; const osLabel = os === 0 ? 'BALANCED' : os < 0 ? `SHORT ${fmt(Math.abs(os))}` : `OVER ${fmt(os)}`; return this._overlay(html` -

Z-Report \u2014 Till Closed

+

Z-Report - Till Closed

Over / Short
${osLabel}
@@ -413,9 +413,9 @@ export class POSTerminal extends LitElement { const rep = this._tillReport; const methods = Object.keys(this._counts); return this._overlay(html` -

Close Till \u2014 Blind Count

+

Close Till - Blind Count

- Count the drawer and enter actuals by tender. Expected totals stay hidden until you post \u2014 that's the blind count. + Count the drawer and enter actuals by tender. Expected totals stay hidden until you post - that's the blind count. ${rep ? html`
${rep.sale_count} sales rung this session.` : nothing}

${methods.map((m) => html` @@ -438,7 +438,7 @@ export class POSTerminal extends LitElement { const sale = this._completed; const productLines = sale.lines.filter(l => l.line_type === 'product' && l.parent_line_id === null); return this._overlay(html` -

Return \u2014 ${sale.number}

+

Return - ${sale.number}

Name the lines coming back. The return posts a credit memo, restocks what returns, and refunds out of the drawer.

${productLines.map((l) => html`
@@ -607,10 +607,10 @@ export class POSTerminal extends LitElement { ${item.quantity} ${item.uom} - ${item.unit_price_ten_thousandths !== null ? fmtPrice(item.unit_price_ten_thousandths) : '\u2014'} + ${item.unit_price_ten_thousandths !== null ? fmtPrice(item.unit_price_ten_thousandths) : '-'} - ${item.line_total_cents !== null ? fmt(item.line_total_cents) : '\u2014'} + ${item.line_total_cents !== null ? fmt(item.line_total_cents) : '-'} +
+
${st.text}
+ ${c.standing === 'good' || c.standing === 'over_limit' ? html` +
Open ${money(c.open_receivable_cents)}
` : nothing} + + { this._poText = (e.target as HTMLInputElement).value; }} + @change=${() => this._saveAccountRefs()} + class="mt-1 w-full rounded border border-white/10 bg-slate-steel px-2 py-2 font-mono text-sm text-white outline-none" + placeholder="PO number" /> + ${c.jobs.length > 0 ? html` + + ` : nothing} +
`; + } + return html` +
+
+ Customer + Walk-in sale +
+ { this._custQuery = (e.target as HTMLInputElement).value; }} + class="mt-2 w-full rounded border border-white/10 bg-slate-steel px-2 py-2 text-sm text-white outline-none disabled:opacity-40" /> + ${hasLines ? html`
Name the customer before the first line.
` : nothing} + ${this._custResults.length > 0 ? html` +
+ ${this._custResults.map((r) => { + const st = standingOf(r); + return html` + `; + })} +
` : nothing} +
`; + } + /* ---- Sale management ---- */ private async _startNewSale() { @@ -190,6 +346,11 @@ export class POSTerminal extends LitElement { this._error = null; this._success = null; this._pendingTenders = []; + this._customer = null; + this._custQuery = ''; + this._custResults = []; + this._poText = ''; + this._jobId = ''; // The last completed sale stays offerable for a void or a return // until the next completion replaces it. const sale = await posService.startSale(REGISTER_ID); @@ -236,6 +397,13 @@ export class POSTerminal extends LitElement { private _handleTender(method: TenderMethod) { if (!this._sale) return; + if (method === 'account') { + const refusal = this._accountRefusal; + if (refusal) { + this._error = refusal; + return; + } + } this._tenderMethod = method; const remaining = this._sale.total_cents - this._tenderedSoFar; this._tenderAmount = (Math.max(remaining, 0) / 100).toFixed(2); @@ -629,7 +797,8 @@ export class POSTerminal extends LitElement { -
+
+ ${this._renderCustomerPanel()}
Subtotal @@ -662,8 +831,8 @@ export class POSTerminal extends LitElement { ${!this._showTender && this._pendingTenders.length === 0 ? html`
${METHODS.map(m => html` - `)}
@@ -697,7 +866,7 @@ export class POSTerminal extends LitElement {
Tenders taken against $${totalDollars}
${METHODS.map(m => html` - `)} diff --git a/web/apps/desk/src/pages/pos/standing.test.ts b/web/apps/desk/src/pages/pos/standing.test.ts new file mode 100644 index 000000000..68323bebe --- /dev/null +++ b/web/apps/desk/src/pages/pos/standing.test.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +import { describe, it, expect } from 'vitest'; +import { chargeRefusal, money, standingOf } from './standing'; +import type { CounterCustomer } from '../../types/pos'; + +const base: CounterCustomer = { + id: 'c1', + account_number: 'A-100', + name: 'Smith Builders', + po_required: false, + credit_limit_cents: 500000, + open_receivable_cents: 120050, + available_credit_cents: 379950, + standing: 'good', + jobs: [], +}; + +describe('money', () => { + it('writes integer cents with grouping and no float drift', () => { + expect(money(0)).toBe('$0.00'); + expect(money(5)).toBe('$0.05'); + expect(money(123456789)).toBe('$1,234,567.89'); + expect(money(-1999)).toBe('-$19.99'); + }); +}); + +describe('standingOf', () => { + it('states available credit against the limit', () => { + expect(standingOf(base)).toEqual({ tone: 'ok', text: 'Credit available $3,799.50 of $5,000.00' }); + }); + it('says on hold when the account has no credit', () => { + const c = { ...base, credit_limit_cents: 0, available_credit_cents: 0, standing: 'no_credit' as const }; + expect(standingOf(c)).toEqual({ tone: 'stop', text: 'On hold: this account has no credit' }); + }); + it('says by how much the account is over the limit', () => { + const c = { ...base, available_credit_cents: -2500, standing: 'over_limit' as const }; + expect(standingOf(c)).toEqual({ tone: 'stop', text: 'Over the credit limit by $25.00' }); + }); + it('says there is no limit', () => { + const c = { ...base, credit_limit_cents: null, available_credit_cents: null, standing: 'no_limit' as const }; + expect(standingOf(c).text).toBe('No credit limit on this account'); + }); +}); + +describe('chargeRefusal', () => { + it('refuses a walk-in sale', () => { + expect(chargeRefusal(null, 1000, '')).toMatch(/Name a customer/); + }); + it('refuses a held or over limit account', () => { + expect(chargeRefusal({ ...base, standing: 'no_credit' }, 100, '')).toMatch(/On hold/); + expect(chargeRefusal({ ...base, standing: 'over_limit', available_credit_cents: -1 }, 100, '')).toMatch(/Over the credit limit/); + }); + it('refuses a sale larger than the available credit, and says by how much', () => { + expect(chargeRefusal(base, 380000, '')).toBe('This sale is $0.50 over the available credit'); + expect(chargeRefusal(base, 379950, '')).toBeNull(); + }); + it('asks for the purchase order the account requires', () => { + const c = { ...base, po_required: true }; + expect(chargeRefusal(c, 1000, ' ')).toMatch(/purchase order/); + expect(chargeRefusal(c, 1000, 'PO-9')).toBeNull(); + }); + it('offers the charge to an account with no limit', () => { + const c = { ...base, credit_limit_cents: null, available_credit_cents: null, standing: 'no_limit' as const }; + expect(chargeRefusal(c, 99999999, '')).toBeNull(); + }); +}); diff --git a/web/apps/desk/src/pages/pos/standing.ts b/web/apps/desk/src/pages/pos/standing.ts new file mode 100644 index 000000000..395626e4d --- /dev/null +++ b/web/apps/desk/src/pages/pos/standing.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +// The counter's account standing, in words: what the cashier reads beside a +// customer, and why the charge to the account is or is not offered. Pure +// functions of the wire figures (integer cents), so the words are tested +// without the screen. + +import type { CounterCustomer } from '../../types/pos'; + +export type Tone = 'ok' | 'warn' | 'stop'; + +export interface Standing { + tone: Tone; + text: string; +} + +/** Whole dollars and cents from integer cents, no float arithmetic on the money. */ +export function money(cents: number): string { + const sign = cents < 0 ? '-' : ''; + const abs = Math.abs(cents); + const whole = Math.floor(abs / 100).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return `${sign}$${whole}.${(abs % 100).toString().padStart(2, '0')}`; +} + +/** The account's standing as the charge tender will read it. */ +export function standingOf(c: CounterCustomer): Standing { + switch (c.standing) { + case 'no_credit': + return { tone: 'stop', text: 'On hold: this account has no credit' }; + case 'over_limit': + return { + tone: 'stop', + text: `Over the credit limit by ${money(Math.abs(c.available_credit_cents ?? 0))}`, + }; + case 'no_limit': + return { tone: 'ok', text: 'No credit limit on this account' }; + default: + return { + tone: 'ok', + text: `Credit available ${money(c.available_credit_cents ?? 0)} of ${money(c.credit_limit_cents ?? 0)}`, + }; + } +} + +/** + * Why a charge of totalCents to this account is refused at the counter, in + * words, or null when it is offered. The server holds the same rules (409 + * credit_limit, 409 po_required); this only keeps the cashier from ringing a + * charge the account will refuse. + */ +export function chargeRefusal( + customer: CounterCustomer | null, + totalCents: number, + purchaseOrder: string, +): string | null { + if (!customer) return 'Name a customer to charge the sale to an account'; + if (customer.standing === 'no_credit') return 'On hold: this account has no credit'; + if (customer.standing === 'over_limit') return standingOf(customer).text; + if (customer.standing === 'good' && customer.available_credit_cents !== null + && totalCents > customer.available_credit_cents) { + return `This sale is ${money(totalCents - customer.available_credit_cents)} over the available credit`; + } + if (customer.po_required && purchaseOrder.trim() === '') { + return 'This account asks for a purchase order: enter it before charging'; + } + return null; +} diff --git a/web/apps/desk/src/services/POSService.ts b/web/apps/desk/src/services/POSService.ts index 4aaec7144..33ba5f950 100644 --- a/web/apps/desk/src/services/POSService.ts +++ b/web/apps/desk/src/services/POSService.ts @@ -16,6 +16,7 @@ import type { RefundMethod, TillSession, TillReport, + CounterCustomer, } from '../types/pos.ts'; import { fetchWithAuth } from './fetchClient'; import { parseApiError, ifMatch } from './apiError'; @@ -138,6 +139,34 @@ export const posService = { return response.json(); }, + // --- The counter's customer --- + + /** Customers by name or account number, each with the account's standing. */ + async findCustomers(query: string): Promise { + if (query.trim().length < 2) return []; + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/customers?q=${encodeURIComponent(query.trim())}`), + 'Failed to look up customers', + ); + return response.json(); + }, + + /** Names the sale's customer (before the first line), purchase order and job. */ + async attachCustomer( + saleId: string, + input: { customer_id: string | null; customer_po?: string | null; job_id?: string | null }, + ): Promise { + const response = await expectOk( + await fetchWithAuth(`${API_URL}/api/v1/pos/transactions/${saleId}/customer`, { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify(input), + }), + 'Failed to name the customer', + ); + return response.json(); + }, + // --- Returns --- async createReturn(input: { diff --git a/web/apps/desk/src/types/pos.ts b/web/apps/desk/src/types/pos.ts index 6100e00ff..0e2a389b2 100644 --- a/web/apps/desk/src/types/pos.ts +++ b/web/apps/desk/src/types/pos.ts @@ -79,10 +79,33 @@ export interface Sale { invoice_id: string | null; completed_at: string | null; created_at: string; + /** The purchase order and job a charge sale names (null until named). */ + customer_po: string | null; + job_id: string | null; lines: SaleLine[]; tenders: Tender[]; } +export type AccountStanding = 'good' | 'no_limit' | 'no_credit' | 'over_limit'; + +export interface CounterJob { + id: string; + name: string; +} + +/** A customer as the counter's lookup shows it, the account's standing beside it. */ +export interface CounterCustomer { + id: string; + account_number: string; + name: string; + po_required: boolean; + credit_limit_cents: number | null; + open_receivable_cents: number; + available_credit_cents: number | null; + standing: AccountStanding; + jobs: CounterJob[]; +} + export interface SaleSummary { id: string; number: string; diff --git a/web/e2e/pos.spec.ts b/web/e2e/pos.spec.ts index d00928e68..58e2bf952 100644 --- a/web/e2e/pos.spec.ts +++ b/web/e2e/pos.spec.ts @@ -127,3 +127,80 @@ test('the counter: split tender, return and void', async ({ page, request }) => expect(sales.items.some((s) => s.status === 'voided')).toBe(true); expect(sales.items.some((s) => s.status === 'completed')).toBe(true); }); + +/** An account with the credit terms the counter must respect. */ +async function makeAccount(request: APIRequestContext, tag: string, extra: Record) { + const run = Date.now().toString(36).toUpperCase(); + const res = await request.post('/api/v1/customers', { + data: { account_number: `E2E-POS-${tag}-${run}`, name: `E2E Counter ${tag} ${run}`, ...extra }, + }); + expect(res.status(), await res.text()).toBe(201); + return (await res.json()) as { id: string; name: string; account_number: string }; +} + +test('the counter: a charge sale to an account that asks for a purchase order', async ({ page, request }) => { + const account = await makeAccount(request, 'PO', { credit_limit_cents: 5000000, po_required: true }); + const held = await makeAccount(request, 'HOLD', { credit_limit_cents: 0 }); + await signIn(page, 'POS Account E2E'); + const product = await stockedProduct(request); + + await page.goto('/pos'); + await expect(page.getByRole('heading', { name: 'POS Terminal' })).toBeVisible(); + const noTill = page.getByText('NO TILL'); + if (await noTill.isVisible()) { + await page.getByRole('button', { name: 'Open Till' }).click(); + await page.getByPlaceholder('200.00').fill('100.00'); + await page.getByRole('button', { name: 'Open Till' }).last().click(); + } + await expect(page.getByText(/TILL OPEN/)).toBeVisible(); + + // The walk-in sale cannot be charged: the account button is off and says why. + await expect(page.getByTestId('pos-customer')).toContainText('Walk-in sale'); + + // An account on hold shows in words, and a sale is not charged to it. + const search = page.locator('#pos-customer-search'); + await search.fill(held.account_number); + await page.getByTestId('pos-customer-results').getByRole('button', { name: new RegExp(held.account_number) }).click(); + await expect(page.getByTestId('pos-customer-standing')).toHaveText('On hold: this account has no credit'); + await addOne(page, product.sku); + const charge = page.getByRole('button', { name: 'Charge to account' }); + await expect(charge).toBeDisabled(); + await page.screenshot({ path: path.join(SHOTS_DIR, 'pos-account-on-hold.png'), fullPage: true }); + + // A new sale for the account with credit: its standing in words, the PO asked. + await page.getByRole('button', { name: 'New Sale', exact: true }).click(); + await expect(page.getByTestId('pos-customer')).toContainText('Walk-in sale'); + await page.locator('#pos-customer-search').fill(account.name); + await page.getByTestId('pos-customer-results').getByRole('button', { name: new RegExp(account.account_number) }).click(); + await expect(page.getByTestId('pos-customer-name')).toHaveText(account.name); + await expect(page.getByTestId('pos-customer-standing')).toContainText('Credit available $50,000.00 of $50,000.00'); + await addOne(page, product.sku); + await expect(page.getByRole('button', { name: 'Charge to account' })).toBeDisabled(); + await page.getByLabel(/Purchase order/).fill('PO-E2E-4471'); + await page.getByLabel(/Purchase order/).blur(); + await expect(page.getByRole('button', { name: 'Charge to account' })).toBeEnabled(); + await page.screenshot({ path: path.join(SHOTS_DIR, 'pos-account-charge.png'), fullPage: true }); + + await page.getByRole('button', { name: 'Charge to account' }).click(); + await page.getByRole('button', { name: /Add Tender/ }).click(); + await page.getByRole('button', { name: /Complete Sale/ }).click(); + await expect(page.getByText(/POS-\d+ complete/)).toBeVisible({ timeout: 15000 }); + await page.screenshot({ path: path.join(SHOTS_DIR, 'pos-account-charged.png'), fullPage: true }); + + // The books: the sale is the account's, the invoice is open on it with the + // purchase order, and the account's balance carries the charge. + const sales = (await (await request.get('/api/v1/pos/transactions?register_id=REG-01')).json()) as { + items: { id: string; customer_id: string | null; status: string; invoice_id: string | null; total_cents: number }[]; + }; + const sale = sales.items.find((s) => s.customer_id === account.id); + expect(sale, 'the charge sale is listed for the account').toBeTruthy(); + expect(sale!.status).toBe('completed'); + const full = (await (await request.get(`/api/v1/pos/transactions/${sale!.id}`)).json()) as { + customer_po: string | null; + tenders: { method: string; amount_cents: number }[]; + }; + expect(full.customer_po).toBe('PO-E2E-4471'); + expect(full.tenders.map((t) => t.method)).toEqual(['account']); + const summary = (await (await request.get(`/api/v1/accounts/${account.id}`)).json()) as { balance_cents: number }; + expect(summary.balance_cents).toBe(sale!.total_cents); +}); From c3417a3b97702968e247b2f10065abc3088f6352 Mon Sep 17 00:00:00 2001 From: colton Date: Tue, 13 Oct 2026 21:14:02 -0700 Subject: [PATCH 108/113] Counter gates: key branch census class, the Z report day span, migration 105 service schema The two new counter routes carry their key branch class decisions. The Z report list takes the requested day as absolute bounds like the sale list. The migration 105 service test applies migration 114 for the columns the sale now reads. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pos/migration105_test.go | 3 +++ core/internal/pos/till_repository.go | 11 ++++++++--- core/pkg/middleware/keybranch_allow.go | 2 ++ docs/refactor/CONTRACT-CHANGES.md | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/core/internal/pos/migration105_test.go b/core/internal/pos/migration105_test.go index d8ca5ac27..ff0edf43e 100644 --- a/core/internal/pos/migration105_test.go +++ b/core/internal/pos/migration105_test.go @@ -470,6 +470,9 @@ func mig105ServiceDB(t *testing.T) *database.DB { t.Fatalf("seed the unlinked legacy half: %v", err) } mig105Apply(t, conn, target) + // the service reads the columns migration 114 added to the sale and the + // invoice; the database stops at 105 for the legacy rows only + mig105Apply(t, conn, "../../migrations/114_pos_sale_account_refs.sql") conn.Close(context.Background()) db, err := database.Connect(url) if err != nil { diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index a83bf421f..2ec8d58ac 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -276,13 +276,18 @@ func (r *PostgresRepository) GetZReportBySession(ctx context.Context, sessionID } func (r *PostgresRepository) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) { + // The day's bounds are absolute instants in the date's own zone: a + // timestamptz never meets a date cast, whose midnight belongs to the + // database session's zone, not the caller's (the sale list's rule). + start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + end := start.AddDate(0, 0, 1) rows, err := r.ex(ctx).Query(ctx, ` SELECT id, till_session_id, register_id, branch_id, ROUND(over_short * 100)::bigint, payload, generated_at FROM till_z_reports WHERE ($1 = '' OR register_id = $1) - AND (generated_at >= $2::date AND generated_at < ($2::date + 1)) - AND `+middleware.Wall("", 3, 4)+` - ORDER BY generated_at DESC`, registerID, date, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + AND (generated_at >= $2 AND generated_at < $3) + AND `+middleware.Wall("", 4, 5)+` + ORDER BY generated_at DESC`, registerID, start, end, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) if err != nil { return nil, fmt.Errorf("failed to list Z-reports: %w", err) } diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index c5ecbc93a..c51e3b440 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -256,6 +256,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "GET /api/v1/payment-terms": {KeyBranchClassStatedLimit, "the dealer wide payment terms vocabulary"}, "GET /api/v1/payment-terms/{id}": {KeyBranchClassStatedLimit, "the dealer wide payment terms vocabulary"}, "GET /api/v1/pos/catalog": {KeyBranchClassStatedLimit, "the dealer wide product catalogue"}, + "GET /api/v1/pos/customers": {KeyBranchClassWalled, "customer_branches (the customer wall the customer list carries, held to the caller's branch context or grants); the counter's lookup reads only what that wall reaches"}, "GET /api/v1/pos/products/search": {KeyBranchClassStatedLimit, "the dealer wide product catalogue"}, "GET /api/v1/price_levels": {KeyBranchClassStatedLimit, "the dealer wide price level vocabulary"}, "GET /api/v1/pricing/categories": {KeyBranchClassStatedLimit, "product category reference data, no branch fact"}, @@ -427,6 +428,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "PUT /api/v1/quotes/{id}": {KeyBranchClassWalled, "quotes.branch_id"}, "PUT /api/v1/quotes/{id}/file": {KeyBranchClassWalled, "quotes.branch_id"}, "PUT /api/v1/ship-tos/{id}": {KeyBranchClassWalled, "customer_ship_tos.customer_id to customer_branches"}, + "PUT /api/v1/pos/transactions/{id}/customer": {KeyBranchClassWalled, "pos_transactions.branch_id (the sale is held to the caller's branch before any write: 404 for a hidden sale, 403 by name with no branch in context and no grant) and the named customer through the customer branch wall (a hidden customer reads as missing); no machine key cashier"}, } // KeyBranchRouteClassFor returns the class the table gives a method and diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 6ad105604..72d292ee3 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -386,3 +386,4 @@ get an entry. | SEC-NOHEADER-1 | the customer account surface and the customer wall for a customer with no `customer_branches` row (the demo seed wrote customers that way), data change: migration 113 and the seed | The account wall and the reconciliation held the every branch arm inside the row test, so such a customer answered 404 on `GET /accounts/{id}`, `/accounts/{id}/transactions` and `GET /ar/customers/{id}/statement` to an owner, an administrator, the dev caller and every caller under the single branch switch, and its balance dropped out of the owner's `balance_sum_cents`; the customer wall hid it from every header and grants caller | The every branch arm sits outside the row test on the account wall and the reconciliation, so those callers read it again (a user held to grants still meets the fail closed 404 until it has a row). The seed writes the `customer_branches` row for each customer's stored primary branch and a re-seed never moves a stored primary branch. Migration 113 inserts the row for a customer's own primary branch where it has none: idempotent, reports by NOTICE how many it set and how many customers have no primary branch (left alone), moves no money and deletes nothing; its down file removes nothing, because a backfilled row cannot be told from a real one | | C2-5m119 | pos, the merge of the no header rule (SEC-NOHEADER-1) into the counter: every route the two rows above name, on the counter's sale, till and return handlers | PR 119 wrote the rule against the earlier counter; the counter's rewrite replaced those files, so the rule is carried into the replacement rather than merged text for text | No wire change beyond the SEC-NOHEADER-1 rows: the sale, till session, return and Z report reads and the register lookups carry the branch wall (a context branch, else the caller's grants, else every branch for an administrator or an unbound key); a no header user's act on a sale or till session of a branch outside the grants is refused 403 naming "belongs to a branch this caller may not target" before any lock or write, a hidden or missing record answers the module's 404 for every caller, and a register of another branch is refused 403 by name. The goldens already record those 404 answers, so none is re-recorded | | C2-5c | counter: `GET /api/v1/pos/customers` (new), `PUT /api/v1/pos/transactions/{id}/customer` (new), the sale document (`customer_po`, `job_id`, both null when unnamed), `POST /api/v1/pos/transactions/{id}/complete` (a new 409 blocker `po_required`), migration 114 | The counter had no way to name a customer on a sale, show the account's standing, or carry the purchase order and job an account asks for, so a charge sale could be rung only by starting the sale with a customer id the screen had no way to find | The lookup answers a bare array of at most 10 customers (name or account number, the caller's branch wall held) with the credit limit, the open receivable the credit check reads, the available credit and a standing word (`good`, `no_limit`, `no_credit`, `over_limit`), whether a purchase order is asked for and the open jobs. The customer route names the customer, purchase order and job on an open sale (the customer before the first line, 409 after; a job must be the customer's own, 400 otherwise; a customer the wall hides is 404) and moves the revision. A charge to the account of a customer with `po_required` is refused 409 `po_required` before any card is charged when the sale names no purchase order. The invoice a charge sale becomes carries the purchase order (`invoices.customer_po`) and the job as its project. Additive: the two sale fields are null on every earlier sale, and the `pos` and `pos_transactions` goldens gain exactly them | +| C2-5d | pos: `GET /api/v1/pos/zreports` (behaviour, no route change) | The day filter cast the date inside the database session, so a Z report generated late in the evening west of the database zone, or early east of it, fell on the other day and the list answered empty | The day is the absolute span of the requested date in the server's zone, the rule the sale list already follows (`GET /pos/transactions`); no golden changes | From 0dde00b7b64b3901aa3e4df2726821144ec1acba Mon Sep 17 00:00:00 2001 From: colton Date: Tue, 13 Oct 2026 21:34:35 -0700 Subject: [PATCH 109/113] Key branch pin legs for the counter's customer routes The lookup and the customer naming each carry their bound key twin leg: the foreign customer never appears in the lookup, a foreign sale reads as missing and takes nothing, the pin's own answers. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../app/serve/wire_key_branch_pin_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/core/internal/app/serve/wire_key_branch_pin_test.go b/core/internal/app/serve/wire_key_branch_pin_test.go index 856b64bc3..e2c54b2c3 100644 --- a/core/internal/app/serve/wire_key_branch_pin_test.go +++ b/core/internal/app/serve/wire_key_branch_pin_test.go @@ -20,6 +20,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -1690,6 +1691,40 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { t.Errorf("bound key line insert on the own transaction: %d rows then %d, want one new line", before, after) } + // The counter's customer: the lookup finds the pin's own customer and + // never the foreign one, and naming a customer on the foreign sale reads + // as a missing sale (nothing written) while the pin's own sale takes it. + var acctA, acctB string + if err := f.db.Pool.QueryRow(context.Background(), `SELECT account_number FROM customers WHERE id = $1`, f.custID).Scan(&acctA); err != nil { + t.Fatal(err) + } + if err := f.db.Pool.QueryRow(context.Background(), `SELECT account_number FROM customers WHERE id = $1`, f.custB).Scan(&acctB); err != nil { + t.Fatal(err) + } + _, buf = f.key(t, f.bound, "GET", "/api/v1/pos/customers?q="+url.QueryEscape(acctB), "", nil) + f.twinLeg("GET", "/api/v1/pos/customers") + if strings.Contains(string(buf), f.custB.String()) { + t.Errorf("bound key counter lookup carries the foreign customer: %s", buf) + } + buf = f.allow(t, "bound", "GET", "/api/v1/pos/customers?q="+url.QueryEscape(acctA), "", nil, http.StatusOK) + if !strings.Contains(string(buf), f.custID.String()) { + t.Errorf("bound key counter lookup does not carry the pin's own customer: %s", buf) + } + if sB, _ := f.key(t, f.bound, "PUT", "/api/v1/pos/transactions/"+f.txnB.String()+"/customer", + fmt.Sprintf(`{"customer_id":%q}`, f.custID.String()), nil); sB != http.StatusNotFound { + t.Errorf("bound key naming a customer on the foreign sale: %d, want 404", sB) + } + f.twinLeg("PUT", "/api/v1/pos/transactions/"+f.txnB.String()+"/customer") + var named *uuid.UUID + if err := f.db.Pool.QueryRow(context.Background(), `SELECT customer_id FROM pos_transactions WHERE id = $1`, f.txnB).Scan(&named); err != nil { + t.Fatal(err) + } + if named != nil && *named == f.custID { + t.Errorf("bound key named the pin's customer on the foreign sale") + } + f.allow(t, "bound", "PUT", "/api/v1/pos/transactions/"+f.txnA.String()+"/customer", + `{"customer_id":null}`, nil, http.StatusOK) + // The account routes name a customer in the path: the foreign customer is // refused with the audit row, the pin's own answers, and the // reconciliation (every branch's books) is refused outright. From 4fbbfadb0483b476f6955a032ccc7dd292b072ea Mon Sep 17 00:00:00 2001 From: colton Date: Tue, 13 Oct 2026 22:59:47 -0700 Subject: [PATCH 110/113] Counter names a customer through one rule; a zero limit holds the account The create, the offline sync and the completion name a customer through the same customer wall the lookup and the attach use (r19 P1-1), a limit of zero refuses every account tender (r20 P2-1), and the cheap P3s of r19 and r20: PO recheck under the lock, the lookup query as text, the Z list day in the server's zone, a comment on CustomerFacts. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../app/serve/wire_noheader_customer_test.go | 95 +++++++++ core/internal/pos/complete.go | 34 +++ core/internal/pos/customer_lookup.go | 29 ++- core/internal/pos/customer_named_wall_test.go | 198 ++++++++++++++++++ core/internal/pos/handler.go | 2 +- core/internal/pos/repository.go | 7 +- core/internal/pos/service.go | 2 +- core/internal/pos/sync.go | 2 +- core/internal/pos/tx_test.go | 2 +- core/pkg/middleware/keybranch_allow.go | 2 +- docs/refactor/CONTRACT-CHANGES.md | 1 + 11 files changed, 360 insertions(+), 14 deletions(-) create mode 100644 core/internal/app/serve/wire_noheader_customer_test.go create mode 100644 core/internal/pos/customer_named_wall_test.go diff --git a/core/internal/app/serve/wire_noheader_customer_test.go b/core/internal/app/serve/wire_noheader_customer_test.go new file mode 100644 index 000000000..c2cc83d23 --- /dev/null +++ b/core/internal/app/serve/wire_noheader_customer_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package serve + +// The counter's customer through the wire (round 21 of the counter's pull +// request): a customer is named at the create and in the offline sync by the +// same rule the lookup and the attach follow, the customer branch wall. A +// cashier granted only A, with no header, who names B's customer is answered +// like a missing customer and nothing is written; a branch bound key keeps its +// pin on both routes. + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestNoHeader_CounterNamesACustomerThroughTheWall(t *testing.T) { + f := newKeyPinFixture(t) + noheaderGrant(t, f, "pin-cashier", f.branchA) + noheaderTaxRate(t, f, f.branchA) + noheaderStock(t, f) + regNA := noheaderRegister(t, f, f.branchA, f.yardA) + + named := func(customer uuid.UUID) int { + return noheaderCount(t, f, `SELECT COUNT(*) FROM pos_transactions WHERE customer_id = $1`, customer) + } + foreignBefore := named(f.custB) + status, buf := f.cashierNoHeader(t, "POST", "/api/v1/pos/transactions", + fmt.Sprintf(`{"register_id":%q,"customer_id":%q}`, regNA, f.custB.String())) + if status != http.StatusNotFound { + t.Errorf("no header create naming B's customer: %d %s, want 404", status, buf) + } + if after := named(f.custB); after != foreignBefore { + t.Errorf("the refused create wrote %d sales naming B's customer", after-foreignBefore) + } + status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/transactions", + fmt.Sprintf(`{"register_id":%q,"customer_id":%q}`, regNA, f.custID.String())) + if status != http.StatusCreated { + t.Errorf("no header create naming A's customer: %d %s, want 201", status, buf) + } + + // The offline sync: the item naming B's customer is an error, written + // nowhere; the item naming A's customer syncs. + sync := func(customer uuid.UUID) (string, string) { + client := uuid.NewString() + return client, fmt.Sprintf(`{"batch_id":"cw-%s","register_id":%q,"items":[{"client_id":%q,"cashier_id":%q,`+ + `"customer_id":%q,"items":[{"product_id":%q,"quantity":"1"}],`+ + `"tenders":[{"method":"account","amount_cents":105}],"client_created_at":%q}]}`, + uuid.NewString()[:8], regNA, client, uuid.NewString(), customer.String(), + f.posProd.String(), time.Now().UTC().Format(time.RFC3339)) + } + invoicesBefore := noheaderCount(t, f, `SELECT COUNT(*) FROM invoices WHERE customer_id = $1`, f.custB) + client, req := sync(f.custB) + status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/sync", req) + if status != http.StatusOK && status != http.StatusForbidden { + t.Errorf("no header sync naming B's customer: %d %s", status, buf) + } + if strings.Contains(string(buf), `"synced_count":1`) { + t.Errorf("no header sync of B's customer synced: %s", buf) + } + if n := noheaderCount(t, f, `SELECT COUNT(*) FROM pos_transactions WHERE id = $1`, client); n != 0 { + t.Errorf("the refused sync item wrote its sale") + } + if after := noheaderCount(t, f, `SELECT COUNT(*) FROM invoices WHERE customer_id = $1`, f.custB); after != invoicesBefore { + t.Errorf("the refused sync item posted %d invoices to B's customer", after-invoicesBefore) + } + _, req = sync(f.custID) + status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/sync", req) + if status != http.StatusOK || !strings.Contains(string(buf), `"synced_count":1`) { + t.Errorf("no header sync of A's customer: %d %s, want the sale synced", status, buf) + } + + // The bound key keeps its pin: a machine key is no cashier, and the + // foreign customer is held at the wall besides; nothing is written. + foreignBefore = named(f.custB) + keyStatus, keyBuf := f.key(t, f.bound, "POST", "/api/v1/pos/transactions", + fmt.Sprintf(`{"register_id":%q,"customer_id":%q}`, regNA, f.custB.String()), nil) + if keyStatus != http.StatusForbidden { + t.Errorf("bound key create naming B's customer: %d %s, want 403", keyStatus, keyBuf) + } + if after := named(f.custB); after != foreignBefore { + t.Errorf("the bound key's create wrote %d sales naming B's customer", after-foreignBefore) + } + _, req = sync(f.custB) + keyStatus, keyBuf = f.key(t, f.bound, "POST", "/api/v1/pos/sync", req, nil) + if keyStatus != http.StatusForbidden { + t.Errorf("bound key sync naming B's customer: %d %s, want 403", keyStatus, keyBuf) + } +} diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go index 689c74532..0dfd856c1 100644 --- a/core/internal/pos/complete.go +++ b/core/internal/pos/complete.go @@ -133,6 +133,13 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st customerID = pre.CustomerID } realCustomerID := *customerID + // a sale named before a grant change is held again: the walk-in + // customer backs a sale that names none and is not the caller's to name + if pre.CustomerID != nil { + if _, _, err := s.namedCustomer(ctx, realCustomerID); err != nil { + return nil, err + } + } if len(preLines) == 0 { return nil, invalid("lines", "the sale has no lines to complete") } @@ -273,6 +280,18 @@ func (s *Service) CompleteSale(ctx context.Context, saleID uuid.UUID, ifMatch st return out, nil } +// refuseHeldAccount refuses every account charge to a customer whose credit +// limit is zero ("no credit", the account on hold), whatever unapplied cash +// the account holds: the standing the counter shows is the rule the server +// keeps. +func refuseHeldAccount(facts CustomerFacts) error { + if facts.CreditLimitCents != nil && *facts.CreditLimitCents <= 0 { + return conflict("credit_hold", + "this account is on hold: it has no credit, so nothing can be charged to it") + } + return nil +} + // checkAccountCredit refuses an ACCOUNT portion that would take the customer // over its credit limit (ADR 0005 14.2 C2-5). Caller-side lock-free read; the // authoritative check runs inside the transaction under the customer's @@ -282,6 +301,9 @@ func (s *Service) checkAccountCredit(ctx context.Context, customerID uuid.UUID, if err != nil { return err } + if err := refuseHeldAccount(facts); err != nil { + return err + } if facts.CreditLimitCents != nil { open, err := s.repo.OpenReceivableCents(ctx, customerID) if err != nil { @@ -327,6 +349,11 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch } sale.WalkInID = pre.WalkInID customerID := realCustomerID + if sale.CustomerID != nil { + if _, _, err := s.namedCustomer(ctx, *sale.CustomerID); err != nil { + return err + } + } if err := httpx.CheckRevision(sale.Revision, ifMatch, bodyRevision); err != nil { return err } @@ -427,6 +454,13 @@ func (s *Service) completeSaleTx(ctx context.Context, saleID uuid.UUID, ifMatch if err != nil { return err } + if err := refuseHeldAccount(facts); err != nil { + return err + } + if facts.PORequired && (sale.CustomerPO == nil || strings.TrimSpace(*sale.CustomerPO) == "") { + return conflict("po_required", + "the customer requires a purchase order number: name it on the sale before charging it to the account") + } if facts.CreditLimitCents != nil { open, err := s.repo.OpenReceivableCents(ctx, customerID) if err != nil { diff --git a/core/internal/pos/customer_lookup.go b/core/internal/pos/customer_lookup.go index 410771a1e..6faf1e367 100644 --- a/core/internal/pos/customer_lookup.go +++ b/core/internal/pos/customer_lookup.go @@ -75,6 +75,26 @@ type AttachCustomerIn struct { JobID *uuid.UUID } +// namedCustomer is the counter's one rule for which customers a caller may +// name: the customer wall (the same predicate the lookup filters by), a +// hidden or inactive customer answered with the module's 404 and nothing +// written. The attach, the create, the offline sync and the completion all +// name a customer through it, so a change of rule is a change here only. +func (s *Service) namedCustomer(ctx context.Context, id uuid.UUID) (*CounterCustomer, CustomerFacts, error) { + c, found, err := s.repo.CounterCustomerByID(ctx, id) + if err != nil { + return nil, CustomerFacts{}, err + } + if !found { + return nil, CustomerFacts{}, httpx.NotFound("customer not found") + } + facts, err := s.repo.CustomerFacts(ctx, id) + if err != nil { + return nil, CustomerFacts{}, err + } + return c, facts, nil +} + // AttachCustomer names the sale's customer, purchase order and job. The // customer is named before the first line, because the lines price for the // customer (price level, tax exemption, currency); the purchase order and @@ -91,13 +111,10 @@ func (s *Service) AttachCustomer(ctx context.Context, saleID uuid.UUID, in Attac } currency := "" if in.CustomerID != nil { - c, found, err := s.repo.CounterCustomerByID(ctx, *in.CustomerID) + c, facts, err := s.namedCustomer(ctx, *in.CustomerID) if err != nil { return nil, err } - if !found { - return nil, httpx.NotFound("customer not found") - } if in.JobID != nil { ok := false for _, j := range c.Jobs { @@ -109,10 +126,6 @@ func (s *Service) AttachCustomer(ctx context.Context, saleID uuid.UUID, in Attac return nil, invalid("job_id", "is not an open job of this customer") } } - facts, err := s.repo.CustomerFacts(ctx, *in.CustomerID) - if err != nil { - return nil, err - } currency = facts.Currency } else { _, def, err := s.repo.WalkInCustomer(ctx) diff --git a/core/internal/pos/customer_named_wall_test.go b/core/internal/pos/customer_named_wall_test.go new file mode 100644 index 000000000..4233edb6d --- /dev/null +++ b/core/internal/pos/customer_named_wall_test.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The customer a caller may name at the counter has one rule (round 21): the +// lookup, the attach, the create, the offline sync and the completion all +// hold the named customer to the same customer wall. A user granted only the +// sale's branch, sending no X-Branch-Id header, who names a customer only +// another branch trades with is answered like a missing customer, and +// nothing is written; an account tender never posts receivable against it. + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +type namedWall struct { + f *fixture + headers []string + hidden uuid.UUID // trades only at branch B + reachable uuid.UUID // trades at the sale's branch A +} + +func newNamedWall(t *testing.T) *namedWall { + t.Helper() + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + // the kill switch is read when the fixture's middleware is built + setPosSetting(t, db, "multi_branch_enabled", "true") + setPosSetting(t, db, "default_branch_required", "false") + branchB := uuid.New() + exec := func(sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + exec(`INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', 'WL-POS-N', 'named wall B')`, branchB) + w := &namedWall{hidden: uuid.New()} + f := newFixture(t, db) + w.f = f + w.reachable = f.customerID + exec(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-cw-a', $1, TRUE, 'test')`, f.branchID) + exec(`INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'Hidden Co', $2, $3)`, + w.hidden, "HID-"+uuid.NewString()[:8], branchB) + exec(`INSERT INTO customer_branches (customer_id, branch_id) VALUES ($1, $2), ($3, $4)`, w.hidden, branchB, w.reachable, f.branchID) + t.Cleanup(func() { + setPosSetting(t, db, "multi_branch_enabled", "false") + setPosSetting(t, db, "default_branch_required", "true") + exec(`DELETE FROM pos_transactions WHERE customer_id = $1`, w.hidden) + exec(`DELETE FROM invoices WHERE customer_id = $1`, w.hidden) + exec(`DELETE FROM customer_branches WHERE customer_id IN ($1, $2)`, w.hidden, w.reachable) + exec(`DELETE FROM customers WHERE id = $1`, w.hidden) + exec(`DELETE FROM user_locations WHERE user_sub = 'pos-cw-a'`) + exec(`DELETE FROM locations WHERE id = $1`, branchB) + }) + w.headers = []string{"X-Test-Role", "cashier", "X-Test-Sub", "pos-cw-a"} + return w +} + +func (w *namedWall) salesNaming(id uuid.UUID) int64 { + return countOf(w.f.t, w.f.db, `SELECT count(*) FROM pos_transactions WHERE customer_id = $1`, id) +} + +// RULE: the create names a customer through the same wall the attach uses. +func TestTheCreateHoldsTheNamedCustomerToTheCustomerWall(t *testing.T) { + w := newNamedWall(t) + f := w.f + body := func(c uuid.UUID) map[string]any { + return map[string]any{"register_id": f.register, "customer_id": c.String()} + } + // the attach and the lookup already hide it; the create must too + if r := f.do("GET", "/api/v1/pos/customers?q=Hidden", nil, w.headers...); strings.Contains(string(r.raw), w.hidden.String()) { + t.Fatalf("the lookup carries the hidden customer: %s", r.raw) + } + r := f.do("POST", "/api/v1/pos/transactions", body(w.hidden), w.headers...) + if r.status != http.StatusNotFound { + t.Fatalf("create naming a customer outside the grants = %d, want 404: %s", r.status, r.raw) + } + if n := w.salesNaming(w.hidden); n != 0 { + t.Errorf("the refused create wrote %d sales naming the hidden customer", n) + } + // the reachable customer is named at create + if r := f.do("POST", "/api/v1/pos/transactions", body(w.reachable), w.headers...); r.status != http.StatusCreated { + t.Errorf("create naming a customer inside the grants = %d, want 201: %s", r.status, r.raw) + } +} + +// RULE: the offline sync names a customer through the same wall: the item is +// an error, no sale and no invoice is written. +func TestTheSyncHoldsTheNamedCustomerToTheCustomerWall(t *testing.T) { + w := newNamedWall(t) + f := w.f + sync := func(customer uuid.UUID) (string, string) { + client := uuid.NewString() + return client, fmt.Sprintf(`{"batch_id":"cw-%s","register_id":%q,"items":[{"client_id":%q,"cashier_id":%q,`+ + `"customer_id":%q,"items":[{"product_id":%q,"quantity":"1"}],`+ + `"tenders":[{"method":"account","amount_cents":599}],"client_created_at":%q}]}`, + uuid.NewString()[:8], f.register, client, uuid.NewString(), customer.String(), + f.productID.String(), time.Now().UTC().Format(time.RFC3339)) + } + client, req := sync(w.hidden) + r := f.do("POST", "/api/v1/pos/sync", req, w.headers...) + if r.status != http.StatusOK || !strings.Contains(string(r.raw), `"synced_count":0`) || !strings.Contains(string(r.raw), `"error_count":1`) { + t.Fatalf("sync naming a customer outside the grants = %d: %s, want the item an error", r.status, r.raw) + } + if n := countOf(t, f.db, `SELECT count(*) FROM pos_transactions WHERE id = $1`, client); n != 0 { + t.Errorf("the refused sync item wrote its sale") + } + if n := countOf(t, f.db, `SELECT count(*) FROM invoices WHERE customer_id = $1`, w.hidden); n != 0 { + t.Errorf("the refused sync item posted %d invoices to the hidden customer", n) + } + _, req = sync(w.reachable) + if r := f.do("POST", "/api/v1/pos/sync", req, w.headers...); !strings.Contains(string(r.raw), `"synced_count":1`) { + t.Errorf("sync naming a customer inside the grants = %d: %s, want the sale synced", r.status, r.raw) + } +} + +// RULE: a sale named before a grant change is held again at the completion: +// the customer leaves the cashier's reach, the charge answers 404 and the +// sale stays open with nothing posted. +func TestTheCompletionHoldsTheNamedCustomerAgain(t *testing.T) { + w := newNamedWall(t) + f := w.f + r := f.do("POST", "/api/v1/pos/transactions", map[string]any{"register_id": f.register, "customer_id": w.reachable.String()}, w.headers...) + if r.status != http.StatusCreated { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + saleID := str(t, r.body, "id") + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/items", map[string]any{"line": f.productLine("1")}, w.headers...); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + cur := f.getSale(t, saleID) + total := num(t, cur, "total_cents") + // the customer's only yard is now another branch + mustExec(t, f.db, `UPDATE customer_branches SET branch_id = (SELECT branch_id FROM customer_branches WHERE customer_id = $1) + WHERE customer_id = $2`, w.hidden, w.reachable) + before := f.counterInvoices(t) + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + map[string]any{"tenders": []map[string]any{tender("account", total)}, "revision": cur["revision"]}, w.headers...) + if r.status != http.StatusNotFound { + t.Errorf("complete naming a customer that left the grants = %d, want 404: %s", r.status, r.raw) + } + if got := f.counterInvoices(t); got != before { + t.Errorf("the refused completion posted %d invoices", got-before) + } + if st := countOf(t, f.db, `SELECT count(*) FROM pos_transactions WHERE id = $1 AND status = 'OPEN'`, saleID); st != 1 { + t.Errorf("the sale is no longer open after the refused completion") + } +} + +// RULE: a limit of zero means no account charges at all, whatever unapplied +// cash the account holds; the refusal is 409 credit_hold, before any card. +func TestAZeroLimitRefusesEveryAccountTenderEvenWithUnappliedCash(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + mustExec(t, f.db, `INSERT INTO payments (id, customer_id, branch_id, currency, amount, method, status, received_on, amount_unapplied) + VALUES ($1, $2, $3, 'USD', 10, 'CHECK', 'POSTED', CURRENT_DATE, 10)`, uuid.New(), f.customerID, f.branchID) + mustExec(t, f.db, `UPDATE customers SET credit_limit = 0 WHERE id = $1`, f.customerID) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, f.productLine("1")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + cur := f.getSale(t, saleID) + before := f.counterInvoices(t) + r := f.completeSale(saleID, tender("account", num(t, cur, "total_cents"))) + if r.status != http.StatusConflict || !strings.Contains(string(r.raw), "credit_hold") { + t.Errorf("an account tender on a zero limit with unapplied cash = %d: %s, want 409 credit_hold", r.status, r.raw) + } + if got := f.counterInvoices(t); got != before { + t.Errorf("the refused charge posted %d invoices", got-before) + } + // a cash tender on the same account is not held + if r := f.completeSale(saleID, f.cashFor("1")); r.status != http.StatusOK { + t.Errorf("cash on a held account = %d, want 200: %s", r.status, r.raw) + } +} + +// RULE: the lookup's query is text to find, never a pattern. +func TestTheLookupTakesItsQueryAsText(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + if r := f.findCustomers("%%"); strings.TrimSpace(string(r.raw)) != "[]" { + t.Errorf("a query of %%%% answered %s, want []", r.raw) + } + if r := f.findCustomers("__"); strings.TrimSpace(string(r.raw)) != "[]" { + t.Errorf("a query of two underscores answered %s, want []", r.raw) + } +} diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index 66a2afb4b..d6a247079 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -623,7 +623,7 @@ func (h *Handler) ListZReports(w http.ResponseWriter, r *http.Request) { } date := time.Now() if d := q.Get("date"); d != "" { - parsed, err := time.Parse("2006-01-02", d) + parsed, err := time.ParseInLocation("2006-01-02", d, time.Local) if err != nil { httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "date", Message: "must be a date as YYYY-MM-DD"})) return diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index b6715050d..0a0231833 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/gablelbm/gable/internal/customer" @@ -189,7 +190,8 @@ type Repository interface { } // CustomerFacts is what the counter reads about the sale's customer: the -// effective currency and the credit limit the ACCOUNT tender checks. +// effective currency and the credit limit the ACCOUNT tender checks. The read +// is unwalled: a caller applies the customer wall first (Service.namedCustomer). type CustomerFacts struct { CreditLimitCents *int64 Name string @@ -1702,6 +1704,9 @@ const counterCustomerCols = `c.id, c.account_number, c.name, COALESCE(c.po_requi CASE WHEN c.credit_limit IS NULL THEN NULL ELSE ROUND(c.credit_limit * 100)::bigint END` func (r *PostgresRepository) FindCounterCustomers(ctx context.Context, q string, limit int) ([]CounterCustomer, error) { + // the query is text to find, never a pattern: %, _ and the escape itself + // match only themselves + q = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(q) rows, err := r.ex(ctx).Query(ctx, `SELECT `+counterCustomerCols+` FROM customers c WHERE COALESCE(c.is_active, TRUE) AND c.account_number <> 'WALK-IN' AND (c.name ILIKE '%' || $1 || '%' OR c.account_number ILIKE '%' || $1 || '%') diff --git a/core/internal/pos/service.go b/core/internal/pos/service.go index 2ab92c2e8..9c4d1ee3d 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -270,7 +270,7 @@ func (s *Service) StartSale(ctx context.Context, in *StartSale, actor string) (* } currency := defaultCurrency if in.CustomerID != nil { - facts, err := s.repo.CustomerFacts(ctx, *in.CustomerID) + _, facts, err := s.namedCustomer(ctx, *in.CustomerID) if err != nil { return nil, err } diff --git a/core/internal/pos/sync.go b/core/internal/pos/sync.go index 61a0172f8..e94dedab5 100644 --- a/core/internal/pos/sync.go +++ b/core/internal/pos/sync.go @@ -191,7 +191,7 @@ func (s *Service) StartSaleAt(ctx context.Context, item *OfflineSale, register, } currency := defaultCurrency if in.CustomerID != nil { - facts, err := s.repo.CustomerFacts(ctx, *in.CustomerID) + _, facts, err := s.namedCustomer(ctx, *in.CustomerID) if err != nil { return nil, err } diff --git a/core/internal/pos/tx_test.go b/core/internal/pos/tx_test.go index 059abf608..3c1b69114 100644 --- a/core/internal/pos/tx_test.go +++ b/core/internal/pos/tx_test.go @@ -52,7 +52,7 @@ func TestFailingEventWriteRollsTheSaleBack(t *testing.T) { {"payments", f.counterPayments(t), paymentsBefore}, {"tenders", countOf(t, f.db, `SELECT count(*) FROM pos_tenders WHERE transaction_id = $1`, saleID), 0}, {"entries", f.counterEntries(t), entriesBefore}, - {"events", countOf(t, f.db, `SELECT count(*) FROM events_outbox WHERE entity_type = 'pos_transaction'`), 0}, + {"events", countOf(t, f.db, `SELECT count(*) FROM events_outbox WHERE entity_type = 'pos_transaction' AND entity_id = $1`, saleID), 0}, } { if c.got != c.want { t.Errorf("%s = %d, want %d", c.what, c.got, c.want) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index c51e3b440..a212435a7 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -404,7 +404,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7, and a batch register that is empty or names no register is refused 400 the same way, rounds 8 and 9), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister, TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister"}, "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, - "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after, and a register of another branch is refused 403 (round 7); no machine key cashier"}, + "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after, and a register of another branch is refused 403 (round 7); the customer it names goes through the customer branch wall (a hidden customer reads as missing, nothing written); no machine key cashier"}, "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, "POST /api/v1/pos/transactions/{id}/items": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before the insert); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions/{id}/void": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index df9ec6270..4e0f6a7d4 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -387,6 +387,7 @@ get an entry. | C2-5m119 | pos, the merge of the no header rule (SEC-NOHEADER-1) into the counter: every route the two rows above name, on the counter's sale, till and return handlers | PR 119 wrote the rule against the earlier counter; the counter's rewrite replaced those files, so the rule is carried into the replacement rather than merged text for text | No wire change beyond the SEC-NOHEADER-1 rows: the sale, till session, return and Z report reads and the register lookups carry the branch wall (a context branch, else the caller's grants, else every branch for an administrator or an unbound key); a no header user's act on a sale or till session of a branch outside the grants is refused 403 naming "belongs to a branch this caller may not target" before any lock or write, a hidden or missing record answers the module's 404 for every caller, and a register of another branch is refused 403 by name. The goldens already record those 404 answers, so none is re-recorded | | C2-5c | counter: `GET /api/v1/pos/customers` (new), `PUT /api/v1/pos/transactions/{id}/customer` (new), the sale document (`customer_po`, `job_id`, both null when unnamed), `POST /api/v1/pos/transactions/{id}/complete` (a new 409 blocker `po_required`), migration 114 | The counter had no way to name a customer on a sale, show the account's standing, or carry the purchase order and job an account asks for, so a charge sale could be rung only by starting the sale with a customer id the screen had no way to find | The lookup answers a bare array of at most 10 customers (name or account number, the caller's branch wall held) with the credit limit, the open receivable the credit check reads, the available credit and a standing word (`good`, `no_limit`, `no_credit`, `over_limit`), whether a purchase order is asked for and the open jobs. The customer route names the customer, purchase order and job on an open sale (the customer before the first line, 409 after; a job must be the customer's own, 400 otherwise; a customer the wall hides is 404) and moves the revision. A charge to the account of a customer with `po_required` is refused 409 `po_required` before any card is charged when the sale names no purchase order. The invoice a charge sale becomes carries the purchase order (`invoices.customer_po`) and the job as its project. Additive: the two sale fields are null on every earlier sale, and the `pos` and `pos_transactions` goldens gain exactly them | | C2-5d | pos: `GET /api/v1/pos/zreports` (behaviour, no route change) | The day filter cast the date inside the database session, so a Z report generated late in the evening west of the database zone, or early east of it, fell on the other day and the list answered empty | The day is the absolute span of the requested date in the server's zone, the rule the sale list already follows (`GET /pos/transactions`); no golden changes | +| C2-5r21 | counter: `POST /api/v1/pos/transactions`, `POST /api/v1/pos/sync`, `POST /api/v1/pos/transactions/{id}/complete` (behaviour, no route change) | The create and the offline sync read the customer a sale names with no customer wall, so a cashier held to one yard's grants could name a customer the lookup and the attach hide, and an account tender then posted receivable against it; an account tender was accepted for a customer whose credit limit is 0 when the account held unapplied cash | The create, the sync item and the completion name a customer through the one rule the lookup and the attach follow (the customer branch wall): a hidden or inactive customer answers the module's 404 and nothing is written (a sync item is reported as an error). A credit limit of 0 means no account charges at all: any `account` tender is refused 409 with the blocker `credit_hold`, before any card is charged and again under the credit lock, whatever unapplied cash the account holds. The PO check is repeated against the locked sale row. The lookup treats `%`, `_` and `\` in its query as text; the Z list's `date` is a day in the server's zone like the sale list's | | SEC-NOHEADER-1 | tax: `POST /api/v1/tax/exemptions` and `POST /api/v1/tax/preview`, a body that is not exactly one JSON value, for a user held by the customer wall (review fix, PR 119 r8 P1-1) | The user wall handed a body it could not read as one complete JSON value to the handler, which read only the first value: a trailing value or byte after the object (` {}`, ` 1`, `x`) let a finance user held to A create an exemption on, or preview, a customer of B | The wall answers 400 ("the request body must be exactly one JSON value") and writes nothing, header or not; an administrator or owner with no header is not held by the wall, as before; a branch bound key keeps the key wall's refusal | | DEMO-1 | quote: `POST /api/v1/quotes/{id}/convert` on a quote already accepted | A quote accepted first (on the desk or by the customer) could never convert: the convert repeated the accept and answered 409 `invalid_state_transition` ("cannot transition from accepted to accepted"), so the order could only be made by hand | It converts as it is: 201 with the order, no second transition and no second `quote.accepted` event, and the accept that already happened keeps the auto purchase order it ran; a quote that already has an order not cancelled is still 409 `already_converted` (three concurrent converts of one accepted quote make one order), and one whose order was cancelled converts again, as ADR 0005 5.8 already allowed for any quote | | QUOTE-SPLIT-1 | parsing: `POST /api/v1/parsing/upload` with a text or spreadsheet file (a pasted list, a text or csv file, an xlsx) and no AI reader set | The file was discarded unread and the canned sample list came back, flagged `synthetic: true`, whatever the file held | The rule-based reader parses the caller's own lines through `ExtractItems`, `synthetic: false`; an image or a PDF without an AI reader still returns the canned sample list flagged `synthetic: true`; the wire shape is unchanged | From b4bae7de242c89495dc830be48a7f68f1624dd8d Mon Sep 17 00:00:00 2001 From: colton Date: Tue, 13 Oct 2026 23:12:17 -0700 Subject: [PATCH 111/113] Leave the Z list's date parse as it was The list's date is read in UTC as the existing wire legs send it; the zone question stays open (round 21 left it as a P3). Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- core/internal/pos/handler.go | 2 +- docs/refactor/CONTRACT-CHANGES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index d6a247079..66a2afb4b 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -623,7 +623,7 @@ func (h *Handler) ListZReports(w http.ResponseWriter, r *http.Request) { } date := time.Now() if d := q.Get("date"); d != "" { - parsed, err := time.ParseInLocation("2006-01-02", d, time.Local) + parsed, err := time.Parse("2006-01-02", d) if err != nil { httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "date", Message: "must be a date as YYYY-MM-DD"})) return diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 4e0f6a7d4..dd4e8adbc 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -387,7 +387,7 @@ get an entry. | C2-5m119 | pos, the merge of the no header rule (SEC-NOHEADER-1) into the counter: every route the two rows above name, on the counter's sale, till and return handlers | PR 119 wrote the rule against the earlier counter; the counter's rewrite replaced those files, so the rule is carried into the replacement rather than merged text for text | No wire change beyond the SEC-NOHEADER-1 rows: the sale, till session, return and Z report reads and the register lookups carry the branch wall (a context branch, else the caller's grants, else every branch for an administrator or an unbound key); a no header user's act on a sale or till session of a branch outside the grants is refused 403 naming "belongs to a branch this caller may not target" before any lock or write, a hidden or missing record answers the module's 404 for every caller, and a register of another branch is refused 403 by name. The goldens already record those 404 answers, so none is re-recorded | | C2-5c | counter: `GET /api/v1/pos/customers` (new), `PUT /api/v1/pos/transactions/{id}/customer` (new), the sale document (`customer_po`, `job_id`, both null when unnamed), `POST /api/v1/pos/transactions/{id}/complete` (a new 409 blocker `po_required`), migration 114 | The counter had no way to name a customer on a sale, show the account's standing, or carry the purchase order and job an account asks for, so a charge sale could be rung only by starting the sale with a customer id the screen had no way to find | The lookup answers a bare array of at most 10 customers (name or account number, the caller's branch wall held) with the credit limit, the open receivable the credit check reads, the available credit and a standing word (`good`, `no_limit`, `no_credit`, `over_limit`), whether a purchase order is asked for and the open jobs. The customer route names the customer, purchase order and job on an open sale (the customer before the first line, 409 after; a job must be the customer's own, 400 otherwise; a customer the wall hides is 404) and moves the revision. A charge to the account of a customer with `po_required` is refused 409 `po_required` before any card is charged when the sale names no purchase order. The invoice a charge sale becomes carries the purchase order (`invoices.customer_po`) and the job as its project. Additive: the two sale fields are null on every earlier sale, and the `pos` and `pos_transactions` goldens gain exactly them | | C2-5d | pos: `GET /api/v1/pos/zreports` (behaviour, no route change) | The day filter cast the date inside the database session, so a Z report generated late in the evening west of the database zone, or early east of it, fell on the other day and the list answered empty | The day is the absolute span of the requested date in the server's zone, the rule the sale list already follows (`GET /pos/transactions`); no golden changes | -| C2-5r21 | counter: `POST /api/v1/pos/transactions`, `POST /api/v1/pos/sync`, `POST /api/v1/pos/transactions/{id}/complete` (behaviour, no route change) | The create and the offline sync read the customer a sale names with no customer wall, so a cashier held to one yard's grants could name a customer the lookup and the attach hide, and an account tender then posted receivable against it; an account tender was accepted for a customer whose credit limit is 0 when the account held unapplied cash | The create, the sync item and the completion name a customer through the one rule the lookup and the attach follow (the customer branch wall): a hidden or inactive customer answers the module's 404 and nothing is written (a sync item is reported as an error). A credit limit of 0 means no account charges at all: any `account` tender is refused 409 with the blocker `credit_hold`, before any card is charged and again under the credit lock, whatever unapplied cash the account holds. The PO check is repeated against the locked sale row. The lookup treats `%`, `_` and `\` in its query as text; the Z list's `date` is a day in the server's zone like the sale list's | +| C2-5r21 | counter: `POST /api/v1/pos/transactions`, `POST /api/v1/pos/sync`, `POST /api/v1/pos/transactions/{id}/complete` (behaviour, no route change) | The create and the offline sync read the customer a sale names with no customer wall, so a cashier held to one yard's grants could name a customer the lookup and the attach hide, and an account tender then posted receivable against it; an account tender was accepted for a customer whose credit limit is 0 when the account held unapplied cash | The create, the sync item and the completion name a customer through the one rule the lookup and the attach follow (the customer branch wall): a hidden or inactive customer answers the module's 404 and nothing is written (a sync item is reported as an error). A credit limit of 0 means no account charges at all: any `account` tender is refused 409 with the blocker `credit_hold`, before any card is charged and again under the credit lock, whatever unapplied cash the account holds. The PO check is repeated against the locked sale row. The lookup treats `%`, `_` and `\` in its query as text | | SEC-NOHEADER-1 | tax: `POST /api/v1/tax/exemptions` and `POST /api/v1/tax/preview`, a body that is not exactly one JSON value, for a user held by the customer wall (review fix, PR 119 r8 P1-1) | The user wall handed a body it could not read as one complete JSON value to the handler, which read only the first value: a trailing value or byte after the object (` {}`, ` 1`, `x`) let a finance user held to A create an exemption on, or preview, a customer of B | The wall answers 400 ("the request body must be exactly one JSON value") and writes nothing, header or not; an administrator or owner with no header is not held by the wall, as before; a branch bound key keeps the key wall's refusal | | DEMO-1 | quote: `POST /api/v1/quotes/{id}/convert` on a quote already accepted | A quote accepted first (on the desk or by the customer) could never convert: the convert repeated the accept and answered 409 `invalid_state_transition` ("cannot transition from accepted to accepted"), so the order could only be made by hand | It converts as it is: 201 with the order, no second transition and no second `quote.accepted` event, and the accept that already happened keeps the auto purchase order it ran; a quote that already has an order not cancelled is still 409 `already_converted` (three concurrent converts of one accepted quote make one order), and one whose order was cancelled converts again, as ADR 0005 5.8 already allowed for any quote | | QUOTE-SPLIT-1 | parsing: `POST /api/v1/parsing/upload` with a text or spreadsheet file (a pasted list, a text or csv file, an xlsx) and no AI reader set | The file was discarded unread and the canned sample list came back, flagged `synthetic: true`, whatever the file held | The rule-based reader parses the caller's own lines through `ExtractItems`, `synthetic: false`; an image or a PDF without an AI reader still returns the canned sample list flagged `synthetic: true`; the wire shape is unchanged | From 7fe1ac7df1c0359526495f129f7ad8e6b02b7385 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 00:11:23 -0700 Subject: [PATCH 112/113] Return holds its customer to the counter's one rule and credits the sale's own A return naming a customer passes the customer wall before any write (404, nothing written); a return against a sale credits that sale's customer, a differing customer_id is refused 400 and a walk-in sale cannot be refunded to an account. Adds the card side and PO recheck tests, and the Z list row. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: colton --- .../app/serve/wire_noheader_customer_test.go | 24 ++++++ core/internal/pos/customer_lookup_test.go | 65 ++++++++++++++++ core/internal/pos/customer_named_wall_test.go | 76 +++++++++++++++++++ core/internal/pos/gateway_order_test.go | 2 +- core/internal/pos/refund_method_wall_test.go | 2 + core/internal/pos/resolve.go | 2 +- core/internal/pos/returns.go | 38 ++++++++-- core/internal/pos/returns_bounds_test.go | 8 +- core/internal/pos/returns_legacy_test.go | 2 +- core/internal/pos/tx_test.go | 2 +- core/internal/pos/walkin_return_test.go | 56 +++++++++++--- core/internal/pos/wire_test.go | 8 +- core/pkg/middleware/keybranch_allow.go | 4 +- docs/refactor/CONTRACT-CHANGES.md | 4 +- 14 files changed, 262 insertions(+), 31 deletions(-) diff --git a/core/internal/app/serve/wire_noheader_customer_test.go b/core/internal/app/serve/wire_noheader_customer_test.go index c2cc83d23..12427c512 100644 --- a/core/internal/app/serve/wire_noheader_customer_test.go +++ b/core/internal/app/serve/wire_noheader_customer_test.go @@ -76,6 +76,30 @@ func TestNoHeader_CounterNamesACustomerThroughTheWall(t *testing.T) { t.Errorf("no header sync of A's customer: %d %s, want the sale synced", status, buf) } + // The return names a customer through the same rule (round 22): a free + // account return naming B's customer is answered 404 with no return and + // no credit memo written; the bound key stays a 403 beside it. + noheaderPurgeReturn(t, f, regNA) + retBody := func(customer uuid.UUID) string { + return fmt.Sprintf(`{"register_id":%q,"customer_id":%q,"refund_method":"account","reason":"wall",`+ + `"lines":[{"product_id":%q,"quantity":"1","unit_price_ten_thousandths":10000}]}`, + regNA, customer.String(), f.posProd.String()) + } + memosBefore := noheaderCount(t, f, `SELECT COUNT(*) FROM credit_memos WHERE customer_id = $1`, f.custB) + status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/returns", retBody(f.custB)) + if status != http.StatusNotFound { + t.Errorf("no header return naming B's customer: %d %s, want 404", status, buf) + } + if after := noheaderCount(t, f, `SELECT COUNT(*) FROM credit_memos WHERE customer_id = $1`, f.custB); after != memosBefore { + t.Errorf("the refused return wrote %d credit memos to B's customer", after-memosBefore) + } + if n := noheaderCount(t, f, `SELECT COUNT(*) FROM pos_returns WHERE register_id = $1`, regNA); n != 0 { + t.Errorf("the refused return wrote %d returns", n) + } + if keyStatus, keyBuf := f.key(t, f.bound, "POST", "/api/v1/pos/returns", retBody(f.custB), nil); keyStatus != http.StatusForbidden { + t.Errorf("bound key return naming B's customer: %d %s, want 403", keyStatus, keyBuf) + } + // The bound key keeps its pin: a machine key is no cashier, and the // foreign customer is held at the wall besides; nothing is written. foreignBefore = named(f.custB) diff --git a/core/internal/pos/customer_lookup_test.go b/core/internal/pos/customer_lookup_test.go index d7b2ede14..cd5f7dc03 100644 --- a/core/internal/pos/customer_lookup_test.go +++ b/core/internal/pos/customer_lookup_test.go @@ -16,6 +16,7 @@ import ( "sync" "testing" + "github.com/gablelbm/gable/internal/payment" "github.com/gablelbm/gable/internal/testutil" "github.com/google/uuid" ) @@ -234,3 +235,67 @@ func TestThreeNamingsOfOneSaleAreSerialized(t *testing.T) { t.Errorf("customer_po = %q, want one of the three", po) } } + +// RULE: the card side checks stand alone. A split of card and account that +// the account side would refuse (a purchase order the account asks for and +// the sale does not carry, or a zero limit) charges no card at all. +func TestASplitWhoseAccountLegIsRefusedChargesNoCard(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { f.service = f.service.WithGateway(gw) }) + f.openTill(0) + mustExec(t, f.db, `UPDATE customers SET po_required = TRUE WHERE id = $1`, f.customerID) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE customers SET po_required = FALSE, credit_limit = NULL WHERE id = $1`, f.customerID) + }) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, f.productLine("2")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + total := num(t, f.getSale(t, saleID), "total_cents") + r := f.completeSale(saleID, withToken(tender("card", total/2)), tender("account", total-total/2)) + if r.status != http.StatusConflict || !strings.Contains(string(r.raw), "po_required") { + t.Fatalf("the split with no PO = %d: %s, want 409 po_required", r.status, r.raw) + } + mustExec(t, f.db, `UPDATE customers SET po_required = FALSE, credit_limit = 0 WHERE id = $1`, f.customerID) + r = f.completeSale(saleID, withToken(tender("card", total/2)), tender("account", total-total/2)) + if r.status != http.StatusConflict || !strings.Contains(string(r.raw), "credit_hold") { + t.Fatalf("the split on a zero limit = %d: %s, want 409 credit_hold", r.status, r.raw) + } + if gw.chargeCalls != 0 { + t.Errorf("the refused splits reached the terminal %d times, want 0", gw.chargeCalls) + } +} + +// RULE: the purchase order is checked again under the credit lock. The PO +// leaves the sale while the card is on the wire; the transaction refuses the +// charge 409 po_required, the card is reversed and no invoice is posted. +func TestThePORecheckUnderTheLockRefusesAPOGoneWhileTheCardWasCharged(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { f.service = f.service.WithGateway(gw) }) + f.openTill(0) + mustExec(t, f.db, `UPDATE customers SET po_required = TRUE WHERE id = $1`, f.customerID) + t.Cleanup(func() { mustExec(t, f.db, `UPDATE customers SET po_required = FALSE WHERE id = $1`, f.customerID) }) + saleID := f.startSale(nil) + if r := f.attach(saleID, map[string]any{"customer_id": f.customerID.String(), "customer_po": "PO-1"}); r.status != http.StatusOK { + t.Fatalf("attach = %d: %s", r.status, r.raw) + } + if r := f.addLine(saleID, f.productLine("2")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + total := num(t, f.getSale(t, saleID), "total_cents") + gw.onCharge = func() { + mustExec(t, f.db, `UPDATE pos_transactions SET customer_po = NULL WHERE id = $1`, saleID) + } + r := f.completeSale(saleID, withToken(tender("card", total/2)), tender("account", total-total/2)) + if r.status != http.StatusConflict || !strings.Contains(string(r.raw), "po_required") { + t.Fatalf("the charge with the PO gone = %d: %s, want 409 po_required", r.status, r.raw) + } + if gw.chargeCalls != 1 || len(gw.voids)+len(gw.refunds) != 1 { + t.Errorf("charges %d, reversals %d, want the one card charged and reversed", gw.chargeCalls, len(gw.voids)+len(gw.refunds)) + } + if got := f.scalar(`SELECT count(*) FROM invoices WHERE customer_id = $1`, f.customerID).(int64); got != 0 { + t.Errorf("%d invoices after the refused charge, want none", got) + } +} diff --git a/core/internal/pos/customer_named_wall_test.go b/core/internal/pos/customer_named_wall_test.go index 4233edb6d..38ed56db1 100644 --- a/core/internal/pos/customer_named_wall_test.go +++ b/core/internal/pos/customer_named_wall_test.go @@ -196,3 +196,79 @@ func TestTheLookupTakesItsQueryAsText(t *testing.T) { t.Errorf("a query of two underscores answered %s, want []", r.raw) } } + +// RULE: a return names a customer through the same wall: a user granted only +// the sale's branch, no header, who returns to a customer only another branch +// trades with, by any refund method, is answered 404 and nothing is written +// (no return, no credit memo, no refund). The same holds when the customer +// comes from the original sale. +func TestTheReturnHoldsTheNamedCustomerToTheCustomerWall(t *testing.T) { + w := newNamedWall(t) + f := w.f + if r := f.do("POST", "/api/v1/pos/till/open", map[string]any{"register_id": f.register, "opening_float_cents": 0}, w.headers...); r.status != http.StatusCreated { + t.Fatalf("open till = %d: %s", r.status, r.raw) + } + r := f.do("POST", "/api/v1/pos/transactions", map[string]any{"register_id": f.register}, w.headers...) + if r.status != http.StatusCreated { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + saleID := str(t, r.body, "id") + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/items", map[string]any{"line": f.productLine("1")}, w.headers...); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + cur := f.getSale(t, saleID) + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/complete", + map[string]any{"tenders": []map[string]any{tender("cash", num(t, cur, "total_cents"))}, "revision": cur["revision"]}, w.headers...) + if r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + lineID := r.body["lines"].([]any)[0].(map[string]any)["id"].(string) + post := func(method string, withSale bool) resp { + req := map[string]any{ + "register_id": f.register, "customer_id": w.hidden.String(), + "refund_method": method, "reason": "wall", "lines": []map[string]any{ + {"line_id": lineID, "quantity": "1", "restock": false}}, + } + if withSale { + req["original_sale_id"] = saleID + } else { + req["lines"] = []map[string]any{{"product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 5990000, "restock": false}} + } + return f.do("POST", "/api/v1/pos/returns", req, w.headers...) + } + for _, c := range []struct { + method string + sale bool + }{{"account", true}, {"account", false}, {"cash", true}, {"cash", false}} { + // a free return meets the wall (404); against a sale it is the + // sale's own customer that is credited, so the named one is refused + // on the field (400): either way nothing is written + want := http.StatusNotFound + if c.sale { + want = http.StatusBadRequest + } + if r := post(c.method, c.sale); r.status != want { + t.Errorf("%s return (sale %v) naming a hidden customer = %d, want %d: %s", c.method, c.sale, r.status, want, r.raw) + } + } + if n := countOf(t, f.db, `SELECT count(*) FROM credit_memos WHERE customer_id = $1`, w.hidden); n != 0 { + t.Errorf("the refused returns wrote %d credit memos to the hidden customer", n) + } + if got := f.counterReturns(t); got != 0 { + t.Errorf("the refused returns wrote %d returns", got) + } + // a sale whose own customer left the grants holds the return the same way + mustExec(t, f.db, `UPDATE pos_transactions SET customer_id = $1 WHERE id = $2`, w.hidden, saleID) + req := map[string]any{"register_id": f.register, "original_sale_id": saleID, "refund_method": "account", + "reason": "wall", "lines": []map[string]any{{"line_id": lineID, "quantity": "1", "restock": false}}} + if r := f.do("POST", "/api/v1/pos/returns", req, w.headers...); r.status != http.StatusNotFound { + t.Errorf("return of a sale naming a hidden customer = %d, want 404: %s", r.status, r.raw) + } + // the reachable customer still returns + mustExec(t, f.db, `UPDATE pos_transactions SET customer_id = $1 WHERE id = $2`, w.reachable, saleID) + req["customer_id"] = w.reachable.String() + if r := f.do("POST", "/api/v1/pos/returns", req, w.headers...); r.status != http.StatusCreated { + t.Errorf("return naming a reachable customer = %d, want 201: %s", r.status, r.raw) + } +} diff --git a/core/internal/pos/gateway_order_test.go b/core/internal/pos/gateway_order_test.go index a1bd4e8c3..fad4afccd 100644 --- a/core/internal/pos/gateway_order_test.go +++ b/core/internal/pos/gateway_order_test.go @@ -133,7 +133,7 @@ func TestAChargeTheTransactionRefusesIsReversed(t *testing.T) { func (f *fixture) cardReturnOn(t *testing.T, saleID, lineID, qty string, extra ...map[string]any) resp { t.Helper() body := map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "register_id": f.register, "original_sale_id": saleID, "refund_method": "card", "reason": "card back", "lines": []map[string]any{ {"line_id": lineID, "quantity": qty, "restock": false}}, } diff --git a/core/internal/pos/refund_method_wall_test.go b/core/internal/pos/refund_method_wall_test.go index 878317dc1..2165bfd09 100644 --- a/core/internal/pos/refund_method_wall_test.go +++ b/core/internal/pos/refund_method_wall_test.go @@ -22,6 +22,8 @@ import ( // line. func (f *fixture) accountReturnOn(t *testing.T, saleID, lineID, qty string) resp { t.Helper() + // a return credits the sale's own customer: the sale is the account's + mustExec(t, f.db, `UPDATE pos_transactions SET customer_id = $1 WHERE id = $2`, f.customerID, saleID) return f.do("POST", "/api/v1/pos/returns", map[string]any{ "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, "refund_method": "account", "reason": "account credit", "lines": []map[string]any{ diff --git a/core/internal/pos/resolve.go b/core/internal/pos/resolve.go index 1a7b03846..3f8a0f758 100644 --- a/core/internal/pos/resolve.go +++ b/core/internal/pos/resolve.go @@ -263,7 +263,7 @@ func (s *Service) ResolveRefundIntent(ctx context.Context, intentID uuid.UUID, i if err := json.Unmarshal(rec.Request, &stored); err != nil { return nil, fmt.Errorf("the claim's stored request does not read back: %w", err) } - st, err := s.priceReturn(ctx, stored.toReturnIn()) + st, err := s.priceReturn(ctx, stored.toReturnIn(), false) if err != nil { // the replay prices outside the transaction, and a contender // may settle the claim and book while it does: the race is diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index 6ef011b9e..d76a7bef7 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -104,7 +104,13 @@ type refundClaim struct { // reads the sale's tax facts: everything the money transaction restates under // the sale row lock, gathered once. The guards that need no lock run here so // a refusal never moves money at the terminal. -func (s *Service) priceReturn(ctx context.Context, in *ReturnIn) (*returnState, error) { +// +// holdCustomer puts a named customer, the request's or the original sale's, +// through the counter's one customer rule (Service.namedCustomer) before +// anything is written: a hidden customer answers the module's 404. The +// operator resolution's replay of a stranded claim passes false: its money +// moved long before, and the replay restates what the claim recorded. +func (s *Service) priceReturn(ctx context.Context, in *ReturnIn, holdCustomer bool) (*returnState, error) { branchID, err := s.repo.GetRegisterBranch(ctx, in.RegisterID) if err != nil { return nil, err @@ -154,8 +160,24 @@ func (s *Service) priceReturn(ctx context.Context, in *ReturnIn) (*returnState, "a return that names the original sale names the line of it: every line carries line_id") } } - if customerID == nil { - customerID = original.CustomerID + // A return against a sale credits that sale's customer: a named + // customer other than the sale's is refused, and a walk-in sale's + // goods go back by the original tenders' methods, never onto an + // account. + if in.CustomerID != nil && (original.CustomerID == nil || *original.CustomerID != *in.CustomerID) { + walkInID, _, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return nil, err + } + if original.CustomerID != nil || walkInID != *in.CustomerID { + return nil, invalid("customer_id", + "a return against a sale credits the sale's own customer: leave the customer off, or name the sale's") + } + } + customerID = original.CustomerID + if customerID == nil && in.RefundMethod == RefundAccount { + return nil, invalid("refund_method", + "the sale named no customer: its goods are refunded to the drawer or the card, not to an account") } } isWalkIn := false @@ -181,8 +203,12 @@ func (s *Service) priceReturn(ctx context.Context, in *ReturnIn) (*returnState, return nil, conflict("walk_in_account", "account credit needs a named customer: the walk-in customer is refunded from the drawer or to the card") } - facts, err := s.repo.CustomerFacts(ctx, *customerID) - if err != nil { + var facts CustomerFacts + if holdCustomer && !isWalkIn { + if _, facts, err = s.namedCustomer(ctx, *customerID); err != nil { + return nil, err + } + } else if facts, err = s.repo.CustomerFacts(ctx, *customerID); err != nil { return nil, err } // Price the lines: a line naming a sale line refunds at that line's own @@ -505,7 +531,7 @@ func (s *Service) ReturnSale(ctx context.Context, cashierID uuid.UUID, in *Retur if s.ar == nil || s.inventory == nil { return nil, errNotConfigured } - st, err := s.priceReturn(ctx, in) + st, err := s.priceReturn(ctx, in, true) if err != nil { return nil, err } diff --git a/core/internal/pos/returns_bounds_test.go b/core/internal/pos/returns_bounds_test.go index fae00e35e..1f366802e 100644 --- a/core/internal/pos/returns_bounds_test.go +++ b/core/internal/pos/returns_bounds_test.go @@ -33,7 +33,7 @@ func (f *fixture) returnOn(t *testing.T, saleID, lineID, qty string, extra ...ma } } return f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "register_id": f.register, "original_sale_id": saleID, "refund_method": "cash", "reason": "bounds", "lines": []map[string]any{line}, }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) } @@ -63,7 +63,7 @@ func TestACashReturnNeedsAnOpenTillSession(t *testing.T) { lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) cashReturn := func() resp { return f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "register_id": f.register, "original_sale_id": saleID, "refund_method": "cash", "reason": "no drawer", "lines": []map[string]any{ {"line_id": lineID, "quantity": "1", "restock": true}}, }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) @@ -100,7 +100,7 @@ func TestACashReturnNeedsAnOpenTillSession(t *testing.T) { t.Fatalf("close = %d: %s", r.status, r.raw) } r = f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID2, + "register_id": f.register, "original_sale_id": saleID2, "refund_method": "cash", "reason": "counted drawer", "lines": []map[string]any{ {"line_id": lineID2, "quantity": "1", "restock": true}}, }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) @@ -226,7 +226,7 @@ func TestAProductLineOnANamedSaleIsRefused(t *testing.T) { f.openTill(0) saleID, _ := f.saleOf("1", tender("cash", 599)) r := f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "register_id": f.register, "original_sale_id": saleID, "refund_method": "cash", "reason": "no line named", "lines": []map[string]any{{ "product_id": f.productID.String(), "quantity": "1", "unit_price_ten_thousandths": 55000, diff --git a/core/internal/pos/returns_legacy_test.go b/core/internal/pos/returns_legacy_test.go index 1e87693fa..8c59399e8 100644 --- a/core/internal/pos/returns_legacy_test.go +++ b/core/internal/pos/returns_legacy_test.go @@ -153,7 +153,7 @@ func TestARequestNamingOneSaleLineTwiceIsRefused(t *testing.T) { } lineID := f.getSale(t, saleID)["lines"].([]any)[0].(map[string]any)["id"].(string) r := f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "register_id": f.register, "original_sale_id": saleID, "refund_method": "cash", "reason": "split across lines", "lines": []map[string]any{ {"line_id": lineID, "quantity": "1", "restock": true}, {"line_id": lineID, "quantity": "1", "restock": false}, diff --git a/core/internal/pos/tx_test.go b/core/internal/pos/tx_test.go index 3c1b69114..c45935380 100644 --- a/core/internal/pos/tx_test.go +++ b/core/internal/pos/tx_test.go @@ -250,7 +250,7 @@ func TestVoidRacingTwoReturns(t *testing.T) { go func(i int, qty string) { defer wg.Done() returnStatuses[i] = f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "register_id": f.register, "original_sale_id": saleID, "refund_method": "cash", "reason": "racing", "lines": []map[string]any{{"line_id": lineID, "quantity": qty, "restock": true}}, }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status diff --git a/core/internal/pos/walkin_return_test.go b/core/internal/pos/walkin_return_test.go index 7a3ac5055..c0197b5a7 100644 --- a/core/internal/pos/walkin_return_test.go +++ b/core/internal/pos/walkin_return_test.go @@ -12,9 +12,11 @@ package pos_test import ( "net/http" + "strings" "testing" "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" ) func TestAWalkInReturnRefusesAccount(t *testing.T) { @@ -27,12 +29,8 @@ func TestAWalkInReturnRefusesAccount(t *testing.T) { "reason": "store credit for a stranger", "lines": []map[string]any{{"line_id": lineID, "quantity": "1", "restock": true}}, }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) - if r.status != http.StatusConflict { - t.Fatalf("walk-in account return = %d, want 409: %s", r.status, r.raw) - } - _, blockers, _ := errorOf(t, r) - if len(blockers) == 0 || blockers[0] != "walk_in_account" { - t.Errorf("blockers = %v, want walk_in_account", blockers) + if r.status != http.StatusBadRequest { + t.Fatalf("walk-in account return = %d, want 400: %s", r.status, r.raw) } // nothing was written: no memo, no return row, the stock whole if got := countOf(t, f.db, `SELECT count(*) FROM credit_memos WHERE customer_id = (SELECT id FROM customers WHERE account_number = 'WALK-IN')`); got != 0 { @@ -60,15 +58,49 @@ func TestAWalkInReturnNamedAsTheWalkInIsRefusedToo(t *testing.T) { "register_id": f.register, "customer_id": walkIn, "original_sale_id": saleID, "refund_method": "account", "reason": "named walk-in", "lines": []map[string]any{{"line_id": lineID, "quantity": "1"}}, }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) - if r.status != http.StatusConflict { - t.Fatalf("named walk-in account return = %d, want 409: %s", r.status, r.raw) - } - _, blockers, _ := errorOf(t, r) - if len(blockers) == 0 || blockers[0] != "walk_in_account" { - t.Errorf("blockers = %v, want walk_in_account", blockers) + if r.status != http.StatusBadRequest { + t.Fatalf("named walk-in account return = %d, want 400: %s", r.status, r.raw) } if got := f.counterMemos(t); got != 0 { t.Errorf("%d memos on the walk-in customer, want 0", got) } f.assertARInvariants(t) } + +// RULE (round 22 ruling): a return against a sale credits that sale's +// customer. A named customer other than the sale's is refused 400 on the +// field, and a walk-in sale's goods never become another customer's account +// credit; nothing is written. +func TestAReturnCreditsTheSalesOwnCustomerOnly(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, body := f.saleOf("1", tender("cash", 599)) // a walk-in sale + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "refund_method": "account", "reason": "someone else's credit", + "lines": []map[string]any{{"line_id": lineID, "quantity": "1"}}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusBadRequest || !strings.Contains(string(r.raw), "customer_id") { + t.Fatalf("a walk-in sale's return naming a customer = %d, want 400 on customer_id: %s", r.status, r.raw) + } + // a customer sale refuses a different customer too + other := uuid.New() + mustExec(t, f.db, `INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'Other Co', $2, $3)`, other, "OTH-"+uuid.NewString()[:8], f.branchID) + t.Cleanup(func() { mustExec(t, f.db, `DELETE FROM customers WHERE id = $1`, other) }) + mustExec(t, f.db, `UPDATE pos_transactions SET customer_id = $1 WHERE id = $2`, f.customerID, saleID) + r = f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": other.String(), "original_sale_id": saleID, + "refund_method": "account", "reason": "someone else's credit", + "lines": []map[string]any{{"line_id": lineID, "quantity": "1"}}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusBadRequest || !strings.Contains(string(r.raw), "customer_id") { + t.Fatalf("a return naming another customer = %d, want 400 on customer_id: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM credit_memos WHERE customer_id = $1`, other); got != 0 { + t.Errorf("%d memos on the other customer, want 0", got) + } + if got := f.counterReturns(t); got != 0 { + t.Errorf("%d return rows, want 0", got) + } +} diff --git a/core/internal/pos/wire_test.go b/core/internal/pos/wire_test.go index 11b6cb69c..635c7a67a 100644 --- a/core/internal/pos/wire_test.go +++ b/core/internal/pos/wire_test.go @@ -440,7 +440,7 @@ func TestReturnRestocksAndReversesCOGS(t *testing.T) { saleLines := body.body["lines"].([]any) firstLine := saleLines[0].(map[string]any)["id"].(string) r := f.do("POST", "/api/v1/pos/returns", map[string]any{ - "register_id": f.register, "customer_id": f.customerID.String(), "original_sale_id": saleID, + "register_id": f.register, "original_sale_id": saleID, "refund_method": "cash", "reason": "wrong length", "lines": []map[string]any{{ "product_id": f.productID.String(), "line_id": firstLine, "quantity": "4", "restock": true, @@ -834,6 +834,9 @@ type fakeGateway struct { // mutex: the tests park a refund in flight or probe the database while // the terminal is on the wire. onRefund func() + // onCharge runs at the start of every Charge call, outside the fake's + // mutex: a test changes the database while the card is on the wire. + onCharge func() // mu guards every slice above: with the gateway called outside every // transaction, contenders reach it concurrently. mu sync.Mutex @@ -850,6 +853,9 @@ func declinedCharge() *payment.GatewayResult { } func (g *fakeGateway) Charge(ctx context.Context, req payment.ChargeRequest) (*payment.GatewayResult, error) { + if g.onCharge != nil { + g.onCharge() + } g.mu.Lock() defer g.mu.Unlock() g.chargeCalls++ diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index a212435a7..31078050b 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -400,9 +400,9 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, "POST /api/v1/pos/refund-intents/{id}/payback": {KeyBranchClassRefused, "a held credit is paid back by a person through the counter terminal rail: finance and owner only, and the route itself refuses a machine key, bound or not, whatever scopes it holds (C2-5, FROM-PRINCIPAL 60b)"}, "POST /api/v1/pos/refund-intents/{id}/resolve": {KeyBranchClassRefused, "a refund claim is resolved by a person: finance and owner only, and the route itself refuses a machine key, bound or not, whatever scopes it holds (C2-5, FROM-PRINCIPAL 41)"}, - "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, + "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); a customer the return names, or its original sale's (a return of a sale credits that sale's customer only, 400 otherwise), passes the counter's one customer rule (404 when hidden, nothing written, round 22); no machine key cashier"}, "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7, and a batch register that is empty or names no register is refused 400 the same way, rounds 8 and 9), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister, TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister"}, - "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, + "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); a customer the return names, or its original sale's (a return of a sale credits that sale's customer only, 400 otherwise), passes the counter's one customer rule (404 when hidden, nothing written, round 22); no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after, and a register of another branch is refused 403 (round 7); the customer it names goes through the customer branch wall (a hidden customer reads as missing, nothing written); no machine key cashier"}, "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"}, diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index dd4e8adbc..9dbe2872a 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -386,8 +386,8 @@ get an entry. | SEC-NOHEADER-1 | the customer account surface and the customer wall for a customer with no `customer_branches` row (the demo seed wrote customers that way), data change: migration 113 and the seed | The account wall and the reconciliation held the every branch arm inside the row test, so such a customer answered 404 on `GET /accounts/{id}`, `/accounts/{id}/transactions` and `GET /ar/customers/{id}/statement` to an owner, an administrator, the dev caller and every caller under the single branch switch, and its balance dropped out of the owner's `balance_sum_cents`; the customer wall hid it from every header and grants caller | The every branch arm sits outside the row test on the account wall and the reconciliation, so those callers read it again (a user held to grants still meets the fail closed 404 until it has a row). The seed writes the `customer_branches` row for each customer's stored primary branch and a re-seed never moves a stored primary branch. Migration 113 inserts the row for a customer's own primary branch where it has none: idempotent, reports by NOTICE how many it set and how many customers have no primary branch (left alone), moves no money and deletes nothing; its down file removes nothing, because a backfilled row cannot be told from a real one | | C2-5m119 | pos, the merge of the no header rule (SEC-NOHEADER-1) into the counter: every route the two rows above name, on the counter's sale, till and return handlers | PR 119 wrote the rule against the earlier counter; the counter's rewrite replaced those files, so the rule is carried into the replacement rather than merged text for text | No wire change beyond the SEC-NOHEADER-1 rows: the sale, till session, return and Z report reads and the register lookups carry the branch wall (a context branch, else the caller's grants, else every branch for an administrator or an unbound key); a no header user's act on a sale or till session of a branch outside the grants is refused 403 naming "belongs to a branch this caller may not target" before any lock or write, a hidden or missing record answers the module's 404 for every caller, and a register of another branch is refused 403 by name. The goldens already record those 404 answers, so none is re-recorded | | C2-5c | counter: `GET /api/v1/pos/customers` (new), `PUT /api/v1/pos/transactions/{id}/customer` (new), the sale document (`customer_po`, `job_id`, both null when unnamed), `POST /api/v1/pos/transactions/{id}/complete` (a new 409 blocker `po_required`), migration 114 | The counter had no way to name a customer on a sale, show the account's standing, or carry the purchase order and job an account asks for, so a charge sale could be rung only by starting the sale with a customer id the screen had no way to find | The lookup answers a bare array of at most 10 customers (name or account number, the caller's branch wall held) with the credit limit, the open receivable the credit check reads, the available credit and a standing word (`good`, `no_limit`, `no_credit`, `over_limit`), whether a purchase order is asked for and the open jobs. The customer route names the customer, purchase order and job on an open sale (the customer before the first line, 409 after; a job must be the customer's own, 400 otherwise; a customer the wall hides is 404) and moves the revision. A charge to the account of a customer with `po_required` is refused 409 `po_required` before any card is charged when the sale names no purchase order. The invoice a charge sale becomes carries the purchase order (`invoices.customer_po`) and the job as its project. Additive: the two sale fields are null on every earlier sale, and the `pos` and `pos_transactions` goldens gain exactly them | -| C2-5d | pos: `GET /api/v1/pos/zreports` (behaviour, no route change) | The day filter cast the date inside the database session, so a Z report generated late in the evening west of the database zone, or early east of it, fell on the other day and the list answered empty | The day is the absolute span of the requested date in the server's zone, the rule the sale list already follows (`GET /pos/transactions`); no golden changes | -| C2-5r21 | counter: `POST /api/v1/pos/transactions`, `POST /api/v1/pos/sync`, `POST /api/v1/pos/transactions/{id}/complete` (behaviour, no route change) | The create and the offline sync read the customer a sale names with no customer wall, so a cashier held to one yard's grants could name a customer the lookup and the attach hide, and an account tender then posted receivable against it; an account tender was accepted for a customer whose credit limit is 0 when the account held unapplied cash | The create, the sync item and the completion name a customer through the one rule the lookup and the attach follow (the customer branch wall): a hidden or inactive customer answers the module's 404 and nothing is written (a sync item is reported as an error). A credit limit of 0 means no account charges at all: any `account` tender is refused 409 with the blocker `credit_hold`, before any card is charged and again under the credit lock, whatever unapplied cash the account holds. The PO check is repeated against the locked sale row. The lookup treats `%`, `_` and `\` in its query as text | +| C2-5d | pos: `GET /api/v1/pos/zreports` (behaviour, no route change) | The day filter cast the date inside the database session, so a Z report generated late in the evening west of the database zone, or early east of it, fell on the other day and the list answered empty | The day is the span from midnight to midnight, as absolute instants, in the zone of the date the handler parses: a `date` query is read as a UTC date, so its day is that UTC day whatever the server's zone, and no `date` means the server's current day in its own zone; no golden changes | +| C2-5r21 | counter: `POST /api/v1/pos/transactions`, `POST /api/v1/pos/sync`, `POST /api/v1/pos/transactions/{id}/complete`, `POST /api/v1/pos/returns` (behaviour, no route change) | The create and the offline sync read the customer a sale names with no customer wall, and the return read the customer it names (the request's, else the original sale's) the same way, so a cashier held to one yard's grants could name a customer the lookup and the attach hide, and an account tender or an account return then posted receivable against it; an account tender was accepted for a customer whose credit limit is 0 when the account held unapplied cash | The create, the sync item, the completion and the return (whatever its refund method, before any card refund or write: no return, credit memo, refund or stock move for a hidden customer; the operator resolution's replay of a stranded claim is not re-held; a return against an original sale credits that sale's customer: a `customer_id` that differs is refused 400 on the field, and a walk-in sale's return cannot be refunded to an account, 400 on `refund_method`) name a customer through the one rule the lookup and the attach follow (the customer branch wall): a hidden or inactive customer answers the module's 404 and nothing is written (a sync item is reported as an error). A credit limit of 0 means no account charges at all: any `account` tender is refused 409 with the blocker `credit_hold`, before any card is charged and again under the credit lock, whatever unapplied cash the account holds. The PO check is repeated against the locked sale row. The lookup treats `%`, `_` and `\` in its query as text | | SEC-NOHEADER-1 | tax: `POST /api/v1/tax/exemptions` and `POST /api/v1/tax/preview`, a body that is not exactly one JSON value, for a user held by the customer wall (review fix, PR 119 r8 P1-1) | The user wall handed a body it could not read as one complete JSON value to the handler, which read only the first value: a trailing value or byte after the object (` {}`, ` 1`, `x`) let a finance user held to A create an exemption on, or preview, a customer of B | The wall answers 400 ("the request body must be exactly one JSON value") and writes nothing, header or not; an administrator or owner with no header is not held by the wall, as before; a branch bound key keeps the key wall's refusal | | DEMO-1 | quote: `POST /api/v1/quotes/{id}/convert` on a quote already accepted | A quote accepted first (on the desk or by the customer) could never convert: the convert repeated the accept and answered 409 `invalid_state_transition` ("cannot transition from accepted to accepted"), so the order could only be made by hand | It converts as it is: 201 with the order, no second transition and no second `quote.accepted` event, and the accept that already happened keeps the auto purchase order it ran; a quote that already has an order not cancelled is still 409 `already_converted` (three concurrent converts of one accepted quote make one order), and one whose order was cancelled converts again, as ADR 0005 5.8 already allowed for any quote | | QUOTE-SPLIT-1 | parsing: `POST /api/v1/parsing/upload` with a text or spreadsheet file (a pasted list, a text or csv file, an xlsx) and no AI reader set | The file was discarded unread and the canned sample list came back, flagged `synthetic: true`, whatever the file held | The rule-based reader parses the caller's own lines through `ExtractItems`, `synthetic: false`; an image or a PDF without an AI reader still returns the canned sample list flagged `synthetic: true`; the wire shape is unchanged | From 0b09f2aa20e7b91528f38b48197d793d632fe67e Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 00:38:39 -0700 Subject: [PATCH 113/113] middleware: the till open row keeps its own description r25 P2-1 (r24 P3-1): round 22 copied the return's customer rule into the key allowlist row for POST /api/v1/pos/till/open by a slip; opening a till names no customer. The row says what it said at b4bae7de again. Text only. Co-Authored-By: Claude Opus 5.5 Signed-off-by: colton --- core/pkg/middleware/keybranch_allow.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 31078050b..c08a3d2f3 100644 --- a/core/pkg/middleware/keybranch_allow.go +++ b/core/pkg/middleware/keybranch_allow.go @@ -402,7 +402,7 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/pos/refund-intents/{id}/resolve": {KeyBranchClassRefused, "a refund claim is resolved by a person: finance and owner only, and the route itself refuses a machine key, bound or not, whatever scopes it holds (C2-5, FROM-PRINCIPAL 41)"}, "POST /api/v1/pos/returns": {KeyBranchClassWalled, "the return is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); a customer the return names, or its original sale's (a return of a sale credits that sale's customer only, 400 otherwise), passes the counter's one customer rule (404 when hidden, nothing written, round 22); no machine key cashier"}, "POST /api/v1/pos/sync": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on every replayed create and on the batch register itself (a batch of another branch's register is refused 403 before any item runs and before the pos_sync_log row is written, round 7, and a batch register that is empty or names no register is refused 400 the same way, rounds 8 and 9), and items[].customer_id through the customer branch wall (round 10 P1-1); TestKeyBranchPin_PosSyncCustomerWall, TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister, TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister"}, - "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); a customer the return names, or its original sale's (a return of a sale credits that sale's customer only, 400 otherwise), passes the counter's one customer rule (404 when hidden, nothing written, round 22); no machine key cashier"}, + "POST /api/v1/pos/till/open": {KeyBranchClassWalled, "the till session is stamped with the register's branch (its location's branch, else pos_registers.branch_id) and the caller's branch context is held against it (403 on a mismatch, round 7); no machine key cashier"}, "POST /api/v1/pos/till/{id}/close": {KeyBranchClassWalled, "till_sessions.branch_id (the close reads the session first); TestKeyBranchPin_PosAndAccountRows"}, "POST /api/v1/pos/transactions": {KeyBranchClassWalled, "the register's branch (its location's branch, else pos_registers.branch_id) on create, pos_transactions.branch_id after, and a register of another branch is refused 403 (round 7); the customer it names goes through the customer branch wall (a hidden customer reads as missing, nothing written); no machine key cashier"}, "POST /api/v1/pos/transactions/{id}/complete": {KeyBranchClassWalled, "pos_transactions.branch_id (the transaction is read before any write)"},