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/api/ROUTES.txt b/core/api/ROUTES.txt index 333e44ecb..476249cc1 100644 --- a/core/api/ROUTES.txt +++ b/core/api/ROUTES.txt @@ -290,7 +290,10 @@ POST /api/v1/payments/{id}/applications internal/payment h.HandleApply POST /api/v1/payments/{id}/refunds internal/payment h.HandleRefund POST /api/v1/payments/{id}/transitions internal/payment h.HandleTransition GET /api/v1/pos/catalog internal/pos h.GetCatalog +GET /api/v1/pos/customers internal/pos h.FindCustomers GET /api/v1/pos/products/search internal/pos h.SearchProducts +POST /api/v1/pos/refund-intents/{id}/payback internal/pos h.PaybackRefundIntent +POST /api/v1/pos/refund-intents/{id}/resolve internal/pos h.ResolveRefundIntent GET /api/v1/pos/returns internal/pos h.ListReturns POST /api/v1/pos/returns internal/pos h.CreateReturn GET /api/v1/pos/returns/{id} internal/pos h.GetReturn @@ -304,6 +307,7 @@ GET /api/v1/pos/transactions internal/pos h.ListTransactions POST /api/v1/pos/transactions internal/pos h.StartTransaction GET /api/v1/pos/transactions/{id} internal/pos h.GetTransaction POST /api/v1/pos/transactions/{id}/complete internal/pos h.CompleteTransaction +PUT /api/v1/pos/transactions/{id}/customer internal/pos h.AttachCustomer POST /api/v1/pos/transactions/{id}/items internal/pos h.AddItem DELETE /api/v1/pos/transactions/{id}/items/{itemId} internal/pos h.RemoveItem POST /api/v1/pos/transactions/{id}/void internal/pos h.VoidTransaction diff --git a/core/api/fragments/payment.yaml b/core/api/fragments/payment.yaml index c0980b3ad..156f5c801 100644 --- a/core/api/fragments/payment.yaml +++ b/core/api/fragments/payment.yaml @@ -83,7 +83,9 @@ paths: allow) in the same transaction, each application its own entry (DR 2200 / CR 1020) and subledger row. Refused with 409 and a blocker: exceeds_unapplied, exceeds_open_amount, invoice_void, customer_mismatch, currency_mismatch, - discount_not_available, period_closed. Writes payment.recorded, then + discount_not_available, period_closed, and void_claim_pending when the + invoice's sale has a void whose refund claims still stand (the counter's + interim; the refusal names the claims). Writes payment.recorded, then payment.applied and invoice.partial or invoice.paid per invoice, then customer.updated (part balance). parameters: @@ -163,7 +165,9 @@ paths: posts its own DR 2200 / CR 1020 entry and a PAYMENT subledger row. The precondition is the payment's revision. Refused with 409 exceeds_unapplied, exceeds_open_amount, invoice_void, payment_voided, customer_mismatch, - currency_mismatch, discount_not_available or period_closed. Writes payment.applied, + currency_mismatch, discount_not_available, period_closed, or + void_claim_pending when the invoice's sale has a void whose refund claims + still stand (the counter's interim; the refusal names the claims). Writes payment.applied, invoice.partial or invoice.paid per invoice, and customer.updated. parameters: - name: id diff --git a/core/api/fragments/pos.yaml b/core/api/fragments/pos.yaml index c6c8c5bfd..a5857fb14 100644 --- a/core/api/fragments/pos.yaml +++ b/core/api/fragments/pos.yaml @@ -1,1447 +1,1499 @@ # 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.' + $ref: "#/components/schemas/PosTransaction" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/WireNotFound" + "500": + $ref: "#/components/responses/InternalError" + get: + operationId: posTransactionList + 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: + - 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: The day's page of sales. 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/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: posTransactionList - tags: - - pos - summary: List transaction summaries + operationId: posTransactionGet + tags: [pos] + summary: Read a sale + description: The sale with its lines and tenders, the branch wall applied. 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: id + in: path + required: true + schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + responses: + "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/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/WireNotFound" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/pos/customers: + get: + operationId: posCustomerFind + tags: [pos] + summary: The counter's customer lookup + description: >- + Customers matching the q text (name or account number, at most 10) + that the caller's branch context or wall reaches, each with the + account's standing the charge tender will read: the credit limit + (null for none, 0 for no credit), the open receivable, the available + credit and a standing word (good, no_limit, no_credit, over_limit), + whether the account asks for a purchase order and its open jobs. A + bare array, never null; q under two characters answers []. + parameters: + - name: q + in: query + required: false + schema: {type: string} + - $ref: "#/components/parameters/XBranchId" responses: - '200': - description: Summaries; an empty array when none. + "200": + description: The matching customers. 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' - /api/v1/pos/transactions/{id}: - get: - operationId: posTransactionGet - tags: - - pos - summary: Get a transaction with its lines and tenders + $ref: "#/components/schemas/PosCounterCustomer" + "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}/customer: + put: + operationId: posTransactionCustomer + tags: [pos] + summary: Name the sale's customer, purchase order and job + description: >- + Names who the sale is for. The customer is named before the first + line (the lines price for the customer: 409 invalid_state_transition + once a line exists and the customer would change); the purchase order + and the job may change while the sale is open. A null customer_id + makes the sale a walk-in sale again. The job must be an open job of + the customer. The sale's revision moves; the response is the whole + sale. A sale or customer that does not exist, or that the caller's + branch context or wall hides, answers 404, and nothing is written; a + caller with no branch in context who holds no grant for the sale's + branch is refused 403 by name. 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' + content: + application/json: + schema: + $ref: "#/components/schemas/PosSaleCustomerRequest" responses: - '200': - description: The transaction. + "200": + description: The sale with its customer named. + 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' - '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/WireNotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "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: >- - A transaction that does not exist, or sits outside the caller's - branch context or wall, answers 404, the same for both; nothing is - written. A caller with no branch in context who holds no grant for - the transaction's branch is refused 403 by name. + 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. A sale that does not exist, or that the caller's branch context + or wall hides, answers 404, the same for both, and nothing is written; + a caller with no branch in context who holds no grant for the sale's + branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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: >- - A transaction that does not exist, or sits outside the caller's - branch context or wall, answers 404, the same for both, and no line - is removed. + 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. A sale that does not exist, or that the caller's branch context + or wall hides, answers 404, the same for both, and nothing is written; + a caller with no branch in context who holds no grant for the sale's + branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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. A transaction that does not exist, or sits outside - the caller's branch context or wall, answers 404, the same for both. Every other 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). A sale that does not exist, or that the caller's branch context + or wall hides, answers 404, the same for both, and nothing is written; + a caller with no branch in context who holds no grant for the sale's + branch is refused 403 by name. 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' - '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/WireNotFound" + "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: >- - A transaction that does not exist, or sits outside the caller's - branch context or wall, answers 404, the same for both; nothing is - written. + 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). A sale that does not exist, or that the caller's branch context + or wall hides, answers 404, the same for both, and nothing is written; + a caller with no branch in context who holds no grant for the sale's + branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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, an empty items list, or a register_id that is empty or names no register answers 400 for every caller. + 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. The batch register is + read before any item runs and before the sync log row is written: a + register_id that is empty or names no register the counter knows is + a 400 for every caller, and a register of another branch is the + branch wall's 403. 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/WireNotFound" + "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/WireNotFound" + "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, or a session the caller's branch context hides, answers 404 like the Z report read. + 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. An unknown session, or a session the + caller's branch context hides, answers 404 like the Z report read. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/close: post: operationId: posTillClose - tags: - - pos - summary: Close a till session + tags: [pos] + summary: Close the drawer description: >- - Every service failure, such as a session not OPEN, answers 409; a - session that does not exist, or sits outside the caller's branch - context or wall, answers 404, the same for both; nothing is written. - A caller with no branch in context who holds no grant for the - session's branch is refused 403 by name. + 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. A session that does not exist, or that + the caller's branch context or wall hides, answers 404, the same for + both, and nothing is written; a caller with no branch in context who + holds no grant for the session's branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' + $ref: "#/components/schemas/PosTillReport" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/WireNotFound" + "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, and 404 for a + session the caller's branch context hides, the read held to the + caller's branch like the X report. 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/WireNotFound" + "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/WireNotFound" + "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/WireNotFound" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/pos/refund-intents/{id}/resolve: + post: + operationId: posRefundIntentResolve + tags: [pos] + summary: Resolve a stranded card refund claim + description: >- + The guarded operator route for a refund claim that outlived its act + (a lost terminal verdict, a crashed process, a transaction that + refused after the terminal answered): finance reads the terminal and + records its actual outcome. outcome refund carries the gateway's own + refund id (the money moved) and books it through the refund's own + money transaction with every cap and lock, completing the claim DONE; + outcome no_refund carries the reconciliation reference finance filed, + marks the claim FAILED and moves nothing. A payback claim (a + payback whose terminal call answered no verdict) settles the same + way: a recorded refund books the credit out to its card, a no + refund releases the claim and the credit stays held at the counter. + Both write one audit row + naming the person, the intent, the outcome and the reason. A claim + whose own terminal call has not answered is live, not stranded: while + its in flight lease runs the resolve answers 409 refund_in_flight, + and once the lease expires with no answer at all the claim is marked + UNKNOWN and settles as recorded. Only the + finance and owner roles; a machine key is refused in the route + itself, bound or not. A bare resolve, a refund without its refund id + and a no refund without its reference are 400s naming the field; a + claim that is not PENDING is 409 intent_not_pending, a no refund over + a claim carrying the terminal's refund id and a refund id that + differs from it are 409s naming the conflict, and the claim's sale is + read through the caller's branch context, so a caller outside the + branch finds a missing row (404). In a configured deployment the + actor is the JWT's subject; with authentication off (dev mode) the + route runs with no subject and the audit row names nobody, which no + configured deployment allows. + parameters: + - 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/PosResolveIntentRequest" + responses: + "200": + description: The settled claim. + content: + application/json: + schema: + $ref: "#/components/schemas/PosRefundIntentResolved" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/WireNotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/pos/refund-intents/{id}/payback: + post: + operationId: posRefundIntentPayback + tags: [pos] + summary: Pay a declined void tender's credit back to its card + description: >- + The one way back for a declined void tender's credit + (FROM-PRINCIPAL 60): a tender whose refund the terminal declined + keeps its charge as unapplied credit once the void completes, held + to its own tender (it applies to no invoice, 409 + tender_credit_held), and this route pays it back to that card + through the counter terminal rail, the rail the charge came from. + The route keys on the void's own released claim (the id the + resolve route would key on): only a FAILED void claim of a voided + sale whose tender's payment still carries the credit unapplied is + paid back, exactly once. Its terminal call runs under the claim + discipline every terminal refund runs under: one live claim per + tender and the in flight lease, so a concurrent payback is 409 + refund_in_flight and a hung call holds the drawer until it is + resolved. Only the finance and owner roles; a machine key is + refused in the route itself, bound or not. The claim's sale is read + through the caller's branch context, so a caller outside the + branch finds a missing row (404), header or not. A claim that is + not FAILED (409 intent_not_failed), a claim of a sale whose void + has not completed and a tender whose credit was already paid back + (409 no_held_credit) are refused before the terminal is reached, + so a refusal never moves money at the terminal; a terminal decline + is 409 card_refund (nothing moved, the credit stays held and the + counter may ask again), and a call that answers no verdict leaves + the payback's own claim standing for the resolve route. The + booking writes one pos.refund_intent.paid_back audit row naming + the person, the void's claim, the outcome and the reason, the + credit's unapplied cash falls to zero and the cash account moves + by the refund. The payment refund route through the online gateway + is never the way back for such a credit (409 tender_credit_held). + parameters: + - 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/PosPaybackIntentRequest" + responses: + "200": + description: The credit paid back to its card. + content: + application/json: + schema: + $ref: "#/components/schemas/PosRefundIntentPaidBack" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/WireNotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "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."} + PosResolveIntentRequest: type: object - description: CatalogProduct. - required: - - product_id - - sku - - description - - price - - uom - - in_stock + required: [outcome, reason] properties: - product_id: + outcome: type: string - format: uuid - sku: + enum: [refund, no_refund] + description: "The terminal's actual outcome: refund (the gateway's own refund id, the money moved) or no_refund (an explicit no at the terminal)." + refund_tx_id: type: string - description: + description: "Required when the outcome is refund: the gateway's own refund id for the claim." + reconciliation_reference: type: string - price: - type: number - description: Float dollars. - uom: - type: string - in_stock: - type: number - PosTillSession: + description: "Required when the outcome is no_refund: the reference finance filed for the reconciliation." + reason: {type: string, description: Why the record was made, 1 to 500 characters.} + PosRefundIntentResolved: 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: [id, sale_id, tender_id, act, state, outcome] 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: + id: {type: string, format: uuid} + sale_id: {type: string, format: uuid} + tender_id: {type: string, format: uuid} + act: {type: string, enum: [void, return, payback]} + state: {type: string, enum: [DONE, FAILED], description: DONE when a recorded refund booked, FAILED when a no refund released the claim.} + outcome: {type: string, enum: [refund, no_refund]} + refund_tx_id: {type: [string, "null"], description: The gateway refund id the operator recorded.} + reconciliation_reference: {type: [string, "null"], description: The reference the operator filed.} + PosPaybackIntentRequest: type: object - description: The current session wrapper. - required: - - session + required: [reason] properties: - session: - oneOf: - - $ref: '#/components/schemas/PosTillSession' - - type: 'null' - PosTillReport: + reason: {type: string, description: Why the credit went back to its card, 1 to 500 characters.} + PosRefundIntentPaidBack: type: object - description: TillReport. - required: - - session - - sale_count - - sales_total - - tax_total - - change_given - - tendered_by_method - - expected_by_method + required: [id, payback_id, sale_id, tender_id, amount_cents, refund_tx_id, state] 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: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - PosZReport: + id: {type: string, format: uuid, description: The void's own released claim the route keyed on.} + payback_id: {type: string, format: uuid, description: The payback's own claim, completed DONE with the terminal's refund id.} + sale_id: {type: string, format: uuid} + tender_id: {type: string, format: uuid} + amount_cents: {type: integer, format: int64, description: The credit paid back, the tender's payment's whole unapplied cash.} + refund_tx_id: {type: string, description: The terminal's own refund id for the payback.} + state: {type: string, enum: [DONE]} + PosReturnRequest: type: object - description: ZReport. - required: - - id - - till_session_id - - register_id - - over_short - - payload - - generated_at + required: [reason, lines] 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: + 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: "Refused on a card return: the refund goes to the sale's own card tender, never a transaction the client names."} + lines: + type: array + items: + $ref: "#/components/schemas/PosReturnLineRequest" + PosReturnLineRequest: type: object - required: - - z_reports + required: [quantity] properties: - z_reports: - type: - - array - - 'null' - items: - $ref: '#/components/schemas/PosZReport' - PosReturnLine: + 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. Required on every line when original_sale_id is named, and named at most once: a request naming one sale line twice is refused naming the second."} + 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: 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: + register_id: {type: string, description: "Defaults REG-01."} + opening_float_cents: {type: integer, format: int64, minimum: 0} + PosTillCloseRequest: 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: [counted_by_method] 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: + counted_by_method: + type: object + 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 + required: [batch_id, items] + properties: + 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: + $ref: "#/components/schemas/PosLineRequest" + tenders: + type: array + items: + $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: SyncError. - required: - - client_id - - reason + 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: - client_id: - type: string - reason: - type: string - PosSyncResponse: + 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: OfflineSyncResponse. errors is omitted when empty. - required: - - batch_id - - synced_count - - duplicate_count - - error_count + 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: - batch_id: - type: string - synced_count: - type: integer - duplicate_count: - type: integer - error_count: - type: integer - errors: + 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 + 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, customer_po, job_id, 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} + customer_po: {type: [string, "null"], description: "The purchase order a charge sale names."} + job_id: {type: [string, "null"], format: uuid, description: "The customer's job the sale belongs to."} + lines: type: array items: - $ref: '#/components/schemas/PosSyncError' - PosTransactionCreate: + $ref: "#/components/schemas/PosLineItem" + tenders: + type: array + items: + $ref: "#/components/schemas/PosTender" + PosCounterJob: type: object + required: [id, name] properties: - register_id: - type: string - description: Defaults to REG-01. - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - PosAddItem: + id: {type: string, format: uuid} + name: {type: string} + PosCounterCustomer: type: object - required: - - product_id - - quantity + required: [id, account_number, name, po_required, credit_limit_cents, open_receivable_cents, available_credit_cents, standing, jobs] properties: - product_id: - type: string - format: uuid - quantity: - type: number - uom: - type: string - PosTenderInput: + id: {type: string, format: uuid} + account_number: {type: string} + name: {type: string} + po_required: {type: boolean, description: "The account asks for a purchase order on a charge sale (409 po_required at completion without one)."} + credit_limit_cents: {type: [integer, "null"], format: int64, description: "Null: no limit. 0: no credit."} + open_receivable_cents: {type: integer, format: int64, description: "What the credit check reads: open invoices less open credit memos and unapplied cash."} + available_credit_cents: {type: [integer, "null"], format: int64, description: "The limit less the open receivable; negative when over; null with no limit."} + standing: {type: string, enum: [good, no_limit, no_credit, over_limit]} + jobs: + type: array + items: + $ref: "#/components/schemas/PosCounterJob" + PosSaleCustomerRequest: type: object - description: AddTenderRequest. - required: - - method - - amount + additionalProperties: false 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: + customer_id: {type: [string, "null"], format: uuid} + customer_po: {type: [string, "null"], maxLength: 100} + job_id: {type: [string, "null"], format: uuid} + PosTransactionPage: type: object - required: - - tenders + required: [items, next_cursor, limit] properties: - tenders: + items: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosTenderInput' - PosOfflineTransaction: + $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: OfflineTransaction. - required: - - client_id - - register_id - - cashier_id - - items - - tenders - - client_created_at + 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: - 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: + 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: OfflineSyncRequest. - required: - - batch_id - - items + 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: - batch_id: - type: string - register_id: - type: string - items: + 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: + allOf: + - $ref: "#/components/schemas/PosTillReport" + description: The frozen closing report, a JSON object. + 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 38aee1bb8..63cec7b67 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -8734,7 +8734,7 @@ paths: tags: [payment] summary: Record a payment description: >- - Records a posted payment for a customer (cash, check, ach or other; a card is taken through POST /payments/card, and account is not a payment). The whole amount is posted to 2200 (DR 1010 / CR 2200). Without applications the payment is unapplied cash. With applications each invoice takes its cash (and an early pay discount, on or before the invoice's discount date, up to what its terms allow) in the same transaction, each application its own entry (DR 2200 / CR 1020) and subledger row. Refused with 409 and a blocker: exceeds_unapplied, exceeds_open_amount, invoice_void, customer_mismatch, currency_mismatch, discount_not_available, period_closed. Writes payment.recorded, then payment.applied and invoice.partial or invoice.paid per invoice, then customer.updated (part balance). + Records a posted payment for a customer (cash, check, ach or other; a card is taken through POST /payments/card, and account is not a payment). The whole amount is posted to 2200 (DR 1010 / CR 2200). Without applications the payment is unapplied cash. With applications each invoice takes its cash (and an early pay discount, on or before the invoice's discount date, up to what its terms allow) in the same transaction, each application its own entry (DR 2200 / CR 1020) and subledger row. Refused with 409 and a blocker: exceeds_unapplied, exceeds_open_amount, invoice_void, customer_mismatch, currency_mismatch, discount_not_available, period_closed, and void_claim_pending when the invoice's sale has a void whose refund claims still stand (the counter's interim; the refusal names the claims). Writes payment.recorded, then payment.applied and invoice.partial or invoice.paid per invoice, then customer.updated (part balance). parameters: - $ref: "#/components/parameters/XBranchId" - $ref: "#/components/parameters/IdempotencyKey" @@ -8808,7 +8808,7 @@ paths: tags: [payment] summary: Apply a payment's unapplied cash to invoices description: >- - Applies unapplied cash later, partly, across many invoices; each application posts its own DR 2200 / CR 1020 entry and a PAYMENT subledger row. The precondition is the payment's revision. Refused with 409 exceeds_unapplied, exceeds_open_amount, invoice_void, payment_voided, customer_mismatch, currency_mismatch, discount_not_available or period_closed. Writes payment.applied, invoice.partial or invoice.paid per invoice, and customer.updated. + Applies unapplied cash later, partly, across many invoices; each application posts its own DR 2200 / CR 1020 entry and a PAYMENT subledger row. The precondition is the payment's revision. Refused with 409 exceeds_unapplied, exceeds_open_amount, invoice_void, payment_voided, customer_mismatch, currency_mismatch, discount_not_available, period_closed, or void_claim_pending when the invoice's sale has a void whose refund claims still stand (the counter's interim; the refusal names the claims). Writes payment.applied, invoice.partial or invoice.paid per invoice, and customer.updated. parameters: - name: id in: path @@ -10176,726 +10176,882 @@ 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/WireNotFound" + "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/WireNotFound" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/pos/customers: + get: + operationId: posCustomerFind + tags: [pos] + summary: The counter's customer lookup + description: >- + Customers matching the q text (name or account number, at most 10) that the caller's branch context or wall reaches, each with the account's standing the charge tender will read: the credit limit (null for none, 0 for no credit), the open receivable, the available credit and a standing word (good, no_limit, no_credit, over_limit), whether the account asks for a purchase order and its open jobs. A bare array, never null; q under two characters answers []. + parameters: + - name: q + in: query + required: false + schema: {type: string} + - $ref: "#/components/parameters/XBranchId" + responses: + "200": + description: The matching customers. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PosCounterCustomer" + "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}/customer: + put: + operationId: posTransactionCustomer + tags: [pos] + summary: Name the sale's customer, purchase order and job + description: >- + Names who the sale is for. The customer is named before the first line (the lines price for the customer: 409 invalid_state_transition once a line exists and the customer would change); the purchase order and the job may change while the sale is open. A null customer_id makes the sale a walk-in sale again. The job must be an open job of the customer. The sale's revision moves; the response is the whole sale. A sale or customer that does not exist, or that the caller's branch context or wall hides, answers 404, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. + parameters: + - 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/PosSaleCustomerRequest" + responses: + "200": + description: The sale with its customer named. + 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" + "404": + $ref: "#/components/responses/WireNotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "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: >- - A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both; nothing is written. A caller with no branch in context who holds no grant for the transaction's branch is refused 403 by name. + 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. A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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: >- - A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both, and no line is removed. + 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. A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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. A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both. Every other 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). A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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: >- - A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both; nothing is written. + 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). A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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, an empty items list, or a register_id that is empty or names no register answers 400 for every caller. + 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. The batch register is read before any item runs and before the sync log row is written: a register_id that is empty or names no register the counter knows is a 400 for every caller, and a register of another branch is the branch wall's 403. 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/WireNotFound" + "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/WireNotFound" + "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, or a session the caller's branch context hides, answers 404 like the Z report read. + 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. An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "500": + $ref: "#/components/responses/InternalError" /api/v1/pos/till/{id}/close: post: operationId: posTillClose - tags: - - pos - summary: Close a till session + tags: [pos] + summary: Close the drawer description: >- - Every service failure, such as a session not OPEN, answers 409; a session that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both; nothing is written. A caller with no branch in context who holds no grant for the session's branch is refused 403 by name. + 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. A session that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the session's branch is refused 403 by name. 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' - '404': - $ref: '#/components/responses/NotFound' - '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/WireNotFound" + "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, and 404 for a session the caller's branch context hides, the read held to the caller's branch like the X report. 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/WireNotFound" + "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/WireNotFound" + "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' - /api/v1/pricing/calculate: - get: - operationId: pricingCalculate - tags: [pricing] - summary: Calculate a customer price for a product + $ref: "#/components/schemas/PosReturn" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/WireNotFound" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/pos/refund-intents/{id}/resolve: + post: + operationId: posRefundIntentResolve + tags: [pos] + summary: Resolve a stranded card refund claim description: >- - Guarded admin or owner. The price one line would carry (ADR 0006 section 7.3): the exact scale 4 unit price in the product's stocking unit, the conversion pair (1 and 1 until the unit sets of C3-2A), the extension rounded once to cents by the line extension rule, and the price basis in lowercase. customer_id and product_id are required UUIDs; a quantity that is not a positive decimal string at scale 4 or a job_id that is not a UUID is a 400 naming it. A missing customer or product is 404. + The guarded operator route for a refund claim that outlived its act (a lost terminal verdict, a crashed process, a transaction that refused after the terminal answered): finance reads the terminal and records its actual outcome. outcome refund carries the gateway's own refund id (the money moved) and books it through the refund's own money transaction with every cap and lock, completing the claim DONE; outcome no_refund carries the reconciliation reference finance filed, marks the claim FAILED and moves nothing. A payback claim (a payback whose terminal call answered no verdict) settles the same way: a recorded refund books the credit out to its card, a no refund releases the claim and the credit stays held at the counter. Both write one audit row naming the person, the intent, the outcome and the reason. A claim whose own terminal call has not answered is live, not stranded: while its in flight lease runs the resolve answers 409 refund_in_flight, and once the lease expires with no answer at all the claim is marked UNKNOWN and settles as recorded. Only the finance and owner roles; a machine key is refused in the route itself, bound or not. A bare resolve, a refund without its refund id and a no refund without its reference are 400s naming the field; a claim that is not PENDING is 409 intent_not_pending, a no refund over a claim carrying the terminal's refund id and a refund id that differs from it are 409s naming the conflict, and the claim's sale is read through the caller's branch context, so a caller outside the branch finds a missing row (404). In a configured deployment the actor is the JWT's subject; with authentication off (dev mode) the route runs with no subject and the audit row names nobody, which no configured deployment allows. parameters: - - name: customer_id - in: query - required: true - schema: {type: string, format: uuid} - - name: product_id - in: query + - name: id + in: path required: true schema: {type: string, format: uuid} - - name: quantity - in: query - required: false - description: A positive decimal string at scale 4; default "1". - schema: {type: string} - - name: job_id - in: query - required: false - schema: {type: string, format: uuid} + - $ref: "#/components/parameters/XBranchId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PosResolveIntentRequest" responses: "200": - description: The calculated price. + description: The settled claim. content: application/json: schema: - $ref: "#/components/schemas/PricingCalculatedPrice" + $ref: "#/components/schemas/PosRefundIntentResolved" "400": - $ref: "#/components/responses/WireBadRequest" + $ref: "#/components/responses/BadRequestEither" "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" + $ref: "#/components/responses/ForbiddenEither" "404": $ref: "#/components/responses/WireNotFound" + "409": + $ref: "#/components/responses/ConflictEither" "500": - $ref: "#/components/responses/InternalErrorEither" - /api/v1/pricing/rules: - get: - operationId: pricingRuleList - tags: [pricing] - summary: List pricing rules - description: >- + $ref: "#/components/responses/InternalError" + /api/v1/pos/refund-intents/{id}/payback: + post: + operationId: posRefundIntentPayback + tags: [pos] + summary: Pay a declined void tender's credit back to its card + description: >- + The one way back for a declined void tender's credit (FROM-PRINCIPAL 60): a tender whose refund the terminal declined keeps its charge as unapplied credit once the void completes, held to its own tender (it applies to no invoice, 409 tender_credit_held), and this route pays it back to that card through the counter terminal rail, the rail the charge came from. The route keys on the void's own released claim (the id the resolve route would key on): only a FAILED void claim of a voided sale whose tender's payment still carries the credit unapplied is paid back, exactly once. Its terminal call runs under the claim discipline every terminal refund runs under: one live claim per tender and the in flight lease, so a concurrent payback is 409 refund_in_flight and a hung call holds the drawer until it is resolved. Only the finance and owner roles; a machine key is refused in the route itself, bound or not. The claim's sale is read through the caller's branch context, so a caller outside the branch finds a missing row (404), header or not. A claim that is not FAILED (409 intent_not_failed), a claim of a sale whose void has not completed and a tender whose credit was already paid back (409 no_held_credit) are refused before the terminal is reached, so a refusal never moves money at the terminal; a terminal decline is 409 card_refund (nothing moved, the credit stays held and the counter may ask again), and a call that answers no verdict leaves the payback's own claim standing for the resolve route. The booking writes one pos.refund_intent.paid_back audit row naming the person, the void's claim, the outcome and the reason, the credit's unapplied cash falls to zero and the cash account moves by the refund. The payment refund route through the online gateway is never the way back for such a credit (409 tender_credit_held). + parameters: + - 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/PosPaybackIntentRequest" + responses: + "200": + description: The credit paid back to its card. + content: + application/json: + schema: + $ref: "#/components/schemas/PosRefundIntentPaidBack" + "400": + $ref: "#/components/responses/BadRequestEither" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ForbiddenEither" + "404": + $ref: "#/components/responses/WireNotFound" + "409": + $ref: "#/components/responses/ConflictEither" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/pricing/calculate: + get: + operationId: pricingCalculate + tags: [pricing] + summary: Calculate a customer price for a product + description: >- + Guarded admin or owner. The price one line would carry (ADR 0006 section 7.3): the exact scale 4 unit price in the product's stocking unit, the conversion pair (1 and 1 until the unit sets of C3-2A), the extension rounded once to cents by the line extension rule, and the price basis in lowercase. customer_id and product_id are required UUIDs; a quantity that is not a positive decimal string at scale 4 or a job_id that is not a UUID is a 400 naming it. A missing customer or product is 404. + parameters: + - name: customer_id + in: query + required: true + schema: {type: string, format: uuid} + - name: product_id + in: query + required: true + schema: {type: string, format: uuid} + - name: quantity + in: query + required: false + description: A positive decimal string at scale 4; default "1". + schema: {type: string} + - name: job_id + in: query + required: false + schema: {type: string, format: uuid} + responses: + "200": + description: The calculated price. + content: + application/json: + schema: + $ref: "#/components/schemas/PricingCalculatedPrice" + "400": + $ref: "#/components/responses/WireBadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/WireNotFound" + "500": + $ref: "#/components/responses/InternalErrorEither" + /api/v1/pricing/rules: + get: + operationId: pricingRuleList + tags: [pricing] + summary: List pricing rules + description: >- Guarded admin or owner. The list envelope of ADR 0001 section 1, newest first (created_at DESC, id DESC); items is never null. parameters: - $ref: "#/components/parameters/Cursor" @@ -23319,742 +23475,464 @@ components: status: type: string enum: [Active, Inactive] - 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: + reason: {type: string} + revision: {type: integer, format: int64, minimum: 1, description: "The sale's revision; or send If-Match."} + PosResolveIntentRequest: + type: object + required: [outcome, reason] + properties: + outcome: type: string - description: + enum: [refund, no_refund] + description: "The terminal's actual outcome: refund (the gateway's own refund id, the money moved) or no_refund (an explicit no at the terminal)." + refund_tx_id: type: string - unit_price: - type: number - description: Float dollars. - uom: + description: "Required when the outcome is refund: the gateway's own refund id for the claim." + reconciliation_reference: type: string - in_stock: - type: number - PosCatalogProduct: + description: "Required when the outcome is no_refund: the reference finance filed for the reconciliation." + reason: {type: string, description: Why the record was made, 1 to 500 characters.: ''} + PosRefundIntentResolved: type: object - description: CatalogProduct. - required: - - product_id - - sku - - description - - price - - uom - - in_stock + required: [id, sale_id, tender_id, act, state, outcome] 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: + id: {type: string, format: uuid} + sale_id: {type: string, format: uuid} + tender_id: {type: string, format: uuid} + act: {type: string, enum: [void, return, payback]} + state: {type: string, enum: [DONE, FAILED], description: DONE when a recorded refund booked, FAILED when a no refund released the claim.: ''} + outcome: {type: string, enum: [refund, no_refund]} + refund_tx_id: {type: [string, "null"], description: The gateway refund id the operator recorded.} + reconciliation_reference: {type: [string, "null"], description: The reference the operator filed.} + PosPaybackIntentRequest: 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: [reason] 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: + reason: {type: string, description: Why the credit went back to its card, 1 to 500 characters.: ''} + PosRefundIntentPaidBack: type: object - description: The current session wrapper. - required: - - session + required: [id, payback_id, sale_id, tender_id, amount_cents, refund_tx_id, state] properties: - session: - oneOf: - - $ref: '#/components/schemas/PosTillSession' - - type: 'null' - PosTillReport: + id: {type: string, format: uuid, description: The void's own released claim the route keyed on.} + payback_id: {type: string, format: uuid, description: The payback's own claim, completed DONE with the terminal's refund id.: ''} + sale_id: {type: string, format: uuid} + tender_id: {type: string, format: uuid} + amount_cents: {type: integer, format: int64, description: The credit paid back, the tender's payment's whole unapplied cash.: ''} + refund_tx_id: {type: string, description: The terminal's own refund id for the payback.} + state: {type: string, enum: [DONE]} + PosReturnRequest: type: object - description: TillReport. - required: - - session - - sale_count - - sales_total - - tax_total - - change_given - - tendered_by_method - - expected_by_method + required: [reason, lines] 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: - type: object - additionalProperties: - type: integer - format: int64 - description: Cents keyed by tender method. - PosZReport: + 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: "Refused on a card return: the refund goes to the sale's own card tender, never a transaction the client names."} + lines: + type: array + items: + $ref: "#/components/schemas/PosReturnLineRequest" + PosReturnLineRequest: type: object - description: ZReport. - required: - - id - - till_session_id - - register_id - - over_short - - payload - - generated_at + required: [quantity] 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: + 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. Required on every line when original_sale_id is named, and named at most once: a request naming one sale line twice is refused naming the second."} + 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 - required: - - z_reports properties: - z_reports: - type: - - array - - 'null' - items: - $ref: '#/components/schemas/PosZReport' - PosReturnLine: + register_id: {type: string, description: "Defaults REG-01."} + opening_float_cents: {type: integer, format: int64, minimum: 0} + PosTillCloseRequest: type: object - description: POSReturnLine. - required: - - id - - return_id - - product_id - - description - - quantity - - uom - - unit_price - - line_total - - restock + required: [counted_by_method] 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: + counted_by_method: + type: object + 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/PosLineRequest" + tenders: + type: array items: - $ref: '#/components/schemas/PosReturn' - PosSyncError: + $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: SyncError. - required: - - client_id - - reason + 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: - client_id: - type: string - reason: - type: string - PosSyncResponse: + 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: OfflineSyncResponse. errors is omitted when empty. - required: - - batch_id - - synced_count - - duplicate_count - - error_count + 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: - batch_id: - type: string - synced_count: - type: integer - duplicate_count: - type: integer - error_count: - type: integer - errors: + 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 + 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, customer_po, job_id, 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} + customer_po: {type: [string, "null"], description: "The purchase order a charge sale names."} + job_id: {type: [string, "null"], format: uuid, description: "The customer's job the sale belongs to."} + lines: type: array items: - $ref: '#/components/schemas/PosSyncError' - PosTransactionCreate: + $ref: "#/components/schemas/PosLineItem" + tenders: + type: array + items: + $ref: "#/components/schemas/PosTender" + PosCounterJob: type: object + required: [id, name] properties: - register_id: - type: string - description: Defaults to REG-01. - cashier_id: - type: string - format: uuid - customer_id: - type: string - format: uuid - PosAddItem: + id: {type: string, format: uuid} + name: {type: string} + PosCounterCustomer: type: object - required: - - product_id - - quantity + required: [id, account_number, name, po_required, credit_limit_cents, open_receivable_cents, available_credit_cents, standing, jobs] properties: - product_id: - type: string - format: uuid - quantity: - type: number - uom: - type: string - PosTenderInput: + id: {type: string, format: uuid} + account_number: {type: string} + name: {type: string} + po_required: {type: boolean, description: "The account asks for a purchase order on a charge sale (409 po_required at completion without one)."} + credit_limit_cents: {type: [integer, "null"], format: int64, description: "Null: no limit. 0: no credit."} + open_receivable_cents: {type: integer, format: int64, description: "What the credit check reads: open invoices less open credit memos and unapplied cash."} + available_credit_cents: {type: [integer, "null"], format: int64, description: "The limit less the open receivable; negative when over; null with no limit."} + standing: {type: string, enum: [good, no_limit, no_credit, over_limit]} + jobs: + type: array + items: + $ref: "#/components/schemas/PosCounterJob" + PosSaleCustomerRequest: type: object - description: AddTenderRequest. - required: - - method - - amount + additionalProperties: false 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: + customer_id: {type: [string, "null"], format: uuid} + customer_po: {type: [string, "null"], maxLength: 100} + job_id: {type: [string, "null"], format: uuid} + PosTransactionPage: type: object - required: - - tenders + required: [items, next_cursor, limit] properties: - tenders: + items: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosTenderInput' - PosOfflineTransaction: + $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: OfflineTransaction. - required: - - client_id - - register_id - - cashier_id - - items - - tenders - - client_created_at + 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: - 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: + 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: OfflineSyncRequest. - required: - - batch_id - - items + 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: - batch_id: - type: string - register_id: - type: string - items: + 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: + allOf: + - $ref: "#/components/schemas/PosTillReport" + description: The frozen closing report, a JSON object. + 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 + items: + $ref: "#/components/schemas/PosSyncItemResult" + pending: type: array - minItems: 1 items: - $ref: '#/components/schemas/PosReturnLineRequest' + $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/account/core.go b/core/internal/account/core.go index f58a063a4..7bdb315ce 100644 --- a/core/internal/account/core.go +++ b/core/internal/account/core.go @@ -23,6 +23,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "time" "github.com/gablelbm/gable/internal/gl" @@ -35,13 +36,15 @@ import ( // Service is the AR core and the reads of the account module. type Service struct { - db *database.DB - gl *gl.Service - logger *slog.Logger - now func() time.Time - auditLog *audit.Logger - events EventRecorder - tx TxRunner + db *database.DB + gl *gl.Service + logger *slog.Logger + now func() time.Time + auditLog *audit.Logger + events EventRecorder + tx TxRunner + applyGuard InvoiceApplyGuard + tenderCreditGuard TenderCreditGuard } // NewService builds the AR core. glSvc may be nil in a unit test that posts no @@ -380,6 +383,55 @@ func (s *Service) setPaymentUnapplied(ctx context.Context, p *paymentRow, newUna return nil } +// refuseHeldInvoice refuses an application to an invoice another module +// holds mid act: the counter wires the guard (C2-5, the fourteenth review's +// P2-1) so that while a sale's void has standing refund claims, the sale's +// invoice takes no application: its open cents are the void's interim, and a +// live application would strand the void's completion. The refusal names the +// standing claims and comes before the amount checks, so the operator is +// told the invoice is held whatever they asked for. Without a guard nothing +// changes. +func (s *Service) refuseHeldInvoice(ctx context.Context, invoiceID uuid.UUID) error { + if s.applyGuard == nil { + return nil + } + claims, err := s.applyGuard.PendingVoidClaimsOfInvoice(ctx, invoiceID) + if err != nil { + return err + } + if len(claims) == 0 { + return nil + } + ids := make([]string, 0, len(claims)) + for _, id := range claims { + ids = append(ids, id.String()) + } + return conflict("void_claim_pending", + fmt.Sprintf("this invoice's sale has a void whose refund claims still stand (claims %s): settle each at POST /api/v1/pos/refund-intents/{id}/resolve before applying anything to the invoice", + strings.Join(ids, ", "))) +} + +// refuseHeldTenderCredit refuses an application of a payment's unapplied +// cash another module holds to one of its own tenders (C2-5, +// FROM-PRINCIPAL 60): a completed void leaves a declined card tender's +// charge as credit on the tender's payment, and that credit is payable +// back only to its card, never spent on another invoice. The refusal names +// the credit's origin, the void and the claim it released, and comes +// before anything of the application is written. Without a guard nothing +// changes. +func (s *Service) refuseHeldTenderCredit(ctx context.Context, paymentID uuid.UUID) error { + if s.tenderCreditGuard == nil { + return nil + } + held, ok, err := s.tenderCreditGuard.HeldTenderCredit(ctx, paymentID) + if err != nil || !ok { + return err + } + return conflict("tender_credit_held", + fmt.Sprintf("the payment's unapplied cash is the credit of the card tender the void of sale %s holds (claim %s): a declined void tender's credit is held to its own tender and applies to no invoice, payable back only to its card", + held.SaleNumber, held.IntentID)) +} + // insertApplication writes one application row. func (s *Service) insertApplication(ctx context.Context, a *appRow, on time.Time, actor string, reason *string, glEntry *uuid.UUID) error { _, err := s.ex(ctx).Exec(ctx, ` diff --git a/core/internal/account/core_payments.go b/core/internal/account/core_payments.go index 5fb2933fe..19faaebab 100644 --- a/core/internal/account/core_payments.go +++ b/core/internal/account/core_payments.go @@ -167,6 +167,15 @@ func (s *Service) applyLocked(ctx context.Context, fx *Effects, pay *paymentRow, if len(lines) == 0 { return invalidField(prefix, "name at least one invoice") } + // the counter's hold first (C2-5, FROM-PRINCIPAL 60): a payment whose + // unapplied cash is a declined void tender's credit applies to no + // invoice, whatever invoice the request names; the refusal names the + // origin and nothing of the application is written. Every path that + // applies a payment's unapplied cash (the apply route, a receipt's own + // applications, the deposit path) runs through here. + if err := s.refuseHeldTenderCredit(ctx, pay.ID); err != nil { + return err + } ids := make([]uuid.UUID, len(lines)) seen := map[uuid.UUID]bool{} var cash int64 @@ -212,6 +221,11 @@ func (s *Service) applyLocked(ctx context.Context, fx *Effects, pay *paymentRow, plans := make([]plan, 0, len(lines)) for _, l := range lines { inv := byID[l.InvoiceID] + // the counter's hold first (C2-5): a held interim invoice refuses + // before its amounts are even read + if err := s.refuseHeldInvoice(ctx, inv.ID); err != nil { + return err + } switch { case inv.Status == invVoid: return conflict("invoice_void", "the invoice is void: it takes no application") @@ -357,6 +371,11 @@ func (s *Service) ApplyCreditMemo(ctx context.Context, in ApplyCreditMemoIn) (*E } for _, l := range in.Lines { inv := byID[l.InvoiceID] + // the counter's hold first (C2-5): a held interim invoice refuses + // before its amounts are even read + if err := s.refuseHeldInvoice(ctx, inv.ID); err != nil { + return nil, err + } switch { case inv.Status == invVoid: return nil, conflict("invoice_void", "the invoice is void: it takes no application") @@ -415,6 +434,11 @@ func (s *Service) WriteOff(ctx context.Context, in WriteOffIn) (*Effects, error) if inv.Status != invUnpaid && inv.Status != invPartial { return nil, httpx.InvalidStateTransition("only an unpaid or partly paid invoice can be written off: it is " + lower(inv.Status)) } + // the counter's hold first (C2-5): a held interim invoice refuses before + // its amounts are even read + if err := s.refuseHeldInvoice(ctx, inv.ID); err != nil { + return nil, err + } if in.AmountCents > inv.Open { return nil, conflict("exceeds_open_amount", fmt.Sprintf("invoice %s has %d cents open: %d cents were asked", inv.Number, inv.Open, in.AmountCents)) } diff --git a/core/internal/account/service.go b/core/internal/account/service.go index 1e584cc14..9aefb0151 100644 --- a/core/internal/account/service.go +++ b/core/internal/account/service.go @@ -44,6 +44,50 @@ func (s *Service) WithAuditLog(l *audit.Logger) *Service { s.auditLog = l; retur // WithOutbox sets the event writer of the HTTP level acts. func (s *Service) WithOutbox(e EventRecorder) *Service { s.events = e; return s } +// InvoiceApplyGuard refuses an application to an invoice another module +// holds mid act: the counter wires it so that while a sale's void has +// standing refund claims, the sale's invoice takes no application (its open +// cents are the void's interim, and a live application would strand the +// void's completion; C2-5, the fourteenth review's P2-1). The guard answers the +// standing claims of the invoice's sale, named in the refusal. Optional: +// without it applications apply as before. +type InvoiceApplyGuard interface { + PendingVoidClaimsOfInvoice(ctx context.Context, invoiceID uuid.UUID) ([]uuid.UUID, error) +} + +// WithInvoiceApplyGuard sets the application guard. The counter's service +// satisfies it; serve wires the two. +func (s *Service) WithInvoiceApplyGuard(g InvoiceApplyGuard) *Service { s.applyGuard = g; return s } + +// HeldTenderCredit names a payment's unapplied cash another module holds to +// one tender: the credit a completed void left on a card tender whose refund +// the terminal declined (C2-5, FROM-PRINCIPAL 60). Cents is what is still +// held; SaleNumber and IntentID name the origin, the void and the claim it +// released. +type HeldTenderCredit struct { + Cents int64 + SaleID uuid.UUID + SaleNumber string + TenderID uuid.UUID + IntentID uuid.UUID +} + +// TenderCreditGuard answers whether a payment's unapplied cash is a credit +// held to its own tender: the counter wires the guard (C2-5) so the account +// core refuses any application of that cash to an invoice; the credit is +// payable back only to its card. Optional: without it applications apply as +// before. +type TenderCreditGuard interface { + HeldTenderCredit(ctx context.Context, paymentID uuid.UUID) (HeldTenderCredit, bool, error) +} + +// WithTenderCreditGuard sets the held credit guard. The counter's service +// satisfies it; serve wires the two. +func (s *Service) WithTenderCreditGuard(g TenderCreditGuard) *Service { + s.tenderCreditGuard = g + return s +} + // RecordEvents writes the events of an act through the outbox, in order. // Owners of an act call it last inside the act's transaction. func (s *Service) RecordEvents(ctx context.Context, evs []outbox.Event) error { diff --git a/core/internal/apicontract/model_shape_test.go b/core/internal/apicontract/model_shape_test.go index 8f7b026a3..c90823de2 100644 --- a/core/internal/apicontract/model_shape_test.go +++ b/core/internal/apicontract/model_shape_test.go @@ -163,20 +163,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 55f3953ba..3d4777fd5 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -656,19 +656,38 @@ 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. + // The counter also guards the AR core's applications: while a sale's void + // has standing refund claims, the sale's invoice takes no application + // (its open cents are the void's interim, C2-5), and a payment whose + // unapplied cash is a declined void tender's credit applies to no invoice + // either (the credit is payable back only to its card, C2-5, + // FROM-PRINCIPAL 60). + accountSvc.WithInvoiceApplyGuard(posSvc) + accountSvc.WithTenderCreditGuard(posSvc) + // and the payment refund route holds the same credit to the counter's + // terminal rail: the online gateway is never the way back for it + paymentSvc.WithTenderCreditGuard(posSvc) posHandler := pos.NewHandler(posSvc) wall.pos(mux, posHandler, "admin", "owner", "cashier") @@ -1310,28 +1329,6 @@ 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 -} - // rowOrNone unwraps RowFor's lookup; the table and the registrations sit // in the same change, so a miss is a wiring bug that fails loudly at boot. func rowOrNone(r links.Row, ok bool) links.Row { diff --git a/core/internal/app/serve/wire_branch_wall.go b/core/internal/app/serve/wire_branch_wall.go index 85c6999e5..f89762dc0 100644 --- a/core/internal/app/serve/wire_branch_wall.go +++ b/core/internal/app/serve/wire_branch_wall.go @@ -289,8 +289,17 @@ func (w *branchWall) draftGuard(db *database.DB, kind string, roles ...string) f // sale names (round 10 P1-1). The cashier creates refuse a machine key // outright in the handler (a cashier must be a user). func (w *branchWall) pos(mux *http.ServeMux, h *pos.Handler, roles ...string) { - h.RegisterRoutes(mux, w.patternWall(w.scoped(roles...), w.keyWall.CustomerBranch(posSyncCustomersOf()), - "POST /api/v1/pos/sync")) + // The counter's operator routes (the resolve and the payback, the finance + // money movers) run behind the finance wall: the claim's sale is read + // through the caller's branch context, so a finance user held to one + // branch finds another branch's claim a missing row (ninth review P1-3, + // ADR 0007 section 2.3). The handler also holds the claim's sale to the + // wall, which is what scopes a caller with no context branch + // (default_branch_required=false, no header) to its grants (r12 P1-2), + // the same record hold the document and purchase order routes carry. + h.WithBranchGuard(w.guard).RegisterRoutes(mux, + w.patternWall(w.scoped(roles...), w.keyWall.CustomerBranch(posSyncCustomersOf()), "POST /api/v1/pos/sync"), + w.scoped("finance", "owner")) } func (w *branchWall) purchaseOrders(mux *http.ServeMux, h *purchase_order.Handler) { diff --git a/core/internal/app/serve/wire_branch_wall_test.go b/core/internal/app/serve/wire_branch_wall_test.go index 6cf03c81e..59904a1c7 100644 --- a/core/internal/app/serve/wire_branch_wall_test.go +++ b/core/internal/app/serve/wire_branch_wall_test.go @@ -35,6 +35,7 @@ import ( "github.com/gablelbm/gable/internal/matching" "github.com/gablelbm/gable/internal/notification" "github.com/gablelbm/gable/internal/order" + "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/internal/product" "github.com/gablelbm/gable/internal/purchase_order" "github.com/gablelbm/gable/internal/quote" @@ -267,6 +268,11 @@ func newWallFixture(t *testing.T, db *database.DB, multiBranch bool) *wallFixtur docHandler := document.NewHandler(docSvc, orderSvc, invoiceSvc, customer.NewService(customer.NewRepository(db)), notification.NewLogEmailService(slog.Default())) wall.documents(mux, docHandler) wall.invoices(mux, invoice.NewService(invoice.NewRepository(db), glSvc, accountSvc, db).WithOutbox(outbox.NewWriter(db, "")).WithOrders(orderSvc).WithStock(invSvc)) + // The counter, as serve mounts it: the register routes behind the cashier + // wall, the refund claim resolution behind the finance wall (its sale read + // carries the branch context). + posSvc := pos.NewService(db, pos.NewRepository(db), slog.Default()).WithAR(accountSvc).WithInventory(invSvc).WithTxRunner(db) + wall.pos(mux, pos.NewHandler(posSvc)) f.srv = httptest.NewServer(asRole(mux)) t.Cleanup(f.srv.Close) return f @@ -467,6 +473,23 @@ func TestBranchWall_ServeWiring(t *testing.T) { t.Errorf("%s create type BRANCH: %d, want 403", role, got) } } + + // POS refund claim resolution: the finance money mover runs behind the + // branch middleware and its own finance and owner wall (the claim's sale + // read carries the branch context, so another branch's claim is a missing + // row). + resolve := func() string { + return fmt.Sprintf(`{"outcome":"no_refund","reconciliation_reference":"R","reason":"w"}`) + } + if got := f.call(t, "POST", "/api/v1/pos/refund-intents/"+uuid.NewString()+"/resolve", resolve(), "cashier", "u-a", A); got != no { + t.Errorf("cashier on the resolution: %d, want 403", got) + } + if got := f.call(t, "POST", "/api/v1/pos/refund-intents/"+uuid.NewString()+"/resolve", resolve(), "finance", "u-a", B); got != no { + t.Errorf("finance at an ungranted branch header: %d, want 403 (the branch middleware)", got) + } + if got := f.call(t, "POST", "/api/v1/pos/refund-intents/"+uuid.NewString()+"/resolve", resolve(), "finance", "u-a", A); got != http.StatusNotFound { + t.Errorf("finance inside the branch on an unknown claim: %d, want 404", got) + } if got := f.call(t, "POST", "/api/v1/locations", create("BIN", f.branchB), "admin", "boss", ""); got != http.StatusCreated { t.Errorf("admin create under any branch: %d, want 201", got) } 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 8d51e4fd4..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" @@ -307,21 +308,21 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { t.Fatalf("seed till sessions: %v", err) } if _, err := db.Pool.Exec(ctx, - `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id, till_session_id) - VALUES ($1, $2, $4, 'OPEN', $5, $6), ($3, $7, $4, 'OPEN', $8, $9)`, + `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id, till_session_id, currency) + VALUES ($1, $2, $4, 'OPEN', $5, $6, 'USD'), ($3, $7, $4, 'OPEN', $8, $9, 'USD')`, f.txnA, f.regA, f.txnB, cashier, f.branchA, f.tillA, f.regB, f.branchB, f.tillB); err != nil { t.Fatalf("seed pos transactions: %v", err) } if _, err := db.Pool.Exec(ctx, - `INSERT INTO pos_line_items (id, transaction_id, product_id, description, quantity, uom, unit_price, line_total) - VALUES ($1, $2, $4, 'pin', 1, 'EA', 1, 1), ($3, $5, $4, 'pin', 1, 'EA', 1, 1)`, + `INSERT INTO pos_line_items (id, transaction_id, product_id, description, quantity, uom, price_uom, uom_qty, price_uom_qty, unit_price, line_total, line_type, price_source) + VALUES ($1, $2, $4, 'pin', 1, 'EA', 'EA', 1, 1, 1, 1, 'PRODUCT', 'PRICE_LIST'), ($3, $5, $4, 'pin', 1, 'EA', 'EA', 1, 1, 1, 1, 'PRODUCT', 'PRICE_LIST')`, f.lineA, f.txnA, f.lineB, f.posProd, f.txnB); err != nil { t.Fatalf("seed pos lines: %v", err) } if _, err := db.Pool.Exec(ctx, - `INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, refund_method, status) - VALUES ($1, $2, $3, $5, 'CASH', 'COMPLETED'), ($6, $4, $7, $5, 'CASH', 'COMPLETED')`, - f.retA, f.regA, f.branchA, f.regB, cashier, f.retB, f.branchB); err != nil { + `INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, refund_method, status, currency, number) + VALUES ($1, $2, $3, $5, 'CASH', 'COMPLETED', 'USD', $8), ($6, $4, $7, $5, 'CASH', 'COMPLETED', 'USD', $9)`, + f.retA, f.regA, f.branchA, f.regB, cashier, f.retB, f.branchB, "RTPIN-"+f.retA.String()[:8], "RTPIN-"+f.retB.String()[:8]); err != nil { t.Fatalf("seed pos returns: %v", err) } if _, err := db.Pool.Exec(ctx, @@ -446,9 +447,11 @@ func newKeyPinFixtureSwitched(t *testing.T, switchState string) *keyPinFixture { WithAuditLog(audit.NewLogger(db)) paymentSvc := payment.NewService(db, payment.NewRepository(db), accountSvc). WithAuditLog(audit.NewLogger(db)) - posSvc := pos.NewService(db, pos.NewRepository(db), productSvc, - inventory.NewService(inventory.NewRepository(db)), invoiceSvc, paymentSvc, slog.Default()). - WithAuditLog(audit.NewLogger(db)) + posSvc := pos.NewService(db, pos.NewRepository(db), slog.Default()). + WithInvoices(invoiceSvc).WithAR(accountSvc). + WithInventory(inventory.NewService(inventory.NewRepository(db))). + WithLedger(glSvc).WithAuditLog(audit.NewLogger(db)). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db) wall.pos(mux, pos.NewHandler(posSvc), "admin", "owner", "cashier") // The AI spending routes exactly as serve registers them, each call @@ -1620,7 +1623,7 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { // The foreign till cannot be closed: no session write, no Z report. f.key(t, f.bound, "POST", "/api/v1/pos/till/"+f.tillB.String()+"/close", - `{"counted_by_method":{"CASH":50}}`, nil) + `{"counted_by_method":{"cash":5000}}`, nil) f.twinLeg("POST", "/api/v1/pos/till/"+f.tillB.String()+"/close") if !tillOpen(f.tillB) { t.Errorf("bound key closed the foreign till session") @@ -1671,7 +1674,7 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { f.twinLeg("GET", "/api/v1/pos/transactions/"+f.txnB.String()) before := linesOn(f.txnB) f.key(t, f.bound, "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/items", - fmt.Sprintf(`{"product_id":%q,"quantity":1,"uom":"EA"}`, f.posProd.String()), nil) + fmt.Sprintf(`{"line":{"product_id":%q,"quantity":"1"}}`, f.posProd.String()), nil) f.twinLeg("POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/items") if after := linesOn(f.txnB); after != before { t.Errorf("bound key line insert on the foreign transaction wrote: %d rows then %d", before, after) @@ -1683,11 +1686,45 @@ func TestKeyBranchPin_PosAndAccountRows(t *testing.T) { } before = linesOn(f.txnA) f.allow(t, "bound", "POST", "/api/v1/pos/transactions/"+f.txnA.String()+"/items", - fmt.Sprintf(`{"product_id":%q,"quantity":1,"uom":"EA"}`, f.posProd.String()), nil, http.StatusOK) + fmt.Sprintf(`{"line":{"product_id":%q,"quantity":"1"}}`, f.posProd.String()), nil, http.StatusOK) if after := linesOn(f.txnA); after != before+1 { 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. diff --git a/core/internal/app/serve/wire_key_branch_round5_test.go b/core/internal/app/serve/wire_key_branch_round5_test.go index 203d58dbe..8618e2c84 100644 --- a/core/internal/app/serve/wire_key_branch_round5_test.go +++ b/core/internal/app/serve/wire_key_branch_round5_test.go @@ -410,10 +410,10 @@ func TestKeyBranchPin_PosSyncCustomerWall(t *testing.T) { client := uuid.New() freshClient := func() string { client = uuid.New(); return client.String() } sync := func(register, customer string) string { - return fmt.Sprintf(`{"batch_id":"pin-%s","register_id":%q,"items":[{"client_id":%q,"register_id":%q,"cashier_id":%q,`+ - `"customer_id":%q,"items":[{"product_id":%q,"quantity":1,"uom":"EA"}],`+ - `"tenders":[{"method":"ACCOUNT","amount":1.08}],"client_created_at":%q}]}`, - uuid.NewString()[:8], register, freshClient(), register, uuid.NewString(), customer, + return fmt.Sprintf(`{"batch_id":"pin-%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], register, freshClient(), uuid.NewString(), customer, f.posProd.String(), time.Now().UTC().Format(time.RFC3339)) } invoicesOn := func(customer uuid.UUID) (n int) { diff --git a/core/internal/app/serve/wire_key_branch_round7_test.go b/core/internal/app/serve/wire_key_branch_round7_test.go index ce6ce9088..44ecf34b9 100644 --- a/core/internal/app/serve/wire_key_branch_round7_test.go +++ b/core/internal/app/serve/wire_key_branch_round7_test.go @@ -74,7 +74,7 @@ func TestKeyBranchPin_Migration108LegacyTillSessionStaysReachable(t *testing.T) if status != http.StatusOK || !strings.Contains(string(buf), sess.String()) { t.Errorf("current till with X-Branch-Id the register's branch: %d %s, want the open session", status, buf) } - status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/till/"+sess.String()+"/close", `{"counted_by_method":{"CASH":0.5}}`, hdr) + status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/till/"+sess.String()+"/close", `{"counted_by_method":{"cash":50}}`, hdr) if status != http.StatusOK { t.Errorf("closing the open drawer with X-Branch-Id the register's branch: %d %s, want 200", status, buf) } @@ -135,8 +135,8 @@ func TestKeyBranchPin_Migration108LegacyReturnStaysReachable(t *testing.T) { _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_returns WHERE id = $1`, ret) }) if _, err := f.db.Pool.Exec(ctx, - `INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, subtotal, tax_amount, total, refund_method, status) - VALUES ($1, $2, NULL, $3, 10.11, 0.99, 11.10, 'CASH', 'COMPLETED')`, ret, reg, uuid.New()); err != nil { + `INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, subtotal, tax_amount, total, refund_method, status, currency, number) + VALUES ($1, $2, NULL, $3, 10.11, 0.99, 11.10, 'CASH', 'COMPLETED', 'USD', $4)`, ret, reg, uuid.New(), "RTPIN7-"+ret.String()[:8]); err != nil { t.Fatal(err) } f.applyMigration108(t) @@ -249,7 +249,7 @@ func TestKeyBranchPin_TillOpenAndReturnsHoldTheRequestBranch(t *testing.T) { }) // The till open on another branch's register: 403, no session. - status, buf := f.userAs(t, "admin", "POST", "/api/v1/pos/till/open", `{"register_id":"`+regB+`","opening_float":50}`, hdr) + status, buf := f.userAs(t, "admin", "POST", "/api/v1/pos/till/open", `{"register_id":"`+regB+`","opening_float_cents":5000}`, hdr) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a different branch than the request") { t.Errorf("a user opening a till on another branch's register: %d %s, want the 403 naming the register rule", status, buf) } @@ -258,8 +258,9 @@ func TestKeyBranchPin_TillOpenAndReturnsHoldTheRequestBranch(t *testing.T) { } // The return on another branch's register: 403, no return. - retBody := `{"register_id":"` + regB + `","refund_method":"CASH","lines":[{"product_id":"` + - f.posProd.String() + `","quantity":1,"unit_price":1}]}` + retBody := `{"register_id":"` + regB + `","refund_method":"cash","reason":"pin",` + + `"customer_id":"` + f.custID.String() + `","lines":[{"product_id":"` + + f.posProd.String() + `","quantity":"1","unit_price_ten_thousandths":10000,"restock":false}]}` status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/returns", retBody, hdr) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a different branch than the request") { t.Errorf("a user recording a return on another branch's register: %d %s, want the 403 naming the register rule", status, buf) @@ -269,7 +270,7 @@ func TestKeyBranchPin_TillOpenAndReturnsHoldTheRequestBranch(t *testing.T) { } // The A controls: the caller's own register passes for both. - status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/till/open", `{"register_id":"`+regA+`","opening_float":50}`, hdr) + status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/till/open", `{"register_id":"`+regA+`","opening_float_cents":5000}`, hdr) var sess struct { ID uuid.UUID `json:"id"` BranchID *uuid.UUID `json:"branch_id"` @@ -278,8 +279,9 @@ func TestKeyBranchPin_TillOpenAndReturnsHoldTheRequestBranch(t *testing.T) { if status != http.StatusCreated || sess.BranchID == nil || *sess.BranchID != f.branchA { t.Errorf("a user opening a till on its own register: %d %s, want 201 at branch A", status, buf) } - retBody = `{"register_id":"` + regA + `","refund_method":"CASH","lines":[{"product_id":"` + - f.posProd.String() + `","quantity":1,"unit_price":1}]}` + retBody = `{"register_id":"` + regA + `","refund_method":"cash","reason":"pin",` + + `"customer_id":"` + f.custID.String() + `","lines":[{"product_id":"` + + f.posProd.String() + `","quantity":"1","unit_price_ten_thousandths":10000,"restock":false}]}` status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/returns", retBody, hdr) var ret struct { ID uuid.UUID `json:"id"` @@ -302,16 +304,22 @@ func TestKeyBranchPin_SyncRefusesAnotherBranchsBatchRegister(t *testing.T) { ctx := context.Background() client := uuid.New() batch := "pin-r7-" + uuid.NewString()[:8] + // the control's sale completes, so the pin's product needs stock + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO inventory (product_id, location_id, location, quantity) VALUES ($1, NULL, '', 50)`, f.posProd); err != nil { + t.Fatalf("seed stock: %v", err) + } t.Cleanup(func() { _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id = $1`, client) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id = $1`, client) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_sync_log WHERE batch_id = $1`, batch) }) - syncBody := func(batchRegister, itemRegister string) string { + syncBody := func(batchRegister, _ string) string { return `{"batch_id":"` + batch + `","register_id":"` + batchRegister + - `","items":[{"client_id":"` + client.String() + `","register_id":"` + itemRegister + + `","items":[{"client_id":"` + client.String() + `","cashier_id":"` + uuid.NewString() + `","customer_id":"` + f.custID.String() + - `","items":[],"tenders":[],"client_created_at":"` + time.Now().UTC().Format(time.RFC3339) + `"}]}` + `","items":[{"product_id":"` + f.posProd.String() + `","quantity":"1"}],` + + `"tenders":[{"method":"cash","amount_cents":105}],"client_created_at":"` + time.Now().UTC().Format(time.RFC3339) + `"}]}` } logRows := func() (n int) { if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM pos_sync_log WHERE batch_id = $1`, batch).Scan(&n); err != nil { diff --git a/core/internal/app/serve/wire_key_branch_round8_test.go b/core/internal/app/serve/wire_key_branch_round8_test.go index e3b35123d..7e5671524 100644 --- a/core/internal/app/serve/wire_key_branch_round8_test.go +++ b/core/internal/app/serve/wire_key_branch_round8_test.go @@ -46,6 +46,11 @@ func TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister(t *testing.T) { ctx := context.Background() client := uuid.New() batch := "pin-r8-" + uuid.NewString()[:8] + // the control's sale completes, so the pin's product needs stock + if _, err := f.db.Pool.Exec(ctx, + `INSERT INTO inventory (product_id, location_id, location, quantity) VALUES ($1, NULL, '', 50)`, f.posProd); err != nil { + t.Fatalf("seed stock: %v", err) + } t.Cleanup(func() { _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_line_items WHERE transaction_id = $1`, client) _, _ = f.db.Pool.Exec(ctx, `DELETE FROM pos_transactions WHERE id = $1`, client) @@ -53,9 +58,10 @@ func TestKeyBranchPin_SyncRefusesAnUnknownBatchRegister(t *testing.T) { }) syncBody := func(batchRegister string) string { return `{"batch_id":"` + batch + `","register_id":"` + batchRegister + - `","items":[{"client_id":"` + client.String() + `","register_id":"` + f.regA + + `","items":[{"client_id":"` + client.String() + `","cashier_id":"` + uuid.NewString() + `","customer_id":"` + f.custID.String() + - `","items":[],"tenders":[],"client_created_at":"` + time.Now().UTC().Format(time.RFC3339) + `"}]}` + `","items":[{"product_id":"` + f.posProd.String() + `","quantity":"1"}],` + + `"tenders":[{"method":"cash","amount_cents":105}],"client_created_at":"` + time.Now().UTC().Format(time.RFC3339) + `"}]}` } logRows := func() (n int) { if err := f.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM pos_sync_log WHERE batch_id = $1`, batch).Scan(&n); err != nil { diff --git a/core/internal/app/serve/wire_key_branch_walled_test.go b/core/internal/app/serve/wire_key_branch_walled_test.go index 148e46043..ee0495ce1 100644 --- a/core/internal/app/serve/wire_key_branch_walled_test.go +++ b/core/internal/app/serve/wire_key_branch_walled_test.go @@ -288,8 +288,8 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { // the fixture's. txnA2 := uuid.New() if _, err := f.db.Pool.Exec(context.Background(), - `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id, till_session_id) - SELECT $1, register_id, cashier_id, 'OPEN', branch_id, till_session_id FROM pos_transactions WHERE id = $2`, txnA2, f.txnA); err != nil { + `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id, till_session_id, currency) + SELECT $1, register_id, cashier_id, 'OPEN', branch_id, till_session_id, currency FROM pos_transactions WHERE id = $2`, txnA2, f.txnA); err != nil { t.Fatalf("seed the second open transaction: %v", err) } if _, err := f.db.Pool.Exec(context.Background(), @@ -332,17 +332,21 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { } return f.regA } - posTender := fixed(`{"tenders":[{"method":"CASH","amount":5}]}`) + posTender := fixed(`{"tenders":[{"method":"cash","amount_cents":500}]}`) posReturn := func(row uuid.UUID) string { - return fmt.Sprintf(`{"register_id":%q,"refund_method":"CASH","reason":"pin","lines":[{"product_id":%q,"quantity":1,"unit_price":1}]}`, regOf(row), f.posProd.String()) + return fmt.Sprintf(`{"register_id":%q,"refund_method":"cash","reason":"pin","customer_id":%q,`+ + `"lines":[{"product_id":%q,"quantity":"1","unit_price_ten_thousandths":10000,"restock":false}]}`, + regOf(row), f.custID.String(), f.posProd.String()) } posSync := func(row uuid.UUID) string { - return fmt.Sprintf(`{"batch_id":"pin-%s","register_id":%q,"items":[{"client_id":%q,"register_id":%q,"cashier_id":%q,`+ - `"items":[{"product_id":%q,"quantity":1,"uom":"EA"}],"tenders":[{"method":"CASH","amount":5}],"client_created_at":%q}]}`, - uuid.NewString()[:8], regOf(row), uuid.NewString(), regOf(row), uuid.NewString(), f.posProd.String(), time.Now().UTC().Format(time.RFC3339)) + return fmt.Sprintf(`{"batch_id":"pin-%s","register_id":%q,"items":[{"client_id":%q,"cashier_id":%q,`+ + `"customer_id":%q,"items":[{"product_id":%q,"quantity":"1"}],`+ + `"tenders":[{"method":"cash","amount_cents":105}],"client_created_at":%q}]}`, + uuid.NewString()[:8], regOf(row), uuid.NewString(), uuid.NewString(), f.custID.String(), + f.posProd.String(), time.Now().UTC().Format(time.RFC3339)) } posOpen := func(row uuid.UUID) string { - return fmt.Sprintf(`{"register_id":%q,"opening_float":10}`, regOf(row)) + return fmt.Sprintf(`{"register_id":%q,"opening_float_cents":1000}`, regOf(row)) } posStart := func(row uuid.UUID) string { return fmt.Sprintf(`{"register_id":%q,"cashier_id":%q}`, regOf(row), uuid.NewString()) @@ -483,7 +487,7 @@ func TestKeyBranchPin_WalledRowsHoldThePin(t *testing.T) { // the wall (it answers a missing one 500). {method: "POST", path: "/api/v1/pos/transactions/%s/complete", body: posTender, twin: f.txnB, own: f.txnA, first: true, sameAsMissing: true, noControl: "the completion deducts stock from a location this fixture does not stock, so the control answers 422 past the body and the transaction read"}, - {method: "POST", path: "/api/v1/pos/transactions/%s/void", twin: f.txnB, own: txnA2, first: true, sameAsMissing: true}, + {method: "POST", path: "/api/v1/pos/transactions/%s/void", body: fixed(`{"reason":"pin"}`), twin: f.txnB, own: txnA2, first: true, sameAsMissing: true}, // The sync is held by the module itself now: a batch whose register // names another branch is refused before any item runs (round 7), so // the foreign leg is a held leg and the control on the pin's own 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..12427c512 --- /dev/null +++ b/core/internal/app/serve/wire_noheader_customer_test.go @@ -0,0 +1,119 @@ +// 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 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) + 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/app/serve/wire_noheader_grants_test.go b/core/internal/app/serve/wire_noheader_grants_test.go index b688f78b5..887e85fb5 100644 --- a/core/internal/app/serve/wire_noheader_grants_test.go +++ b/core/internal/app/serve/wire_noheader_grants_test.go @@ -110,7 +110,7 @@ func TestNoHeader_RegistersAndTills(t *testing.T) { t.Fatalf("pre: %d sessions on the B register", n) } status, buf := f.cashierNoHeader(t, "POST", "/api/v1/pos/till/open", - `{"register_id":"`+regNB+`","opening_float":10}`) + `{"register_id":"`+regNB+`","opening_float_cents":1000}`) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a different branch than the request") { t.Errorf("no header open on B's register: %d %s, want the 403 naming the register rule", status, buf) } @@ -124,7 +124,7 @@ func TestNoHeader_RegistersAndTills(t *testing.T) { t.Fatalf("pre: %d Z reports on B's session, want the fixture's one", n) } status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/till/"+f.tillB.String()+"/close", - `{"counted_by_method":{"CASH":50}}`) + `{"counted_by_method":{"cash":5000}}`) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a branch this caller may not target") { t.Errorf("no header close of B's session: %d %s, want the 403 naming the record rule", status, buf) } @@ -137,8 +137,8 @@ func TestNoHeader_RegistersAndTills(t *testing.T) { // Current, report and Z report on B's drawer read as none or missing. status, buf = f.cashierNoHeader(t, "GET", "/api/v1/pos/till/current?register_id="+f.regB, "") - if status != http.StatusOK || strings.Contains(string(buf), `"session":{`) { - t.Errorf("no header current till on B's register: %d %s, want no session", status, buf) + if status != http.StatusNotFound { + t.Errorf("no header current till on B's register: %d %s, want 404 (no open session)", status, buf) } status, buf = f.cashierNoHeader(t, "GET", "/api/v1/pos/till/"+f.tillB.String()+"/report", "") if status != http.StatusNotFound { @@ -150,7 +150,7 @@ func TestNoHeader_RegistersAndTills(t *testing.T) { } // The Z report list carries A's rows only. - status, buf = f.cashierNoHeader(t, "GET", "/api/v1/pos/zreports", "") + status, buf = f.cashierNoHeader(t, "GET", "/api/v1/pos/zreports?date="+time.Now().UTC().Format("2006-01-02"), "") if status != http.StatusOK || !strings.Contains(string(buf), f.zrA.String()) { t.Errorf("no header Z report list: %d %s, want A's row", status, buf) } @@ -172,7 +172,7 @@ func TestNoHeader_RegistersAndTills(t *testing.T) { _, _ = f.db.Pool.Exec(context.Background(), `DELETE FROM till_sessions WHERE id = $1`, nullSess) }) status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/till/"+nullSess.String()+"/close", - `{"counted_by_method":{"CASH":10}}`) + `{"counted_by_method":{"cash":1000}}`) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a branch this caller may not target") { t.Errorf("no header close of a NULL branch session: %d %s, want the 403 the record rule names", status, buf) } @@ -183,19 +183,19 @@ func TestNoHeader_RegistersAndTills(t *testing.T) { t.Errorf("the refused close wrote %d Z reports on the NULL branch session, want none", n) } status, buf = f.userAs(t, "admin", "POST", "/api/v1/pos/till/"+nullSess.String()+"/close", - `{"counted_by_method":{"CASH":10}}`, nil) + `{"counted_by_method":{"cash":1000}}`, nil) if status != http.StatusOK { t.Errorf("admin close of a NULL branch session: %d %s, want 200", status, buf) } // The controls on A: the open lands, and the reads answer. status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/till/open", - `{"register_id":"`+regNA+`","opening_float":10}`) + `{"register_id":"`+regNA+`","opening_float_cents":1000}`) if status != http.StatusCreated { t.Errorf("no header open on A's register: %d %s, want 201", status, buf) } status, buf = f.cashierNoHeader(t, "GET", "/api/v1/pos/till/current?register_id="+f.regA, "") - if status != http.StatusOK || !strings.Contains(string(buf), `"session":{`) { + if status != http.StatusOK || !strings.Contains(string(buf), f.tillA.String()) { t.Errorf("no header current till on A's register: %d %s, want the session", status, buf) } status, buf = f.cashierNoHeader(t, "GET", "/api/v1/pos/till/"+f.tillA.String()+"/report", "") @@ -241,7 +241,7 @@ func TestNoHeader_Sales(t *testing.T) { t.Fatalf("pre: %d lines on B's sale", n) } status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/items", - `{"product_id":"`+f.posProd.String()+`","quantity":1}`) + `{"line":{"product_id":"`+f.posProd.String()+`","quantity":"1"}}`) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a branch this caller may not target") { t.Errorf("no header add item to B's sale: %d %s, want the 403 naming the record rule", status, buf) } @@ -272,7 +272,7 @@ func TestNoHeader_Sales(t *testing.T) { // Complete B's sale: refused, no tender, the sale stays open. status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/complete", - `{"tenders":[{"method":"CASH","amount":0}]}`) + `{"tenders":[{"method":"cash","amount_cents":100}]}`) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a branch this caller may not target") { t.Errorf("no header complete of B's sale: %d %s, want the 403 naming the record rule", status, buf) } @@ -284,7 +284,7 @@ func TestNoHeader_Sales(t *testing.T) { } // Void B's sale: refused, the sale stays open. - status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/void", "") + status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/void", `{"reason":"pin"}`) if status != http.StatusForbidden || !strings.Contains(string(buf), "belongs to a branch this caller may not target") { t.Errorf("no header void of B's sale: %d %s, want the 403 naming the record rule", status, buf) } @@ -303,6 +303,29 @@ func TestNoHeader_Sales(t *testing.T) { } } +// noheaderStock holds stock of the POS product in A's yard: a synced sale +// issues it and a return restocks it (the fixture's cleanup removes the +// product's inventory rows). +func noheaderStock(t *testing.T, f *keyPinFixture) { + t.Helper() + if _, err := f.db.Pool.Exec(context.Background(), + `INSERT INTO inventory (product_id, location_id, location, quantity, allocated) VALUES ($1, $2, 'Y', 100, 0)`, + f.posProd, f.yardA); err != nil { + t.Fatalf("seed stock: %v", err) + } +} + +// noheaderTaxRate gives the branch a default tax rate: a return or a sale +// completion refuses without one (tax_rate_not_configured), and the fixture's +// branches carry none. +func noheaderTaxRate(t *testing.T, f *keyPinFixture, branch uuid.UUID) { + t.Helper() + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE locations SET default_tax_rate = 0.05 WHERE id = $1`, branch); err != nil { + t.Fatalf("set the branch tax rate: %v", err) + } +} + // TestNoHeader_Returns is the returns family: recording a return on B's // register, reading and listing B's return. The create writes nothing and // answers the register refusal; the reads answer missing; the list carries @@ -313,10 +336,14 @@ func TestNoHeader_Returns(t *testing.T) { regNB := noheaderRegister(t, f, f.branchB, f.yardB) regNA := noheaderRegister(t, f, f.branchA, f.yardA) noheaderPurgeReturn(t, f, regNA) + // A cash return pays out of an open drawer: A's seeded register holds one. + noheaderPurgeReturn(t, f, f.regA) + noheaderTaxRate(t, f, f.branchA) + noheaderStock(t, f) body := func(register string) string { - return `{"register_id":"` + register + `","refund_method":"CASH","lines":[{"product_id":"` + - f.posProd.String() + `","quantity":1,"unit_price":1.00}]}` + return `{"register_id":"` + register + `","refund_method":"cash","reason":"pin","lines":[{"product_id":"` + + f.posProd.String() + `","quantity":"1","unit_price_ten_thousandths":10000}]}` } // Create on B's register: refused, no return written. @@ -342,7 +369,7 @@ func TestNoHeader_Returns(t *testing.T) { } // The control on A: the return lands. - status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/returns", body(regNA)) + status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/returns", body(f.regA)) if status != http.StatusCreated { t.Errorf("no header return on A's register: %d %s, want 201", status, buf) } @@ -355,6 +382,8 @@ func TestNoHeader_Sync(t *testing.T) { f := newKeyPinFixture(t) ctx := context.Background() noheaderGrant(t, f, "pin-cashier", f.branchA) + noheaderTaxRate(t, f, f.branchA) + noheaderStock(t, f) batch := "nh-" + uuid.NewString()[:8] clientB, clientA := uuid.New(), uuid.New() t.Cleanup(func() { @@ -364,8 +393,9 @@ func TestNoHeader_Sync(t *testing.T) { }) body := func(register string, client uuid.UUID) string { return `{"batch_id":"` + batch + `","register_id":"` + register + - `","items":[{"client_id":"` + client.String() + `","register_id":"` + register + - `","cashier_id":"` + uuid.NewString() + `","items":[],"tenders":[],"client_created_at":"` + + `","items":[{"client_id":"` + client.String() + + `","cashier_id":"` + uuid.NewString() + `","items":[{"product_id":"` + f.posProd.String() + + `","quantity":"1"}],"tenders":[{"method":"cash","amount_cents":100000}],"client_created_at":"` + time.Now().UTC().Format(time.RFC3339) + `"}]}` } @@ -494,8 +524,8 @@ func TestNoHeader_UserWithNoGrants(t *testing.T) { regNA := noheaderRegister(t, f, f.branchA, f.yardA) status, buf := f.cashierNoHeader(t, "GET", "/api/v1/pos/till/current?register_id="+f.regA, "") - if status != http.StatusOK || strings.Contains(string(buf), `"session":{`) { - t.Errorf("grants-less current till on A's register: %d %s, want no session", status, buf) + if status != http.StatusNotFound { + t.Errorf("grants-less current till on A's register: %d %s, want 404 (no open session)", status, buf) } status, buf = f.cashierNoHeader(t, "GET", "/api/v1/pos/transactions/"+f.txnA.String(), "") if status != http.StatusNotFound { @@ -506,7 +536,7 @@ func TestNoHeader_UserWithNoGrants(t *testing.T) { t.Errorf("grants-less sale list: %d %s, want no rows", status, buf) } status, buf = f.cashierNoHeader(t, "POST", "/api/v1/pos/till/open", - `{"register_id":"`+regNA+`","opening_float":10}`) + `{"register_id":"`+regNA+`","opening_float_cents":1000}`) if status != http.StatusForbidden { t.Errorf("grants-less open on A's register: %d %s, want 403", status, buf) } @@ -528,7 +558,7 @@ func TestNoHeader_AdminKeepsReach(t *testing.T) { t.Errorf("admin no header read of B's sale: %d %s, want 200", status, buf) } status, buf = f.userAs(t, "admin", "GET", "/api/v1/pos/till/current?register_id="+f.regB, "", nil) - if status != http.StatusOK || !strings.Contains(string(buf), `"session":{`) { + if status != http.StatusOK || !strings.Contains(string(buf), f.tillB.String()) { t.Errorf("admin no header current till on B's register: %d %s, want the session", status, buf) } status, buf = f.userAs(t, "admin", "GET", "/api/v1/pos/transactions", "", nil) diff --git a/core/internal/app/serve/wire_noheader_round3_test.go b/core/internal/app/serve/wire_noheader_round3_test.go index 8ba2ff846..504b17081 100644 --- a/core/internal/app/serve/wire_noheader_round3_test.go +++ b/core/internal/app/serve/wire_noheader_round3_test.go @@ -217,7 +217,7 @@ func TestNoHeader_PosMissingRecordIsNotFound(t *testing.T) { missing := uuid.NewString() for _, hdr := range []map[string]string{nil, headerA(f)} { - status, buf := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+missing+"/void", "", hdr) + status, buf := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+missing+"/void", `{"reason":"pin"}`, hdr) if status != http.StatusNotFound { t.Errorf("void of a missing sale (header %v): %d %s, want 404", hdr != nil, status, buf) } @@ -229,7 +229,7 @@ func TestNoHeader_PosMissingRecordIsNotFound(t *testing.T) { // The control: a record of another branch still answers the record // refusal by name, not a 404. - status, buf := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/void", "", nil) + status, buf := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/void", `{"reason":"pin"}`, nil) if status != http.StatusForbidden || !strings.Contains(string(buf), "branch this caller may not target") { t.Errorf("void of B's sale: %d %s, want the 403 record refusal", status, buf) } diff --git a/core/internal/app/serve/wire_noheader_round4_test.go b/core/internal/app/serve/wire_noheader_round4_test.go index ee866b879..187d6a90e 100644 --- a/core/internal/app/serve/wire_noheader_round4_test.go +++ b/core/internal/app/serve/wire_noheader_round4_test.go @@ -480,11 +480,11 @@ func TestNoHeader_ReportScheduleGuardsHold(t *testing.T) { func posHiddenLegs(f *keyPinFixture) []struct{ label, method, path, body string } { tx, till, line := f.txnB.String(), f.tillB.String(), f.lineB.String() return []struct{ label, method, path, body string }{ - {"close the till session", "POST", "/api/v1/pos/till/" + till + "/close", `{"counted_by_method":{"CASH":50}}`}, - {"add a line", "POST", "/api/v1/pos/transactions/" + tx + "/items", `{"product_id":"` + f.posProd.String() + `","quantity":1,"uom":"EA"}`}, + {"close the till session", "POST", "/api/v1/pos/till/" + till + "/close", `{"counted_by_method":{"cash":5000}}`}, + {"add a line", "POST", "/api/v1/pos/transactions/" + tx + "/items", `{"line":{"product_id":"` + f.posProd.String() + `","quantity":"1","uom":"EA"}}`}, {"remove a line", "DELETE", "/api/v1/pos/transactions/" + tx + "/items/" + line, ""}, - {"complete the sale", "POST", "/api/v1/pos/transactions/" + tx + "/complete", `{"tenders":[{"method":"CASH","amount":50}]}`}, - {"void the sale", "POST", "/api/v1/pos/transactions/" + tx + "/void", ""}, + {"complete the sale", "POST", "/api/v1/pos/transactions/" + tx + "/complete", `{"tenders":[{"method":"cash","amount_cents":5000}]}`}, + {"void the sale", "POST", "/api/v1/pos/transactions/" + tx + "/void", `{"reason":"pin"}`}, } } @@ -543,11 +543,11 @@ func TestNoHeader_PosVoidOfAHiddenSaleIsMissing(t *testing.T) { f := newKeyPinFixture(t) noheaderGrant(t, f, "pin-cashier", f.branchA) before := posBState(t, f) - status, buf := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/void", "", headerA(f)) + status, buf := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+f.txnB.String()+"/void", `{"reason":"pin"}`, headerA(f)) if status != http.StatusNotFound { t.Errorf("header A cashier void of B's live sale: %d %s, want the missing sale's 404", status, buf) } - if mStatus, _ := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+uuid.NewString()+"/void", "", headerA(f)); mStatus != status { + if mStatus, _ := f.userAs(t, "cashier", "POST", "/api/v1/pos/transactions/"+uuid.NewString()+"/void", `{"reason":"pin"}`, headerA(f)); mStatus != status { t.Errorf("a missing sale answers %d, B's hidden sale %d, want the same", mStatus, status) } if after := posBState(t, f); after != before { diff --git a/core/internal/characterization/golden_test.go b/core/internal/characterization/golden_test.go index d9b422e96..93641d3a7 100644 --- a/core/internal/characterization/golden_test.go +++ b/core/internal/characterization/golden_test.go @@ -430,7 +430,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 2c22393bb..5e8199c07 100644 --- a/core/internal/characterization/harness_test.go +++ b/core/internal/characterization/harness_test.go @@ -713,7 +713,8 @@ 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) } @@ -723,6 +724,20 @@ func (h *harness) doStep(t *testing.T, s stepDef) capturedStep { } } 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 352737588..70ae1a8c9 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 981b34972..3238c7c21 100644 --- a/core/internal/characterization/scenarios_r1b_c_test.go +++ b/core/internal/characterization/scenarios_r1b_c_test.go @@ -486,6 +486,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", @@ -496,7 +500,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", @@ -504,77 +508,107 @@ 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}, {name: "pos.transaction.get", method: "GET", path: "/api/v1/pos/transactions/{c_tx}"}, {name: "pos.transaction.get.not_found", method: "GET", path: "/api/v1/pos/transactions/" + cMissingID}, {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", method: "GET", path: "/api/v1/pos/transactions?register_id=REG-01", sortPrimaryArray: true, sortEnvelopeItems: 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}, + {name: "pos.transaction.list.after", method: "GET", path: "/api/v1/pos/transactions?register_id=REG-01", sortPrimaryArray: true, sortEnvelopeItems: 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, sortEnvelopeItems: true}, }, }, { @@ -586,10 +620,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", }}, }, @@ -600,10 +633,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", }}, }, @@ -615,8 +647,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", }}, }, @@ -626,7 +658,7 @@ func r1bCPOSGroups() []groupDef { {name: "pos.sync.no_items", method: "POST", path: "/api/v1/pos/sync", body: map[string]any{"batch_id": "r1c-batch-3", "items": []map[string]any{}}}, {name: "pos.sync.bad_body", method: "POST", path: "/api/v1/pos/sync", body: "not an object"}, - {name: "pos.transaction.list.after_sync", method: "GET", path: "/api/v1/pos/transactions?register_id=REG-01", sortPrimaryArray: true}, + {name: "pos.transaction.list.after_sync", method: "GET", path: "/api/v1/pos/transactions?register_id=REG-01", sortPrimaryArray: true, sortEnvelopeItems: true}, }, }, { @@ -635,9 +667,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"}, @@ -645,29 +677,29 @@ 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}, {name: "pos.return.get.bad_id", method: "GET", path: "/api/v1/pos/returns/not-a-uuid"}, - {name: "pos.return.list", method: "GET", path: "/api/v1/pos/returns?register_id=REG-01", sortPrimaryArray: true}, + {name: "pos.return.list", method: "GET", path: "/api/v1/pos/returns?register_id=REG-01", sortPrimaryArray: true, sortEnvelopeItems: true}, {name: "pos.return.list.other_day", method: "GET", path: "/api/v1/pos/returns?register_id=REG-01&date={today-30}"}, {name: "pos.return.list.unknown_register", method: "GET", path: "/api/v1/pos/returns?register_id=REG-NONE"}, }, @@ -681,23 +713,26 @@ 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"}, + // The frozen report's payload is pinned whole: the normaliser + // rewrites its ids and timestamps the same way it does the + // live report's, so the golden shows the field. {name: "pos.till.zreport", method: "GET", path: "/api/v1/pos/till/{c_till}/zreport"}, {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"}, diff --git a/core/internal/characterization/testdata/goldens/pos.json b/core/internal/characterization/testdata/goldens/pos.json index 9fde58ea4..f0cd94cfb 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" } }, @@ -15,12 +15,17 @@ "body": { "branch_id": "", "cashier_id": "", + "closed_at": null, + "counted_by_method": {}, + "expected_by_method": {}, + "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" } } }, @@ -34,16 +39,19 @@ "status": 200, "content_type": "application/json", "body": { - "session": { - "branch_id": "", - "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": {}, + "expected_by_method": {}, + "gl_entry_id": null, + "id": "", + "notes": "", + "opened_at": "", + "opening_float_cents": 10000, + "over_short_cents": null, + "register_id": "REG-01", + "status": "open" } } }, @@ -62,17 +70,26 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", + "currency": "USD", "customer_id": "", - "id": "", + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [], + "number": "POS-000001", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], "till_session_id": "", - "total": 0 + "total_cents": 0 } } }, @@ -83,17 +100,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" }, - "meta": { - "request_id": "" + { + "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" + }, + { + "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 2051d99bf..5f781be75 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" } }, @@ -25,29 +25,33 @@ "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 } } }, @@ -57,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" } }, @@ -75,32 +80,35 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", + "cashier_id": "", "created_at": "", - "customer_id": "", - "gl_entry_id": "", + "credit_memo_id": "", + "currency": "USD", + "customer_id": "", "id": "", "lines": [ { "description": "", "id": "", - "line_total": 3000, - "product_id": "", - "quantity": 1, + "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 } } }, @@ -113,21 +121,28 @@ "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": 409, "content_type": "application/json", "body": { "error": { - "code": "BAD_REQUEST", - "message": "Bad Request" + "code": "conflict", + "details": [ + { + "code": "walk_in_account", + "message": "account credit needs a named customer: the walk-in customer is refunded from the drawer or to the card" + } + ], + "message": "account credit needs a named customer: the walk-in customer is refunded from the drawer or to the card" }, "meta": { "request_id": "" @@ -142,7 +157,8 @@ "path": "/api/v1/pos/returns", "body": { "lines": [], - "refund_method": "CASH", + "reason": "r1b c", + "refund_method": "cash", "register_id": "REG-01" } }, @@ -151,8 +167,14 @@ "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": "" @@ -169,10 +191,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" } @@ -182,8 +205,14 @@ "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": "" @@ -200,11 +229,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" } }, @@ -213,8 +243,14 @@ "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": "" @@ -234,8 +270,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": "" @@ -247,7 +283,7 @@ "name": "pos.return.get", "request": { "method": "GET", - "path": "/api/v1/pos/returns/" + "path": "/api/v1/pos/returns/" }, "response": { "status": 200, @@ -256,29 +292,33 @@ "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 } } }, @@ -293,8 +333,8 @@ "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "return not found" }, "meta": { "request_id": "" @@ -313,8 +353,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": "" @@ -325,6 +371,7 @@ { "name": "pos.return.list", "sort_primary_array": true, + "sort_envelope_items": true, "request": { "method": "GET", "path": "/api/v1/pos/returns?register_id=REG-01" @@ -332,41 +379,48 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "returns": [ - { - "branch_id": "", - "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 - }, - { - "branch_id": "", - "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": [], + "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": [], + "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 + } + ] } }, { @@ -378,9 +432,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "returns": null - } + "body": [] } }, { @@ -392,9 +444,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "returns": null - } + "body": [] } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_sync.json b/core/internal/characterization/testdata/goldens/pos_sync.json index 82ada0d46..eb463e9fb 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" } ] } @@ -36,6 +35,9 @@ "batch_id": "r1c-batch-1", "duplicate_count": 0, "error_count": 0, + "errors": [], + "pending": [], + "pending_count": 0, "synced_count": 1 } } @@ -55,14 +57,13 @@ "items": [ { "product_id": "", - "quantity": 1 + "quantity": "1" } ], - "register_id": "REG-01", "tenders": [ { - "amount": 200, - "method": "CASH" + "amount_cents": 20000, + "method": "cash" } ] } @@ -77,6 +78,9 @@ "batch_id": "r1c-batch-1", "duplicate_count": 1, "error_count": 0, + "errors": [], + "pending": [], + "pending_count": 0, "synced_count": 0 } } @@ -96,13 +100,13 @@ "items": [ { "product_id": "", - "quantity": 1 + "quantity": "1" } ], "tenders": [ { - "amount": 0.01, - "method": "CASH" + "amount_cents": 1, + "method": "cash" } ] } @@ -120,9 +124,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": [], + "pending_count": 0, "synced_count": 0 } } @@ -146,8 +152,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 +186,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 +213,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": "" @@ -203,6 +225,7 @@ { "name": "pos.transaction.list.after_sync", "sort_primary_array": true, + "sort_envelope_items": true, "request": { "method": "GET", "path": "/api/v1/pos/transactions?register_id=REG-01" @@ -210,59 +233,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": "", + "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": "", + "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-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 fe568eab3..ebac765c7 100644 --- a/core/internal/characterization/testdata/goldens/pos_till_close.json +++ b/core/internal/characterization/testdata/goldens/pos_till_close.json @@ -9,25 +9,30 @@ "status": 200, "content_type": "application/json", "body": { - "change_given": 48596, + "change_cents": 67981, "expected_by_method": { - "CASH": 11404 + "CASH": 9219 }, - "sale_count": 1, - "sales_total": 1872, + "sale_count": 2, + "sales_total_cents": 2019, "session": { "branch_id": "", "cashier_id": "", + "closed_at": null, + "counted_by_method": {}, + "expected_by_method": {}, + "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": 66, "tendered_by_method": { - "CASH": 50000 + "CASH": 2019 } } } @@ -43,8 +48,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": "" @@ -63,8 +74,8 @@ "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "till session not found" }, "meta": { "request_id": "" @@ -83,8 +94,8 @@ "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": "" @@ -103,8 +114,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": "" @@ -119,7 +136,7 @@ "path": "/api/v1/pos/till/not-a-uuid/close", "body": { "counted_by_method": { - "CASH": 1 + "cash": 100 } } }, @@ -128,8 +145,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": "" @@ -149,8 +172,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": "" @@ -165,7 +188,7 @@ "path": "/api/v1/pos/till//close", "body": { "counted_by_method": { - "CASH": 1 + "cash": 100 } } }, @@ -174,8 +197,8 @@ "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "till session not found" }, "meta": { "request_id": "" @@ -190,7 +213,7 @@ "path": "/api/v1/pos/till//close", "body": { "counted_by_method": { - "CASH": 200 + "cash": 20000 }, "notes": "r1b c close count" } @@ -199,12 +222,12 @@ "status": 200, "content_type": "application/json", "body": { - "change_given": 48596, + "change_cents": 67981, "expected_by_method": { - "CASH": 11404 + "CASH": 9219 }, - "sale_count": 1, - "sales_total": 1872, + "sale_count": 2, + "sales_total_cents": 2019, "session": { "branch_id": "", "cashier_id": "", @@ -213,20 +236,20 @@ "CASH": 20000 }, "expected_by_method": { - "CASH": 11404 + "CASH": 9219 }, "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", - "opening_float": 10000, - "over_short": 8596, + "opening_float_cents": 10000, + "over_short_cents": 10781, "register_id": "REG-01", - "status": "CLOSED" + "status": "closed" }, - "tax_total": 0, + "tax_total_cents": 66, "tendered_by_method": { - "CASH": 50000 + "CASH": 2019 } } } @@ -238,7 +261,7 @@ "path": "/api/v1/pos/till//close", "body": { "counted_by_method": { - "CASH": 200 + "cash": 20000 } } }, @@ -247,8 +270,8 @@ "content_type": "application/json", "body": { "error": { - "code": "CONFLICT", - "message": "Conflict" + "code": "invalid_state_transition", + "message": "the till session is not open" }, "meta": { "request_id": "" @@ -263,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": "" + } } } }, @@ -276,7 +305,7 @@ "method": "POST", "path": "/api/v1/pos/till/open", "body": { - "opening_float": 100, + "opening_float_cents": 10000, "register_id": "REG-01" } }, @@ -285,13 +314,18 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", + "cashier_id": "", + "closed_at": null, + "counted_by_method": {}, + "expected_by_method": {}, + "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" } } }, @@ -305,12 +339,12 @@ "status": 200, "content_type": "application/json", "body": { - "change_given": 48596, + "change_cents": 67981, "expected_by_method": { - "CASH": 11404 + "CASH": 9219 }, - "sale_count": 1, - "sales_total": 1872, + "sale_count": 2, + "sales_total_cents": 2019, "session": { "branch_id": "", "cashier_id": "", @@ -319,20 +353,20 @@ "CASH": 20000 }, "expected_by_method": { - "CASH": 11404 + "CASH": 9219 }, "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", - "opening_float": 10000, - "over_short": 8596, + "opening_float_cents": 10000, + "over_short_cents": 10781, "register_id": "REG-01", - "status": "CLOSED" + "status": "closed" }, - "tax_total": 0, + "tax_total_cents": 66, "tendered_by_method": { - "CASH": 50000 + "CASH": 2019 } } } @@ -350,14 +384,14 @@ "branch_id": "", "generated_at": "", "id": "", - "over_short": 8596, + "over_short_cents": 10781, "payload": { - "change_given": 48596, + "change_cents": 67981, "expected_by_method": { - "CASH": 11404 + "CASH": 9219 }, - "sale_count": 1, - "sales_total": 1872, + "sale_count": 2, + "sales_total_cents": 2019, "session": { "branch_id": "", "cashier_id": "", @@ -366,20 +400,20 @@ "CASH": 20000 }, "expected_by_method": { - "CASH": 11404 + "CASH": 9219 }, "gl_entry_id": "", "id": "", "notes": "r1b c close count", "opened_at": "", - "opening_float": 10000, - "over_short": 8596, + "opening_float_cents": 10000, + "over_short_cents": 10781, "register_id": "REG-01", - "status": "CLOSED" + "status": "closed" }, - "tax_total": 0, + "tax_total_cents": 66, "tendered_by_method": { - "CASH": 50000 + "CASH": 2019 } }, "register_id": "REG-01", @@ -398,8 +432,8 @@ "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": "" @@ -416,49 +450,47 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "z_reports": [ - { - "branch_id": "", - "generated_at": "", - "id": "", - "over_short": 8596, - "payload": { - "change_given": 48596, - "expected_by_method": { - "CASH": 11404 + "body": [ + { + "branch_id": "", + "generated_at": "", + "id": "", + "over_short_cents": 10781, + "payload": { + "change_cents": 67981, + "expected_by_method": { + "CASH": 9219 + }, + "sale_count": 2, + "sales_total_cents": 2019, + "session": { + "branch_id": "", + "cashier_id": "", + "closed_at": "", + "counted_by_method": { + "CASH": 20000 }, - "sale_count": 1, - "sales_total": 1872, - "session": { - "branch_id": "", - "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" + "expected_by_method": { + "CASH": 9219 }, - "tax_total": 0, - "tendered_by_method": { - "CASH": 50000 - } + "gl_entry_id": "", + "id": "", + "notes": "r1b c close count", + "opened_at": "", + "opening_float_cents": 10000, + "over_short_cents": 10781, + "register_id": "REG-01", + "status": "closed" }, - "register_id": "REG-01", - "till_session_id": "" - } - ] - } + "tax_total_cents": 66, + "tendered_by_method": { + "CASH": 2019 + } + }, + "register_id": "REG-01", + "till_session_id": "" + } + ] } }, { @@ -470,9 +502,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "z_reports": null - } + "body": [] } }, { @@ -484,9 +514,7 @@ "response": { "status": 200, "content_type": "application/json", - "body": { - "z_reports": null - } + "body": [] } } ] diff --git a/core/internal/characterization/testdata/goldens/pos_transactions.json b/core/internal/characterization/testdata/goldens/pos_transactions.json index c43246b7d..b902a05cc 100644 --- a/core/internal/characterization/testdata/goldens/pos_transactions.json +++ b/core/internal/characterization/testdata/goldens/pos_transactions.json @@ -9,16 +9,19 @@ "status": 200, "content_type": "application/json", "body": { - "session": { - "branch_id": "", - "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": {}, + "expected_by_method": {}, + "gl_entry_id": null, + "id": "", + "notes": "", + "opened_at": "", + "opening_float_cents": 10000, + "over_short_cents": null, + "register_id": "REG-01", + "status": "open" } } }, @@ -68,7 +71,7 @@ "method": "POST", "path": "/api/v1/pos/till/open", "body": { - "opening_float": 50, + "opening_float_cents": 5000, "register_id": "REG-01" } }, @@ -77,8 +80,14 @@ "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": "" @@ -98,8 +107,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": "" @@ -122,17 +131,26 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", + "currency": "USD", "customer_id": "", - "id": "", + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [], + "number": "POS-000002", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], "till_session_id": "", - "total": 0 + "total_cents": 0 } } }, @@ -150,16 +168,26 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", + "currency": "USD", + "customer_id": null, + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [], + "number": "POS-000003", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], "till_session_id": "", - "total": 0 + "total_cents": 0 } } }, @@ -177,16 +205,26 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", + "currency": "USD", + "customer_id": null, + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [], + "number": "POS-000004", "register_id": "REG-01", - "status": "OPEN", - "subtotal": 0, - "tax_amount": 0, + "revision": 1, + "status": "open", + "subtotal_cents": 0, + "tax_cents": 0, + "tenders": [], "till_session_id": "", - "total": 0 + "total_cents": 0 } } }, @@ -202,11 +240,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": "" } } } @@ -223,154 +261,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, + "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" } ] @@ -404,10 +442,12 @@ "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": { @@ -415,30 +455,58 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", + "currency": "USD", "customer_id": "", - "id": "", - "line_items": [ + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, + "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, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "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, + "revision": 2, + "status": "open", + "subtotal_cents": 1403, + "tax_cents": 168, + "tenders": [], "till_session_id": "", - "total": 1404 + "total_cents": 1571 } } }, @@ -446,11 +514,12 @@ "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": { @@ -458,41 +527,89 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", + "currency": "USD", "customer_id": "", - "id": "", - "line_items": [ + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, + "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, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "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, + "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, - "transaction_id": "", - "unit_price": 3230, - "uom": "EA" + "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, + "revision": 3, + "status": "open", + "subtotal_cents": 7863, + "tax_cents": 944, + "tenders": [], "till_session_id": "", - "total": 7864 + "total_cents": 8807 } } }, @@ -502,8 +619,10 @@ "method": "POST", "path": "/api/v1/pos/transactions/not-a-uuid/items", "body": { - "product_id": "", - "quantity": 1 + "line": { + "product_id": "", + "quantity": "1" + } } }, "response": { @@ -511,11 +630,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": "" } } } @@ -524,7 +649,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": { @@ -532,11 +657,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": "" } } } @@ -545,22 +670,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": "" } } } @@ -569,37 +702,65 @@ "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, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", + "currency": "USD", "customer_id": "", - "id": "", - "line_items": [ + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, + "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, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "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, + "revision": 4, + "status": "open", + "subtotal_cents": 1403, + "tax_cents": 168, + "tenders": [], "till_session_id": "", - "total": 1404 + "total_cents": 1571 } } }, @@ -607,18 +768,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": "" } } } @@ -627,18 +794,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": "" } } } @@ -647,37 +820,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": "" + } } } }, @@ -685,37 +840,65 @@ "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, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", + "currency": "USD", "customer_id": "", - "id": "", - "line_items": [ + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, + "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, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "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, + "revision": 4, + "status": "open", + "subtotal_cents": 1403, + "tax_cents": 168, + "tenders": [], "till_session_id": "", - "total": 1404 + "total_cents": 1571 } } }, @@ -723,18 +906,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": "" } } } @@ -750,11 +933,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": "" } } } @@ -762,6 +951,7 @@ { "name": "pos.transaction.list", "sort_primary_array": true, + "sort_envelope_items": true, "request": { "method": "GET", "path": "/api/v1/pos/transactions?register_id=REG-01" @@ -769,40 +959,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 + } } }, { @@ -814,14 +1040,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": [] } @@ -831,8 +1113,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": "" @@ -848,8 +1136,8 @@ "body": { "tenders": [ { - "amount": 1, - "method": "CASH" + "amount_cents": 100, + "method": "cash" } ] } @@ -859,8 +1147,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": "" @@ -872,7 +1166,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": { @@ -880,8 +1174,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": "" @@ -890,26 +1184,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,16 +1211,87 @@ } } }, + { + "name": "pos.transaction.complete.stale_revision", + "request": { + "method": "POST", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"1\"" + }, + "body": { + "tenders": [ + { + "amount_cents": 50000, + "method": "cash" + } + ] + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "stale_revision", + "message": "the document was changed after this revision was read; reload and retry" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "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 1403 cents and the tenders carry 1" + } + ], + "message": "the sale needs 1403 cents and the tenders carry 1" + }, + "meta": { + "request_id": "" + } + } + } + }, { "name": "pos.transaction.complete.not_found", "request": { "method": "POST", - "path": "/api/v1/pos/transactions//complete", + "path": "/api/v1/pos/transactions//complete", + "headers": { + "If-Match": "\"4\"" + }, "body": { "tenders": [ { - "amount": 1, - "method": "CASH" + "amount_cents": 50000, + "method": "cash" } ] } @@ -936,11 +1301,11 @@ "content_type": "application/json", "body": { "error": { - "code": "NOT_FOUND", - "message": "Not Found" + "code": "not_found", + "message": "sale not found" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -949,12 +1314,15 @@ "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" } ] } @@ -964,40 +1332,72 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 48596, + "cashier_id": "", + "change_cents": 48597, "completed_at": "", "created_at": "", + "currency": "USD", "customer_id": "", - "id": "", - "line_items": [ + "customer_po": null, + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 1404, + "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, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "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 + "total_cents": 1403 } } }, @@ -1005,26 +1405,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": "" } } } @@ -1033,53 +1436,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": "" + } } } }, @@ -1087,10 +1462,12 @@ "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": { @@ -1098,77 +1475,84 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": null, + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "3/4 Plywood G1S 4x8 (Fir)", - "id": "", - "line_total": 3800, + "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, - "transaction_id": "", - "unit_price": 3800, - "uom": "PCS" + "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, + "revision": 2, + "status": "open", + "subtotal_cents": 3800, + "tax_cents": 456, + "tenders": [], "till_session_id": "", - "total": 4256 + "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": "" + } } } }, @@ -1176,18 +1560,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": "" } } } @@ -1196,18 +1589,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": 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": "" } } } @@ -1216,10 +1612,12 @@ "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": { @@ -1227,42 +1625,79 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 0, + "cashier_id": "", + "change_cents": 0, + "completed_at": null, "created_at": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": null, + "customer_po": null, + "id": "", + "invoice_id": null, + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 550, + "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, - "transaction_id": "", - "unit_price": 550, - "uom": "PCS" + "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, + "revision": 2, + "status": "open", + "subtotal_cents": 550, + "tax_cents": 66, + "tenders": [], "till_session_id": "", - "total": 616 + "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" } ] } @@ -1272,39 +1707,85 @@ "content_type": "application/json", "body": { "branch_id": "", - "cashier_id": "", - "change_due": 9384, + "cashier_id": "", + "change_cents": 0, "completed_at": "", "created_at": "", - "id": "", - "line_items": [ + "currency": "USD", + "customer_id": null, + "customer_po": null, + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 550, + "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, - "transaction_id": "", - "unit_price": 550, - "uom": "PCS" + "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 + "total_cents": 616 } } }, @@ -1312,24 +1793,98 @@ "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, + "cashier_id": "", + "change_cents": 0, "completed_at": "", "created_at": "", - "id": "", + "currency": "USD", + "customer_id": null, + "customer_po": null, + "id": "", + "invoice_id": "", + "job_id": null, + "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, + "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": 616 + "total_cents": 616 } } }, @@ -1337,64 +1892,86 @@ "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, + "cashier_id": "", + "change_cents": 48597, "completed_at": "", "created_at": "", + "currency": "USD", "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" - }, + "customer_po": null, + "id": "", + "invoice_id": "", + "job_id": null, + "lines": [ { + "charge_code": null, + "charge_code_id": null, "created_at": "", "description": "2x4x8 SPF Premium", - "id": "", - "line_total": 468, + "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": 1, - "transaction_id": "", - "unit_price": 468, - "uom": "PCS" + "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 + "total_cents": 1403 } } }, { "name": "pos.transaction.list.after", "sort_primary_array": true, + "sort_envelope_items": true, "request": { "method": "GET", "path": "/api/v1/pos/transactions?register_id=REG-01" @@ -1402,42 +1979,160 @@ "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, + "sort_envelope_items": 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/reporting_reports.json b/core/internal/characterization/testdata/goldens/reporting_reports.json index a4dac0ffc..38db99809 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": 24.35, + "CHECK": 313596.85 }, "date": "", - "total_collected": 313594.85, - "transaction_count": 42 + "total_collected": 313621.19999999995, + "transaction_count": 46 } } }, @@ -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/customer/migration113_test.go b/core/internal/customer/migration113_test.go index c9ec2c80f..4f12ea95a 100644 --- a/core/internal/customer/migration113_test.go +++ b/core/internal/customer/migration113_test.go @@ -191,6 +191,14 @@ func TestMigration113_BackfillsThePrimaryBranchRow(t *testing.T) { balances := scalar113[int64](t, conn, `SELECT SUM(ROUND(balance_due * 100))::bigint FROM customers`) digest := customersDigest() rowTotal := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`) + // The three planted customers, and any customer the migrated schema + // itself holds with a primary branch and no row (the counter's walk-in + // customer, migration 105): the migration sets a row on each. + pending := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customers c WHERE c.primary_branch_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM customer_branches cb WHERE cb.customer_id = c.id AND cb.branch_id = c.primary_branch_id)`) + if pending < 3 { + t.Fatalf("%d customers lack their primary branch row, want at least the three planted", pending) + } // 1. The first run sets the row on the rowless customers and the one // shared with the other branch, and reports the customer with no branch. @@ -205,14 +213,14 @@ func TestMigration113_BackfillsThePrimaryBranchRow(t *testing.T) { if len(rowsOf(held)) != 1 || len(rowsOf(noBranch)) != 0 { t.Errorf("a customer that needed nothing changed: held %v, no branch %v", rowsOf(held), rowsOf(noBranch)) } - if n := notice113(*notices, "set the customer_branches row for its primary branch on 3 customer(s)"); n == "" { - t.Errorf("no NOTICE counts the three customers set; notices: %v", *notices) + if n := notice113(*notices, fmt.Sprintf("set the customer_branches row for its primary branch on %d customer(s)", pending)); n == "" { + t.Errorf("no NOTICE counts the rowless customers set; notices: %v", *notices) } if n := notice113(*notices, "1 customer(s) have no primary branch and were left as they are"); n == "" { t.Errorf("no NOTICE reports the one customer with no primary branch; notices: %v", *notices) } - if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+3 { - t.Errorf("customer_branches holds %d rows, want %d (three added, none deleted)", got, rowTotal+3) + if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+pending { + t.Errorf("customer_branches holds %d rows, want %d (the rowless customers added, none deleted)", got, rowTotal+pending) } if customersDigest() != digest { t.Errorf("the migration changed a customers row") @@ -224,8 +232,8 @@ func TestMigration113_BackfillsThePrimaryBranchRow(t *testing.T) { // 2. A second run changes nothing and says so. *notices = nil applyFile113(t, conn, target) - if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+3 { - t.Errorf("a second run changed the row count to %d, want %d", got, rowTotal+3) + if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+pending { + t.Errorf("a second run changed the row count to %d, want %d", got, rowTotal+pending) } if n := notice113(*notices, "on 0 customer(s)"); n == "" { t.Errorf("the second run does not report zero customers set; notices: %v", *notices) @@ -237,11 +245,11 @@ func TestMigration113_BackfillsThePrimaryBranchRow(t *testing.T) { t.Fatalf("migration 113 down file: %v %v", down, err) } applyFile113(t, conn, down[0]) - if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+3 { - t.Errorf("the down file changed the row count to %d, want %d (it removes nothing)", got, rowTotal+3) + if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+pending { + t.Errorf("the down file changed the row count to %d, want %d (it removes nothing)", got, rowTotal+pending) } applyFile113(t, conn, target) - if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+3 { - t.Errorf("up after down changed the row count to %d, want %d", got, rowTotal+3) + if got := scalar113[int64](t, conn, `SELECT COUNT(*) FROM customer_branches`); got != rowTotal+pending { + t.Errorf("up after down changed the row count to %d, want %d", got, rowTotal+pending) } } diff --git a/core/internal/inventory/branch_location_row_test.go b/core/internal/inventory/branch_location_row_test.go new file mode 100644 index 000000000..d2890adb6 --- /dev/null +++ b/core/internal/inventory/branch_location_row_test.go @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package inventory_test + +// The write path's row against a real database (the fourteenth review's P1-2): a +// branch scoped caller that names a location acts on exactly that location's +// row, a location outside the caller's branch is refused, and only a request +// that names no location lets the branch rule pick a row. The stock routes +// (the cycle count, the receipt, the transfer) and the purchase order receive +// all write through this one read, so the suites here pin the row each act +// touches. + +import ( + "context" + "errors" + "testing" + + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/google/uuid" +) + +// locWorld is one branch with two yards (yard1 holds the larger row) and a +// second branch with one yard, plus an unassigned row. +type locWorld struct { + svc *inventory.Service + productID, branchID uuid.UUID + yard1, yard2 uuid.UUID + otherBranch, otherYard uuid.UUID + unassigned uuid.UUID + ctx context.Context +} + +func newLocWorld(t *testing.T) (*locWorld, func(sql string, args ...any) string) { + t.Helper() + db := testutil.RequireDB(t) + ctx := context.Background() + w := &locWorld{svc: inventory.NewService(inventory.NewRepository(db)), + productID: uuid.New(), branchID: uuid.New(), + yard1: uuid.New(), yard2: uuid.New(), otherBranch: uuid.New(), otherYard: uuid.New()} + w.ctx = branchctx.With(ctx, &branchctx.Context{BranchID: &w.branchID}) + tag := "IL-" + uuid.NewString()[:8] + must := func(sql string, args ...any) { + if _, err := db.Pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + must(`INSERT INTO locations (id, type, code) VALUES ($1, 'BRANCH', $2)`, w.branchID, tag) + must(`INSERT INTO locations (id, type, code, parent_id, branch_id) + VALUES ($1, 'YARD', $2, $4, $4), ($3, 'YARD', $5, $4, $4)`, w.yard1, tag+"-1", w.yard2, w.branchID, tag+"-2") + must(`INSERT INTO locations (id, type, code) VALUES ($1, 'BRANCH', $2)`, w.otherBranch, tag+"-O") + must(`INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'YARD', $2, $3, $3)`, + w.otherYard, tag+"-OY", w.otherBranch) + must(`INSERT INTO products (id, sku, description, uom_primary, base_price) VALUES ($1, $2, 'loc stud', 'PCS', 1)`, + w.productID, tag+"-P") + must(`INSERT INTO inventory (id, product_id, location_id, location, quantity, created_at) + VALUES ($1, $2, $3, 'Y1', 100, NOW() - INTERVAL '2 hours'), + ($4, $2, $5, 'Y2', 5, NOW() - INTERVAL '1 hour'), + ($6, $2, $7, 'OY', 50, NOW())`, + uuid.New(), w.productID, w.yard1, uuid.New(), w.yard2, uuid.New(), w.otherYard) + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM inventory WHERE product_id = $1`, w.productID) + _, _ = db.Pool.Exec(ctx, `DELETE FROM products WHERE id = $1`, w.productID) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE branch_id IN ($1, $2) OR id IN ($1, $2)`, w.branchID, w.otherBranch) + }) + read := func(sql string, args ...any) string { + t.Helper() + var out string + if err := db.Pool.QueryRow(ctx, sql, args...).Scan(&out); err != nil { + t.Fatal(err) + } + return out + } + return w, read +} + +// qtyOf reads one yard's quantity, so each assertion names the row it means. +func (w *locWorld) qtyOf(t *testing.T, read func(sql string, args ...any) string, yard uuid.UUID) string { + t.Helper() + return read(`SELECT quantity::text FROM inventory WHERE product_id = $1 AND location_id = $2`, w.productID, yard) +} + +// RULE: a cycle count or a receipt that names a yard acts on that yard's row +// only, whatever else the branch holds. +func TestAnAdjustWithANamedLocationActsOnThatLocationRowAlone(t *testing.T) { + w, read := newLocWorld(t) + // a cycle count of yard 2, an absolute set to 7 + if err := w.svc.AdjustStock(w.ctx, inventory.StockAdjustmentRequest{ + ProductID: w.productID, LocationID: &w.yard2, Quantity: 7}); err != nil { + t.Fatalf("cycle count of yard 2: %v", err) + } + if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { + t.Errorf("yard 1 after a cycle count of yard 2 = %s, want 100.0000 (the count touched another yard's row)", got) + } + if got := w.qtyOf(t, read, w.yard2); got != "7.0000" { + t.Errorf("yard 2 after its own cycle count = %s, want 7.0000", got) + } + // a receipt of 3 more at yard 2 + if err := w.svc.AdjustStock(w.ctx, inventory.StockAdjustmentRequest{ + ProductID: w.productID, LocationID: &w.yard2, Quantity: 3, IsDelta: true}); err != nil { + t.Fatalf("receipt of 3 at yard 2: %v", err) + } + if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { + t.Errorf("yard 1 after a receipt at yard 2 = %s, want 100.0000", got) + } + if got := w.qtyOf(t, read, w.yard2); got != "10.0000" { + t.Errorf("yard 2 after its receipt = %s, want 10.0000", got) + } +} + +// RULE: a transfer moves the named rows only. +func TestATransferMovesTheNamedRowsAlone(t *testing.T) { + w, read := newLocWorld(t) + if err := w.svc.MoveStock(w.ctx, inventory.StockMovementRequest{ + ProductID: w.productID, FromLocationID: &w.yard1, ToLocationID: w.yard2, Quantity: 10}); err != nil { + t.Fatalf("transfer 10 from yard 1 to yard 2: %v", err) + } + if got := w.qtyOf(t, read, w.yard1); got != "90.0000" { + t.Errorf("yard 1 after the transfer = %s, want 90.0000", got) + } + if got := w.qtyOf(t, read, w.yard2); got != "15.0000" { + t.Errorf("yard 2 after the transfer = %s, want 15.0000", got) + } +} + +// RULE: a location outside the caller's branch is refused, never substituted: +// both the row on the other branch and the caller's own rows stand untouched. +func TestAnActOnALocationOutsideTheCallersBranchIsRefused(t *testing.T) { + w, read := newLocWorld(t) + err := w.svc.AdjustStock(w.ctx, inventory.StockAdjustmentRequest{ + ProductID: w.productID, LocationID: &w.otherYard, Quantity: 7}) + if !errors.Is(err, inventory.ErrLocationOutsideBranch) { + t.Fatalf("an adjust on another branch's yard = %v, want ErrLocationOutsideBranch", err) + } + err = w.svc.MoveStock(w.ctx, inventory.StockMovementRequest{ + ProductID: w.productID, FromLocationID: &w.yard1, ToLocationID: w.otherYard, Quantity: 10}) + if err == nil { + t.Fatal("a transfer into another branch's yard succeeded") + } + if got := w.qtyOf(t, read, w.otherYard); got != "50.0000" { + t.Errorf("the other branch's yard moved to %s, want 50.0000 (refused, not substituted)", got) + } + if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { + t.Errorf("yard 1 after the refused acts = %s, want 100.0000 (a refusal must not touch the caller's own rows)", got) + } + if got := w.qtyOf(t, read, w.yard2); got != "5.0000" { + t.Errorf("yard 2 after the refused acts = %s, want 5.0000", got) + } +} + +// RULE: a receipt at a yard of the caller's branch that holds no row yet +// creates the row there (the route's first receipt at a yard). +func TestAReceiptAtAYardWithNoRowCreatesItThere(t *testing.T) { + w, read := newLocWorld(t) + fresh := uuid.New() + db := testutil.RequireDB(t) + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'YARD', $2, $3, $3)`, + fresh, "IL-FRESH-"+fresh.String()[:6], w.branchID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM inventory WHERE location_id = $1`, fresh) + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM locations WHERE id = $1`, fresh) + }) + if err := w.svc.AdjustStock(w.ctx, inventory.StockAdjustmentRequest{ + ProductID: w.productID, LocationID: &fresh, Quantity: 4, IsDelta: true}); err != nil { + t.Fatalf("first receipt at a fresh yard: %v", err) + } + if got := w.qtyOf(t, read, fresh); got != "4.0000" { + t.Errorf("the fresh yard = %s, want 4.0000", got) + } + if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { + t.Errorf("yard 1 after a receipt at a fresh yard = %s, want 100.0000", got) + } +} + +// RULE: a named location that holds no inventory row yet is still held to +// the branch wall (the fifteenth review's P3-1): the row's absence must not +// let a service caller seed a first row on another branch's yard, so the +// read falls back to the location's own branch and refuses, and nothing is +// created. +func TestAnActOnAnotherBranchsYardWithNoRowIsRefused(t *testing.T) { + w, read := newLocWorld(t) + db := testutil.RequireDB(t) + freshOther := uuid.New() + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'YARD', $2, $3, $3)`, + freshOther, "IL-FO-"+freshOther.String()[:6], w.otherBranch); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM inventory WHERE location_id = $1`, freshOther) + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM locations WHERE id = $1`, freshOther) + }) + err := w.svc.AdjustStock(w.ctx, inventory.StockAdjustmentRequest{ + ProductID: w.productID, LocationID: &freshOther, Quantity: 7}) + if !errors.Is(err, inventory.ErrLocationOutsideBranch) { + t.Fatalf("an adjust on another branch's rowless yard = %v, want ErrLocationOutsideBranch (the handler's location guard covers the routes; the service caller needs the read's own hold)", err) + } + var n int + if err := db.Pool.QueryRow(context.Background(), + `SELECT count(*) FROM inventory WHERE product_id = $1 AND location_id = $2`, w.productID, freshOther).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("%d rows were created on the other branch's rowless yard, want 0 (a refusal must not seed another branch)", n) + } + if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { + t.Errorf("yard 1 after the refused adjust = %s, want 100.0000 (the caller's own rows stand untouched)", got) + } +} + +// RULE: a request that names no location keeps the stated picks: an unbound +// caller the legacy unassigned row, a branch scoped caller the branch's first +// row by creation, never the largest quantity by accident. +func TestAnAdjustWithNoLocationTakesTheStatedRow(t *testing.T) { + w, read := newLocWorld(t) + db := testutil.RequireDB(t) + // the unassigned row, older than every yard row + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO inventory (id, product_id, location_id, location, quantity, created_at) + VALUES ($1, $2, NULL, 'LEG', 40, NOW() - INTERVAL '3 hours')`, uuid.New(), w.productID); err != nil { + t.Fatal(err) + } + // a newest yard holding the branch's largest row: the branch's first row + // by creation is yard 1, so a pick by largest quantity lands elsewhere + newYard := uuid.New() + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO locations (id, type, code, parent_id, branch_id) VALUES ($1, 'YARD', $2, $3, $3)`, + newYard, "IL-NEW-"+newYard.String()[:6], w.branchID); err != nil { + t.Fatal(err) + } + if _, err := db.Pool.Exec(context.Background(), + `INSERT INTO inventory (id, product_id, location_id, location, quantity, created_at) + VALUES ($1, $2, $3, 'NEW', 900, NOW())`, uuid.New(), w.productID, newYard); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM inventory WHERE product_id = $1 AND (location_id IS NULL OR location_id = $2)`, w.productID, newYard) + _, _ = db.Pool.Exec(context.Background(), `DELETE FROM locations WHERE id = $1`, newYard) + }) + // an unbound caller: the legacy unassigned row + if err := w.svc.AdjustStock(context.Background(), inventory.StockAdjustmentRequest{ + ProductID: w.productID, Quantity: 41}); err != nil { + t.Fatalf("unbound adjust with no location: %v", err) + } + var legacy string + if err := db.Pool.QueryRow(context.Background(), + `SELECT quantity::text FROM inventory WHERE product_id = $1 AND location_id IS NULL`, w.productID).Scan(&legacy); err != nil { + t.Fatal(err) + } + if legacy != "41.0000" { + t.Errorf("the unassigned row = %s, want 41.0000 (the unbound caller's legacy row)", legacy) + } + if got := w.qtyOf(t, read, w.yard1); got != "100.0000" { + t.Errorf("yard 1 after the unbound adjust = %s, want 100.0000", got) + } + // a branch scoped caller: the branch's first row by creation (yard 1, + // created before yard 2 and the new yard), though the new yard holds the + // most: the pick follows creation order, never quantity + if err := w.svc.AdjustStock(w.ctx, inventory.StockAdjustmentRequest{ + ProductID: w.productID, Quantity: 42}); err != nil { + t.Fatalf("branch scoped adjust with no location: %v", err) + } + if got := w.qtyOf(t, read, w.yard1); got != "42.0000" { + t.Errorf("yard 1 (the branch's first row) = %s, want 42.0000", got) + } + if got := w.qtyOf(t, read, w.yard2); got != "5.0000" { + t.Errorf("yard 2 after the branch scoped adjust = %s, want 5.0000", got) + } + if got := w.qtyOf(t, read, newYard); got != "900.0000" { + t.Errorf("the newest yard after the branch scoped adjust = %s, want 900.0000 (a silent largest quantity pick would have taken it)", got) + } +} diff --git a/core/internal/inventory/handler.go b/core/internal/inventory/handler.go index 84e002756..c3f91920d 100644 --- a/core/internal/inventory/handler.go +++ b/core/internal/inventory/handler.go @@ -90,6 +90,10 @@ func (h *Handler) AdjustStock(w http.ResponseWriter, r *http.Request) { } if err := h.service.AdjustStock(r.Context(), req); err != nil { + if errors.Is(err, ErrLocationOutsideBranch) { + httputil.RespondError(w, r, "location_id is in a branch this caller may not target", http.StatusForbidden, err) + return + } slog.Error("AdjustStock failed", "error", err) httputil.RespondError(w, r, "Internal Server Error", http.StatusInternalServerError, err) return @@ -112,6 +116,10 @@ func (h *Handler) MoveStock(w http.ResponseWriter, r *http.Request) { } if err := h.service.MoveStock(r.Context(), req); err != nil { + if errors.Is(err, ErrLocationOutsideBranch) { + httputil.RespondError(w, r, "from_location_id or to_location_id is in a branch this caller may not target", http.StatusForbidden, err) + return + } slog.Error("MoveStock failed", "error", err) httputil.RespondError(w, r, "Internal Server Error", http.StatusInternalServerError, err) return 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..6562d0b22 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,75 @@ 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, restated by the fourteenth review): a request that names a + // location reads exactly that location's row, held to the caller's branch + // when the context carries one (a row on another branch is refused by + // ErrLocationOutsideBranch, never substituted), and a named location that + // holds no row yet is held the same way (the fifteenth review's P3-1): its + // branch is read from the location itself, so the row's absence cannot let + // a service caller seed a first row on another branch's yard (the HTTP + // routes reach the same refusal through the handler's location guard); + // only a request that names no location lets the branch rule pick a row, + // and it takes the branch's first row by creation, a stated rule the + // stock routes document, never the largest quantity by accident; an + // unbound request that names no location keeps the legacy behaviour, the + // row with no location. + branch := branchctx.IDForQuery(ctx) + var inv Inventory + var rowBranch *uuid.UUID + if locationID != nil { + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + SELECT i.id, i.product_id, i.location_id, i.location, i.quantity, i.allocated, i.updated_at, l.branch_id + FROM inventory i + LEFT JOIN locations l ON l.id = i.location_id + WHERE i.product_id = $1 AND i.location_id = $2 + ORDER BY i.created_at, i.id + LIMIT 1`, productID, locationID).Scan( + &inv.ID, + &inv.ProductID, + &inv.LocationID, + &inv.Location, + &inv.Quantity, + &inv.Allocated, + &inv.UpdatedAt, + &rowBranch, + ) + if err != nil { + if err == pgx.ErrNoRows { + // no row to read the branch from: the named location's own + // branch answers the wall instead + if branch != nil { + locBranch, lbErr := r.LocationBranchID(ctx, *locationID) + if lbErr != nil { + return nil, fmt.Errorf("failed to get inventory: %w", lbErr) + } + if locBranch == nil || *locBranch != *branch { + return nil, ErrLocationOutsideBranch + } + } + return nil, nil + } + return nil, fmt.Errorf("failed to get inventory: %w", err) + } + if branch != nil && (rowBranch == nil || *rowBranch != *branch) { + return nil, ErrLocationOutsideBranch + } + return &inv, nil + } 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 i.location_id IS NULL) + ) + ORDER BY i.created_at, 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).Scan( &inv.ID, &inv.ProductID, &inv.LocationID, diff --git a/core/internal/inventory/service.go b/core/internal/inventory/service.go index ea88bd673..e17ea9542 100644 --- a/core/internal/inventory/service.go +++ b/core/internal/inventory/service.go @@ -20,6 +20,12 @@ import ( // mismatch it should survive; the second is not. var ErrNothingAllocated = errors.New("inventory: no allocated stock") +// ErrLocationOutsideBranch is the write path's branch wall: the request named +// a location whose row sits in a branch the caller's context does not carry. +// A sentinel so a caller can answer 403 instead of creating a first row on +// another branch's yard. +var ErrLocationOutsideBranch = errors.New("inventory: the named location is not on the caller's branch") + type Service struct { repo Repository levels LevelStore // the levels read's store (C3-1b), when the repo can serve it diff --git a/core/internal/invoice/fulfilment.go b/core/internal/invoice/fulfilment.go index 0f6f77589..eb6564438 100644 --- a/core/internal/invoice/fulfilment.go +++ b/core/internal/invoice/fulfilment.go @@ -73,6 +73,7 @@ type FulfilmentInvoice struct { ShipToID *uuid.UUID ShipToSnapshot []byte // JSON, or nil ProjectID *uuid.UUID + CustomerPO *string // a counter sale's purchase order InvoiceDate time.Time // the branch's local date PaymentTermsID uuid.UUID @@ -98,6 +99,117 @@ 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 + + // CustomerPO and JobID are the purchase order and job the counter sale + // named: the invoice's customer_po and project. + CustomerPO *string + JobID *uuid.UUID + + 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, + ProjectID: in.JobID, CustomerPO: in.CustomerPO, + 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 +217,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 e8c21334c..347c97116 100644 --- a/core/internal/invoice/repository.go +++ b/core/internal/invoice/repository.go @@ -614,6 +614,59 @@ 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, + customer_po) + 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, $18::uuid, $15, $16, $17::date, 'POS', CASE WHEN $7::bigint = 0 THEN NOW() END, NOW(), NOW(), + $19)`, + 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, in.ProjectID, in.CustomerPO) + 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/payment/service.go b/core/internal/payment/service.go index 48f4a6ace..ab08b6e8a 100644 --- a/core/internal/payment/service.go +++ b/core/internal/payment/service.go @@ -60,8 +60,13 @@ type Service struct { auditLog AuditSink events EventRecorder branches BranchGuard - logger *slog.Logger - now func() time.Time + // tenderCreditGuard, when wired, answers whether the payment's + // unapplied cash is a counter tender's held credit (the counter wires + // it; C2-5, FROM-PRINCIPAL 60): this route's online gateway is then + // never the way back for it. + tenderCreditGuard account.TenderCreditGuard + logger *slog.Logger + now func() time.Time } func NewService(db *database.DB, repo *Repository, accountService *account.Service) *Service { @@ -75,6 +80,15 @@ func (s *Service) WithGateway(gw PaymentGateway, publicKey string) *Service { return s } +// WithTenderCreditGuard sets the held credit guard. The counter's service +// satisfies it (C2-5, FROM-PRINCIPAL 60); serve wires the two, so a +// payment whose unapplied cash is a counter tender's credit is refused +// here before the online gateway is ever asked. +func (s *Service) WithTenderCreditGuard(g account.TenderCreditGuard) *Service { + s.tenderCreditGuard = g + return s +} + // WithBrainNotifier sets the FB Brain financial notifier and returns the service for chaining. // When set, successfully paid invoices will fire an async notification to Brain's 10bps engine. func (s *Service) WithBrainNotifier(n *BrainNotifier, orgID string) *Service { @@ -492,6 +506,20 @@ func (s *Service) Refund(ctx context.Context, id uuid.UUID, in *RefundInput, pre case in.AmountCents > facts.Unapplied: return nil, conflictBlocker("exceeds_unapplied", fmt.Sprintf("the payment has %d cents unapplied: %d cents were asked", facts.Unapplied, in.AmountCents)) } + // A counter tender's credit rides the terminal rail it was charged on + // (C2-5, FROM-PRINCIPAL 60b): this route's gateway is the online rail, + // never the way back for it, so the refund is refused before the + // gateway is asked and the credit stays payable at the counter, to the + // card that paid it. + if s.tenderCreditGuard != nil { + if held, ok, err := s.tenderCreditGuard.HeldTenderCredit(ctx, id); err != nil { + return nil, err + } else if ok { + return nil, conflictBlocker("tender_credit_held", + fmt.Sprintf("the payment's unapplied cash is the credit of the card tender the void of sale %s holds (claim %s): pay it back at POST /api/v1/pos/refund-intents/{id}/payback, through the counter terminal the charge came from", + held.SaleNumber, held.IntentID)) + } + } gatewayID := "" if facts.Method == PaymentMethodCard { if s.gateway == nil { 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/audit_event_proofs_test.go b/core/internal/pos/audit_event_proofs_test.go new file mode 100644 index 000000000..bbef52f40 --- /dev/null +++ b/core/internal/pos/audit_event_proofs_test.go @@ -0,0 +1,169 @@ +// 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 := f.counterInvoices(t) + 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 := f.counterInvoices(t); 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 := f3.counterMemos(t) + if r := f3.returnOn(t, saleID3, lineID, "1"); r.status == http.StatusCreated { + t.Fatal("the return committed under a failing audit write") + } + if got := f3.counterMemos(t); got != memosBefore { + t.Errorf("%d memos after the refused return, want %d", got, memosBefore) + } + if got := f3.counterReturns(t); 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 := f.counterMemos(t) + if r := f.returnOn(t, saleID, lineID, "1"); r.status == http.StatusCreated { + t.Fatal("the return committed under a failing event write") + } + // the walk-in sale's row is not this customer's: the return's credit never landed here + for _, c := range []struct { + what string + got int64 + want int64 + }{ + {"memos", f.counterMemos(t), memosBefore}, + {"return rows", f.counterReturns(t), 0}, + {"refund entries", f.counterRefunds(t), 0}, + {"subledger rows", countOf(t, f.db, `SELECT count(*) FROM customer_transactions WHERE customer_id = $1`, f.customerID), 0}, + } { + if c.got != c.want { + t.Errorf("%s = %d, want %d", c.what, c.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/branch_wall_test.go b/core/internal/pos/branch_wall_test.go new file mode 100644 index 000000000..c00962f49 --- /dev/null +++ b/core/internal/pos/branch_wall_test.go @@ -0,0 +1,97 @@ +// 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" + "strings" + "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: the register rule's 403, + // never 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.StatusForbidden || !strings.Contains(string(r.raw), "belongs to a different branch than the request") { + t.Errorf("free return on another branch's register = %d, want the register rule's 403: %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/card_cap_test.go b/core/internal/pos/card_cap_test.go new file mode 100644 index 000000000..9e8c3a81f --- /dev/null +++ b/core/internal/pos/card_cap_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The card cap and the legacy tax reads, each with a test that fails under +// the sign mutants (seventh review P2-4 and P3-1): a split cash and card sale +// whose card returns are booked negative, a legacy card return booked +// positive that carries cents past its quantity, and a legacy return that +// carries tax. Every refund act asserts the ledger ties after it. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// splitSale sells two units and tenders cashCents in cash and the rest on the +// card, returning the sale, its first line and the card tender's cents. +func splitSale(t *testing.T, f *fixture, cashCents int64) (saleID, lineID string, cardCents int64) { + t.Helper() + saleID = f.startSale(nil) + 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") + cardCents = total - cashCents + if r := f.completeSale(saleID, tender("cash", cashCents), withToken(tender("card", cardCents))); 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) + return saleID, lineID, cardCents +} + +// RULE (seventh review P2-4): the card returns the sale already booked (each +// negative) bound the next one at the card tender less them. A split sale of +// 500 cash and the rest on the card: one unit back to the card fits, the +// second does not (99 left on the card), and the terminal is asked once. +func TestASplitSaleCardReturnsAreBoundedByTheCardTender(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) + saleID, lineID, cardCents := splitSale(t, f, 500) + if cardCents != 698 { + t.Fatalf("the card tender = %d, want 698 (1198 less 500 cash)", cardCents) + } + f.assertARInvariants(t) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("the first card return = %d: %s", r.status, r.raw) + } + f.assertARInvariants(t) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the second card return = %d, want 409 (99 left on the card): %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "exceeds_card_tender" { + t.Errorf("blockers = %v, want exceeds_card_tender", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Errorf("%d gateway refunds, want 1 (the refused return never reached the terminal)", refunds) + } + f.assertARInvariants(t) +} + +// RULE (seventh review P2-4): a legacy card return (positive in the shape +// migration 105 leaves) that carries cents past its quantity lowers what the +// card tender has left by those cents. A split sale of 800 cash and 398 on +// the card, a legacy card return of a tenth of a unit carrying 350: 48 left +// on the card, so a card return of one unit (599) is refused by the card cap +// alone (the quantity and the line's money both allow it). +func TestALegacyCardReturnCarryingCentsBoundsTheCardTender(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) + saleID, lineID, cardCents := splitSale(t, f, 800) + if cardCents != 398 { + t.Fatalf("the card tender = %d, want 398 (1198 less 800 cash)", cardCents) + } + legacyReturnOf(t, f, saleID, "0.1", "5.50", "3.50", "3.50", "0.00", "3.50", "CARD") + f.assertARInvariants(t) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the card return past the card tender = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "exceeds_card_tender" { + t.Errorf("blockers = %v, want exceeds_card_tender (the legacy 350 left 48 on the card)", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 0 { + t.Errorf("%d gateway refunds, want 0", refunds) + } + f.assertARInvariants(t) +} + +// RULE (seventh review P3-1): the tax a legacy return took back (positive in +// the shape migration 105 leaves) counts against the sale's tax. A legacy +// return of a tenth of a unit carrying 40 of the sale's 49 cents of tax +// leaves 9: a return of half a unit, whose share is 25, takes 9 and no more. +func TestALegacyReturnsTaxCountsAgainstTheSalesTax(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + 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) + } + 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) + legacyReturnOf(t, f, saleID, "0.1", "5.50", "0.55", "0.55", "0.40", "0.95", "CASH") + f.assertARInvariants(t) + r := f.returnOn(t, saleID, lineID, "0.5") + if r.status != http.StatusCreated { + t.Fatalf("the half return = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "tax_cents"); got != -9 { + t.Errorf("the half return tax = %d, want -9 (49 less the legacy 40, under the 25 share)", got) + } + if got := num(t, r.body, "total_cents"); got != -284 { + t.Errorf("the half return total = %d, want -284 (275 of line and 9 of tax)", got) + } + if got := f.accountBalance("1010"); got != 315 { + t.Errorf("cash balance = %d, want 315 (the sale in, the linked refund out; the legacy cents never left this drawer)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/cash_claim_test.go b/core/internal/pos/cash_claim_test.go new file mode 100644 index 000000000..8dfd36d48 --- /dev/null +++ b/core/internal/pos/cash_claim_test.go @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The card claim against the drawer and the books (eighth review P2-1, +// seventh review P2-1): the refund intent serializes card refunds against +// card refunds only, so a cash return could commit while a card claim stood +// (the same goods paid twice, once at the terminal and once from the drawer), +// and the money transaction restated its total under the lock to a figure the +// terminal was never asked for, booking a cent less than the gateway took. + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// parkRefund parks the stub gateway's refunds and answers a release function +// that also runs at cleanup, so a failing assertion never leaves the parked +// handler holding the test server's close. +func parkRefund(t *testing.T, gw *fakeGateway) (parked <-chan struct{}, release func()) { + t.Helper() + // buffered: a second refund that reaches the terminal (the very defect a + // claim test hunts) must not block on the announce and hang the package + p := make(chan struct{}, 16) + r := make(chan struct{}) + gw.onRefund = func() { + p <- struct{}{} + <-r + } + var once sync.Once + release = func() { + once.Do(func() { close(r) }) + } + t.Cleanup(release) + return p, release +} + +// awaitParked waits for a refund to reach the terminal, failing at the +// deadline instead of hanging the package (seventh review P3-5). +func awaitParked(t *testing.T, parked <-chan struct{}) { + t.Helper() + select { + case <-parked: + case <-time.After(requestDeadline): + t.Fatalf("no refund reached the terminal within %s", requestDeadline) + } +} + +// awaitResp waits for a contender's answer, failing at the deadline. +func awaitResp(t *testing.T, ch <-chan resp) resp { + t.Helper() + select { + case r := <-ch: + return r + case <-time.After(requestDeadline): + t.Fatalf("a contender did not answer within %s", requestDeadline) + return resp{} + } +} + +// RULE (eighth review P2-1): a cash return of a sale is refused with 409 +// refund_in_flight while any card refund intent of that sale is PENDING, +// under the sale row lock, before the drawer pays: the same goods cannot be +// paid back twice. The parked card return then books exactly what its claim +// held. +func TestACashReturnWhileACardReturnIsParkedRefuses(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + parked, release := parkRefund(t, gw) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "1") }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + // the card claim stands: the drawer must not pay the same unit again + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the cash return under a parked card claim = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the card claim holds the sale)", blockers) + } + f.assertARInvariants(t) + release() + if rr := awaitResp(t, returnDone); rr.status != http.StatusCreated { + t.Fatalf("the parked card return = %d: %s", rr.status, rr.raw) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds, want 1 (the terminal refunded the card once)", refunds) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (599 charged, 599 refunded once)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_returns WHERE original_transaction_id = $1`, saleID); got != 1 { + t.Errorf("%d returns of the sale, want 1 (the cash return was refused: the goods were paid back once)", got) + } + f.assertARInvariants(t) +} + +// RULE (eighth review P2-1): the refusal holds while a void's claim stands +// too, and the void then completes. +func TestACashReturnWhileAVoidIsParkedRefuses(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + parked, release := parkRefund(t, gw) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + voidDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + voidDone <- f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "parked", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the cash return under a parked void = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the void's claim holds the sale)", blockers) + } + f.assertARInvariants(t) + release() + if rr := <-voidDone; rr.status != http.StatusOK { + t.Fatalf("the parked void = %d: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided", got) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds, want 1", refunds) + } + f.assertARInvariants(t) +} + +// RULE (eighth review P2-1, the crashed claim): a claim whose act died +// between the terminal call and its money transaction stands PENDING; the +// drawer refuses the same goods until it is reconciled. +func TestACashReturnAfterACrashedClaimRefuses(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) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + // what a crashed card return leaves: the PENDING claim, nothing booked + mustExec(t, f.db, ` + INSERT INTO pos_refund_intents (id, sale_id, tender_id, gateway_tx_id, amount, act, state) + SELECT $1, t.id, d.id, d.gateway_tx_id, d.amount, 'return', 'PENDING' + FROM pos_transactions t JOIN pos_tenders d ON d.transaction_id = t.id + WHERE t.id = $2 AND d.method = 'CARD'`, uuid.New(), saleID) + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the cash return after a crashed card claim = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the crashed claim holds the sale)", blockers) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the charge stands; the drawer paid nothing)", got) + } + f.assertARInvariants(t) +} + +// RULE (seventh review P2-1, the lead's decision): the money transaction +// restates its total under the sale row lock, and the terminal is asked for +// the figure the lock sees: the claim restates the same way inside its own +// lock, and the money transaction refuses when its restated total differs +// from the amount the gateway took. The books never record a cent less than +// the terminal moved: the books and the terminal hold the same figure, or +// the intent stands beside the orphan row for finance. +func TestTheCardReturnBooksWhatTheGatewayTook(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + var refundTook int64 + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + parked, release := parkRefund(t, gw) + f.openTill(0) + // 4 at 5.50 less 2 cents: line 2198, tax 195, total 2393 + saleID := f.startSale(nil) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "4", + "discount_cents": 2, "discount_reason": "two off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, withToken(tender("card", 2393))); 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) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "3") }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT ROUND(amount * 100)::bigint FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID). + Scan(&refundTook); err != nil { + t.Fatalf("read the standing claim: %v", err) + } + if refundTook != 1795 { + t.Fatalf("the claim holds %d cents, want 1795 (the figure the lock sees: 1649 of line and 146 of tax)", refundTook) + } + // the cash return of the last unit is refused while the claim stands + if r := f.returnOn(t, saleID, lineID, "1"); r.status != http.StatusConflict { + t.Fatalf("the cash return under the parked claim = %d, want 409: %s", r.status, r.raw) + } + release() + rr := awaitResp(t, returnDone) + if rr.status != http.StatusCreated { + t.Fatalf("the parked card return = %d: %s", rr.status, rr.raw) + } + if got := num(t, rr.body, "total_cents"); got != -refundTook { + t.Errorf("the card return booked %d, want %d (exactly what the terminal took)", got, -refundTook) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0 (the books match the terminal)", got) + } + if got := f.accountBalance("1010"); got != 598 { + t.Errorf("card balance = %d, want 598 (2393 charged, 1795 refunded once to the card)", got) + } + f.assertARInvariants(t) + // the last unit still returns: the drawer pays it + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("the cash return after the card return = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "total_cents"); got != -598 { + t.Errorf("the completing cash return total = %d, want -598 (the line's remainder 549 and the tax's remainder 49)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (2393 in, 1795 to the card and 598 from the drawer)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/claim_guard_test.go b/core/internal/pos/claim_guard_test.go new file mode 100644 index 000000000..d0d7f2f62 --- /dev/null +++ b/core/internal/pos/claim_guard_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The claim races the reviews proved by probe (seventh review P1-1, eighth +// review P2-1 and P2-2): a claim whose pre-read passed can reach the claim's +// lock after the sale moved under it. A test transaction holds the sale row +// FOR UPDATE, the act blocks inside its claim, and the holder commits the +// state a finished opposite act leaves. The claim must refuse under its lock +// before the terminal is reached: the stub gateway's refund count stays at +// zero, because the tender was already refunded (a committed void) or the +// sale was already returned against (a committed card return). + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// heldSale holds the sale row FOR UPDATE in one open transaction, answering a +// commit function that writes what the racing act leaves and releases the row. +func heldSale(t *testing.T, f *fixture, saleID string) func(func(tx pgx.Tx) error) { + t.Helper() + ctx := context.Background() + conn, err := f.db.Pool.Acquire(ctx) + if err != nil { + t.Fatalf("holder connection: %v", err) + } + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatalf("holder transaction: %v", err) + } + if _, err := tx.Exec(ctx, `SELECT 1 FROM pos_transactions WHERE id = $1 FOR UPDATE`, saleID); err != nil { + t.Fatalf("hold the sale row: %v", err) + } + return func(write func(tx pgx.Tx) error) { + t.Helper() + if err := write(tx); err != nil { + t.Fatalf("holder write: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("holder commit: %v", err) + } + conn.Release() + } +} + +// waitForBlockedClaim polls until a transaction is waiting on another's row +// lock, so the test never races the contender's arrival at the lock. +func waitForBlockedClaim(t *testing.T, f *fixture) { + t.Helper() + ctx := context.Background() + for i := 0; i < 200; i++ { + var waiting int + if err := f.db.Pool.QueryRow(ctx, + `SELECT count(*) FROM pg_locks WHERE locktype = 'transactionid' AND NOT granted`).Scan(&waiting); err != nil { + t.Fatalf("poll locks: %v", err) + } + if waiting > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("no claim ever blocked on the held sale row") +} + +// RULE (seventh review P1-1, the probe as a test): a card return whose claim +// comes after a committed void must never reach the gateway: the void already +// refunded the tender, and a second terminal refund pays the sale twice. The +// claim rechecks the sale's status under the sale row lock and refuses the +// tender that a DONE void intent already refunded. +func TestACardReturnAfterACommittedVoidNeverReachesTheTerminal(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + commit := heldSale(t, f, saleID) + done := make(chan resp, 1) + go func() { done <- f.cardReturnOn(t, saleID, lineID, "1") }() + waitForBlockedClaim(t, f) + // what a committed void leaves: the sale VOIDED and the tender's intent + // DONE carrying the gateway's refund id + commit(func(tx pgx.Tx) error { + if _, err := tx.Exec(context.Background(), + `UPDATE pos_transactions SET status = 'VOIDED', updated_at = NOW() WHERE id = $1`, saleID); err != nil { + return err + } + _, err := tx.Exec(context.Background(), ` + INSERT INTO pos_refund_intents (id, sale_id, tender_id, gateway_tx_id, amount, act, state, refund_tx_id) + SELECT $1, t.id, d.id, d.gateway_tx_id, d.amount, 'void', 'DONE', 'ref-void-1' + FROM pos_transactions t JOIN pos_tenders d ON d.transaction_id = t.id + WHERE t.id = $2 AND d.method = 'CARD'`, uuid.New(), saleID) + return err + }) + r := awaitResp(t, done) + if r.status != http.StatusConflict { + t.Fatalf("the card return after a committed void = %d, want 409: %s", r.status, r.raw) + } + if code, _, _ := errorOf(t, r); code != "invalid_state_transition" { + t.Errorf("code = %q, want invalid_state_transition (the claim rechecks the status under the lock)", code) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 0 { + t.Fatalf("%d gateway refunds, want 0 (the void already refunded the tender: a second call pays the sale twice)", refunds) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 0 { + t.Errorf("%d PENDING refund intents, want 0 (the refused claim left nothing standing)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0 (no refund was made)", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the charge stands, nothing refunded by the return)", got) + } + f.assertARInvariants(t) +} + +// RULE (seventh review P1-1, the mirror): a void whose claim comes after a +// committed card return must never reach the gateway: the return already +// refunded the tender and took the goods back, and the way back is another +// return. The claim rechecks the sale's returns under the sale row lock. +func TestAVoidAfterACommittedCardReturnNeverReachesTheTerminal(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + commit := heldSale(t, f, saleID) + done := make(chan resp, 1) + go func() { + done <- f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "racing a committed return", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + waitForBlockedClaim(t, f) + // what a committed card return leaves: the return row and the tender's + // DONE intent carrying the gateway's refund id + commit(func(tx pgx.Tx) error { + ctx := context.Background() + ret := uuid.New() + if _, err := tx.Exec(ctx, ` + INSERT INTO pos_returns (id, register_id, cashier_id, original_transaction_id, subtotal, tax_amount, total, + refund_method, reason, currency) VALUES ($1, $2, $3, $4, -5.50, -0.49, -5.99, 'CARD', 'raced', 'USD')`, + ret, f.register, mustUUID(t), saleID); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + INSERT INTO pos_refund_intents (id, sale_id, tender_id, gateway_tx_id, amount, act, state, refund_tx_id) + SELECT $1, t.id, d.id, d.gateway_tx_id, d.amount, 'return', 'DONE', 'ref-ret-1' + FROM pos_transactions t JOIN pos_tenders d ON d.transaction_id = t.id + WHERE t.id = $2 AND d.method = 'CARD'`, uuid.New(), saleID) + return err + }) + r := awaitResp(t, done) + if r.status != http.StatusConflict { + t.Fatalf("the void after a committed card 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 (the claim rechecks the returns under the lock)", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 0 { + t.Fatalf("%d gateway refunds, want 0 (the return already refunded the tender)", refunds) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void was refused)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/claim_recheck_test.go b/core/internal/pos/claim_recheck_test.go new file mode 100644 index 000000000..6f786df05 --- /dev/null +++ b/core/internal/pos/claim_recheck_test.go @@ -0,0 +1,575 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The round 4 rechecks the eleventh review could not kill (r11 P2-1): every +// figure the money transaction restates under the sale row lock, and every +// fact of the intent table it rereads there, refuses a booking whose money +// moved on figures that no longer hold. The routes the counter offers refuse +// the writes that would reach them (the intent table's unique claim, the +// status wall), so each test stands the concurrent act in through the real +// tables the recheck reads, in the window between an unlocked read and the +// lock the act takes: the pricing against the claim, the claim against the +// money transaction, the replay's read against its own transaction, and the +// money transaction's two reads of the intent facts against each other. + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// holdRowLock takes one row FOR NO KEY UPDATE on its own held connection and +// keeps it: the mode the acts themselves lock in, strong enough to park the +// sale row lock or the session share lock, soft enough that a stand-in's +// foreign keys still take their KEY SHARE on the held row. blocked answers +// once another transaction is waiting on that row lock (the stand-in window +// between an unlocked read and the lock the act takes), and release commits +// and gives the connection back. The wait is probed on the holding +// transaction's own id, so a package running beside this one against the +// same database never answers it. +func holdRowLock(t *testing.T, f *fixture, table, id string) (blocked func(), release func()) { + t.Helper() + ctx := context.Background() + conn, err := f.db.Pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire the holding connection: %v", err) + } + if _, err := conn.Exec(ctx, "BEGIN"); err != nil { + t.Fatalf("begin the hold: %v", err) + } + if _, err := conn.Exec(ctx, "SELECT 1 FROM "+table+" WHERE id = $1 FOR NO KEY UPDATE", id); err != nil { + t.Fatalf("lock the row: %v", err) + } + var xid string + if err := conn.QueryRow(ctx, "SELECT pg_current_xact_id()::text").Scan(&xid); err != nil { + t.Fatalf("read the hold's transaction id: %v", err) + } + blocked = func() { + t.Helper() + deadline := time.Now().Add(requestDeadline) + for { + var waiting int64 + if err := f.db.Pool.QueryRow(ctx, + `SELECT count(*) FROM pg_locks WHERE locktype = 'transactionid' AND NOT granted AND transactionid = $1::xid`, xid). + Scan(&waiting); err != nil { + t.Fatalf("read the lock waits: %v", err) + } + if waiting > 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("no transaction reached the %s row lock within %s", table, requestDeadline) + } + time.Sleep(2 * time.Millisecond) + } + } + var once sync.Once + release = func() { + t.Helper() + once.Do(func() { + if _, err := conn.Exec(ctx, "COMMIT"); err != nil { + t.Fatalf("release the %s row lock: %v", table, err) + } + conn.Release() + }) + } + t.Cleanup(release) + return blocked, release +} + +// cardTenderID answers the sale's card tender id. +func cardTenderID(t *testing.T, f *fixture, saleID string) string { + t.Helper() + var id string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT id::text FROM pos_tenders WHERE transaction_id = $1 AND method = 'CARD'`, saleID).Scan(&id); err != nil { + t.Fatalf("read the card tender: %v", err) + } + return id +} + +// insertVoidIntentDone stands a void claim of the tender in as DONE: the row +// a resolution of the sale's void leaves, standing for the void whose tender +// the terminal already refunded whole. +func insertVoidIntentDone(t *testing.T, f *fixture, saleID, tenderID string) { + t.Helper() + if _, err := f.db.Pool.Exec(context.Background(), ` + INSERT INTO pos_refund_intents (id, sale_id, tender_id, gateway_tx_id, amount, act, state, created_at, updated_at) + VALUES ($1, $2, $3, 'gw-1', 0, 'void', 'DONE', NOW(), NOW())`, + uuid.New(), saleID, tenderID); err != nil { + t.Fatalf("insert the stand-in void intent: %v", err) + } +} + +// settleIntentAsWinner stands a winning refund resolution's footprint in: the +// claim DONE under the recorded refund id, and the return rows its booking +// wrote (the memo side is not read by any guard here). This is the state a +// contender that won the claim while this act priced leaves behind. +func settleIntentAsWinner(t *testing.T, f *fixture, intentID, saleID, lineID string, cents int64) { + t.Helper() + ctx := context.Background() + if _, err := f.db.Pool.Exec(ctx, + `UPDATE pos_refund_intents SET state = 'DONE', refund_tx_id = 'ref-winner', updated_at = NOW() WHERE id = $1`, intentID); err != nil { + t.Fatalf("settle the claim: %v", err) + } + returnID := uuid.New() + if _, err := f.db.Pool.Exec(ctx, `INSERT INTO pos_returns (id, number, register_id, original_transaction_id, customer_id, branch_id, + cashier_id, currency, subtotal, tax_amount, total, refund_method, reason, status, created_at, updated_at, revision) + VALUES ($1, 'RTN-WINNER', $2, $3, $4, NULL, $5, 'USD', 0, 0, $6::bigint::numeric / 100, 'CARD', 'stand-in winner', 'COMPLETED', NOW(), NOW(), 1)`, + returnID, f.register, saleID, f.customerID, mustUUID(t), -cents); err != nil { + t.Fatalf("insert the winner's return: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, `INSERT INTO pos_return_lines (return_id, position, line_type, product_id, description, quantity, uom, + unit_price, line_total, restock, sale_line_id, created_at) + VALUES ($1, 0, 'PRODUCT', $2, 'stand-in winner', -1, 'EA', 0, $3::bigint::numeric / 100, FALSE, $4, NOW())`, + returnID, f.productID, -cents, lineID); err != nil { + t.Fatalf("insert the winner's return line: %v", err) + } +} + +// insertBookedCardReturn stands a booked card refund of the sale in: a CARD +// return row with no lines, so the caps and the remainder rule read nothing +// of it and only the card cap's own sum counts it. +func insertBookedCardReturn(t *testing.T, f *fixture, saleID string, cents int64) { + t.Helper() + if _, err := f.db.Pool.Exec(context.Background(), `INSERT INTO pos_returns (id, number, register_id, original_transaction_id, customer_id, branch_id, + cashier_id, currency, subtotal, tax_amount, total, refund_method, reason, status, created_at, updated_at, revision) + VALUES ($1, 'RTN-CARDED', $2, $3, $4, NULL, $5, 'USD', 0, 0, $6::bigint::numeric / 100, 'CARD', 'stand-in booked card refund', 'COMPLETED', NOW(), NOW(), 1)`, + uuid.New(), f.register, saleID, f.customerID, mustUUID(t), -cents); err != nil { + t.Fatalf("insert the stand-in card return: %v", err) + } +} + +// RULE (r11 P2-1, the claim's restate): a remainder that moves between the +// pricing and the claim is restated under the sale row lock before the +// terminal is asked, so the figure the terminal refunds is the figure the +// books can take. Without the restate the terminal is asked the stale naive +// share, the money transaction then restates under it and refuses, and the +// money that moved is orphaned beside a claim that cannot book it. +func TestTheClaimRestatesItsRemainderUnderTheLockBeforeTheTerminalIsAsked(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID := f.startSale(nil) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "4", + "discount_cents": 2, "discount_reason": "two off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, withToken(tender("card", 2393))); 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) + + blocked, release := holdRowLock(t, f, "pos_transactions", saleID) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "3") }() + t.Cleanup(func() { release(); <-fin }) + // the return priced on the pre-transaction figures and its claim is now + // parked behind the sale row lock: move the remainder inside the window + blocked() + insertUnlinkedReturnLine(t, f, saleID, 599) + release() + rr := awaitResp(t, returnDone) + if rr.status != http.StatusCreated { + t.Fatalf("the card return under the moved remainder = %d, want 201 (the claim restated before the terminal): %s", rr.status, rr.raw) + } + gw.mu.Lock() + asked := gw.refundCents[0] + calls := len(gw.refunds) + gw.mu.Unlock() + if asked != 1794 { + t.Errorf("the terminal was asked for %d cents, want 1794 (the restated remainder under the lock, never the naive 1795)", asked) + } + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1", calls) + } + if got := num(t, rr.body, "total_cents"); got != -1794 { + t.Errorf("the booked return total = %d, want -1794 (the books take the figure the terminal was asked for)", got) + } + if got := f.scalar(`SELECT ROUND(amount * 100)::bigint FROM pos_refund_intents WHERE sale_id = $1 AND act = 'return'`, saleID).(int64); got != 1794 { + t.Errorf("the claim holds %d cents, want 1794", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (2393 charged, 1794 refunded once)", got) + } + f.assertARInvariants(t) +} + +// RULE (r11 P2-1, the card cap under the money transaction's lock): a booked +// card refund that lands between the claim and the money transaction is +// counted before the booking is allowed, so the tender is never refunded +// past what it kept less every refund already booked. +func TestACardReturnPastTheBookedRefundsRefusesUnderTheLock(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + + parked, release := parkRefund(t, gw) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "1") }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + // a card refund of the sale booked while this return sat at the terminal: + // the claim passed the cap on the figures it saw, the booking must not + insertBookedCardReturn(t, f, saleID, 500) + release() + rr := awaitResp(t, returnDone) + if rr.status != http.StatusConflict { + t.Fatalf("the card return past the booked refunds = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "exceeds_card_tender" { + t.Fatalf("blockers = %v, want exceeds_card_tender (599 asked against the tender's 599 less the 500 booked)", blockers) + } + _, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID == "" { + t.Fatalf("the claim = (%s, %q), want PENDING carrying the refund id", state, refundTxID) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1 recording the money that moved", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the charge stands: nothing booked past the cap)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 0 { + t.Errorf("%d refund rows, want 0 (nothing booked)", got) + } + if got := f.counterReturns(t); got != 1 { + t.Errorf("%d returns of the sale, want 1 (the stand-in booked refund alone)", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1 (the terminal was reached once)", calls) + } + f.assertARInvariants(t) +} + +// RULE (r11 P2-1, the held figure): the money transaction rereads the +// tender's standing claims and refuses when the table holds a different +// figure than the claim this return books under: the booking follows the +// intent table's own record, never a figure the table no longer holds. +func TestAClaimWhoseTableAmountMovedRefusesTheBooking(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + + parked, release := parkRefund(t, gw) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "1") }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + // the claim's own row now holds a different figure than the booking's + // in-memory claim: the stand-in for a second standing claim the unique + // index refuses, whose sum the transaction must not book beside + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE pos_refund_intents SET amount = 5.00, updated_at = NOW() WHERE sale_id = $1 AND state = 'PENDING'`, saleID); err != nil { + t.Fatalf("move the claim's table figure: %v", err) + } + release() + rr := awaitResp(t, returnDone) + if rr.status != http.StatusConflict { + t.Fatalf("the card return under a moved table figure = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Fatalf("blockers = %v, want refund_in_flight (the table holds 500 against the booking's 599)", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING' + AND ROUND(amount * 100)::bigint = 500`, saleID); got != 1 { + t.Errorf("the claim's table figure was not kept at 500: %d rows", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1 recording the money that moved", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (nothing booked beside a moved figure)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 0 { + t.Errorf("%d refund rows, want 0 (nothing booked)", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1", calls) + } + f.assertARInvariants(t) +} + +// RULE (r11 P2-1, the money transaction's tender fact): a void claim of the +// tender that reaches DONE between the transaction's two reads of the intent +// facts refuses the booking: the tender was refunded whole by the sale's +// void, and no second refund of it can be booked, whatever the claim this +// return holds. The window is the transaction's own till session lock, taken +// between the first read (before the caps) and the second (the booking's +// rules). +func TestATenderTheVoidRefundedBetweenTheFactsReadsRefusesTheBooking(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + sessionID := f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + + blocked, release := holdRowLock(t, f, "till_sessions", sessionID) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "1") }() + t.Cleanup(func() { release(); <-fin }) + // the money transaction passed its first read of the claim facts and now + // parks on the session row: the tender's void resolution lands inside + blocked() + insertVoidIntentDone(t, f, saleID, cardTenderID(t, f, saleID)) + release() + rr := awaitResp(t, returnDone) + if rr.status != http.StatusConflict { + t.Fatalf("the card return under a void refunded tender = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "tender_refunded" { + t.Fatalf("blockers = %v, want tender_refunded (the void's resolution refunded the tender whole between the reads)", blockers) + } + _, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID == "" { + t.Fatalf("the claim = (%s, %q), want PENDING carrying the refund id", state, refundTxID) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1 recording the money that moved", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the tender was refunded by the void: this booking never ran)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 0 { + t.Errorf("%d refund rows, want 0", got) + } + if got := f.counterReturns(t); got != 0 { + t.Errorf("%d returns booked, want 0", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1", calls) + } + f.assertARInvariants(t) +} + +// RULE (r11 P2-1, the money transaction's pending fact): the claim settled +// by another resolution between the transaction's two reads of the intent +// facts refuses the booking with the race named (intent_not_pending), never +// the misleading in-flight figure the later checks would answer with. +func TestAClaimSettledBetweenTheFactsReadsRefusesWithTheRaceNamed(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + sessionID := f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + + blocked, release := holdRowLock(t, f, "till_sessions", sessionID) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "1") }() + t.Cleanup(func() { release(); <-fin }) + blocked() + // the claim was settled by a concurrent resolution inside the window + intentID, _, _, _ := pendingIntent(t, f, saleID) + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE pos_refund_intents SET state = 'DONE', refund_tx_id = 'ref-won', updated_at = NOW() WHERE id = $1`, intentID); err != nil { + t.Fatalf("settle the claim: %v", err) + } + release() + rr := awaitResp(t, returnDone) + if rr.status != http.StatusConflict { + t.Fatalf("the card return under its settled claim = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "intent_not_pending" { + t.Fatalf("blockers = %v, want intent_not_pending (the claim was settled by another resolution first)", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'DONE'`, intentID); got != 1 { + t.Errorf("the settled claim was not kept settled: %d rows", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1 recording the money that moved", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the winner's booking is the only one)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 0 { + t.Errorf("%d refund rows, want 0 (the stand-in settled no money)", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1", calls) + } + f.assertARInvariants(t) +} + +// RULE (r11 P2-1, the replay's pre-cap pending fact): a replay whose claim a +// concurrent resolution won while it priced answers the race under the sale +// row lock and before the caps (intent_not_pending), never the winner's own +// booking tripping the caps of the act that lost (exceeds_sold). The +// stand-in is the winner's whole footprint: the settled claim and the return +// rows its booking wrote. +func TestAReplayWhoseClaimWasWonUnderItAnswersTheRaceNeverTheCaps(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID != "" { + t.Fatalf("the stranded claim = (%s, %q)", state, refundTxID) + } + + blocked, release := holdRowLock(t, f, "pos_transactions", saleID) + resolveDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + resolveDone <- f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": "ref-winner", "reason": "the terminal refunded", + }, "finance") + }() + t.Cleanup(func() { release(); <-fin }) + // the replay read its claim PENDING and priced, and its transaction now + // parks on the sale row: the concurrent resolution wins inside + blocked() + settleIntentAsWinner(t, f, intentID, saleID, lineID, 599) + release() + rr := awaitResp(t, resolveDone) + if rr.status != http.StatusConflict { + t.Fatalf("the replay under a won claim = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "intent_not_pending" { + t.Fatalf("blockers = %v, want intent_not_pending (the race, never the caps the winner's booking trips)", blockers) + } + // the replay writes no orphan row of its own and moves nothing: the + // stranded act's timeout left its own unknown outcome row, and the claim + // carrying no refund id means no money to orphan + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0 (the replay refused before any money of its own)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_outcome_unknown' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d unknown outcome rows, want 1 (the stranded act's own record alone)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 0 { + t.Errorf("%d refund rows, want 0 (the stand-in winner booked none)", got) + } + if got := f.counterReturns(t); got != 1 { + t.Errorf("%d returns booked, want 1 (the stand-in winner's alone)", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1 (the stranded act's own call)", calls) + } + f.assertARInvariants(t) +} + +// RULE (r11 P2-1, the replay's pre-cap tender fact): a replay that reaches +// its transaction under a tender the sale's void already refunded answers +// the tender (tender_refunded) before the caps, whatever else the window +// wrote: the intent table's authority outranks the figure guards, and the +// answer names the money, not the goods. +func TestAReplayUnderAVoidRefundedTenderAnswersTheTenderNeverTheCaps(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID != "" { + t.Fatalf("the stranded claim = (%s, %q)", state, refundTxID) + } + + blocked, release := holdRowLock(t, f, "pos_transactions", saleID) + resolveDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + resolveDone <- f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": "ref-winner", "reason": "the terminal refunded", + }, "finance") + }() + t.Cleanup(func() { release(); <-fin }) + blocked() + // the window writes the void's DONE claim beside the winner's footprint: + // the caps would trip on the winner's rows, the tender fact outranks them + settleIntentAsWinner(t, f, intentID, saleID, lineID, 599) + insertVoidIntentDone(t, f, saleID, cardTenderID(t, f, saleID)) + release() + rr := awaitResp(t, resolveDone) + if rr.status != http.StatusConflict { + t.Fatalf("the replay under a void refunded tender = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "tender_refunded" { + t.Fatalf("blockers = %v, want tender_refunded (the intent table's authority, never the caps)", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0 (the replay refused before any money of its own)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_outcome_unknown' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d unknown outcome rows, want 1 (the stranded act's own record alone)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 0 { + t.Errorf("%d refund rows, want 0", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1", calls) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/claim_restate_test.go b/core/internal/pos/claim_restate_test.go new file mode 100644 index 000000000..f2162b43f --- /dev/null +++ b/core/internal/pos/claim_restate_test.go @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The round 4 claims the probes left unproved (ninth review P2-3 and P3-1): +// the figures the claim restates under its lock are the figures the terminal +// is asked for and the books take; a remainder that moves between the claim +// and the money transaction refuses the booking (refund_amount_changed) with +// the claim standing; and the void leg's resolution writes its audit row +// inside the booking, so a rolled back replay leaves none. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// RULE (ninth review P3-1, the restate): after the drawer returns one unit +// (599 of the 2393), the card return of the other three claims, is asked for +// and books exactly the restated remainder 1794 (1648 of line and 146 of +// tax), never the naive rounded share 1795. +func TestTheCardClaimIsAskedForAndBooksTheRestatedRemainder(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) + saleID := f.startSale(nil) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "4", + "discount_cents": 2, "discount_reason": "two off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, withToken(tender("card", 2393))); 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) + if r := f.returnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("the cash return of one unit = %d: %s", r.status, r.raw) + } + r := f.cardReturnOn(t, saleID, lineID, "3") + if r.status != http.StatusCreated { + t.Fatalf("the card return of three units = %d: %s", r.status, r.raw) + } + gw.mu.Lock() + cents := gw.refundCents[0] + gw.mu.Unlock() + if cents != 1794 { + t.Errorf("the terminal was asked for %d cents, want 1794 (the restated remainder, not the naive 1795)", cents) + } + if got := num(t, r.body, "total_cents"); got != -1794 { + t.Errorf("the booked return total = %d, want -1794", got) + } + if got := f.scalar(`SELECT ROUND(amount * 100)::bigint FROM pos_refund_intents WHERE sale_id = $1 AND act = 'return'`, saleID).(int64); got != 1794 { + t.Errorf("the claim holds %d cents, want 1794", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (2393 charged, 1794 to the card, 599 from the drawer)", got) + } + f.assertARInvariants(t) +} + +// RULE (ninth review P3-1, refund_amount_changed reached): a remainder that +// moves between the claim and the money transaction refuses the booking: the +// books never record a cent less than the terminal moved, the claim stands +// PENDING beside the orphan row, and the operator resolution's replay refuses +// the same way. The competing movement is written in the parked window the +// way the suite's crash proofs work: a stand-in row through the real table +// the rule reads (an unlinked return line of the sale), standing for the +// concurrent act the intent table now refuses. +func TestAMovedRemainderRefusesTheBookingAndKeepsTheClaim(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID := f.startSale(nil) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "4", + "discount_cents": 2, "discount_reason": "two off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, withToken(tender("card", 2393))); 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) + + parked, release := parkRefund(t, gw) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "3") }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + // the concurrent act the window allows: an unlinked return line of the + // sale, the shape a legacy route or a direct write leaves + insertUnlinkedReturnLine(t, f, saleID, 599) + release() + rr := awaitResp(t, returnDone) + if rr.status != http.StatusConflict { + t.Fatalf("the card return under a moved remainder = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "refund_amount_changed" { + t.Fatalf("blockers = %v, want refund_amount_changed (the terminal took 1795, the sale now restates 1196)", blockers) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID == "" { + t.Fatalf("the claim = (%s, %q), want PENDING carrying the refund id", state, refundTxID) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1 recording the money that moved", got) + } + if got := f.accountBalance("1010"); got != 2393 { + t.Errorf("card balance = %d, want 2393 (the charge stands: nothing booked under a moved remainder)", got) + } + f.assertARInvariants(t) + // the resolution's replay refuses the same way: the money moved, the + // books cannot take it under figures that no longer hold + r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": refundTxID, "reason": "the terminal refunded 1795", + }, "finance") + if r.status != http.StatusConflict { + t.Fatalf("the resolution of the moved remainder = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ = errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_amount_changed" { + t.Errorf("blockers = %v, want refund_amount_changed on the replay too", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID); got != 1 { + t.Errorf("the replay settled the claim it could not book") + } + // the resolution writes no orphan row of its own (tenth review P3-2): the + // claim and the act's own row already record the money, and finance + // reconciling orphans must not see the same refund twice + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows after the refused replay, want 1 (the stranded act's own record alone)", got) + } + f.assertARInvariants(t) +} + +// insertUnlinkedReturnLine writes one unlinked (legacy shaped) return row of +// the sale through the real tables the remainder rule reads: the stand-in for +// a concurrent act landing in the parked window, the same standing-in the +// suite's failing event writes do for crashes. +func insertUnlinkedReturnLine(t *testing.T, f *fixture, saleID string, cents int64) { + t.Helper() + returnID := uuid.New() + ctx := context.Background() + if _, err := f.db.Pool.Exec(ctx, `INSERT INTO pos_returns (id, number, register_id, original_transaction_id, customer_id, branch_id, + cashier_id, currency, subtotal, tax_amount, total, refund_method, reason, status, created_at, updated_at, revision) + VALUES ($1, 'RTN-LEGACY', $2, $3, $4, NULL, $5, 'USD', 0, 0, $6::bigint::numeric / 100, 'CASH', 'stand-in', 'COMPLETED', NOW(), NOW(), 1)`, + returnID, f.register, saleID, f.customerID, mustUUID(t), -cents); err != nil { + t.Fatalf("insert the stand-in return: %v", err) + } + if _, err := f.db.Pool.Exec(ctx, `INSERT INTO pos_return_lines (return_id, position, line_type, product_id, description, quantity, uom, + unit_price, line_total, restock, created_at) + VALUES ($1, 0, 'PRODUCT', $2, 'stand-in', -1, 'EA', 0, $3::bigint::numeric / 100, FALSE, NOW())`, + returnID, f.productID, -cents); err != nil { + t.Fatalf("insert the stand-in return line: %v", err) + } +} + +// RULE (ninth review P2-3, the void leg's audit row): a void claim's refund +// resolution writes its pos.refund_intent.resolved row inside the booking +// transaction, so a replay that rolls back leaves none and a booking leaves +// exactly one. +func TestTheVoidResolutionsAuditRowRollsBackWithItsBooking(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" + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "stranded", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status == http.StatusOK { + t.Fatalf("the void committed under a failing event write: %s", r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID == "" { + t.Fatalf("the stranded void's claim = (%s, %q)", state, refundTxID) + } + // the replay still fails (the event write still fails): no audit row + if r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": refundTxID, "reason": "rolled back replay", + }, "finance"); r.status == http.StatusOK { + t.Fatalf("the replay committed under the failing event write: %s", r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' AND changes->>'intent_id' = $1`, intentID); got != 0 { + t.Errorf("%d resolution audit rows after a rolled back replay, want 0 (the row rides the booking's transaction)", got) + } + // the refused replay writes no orphan row either (tenth review P3-2): the + // stranded act's own record already names the refund + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows after the rolled back replay, want 1 (the stranded void's own record alone)", got) + } + // the failure gone, the booking writes exactly one + f.events.fail = "" + if r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": refundTxID, "reason": "the terminal refunded", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the resolution = %d, want 200: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' + AND changes->>'intent_id' = $1 AND changes->>'outcome' = 'refund' AND changes->>'refund_tx_id' = $2`, intentID, refundTxID); got != 1 { + t.Errorf("%d resolution audit rows naming the intent, the outcome and the id, want 1", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/close_claim_test.go b/core/internal/pos/close_claim_test.go new file mode 100644 index 000000000..395d7d048 --- /dev/null +++ b/core/internal/pos/close_claim_test.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The close against the terminal (eighth review P2-2): a card void parked at +// the gateway let the close commit, and the void was then refused +// session_closed after the card was refunded: the money left after the drawer +// was counted, and the closed session's expected card still counted what the +// terminal gave back. The close refuses while any intent of the session's +// sales is PENDING. + +import ( + "net/http" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE (eighth review P2-2): the close is refused 409 refund_in_flight while +// a card refund intent of one of the session's sales is PENDING, under the +// session lock: the drawer is not counted and closed while a terminal refund +// of one of its sales may still move money. The parked void then completes, +// and the close goes through. +func TestTheCloseRefusesWhileAVoidIsParkedAtTheGateway(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + sessionID := f.currentTillID(t) + parked, release := parkRefund(t, gw) + voidDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + voidDone <- f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "parked at the terminal", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + // the close fires while the void's refund is on the wire + r := f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 0, "card": 599}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the close under a parked card void = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the void's claim holds the session)", blockers) + } + // the refusal names the standing claim and who unblocks the drawer (tenth + // review P3-5): the cashier knows the claim id and the resolve route + intentID, _, _, _ := pendingIntent(t, f, saleID) + if !strings.Contains(string(r.raw), intentID) { + t.Errorf("the close's refusal does not name the standing claim %s: %s", intentID, r.raw) + } + if !strings.Contains(string(r.raw), "/api/v1/pos/refund-intents/") { + t.Errorf("the close's refusal does not name the resolve route: %s", r.raw) + } + if got := f.scalar(`SELECT status FROM till_sessions WHERE id = $1`, sessionID); got != "OPEN" { + t.Errorf("the session = %v, want OPEN (the close refused, nothing was counted)", got) + } + f.assertARInvariants(t) + release() + if rr := <-voidDone; rr.status != http.StatusOK { + t.Fatalf("the parked void = %d: %s", rr.status, rr.raw) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds, want 1 (the terminal refunded the card once)", refunds) + } + // the void completed: the drawer closes over it + r = f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 0, "card": 599}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("the close after the void completed = %d: %s", r.status, r.raw) + } + if got := f.scalar(`SELECT status FROM till_sessions WHERE id = $1`, sessionID); got != "CLOSED" { + t.Errorf("the session = %v, want CLOSED", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/complete.go b/core/internal/pos/complete.go new file mode 100644 index 000000000..0dfd856c1 --- /dev/null +++ b/core/internal/pos/complete.go @@ -0,0 +1,768 @@ +// 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 + } + if err := s.holdSaleBranch(ctx, saleID); err != nil { + return nil, err + } + 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 + } + // 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 + // 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") + } + // 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). + 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(estimate.taxCents) + 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") + } + } + // 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 { + facts, err := s.repo.CustomerFacts(ctx, realCustomerID) + if err != nil { + return nil, err + } + // An account that asks for a purchase order is not sold to on + // account without one; the check runs before any card is charged. + if facts.PORequired && (pre.CustomerPO == nil || strings.TrimSpace(*pre.CustomerPO) == "") { + return nil, conflict("po_required", + "the customer requires a purchase order number: name it on the sale before charging it to the account") + } + 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). Every + // charge the transaction then refuses is reversed (gateway.go), and a + // tender whose charge is declined or fails reverses every charge the + // tenders before it already took (fourth review P1-3): no card is left + // charged behind a sale that never was. + type chargeMade struct { + result *chargeResult + amountCents int64 + } + var chargesMade []chargeMade + 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 || res.Status != chargeApproved { + // the charges already made go back before the refusal answers, + // every one of them (a reversal that fails must not leave the + // later charges standing): the first failure is answered, the + // rest are still attempted + var firstErr error + for _, c := range chargesMade { + if rerr := s.reverseCharge(ctx, c.result.GatewayTxID, c.amountCents, saleID, actor, err); rerr != nil && firstErr == nil { + firstErr = rerr + } + } + if firstErr != nil { + return nil, firstErr + } + if err != nil { + return nil, fmt.Errorf("card charge failed: %w", err) + } + 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)}) + } + var out *Sale + 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; every charge is attempted + // (a reversal that fails must not leave the later charges standing) + // and a reversal that fails too answers 502 charge_not_reversed + // instead of the refusal. + var firstErr error + for _, c := range chargesMade { + if rerr := s.reverseCharge(ctx, c.result.GatewayTxID, c.amountCents, saleID, actor, err); rerr != nil && firstErr == nil { + firstErr = rerr + } + } + if firstErr != nil { + return nil, firstErr + } + return nil, err + } + 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 +// 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 err := refuseHeldAccount(facts); 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 { + // 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 + 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 + } + 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 + } + 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 + // 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), 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 + } + 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 { + 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, + CustomerPO: sale.CustomerPO, JobID: sale.JobID, + } + refs, err := s.costRefs(ctx, lines) + 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{ + 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, + } + invoiceLineOf[b.ID] = fl.ID + if b.PricedUnitPrice != nil { + p := int64(*b.PricedUnitPrice) + fl.PricedUnitPrice = &p + } + if b.ParentLineID != nil { + parent := invoiceLineOf[*b.ParentLineID] + fl.ParentLineID = &parent + } + 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 stored link each sale line now carries to the invoice line built + // from it: a linked return reads its cost and its memo link from this + // id, never from a position (a removed cart line leaves the sale's + // positions gapped while the invoice numbers its own lines from zero). + if err := s.repo.LinkInvoiceLines(ctx, saleID, invoiceLineOf); 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). The change is the checked value + // from the live total above. + 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 +} + +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/credit_lock_test.go b/core/internal/pos/credit_lock_test.go new file mode 100644 index 000000000..c4ee19b1c --- /dev/null +++ b/core/internal/pos/credit_lock_test.go @@ -0,0 +1,59 @@ +// 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 concurrent ACCOUNT sales for one +// customer cannot each read an exposure that does not yet count the others +// and end over the limit together (three contenders, the recipe's shape). + +import ( + "sync" + "testing" + + "github.com/gablelbm/gable/internal/testutil" +) + +func TestThreeAccountSalesCannotAllPassTheCreditCheck(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) + }) + // Three sales that each fit the limit alone (2395 of a 3000 limit) but + // not together; completed at the same time at pool size 4, three + // contenders (the recipe's shape). + sales := make([]string, 3) + 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, 3) + 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 others 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/customer_lookup.go b/core/internal/pos/customer_lookup.go new file mode 100644 index 000000000..6faf1e367 --- /dev/null +++ b/core/internal/pos/customer_lookup.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "context" + "fmt" + "strings" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/audit" + "github.com/google/uuid" +) + +// The counter's account standing words (the screen words them): the +// customer's credit as the charge tender will read it. +const ( + StandingGood = "good" + StandingNoLimit = "no_limit" + StandingNoCredit = "no_credit" + StandingOver = "over_limit" +) + +// standOf fills the account's available credit and its standing from the +// credit limit and the open receivable the credit check reads (ADR 0005 +// section 5.3): no limit is unlimited, a limit of zero is no credit, and an +// open receivable over the limit is over it. +func standOf(c *CounterCustomer, open int64) { + c.OpenReceivableCents = httpx.Cents(open) + switch { + case c.CreditLimitCents == nil: + c.AvailableCreditCents = nil + c.Standing = StandingNoLimit + case int64(*c.CreditLimitCents) <= 0: + zero := httpx.Cents(0) + c.AvailableCreditCents = &zero + c.Standing = StandingNoCredit + default: + avail := httpx.Cents(int64(*c.CreditLimitCents) - open) + c.AvailableCreditCents = &avail + c.Standing = StandingGood + if avail < 0 { + c.Standing = StandingOver + } + } +} + +// FindCustomers is the counter's customer lookup: by name or account number, +// the account's standing beside each. The wall is the repository's. +func (s *Service) FindCustomers(ctx context.Context, q string) ([]CounterCustomer, error) { + q = strings.TrimSpace(q) + if len(q) < 2 { + return []CounterCustomer{}, nil + } + found, err := s.repo.FindCounterCustomers(ctx, q, 10) + if err != nil { + return nil, err + } + for i := range found { + open, err := s.repo.OpenReceivableCents(ctx, found[i].ID) + if err != nil { + return nil, err + } + standOf(&found[i], open) + } + return found, nil +} + +// AttachCustomerIn names who the sale is for and what the account asks the +// sale to carry. +type AttachCustomerIn struct { + CustomerID *uuid.UUID + CustomerPO string + 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 +// the job may change while the sale is open. +func (s *Service) AttachCustomer(ctx context.Context, saleID uuid.UUID, in AttachCustomerIn, actor string) (*Sale, error) { + if err := s.holdSaleBranch(ctx, saleID); err != nil { + return nil, err + } + if len(in.CustomerPO) > 100 { + return nil, invalid("customer_po", "must be at most 100 characters") + } + if in.CustomerID == nil && (in.CustomerPO != "" || in.JobID != nil) { + return nil, invalid("customer_id", "name the customer before a purchase order or a job") + } + currency := "" + if in.CustomerID != nil { + c, facts, err := s.namedCustomer(ctx, *in.CustomerID) + if err != nil { + return nil, err + } + if in.JobID != nil { + ok := false + for _, j := range c.Jobs { + if j.ID == *in.JobID { + ok = true + } + } + if !ok { + return nil, invalid("job_id", "is not an open job of this customer") + } + } + currency = facts.Currency + } else { + _, def, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return nil, err + } + currency = def + } + 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 sale.Status != StatusOpen && sale.Status != StatusHeld { + return httpx.InvalidStateTransition(fmt.Sprintf("cannot change the customer of a %s sale", sale.Status.Status())) + } + same := (sale.CustomerID == nil && in.CustomerID == nil) || + (sale.CustomerID != nil && in.CustomerID != nil && *sale.CustomerID == *in.CustomerID) + if !same { + lines, err := s.repo.GetLines(ctx, saleID) + if err != nil { + return err + } + if len(lines) > 0 { + return httpx.InvalidStateTransition( + "the customer is named before the first line: the lines price for the customer, so start a new sale to change it") + } + } + var po *string + if in.CustomerPO != "" { + p := in.CustomerPO + po = &p + } + if err := s.repo.SetSaleAccount(ctx, saleID, in.CustomerID, currency, po, in.JobID); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "pos.sale.customer_attached", EntityType: "pos_transaction", EntityID: saleID, UserID: actor, + Changes: map[string]any{"customer_id": in.CustomerID, "customer_po": in.CustomerPO, "job_id": in.JobID}, + }); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + } + out, err = s.GetSale(ctx, saleID) + return err + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/core/internal/pos/customer_lookup_test.go b/core/internal/pos/customer_lookup_test.go new file mode 100644 index 000000000..cd5f7dc03 --- /dev/null +++ b/core/internal/pos/customer_lookup_test.go @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The counter's customer: the lookup with the account's standing, naming the +// customer on an open sale, and the purchase order and job an account that +// asks for them is charged with. + +import ( + "bytes" + "encoding/json" + "net/http" + "net/url" + "strings" + "sync" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +const cashierHeaders = "X-Test-Role" + +// decodeArray reads a bare array body, numbers kept exact like the fixture's. +func decodeArray(t *testing.T, raw []byte, out *[]map[string]any) bool { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + return dec.Decode(out) == nil +} + +func (f *fixture) findCustomers(q string) resp { + f.t.Helper() + return f.do("GET", "/api/v1/pos/customers?q="+url.QueryEscape(q), nil, + cashierHeaders, "cashier", "X-Test-Sub", mustUUID(f.t)) +} + +func (f *fixture) attach(saleID string, body map[string]any) resp { + f.t.Helper() + return f.do("PUT", "/api/v1/pos/transactions/"+saleID+"/customer", body, + cashierHeaders, "cashier", "X-Test-Sub", mustUUID(f.t)) +} + +func standingOf(t *testing.T, r resp) map[string]any { + t.Helper() + if r.status != http.StatusOK { + t.Fatalf("lookup = %d: %s", r.status, r.raw) + } + var out []map[string]any + if !decodeArray(t, r.raw, &out) || len(out) != 1 { + t.Fatalf("lookup = %s, want exactly the one customer", r.raw) + } + return out[0] +} + +// RULE: the lookup finds a customer by name or by account number and states +// the standing the charge tender will read: no limit, good with the available +// credit, no credit (a limit of zero: on hold), and over the limit. +func TestTheCounterLookupStatesTheAccountsStanding(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + acct := f.scalar(`SELECT account_number FROM customers WHERE id = $1`, f.customerID).(string) + + for _, q := range []string{"Counter Co", "counter", acct} { + c := standingOf(t, f.findCustomers(q)) + if c["id"] != f.customerID.String() || c["standing"] != "no_limit" || c["available_credit_cents"] != nil { + t.Errorf("lookup %q = %v, want the customer with no limit", q, c) + } + } + if r := f.findCustomers("c"); strings.TrimSpace(string(r.raw)) != "[]" { + t.Errorf("a one character query answered %s, want []", r.raw) + } + + mustExec(t, f.db, `UPDATE customers SET credit_limit = 100.00 WHERE id = $1`, f.customerID) + c := standingOf(t, f.findCustomers(acct)) + if c["standing"] != "good" || num(t, c, "credit_limit_cents") != 10000 || num(t, c, "available_credit_cents") != 10000 { + t.Errorf("a 100.00 limit with nothing open = %v, want good with 10000 available", c) + } + + mustExec(t, f.db, `UPDATE customers SET credit_limit = 0 WHERE id = $1`, f.customerID) + if c := standingOf(t, f.findCustomers(acct)); c["standing"] != "no_credit" { + t.Errorf("a limit of zero = %v, want no_credit (on hold)", c) + } + + // an account sale takes 599 plus tax on account; then a limit under it + mustExec(t, f.db, `UPDATE customers SET credit_limit = NULL WHERE id = $1`, f.customerID) + f.openTill(0) + 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) + total := num(t, cur, "total_cents") + if r := f.completeSale(saleID, tender("account", total)); r.status != http.StatusOK { + t.Fatalf("charge sale = %d: %s", r.status, r.raw) + } + mustExec(t, f.db, `UPDATE customers SET credit_limit = 1.00 WHERE id = $1`, f.customerID) + c = standingOf(t, f.findCustomers(acct)) + if c["standing"] != "over_limit" || num(t, c, "open_receivable_cents") != total || num(t, c, "available_credit_cents") != 100-total { + t.Errorf("a 1.00 limit under %d open = %v, want over_limit", total, c) + } + mustExec(t, f.db, `UPDATE customers SET credit_limit = NULL WHERE id = $1`, f.customerID) +} + +// RULE: the customer is named before the first line, the purchase order and +// the job may follow; a job is the customer's own; a sale answers the +// customer it was named for. +func TestTheSaleTakesItsCustomerBeforeTheFirstLine(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + job, other := uuid.New(), uuid.New() + mustExec(t, f.db, `INSERT INTO projects (id, customer_id, name, status) VALUES ($1, $2, 'Smith Deck', 'Active')`, job, f.customerID) + 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) + otherJob := uuid.New() + mustExec(t, f.db, `INSERT INTO projects (id, customer_id, name, status) VALUES ($1, $2, 'Elsewhere', 'Active')`, otherJob, other) + t.Cleanup(func() { + mustExec(t, f.db, `DELETE FROM projects WHERE customer_id = $1`, other) + mustExec(t, f.db, `DELETE FROM customers WHERE id = $1`, other) + }) + + if c := standingOf(t, f.findCustomers("Counter Co")); len(c["jobs"].([]any)) != 1 { + t.Errorf("the lookup carries %v jobs, want the customer's one open job", c["jobs"]) + } + + saleID := f.startSale(nil) + r := f.attach(saleID, map[string]any{"customer_id": f.customerID.String(), "customer_po": " PO-7731 ", "job_id": job.String()}) + if r.status != http.StatusOK || str(t, r.body, "customer_id") != f.customerID.String() || + str(t, r.body, "customer_po") != "PO-7731" || str(t, r.body, "job_id") != job.String() { + t.Fatalf("attach = %d: %s", r.status, r.raw) + } + // another customer's job is refused, and so is a job with no customer + if r := f.attach(saleID, map[string]any{"customer_id": f.customerID.String(), "job_id": otherJob.String()}); r.status != http.StatusBadRequest { + t.Errorf("another customer's job = %d, want 400: %s", r.status, r.raw) + } + if r := f.attach(saleID, map[string]any{"customer_id": nil, "job_id": job.String()}); r.status != http.StatusBadRequest { + t.Errorf("a job with no customer = %d, want 400: %s", r.status, r.raw) + } + // a missing customer is a 404 + if r := f.attach(saleID, map[string]any{"customer_id": uuid.NewString()}); r.status != http.StatusNotFound { + t.Errorf("a missing customer = %d, want 404: %s", r.status, r.raw) + } + // the first line, then the customer is fixed; the PO may still change + if r := f.addLine(saleID, f.productLine("1")); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.attach(saleID, map[string]any{"customer_id": other.String()}); r.status != http.StatusConflict { + t.Errorf("another customer after a line = %d, want 409: %s", r.status, r.raw) + } + r = f.attach(saleID, map[string]any{"customer_id": f.customerID.String(), "customer_po": "PO-8000", "job_id": job.String()}) + if r.status != http.StatusOK || str(t, r.body, "customer_po") != "PO-8000" { + t.Errorf("a new PO after a line = %d: %s", r.status, r.raw) + } + if got := f.scalar(`SELECT count(*) FROM audit_log WHERE action = 'pos.sale.customer_attached' AND entity_id = $1`, uuid.MustParse(saleID)); got.(int64) < 2 { + t.Errorf("%v customer audit rows, want one per naming", got) + } +} + +// RULE: an account that asks for a purchase order is not charged without one +// (409 po_required, nothing moved, before a card could be charged), and the +// invoice carries the PO and the job the sale named. +func TestAChargeSaleCarriesThePurchaseOrderAndJob(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + job := uuid.New() + mustExec(t, f.db, `INSERT INTO projects (id, customer_id, name, status) VALUES ($1, $2, 'Smith Deck', 'Active')`, job, f.customerID) + 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()}); r.status != http.StatusOK { + t.Fatalf("attach = %d: %s", r.status, r.raw) + } + if r := f.addLine(saleID, f.productLine("1")); 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, tender("account", total)) + if r.status != http.StatusConflict || !strings.Contains(string(r.raw), "po_required") { + t.Fatalf("a charge with no PO = %d: %s, want 409 po_required", r.status, r.raw) + } + 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) + } + // cash needs no PO: the check is the account tender's + if r := f.attach(saleID, map[string]any{"customer_id": f.customerID.String(), "customer_po": "PO-5521", "job_id": job.String()}); r.status != http.StatusOK { + t.Fatalf("attach PO = %d: %s", r.status, r.raw) + } + r = f.completeSale(saleID, tender("account", total)) + if r.status != http.StatusOK { + t.Fatalf("a charge with the PO = %d: %s", r.status, r.raw) + } + inv := str(t, r.body, "invoice_id") + if got := f.scalar(`SELECT customer_po FROM invoices WHERE id = $1`, uuid.MustParse(inv)); got != "PO-5521" { + t.Errorf("invoice customer_po = %v, want PO-5521", got) + } + if got := f.scalar(`SELECT project_id::text FROM invoices WHERE id = $1`, uuid.MustParse(inv)); got != job.String() { + t.Errorf("invoice project_id = %v, want the job %s", got, job) + } + f.assertARInvariants(t) +} + +// RULE: naming the customer takes the sale row's lock, so three contenders at +// pool size 4 each land whole: every answer is 200, every one moves the +// revision, and the sale ends holding one of the purchase orders. +func TestThreeNamingsOfOneSaleAreSerialized(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) + saleID := f.startSale(nil) + start := num(t, f.getSale(t, saleID), "revision") + var wg sync.WaitGroup + statuses := make([]int, 3) + pos := []string{"PO-A", "PO-B", "PO-C"} + for i := range pos { + wg.Add(1) + go func(i int) { + defer wg.Done() + statuses[i] = f.attach(saleID, map[string]any{"customer_id": f.customerID.String(), "customer_po": pos[i]}).status + }(i) + } + wg.Wait() + for i, st := range statuses { + if st != http.StatusOK { + t.Errorf("contender %d = %d, want 200", i, st) + } + } + end := f.getSale(t, saleID) + if got := num(t, end, "revision"); got != start+3 { + t.Errorf("revision = %d, want %d (three namings)", got, start+3) + } + po := str(t, end, "customer_po") + if po != "PO-A" && po != "PO-B" && po != "PO-C" { + 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 new file mode 100644 index 000000000..38ed56db1 --- /dev/null +++ b/core/internal/pos/customer_named_wall_test.go @@ -0,0 +1,274 @@ +// 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) + } +} + +// 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/fixture_test.go b/core/internal/pos/fixture_test.go new file mode 100644 index 000000000..0043fc018 --- /dev/null +++ b/core/internal/pos/fixture_test.go @@ -0,0 +1,656 @@ +// 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" + "time" + + "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/payment" + "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 + // cashierSub, when set, is the subject every request that names a random + // one acts as: a test that runs the no header rule (a user with no grants + // is refused everywhere) grants it the sale's branch. + cashierSub string + 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 + // paymentGateway, when an opt sets it, wires the payment route's card + // refunds at a terminal too, the way serve wires Run Payments. + paymentGateway payment.PaymentGateway +} + +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) + // the counter guards the AR core's applications as serve wires it (C2-5): + // an invoice whose sale's void has standing claims takes no application, + // and a payment whose unapplied cash is a declined void tender's credit + // applies to no invoice (held to its tender) + acct.WithInvoiceApplyGuard(f.service) + acct.WithTenderCreditGuard(f.service) + 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() + // the branch guard serve passes the counter (r12 P1-2): the resolution + // route holds the claim's sale to the caller's grants, so the fixture + // proves the same hold serve proves + pos.NewHandler(f.service).WithBranchGuard(middleware.NewBranchGuard(db)).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, and a paymentGateway opt wires its + // card refunds at a terminal the way serve wires Run Payments + payments := payment.NewService(db, payment.NewRepository(db), acct) + if f.paymentGateway != nil { + payments = payments.WithGateway(f.paymentGateway, "") + } + // the payment refund route holds a counter tender's credit to the + // terminal rail as serve wires it (C2-5, FROM-PRINCIPAL 60b) + payments = payments.WithTenderCreditGuard(f.service) + payment.NewHandler(payments).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 + // 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 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 { + 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_refund_intents`) + 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(`DELETE FROM projects WHERE customer_id = $1`, f.customerID) + 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 +} + +// requestDeadline bounds one request a test sends. +const requestDeadline = 30 * time.Second + +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) + } + // every request carries a deadline: a contender stuck behind a lock + // fails its test at the deadline instead of hanging the package to the + // test timeout (seventh review P3-5) + ctx, cancel := context.WithTimeout(context.Background(), requestDeadline) + defer cancel() + req, err := http.NewRequestWithContext(ctx, 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 { + v := headers[i+1] + if f.cashierSub != "" && headers[i] == "X-Test-Sub" { + if _, err := uuid.Parse(v); err == nil { + v = f.cashierSub + } + } + req.Header.Set(headers[i], v) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + // Errorf, not Fatal: contenders call this from their own goroutines + f.t.Errorf("%s %s did not answer within %s: %v", method, path, requestDeadline, err) + return resp{raw: []byte(err.Error())} + } + 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 +// own documents sourced: its customers' (and the walk-in's) invoices, credit +// memos, payments, applications and refunds, the counter's own acts and this +// register's till sessions, and every reversal of one of those. A package +// running beside this one against the same database never enters the count +// (fourth review P2-5). +func (f *fixture) accountBalance(code string) int64 { + f.t.Helper() + walkIn := `(SELECT id FROM customers WHERE account_number = 'WALK-IN')` + invs := `(SELECT id FROM invoices WHERE customer_id IN ($2, ` + 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 ($2, ` + 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 ($2, ` + walkIn + `))` + docs := `((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 + ` OR payment_id IN ` + pays + ` OR credit_memo_id IN ` + cms + `) + UNION (SELECT id::text FROM payment_refunds WHERE payment_id IN ` + pays + ` OR credit_memo_id IN ` + cms + `) + UNION (SELECT id::text FROM pos_transactions) + UNION (SELECT id::text FROM pos_returns) + UNION (SELECT id::text FROM till_sessions WHERE register_id = $3))` + 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($4::text[])) + AND (e.source_ref_id::text IN `+docs+` + OR e.reverses_entry_id IN (SELECT o.id FROM gl_journal_entries o WHERE o.source_ref_id::text IN `+docs+`))`, + code, f.customerID, f.register, f.entryExclusions()).Scan(&n); err != nil { + f.t.Fatal(err) + } + return n +} + +// The fixture's own document counts (fourth review P2-5): every count a +// test asserts is scoped to what this fixture's acts wrote, so the pos +// suite passes beside the other packages writing the same tables in one +// database (the way CI runs it): the counter's invoices, the memos of its +// customers and its returns, its register's returns, and its customers' +// payment refunds. + +func (f *fixture) counterInvoices(t *testing.T) int64 { + t.Helper() + return countOf(t, f.db, `SELECT count(*) FROM invoices WHERE id IN (SELECT invoice_id FROM pos_transactions WHERE invoice_id IS NOT NULL)`) +} + +func (f *fixture) counterMemos(t *testing.T) int64 { + t.Helper() + return countOf(t, f.db, `SELECT count(*) FROM credit_memos WHERE customer_id IN ($1, + (SELECT id FROM customers WHERE account_number = 'WALK-IN')) + OR id IN (SELECT credit_memo_id FROM pos_returns WHERE credit_memo_id IS NOT NULL)`, f.customerID) +} + +func (f *fixture) counterReturns(t *testing.T) int64 { + t.Helper() + return countOf(t, f.db, `SELECT count(*) FROM pos_returns WHERE register_id = $1`, f.register) +} + +func (f *fixture) counterPayments(t *testing.T) int64 { + t.Helper() + return countOf(t, f.db, `SELECT count(*) FROM payments WHERE customer_id IN ($1, + (SELECT id FROM customers WHERE account_number = 'WALK-IN'))`, f.customerID) +} + +func (f *fixture) counterEntries(t *testing.T) int64 { + t.Helper() + return countOf(t, f.db, `SELECT count(*) FROM gl_journal_entries e WHERE e.source_ref_id::text IN + ((SELECT id::text FROM invoices WHERE id IN (SELECT invoice_id FROM pos_transactions WHERE invoice_id IS NOT NULL)) + UNION (SELECT id::text FROM payments WHERE customer_id IN ($1, (SELECT id FROM customers WHERE account_number = 'WALK-IN'))) + UNION (SELECT id::text FROM pos_transactions) + UNION (SELECT id::text FROM pos_returns) + UNION (SELECT id::text FROM till_sessions WHERE register_id = $2))`, f.customerID, f.register) +} + +func (f *fixture) counterRefunds(t *testing.T) int64 { + t.Helper() + return countOf(t, f.db, `SELECT count(*) FROM payment_refunds WHERE payment_id IN + (SELECT id FROM payments WHERE customer_id IN ($1, (SELECT id FROM customers WHERE account_number = 'WALK-IN'))) + OR credit_memo_id IN (SELECT credit_memo_id FROM pos_returns WHERE credit_memo_id IS NOT NULL)`, f.customerID) +} + +// 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/gateway.go b/core/internal/pos/gateway.go new file mode 100644 index 000000000..4310df1b8 --- /dev/null +++ b/core/internal/pos/gateway.go @@ -0,0 +1,286 @@ +// 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" + "errors" + "fmt" + "net/http" + "time" + + "github.com/gablelbm/gable/internal/payment" + "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 + +// refundCallTimeout bounds one terminal refund call, so every path a claim's +// call can take ends inside it: an answer, the terminal's own no, or an +// error that stamps the claim answered with no verdict. Without a bound a +// hung connection would hold the tender's claim in flight forever. +const refundCallTimeout = 30 * time.Second + +// refundAnswerLease is the stated in flight timeout a claim's terminal +// call carries (fourteenth review P1-1): while it runs and the terminal has not +// answered, the resolve route and the drawer close refuse the claim; once it +// expires with no answer at all (a crashed or hung act) the claim becomes +// resolvable and records that the terminal never spoke. It exceeds the call +// bound so a bounded call's error always lands inside the lease; that holds +// for one call, so a void's sequential calls restamp each claim's lease in +// a committed write immediately before its own call, and a later tender's +// call never runs under a lease the earlier calls already spent (the +// sixteenth review's P1-2). +const refundAnswerLease = 90 * 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 + // tenderID is the tender the intent claimed (the money transaction marks + // that intent done as it posts). + tenderID uuid.UUID + // act names the refused act ("void" or "return") for the audit row. + act string + entity uuid.UUID + actor string +} + +// abandonRefundIntent strands a claim the act ended before the terminal was +// reached, in its own committed write: the terminal will never answer for it, +// so the claim records that (UNKNOWN) and stays resolvable as any stranded +// claim is. A failing write is logged and left alone: the claim stays on its +// in flight lease, which expires and reaches the same state (the safe side). +func (s *Service) abandonRefundIntent(ctx context.Context, intent RefundIntent) { + if err := s.repo.AbandonRefundIntent(context.WithoutCancel(ctx), intent.ID); err != nil { + s.logger.Error("CRITICAL: the never called claim was not abandoned", + "act", intent.Act, "sale_id", intent.SaleID, "tender_id", intent.TenderID, "error", err) + } +} + +// failRefundIntent releases a refund intent whose gateway call failed, in +// its own committed write: no money moved for it, so the tender may be +// refunded again. A failing write is logged and left alone: the intent +// stays PENDING, which refuses future refunds of the tender until it is +// reconciled (the safe side). A release that settles no row means another +// hand (a concurrent resolution) settled the claim first: nothing of this +// call's own refusal is lost by that, so it is noted and not alarmed. +func (s *Service) failRefundIntent(ctx context.Context, intent RefundIntent) { + if err := s.repo.FailRefundIntent(ctx, intent.SaleID, intent.TenderID); err != nil { + var hx *httpx.Error + if errors.As(err, &hx) && hx.Code == httpx.CodeConflict { + s.logger.Info("the declined refund claim was settled by another hand", + "act", intent.Act, "sale_id", intent.SaleID, "tender_id", intent.TenderID) + return + } + s.logger.Error("CRITICAL: the failed refund intent was not released", + "act", intent.Act, "sale_id", intent.SaleID, "tender_id", intent.TenderID, "error", err) + } +} + +// refundVerdict is how a refund call's answer reads: accepted (the money +// moved), declined (the terminal's own no: nothing moved), or unknown (every +// other answer proves nothing either way; tenth review P2-3). +type refundVerdict int + +const ( + refundAccepted refundVerdict = iota + refundDeclined + refundUnknown +) + +// refundVerdictOf reads the terminal's answer to a refund call. Only the +// explicit decline releases a claim: a PENDING answer means the refund is +// under way and an ERROR is the terminal's own retry mapping, and neither +// proves the money did not move. +func refundVerdictOf(res *payment.GatewayResult) refundVerdict { + switch res.Status { + case payment.GatewayStatusRefunded, payment.GatewayStatusApproved: + return refundAccepted + case payment.GatewayStatusDeclined: + return refundDeclined + default: + return refundUnknown + } +} + +// noVerdictOf builds the record for an answer that carries no verdict, so +// the unknown outcome audit row names what the terminal said. +func noVerdictOf(res *payment.GatewayResult) error { + return fmt.Errorf("the terminal answered %s", res.Status) +} + +// checkCardClaim refuses a claim the intent table already contradicts +// (seventh review P1-1, made structural): a tender whose void intent is DONE +// was refunded whole; a tender another standing claim already holds is +// refused as in flight (the same refusal its unique index makes at the +// insert, answered before the insert); and no claim may take the tender past +// what it kept less every refund already booked (the card returns, whose +// DONE intents commit beside their rows). Runs under the sale row lock +// inside the claim's own transaction, before the terminal is reached. +func (s *Service) checkCardClaim(ctx context.Context, saleID, tenderID uuid.UUID, amountCents, tenderCap int64) error { + facts, err := s.repo.RefundIntentFacts(ctx, saleID, tenderID) + if err != nil { + return err + } + if facts.DoneVoid { + return conflict("tender_refunded", + "the card tender was already refunded whole by this sale's void: no second refund of it can be made") + } + if facts.PendingCents > 0 { + return conflict("refund_in_flight", + "a card refund of this sale's tender is already in flight: only one refund of a tender runs at a time") + } + // Defence in depth (fourteenth review P1-1): a recorded refund the terminal moved but + // the books never took holds the tender even on a settled claim, so a + // late answer behind an early release can never be paid twice. + if facts.UnbookedRefundedCents > 0 { + return conflict("tender_refunded", + "the terminal already refunded this tender under a recorded refund id that never booked: reconcile that claim before refunding the tender again") + } + booked, err := s.repo.CardRefundedCents(ctx, saleID) + if err != nil { + return err + } + if amountCents > tenderCap-booked { + return conflict("exceeds_card_tender", + fmt.Sprintf("the card tender kept %d cents less the %d booked card refunds: refund the rest from the drawer", + tenderCap, booked)) + } + return nil +} + +// unknownRefundOutcome records a refund whose terminal call answered no +// verdict (a transport or context error: a timeout, a dropped connection, a +// cancel after the terminal acted): the intent stays PENDING, because the +// terminal may have moved the money and releasing the claim would let the +// next refund pay the tender twice, and one committed audit row carries the +// intent's own id and the unknown outcome for reconciliation. +func (s *Service) unknownRefundOutcome(ctx context.Context, intent RefundIntent, err error) { + s.logger.Error("CRITICAL: the card refund answered no verdict; the claim stands until it is reconciled", + "intent_id", intent.ID, "act", intent.Act, "sale_id", intent.SaleID, "tender_id", intent.TenderID, + "gateway_tx_id", intent.GatewayTxID, "amount_cents", intent.AmountCents, "error", err) + // The call is off the wire (fourteenth review P1-1): stamp the answer so the claim is + // stranded and resolvable, exactly as the unknown outcome row records it, + // never held as though its terminal call were still live. A failing stamp + // is logged beside the row: the claim stays IN_FLIGHT until its lease + // expires, the safe side. + sctx, scancel := context.WithTimeout(context.WithoutCancel(ctx), reversalTimeout) + defer scancel() + if serr := s.repo.StampRefundIntentAnswered(sctx, intent.ID); serr != nil { + s.logger.Error("CRITICAL: the no verdict answer was not stamped on the claim", + "intent_id", intent.ID, "sale_id", intent.SaleID, "error", serr) + } + if s.auditLog == nil { + return + } + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), reversalTimeout) + defer cancel() + if aerr := s.auditLog.Log(rctx, audit.Entry{ + Action: "pos.refund_outcome_unknown", EntityType: "pos_transaction", EntityID: intent.SaleID, + Changes: map[string]any{ + "intent_id": intent.ID, "act": intent.Act, "tender_id": intent.TenderID, + "gateway_tx_id": intent.GatewayTxID, "amount_cents": intent.AmountCents, + "outcome": "unknown", "error": fmt.Sprintf("%v", err), + }}); aerr != nil { + s.logger.Error("CRITICAL: the unknown refund outcome audit row was not written", + "intent_id", intent.ID, "sale_id", intent.SaleID, "error", aerr) + } +} + +// 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_loops_test.go b/core/internal/pos/gateway_loops_test.go new file mode 100644 index 000000000..7d0c193af --- /dev/null +++ b/core/internal/pos/gateway_loops_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The proof gaps of the gateway loops (seventh review P3-2 to P3-4): the +// reversal loop of a declined later charge, the intents a successful void +// leaves, and the void claim's status recheck that refuses before the +// terminal. Every money act asserts the ledger ties after it. + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/jackc/pgx/v5" +) + +// RULE (seventh review P3-2): a completion whose third card charge is +// declined reverses the two charges the earlier tenders took, and the loop +// goes on past a reversal that fails: the first charge's void and refund both +// fail (502 charge_not_reversed), and the second charge is still put back. +func TestADeclinedLaterChargeReversesEveryEarlierCharge(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{ + charges: []*payment.GatewayResult{approvedCharge(), approvedCharge(), declinedCharge()}, + voidErrs: []error{fmt.Errorf("void refused")}, + refundErrs: []error{fmt.Errorf("refund refused")}, + } + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + 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", 1000)), withToken(tender("card", 1000)), withToken(tender("card", 395))) + if r.status != http.StatusBadGateway { + t.Fatalf("the completion with a declined third charge = %d, want 502: %s", r.status, r.raw) + } + if code, _, _ := errorOf(t, r); code != "charge_not_reversed" { + t.Errorf("code = %q, want charge_not_reversed (the first reversal failed)", code) + } + gw.mu.Lock() + voids, refunds, charges := len(gw.voids), len(gw.refunds), gw.chargeCalls + gw.mu.Unlock() + if charges != 3 { + t.Errorf("%d charge calls, want 3", charges) + } + if voids != 2 { + t.Errorf("%d gateway voids, want 2 (the loop goes on past the first failed reversal)", voids) + } + if refunds != 1 { + t.Errorf("%d gateway refunds, want 1 (the first charge's fallback; the second charge's void went through)", refunds) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.card_charge_reversal' AND entity_id = $1`, saleID); got != 2 { + t.Errorf("%d charge reversal rows, want 2 (one per charge the declined tender found standing)", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "open" { + t.Errorf("sale status = %q, want open (the completion never happened)", got) + } + f.assertARInvariants(t) +} + +// RULE (seventh review P3-3): a void that goes through leaves every one of its +// refund intents DONE carrying the terminal's refund id, and none PENDING. +func TestASuccessfulVoidLeavesItsIntentsDone(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + 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", 2000)), withToken(tender("card", 395))) + if r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + f.assertARInvariants(t) + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "changed mind", "revision": rev(t, r)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("void = %d: %s", r.status, r.raw) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 2 { + t.Errorf("%d gateway refunds, want 2 (one per card tender)", refunds) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND act = 'void' AND state = 'DONE' AND refund_tx_id IS NOT NULL`, saleID); got != 2 { + t.Errorf("%d DONE void intents carrying a refund id, want 2", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state <> 'DONE'`, saleID); got != 0 { + t.Errorf("%d intents left not DONE, want 0", got) + } + f.assertARInvariants(t) +} + +// RULE (seventh review P3-4): the void's claim rechecks the sale's status +// under the sale row lock and refuses before the terminal. A void blocked in +// its claim while a racing void commits answers 409 and the stub gateway is +// never called. The racing act here leaves the revision as it was, so only the +// claim's status recheck can refuse it. +func TestAVoidWhoseSaleIsVoidedUnderItsClaimMakesNoGatewayCall(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + commit := heldSale(t, f, saleID) + done := make(chan resp, 1) + go func() { + done <- f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "second void", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + waitForBlockedClaim(t, f) + commit(func(tx pgx.Tx) error { + _, err := tx.Exec(context.Background(), + `UPDATE pos_transactions SET status = 'VOIDED', updated_at = NOW() WHERE id = $1`, saleID) + return err + }) + r := awaitResp(t, done) + if r.status != http.StatusConflict { + t.Fatalf("the void of a sale voided under its claim = %d, want 409: %s", r.status, r.raw) + } + if code, _, _ := errorOf(t, r); code != "invalid_state_transition" { + t.Errorf("code = %q, want invalid_state_transition (the claim rechecks the status under the lock)", code) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 0 { + t.Errorf("%d gateway refunds, want 0 (the claim refuses before the terminal)", refunds) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1`, saleID); got != 0 { + t.Errorf("%d refund intents, want 0 (the refused claim left nothing)", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (nothing refunded)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/gateway_order_test.go b/core/internal/pos/gateway_order_test.go new file mode 100644 index 000000000..fad4afccd --- /dev/null +++ b/core/internal/pos/gateway_order_test.go @@ -0,0 +1,670 @@ +// 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 11 and section 14.2 C2-5, first +// review P1-2, second review P1-5): a refusal never moves money at the +// terminal (the completion guards and reverses before it answers; the void +// and the card return claim their refunds through a committed intent unique +// per sale and tender before they call the gateway, so concurrent claims of +// one tender make exactly one refund), and when a transaction still 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, beside its standing +// intent. + +import ( + "context" + "fmt" + "net/http" + "sort" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// 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) +} + +// cardReturnOn posts a card return of qty of the sale's first line. +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, "original_sale_id": saleID, + "refund_method": "card", "reason": "card back", "lines": []map[string]any{ + {"line_id": lineID, "quantity": qty, "restock": false}}, + } + for _, e := range extra { + for k, v := range e { + body[k] = v + } + } + return f.do("POST", "/api/v1/pos/returns", body, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) +} + +// RULE (third review P2-1, fourth review P2-4): a card return refunds the +// sale's own card tender's gateway transaction, never a transaction the +// client names, and never more than that tender kept net of earlier card +// refunds. A sale that was not paid by card, a sale paid with several card +// tenders, and a return that names no sale are all refused: a free card +// return has no card of the business behind it. +func TestACardReturnRefundsTheSalesOwnCardTender(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) + saleID, body := f.saleOf("4", withToken(tender("card", 2395))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + + // a client named gateway transaction is refused outright + r := f.cardReturnOn(t, saleID, lineID, "1", map[string]any{"gateway_tx_id": "someone-elses-charge"}) + if r.status != http.StatusBadRequest { + t.Fatalf("client gateway id = %d, want 400: %s", r.status, r.raw) + } + _, _, fields := errorOf(t, r) + if len(fields) == 0 || fields[0] != "gateway_tx_id" { + t.Errorf("fields = %v, want gateway_tx_id", fields) + } + if len(gw.refunds) != 0 { + t.Fatalf("%d gateway refunds on the refused id, want 0", len(gw.refunds)) + } + + // the return refunds the sale's own charge (gw-1), one unit's money + r = f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("card return = %d: %s", r.status, r.raw) + } + if len(gw.refunds) != 1 || gw.refunds[0] != "gw-1" { + t.Fatalf("gateway refunds = %v, want one against the sale's own gw-1", gw.refunds) + } + if got := num(t, r.body, "total_cents"); got != -599 { + t.Errorf("card return total = %d, want -599", got) + } + // the refund row carries the tender's own gateway transaction + memoID := str(t, r.body, "credit_memo_id") + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds rf JOIN credit_memos m ON m.id = rf.credit_memo_id + WHERE m.id = $1 AND rf.gateway_refund_id = 'gw-1'`, memoID); got != 1 { + t.Errorf("%d refund rows naming the sale's own gateway transaction, want 1", got) + } + f.assertARInvariants(t) + + // a sale that was not paid by card has no card to refund + cashSale, cashBody := f.saleOf("1", tender("cash", 599)) + cashLine := cashBody.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r = f.cardReturnOn(t, cashSale, cashLine, "1") + if r.status != http.StatusConflict { + t.Fatalf("card return of a cash sale = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_tender" { + t.Errorf("blockers = %v, want card_tender", blockers) + } + f.assertARInvariants(t) + + // a free return (no sale named) has no card of the business behind it + r = f.do("POST", "/api/v1/pos/returns", map[string]any{ + "register_id": f.register, "customer_id": f.customerID.String(), "refund_method": "card", + "reason": "no receipt", "lines": []map[string]any{{ + "product_id": f.productID.String(), "quantity": "1", + "unit_price_ten_thousandths": 55000, + }}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("free card return = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ = errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_refund_needs_sale" { + t.Errorf("blockers = %v, want card_refund_needs_sale", blockers) + } + f.assertARInvariants(t) +} + +// RULE: a card return never refunds more than the sale's card tender kept +// (net of change and of earlier card refunds): a split cash and card sale +// returns its cash portion to the drawer. +func TestACardReturnIsCappedAtTheTenderItRefunds(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) + 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) + } + // total 2395: cash 100 walks out as change, the card keeps 2295 + if r := f.completeSale(saleID, tender("cash", 100), withToken(tender("card", 2295))); 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.cardReturnOn(t, saleID, lineID, "4") + if r.status != http.StatusConflict { + t.Fatalf("card return past the tender = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "exceeds_card_tender" { + t.Errorf("blockers = %v, want exceeds_card_tender", blockers) + } + if len(gw.refunds) != 0 { + t.Fatalf("%d gateway refunds past the tender, want 0", len(gw.refunds)) + } + f.assertARInvariants(t) +} + +// RULE (third review P2-2, fourth review P2-3): concurrent voids of one card +// sale make exactly one gateway refund: the void takes the sale row lock +// before it calls the gateway, so the losing voids see the sale already +// voided and never move money. +func TestConcurrentVoidsMakeExactlyOneGatewayRefund(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("4", withToken(tender("card", 2395))) + revision := rev(t, body) + var mu sync.Mutex + codes := make([]int, 3) + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "double tap", "revision": revision}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + mu.Lock() + codes[i] = r.status + mu.Unlock() + }(i) + } + wg.Wait() + sort.Ints(codes) + if codes[0] != http.StatusOK || codes[1] != http.StatusConflict || codes[2] != http.StatusConflict { + t.Fatalf("void codes = %v, want one 200 and two 409", codes) + } + if len(gw.refunds) != 1 { + t.Fatalf("%d gateway refunds for one voided sale, want exactly 1", len(gw.refunds)) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0 (no refund was made without its void)", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (the charge refunded whole)", got) + } + f.assertARInvariants(t) +} + +// RULE (fourth review P1-4): a void whose second card refund fails leaves no +// gateway money unrecorded: every refund already taken is recorded in its own +// committed row, and the sale stands as it was. +func TestAVoidWhoseSecondCardRefundFailsRecordsTheFirst(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{ + charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{nil, fmt.Errorf("gateway unreachable")}, + } + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + 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) + } + // two card tenders: 2000 + 395 for the 2395 total + r := f.completeSale(saleID, withToken(tender("card", 2000)), withToken(tender("card", 395))) + if r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "gateway flaky", "revision": rev(t, r)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status == http.StatusOK { + t.Fatalf("the void committed with a failed refund: %s", r.raw) + } + if len(gw.refunds) != 2 { + t.Fatalf("%d gateway refund calls, want 2 (the first taken, the second failed)", len(gw.refunds)) + } + // the refund that was taken is accounted for in its own committed row + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1 (the refund the failed void left standing)", got) + } + 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 (fifth review P3-2 and P3-3): a sale paid with two card tenders +// refuses a card return outright (a return cannot pick one), and a card +// return past the sold quantity is refused before the claim and the +// terminal: neither moves gateway money nor claims an intent. +func TestATwoCardTenderSaleAndAnOverQuantityReturnRefuseBeforeTheTerminal(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + 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) + } + if r := f.completeSale(saleID, withToken(tender("card", 2000)), withToken(tender("card", 395))); 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) + // two card tenders: a card return cannot pick one + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("card return of a two card tender sale = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_tender" { + t.Errorf("blockers = %v, want card_tender", blockers) + } + // past the sold quantity: refused before the claim and the terminal + r = f.cardReturnOn(t, saleID, lineID, "5") + if r.status != http.StatusConflict { + t.Fatalf("card return past the sold quantity = %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 (the pre claim cap check)", blockers) + } + if len(gw.refunds) != 0 { + t.Fatalf("%d gateway refunds, want 0 (both refusals came before the terminal)", len(gw.refunds)) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1`, saleID); got != 0 { + t.Errorf("%d refund intents claimed by the refusals, want 0", got) + } + f.assertARInvariants(t) +} + +// RULE (fifth review P3-6): a void whose card payment's application read +// fails answers the read's 500, never a confusing reversal refusal, and the +// refund it already took at the gateway is recorded beside its standing +// intent. +func TestAVoidWhoseCardApplicationReadFailsAnswers500(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))) + // the application row the void's reversal reads is gone: the read fails + if _, err := f.db.Pool.Exec(context.Background(), + `DELETE FROM ar_applications WHERE payment_id IN (SELECT payment_id FROM pos_tenders WHERE transaction_id = $1)`, saleID); err != nil { + t.Fatal(err) + } + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "application gone", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusInternalServerError { + t.Fatalf("the void under a failed application read = %d, want 500: %s", r.status, r.raw) + } + if len(gw.refunds) != 1 { + t.Fatalf("%d gateway refunds, want 1 (taken before the transaction)", len(gw.refunds)) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1 (the refund the refused void left standing)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 1 { + t.Errorf("%d standing intents, want 1", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void rolled back)", got) + } + // no invariant read here: the hand deleted application row leaves the + // documents leg of 9.3 unable to tie by construction; the subject is + // the error path, and the books it leaves are the refused void's +} + +// RULE (fifth review P3-7): a refused completion attempts the reversal of +// every charge it already took, not only the first: the charge whose +// reversal fails is answered, and the later ones are still put back. +func TestARefusedCompletionAttemptsEveryChargeReversal(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{ + charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + voidErrs: []error{fmt.Errorf("void refused")}, + refundErrs: []error{fmt.Errorf("refund refused")}, + } + 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", 2000)), withToken(tender("card", 395))) + if r.status != http.StatusBadGateway { + t.Fatalf("the refused completion = %d, want 502: %s", r.status, r.raw) + } + if code, _, _ := errorOf(t, r); code != "charge_not_reversed" { + t.Errorf("code = %q, want charge_not_reversed", code) + } + if len(gw.voids) != 2 { + t.Errorf("%d gateway voids, want 2 (every charge's reversal attempted, the first failed and the second went through)", 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 != 2 { + t.Errorf("%d charge reversal rows, want 2 (one per attempted charge)", got) + } + f.assertARInvariants(t) +} + +// RULE (fourth review P1-3): a completion whose second card charge is +// declined reverses the charge the first tender already took: no card is +// left charged behind a sale that never was. +func TestACompletionWhoseSecondChargeIsDeclinedReversesTheFirst(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), declinedCharge()}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + 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", 2000)), withToken(tender("card", 395))) + if r.status != http.StatusConflict { + t.Fatalf("second charge declined = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_declined" { + t.Errorf("blockers = %v, want card_declined", blockers) + } + if gw.chargeCalls != 2 { + t.Fatalf("%d gateway charges, want 2", gw.chargeCalls) + } + if len(gw.voids) != 1 { + t.Errorf("%d gateway voids, want 1 (the first charge is put back)", 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 never was)", 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) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want the 100 untouched", got) + } + f.assertARInvariants(t) +} + +// RULE (third review P3-G): when neither the void nor the refund of a +// refused sale's charge works, the completion answers 502 +// charge_not_reversed and the reversal row records the outcome failed. +func TestAChargeNeitherVoidNorRefundCanReverseAnswers502(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{ + charges: []*payment.GatewayResult{approvedCharge()}, + voidErrs: []error{fmt.Errorf("void refused")}, + refundErrs: []error{fmt.Errorf("refund refused")}, + } + 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.StatusBadGateway { + t.Fatalf("unreversed charge = %d, want 502: %s", r.status, r.raw) + } + if code, _, _ := errorOf(t, r); code != "charge_not_reversed" { + t.Errorf("code = %q, want charge_not_reversed", code) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.card_charge_reversal' + AND entity_id = $1 AND changes->>'outcome' = 'failed'`, saleID); got != 1 { + t.Errorf("%d failed charge reversal rows, want 1", got) + } + f.assertARInvariants(t) +} + +// RULE (third review P3-C): a return whose transaction refuses after the +// gateway refund leaves the money accounted for: the refund is recorded in +// its own committed audit row carrying the gateway ids, while no memo, no +// return row and no stock move survive. +func TestAReturnTheTransactionRefusesRecordsItsGatewayRefund(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_return.completed" + }) + f.openTill(0) + saleID, body := f.saleOf("4", withToken(tender("card", 2395))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status == http.StatusCreated { + t.Fatalf("the return committed under a failing event write: %s", r.raw) + } + // the refund was made against the sale's own charge, exactly once + if len(gw.refunds) != 1 || gw.refunds[0] != "gw-1" { + t.Fatalf("gateway refunds = %v, want one against the sale's own gw-1", gw.refunds) + } + n := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' + AND entity_id = $1 AND changes->>'act' = 'return'`, saleID) + if n != 1 { + t.Errorf("%d orphaned refund rows for the refused return, want 1", n) + } + if got := f.counterMemos(t); got != 0 { + t.Errorf("%d memos after the refused return, want 0", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the return rolled back)", got) + } + f.assertARInvariants(t) +} + +// RULE (third review P3-B): a card sale that has been returned against is +// never voided, and the refusal moves no gateway money: the returns guard +// runs again inside the void's claim transaction, under the sale row lock, +// before any terminal is reached, so a double refund (the return's and the +// void's) can never happen. +func TestAVoidAfterAReturnOfACardSaleMakesNoGatewayRefunds(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}} + 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))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("card return = %d: %s", r.status, r.raw) + } + // the card was refunded once by the return; the void must add nothing + if len(gw.refunds) != 1 { + t.Fatalf("%d gateway refunds after the return, want 1", len(gw.refunds)) + } + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "after a return", "revision": num(t, f.getSale(t, saleID), "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) + } + if len(gw.refunds) != 1 { + t.Errorf("%d gateway refunds after the refused void, want still 1 (the refusal moved no money)", len(gw.refunds)) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0", got) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed", got) + } + f.assertARInvariants(t) + + // The claim's guard is the one that holds under a race: a return + // committing after the void's pre-transaction check still refuses the + // void under the sale row lock, before the terminal. The sale row is + // held so the void parks on its claim's lock, a return lands, then the + // row releases; the void's claim then reads the sale returned against + // and refuses, having claimed and refunded nothing. + saleID2, body2 := f.saleOf("2", withToken(tender("card", 1198))) + racing := uuid.New() + ctx := context.Background() + park, err := f.db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = park.Rollback(context.Background()) }) + if _, err := park.Exec(ctx, `SELECT 1 FROM pos_transactions WHERE id = $1 FOR UPDATE`, saleID2); err != nil { + t.Fatal(err) + } + voidDone := make(chan int, 1) + go func() { + voidDone <- f.do("POST", "/api/v1/pos/transactions/"+saleID2+"/void", + map[string]any{"reason": "racing a return", "revision": rev(t, body2)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status + }() + time.Sleep(400 * time.Millisecond) + // the return that wins the race, written inside the holding transaction: + // the commit releases the sale row and publishes the return in one step + // (an insert from outside would wait on the row lock the hold takes) + if _, err := park.Exec(ctx, `INSERT INTO pos_returns (id, register_id, cashier_id, original_transaction_id, + subtotal, tax_amount, total, refund_method, reason, currency) VALUES ($1, $2, $3, $4, -5.50, -0.49, -5.99, + 'CASH', 'racing return', 'USD')`, racing, f.register, mustUUID(t), saleID2); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + mustExec(t, f.db, `DELETE FROM pos_return_lines WHERE return_id = $1`, racing) + mustExec(t, f.db, `DELETE FROM pos_returns WHERE id = $1`, racing) + }) + if err := park.Commit(ctx); err != nil { + t.Fatal(err) + } + if st := <-voidDone; st != http.StatusConflict { + t.Fatalf("void racing a committed return = %d, want 409 (the claim's guard under the sale lock)", st) + } + if len(gw.refunds) != 1 { + t.Errorf("%d gateway refunds, want 1 (the first sale's return only; the racing void refunded nothing)", len(gw.refunds)) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1`, saleID2); got != 0 { + t.Errorf("%d refund intents for the racing void, want 0 (it claimed nothing)", got) + } + if got := str(t, f.getSale(t, saleID2), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void was refused)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/handler.go b/core/internal/pos/handler.go index fd215d5d5..66a2afb4b 100644 --- a/core/internal/pos/handler.go +++ b/core/internal/pos/handler.go @@ -4,30 +4,86 @@ package pos import ( + "context" "encoding/json" "errors" + "fmt" "log/slog" "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" - "github.com/jackc/pgx/v5" ) -// 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 + guard BranchGuard // optional; see WithBranchGuard } -// 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} } + +// BranchGuard holds the record the resolution acts on to the caller's +// branch wall (ADR 0007 section 2.3). *middleware.BranchGuard satisfies it. +type BranchGuard interface { + CheckPayloadBranch(ctx context.Context, branch uuid.UUID) error +} + +// WithBranchGuard makes the resolution route hold the claim's sale to the +// caller's wall. The sale read's repository filter stops a caller whose +// context names a branch; the record check is what stops a bound caller +// with no context branch (default_branch_required=false, no header), whose +// grants the filter never sees. Without it that caller settles any branch's +// claim, so serve always sets it, and a handler mounted without it refuses +// (fail closed, the fifteenth review's P3-2) rather than settle a claim +// nothing holds. +func (h *Handler) WithBranchGuard(g BranchGuard) *Handler { + h.guard = g + return h } -// RegisterRoutes registers POS API routes. -// NOTE: POS routes use /api/pos/* (legacy). Migrate to /api/v1/pos/* in API versioning sprint. +// saleOfIntentHeld holds the claim's sale to the caller's wall (r12 P1-2): +// a refusal is the same missing row the branch scoped read answers (404, +// nothing moved), so a caller outside the sale's branch learns nothing of +// the claim. It reports whether the request may proceed, and a handler +// wired with no guard at all refuses: the claim routes fail closed (the +// fifteenth review's P3-2). +func (h *Handler) saleOfIntentHeld(w http.ResponseWriter, r *http.Request, intentID uuid.UUID) bool { + if h.guard == nil { + httpx.WriteError(w, r, httpx.Unavailable( + "the branch wall is not wired on this handler: a refund claim is settled only inside the caller's branch, so the route refuses rather than settle a claim nothing holds")) + return false + } + branch, err := h.service.IntentSaleBranch(r.Context(), intentID) + if err != nil { + httpx.WriteError(w, r, err) + return false + } + if branch == uuid.Nil { + return true + } + if err := h.guard.CheckPayloadBranch(r.Context(), branch); err != nil { + if errors.Is(err, middleware.ErrPayloadBranchRefused) { + httpx.WriteError(w, r, httpx.NotFound("refund claim not found")) + return false + } + httpx.WriteError(w, r, fmt.Errorf("branch grant lookup: %w", err)) + return false + } + return true +} + +// RegisterRoutes registers the counter's routes. roleGuard composes with +// the branch middleware in serve.go; resolveGuard, when passed as the second +// guard, is the resolution route's own (the finance and owner roles behind +// the branch middleware), and when absent the route's own finance and owner +// role check stands alone, as it does in tests that mount the branch +// middleware around the whole mux. 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 { @@ -37,9 +93,18 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand } return handler } + resolveMount := func(handler http.HandlerFunc) http.HandlerFunc { + if len(roleGuard) > 1 && roleGuard[1] != nil { + return func(w http.ResponseWriter, r *http.Request) { + roleGuard[1](handler).ServeHTTP(w, r) + } + } + return resolveGuard(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)) @@ -47,9 +112,14 @@ 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)) + // The counter's customer: the lookup with the account's standing, and + // naming the customer (and the account's purchase order and job) on an + // open sale. + mux.HandleFunc("GET /api/v1/pos/customers", guard(h.FindCustomers)) + mux.HandleFunc("PUT /api/v1/pos/transactions/{id}/customer", guard(h.AttachCustomer)) + // Offline sync mux.HandleFunc("POST /api/v1/pos/sync", guard(h.SyncOffline)) mux.HandleFunc("GET /api/v1/pos/catalog", guard(h.GetCatalog)) @@ -62,16 +132,35 @@ 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)) + + // The operator resolution of a stranded refund claim (FROM-PRINCIPAL 41): + // finance and owner only, and never a machine key, bound or not, refused + // in the route itself. It carries no cashier identity and no client + // revision: the operator's record is its own authority. It runs behind + // the branch middleware like every money route of the counter (ninth + // review P1-3): the claim's sale is read through the caller's branch + // context, so another branch's claim is a missing row. + mux.HandleFunc("POST /api/v1/pos/refund-intents/{id}/resolve", resolveMount(h.ResolveRefundIntent)) + + // The payback of a declined void tender's credit (FROM-PRINCIPAL 60b): + // the same mount, the same roles and the same wall as the resolve route, + // a small sibling of it keyed on the void's own released claim. + mux.HandleFunc("POST /api/v1/pos/refund-intents/{id}/payback", resolveMount(h.PaybackRefundIntent)) +} + +// resolveGuard wraps the resolution route: the finance and owner roles only +// (a machine key has no role and is refused before it). +func resolveGuard(handler http.HandlerFunc) http.HandlerFunc { + return middleware.RequireRole("finance", "owner")(handler).ServeHTTP } -// posRefusalBody is the wire ADR's error envelope for the refusals this -// package writes. The shared writer lands with the platform packages; -// pkg/actor and pkg/middleware write the same shape for theirs. -type posRefusalBody struct { +// 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"` Message string `json:"message"` @@ -81,559 +170,624 @@ type posRefusalBody struct { } `json:"meta"` } -// writePosRefusal answers the request with 403 and the refusal's words. -// httputil.RespondError logs the words but answers the client with the -// generic message of the status, and a caller has to read which register and -// branch the refusal names. -func writePosRefusal(w http.ResponseWriter, r *http.Request, message string, logAttrs ...any) { +// refuseMachineKey answers the request with 403 when the caller +// authenticated with a machine key, reporting whether it answered: the key, +// bound or not, whatever scopes it holds, is refused on routes whose act +// belongs to a person. +func refuseMachineKey(w http.ResponseWriter, r *http.Request, message string) 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 posRefusalBody + var body cashierRefusalBody body.Error.Code = "forbidden" body.Error.Message = message body.Meta.RequestID = reqID - - attrs := append([]any{"status", http.StatusForbidden, "method", r.Method, "path", r.URL.Path, "request_id", reqID}, logAttrs...) - slog.Warn("POS refusal", attrs...) + slog.Warn("machine key refused on a POS person-only route", + "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 } // 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 - } - - writePosRefusal(w, r, "a cashier must be a user", "key_id", keyID, "reason", "machine key on a POS cashier route") - return true + return refuseMachineKey(w, r, "a cashier must be a user") } -// refuseRecordBranch answers the request with the record branch refusal's -// 403 and words when err is that refusal, reporting whether it answered: an -// act on a record of a branch outside the grants a caller with no branch in -// context is held to (SEC-NOHEADER-1) is refused by name, nothing written. -func refuseRecordBranch(w http.ResponseWriter, r *http.Request, err error) bool { - var refused *RecordBranchRefusedError - if !errors.As(err, &refused) { - return false +// 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 uuid.Nil, false } - writePosRefusal(w, r, refused.Error(), "record_id", refused.ID, "what", refused.What, - "reason", "record of a branch this caller may not target") - return true + if claims := middleware.ClaimsFromContext(r.Context()); claims != nil && claims.Subject != "" { + if id, err := uuid.Parse(claims.Subject); err == nil { + return id, true + } + } + return uuid.Nil, true } -// answerMissingRecord answers the request with the module's 404 when err is -// the missing record verdict, reporting whether it answered: a record that -// does not exist is missing for every caller alike, never a 403 claiming it -// belongs to another branch (r4 P3-1) nor a bare 500 (r4 P3-2). -func answerMissingRecord(w http.ResponseWriter, r *http.Request, err error) bool { - var missing *MissingRecordError - if !errors.As(err, &missing) { - return false - } - httputil.RespondError(w, r, missing.Error(), http.StatusNotFound, err) - return true +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) } -// CreateReturn records a merchandise return and issues the refund. -func (h *Handler) CreateReturn(w http.ResponseWriter, r *http.Request) { - if refuseMachineKeyCashier(w, r) { +// StartTransaction is POST /pos/transactions. +func (h *Handler) StartTransaction(w http.ResponseWriter, r *http.Request) { + cashierID, ok := cashierOf(w, r) + if !ok { return } - var req ReturnRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + var req startSaleRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - if req.RegisterID == "" { - req.RegisterID = "REG-01" + in, err := req.parse() + if err != nil { + httpx.WriteError(w, r, err) + return } - 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 in.CashierID == uuid.Nil { + in.CashierID = cashierID } - ret, err := h.service.ReturnSale(r.Context(), cashierID, req) + sale, err := h.service.StartSale(r.Context(), in, in.CashierID.String()) if err != nil { - // A register of another branch is a refusal, not a bad request. - var refused *RegisterBranchRefusedError - if errors.As(err, &refused) { - writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") - return - } - httputil.RespondError(w, r, err.Error(), http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(ret) + w.Header().Set("Location", "/api/v1/pos/transactions/"+sale.ID.String()) + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusCreated, sale) } -// GetReturn returns a single return with its lines. -func (h *Handler) GetReturn(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 return 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 } - ret, err := h.service.GetReturn(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(ret) + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -// 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 - } +// 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 { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) + return } - list, err := h.service.ListReturns(r.Context(), registerID, date) + var req addLineRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + parsed, err := req.parse() 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}) + // 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 + } + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -// GetZReport returns the immutable Z snapshot for a closed session. -func (h *Handler) GetZReport(w http.ResponseWriter, r *http.Request) { +// 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 { - 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) + itemID, err := uuid.Parse(r.PathValue("itemId")) if err != nil { - httputil.RespondError(w, r, err.Error(), http.StatusNotFound, 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(z) + 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) } -// 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 - } +// 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 { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) + return } - list, err := h.service.ListZReports(r.Context(), registerID, date) + 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 list Z-reports", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"z_reports": list}) + 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) } -// --- Till handlers --- - -func (h *Handler) OpenTill(w http.ResponseWriter, r *http.Request) { - if refuseMachineKeyCashier(w, r) { +// 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 { + httpx.WriteError(w, r, httpx.BadRequest("one or more fields failed validation", httpx.FieldError{Field: "id", Message: "must be a UUID"})) 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 voidSaleRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - if req.RegisterID == "" { - req.RegisterID = "REG-01" + reason, revision, err := req.parse() + if err != nil { + httpx.WriteError(w, r, err) + return } - 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 - } + sale, err := h.service.VoidSale(r.Context(), id, r.Header.Get("If-Match"), revision, reason, actorFrom(r)) + 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)) + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) +} + +// 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 { - // A register of another branch is a refusal, not a conflict. - var refused *RegisterBranchRefusedError - if errors.As(err, &refused) { - writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") + httpx.WriteError(w, r, err) + return + } + f := SaleFilter{RegisterID: q.Get("register_id"), Date: time.Now().Format("2006-01-02")} + if d := q.Get("date"); d != "" { + 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 } - httputil.RespondError(w, r, err.Error(), http.StatusConflict, err) + f.Date = d + } + 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 } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(session) + httpx.WriteList(w, items, "", limit) } -func (h *Handler) CurrentTill(w http.ResponseWriter, r *http.Request) { - registerID := r.URL.Query().Get("register_id") - if registerID == "" { - 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 } - session, err := h.service.CurrentTill(r.Context(), registerID) + query := r.URL.Query().Get("q") + if query == "" { + writeJSON(w, http.StatusOK, []QuickSearchResult{}) + return + } + limit := 20 + results, err := h.service.repo.SearchProducts(r.Context(), query, limit) if err != nil { - httputil.RespondError(w, r, "failed to look up till session", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"session": session}) + if results == nil { + results = []QuickSearchResult{} + } + writeJSON(w, http.StatusOK, results) } -func (h *Handler) TillReportHandler(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) +// FindCustomers is GET /pos/customers: the counter's lookup by name or +// account number, each account's standing beside it. A bare array, never +// null; q under two characters answers []. +func (h *Handler) FindCustomers(w http.ResponseWriter, r *http.Request) { + if _, err := httpx.StrictQuery(r, "q"); err != nil { + httpx.WriteError(w, r, err) return } - report, err := h.service.TillReport(r.Context(), id) + found, err := h.service.FindCustomers(r.Context(), r.URL.Query().Get("q")) if err != nil { - // A session the caller's branch context hides reads as missing, the - // same 404 the Z report and return reads answer (r17 P3-1), not a - // 500. - if errors.Is(err, pgx.ErrNoRows) { - httputil.RespondError(w, r, err.Error(), http.StatusNotFound, err) - return - } - 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) + writeJSON(w, http.StatusOK, found) } -func (h *Handler) CloseTill(w http.ResponseWriter, r *http.Request) { +// AttachCustomer is PUT /pos/transactions/{id}/customer: the sale's customer, +// purchase order and job. A null customer_id makes the sale a walk-in again. +func (h *Handler) AttachCustomer(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 attachCustomerRequest + 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) + in, 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.AttachCustomer(r.Context(), id, *in, actorFrom(r)) if err != nil { - if answerMissingRecord(w, r, err) { - return - } - if refuseRecordBranch(w, r, err) { - return - } - 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) + httpx.WriteRevisionETag(w, sale.Revision) + writeJSON(w, http.StatusOK, sale) } -// --- Request types --- - -type startTransactionRequest struct { - RegisterID string `json:"register_id"` - CashierID uuid.UUID `json:"cashier_id"` - CustomerID *uuid.UUID `json:"customer_id,omitempty"` +// 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 { + httpx.WriteError(w, r, err) + return + } + resp, err := h.service.SyncOfflineTransactions(r.Context(), batch, actorFrom(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, resp) } -type completeTransactionRequest struct { - Tenders []AddTenderRequest `json:"tenders"` +// 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 { + httpx.WriteError(w, r, err) + return + } + if catalog == nil { + catalog = []CatalogProduct{} + } + writeJSON(w, http.StatusOK, catalog) } -// --- Handlers --- - -func (h *Handler) StartTransaction(w http.ResponseWriter, r *http.Request) { - if refuseMachineKeyCashier(w, r) { +// OpenTill is POST /pos/till/open. +func (h *Handler) OpenTill(w http.ResponseWriter, r *http.Request) { + cashierID, ok := cashierOf(w, r) + if !ok { return } - var req startTransactionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + var req openTillRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - - if req.RegisterID == "" { - req.RegisterID = "REG-01" + register, cents, err := req.parse() + if err != nil { + httpx.WriteError(w, r, err) + return } - if req.CashierID == uuid.Nil { - req.CashierID = uuid.New() // Demo fallback + 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) +} - tx, err := h.service.StartTransaction(r.Context(), req.RegisterID, req.CashierID, req.CustomerID) +// 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 { - // A register of another branch is a refusal, not an internal error. - var refused *RegisterBranchRefusedError - if errors.As(err, &refused) { - writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") - return - } - 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) + register := q.Get("register_id") + if register == "" { + register = "REG-01" + } + session, err := h.service.CurrentTill(r.Context(), register) + if err != nil { + 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) } -func (h *Handler) GetTransaction(w http.ResponseWriter, r *http.Request) { +// 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, "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.GetTransaction(r.Context(), id) + report, err := h.service.TillReport(r.Context(), id) 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, report) } -func (h *Handler) AddItem(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 AddLineItemRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + var req closeTillRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - - tx, err := h.service.AddItem(r.Context(), txID, req) + counted, notes, err := req.parse() if err != nil { - if answerMissingRecord(w, r, err) { - return - } - if refuseRecordBranch(w, r, err) { - return - } - 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) + // 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 + } + writeJSON(w, http.StatusOK, report) } -func (h *Handler) RemoveItem(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 } - - itemID, err := uuid.Parse(r.PathValue("itemId")) + z, err := h.service.GetZReport(r.Context(), id) if err != nil { - httputil.RespondError(w, r, "Invalid item ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } + writeJSON(w, http.StatusOK, z) +} - tx, err := h.service.RemoveItem(r.Context(), txID, itemID) +// 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 { - if answerMissingRecord(w, r, err) { - return - } - if refuseRecordBranch(w, r, err) { + httpx.WriteError(w, r, err) + return + } + date := time.Now() + 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 } - httputil.RespondError(w, r, "failed to remove item", http.StatusInternalServerError, err) + date = parsed + } + list, err := h.service.ListZReports(r.Context(), q.Get("register_id"), date) + if err != nil { + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tx) + writeJSON(w, http.StatusOK, list) } -func (h *Handler) CompleteTransaction(w http.ResponseWriter, r *http.Request) { - txID, err := uuid.Parse(r.PathValue("id")) - if err != nil { - httputil.RespondError(w, r, "Invalid transaction ID", http.StatusBadRequest, err) +// CreateReturn is POST /pos/returns. +func (h *Handler) CreateReturn(w http.ResponseWriter, r *http.Request) { + cashierID, ok := cashierOf(w, r) + if !ok { return } - - var req completeTransactionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) + var req returnRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - - if len(req.Tenders) == 0 { - httputil.RespondError(w, r, "At least one tender is required", http.StatusBadRequest, nil) + in, err := req.parse() + if err != nil { + httpx.WriteError(w, r, err) return } - - tx, err := h.service.CompleteTransaction(r.Context(), txID, req.Tenders) + ret, err := h.service.ReturnSale(r.Context(), cashierID, in, cashierID.String()) if err != nil { - if answerMissingRecord(w, r, err) { - return - } - if refuseRecordBranch(w, r, err) { - return - } - 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) + w.Header().Set("Location", "/api/v1/pos/returns/"+ret.ID.String()) + writeJSON(w, http.StatusCreated, ret) } -func (h *Handler) VoidTransaction(w http.ResponseWriter, r *http.Request) { - txID, err := uuid.Parse(r.PathValue("id")) +// 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 { - 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 } + ret, err := h.service.GetReturn(r.Context(), id) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusOK, ret) +} - tx, err := h.service.VoidTransaction(r.Context(), txID) +// 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 { - if answerMissingRecord(w, r, err) { - return - } - if refuseRecordBranch(w, r, err) { + httpx.WriteError(w, r, err) + return + } + f := ReturnFilter{RegisterID: q.Get("register_id"), Date: time.Now().Format("2006-01-02")} + if d := q.Get("date"); d != "" { + 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 } - httputil.RespondError(w, r, "failed to void transaction", http.StatusInternalServerError, err) + f.Date = d + } + list, err := h.service.ListReturns(r.Context(), f) + if err != nil { + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tx) + writeJSON(w, http.StatusOK, list) } -func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { - registerID := r.URL.Query().Get("register_id") - dateStr := r.URL.Query().Get("date") - - date := time.Now() - if dateStr != "" { - parsed, err := time.Parse("2006-01-02", dateStr) - if err == nil { - date = parsed - } +// ResolveRefundIntent is POST /pos/refund-intents/{id}/resolve: the guarded +// admin route that records a stranded claim's actual outcome at the terminal +// and settles it. Finance and owner roles only; a machine key is refused +// here, bound or not, whatever scopes it holds. The claim's sale is held to +// the caller's branch wall before anything of it is read (r12 P1-2), so a +// caller outside the branch, header or not, finds a missing row. +func (h *Handler) ResolveRefundIntent(w http.ResponseWriter, r *http.Request) { + if refuseMachineKey(w, r, "a refund claim is resolved by a person, never a machine key") { + return } - - summaries, err := h.service.ListTransactions(r.Context(), registerID, date) + id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "failed to list transactions", 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 } - - if summaries == nil { - summaries = []TransactionSummary{} + if !h.saleOfIntentHeld(w, r, id) { + return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(summaries) -} - -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{}) + var req resolveIntentRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) return } - - results, err := h.service.SearchProducts(r.Context(), query) + in, err := req.parse() if err != nil { - httputil.RespondError(w, r, "product search failed", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - - if results == nil { - results = []QuickSearchResult{} + resolved, err := h.service.ResolveRefundIntent(r.Context(), id, *in, actorFrom(r)) + if err != nil { + httpx.WriteError(w, r, err) + return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(results) + writeJSON(w, http.StatusOK, resolved) } -// 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) +// PaybackRefundIntent is POST /pos/refund-intents/{id}/payback: the guarded +// admin route that pays a declined void tender's credit back to its card +// through the counter terminal rail, the credit's one way back. Finance and +// owner roles only; a machine key is refused here, bound or not. The +// claim's sale is held to the caller's branch wall exactly as the resolve +// route holds it, so a caller outside the branch, header or not, finds a +// missing row and the terminal is never asked. +func (h *Handler) PaybackRefundIntent(w http.ResponseWriter, r *http.Request) { + if refuseMachineKey(w, r, "a held credit is paid back by a person, never a machine key") { return } - - if req.BatchID == "" { - httputil.RespondError(w, r, "batch_id is required", http.StatusBadRequest, nil) + 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) + if !h.saleOfIntentHeld(w, r, id) { return } - - resp, err := h.service.SyncOfflineTransactions(r.Context(), req) + var req paybackIntentRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.parse() if err != nil { - // A batch register of another branch is a refusal, not an internal - // error. - var refused *RegisterBranchRefusedError - if errors.As(err, &refused) { - writePosRefusal(w, r, refused.Error(), "register", refused.RegisterID, "reason", "register of another branch") - return - } - // A batch register that does not exist is a bad request, refused - // before any item runs and before the batch is logged. - var unknown *UnknownRegisterError - if errors.As(err, &unknown) { - httputil.RespondError(w, r, unknown.Error(), http.StatusBadRequest, err) - return - } - 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()) + paid, err := h.service.PaybackRefundIntent(r.Context(), id, *in, actorFrom(r)) if err != nil { - httputil.RespondError(w, r, "failed to get catalog", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } + writeJSON(w, http.StatusOK, paid) +} - 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 index 68e44c39e..815ab79ea 100644 --- a/core/internal/pos/handler_machinekey_test.go +++ b/core/internal/pos/handler_machinekey_test.go @@ -3,60 +3,36 @@ 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" - "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)) - +// 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(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))) - }) + 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. @@ -70,63 +46,83 @@ type keyRefusalBody struct { } `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) + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + keyID := uuid.NewString() + keyed := keyedServer(f, keyID) - routes := []string{ - "/api/v1/pos/transactions", - "/api/v1/pos/till/open", - "/api/v1/pos/returns", + // 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) + } } - 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 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) } - - // 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()) - } - }) + 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) } diff --git a/core/internal/pos/input.go b/core/internal/pos/input.go new file mode 100644 index 000000000..eebc5f3d6 --- /dev/null +++ b/core/internal/pos/input.go @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "encoding/json" + "fmt" + "strings" + + "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 +} + +// attachCustomerRequest is PUT /pos/transactions/{id}/customer. +type attachCustomerRequest struct { + CustomerID *string `json:"customer_id"` + CustomerPO *string `json:"customer_po"` + JobID *string `json:"job_id"` +} + +func (r *attachCustomerRequest) parse() (*AttachCustomerIn, error) { + v := &httpx.Validator{} + out := &AttachCustomerIn{} + if id, ok := v.UUID("customer_id", r.CustomerID, false); ok { + out.CustomerID = &id + } + if id, ok := v.UUID("job_id", r.JobID, false); ok { + out.JobID = &id + } + if r.CustomerPO != nil { + out.CustomerPO = strings.TrimSpace(*r.CustomerPO) + } + if err := v.Err(); err != nil { + return nil, err + } + return out, 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, 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 + } + 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 { + method, ok := ParseTenderMethod(m) + if !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[string(method)] = 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 +} + +// resolveIntentRequest is POST /pos/refund-intents/{id}/resolve: the +// operator's record of the terminal's actual outcome for a stranded claim. +// outcome is "refund" (the gateway's own refund id, the money moved) or +// "no_refund" (an explicit no at the terminal, with the reconciliation +// reference finance filed). A bare resolve is refused naming the field. +type resolveIntentRequest struct { + Outcome *string `json:"outcome"` + RefundTxID *string `json:"refund_tx_id"` + ReconciliationReference *string `json:"reconciliation_reference"` + Reason *string `json:"reason"` +} + +func (r *resolveIntentRequest) parse() (*ResolveIntentIn, error) { + v := &httpx.Validator{} + out := ResolveIntentIn{} + switch { + case r.Outcome != nil: + out.Outcome = *r.Outcome + } + switch out.Outcome { + case "refund": + if r.RefundTxID != nil { + out.RefundTxID = *r.RefundTxID + } + v.Required("refund_tx_id", out.RefundTxID) + case "no_refund": + if r.ReconciliationReference != nil { + out.ReconciliationReference = *r.ReconciliationReference + } + v.Required("reconciliation_reference", out.ReconciliationReference) + case "": + v.Check(false, "outcome", "records the terminal's outcome: refund with its refund id, or no_refund with a reconciliation reference") + default: + v.Check(false, "outcome", "must be refund or no_refund") + } + if r.Reason != nil { + out.Reason = *r.Reason + } + v.Required("reason", out.Reason) + if err := v.Err(); err != nil { + return nil, err + } + return &out, nil +} + +// paybackIntentRequest is POST /pos/refund-intents/{id}/payback: the +// operator's record of why the credit went back to its card. The outcome is +// the route's own (the terminal is asked for the credit), so only the +// reason is sent; a bare payback is refused naming the field. +type paybackIntentRequest struct { + Reason *string `json:"reason"` +} + +func (r *paybackIntentRequest) parse() (*PaybackIntentIn, error) { + v := &httpx.Validator{} + out := PaybackIntentIn{} + if r.Reason != nil { + out.Reason = *r.Reason + } + v.Required("reason", out.Reason) + if err := v.Err(); err != nil { + return nil, err + } + return &out, nil +} diff --git a/core/internal/pos/kit_sale_test.go b/core/internal/pos/kit_sale_test.go new file mode 100644 index 000000000..ae2351ce7 --- /dev/null +++ b/core/internal/pos/kit_sale_test.go @@ -0,0 +1,163 @@ +// 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 ( + "context" + "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) +} + +// RULE (fourth review P1-1): a returned kit line restocks its components at +// the cost the sale relieved for them; the kit product itself has no +// inventory row and never restocks. A component line of a kit sale carries +// no price of its own, so its restock is refused: the kit line carries the +// return. +func TestAKitReturnRestocksItsComponentsAtTheirCost(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + 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) + } + if r := f.completeSale(saleID, tender("cash", 2395)); r.status != http.StatusOK { + t.Fatalf("kit sale complete = %d: %s", r.status, r.raw) + } + // the average moves after the sale: the components still come 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) + }) + + // the component line's restock is refused: the kit line carries it + sale := f.getSale(t, saleID) + rawLines, ok := sale["lines"].([]any) + if !ok || len(rawLines) < 2 { + t.Fatalf("the kit sale reads %d lines, want the kit line and its component", len(rawLines)) + } + kitLine, ok := rawLines[0].(map[string]any) + if !ok { + t.Fatalf("the kit sale's first line is %T, want an object", rawLines[0]) + } + compLine, ok := rawLines[1].(map[string]any) + if !ok { + t.Fatalf("the kit sale's second line is %T, want an object", rawLines[1]) + } + if str(t, compLine, "line_type") != "component" { + t.Fatalf("second line = %s, want the component", str(t, compLine, "line_type")) + } + + // one kit of two components back: the refund is the kit's own price + r := f.returnOn(t, saleID, str(t, kitLine, "id"), "1") + if r.status != http.StatusCreated { + t.Fatalf("kit return = %d, want 201: %s", r.status, r.raw) + } + rComp := f.returnOn(t, saleID, str(t, compLine, "id"), "1") + if rComp.status != http.StatusBadRequest { + t.Fatalf("component restock = %d, want 400: %s", rComp.status, rComp.raw) + } + _, _, fields := errorOf(t, rComp) + if len(fields) == 0 || fields[0] != "lines[0].restock" { + t.Errorf("fields = %v, want lines[0].restock", fields) + } + for key, want := range map[string]int64{"subtotal_cents": -1100, "tax_cents": -98, "total_cents": -1198} { + if got := num(t, r.body, key); got != want { + t.Errorf("kit return %s = %d, want %d", key, got, want) + } + } + // the components are back on hand (2 of the 4 that left), at their own + // cost: DR 1030 / CR 5010 650 (2 x 3.25), never today's 9.00 + if got := f.stock(); got != "98.0000/0.0000" { + t.Errorf("stock = %s, want 98/0 (the kit's components back)", got) + } + memoID := str(t, r.body, "credit_memo_id") + _, legs := f.legsFor(memoID) + if legs["1030"].debit != 650 || legs["5010"].credit != 650 { + t.Errorf("restock legs = 1030 %+v, 5010 %+v, want 650 each (the components at the cost the sale relieved)", legs["1030"], legs["5010"]) + } + // the kit line's memo line names the kit's own invoice line + kitInvLine, ok := f.scalar(`SELECT invoice_line_id::text FROM pos_line_items WHERE id = $1`, str(t, kitLine, "id")).(string) + if !ok { + t.Fatalf("the kit sale line carries no invoice line link (it is %v), so the cost fell back to the average", f.scalar(`SELECT invoice_line_id::text FROM pos_line_items WHERE id = $1`, str(t, kitLine, "id"))) + } + 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 != kitInvLine { + t.Errorf("the memo line names invoice line %v, want the kit's own %s", memoInvLine, kitInvLine) + } + if got := f.accountBalance("1010"); got != 2395-1198 { + t.Errorf("cash balance = %d, want %d (the sale in, the kit return out)", got, 2395-1198) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/migration105_down_test.go b/core/internal/pos/migration105_down_test.go new file mode 100644 index 000000000..9eb02962d --- /dev/null +++ b/core/internal/pos/migration105_down_test.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// Migration 105's down and backfill on contract era rows (eighth review +// P3-3): the down deletes the kit, component, charge and text lines the old +// shape cannot hold, so it refuses (naming the count) while any belongs to a +// completed sale, and the up's invoice line backfill matches only product +// sale lines to product invoice lines. + +import ( + "context" + "os" + "strings" + "testing" +) + +func mustRead(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +const ( + mig105DoneSale = "00000000-0000-0000-0000-0000000000d9" + mig105KitSale = "00000000-0000-0000-0000-0000000000d8" +) + +// RULE (eighth review P3-3): the down refuses while a contract era kit, +// component, charge or text line belongs to a completed sale, naming the +// count and changing nothing; once no completed sale holds one, it runs. +func TestMigration105_DownRefusesWhileACompletedSaleHoldsAContractEraLine(t *testing.T) { + c, _ := mig105ScratchDB(t) + before, target, downFile := mig105Files(t) + for _, f := range before { + mig105Apply(t, c, f) + } + mig105Legacy(t, c) + mig105Apply(t, c, target) + ctx := context.Background() + if _, err := c.Exec(ctx, ` + INSERT INTO pos_transactions (id, register_id, cashier_id, branch_id, currency, status, completed_at) + VALUES ('`+mig105DoneSale+`', 'REG-01', '00000000-0000-0000-0000-0000000000e1', `+mig105Branch+`, 'USD', 'COMPLETED', NOW()); + INSERT INTO pos_line_items (transaction_id, line_type, description, price_source) + VALUES ('`+mig105DoneSale+`', 'TEXT', 'delivery note', 'NONE');`); err != nil { + t.Fatalf("seed the completed sale with a text line: %v", err) + } + sql := mustRead(t, downFile) + _, err := c.Exec(ctx, sql) + if err == nil { + t.Fatal("the down ran while a completed sale held a text line") + } + if !strings.Contains(err.Error(), "1 ") || !strings.Contains(err.Error(), "completed") { + t.Errorf("the down's refusal = %v, want it to name the count of lines and the completed sales", err) + } + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'pos_line_items' AND column_name = 'line_type'`); n != 1 { + t.Errorf("the refused down changed the schema: line_type columns = %d, want 1", n) + } + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM pos_line_items WHERE transaction_id = '`+mig105DoneSale+`'`); n != 1 { + t.Errorf("the refused down deleted the completed sale's line: %d rows, want 1", n) + } + // the same line on a sale that is not completed does not hold the down + if _, err := c.Exec(ctx, `UPDATE pos_transactions SET status = 'OPEN', completed_at = NULL WHERE id = '`+mig105DoneSale+`'`); err != nil { + t.Fatal(err) + } + if _, err := c.Exec(ctx, sql); err != nil { + t.Fatalf("the down refused with no completed sale holding a contract era line: %v", err) + } +} + +// RULE (eighth review P3-3): the backfill pairs product sale lines with +// product invoice lines only. A completed sale whose first line is a kit of +// the product and whose second is the product itself, over an invoice that +// holds the product line alone, links the product line to it and leaves the +// kit line unlinked (the kit's own invoice line is not a product line). +func TestMigration105_BackfillLinksOnlyProductLines(t *testing.T) { + c, _ := mig105ScratchDB(t) + before, target, _ := mig105Files(t) + for _, f := range before { + mig105Apply(t, c, f) + } + mig105Legacy(t, c) + mig105Apply(t, c, target) + ctx := context.Background() + if _, err := c.Exec(ctx, ` + INSERT INTO invoices (id, customer_id, branch_id, number, status, invoice_date, currency, + subtotal, tax_amount, total_amount, amount_open, created_at, updated_at) + VALUES ('00000000-0000-0000-0000-0000000000d7', '00000000-0000-0000-0000-0000000000a1', + `+mig105Branch+`, 'INV-MIG-2', 'UNPAID', '2026-01-01', 'USD', 5.50, 0.49, 5.99, 5.99, + '2026-01-01T11:02:00Z', '2026-01-01T11:02:00Z'); + INSERT INTO invoice_lines (id, invoice_id, product_id, description, quantity, uom, price_uom, + uom_qty, price_uom_qty, unit_price, line_total, unit_cost, position, created_at) + VALUES ('00000000-0000-0000-0000-0000000000d6', '00000000-0000-0000-0000-0000000000d7', + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 1, 'PCS', 'PCS', 1, 1, 5.50, 5.50, 3.25, 0, '2026-01-01T11:02:00Z'); + INSERT INTO pos_transactions (id, register_id, cashier_id, branch_id, currency, status, completed_at, invoice_id) + VALUES ('`+mig105KitSale+`', 'REG-01', '00000000-0000-0000-0000-0000000000e1', `+mig105Branch+`, 'USD', 'COMPLETED', NOW(), + '00000000-0000-0000-0000-0000000000d7'); + INSERT INTO pos_line_items (id, transaction_id, line_type, product_id, description, quantity, uom, price_uom, + uom_qty, price_uom_qty, unit_price, line_total, position, price_source) + VALUES + ('00000000-0000-0000-0000-0000000000d5', '`+mig105KitSale+`', 'KIT', '00000000-0000-0000-0000-0000000000f1', + 'a kit', 1, 'PCS', 'PCS', 1, 1, 5.50, 5.50, 0, 'PRICE_LIST'), + ('00000000-0000-0000-0000-0000000000d4', '`+mig105KitSale+`', 'PRODUCT', '00000000-0000-0000-0000-0000000000f1', + '2x4x8', 1, 'PCS', 'PCS', 1, 1, 5.50, 5.50, 1, 'PRICE_LIST');`); err != nil { + t.Fatalf("seed the kit sale: %v", err) + } + // the backfill runs on every apply + mig105Apply(t, c, target) + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM pos_line_items WHERE id = '00000000-0000-0000-0000-0000000000d5' AND invoice_line_id IS NULL`); n != 1 { + t.Errorf("the kit line was linked to an invoice line: the backfill matched a non product line") + } + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM pos_line_items + WHERE id = '00000000-0000-0000-0000-0000000000d4' AND invoice_line_id = '00000000-0000-0000-0000-0000000000d6'`); n != 1 { + t.Errorf("the product line was not linked to the invoice's product line") + } +} + +// RULE (ninth review P3-2, the invoice side of the backfill): the pairing +// counts product invoice lines only on the invoice's side too. An invoice +// whose first line of the product is a component (a kit's member) and whose +// second is the product's own line links the sale's product line to the +// product line, never to the component that happens to sit before it. +func TestMigration105_BackfillPairsTheInvoiceSideProductLineOnly(t *testing.T) { + c, _ := mig105ScratchDB(t) + before, target, _ := mig105Files(t) + for _, f := range before { + mig105Apply(t, c, f) + } + mig105Legacy(t, c) + mig105Apply(t, c, target) + ctx := context.Background() + if _, err := c.Exec(ctx, ` + INSERT INTO invoices (id, customer_id, branch_id, number, status, invoice_date, currency, + subtotal, tax_amount, total_amount, amount_open, created_at, updated_at) + VALUES ('00000000-0000-0000-0000-0000000000e7', '00000000-0000-0000-0000-0000000000a1', + `+mig105Branch+`, 'INV-MIG-3', 'UNPAID', '2026-01-01', 'USD', 11.00, 0.98, 11.98, 11.98, + '2026-01-01T11:02:00Z', '2026-01-01T11:02:00Z'); + INSERT INTO invoice_lines (id, invoice_id, line_type, parent_line_id, product_id, description, quantity, uom, price_uom, + uom_qty, price_uom_qty, unit_price, line_total, unit_cost, taxable, position, created_at) + VALUES + ('00000000-0000-0000-0000-0000000000e8', '00000000-0000-0000-0000-0000000000e7', 'KIT', NULL, + '00000000-0000-0000-0000-0000000000f1', 'bundle', 1, 'PCS', 'PCS', 1, 1, 11.00, 11.00, 3.25, TRUE, 0, '2026-01-01T11:02:00Z'), + ('00000000-0000-0000-0000-0000000000e5', '00000000-0000-0000-0000-0000000000e7', 'COMPONENT', '00000000-0000-0000-0000-0000000000e8', + '00000000-0000-0000-0000-0000000000f1', 'kit member', 1, 'PCS', 'PCS', 1, 1, 0, 0, 3.25, FALSE, 1, '2026-01-01T11:02:00Z'), + ('00000000-0000-0000-0000-0000000000e6', '00000000-0000-0000-0000-0000000000e7', 'PRODUCT', NULL, + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 1, 'PCS', 'PCS', 1, 1, 5.50, 5.50, 3.25, TRUE, 2, '2026-01-01T11:02:00Z'); + INSERT INTO pos_transactions (id, register_id, cashier_id, branch_id, currency, status, completed_at, invoice_id) + VALUES ('`+mig105KitSale+`', 'REG-01', '00000000-0000-0000-0000-0000000000e1', `+mig105Branch+`, 'USD', 'COMPLETED', NOW(), + '00000000-0000-0000-0000-0000000000e7'); + INSERT INTO pos_line_items (id, transaction_id, line_type, product_id, description, quantity, uom, price_uom, + uom_qty, price_uom_qty, unit_price, line_total, position, price_source) + VALUES ('00000000-0000-0000-0000-0000000000e4', '`+mig105KitSale+`', 'PRODUCT', '00000000-0000-0000-0000-0000000000f1', + '2x4x8', 1, 'PCS', 'PCS', 1, 1, 5.50, 5.50, 0, 'PRICE_LIST');`); err != nil { + t.Fatalf("seed the component first invoice: %v", err) + } + // the backfill runs on every apply + mig105Apply(t, c, target) + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM pos_line_items + WHERE id = '00000000-0000-0000-0000-0000000000e4' AND invoice_line_id = '00000000-0000-0000-0000-0000000000e6'`); n != 1 { + t.Errorf("the sale's product line was not linked to the invoice's own product line") + } + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM pos_line_items + WHERE id = '00000000-0000-0000-0000-0000000000e4' AND invoice_line_id IN ('00000000-0000-0000-0000-0000000000e5', '00000000-0000-0000-0000-0000000000e8')`); n != 0 { + t.Errorf("the sale's product line was linked to the component or kit line that sits before it") + } +} + +// RULE (ninth review P3-3, tenth review P3-4): the down drops the refund +// intents table with its rows, and a PENDING claim dropped that way releases +// its tender (the next refund would pay the card twice), so the down refuses +// while any claim is still pending, naming the count and changing nothing. +func TestMigration105_DownRefusesWhileAClaimIsPending(t *testing.T) { + c, _ := mig105ScratchDB(t) + before, target, downFile := mig105Files(t) + for _, f := range before { + mig105Apply(t, c, f) + } + mig105Legacy(t, c) + mig105Apply(t, c, target) + ctx := context.Background() + if _, err := c.Exec(ctx, ` + INSERT INTO pos_refund_intents (id, sale_id, tender_id, gateway_tx_id, amount, act, state) + VALUES ('00000000-0000-0000-0000-0000000000f2', '00000000-0000-0000-0000-0000000000b1', + '00000000-0000-0000-0000-0000000000d2', 'gw-mig', 9.98, 'void', 'PENDING')`); err != nil { + t.Fatalf("seed the pending claim: %v", err) + } + sql := mustRead(t, downFile) + _, err := c.Exec(ctx, sql) + if err == nil { + t.Fatal("the down ran while a refund claim was still pending") + } + if !strings.Contains(err.Error(), "pending") || !strings.Contains(err.Error(), "1 ") { + t.Errorf("the down's refusal = %v, want it to name the count of pending claims", err) + } + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM pos_refund_intents WHERE state = 'PENDING'`); n != 1 { + t.Errorf("the refused down did not leave the claim standing: %d rows", n) + } + // the claim settled, the down runs and drops the table with it + if _, err := c.Exec(ctx, `UPDATE pos_refund_intents SET state = 'DONE' WHERE state = 'PENDING'`); err != nil { + t.Fatal(err) + } + if _, err := c.Exec(ctx, sql); err != nil { + t.Fatalf("the down refused with no pending claim: %v", err) + } + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM information_schema.tables WHERE table_name = 'pos_refund_intents'`); n != 0 { + t.Errorf("the intents table survived the down: %d rows", n) + } +} + +// RULE (tenth review P3-4): a voided sale's contract era lines are history +// the same way a completed sale's are, so the down refuses over them too. +func TestMigration105_DownRefusesWhileAVoidedSaleHoldsAContractEraLine(t *testing.T) { + c, _ := mig105ScratchDB(t) + before, target, downFile := mig105Files(t) + for _, f := range before { + mig105Apply(t, c, f) + } + mig105Legacy(t, c) + mig105Apply(t, c, target) + ctx := context.Background() + if _, err := c.Exec(ctx, ` + INSERT INTO pos_transactions (id, register_id, cashier_id, branch_id, currency, status, completed_at) + VALUES ('`+mig105DoneSale+`', 'REG-01', '00000000-0000-0000-0000-0000000000e1', `+mig105Branch+`, 'USD', 'VOIDED', NOW()); + INSERT INTO pos_line_items (transaction_id, line_type, description, price_source) + VALUES ('`+mig105DoneSale+`', 'TEXT', 'delivery note', 'NONE');`); err != nil { + t.Fatalf("seed the voided sale with a text line: %v", err) + } + sql := mustRead(t, downFile) + _, err := c.Exec(ctx, sql) + if err == nil { + t.Fatal("the down ran while a voided sale held a text line") + } + if !strings.Contains(err.Error(), "1 ") || !strings.Contains(err.Error(), "voided") { + t.Errorf("the down's refusal = %v, want it to name the count of lines and the voided sales", err) + } + if n := mig105Scalar[int](t, c, `SELECT count(*) FROM pos_line_items WHERE transaction_id = '`+mig105DoneSale+`'`); n != 1 { + t.Errorf("the refused down deleted the voided sale's line: %d rows, want 1", n) + } +} diff --git a/core/internal/pos/migration105_test.go b/core/internal/pos/migration105_test.go new file mode 100644 index 000000000..ff0edf43e --- /dev/null +++ b/core/internal/pos/migration105_test.go @@ -0,0 +1,525 @@ +// 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/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/database" + "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) + } + } + + // A completion under 105 stores its links: an invoice behind the + // completed sale, its lines, and each sale line's invoice line id (the + // sixth review's P3-3 cycle). + if _, err := conn.Exec(ctx, ` + INSERT INTO invoices (id, customer_id, branch_id, number, status, invoice_date, currency, + subtotal, tax_amount, total_amount, amount_open, created_at, updated_at) + VALUES ('00000000-0000-0000-0000-0000000000b5', '00000000-0000-0000-0000-0000000000a1', + `+mig105Branch+`, 'INV-MIG-1', 'UNPAID', '2026-01-01', 'USD', 16.50, 0.98, 17.48, 17.48, + '2026-01-01T11:02:00Z', '2026-01-01T11:02:00Z'); + INSERT INTO invoice_lines (id, invoice_id, product_id, description, quantity, uom, price_uom, + uom_qty, price_uom_qty, unit_price, line_total, unit_cost, position, created_at) + VALUES + ('00000000-0000-0000-0000-0000000000c5', '00000000-0000-0000-0000-0000000000b5', + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 2, 'PCS', 'PCS', 1, 1, 5.50, 11.00, 3.25, 0, '2026-01-01T11:02:00Z'), + ('00000000-0000-0000-0000-0000000000c6', '00000000-0000-0000-0000-0000000000b5', + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 1, 'PCS', 'PCS', 1, 1, 5.50, 5.50, 3.25, 1, '2026-01-01T11:02:00Z'); + UPDATE pos_transactions SET invoice_id = '00000000-0000-0000-0000-0000000000b5' + WHERE id = '00000000-0000-0000-0000-0000000000b1'; + UPDATE pos_line_items SET invoice_line_id = '00000000-0000-0000-0000-0000000000c5' + WHERE id = '00000000-0000-0000-0000-0000000000c1'; + UPDATE pos_line_items SET invoice_line_id = '00000000-0000-0000-0000-0000000000c6' + WHERE id = '00000000-0000-0000-0000-0000000000c2';`); err != nil { + t.Fatalf("seed the completion's links: %v", err) + } + + // 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"}, + {"pos_returns", "number"}, {"pos_returns", "credit_memo_id"}, + {"pos_line_items", "line_type"}, {"pos_line_items", "invoice_line_id"}, {"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]) + } + } + 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) + } + // the invoice link column survives the down (the invoices do: 105 never + // created them), so the up can backfill the line links again + if got := mig105Scalar[int](t, conn, `SELECT count(*) FROM pos_transactions + WHERE id = '00000000-0000-0000-0000-0000000000b1' AND invoice_id = '00000000-0000-0000-0000-0000000000b5'`); got != 1 { + t.Errorf("the completed sale lost its invoice link in the down") + } + 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. 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") + } + // the backfill restored each sale line's own invoice line, by product + // and order within the invoice: the linked return of a sale completed + // before, or downed and upped under, this item keeps its cost link + for line, want := range map[string]string{ + "00000000-0000-0000-0000-0000000000c1": "00000000-0000-0000-0000-0000000000c5", + "00000000-0000-0000-0000-0000000000c2": "00000000-0000-0000-0000-0000000000c6", + } { + if got := mig105Scalar[string](t, conn, `SELECT invoice_line_id::text FROM pos_line_items WHERE id = $1`, line); got != want { + t.Errorf("sale line %s links invoice line %s after the re-up, want its own %s (the backfill)", line, got, want) + } + } +} + +// 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) + } +} + +// mig105ServiceDB builds the schema to 104 in a scratch database, writes the +// legacy rows in the base shape, applies 105, and answers a live database +// handle the fixture can serve on (sixth review P1-1: the seeded database +// test through 105). +func mig105ServiceDB(t *testing.T) *database.DB { + t.Helper() + conn, url := mig105ScratchDB(t) + before, target, _ := mig105Files(t) + for _, f := range before { + mig105Apply(t, conn, f) + } + mig105Legacy(t, conn) + // a completed sale of lines 2 and 1 of the legacy product with an + // unlinked legacy return of half a unit: the exact rows a dealer's + // pre contract database holds, in the positive shape the base commit + // wrote them (and 105 leaves: it negates only while copying into the + // credit memos). The seeded CASH return a4 becomes that half return. + legacy := ` + 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-0000000000a6', '00000000-0000-0000-0000-0000000000a4', + '00000000-0000-0000-0000-0000000000f1', '2x4x8', 0.5, 'PCS', 5.50, 2.75, FALSE, '2026-02-03T12:00:00Z'); + UPDATE pos_returns SET original_transaction_id = '00000000-0000-0000-0000-0000000000b1', + subtotal = 2.75, tax_amount = 0.24, total = 2.99 + WHERE id = '00000000-0000-0000-0000-0000000000a4';` + if _, err := conn.Exec(context.Background(), legacy); err != nil { + 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 { + t.Fatalf("connect the migrated scratch database: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +// RULE (sixth review P1-1): on a database migration 105 really migrated, a +// sale partly returned before the contract cannot be returned for more than +// it sold: the legacy half the migration leaves positive counts against the +// cap, and the later linked returns bring back no more than the rest. +func TestMigration105_ALegacyReturnMigratedBoundsTheLaterReturns(t *testing.T) { + db := mig105ServiceDB(t) + f := newFixture(t, db) + f.openTill(0) + saleID := "00000000-0000-0000-0000-0000000000b1" + lines := f.getSale(t, saleID)["lines"].([]any) + first := lines[0].(map[string]any)["id"].(string) + second := lines[1].(map[string]any)["id"].(string) + // the second line's one unit comes back (the legacy half bears on the + // first line): 5.50 of line with its tax share of the sale's 98 + r := f.returnOn(t, saleID, second, "1", map[string]any{"restock": false}) + if r.status != http.StatusCreated { + t.Fatalf("return of the second line = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "total_cents"); got != -583 { + t.Errorf("return of the second line total = %d, want -583", got) + } + // the first line's whole two units are refused: half already came home + r = f.returnOn(t, saleID, first, "2", map[string]any{"restock": false}) + if r.status != http.StatusConflict { + t.Fatalf("return of the first line whole = %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) + } + // one and a half of it still come back, the line's remainder less the + // legacy refund: 1100 less the 275 the legacy half took + r = f.returnOn(t, saleID, first, "1.5", map[string]any{"restock": false}) + if r.status != http.StatusCreated { + t.Fatalf("return of the first line's remainder = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "subtotal_cents"); got != -825 { + t.Errorf("return of the first line's remainder subtotal = %d, want -825", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/migration108_test.go b/core/internal/pos/migration108_test.go index e6eb0dbf1..295caafa6 100644 --- a/core/internal/pos/migration108_test.go +++ b/core/internal/pos/migration108_test.go @@ -192,11 +192,11 @@ func TestMigration108_BackfillsTheBranchFromTheRegister(t *testing.T) { ('%[5]s', '%[2]s', '%[11]s', NULL, -1.25, '{"legacy":true}'), ('%[6]s', '%[3]s', '%[10]s', '%[13]s', 0, '{"set":true}'), ('%[7]s', '%[1]s', '%[12]s', NULL, 2.50, '{"stuck":true}'); - INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, subtotal, tax_amount, total, refund_method, status) VALUES - ('%[8]s', '%[10]s', NULL, '%[14]s', 10.11, 0.99, 11.10, 'CASH', 'COMPLETED'), - ('%[9]s', '%[11]s', NULL, '%[14]s', 20.22, 1.98, 22.20, 'CARD', 'COMPLETED'), - ('%[15]s', '%[10]s', '%[13]s', '%[14]s', 5.00, 0.45, 5.45, 'CASH', 'COMPLETED'), - ('%[16]s', '%[12]s', NULL, '%[14]s', 1.00, 0.09, 1.09, 'CASH', 'COMPLETED'); + INSERT INTO pos_returns (id, register_id, branch_id, cashier_id, subtotal, tax_amount, total, refund_method, status, currency, number) VALUES + ('%[8]s', '%[10]s', NULL, '%[14]s', 10.11, 0.99, 11.10, 'CASH', 'COMPLETED', 'USD', 'RTNM108-1'), + ('%[9]s', '%[11]s', NULL, '%[14]s', 20.22, 1.98, 22.20, 'CARD', 'COMPLETED', 'USD', 'RTNM108-2'), + ('%[15]s', '%[10]s', '%[13]s', '%[14]s', 5.00, 0.45, 5.45, 'CASH', 'COMPLETED', 'USD', 'RTNM108-3'), + ('%[16]s', '%[12]s', NULL, '%[14]s', 1.00, 0.09, 1.09, 'CASH', 'COMPLETED', 'USD', 'RTNM108-4'); `, openSess, closedSess, setSess, stuckSess, zrLegacy, zrSet, zrStuck, retNoLoc, retYard, regNoLoc, regYard, gone, branchY, cashier, retSet, retStuck) if _, err := conn.Exec(ctx, plant); err != nil { diff --git a/core/internal/pos/model.go b/core/internal/pos/model.go index c86e88dfe..27c4e7861 100644 --- a/core/internal/pos/model.go +++ b/core/internal/pos/model.go @@ -4,168 +4,284 @@ package pos import ( + "encoding/json" + "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 } + +// 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"` -// 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"` + // CustomerPO and JobID are the purchase order and job a charge sale + // names when the account asks for them. + CustomerPO *string `json:"customer_po"` + JobID *uuid.UUID `json:"job_id"` + + 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"` + + // 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:"-"` + // 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. + SaleLineID *uuid.UUID `json:"-"` + InvoiceLineID *uuid.UUID `json:"-"` } -// --- 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, an object on the +// wire (raw JSON, never a base64 encoded string a reader must decode before +// it can parse). +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 json.RawMessage `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/payback.go b/core/internal/pos/payback.go new file mode 100644 index 000000000..2aa4bf241 --- /dev/null +++ b/core/internal/pos/payback.go @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +// The payback of a declined void tender's credit (C2-5, FROM-PRINCIPAL +// 60b): a tender whose refund the terminal declined keeps its charge as +// unapplied credit on the customer once the void completes (held to its +// tender, round 9's apply refusal), and the one way back is that card, +// through the counter terminal rail: the same rail the charge came from, +// never the payment route's online gateway. The payback route is a small +// sibling of the resolve route, keyed on the void's own released claim, +// finance and owner only, and it runs its terminal call under the same +// claim discipline every terminal refund runs under: a committed payback +// claim per tender opened before the call, under the tender's payment row +// lock with the credit rechecked there (one live claim per tender, the in +// flight lease, and exactly one payback per tender ever, made structural +// by migration 111's index), the terminal's refund id written in its own +// committed write the moment it answers, and the money transaction booking +// the credit out only under the claim's row lock. A payback whose call +// answers no verdict stands PENDING like any act and settles through the +// resolve route. + +import ( + "context" + "fmt" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/audit" + "github.com/google/uuid" +) + +// PaybackIntentIn is a parsed payback request. +type PaybackIntentIn struct { + Reason string +} + +// RefundIntentPaidBack is the payback's answer: the credit it paid, the +// terminal's refund id, and the payback's own claim beside the void's. +type RefundIntentPaidBack struct { + ID uuid.UUID `json:"id"` + PaybackID uuid.UUID `json:"payback_id"` + SaleID uuid.UUID `json:"sale_id"` + TenderID uuid.UUID `json:"tender_id"` + AmountCents int64 `json:"amount_cents"` + RefundTxID string `json:"refund_tx_id"` + State string `json:"state"` +} + +// paybackAuditOf builds the audit row the payback writes, the resolve +// route's own shape (the person, the intent, the outcome and the reason): +// the intent named is the void's released claim the route keys on, and the +// payback's own claim rides beside it. +func paybackAuditOf(origin *RefundIntentRecord, claim RefundIntent, refundTxID, reason, actor string) audit.Entry { + return audit.Entry{ + Action: "pos.refund_intent.paid_back", EntityType: "pos_transaction", EntityID: origin.SaleID, UserID: actor, + Changes: map[string]any{ + "intent_id": origin.ID, "payback_intent_id": claim.ID, "act": claim.Act, "tender_id": claim.TenderID, + "amount_cents": claim.AmountCents, "outcome": "refund", "reason": reason, + "refund_tx_id": refundTxID, + }} +} + +// PaybackRefundIntent pays a declined void tender's credit back to its card +// through the counter terminal rail (FROM-PRINCIPAL 60b). The route keys on +// the void's own released claim: only a void claim the terminal (or a +// recorded no refund) released, of a sale whose void completed, holds a +// credit, and only while the tender's payment still carries it unapplied; +// a second payback answers 409 and never reaches the terminal. +func (s *Service) PaybackRefundIntent(ctx context.Context, intentID uuid.UUID, in PaybackIntentIn, actor string) (*RefundIntentPaidBack, error) { + if s.ar == nil { + return nil, errNotConfigured + } + if s.gateway == nil { + return nil, unavailableTerminal() + } + rec, err := s.repo.GetRefundIntent(ctx, intentID) + if err != nil { + return nil, err + } + // The branch wall, the resolve route's own (r12 P1-2): the sale read + // carries the branch context the middleware settled, so a caller outside + // the sale's branch finds the missing row before anything of the claim is + // read; the handler holds the no context caller the same way. + sale, err := s.repo.GetSale(ctx, rec.SaleID) + if err != nil { + return nil, err + } + // Every guard that needs no lock runs BEFORE the terminal is asked for + // money: a refusal never moves money at the terminal. + if rec.Act != "void" { + return nil, conflict("no_held_credit", + fmt.Sprintf("the claim's act is %s: only a void's declined tender leaves a credit on its payment", rec.Act)) + } + if rec.State != "FAILED" { + return nil, conflict("intent_not_failed", + fmt.Sprintf("the refund claim is %s: only a void claim the terminal (or a recorded no refund) released holds a credit to pay back", rec.State)) + } + if sale.Status != StatusVoided { + return nil, conflict("no_held_credit", + fmt.Sprintf("the sale is %s: the void has not completed, so the tender's charge is still applied to its invoice", sale.Status.Status())) + } + _, unapplied, err := s.repo.TenderPaymentFacts(ctx, rec.TenderID) + if err != nil { + return nil, err + } + if unapplied <= 0 { + return nil, conflict("no_held_credit", + "the tender's payment holds no unapplied credit: the credit was already paid back to its card") + } + // A recorded refund that never booked holds the tender against every + // later act (the sixteenth review's P1-2): a released claim carrying the + // terminal's refund id is money that moved while the books refused, so + // the credit is already paid as far as the card is concerned and the + // payback refuses it, never a second terminal call. + if facts, err := s.repo.RefundIntentFacts(ctx, rec.SaleID, rec.TenderID); err != nil { + return nil, err + } else if facts.UnbookedRefundedCents > 0 { + return nil, conflict("tender_refunded", + "the terminal already refunded this tender under a recorded refund id that never booked: reconcile that claim before paying the credit again") + } + // The payback's own claim, committed before the terminal is reached (the + // same discipline every terminal refund runs under): its unique live + // (sale, tender) index refuses a concurrent payback, its lease holds + // the resolve route and the drawer close while the call is on the wire, + // and exactly one payback per tender is structural (migration 111's + // partial unique index on the tender's payback claims PENDING or DONE). + // The claim opens in a transaction that locks the sale row and the + // tender's payment row, in the money transaction's own order, and + // rechecks the credit under that lock (the sixteenth review's P1-1): a + // contender whose earlier read saw the credit standing cannot open a + // second claim beside the first payback's booking, because the first's + // booking holds the payment row the contender rechecks on, and a tender + // whose payback already booked answers no credit here. + claim := RefundIntent{ID: uuid.New(), SaleID: rec.SaleID, TenderID: rec.TenderID, GatewayTxID: rec.GatewayTxID, + AmountCents: unapplied, Act: "payback"} + if err := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, rec.SaleID); err != nil { + return err + } + _, locked, err := s.repo.LockTenderPaymentFacts(ctx, rec.TenderID) + if err != nil { + return err + } + if locked <= 0 { + return conflict("no_held_credit", + "the tender's payment holds no unapplied credit: the credit was already paid back to its card") + } + done, err := s.repo.TenderHasDonePayback(ctx, rec.TenderID) + if err != nil { + return err + } + if done { + return conflict("no_held_credit", + "the tender's credit was already paid back to its card: exactly one payback runs per tender") + } + claim.AmountCents = locked + return s.repo.InsertRefundIntent(ctx, claim) + }); err != nil { + return nil, err + } + // The terminal call, bounded: every path it can take ends inside the + // bound, so the claim it holds never sits in flight past its lease. + callCtx, cancel := context.WithTimeout(ctx, refundCallTimeout) + res, err := s.gateway.Refund(callCtx, claim.GatewayTxID, claim.AmountCents) + cancel() + if err != nil { + s.unknownRefundOutcome(ctx, claim, err) + return nil, fmt.Errorf("card refund failed: %w", err) + } + verdict := refundVerdictOf(res) + if verdict != refundAccepted { + if verdict == refundDeclined { + s.failRefundIntent(ctx, claim) + } else { + s.unknownRefundOutcome(ctx, claim, noVerdictOf(res)) + } + return nil, conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) + } + // The terminal's own refund id, on the claim the moment it answers, in + // its own committed write whatever happens next. + if err := s.repo.MarkRefundIntentRefunded(ctx, claim.ID, res.TransactionID); err != nil { + s.logger.Error("CRITICAL: the payback's refund id was not written to the claim", + "intent_id", claim.ID, "sale_id", claim.SaleID, "error", err) + } + entry := paybackAuditOf(rec, claim, res.TransactionID, in.Reason, actor) + out, err := s.runPaybackTx(ctx, claim, res.TransactionID, in.Reason, actor, &entry) + if err != nil { + // the terminal moved the money and the books refused: one committed + // orphan row carries the ids, and the claim it leaves standing keeps + // the tender from being paid twice until it is reconciled. + s.recordOrphanRefund(ctx, gatewayRefund{gatewayTxID: claim.GatewayTxID, refundTxID: res.TransactionID, + amountCents: claim.AmountCents, tenderID: claim.TenderID, act: "payback", entity: claim.SaleID, actor: actor}, err) + return nil, err + } + return out, nil +} + +// runPaybackTx books one payback under its claim's row lock: the credit is +// paid out of the tender's payment's unapplied cash under the terminal's own +// refund id, and the claim completes DONE with it. The payback route runs it +// after its terminal call; the resolve route's refund leg runs the same +// booking on a payback claim whose call answered no verdict, on the word of +// the operator's recorded id. +func (s *Service) runPaybackTx(ctx context.Context, claim RefundIntent, refundTxID, reason, actor string, entry *audit.Entry) (*RefundIntentPaidBack, error) { + var out *RefundIntentPaidBack + err := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, claim.SaleID); err != nil { + return err + } + // The claim row, locked and read right after the sale lock (r12 + // P3-1): the loser of a race is told the race, never a cap the + // winner's own booking answers; a claim still inside its in flight + // lease with no answer settles nothing (the fourteenth review's P1-1), + // and the refund booked must be the terminal's own id when the claim + // carries one. + held, err := s.repo.LockRefundIntent(ctx, claim.ID) + if err != nil { + return err + } + if held.State != "PENDING" { + return conflict("intent_not_pending", + fmt.Sprintf("the refund claim is %s: only a PENDING claim is resolved", held.State)) + } + if err := s.settleUnansweredClaim(ctx, held); err != nil { + return err + } + if held.RefundTxID != "" && held.RefundTxID != refundTxID { + return conflict("refund_tx_id_conflict", + fmt.Sprintf("the claim carries the terminal's refund id %s, not %s: record the terminal's own id", + held.RefundTxID, refundTxID)) + } + // The unbooked refund restated under the sale lock: a recorded + // refund id that landed on a settled claim of this tender while the + // payback's own call was on the wire is money that already moved, + // and the booking refuses it as the route's guard does. + if facts, err := s.repo.RefundIntentFacts(ctx, claim.SaleID, claim.TenderID); err != nil { + return err + } else if facts.UnbookedRefundedCents > 0 { + return conflict("tender_refunded", + "the terminal already refunded this tender under a recorded refund id that never booked: reconcile that claim before paying the credit again") + } + tenders, err := s.repo.GetTenders(ctx, claim.SaleID) + if err != nil { + return err + } + var tender *Tender + for i := range tenders { + if tenders[i].ID == claim.TenderID { + tender = &tenders[i] + } + } + if tender == nil || tender.PaymentID == nil { + return conflict("no_held_credit", "the claim names no card tender the sale still holds") + } + sale, err := s.repo.GetSale(ctx, claim.SaleID) + if err != nil { + return err + } + date, err := s.repo.BranchLocalDate(ctx, sale.BranchID, s.now()) + if err != nil { + return err + } + originID := originOf(ctx, s, claim) + // the claim completes with the posting; the credit leaves the + // payment's unapplied cash under the terminal's own refund id (the + // cash account moves by the refund), exactly the way the resolve + // route's refund leg books a recorded refund + if err := s.repo.CompleteRefundIntent(ctx, claim.SaleID, claim.TenderID, refundTxID); err != nil { + return err + } + _, rfx, err := s.ar.RefundPayment(ctx, account.RefundPaymentIn{ + PaymentID: *tender.PaymentID, AmountCents: held.AmountCents, Reason: reason, + GatewayRefundID: refundTxID, Actor: actor, On: date}) + if err != nil { + return err + } + if entry != nil && s.auditLog != nil { + if err := s.auditLog.Log(ctx, *entry); err != nil { + return fmt.Errorf("failed to write the payback audit row: %w", err) + } + } + if rfx != nil { + for _, ev := range rfx.Events() { + if err := s.recordEvent(ctx, ev); err != nil { + return err + } + } + } + out = &RefundIntentPaidBack{ID: originID, PaybackID: claim.ID, SaleID: claim.SaleID, + TenderID: claim.TenderID, AmountCents: held.AmountCents, RefundTxID: refundTxID, State: "DONE"} + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// originOf reads the void claim the payback route keyed on (the tender's +// released void claim), for the answer's own id. +func originOf(ctx context.Context, s *Service, claim RefundIntent) uuid.UUID { + var id uuid.UUID + if err := s.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT id FROM pos_refund_intents WHERE sale_id = $1 AND tender_id = $2 AND act = 'void' AND state = 'FAILED' + ORDER BY created_at, id LIMIT 1`, claim.SaleID, claim.TenderID).Scan(&id); err != nil { + return claim.ID + } + return id +} + +// unavailableTerminal answers the payback's own unconfigured terminal +// refusal: the held credit's one rail is the counter terminal. +func unavailableTerminal() error { + return httpx.Unavailable("the card terminal is not configured: a held credit is paid back through it alone") +} diff --git a/core/internal/pos/payback_race_test.go b/core/internal/pos/payback_race_test.go new file mode 100644 index 000000000..5e103537f --- /dev/null +++ b/core/internal/pos/payback_race_test.go @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The payback race (the sixteenth review's P1-1): the payback's credit read +// and its claim insert were two unlocked steps, so a second payback that read +// the credit standing could open its claim beside the first's DONE write, +// call the terminal a second time, and leave a PENDING claim carrying the +// terminal's refund id that neither resolve outcome can settle: the card was +// paid twice and the drawer never closed again. The claim now opens in a +// transaction that locks the tender's payment row and rechecks the credit +// under that lock, and the one payback per tender rule is structural (a +// partial unique index on the tender's payback claims, migration 111). + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// holdPaymentRow parks the tender's payment row on its own held connection +// (the row the payback's money transaction books the credit out of), so a +// payback that already wrote its claim DONE parks inside its booking. Its +// waiters(n) waits until n sessions stand blocked behind that hold, directly +// or through any depth of one another's queues (the payback's money +// transaction at the payment row, the contenders behind its sale row, one +// behind another in the lock queue), and release commits and gives the +// connection back. The wait is read on the holding connection itself, so a +// pool sized for the contenders never runs out to the probe, and it counts +// only sessions blocked by that hold or by a session blocked by it, so a +// package running beside this one against the same database never answers it. +func holdPaymentRow(t *testing.T, f *fixture, paymentID string) (waiters func(n int), release func()) { + t.Helper() + ctx := context.Background() + conn, err := f.db.Pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire the holding connection: %v", err) + } + if _, err := conn.Exec(ctx, "BEGIN"); err != nil { + t.Fatalf("begin the hold: %v", err) + } + if _, err := conn.Exec(ctx, "SELECT 1 FROM payments WHERE id = $1 FOR NO KEY UPDATE", paymentID); err != nil { + t.Fatalf("lock the payment row: %v", err) + } + waiters = func(n int) { + t.Helper() + deadline := time.Now().Add(requestDeadline) + for { + // The statistics snapshot a session builds (pg_stat_activity + // reads it) is cached for the transaction, and a tight polling + // loop keeps reusing it, so a contender that connects + // after the first poll would stay invisible to the probe until + // the transaction ends. Discard it by hand before every read, + // so each poll sees the sessions as they stand. + if _, err := conn.Exec(ctx, `SELECT pg_stat_clear_snapshot()`); err != nil { + t.Fatalf("clear the statistics snapshot: %v", err) + } + var blocked int64 + if err := conn.QueryRow(ctx, ` + WITH RECURSIVE behind AS ( + SELECT s.pid AS blocked + FROM pg_stat_activity s + WHERE s.pid <> pg_backend_pid() AND s.wait_event_type = 'Lock' + AND pg_backend_pid() = ANY(pg_blocking_pids(s.pid)) + UNION + SELECT s.pid + FROM pg_stat_activity s JOIN behind ON behind.blocked = ANY(pg_blocking_pids(s.pid)) + WHERE s.pid <> pg_backend_pid() AND s.wait_event_type = 'Lock' + ) + SELECT count(*) FROM behind`).Scan(&blocked); err != nil { + t.Fatalf("read the sessions blocked behind the hold: %v", err) + } + if int(blocked) >= n { + return + } + if time.Now().After(deadline) { + t.Fatalf("only %d sessions reached the payment row hold within %s, want %d", blocked, requestDeadline, n) + } + time.Sleep(2 * time.Millisecond) + } + } + var once sync.Once + release = func() { + t.Helper() + once.Do(func() { + if _, err := conn.Exec(ctx, "COMMIT"); err != nil { + t.Fatalf("release the payment row hold: %v", err) + } + conn.Release() + }) + } + t.Cleanup(release) + return waiters, release +} + +// startPayback fires one payback request in its own goroutine, answering its +// response on the channel and its end on fin (the test joins it in its own +// cleanup, beside the releases that unblock it, the house contention shape). +func startPayback(f *fixture, claimID string) (<-chan resp, <-chan struct{}) { + done := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + done <- f.do("POST", "/api/v1/pos/refund-intents/"+claimID+"/payback", + map[string]any{"reason": "the customer is at the counter for their card"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(f.t)) + }() + return done, fin +} + +// RULE (the sixteenth review's P1-1, forced interleave): the tender's payment +// row is held while the first payback's money transaction has already written +// its claim DONE and is parked at its booking; a second payback that read the +// credit standing starts behind it. Exactly one terminal call is made, one +// payback answers 200 and the other 409, no payback claim is left PENDING, +// the credit is booked once, and the drawer still closes. +func TestForcedConcurrentPaybacksPayTheCardOnce(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + till := f.openTill(10000) + _, declined, _ := declinedCreditVoid(t, f, gw, mustUUID(t)) + callsAfterVoid := gatewayRefundCalls(t, gw) + payID, _ := f.paymentOfTender(t, declined["tender_id"].(string)) + + // the first payback parks at the terminal, so its claim is committed + // PENDING and the credit still stands when the hold is taken + parked, releaseTerminal := parkRefund(t, gw) + a, finA := startPayback(f, declined["id"].(string)) + awaitParked(t, parked) + + // the payment row hold: the first payback's money transaction will write + // its claim DONE and park at its booking under it + waiters, releaseHold := holdPaymentRow(t, f, payID) + releaseTerminal() + // one session waits on the hold: the payback's money transaction, past + // its DONE write + waiters(1) + + // the second payback starts on the credit it read standing, while the + // first's booking is parked: it must not reach the terminal + b, finB := startPayback(f, declined["id"].(string)) + t.Cleanup(func() { releaseHold(); releaseTerminal(); <-finA; <-finB }) + waiters(2) + releaseHold() + + ra, rb := awaitResp(t, a), awaitResp(t, b) + if ra.status != http.StatusOK { + t.Fatalf("the first payback = %d, want 200: %s", ra.status, ra.raw) + } + if rb.status != http.StatusConflict { + t.Fatalf("the second payback = %d, want 409: %s", rb.status, rb.raw) + } + _, blockers, _ := errorOf(t, rb) + if len(blockers) == 0 || blockers[0] != "no_held_credit" { + t.Errorf("the losing payback names %v, want no_held_credit (the credit was booked under the lock the loser rechecked)", blockers) + } + // the terminal was asked once for the credit, and the books took it once + if got := gatewayRefundCalls(t, gw); got != callsAfterVoid+1 { + t.Errorf("%d terminal refund calls after the race, want %d (exactly one payback call: the card is never paid twice)", got, callsAfterVoid+1) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != 0 { + t.Errorf("the credit after the race = %d, want 0 (booked once)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE act = 'payback' AND tender_id = $1`, declined["tender_id"]); got != 1 { + t.Errorf("%d payback claims of the tender, want 1 (the winner's DONE claim alone)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE act = 'payback' AND tender_id = $1 AND state = 'PENDING'`, declined["tender_id"]); got != 0 { + t.Errorf("%d PENDING payback claims after the race, want 0 (no claim is left standing)", got) + } + f.assertARInvariants(t) + + // the drawer still closes: no claim of the session is left PENDING + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the close after the race = %d, want 200: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} + +// RULE (BRIEF-COMMON's transaction rule, and the sixteenth review's P2-1): a +// race of three paybacks at pool 4. The first payback's money transaction is +// parked at its booking with its claim DONE; two more paybacks that read the +// credit standing queue behind it. Exactly one terminal call is made, one +// payback answers 200 and the others 409, no payback claim is left PENDING, +// and the drawer closes afterwards. +func TestThreeConcurrentPaybacksAtPoolFourPayTheCardOnce(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + till := f.openTill(10000) + _, declined, _ := declinedCreditVoid(t, f, gw, mustUUID(t)) + callsAfterVoid := gatewayRefundCalls(t, gw) + payID, _ := f.paymentOfTender(t, declined["tender_id"].(string)) + + parked, releaseTerminal := parkRefund(t, gw) + a, finA := startPayback(f, declined["id"].(string)) + awaitParked(t, parked) + waiters, releaseHold := holdPaymentRow(t, f, payID) + releaseTerminal() + waiters(1) + + b, finB := startPayback(f, declined["id"].(string)) + c, finC := startPayback(f, declined["id"].(string)) + t.Cleanup(func() { releaseHold(); releaseTerminal(); <-finA; <-finB; <-finC }) + waiters(3) + releaseHold() + ra := awaitResp(t, a) + var ok, refused, other int + for _, r := range []resp{ra, awaitResp(t, b), awaitResp(t, c)} { + switch { + case r.status == http.StatusOK: + ok++ + case r.status == http.StatusConflict: + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "no_held_credit" { + other++ + t.Errorf("a losing payback names %v, want no_held_credit: %s", blockers, r.raw) + } else { + refused++ + } + default: + other++ + t.Errorf("a contender answered %d: %s", r.status, r.raw) + } + } + if ok != 1 || refused != 2 || other != 0 { + t.Fatalf("of three contenders, %d answered 200, %d answered 409 no_held_credit and %d answered otherwise: want exactly one 200", ok, refused, other) + } + if got := gatewayRefundCalls(t, gw); got != callsAfterVoid+1 { + t.Errorf("%d terminal refund calls after the race of three, want %d (exactly one payback call)", got, callsAfterVoid+1) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != 0 { + t.Errorf("the credit after the race of three = %d, want 0", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE act = 'payback' AND tender_id = $1 AND state = 'PENDING'`, declined["tender_id"]); got != 0 { + t.Errorf("%d PENDING payback claims after the race of three, want 0 (the drawer cannot close over one)", got) + } + f.assertARInvariants(t) + + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the close after the race of three = %d, want 200: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/refund_intent_test.go b/core/internal/pos/refund_intent_test.go new file mode 100644 index 000000000..14ace14a8 --- /dev/null +++ b/core/internal/pos/refund_intent_test.go @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The refund intent (ADR 0005 section 11: "The card gateway is called +// before the transaction and its result written inside it", and section +// 14.2 C2-5: "each card payment, whose gateway void or refund is made +// before the transaction (as every gateway call is)"). Because the gateway +// sits outside the money transaction, no row lock can span the call, so a +// committed intent row, unique per sale and tender while pending, is what +// serializes the card refunds of a tender: a second void or card return is +// refused 409 refund_in_flight before it reaches the terminal; the money +// transaction marks the intent done as it posts; a gateway failure releases +// it having moved no money; a refund whose transaction refused leaves it +// standing beside the orphaned refund row. + +import ( + "context" + "net/http" + "sync" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE (sixth review P2-1, the lead's decision): three concurrent card +// returns of the one unit a card sale sold make exactly one gateway refund: +// the winner's committed intent refuses the losers before the terminal, so +// the card is never refunded twice for one unit. +func TestThreeConcurrentCardReturnsMakeOneGatewayRefund(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + var mu sync.Mutex + codes := make([]int, 3) + blockersOf := make([]string, 3) + raws := make([]string, 3) + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + r := f.cardReturnOn(t, saleID, lineID, "1") + mu.Lock() + defer mu.Unlock() + codes[i] = r.status + raws[i] = string(r.raw) + if r.status != http.StatusCreated { + _, blockers, _ := errorOf(t, r) + if len(blockers) > 0 { + blockersOf[i] = blockers[0] + } + } + }(i) + } + wg.Wait() + wins, refused := 0, 0 + for _, st := range codes { + switch st { + case http.StatusCreated: + wins++ + case http.StatusConflict: + refused++ + } + } + if wins != 1 || refused != 2 { + t.Fatalf("card return codes = %v, want one 201 and two 409", codes) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds for one returned unit, want exactly 1", refunds) + } + for i, st := range codes { + if st == http.StatusCreated { + continue + } + // both refusals happen before the terminal: the loser either met the + // winner's standing claim (refund_in_flight) or rechecked the caps + // under the claim's sale lock after the winner committed + // (exceeds_sold) + if blockersOf[i] != "refund_in_flight" && blockersOf[i] != "exceeds_sold" { + t.Errorf("loser %d blocker = %q, want refund_in_flight or exceeds_sold (refused before the terminal): %s", i, blockersOf[i], raws[i]) + } + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0 (no refund was made without its return)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (the charge refunded once)", got) + } + if got := f.stock(); got != "99.0000/0.0000" { + t.Errorf("stock = %s, want 99 (the card return restocks nothing)", got) + } + f.assertARInvariants(t) +} + +// RULE (sixth review P2-1): a void racing a card return of the same tender +// makes exactly one gateway refund: whichever act commits the intent first +// owns the terminal, and the other is refused refund_in_flight before it +// reaches it. Here the card return is parked at the gateway with its intent +// committed, and the void runs against it. +func TestAVoidRacingACardReturnMakesOneGatewayRefund(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + // after the fixture, so the release runs before the server's close + parked, release := parkRefund(t, gw) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + returnDone := make(chan resp, 1) + go func() { + returnDone <- f.cardReturnOn(t, saleID, lineID, "1") + }() + awaitParked(t, parked) + // the return's refund is in flight with its intent committed: the void + // of the same sale is refused before the terminal + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "racing a card return", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the void racing a card return = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the return's intent refused the void)", blockers) + } + release() + if rr := awaitResp(t, returnDone); rr.status != http.StatusCreated { + t.Fatalf("the parked card return = %d: %s", rr.status, rr.raw) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds for one unit refunded and a void racing it, want 1", refunds) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (the void was refused)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (599 charged, 599 refunded once)", got) + } + f.assertARInvariants(t) +} + +// RULE (sixth review P2-3, the lead's decision): no lock of the money +// transaction is held across a gateway call: while the terminal is on the +// wire, the sale row and its session row are free (a NOWAIT lock on either +// succeeds), for the void's refund and the return's alike. +func TestTheGatewayCallRunsOutsideEveryTransaction(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + ctx := context.Background() + probe := func(saleID, sessionID string) func() { + return func() { + for _, row := range []struct{ what, sql, id string }{ + {"the sale row", `SELECT 1 FROM pos_transactions WHERE id = $1 FOR UPDATE NOWAIT`, saleID}, + {"the session row", `SELECT 1 FROM till_sessions WHERE id = $1 FOR UPDATE NOWAIT`, sessionID}, + } { + conn, err := f.db.Pool.Acquire(ctx) + if err != nil { + t.Errorf("probe connection: %v", err) + return + } + if _, err := conn.Exec(ctx, row.sql, row.id); err != nil { + t.Errorf("while the gateway was called, %s was locked: %v (ADR 0005 section 11 keeps the call outside the transaction)", row.what, err) + } + conn.Release() + } + } + } + // the void's refund + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + sessionID := f.currentTillID(t) + gw.onRefund = probe(saleID, sessionID) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "probe", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the probed void = %d: %s", r.status, r.raw) + } + // the card return's refund + saleID2, body2 := f.saleOf("1", withToken(tender("card", 599))) + lineID := body2.body["lines"].([]any)[0].(map[string]any)["id"].(string) + gw.onRefund = probe(saleID2, sessionID) + if r := f.cardReturnOn(t, saleID2, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("the probed card return = %d: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} + +// RULE (the lead's decision, fix round 4): only a definite refusal from the +// terminal (a refusal status, the terminal's own no) releases the intent +// having moved no money: the tender may be refunded again, and the retry's +// own intent is the one that completes. A call that answers no verdict keeps +// the claim standing (the timed out and declined legs of the verdict tests). +func TestADefinitelyRefusedCardRefundReleasesTheIntentAndMovesNoMoney(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusDeclined}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the card return under a refused refund = %d, want 409: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'FAILED'`, saleID); got != 1 { + t.Errorf("%d FAILED refund intents, want 1 (the refused call released its claim)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 0 { + t.Errorf("%d PENDING refund intents after the refusal, want 0", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 0 { + t.Errorf("%d orphaned refund rows, want 0 (no money moved)", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the charge stands, nothing refunded)", got) + } + f.assertARInvariants(t) + // the retry goes through: the failed intent no longer blocks the tender + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("the retried card return = %d: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'DONE'`, saleID); got != 1 { + t.Errorf("%d DONE refund intents, want 1 (the retry's claim completed with its posting)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 after the retry (refunded once)", got) + } + f.assertARInvariants(t) +} + +// RULE (the lead's decision): a gateway success whose money transaction +// refuses leaves the intent standing and the refund recorded, and the +// standing intent refuses the next card refund of the tender until the +// money is reconciled. +func TestARefusedCardReturnLeavesItsIntentRefusingTheNext(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_return.completed" + }) + f.openTill(0) + saleID, body := f.saleOf("4", withToken(tender("card", 2395))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status == http.StatusCreated { + t.Fatalf("the return committed under a failing event write: %s", r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 1 { + t.Fatalf("%d PENDING refund intents after the refused transaction, want 1 (the claim records the refund)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.gateway_refund_orphaned' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d orphaned refund rows, want 1", got) + } + // the standing intent refuses the next card return of the tender before + // the terminal + r = f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the next card return = %d, want 409 (the standing intent refuses it): %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds, want 1 (the refusal never reached the terminal)", refunds) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/refund_method_wall_test.go b/core/internal/pos/refund_method_wall_test.go new file mode 100644 index 000000000..2165bfd09 --- /dev/null +++ b/core/internal/pos/refund_method_wall_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 refund method wall (ninth review P1-2, tenth review P1-1): every refund +// method but the card's own consults the intent table under the sale row lock +// before any money moves. A card claim PENDING at the terminal means the +// goods' money may already have left on the card, so the account must not +// take the same goods onto credit, exactly as the drawer must not pay them +// out a second time (the rule the cash method already carries). + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// accountReturnOn posts an account credit return of qty of the sale's first +// 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{ + {"line_id": lineID, "quantity": qty, "restock": false}}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) +} + +// RULE: an ACCOUNT return of a sale whose card claim is PENDING at the +// terminal is refused 409 refund_in_flight: the same goods cannot sit on the +// account as credit while the card may already have paid them back. The +// parked card return then books, and the account holds no credit. +func TestAnAccountReturnWhileACardReturnIsParkedRefuses(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + parked, release := parkRefund(t, gw) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + returnDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); returnDone <- f.cardReturnOn(t, saleID, lineID, "1") }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + r := f.accountReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the account return under a parked card claim = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the card claim holds the sale)", blockers) + } + if got := f.counterMemos(t); got != 0 { + t.Errorf("%d credit memos, want 0 (no account credit for goods the card may have refunded)", got) + } + if got := f.balance(); got != 0 { + t.Errorf("customer balance = %d, want 0 (nothing left on the account)", got) + } + f.assertARInvariants(t) + release() + if rr := awaitResp(t, returnDone); rr.status != http.StatusCreated { + t.Fatalf("the parked card return = %d: %s", rr.status, rr.raw) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds, want 1 (the terminal refunded the card once)", refunds) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (599 charged, 599 refunded once)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_returns WHERE original_transaction_id = $1`, saleID); got != 1 { + t.Errorf("%d returns of the sale, want 1 (the account return was refused)", got) + } + f.assertARInvariants(t) +} + +// RULE: the refusal holds while a void's claim stands too, and the void then +// completes. +func TestAnAccountReturnWhileAVoidIsParkedRefuses(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + parked, release := parkRefund(t, gw) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + voidDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + voidDone <- f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "parked", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + r := f.accountReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the account return under a parked void = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the void's claim holds the sale)", blockers) + } + if got := f.counterMemos(t); got != 0 { + t.Errorf("%d credit memos, want 0", got) + } + f.assertARInvariants(t) + release() + if rr := <-voidDone; rr.status != http.StatusOK { + t.Fatalf("the parked void = %d: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided", got) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds, want 1", refunds) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/refund_resolve_test.go b/core/internal/pos/refund_resolve_test.go new file mode 100644 index 000000000..1bb35e13f --- /dev/null +++ b/core/internal/pos/refund_resolve_test.go @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The resolution of a stranded refund claim (seventh review P2-3, eighth +// review P3-2, FROM-PRINCIPAL 41): the claim carries the gateway's own +// refund id the moment the terminal answers, so a PENDING claim that +// outlives its act can be told apart (money moved or may not have), and the +// guarded admin route settles it: a recorded refund books through the +// refund's own money transaction with every cap and lock, an explicit no +// refund marks the claim FAILED and moves nothing, and both write one audit +// row naming the person, the intent, the outcome and the reason. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/pos" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" +) + +// resolveOn posts one resolution of the claim with the finance role. +func (f *fixture) resolveOn(t *testing.T, intentID string, body map[string]any, role string) resp { + t.Helper() + return f.resolveOnSub(t, intentID, body, role, mustUUID(t)) +} + +// resolveOnSub posts one resolution as a named subject (the branch wall +// tests grant one). +func (f *fixture) resolveOnSub(t *testing.T, intentID string, body map[string]any, role, sub string) resp { + t.Helper() + return f.do("POST", "/api/v1/pos/refund-intents/"+intentID+"/resolve", body, + "X-Test-Role", role, "X-Test-Sub", sub) +} + +// pendingIntent reads the sale's standing claim row. +func pendingIntent(t *testing.T, f *fixture, saleID string) (id, state, refundTxID string, request []byte) { + t.Helper() + var req []byte + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT id::text, state, COALESCE(refund_tx_id, ''), request FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID). + Scan(&id, &state, &refundTxID, &req); err != nil { + t.Fatalf("read the standing claim: %v", err) + } + request = req + return id, state, refundTxID, request +} + +// RULE (seventh review P2-3): the gateway's own refund id lands on the +// standing claim in its own committed write the moment the terminal answers, +// before the money transaction opens: a transaction that then refuses leaves +// a PENDING claim that names the refund that moved. +func TestTheGatewayRefundIdLandsOnTheClaimWhenTheTerminalAnswers(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_return.completed" + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status == http.StatusCreated { + t.Fatalf("the return committed under a failing event write: %s", r.raw) + } + _, state, refundTxID, request := pendingIntent(t, f, saleID) + if state != "PENDING" { + t.Fatalf("the refused return left the claim %s, want PENDING", state) + } + if refundTxID == "" { + t.Fatalf("the standing claim carries no refund id: the money that moved cannot be told from money that may not have") + } + if refundTxID != gw.refunds[0]+"x"[:0] && refundTxID != "ref-1" { + t.Errorf("the claim carries %q, want the gateway's own refund id", refundTxID) + } + var stored struct { + Lines []struct { + Quantity int64 `json:"quantity"` + } `json:"lines"` + } + if err := json.Unmarshal(request, &stored); err != nil || len(stored.Lines) != 1 || stored.Lines[0].Quantity != 10000 { + t.Errorf("the claim's stored request = %s (err %v): a return claim carries its request shape for the replay", request, err) + } + f.assertARInvariants(t) +} + +// RULE (FROM-PRINCIPAL 41): an explicit no refund, with the reconciliation +// reference finance filed, marks the claim FAILED and moves nothing; the +// audit row names the person, the intent, the outcome and the reason; and +// the released tender may be refunded again through the normal route. +func TestANoRefundResolutionReleasesTheClaimAndMovesNothing(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, _, refundTxID, _ := pendingIntent(t, f, saleID) + if refundTxID != "" { + t.Fatalf("a claim whose call answered no verdict carries a refund id (%s)", refundTxID) + } + r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-41", "reason": "terminal log shows no refund", + }, "finance") + if r.status != http.StatusOK { + t.Fatalf("the no refund resolution = %d, want 200: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'FAILED'`, intentID); got != 1 { + t.Fatalf("the claim is not FAILED: %d rows", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' + AND changes->>'intent_id' = $1 AND changes->>'outcome' = 'no_refund' AND changes->>'reason' = 'terminal log shows no refund' + AND changes->>'reconciliation_reference' = 'REC-41'`, intentID); got != 1 { + t.Errorf("%d resolution audit rows naming the person's record, want 1", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (nothing moved: the terminal answered nothing)", got) + } + if got := f.counterReturns(t); got != 0 { + t.Errorf("%d returns booked, want 0", got) + } + f.assertARInvariants(t) + // the released claim no longer blocks the tender: the retry goes through + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("the retried card return = %d: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} + +// RULE (FROM-PRINCIPAL 41): a recorded refund books through the return's own +// money transaction with every cap and lock: the memo, its entry, the stock +// and the refund post in one transaction, the refund row names the gateway's +// refund id the operator recorded, and the claim completes DONE with it. +func TestARecordedRefundResolutionBooksTheStrandedReturn(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_return.completed" + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status == http.StatusCreated { + t.Fatalf("the return committed under a failing event write: %s", r.raw) + } + intentID, _, refundTxID, _ := pendingIntent(t, f, saleID) + if refundTxID == "" { + t.Fatal("the terminal answered; the claim carries its refund id") + } + // the transient write failure that stranded the act is gone by the time + // finance resolves it + f.events.fail = "" + r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": refundTxID, "reason": "terminal log shows the refund", + }, "owner") + if r.status != http.StatusOK { + t.Fatalf("the refund resolution = %d, want 200: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'DONE' AND refund_tx_id = $2`, intentID, refundTxID); got != 1 { + t.Fatalf("the claim did not complete with the recorded refund: %d rows", got) + } + if got := f.counterReturns(t); got != 1 { + t.Errorf("%d returns booked, want 1 (the stranded return's own transaction)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds WHERE gateway_refund_id = $1`, refundTxID); got != 1 { + t.Errorf("%d refund rows naming the recorded refund id, want 1", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' AND changes->>'intent_id' = $1`, intentID); got != 1 { + t.Errorf("%d resolution audit rows, want 1 (written with the booking: a rolled back replay leaves none)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (599 charged, 599 refunded once)", got) + } + if got := f.stock(); got != "99.0000/0.0000" { + t.Errorf("stock = %s, want 99 (the claimed return restocked nothing)", got) + } + f.assertARInvariants(t) + // the sale's goods bound its refunds: the booked return counts against + // every later one + if rr := f.cardReturnOn(t, saleID, lineID, "1"); rr.status != http.StatusConflict { + t.Fatalf("the second card return of the unit = %d, want 409: %s", rr.status, rr.raw) + } + f.assertARInvariants(t) +} + +// RULE (FROM-PRINCIPAL 41): a recorded refund of a void claim books through +// the void's own money transaction: the payments reversed, the invoice +// voided, the stock back, every guard under the sale row lock. +func TestARecordedRefundResolutionBooksTheStrandedVoid(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" + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "stranded", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status == http.StatusOK { + t.Fatalf("the void committed under a failing event write: %s", r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID == "" { + t.Fatalf("the stranded void's claim = (%s, %q), want PENDING with the refund id", state, refundTxID) + } + // the transient write failure that stranded the act is gone by the time + // finance resolves it + f.events.fail = "" + r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": refundTxID, "reason": "terminal log shows the refund", + }, "finance") + if r.status != http.StatusOK { + t.Fatalf("the refund resolution = %d, want 200: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the void's own transaction ran)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'DONE'`, intentID); got != 1 { + t.Errorf("the claim did not complete with the booking: %d rows", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (599 charged, 599 refunded once)", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100 (the void restocked the unit)", got) + } + f.assertARInvariants(t) +} + +// RULE (FROM-PRINCIPAL 41): the route refuses a bare resolve and a half +// record naming the field, refuses a claim that is not PENDING, keeps every +// other role out, and refuses a machine key in the route itself, bound or +// not. +func TestTheResolutionRouteRefuses(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, _, _, _ := pendingIntent(t, f, saleID) + + // a bare resolve is a 400 naming the field + for _, tc := range []struct { + name string + body map[string]any + field string + }{ + {"a bare resolve", map[string]any{"reason": "checked"}, "outcome"}, + {"a refund without its id", map[string]any{"outcome": "refund", "reason": "checked"}, "refund_tx_id"}, + {"a no refund without its reference", map[string]any{"outcome": "no_refund", "reason": "checked"}, "reconciliation_reference"}, + {"a reasonless record", map[string]any{"outcome": "no_refund", "reconciliation_reference": "REC-1"}, "reason"}, + } { + r := f.resolveOn(t, intentID, tc.body, "finance") + if r.status != http.StatusBadRequest { + t.Errorf("%s = %d, want 400: %s", tc.name, r.status, r.raw) + continue + } + if _, _, fields := errorOf(t, r); len(fields) == 0 || fields[0] != tc.field { + t.Errorf("%s names %v, want %s", tc.name, fields, tc.field) + } + } + // an unknown outcome word is a 400 + if r := f.resolveOn(t, intentID, map[string]any{"outcome": "maybe", "reason": "checked"}, "finance"); r.status != http.StatusBadRequest { + t.Errorf("an unknown outcome word = %d, want 400: %s", r.status, r.raw) + } + // the counter's own roles are not the resolution's roles + for _, role := range []string{"cashier", "admin", "sales"} { + if r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-1", "reason": "checked"}, role); r.status != http.StatusForbidden { + t.Errorf("the %s role on the resolution = %d, want 403: %s", role, r.status, r.raw) + } + } + // a machine key is refused in the route itself, bound or not + mux := http.NewServeMux() + pos.NewHandler(f.service).RegisterRoutes(mux) + keyed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux.ServeHTTP(w, r.WithContext(middleware.WithKeyID(r.Context(), uuid.NewString()))) + })) + t.Cleanup(keyed.Close) + req, _ := http.NewRequest(http.MethodPost, keyed.URL+"/api/v1/pos/refund-intents/"+intentID+"/resolve", + strings.NewReader(`{"outcome":"no_refund","reconciliation_reference":"REC-1","reason":"checked"}`)) + 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("a machine key on the resolution = %d, want 403 (the route itself, whatever the key's binding): %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "never a machine key") { + t.Errorf("the refusal does not say who may resolve: %s", rec.Body.String()) + } + // a claim that is not PENDING is a 409 + if r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-1", "reason": "checked"}, "finance"); r.status != http.StatusOK { + t.Fatalf("the good resolution = %d: %s", r.status, r.raw) + } + rr := f.resolveOn(t, intentID, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-2", "reason": "again"}, "finance") + if rr.status != http.StatusConflict { + t.Fatalf("the second resolution of a settled claim = %d, want 409: %s", rr.status, rr.raw) + } + _, blockers, _ := errorOf(t, rr) + if len(blockers) == 0 || blockers[0] != "intent_not_pending" { + t.Errorf("blockers = %v, want intent_not_pending", blockers) + } + // an unknown claim is a 404, never a silent ok + if rr := f.resolveOn(t, uuid.NewString(), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-3", "reason": "none"}, "finance"); rr.status != http.StatusNotFound { + t.Errorf("an unknown claim = %d, want 404: %s", rr.status, rr.raw) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/refund_verdict_test.go b/core/internal/pos/refund_verdict_test.go new file mode 100644 index 000000000..0d753f90c --- /dev/null +++ b/core/internal/pos/refund_verdict_test.go @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The verdicts of a refund call (seventh review P2-2): an error from the +// terminal carries no verdict (a timeout, a dropped connection, a context +// cancel after the terminal acted), so the terminal may have moved the money; +// releasing the claim would let the next refund pay the tender twice. Only a +// definite refusal status proves nothing moved. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE (seventh review P2-2): a refund call that answers no verdict (a +// timeout) leaves the intent PENDING with one committed audit row naming the +// intent and the unknown outcome; the next refund of the tender is refused +// before the terminal, and the stub gateway counts no second call. +func TestATimedOutCardRefundLeavesItsClaimStanding(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusInternalServerError { + t.Fatalf("the card return under a timed out refund = %d, want 500: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 1 { + t.Fatalf("%d PENDING refund intents after the timed out call, want 1 (no verdict came back: the claim must stand)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'FAILED'`, saleID); got != 0 { + t.Errorf("%d FAILED refund intents, want 0 (the claim is not released without a verdict)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_outcome_unknown' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d unknown outcome rows, want 1 (the intent is findable for reconciliation)", got) + } + var intentID string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT id FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID).Scan(&intentID); err != nil { + t.Fatal(err) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_outcome_unknown' AND changes->>'intent_id' = $1`, intentID); got != 1 { + t.Errorf("%d unknown outcome rows naming the intent, want 1", got) + } + // the standing claim refuses the next card refund before the terminal + r = f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the next card return = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refund calls, want 1 (the refusal never reached the terminal)", refunds) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the charge stands, nothing booked)", got) + } + f.assertARInvariants(t) +} + +// RULE (seventh review P2-2): a definite refusal status (the terminal's own +// no) proves nothing moved: the claim is released and the retry goes through. +func TestADeclinedCardRefundReleasesItsClaim(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusDeclined}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the declined card return = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_refund" { + t.Errorf("blockers = %v, want card_refund (the terminal's own no)", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'FAILED'`, saleID); got != 1 { + t.Fatalf("%d FAILED refund intents after the declined call, want 1 (a definite refusal moved nothing)", got) + } + // the retry goes through: the released claim no longer blocks the tender + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("the retried card return = %d: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'DONE'`, saleID); got != 1 { + t.Errorf("%d DONE refund intents, want 1 (the retry's claim completed with its posting)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (refunded once)", got) + } + f.assertARInvariants(t) +} + +// RULE (tenth review P2-3): a status that is neither a success nor the +// terminal's own no (PENDING, the refund under way; ERROR, the terminal's +// retry answer) proves nothing about the money: the claim stands PENDING +// with its unknown outcome row, the next refund of the tender is refused +// before the terminal, and nothing is booked. Only the explicit decline +// releases. +func TestANonVerdictStatusLeavesTheCardReturnClaimStanding(t *testing.T) { + testutil.LockOutboxTables(t) + for _, tc := range []struct { + name string + status payment.GatewayStatus + }{ + {"a pending status", payment.GatewayStatusPending}, + {"an error status", payment.GatewayStatusError}, + } { + t.Run(tc.name, func(t *testing.T) { + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundStatuses: []payment.GatewayStatus{tc.status}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the %s card return = %d, want 409: %s", tc.name, r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_refund" { + t.Errorf("blockers = %v, want card_refund naming the status", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 1 { + t.Fatalf("%d PENDING refund intents after the %s answer, want 1 (no verdict came back)", got, tc.name) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_outcome_unknown' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d unknown outcome rows after the %s answer, want 1", got, tc.name) + } + // the standing claim refuses the next refund before the terminal + r = f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the next card return after the %s answer = %d, want 409: %s", tc.name, r.status, r.raw) + } + _, blockers, _ = errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refund calls, want 1 (the refusal never reached the terminal)", refunds) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (nothing booked on a non verdict)", got) + } + f.assertARInvariants(t) + }) + } +} + +// RULE (tenth review P2-3, ninth review P2-3): the void's refund reads its +// verdict by the same rule: a status that is neither a success nor the +// terminal's own no leaves the claim PENDING with its unknown outcome row +// and refuses the next void before the terminal. +func TestANonVerdictStatusLeavesTheVoidClaimStanding(t *testing.T) { + testutil.LockOutboxTables(t) + for _, tc := range []struct { + name string + status payment.GatewayStatus + }{ + {"a pending status", payment.GatewayStatusPending}, + {"an error status", payment.GatewayStatusError}, + } { + t.Run(tc.name, func(t *testing.T) { + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundStatuses: []payment.GatewayStatus{tc.status}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "no verdict", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the %s void = %d, want 409: %s", tc.name, r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_refund" { + t.Errorf("blockers = %v, want card_refund naming the status", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 1 { + t.Fatalf("%d PENDING refund intents after the %s answer, want 1", got, tc.name) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_outcome_unknown' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d unknown outcome rows after the %s answer, want 1", got, tc.name) + } + // the next void is refused before the terminal + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "again", "revision": num(t, f.getSale(t, saleID), "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the next void after the %s answer = %d, want 409: %s", tc.name, r.status, r.raw) + } + _, blockers, _ = errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refund calls, want 1", refunds) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q, want completed (nothing booked on a non verdict)", got) + } + f.assertARInvariants(t) + }) + } +} + +// RULE (ninth review P2-3): the void's timeout keeps its claim the same way +// the return's does. +func TestATimedOutVoidRefundLeavesItsClaimStanding(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "timed out", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusInternalServerError { + t.Fatalf("the timed out void = %d, want 500: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID); got != 1 { + t.Fatalf("%d PENDING refund intents after the timed out void, want 1 (the terminal may have moved the money)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_outcome_unknown' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d unknown outcome rows, want 1", got) + } + // the next void is refused before the terminal + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "again", "revision": num(t, f.getSale(t, saleID), "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the next void = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight", blockers) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refund calls, want 1", refunds) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (nothing booked)", got) + } + f.assertARInvariants(t) +} + +// RULE (ninth review P2-3): the terminal's own no releases the void's claim: +// the retry goes through. +func TestADeclinedVoidRefundReleasesItsClaim(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusDeclined, payment.GatewayStatusRefunded}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "declined", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the declined void = %d, want 409: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'FAILED'`, saleID); got != 1 { + t.Fatalf("%d FAILED refund intents after the declined void, want 1 (the terminal's own no moved nothing)", got) + } + // the retry goes through + r = f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "again", "revision": num(t, f.getSale(t, saleID), "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("the retried void = %d: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided", got) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 2 { + t.Fatalf("%d gateway refund calls, want 2 (the declined one and the retry's)", refunds) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/register_branch_test.go b/core/internal/pos/register_branch_test.go index 023f81fff..18818ef24 100644 --- a/core/internal/pos/register_branch_test.go +++ b/core/internal/pos/register_branch_test.go @@ -5,9 +5,9 @@ package pos_test import ( "context" + "log/slog" "strings" "testing" - "time" "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/internal/testutil" @@ -55,15 +55,14 @@ func pinnedTo(branch uuid.UUID) context.Context { // A register with no branched location still belongs to the branch its own // pos_registers.branch_id names: a caller pinned to another branch is refused // and nothing is written, and the register's own branch writes there. -func TestCreateTransactionUsesTheRegistersOwnBranch(t *testing.T) { +func TestCreateSaleUsesTheRegistersOwnBranch(t *testing.T) { repo, register, own, other := registerBranchFixture(t) - now := time.Now() - mk := func() *pos.POSTransaction { - return &pos.POSTransaction{RegisterID: register, CashierID: uuid.New(), Status: pos.TransactionStatusOpen, ClientCreatedAt: &now} + mk := func() *pos.Sale { + return &pos.Sale{RegisterID: register, CashierID: uuid.New(), Currency: "USD"} } refused := mk() - err := repo.CreateTransaction(pinnedTo(other), refused) + err := repo.CreateSale(pinnedTo(other), refused) if err == nil || !strings.Contains(err.Error(), "different branch") { t.Fatalf("a caller pinned to another branch: err = %v, want the different branch refusal", err) } @@ -74,11 +73,11 @@ func TestCreateTransactionUsesTheRegistersOwnBranch(t *testing.T) { } ok := mk() - if err := repo.CreateTransaction(pinnedTo(own), ok); err != nil { + if err := repo.CreateSale(pinnedTo(own), ok); err != nil { t.Fatalf("a caller on the register's own branch: %v", err) } if ok.BranchID != own { - t.Errorf("the transaction landed at %s, want the register's branch %s", ok.BranchID, own) + t.Errorf("the sale landed at %s, want the register's branch %s", ok.BranchID, own) } } @@ -93,13 +92,14 @@ func TestGetRegisterBranchFallsBackToTheRegistersOwnBranch(t *testing.T) { } } -func TestCreateTillSessionStampsTheRegistersOwnBranch(t *testing.T) { +func TestOpenTillStampsTheRegistersOwnBranch(t *testing.T) { repo, register, own, _ := registerBranchFixture(t) - s := &pos.TillSession{RegisterID: register, CashierID: uuid.New(), Status: pos.TillSessionOpen, OpeningFloat: 5000} - if err := repo.CreateTillSession(context.Background(), s); err != nil { - t.Fatalf("CreateTillSession: %v", err) + svc := pos.NewService(testutil.RequireDB(t), repo, slog.Default()) + session, err := svc.OpenTill(context.Background(), register, uuid.New(), 5000, "u-test") + if err != nil { + t.Fatalf("OpenTill: %v", err) } - if s.BranchID == nil || *s.BranchID != own { - t.Errorf("the till session branch = %v, want the register's branch %s", s.BranchID, own) + if session.BranchID == nil || *session.BranchID != own { + t.Errorf("the till session branch = %v, want the register's branch %s", session.BranchID, own) } } diff --git a/core/internal/pos/repository.go b/core/internal/pos/repository.go index ba7fc68b3..0a0231833 100644 --- a/core/internal/pos/repository.go +++ b/core/internal/pos/repository.go @@ -8,94 +8,83 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" + "github.com/gablelbm/gable/internal/customer" + "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/gablelbm/gable/pkg/middleware" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) -// RegisterBranchRefusedError is the refusal a write answers when the caller's -// branch context disagrees with the register's own branch, or when the -// register's branch is outside the grants a caller with no branch in context -// is held to (SEC-NOHEADER-1). It is a typed error so the HTTP layer can -// answer it 403 instead of the 500 a plain service error maps to, nothing -// written either way. -type RegisterBranchRefusedError struct { - RegisterID string -} - -func (e *RegisterBranchRefusedError) Error() string { - return fmt.Sprintf("register %s belongs to a different branch than the request", e.RegisterID) -} - -// RecordBranchRefusedError is the refusal an act on an existing record (a -// till session, a sale) answers when the record's own branch is outside the -// grants a caller with no branch in context is held to (SEC-NOHEADER-1). -// It is a typed error so the HTTP layer can answer it 403, nothing written. -type RecordBranchRefusedError struct { - What string - ID uuid.UUID -} - -func (e *RecordBranchRefusedError) Error() string { - return fmt.Sprintf("%s %s belongs to a branch this caller may not target", e.What, e.ID) -} - -// UnknownRegisterError is the refusal a write answers when the register it -// names does not exist, an empty register_id included, which names no register -// either (r19 P3-1). It is a typed error so the HTTP layer can answer it 400 -// instead of the 500 a plain service error maps to; the offline sync -// refuses a batch whose register does not resolve before any item runs and -// before the log row, so the log never names a register nobody made (r17 -// P3-3). -type UnknownRegisterError struct { - RegisterID string -} - -func (e *UnknownRegisterError) Error() string { - if e.RegisterID == "" { - return "the batch register_id is empty and names no register" - } - return fmt.Sprintf("register %s does not exist", e.RegisterID) -} - -// MissingRecordError is the answer an act addressing a record that does not -// exist gets (a void of a missing sale, a close of a missing session, r4 -// P3-1 and P3-2): the module's own 404, typed so the HTTP layer never -// answers it with the record branch refusal's 403 (which would claim the -// missing record belongs to another branch) nor a bare 500. -type MissingRecordError struct { - What string - ID uuid.UUID -} - -func (e *MissingRecordError) Error() string { - return fmt.Sprintf("%s %s not found", e.What, e.ID) -} - -// 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 + 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 + 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) - AddLineItem(ctx context.Context, item *POSLineItem) error - RemoveLineItem(ctx context.Context, itemID uuid.UUID) error - GetLineItems(ctx context.Context, txID uuid.UUID) ([]POSLineItem, error) + // Tenders + AddTender(ctx context.Context, t *Tender) error + GetTenders(ctx context.Context, saleID uuid.UUID) ([]Tender, 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) + // 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) + // RefundedCentsByLine sums the cents every earlier return refunded per + // sale line, and RefundedTaxCents the tax they took back: the remainder + // rule of repeated partial returns reads them. CardRefundedCents sums + // what the card returns took, for the card refund's cap. + // LegacyReturnedQtyByProduct sums the unlinked (pre contract) return + // lines of a sale by product, so the cap counts them too. + // LegacyRefundedCentsByProduct sums the cents those lines refunded, so + // the remainder rule counts them too. + RefundedCentsByLine(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]int64, error) + RefundedTaxCents(ctx context.Context, saleID uuid.UUID) (int64, error) + CardRefundedCents(ctx context.Context, saleID uuid.UUID) (int64, error) + LegacyReturnedQtyByProduct(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]httpx.Quantity, error) + LegacyRefundedCentsByProduct(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]int64, error) + SaleHasReturns(ctx context.Context, saleID uuid.UUID) (bool, error) + // InvoiceLineCosts reads an invoice's lines with the unit cost the sale + // relieved (a linked return's restock cost, found by the stored link). + InvoiceLineCosts(ctx context.Context, invoiceID uuid.UUID) ([]InvoiceLineCost, error) + // LinkInvoiceLines records on each sale line the invoice line completion + // built from it; InvoiceLineIDsBySaleLine reads the links back. + LinkInvoiceLines(ctx context.Context, saleID uuid.UUID, links map[uuid.UUID]uuid.UUID) error + InvoiceLineIDsBySaleLine(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]uuid.UUID, 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) 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 @@ -103,423 +92,1692 @@ type Repository interface { GetZReportBySession(ctx context.Context, sessionID uuid.UUID) (*ZReport, error) ListZReports(ctx context.Context, registerID string, date time.Time) ([]ZReport, error) - // Returns - 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) + // 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 + // Refund intents (ADR 0005 section 11: the gateway is called before the + // transaction, so a committed claim is what serializes the card refunds + // of a tender). InsertRefundIntent writes one PENDING claim through the + // caller's transaction (a live claim of the same tender answers 409 + // refund_in_flight before any gateway call); CompleteRefundIntent marks + // the claim done inside the money transaction that posts the refund; + // FailRefundIntent releases the claim of a gateway call that failed. + // RefundIntentFacts reads the table's authority for one tender (what its + // live claims hold and whether a void already refunded it whole); + // SaleHasPendingRefundIntent answers whether any claim of a sale still + // stands; SessionPendingRefundIntentIDs lists the ids of the standing + // claims of a session's sales, so the close's refusal can name them. + InsertRefundIntent(ctx context.Context, intent RefundIntent) error + CompleteRefundIntent(ctx context.Context, saleID, tenderID uuid.UUID, refundTxID string) error + FailRefundIntent(ctx context.Context, saleID, tenderID uuid.UUID) error + RefundIntentFacts(ctx context.Context, saleID, tenderID uuid.UUID) (RefundIntentFacts, error) + SaleHasPendingRefundIntent(ctx context.Context, saleID uuid.UUID) (bool, error) + PendingVoidClaimsOfInvoice(ctx context.Context, invoiceID uuid.UUID) ([]uuid.UUID, error) + // HeldTenderCreditOfPayment answers the declined void tender credit a + // payment still holds (the account core's held credit guard, C2-5, + // FROM-PRINCIPAL 60): the void completed, one of its card tenders' refund + // claims was released (declined or recorded as a no refund), and that + // tender's payment still carries the unapplied cents the completion + // reversed onto it. + HeldTenderCreditOfPayment(ctx context.Context, paymentID uuid.UUID) (TenderCreditFacts, bool, error) + // TenderPaymentFacts reads a tender's payment and its unapplied cents + // (the payback route's credit read). + TenderPaymentFacts(ctx context.Context, tenderID uuid.UUID) (uuid.UUID, int64, error) + // LockTenderPaymentFacts is that read under the payment row's lock (the + // payback's claim transaction rechecks the credit there); TenderHasDonePayback + // answers whether the tender's credit was already paid back. + LockTenderPaymentFacts(ctx context.Context, tenderID uuid.UUID) (uuid.UUID, int64, error) + TenderHasDonePayback(ctx context.Context, tenderID uuid.UUID) (bool, error) + SessionPendingRefundIntentIDs(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) + // PendingRefundIntentIDs lists the ids of the sale's standing claims (the + // per tender void resolution reads them to know when every tender is + // resolved); SaleHasVoidIntentDone answers whether the sale's void already + // booked a tender's refund (its goods were paid back at the terminal). + PendingRefundIntentIDs(ctx context.Context, saleID uuid.UUID) ([]uuid.UUID, error) + SaleHasVoidIntentDone(ctx context.Context, saleID uuid.UUID) (bool, error) + // MarkRefundIntentRefunded writes the gateway's own refund id onto the + // claim in its own committed write, the moment the terminal answers, + // whatever state the claim is in; StampRefundIntentAnswered records that + // the call came back carrying no verdict, and + // MarkRefundIntentUnansweredUnknown marks a claim a resolution settles on + // an expired lease (the terminal never spoke); GetRefundIntent reads one + // claim whole, LockRefundIntent reads one claim whole under its row lock + // (the resolution's transaction), and FailRefundIntentByID releases one + // claim by its id (the operator resolution's no refund leg). + MarkRefundIntentRefunded(ctx context.Context, intentID uuid.UUID, refundTxID string) error + // RestampRefundIntentLease renews one claim's lease immediately before + // its own terminal call (the void's sequential calls). + RestampRefundIntentLease(ctx context.Context, intentID uuid.UUID) error + StampRefundIntentAnswered(ctx context.Context, intentID uuid.UUID) error + MarkRefundIntentUnansweredUnknown(ctx context.Context, intentID uuid.UUID) error + AbandonRefundIntent(ctx context.Context, intentID uuid.UUID) error + GetRefundIntent(ctx context.Context, id uuid.UUID) (*RefundIntentRecord, error) + LockRefundIntent(ctx context.Context, id uuid.UUID) (*RefundIntentRecord, error) + FailRefundIntentByID(ctx context.Context, id uuid.UUID) error + // Lookups + GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) + // SaleBranch and TillSessionBranch read a record's own branch with no + // wall, for the act hold that must decide before the walled read hides + // the record (found false: the record does not exist). + SaleBranch(ctx context.Context, id uuid.UUID) (branch uuid.UUID, found bool, err error) + TillSessionBranch(ctx context.Context, id uuid.UUID) (branch *uuid.UUID, found bool, err 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) + // FindCounterCustomers searches the customers the caller's wall reaches + // by name or account number, standing figures left to the service. + FindCounterCustomers(ctx context.Context, q string, limit int) ([]CounterCustomer, error) + // CounterCustomerByID reads one customer through the same wall; false + // when it does not exist or the wall hides it. + CounterCustomerByID(ctx context.Context, id uuid.UUID) (*CounterCustomer, bool, error) + // SetSaleAccount names the customer, currency, purchase order and job of + // an open sale and moves its revision. + SetSaleAccount(ctx context.Context, saleID uuid.UUID, customerID *uuid.UUID, currency string, po *string, jobID *uuid.UUID) 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 - - // TransactionBranch and TillSessionBranch read a record's own branch - // with no wall, for the act hold that must decide before the walled - // read hides the record from a caller held to the grants. The second - // return tells a record that does not exist (found false) from one - // that carries no branch (found true, nil branch), so the act can - // answer the module's 404 rather than a branch refusal (r4 P3-1). - TransactionBranch(ctx context.Context, id uuid.UUID) (branch *uuid.UUID, found bool, err error) - TillSessionBranch(ctx context.Context, id uuid.UUID) (branch *uuid.UUID, found bool, err error) + SaleExists(ctx context.Context, id uuid.UUID) (bool, error) + LogSyncBatch(ctx context.Context, batch LogBatch) error } -// PostgresRepository implements Repository for PostgreSQL. +// CustomerFacts is what the counter reads about the sale's customer: the +// 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 + Currency string + PORequired bool +} + +// 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 + CardRefundsCents int64 +} + +// 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 string + Status string +} + +// ReturnFilter is the return list's filters. +type ReturnFilter struct { + RegisterID string + Date string +} + +// 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. type PostgresRepository struct { db *database.DB grants *middleware.BranchGuard } -// NewRepository creates a new POS repository. +// NewRepository creates the counter's repository. func NewRepository(db *database.DB) *PostgresRepository { return &PostgresRepository{db: db, grants: middleware.NewBranchGuard(db)} } -// holdRegisterBranch holds the register's branch to the caller's grants when -// the context carries no branch (SEC-NOHEADER-1): a caller that sends the -// header keeps the equality check the caller already ran, and an -// administrator keeps its reach, so only a no header user is held. -func (r *PostgresRepository) holdRegisterBranch(ctx context.Context, registerID string, branch *uuid.UUID) error { - if err := r.grants.CheckGrantsBranch(ctx, branch); err != nil { - if errors.Is(err, middleware.ErrPayloadBranchRefused) { - return &RegisterBranchRefusedError{RegisterID: registerID} +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`) + +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 + // 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, $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)) + } 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 `+middleware.Wall("", 2, 3)+` + FOR NO KEY UPDATE`, id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return httpx.NotFound("sale not found") } - tx.CreatedAt = time.Now() + return err +} - // Derive the register's branch_id (its location's branch, else its own pos_registers.branch_id). - var registerBranch *uuid.UUID - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT COALESCE(l.branch_id, r.branch_id) - FROM pos_registers r - LEFT JOIN locations l ON l.id = r.location_id - WHERE r.id = $1`, tx.RegisterID).Scan(®isterBranch) +// 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 resolve register branch: %w", err) + return fmt.Errorf("failed to move the sale's revision: %w", err) + } + return nil +} + +const saleHeadCols = `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` + +// saleCols is the full sale's columns: the list item's head and the purchase +// order and job a charge sale names. +const saleCols = saleHeadCols + `, t.customer_po, t.job_id` + +func scanSale(row pgx.Row, s *Sale) error { + 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, + &completed, &created, &s.CustomerPO, &s.JobID); err != nil { + return err } + s.CompletedAt = httpx.PtrTimestamp(completed) + s.CreatedAt = httpx.TimestampOf(created) + return 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 &RegisterBranchRefusedError{RegisterID: tx.RegisterID} +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 `+middleware.Wall("t", 2, 3), id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)), s) + if errors.Is(err, pgx.ErrNoRows) { + return nil, httpx.NotFound("sale not found") } - // A caller with no branch in context is held to the caller's grants. - if err := r.holdRegisterBranch(ctx, tx.RegisterID, registerBranch); err != nil { - return err + if err != nil { + return nil, fmt.Errorf("failed to read the sale: %w", err) } + return s, 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) +// SaleBranch reads a sale's own branch with no wall, for the act hold that +// must decide before the walled read hides the record from a caller held to +// the grants. A sale that does not exist answers found false. +func (r *PostgresRepository) SaleBranch(ctx context.Context, id uuid.UUID) (uuid.UUID, bool, error) { + var branch uuid.UUID + err := r.ex(ctx).QueryRow(ctx, `SELECT branch_id FROM pos_transactions WHERE id = $1`, id).Scan(&branch) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } if err != nil { - return fmt.Errorf("failed to create POS transaction: %w", err) + return uuid.Nil, false, fmt.Errorf("failed to read the sale's branch: %w", err) + } + return branch, true, nil +} + +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 } -// 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) 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 false, fmt.Errorf("failed to check transaction existence: %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 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) - if err != nil { - return nil, fmt.Errorf("failed to get product catalog: %w", err) +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 fmt.Errorf("failed to void the sale: %w", err) + } + if tag.RowsAffected() == 0 { + return httpx.InvalidStateTransition("only a completed sale can be voided") + } + return nil +} + +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) { + // 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 AND t.created_at < $4 + AND ` + middleware.Wall("t", 5, 6) + args := []any{f.RegisterID, nil, start, end, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)} + if f.Status != "" { + args[1] = f.Status + } + rows, err := r.ex(ctx).Query(ctx, `SELECT `+saleHeadCols+`, + (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 $7`, append(args, limit)...) + if err != nil { + return nil, fmt.Errorf("failed to list sales: %w", err) } defer rows.Close() + var out []SaleSummary + 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, &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, + CreatedAt: s.CreatedAt, ItemCount: count}) + } + return out, rows.Err() +} - var products []CatalogProduct +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() + } + _, 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 add the sale line: %w", mapWriteError(err)) + } + } + return nil +} + +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 remove the line: %w", err) + } + if tag.RowsAffected() == 0 { + return httpx.NotFound("line not found on this sale") + } + return nil +} + +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 read the sale's lines: %w", err) + } + defer rows.Close() + out := []salesdoc.Line{} 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) + var l salesdoc.Line + var sc salesdoc.LineScan + if err := rows.Scan(sc.Dests(&l)...); err != nil { + return nil, err } - products = append(products, p) + sc.Finish(&l) + out = append(out, l) } - return products, nil + return out, rows.Err() } -// syncErrorJSON is used for safe JSON marshalling of sync error details. -type syncErrorJSON struct { - ClientID string `json:"client_id"` - Reason string `json:"reason"` +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(&created) + t.CreatedAt = httpx.TimestampOf(created) + if err != nil { + return fmt.Errorf("failed to record the tender: %w", mapWriteError(err)) + } + return nil } -// 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) 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, method, id`, saleID) + if err != nil { + return nil, fmt.Errorf("failed to read the tenders: %w", err) + } + defer rows.Close() + out := []Tender{} + 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, &createdAt); err != nil { + return nil, err } - data, err := json.Marshal(items) + t.Method = TenderMethod(method) + t.SaleID = saleID + t.CreatedAt = httpx.TimestampOf(createdAt) + 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 + 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) + 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(&createdAt) + ret.CreatedAt = httpx.TimestampOf(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() + } + 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, $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, lineType) if err != nil { - return fmt.Errorf("failed to marshal sync errors: %w", err) + return fmt.Errorf("failed to record the return 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 ` + middleware.Wall("", 2, 3) + ` - ` - var tx POSTransaction - var subtotal, taxAmount, total, changeDue float64 - err := r.db.GetExecutor(ctx).QueryRow(ctx, query, id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(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 ` + middleware.Wall("", 8, 10) + ` - ` - _, 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, middleware.BranchIDForQuery(ctx), - float64(tx.ChangeDue)/100.0, - middleware.GrantsSubForQuery(ctx), - ) - if err != nil { - return fmt.Errorf("failed to update POS transaction: %w", err) +// 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) } - return nil + 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) 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 ` + middleware.Wall("t", 4, 5) + ` - 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) +// RefundedCentsByLine sums, per sale line, the cents every earlier return of +// the sale refunded against it (the stored line totals are negative, so the +// sum is negated back): the remainder rule reads it, so repeated partial +// returns never refund more than the line was paid. +func (r *PostgresRepository) RefundedCentsByLine(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]int64, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT rl.sale_line_id, -ROUND(SUM(rl.line_total) * 100)::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 refunded cents: %w", err) + } + defer rows.Close() + out := map[uuid.UUID]int64{} + for rows.Next() { + var id uuid.UUID + var cents int64 + if err := rows.Scan(&id, ¢s); err != nil { + return nil, err + } + out[id] = cents + } + return out, rows.Err() +} + +// RefundedTaxCents sums the tax every earlier return of the sale took back. +// Each row is read with its own sign: the contract writes negative amounts, +// the legacy rows migration 105 leaves carry positive ones (105 negates only +// while copying into credit memos), and either way the tax came back. +func (r *PostgresRepository) RefundedTaxCents(ctx context.Context, saleID uuid.UUID) (int64, error) { + var cents int64 + err := r.ex(ctx).QueryRow(ctx, + `SELECT COALESCE(ROUND(SUM(ABS(tax_amount)) * 100)::bigint, 0) FROM pos_returns WHERE original_transaction_id = $1`, + saleID).Scan(¢s) + if err != nil { + return 0, fmt.Errorf("failed to read the sale's refunded tax: %w", err) + } + return cents, nil +} + +// CardRefundedCents sums the cents the card returns of the sale already +// took, each row with its own sign (the contract negative, the legacy rows +// migration 105 leaves positive): a card return is capped at the card tender +// less this. +func (r *PostgresRepository) CardRefundedCents(ctx context.Context, saleID uuid.UUID) (int64, error) { + var cents int64 + err := r.ex(ctx).QueryRow(ctx, + `SELECT COALESCE(ROUND(SUM(ABS(total)) * 100)::bigint, 0) FROM pos_returns + WHERE original_transaction_id = $1 AND refund_method = 'CARD'`, saleID).Scan(¢s) + if err != nil { + return 0, fmt.Errorf("failed to read the sale's card refunds: %w", err) + } + return cents, nil +} - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, registerID, startOfDay, endOfDay, - middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) +// LegacyReturnedQtyByProduct sums, per product, the quantity the sale's +// legacy return lines brought back: the lines migration 105 could not link +// to a sale line (their sale_line_id is null). Each line is read with its +// own sign: the base commit wrote positive quantities and 105 leaves them, +// while a free (unlinked) return the contract writes is negative. +func (r *PostgresRepository) LegacyReturnedQtyByProduct(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]httpx.Quantity, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT rl.product_id, ROUND(SUM(ABS(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 NULL AND rl.product_id IS NOT NULL + GROUP BY rl.product_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 unlinked return lines: %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() +} - var summaries []TransactionSummary +// LegacyRefundedCentsByProduct sums, per product, the cents the sale's +// legacy (unlinked) return lines refunded, each line with its own sign: the +// remainder rule counts them, so the goods bound the money whichever route +// brought them back. +func (r *PostgresRepository) LegacyRefundedCentsByProduct(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]int64, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT rl.product_id, ROUND(SUM(ABS(rl.line_total)) * 100)::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 NULL AND rl.product_id IS NOT NULL + GROUP BY rl.product_id`, saleID) + if err != nil { + return nil, fmt.Errorf("failed to read the sale's unlinked refund cents: %w", err) + } + defer rows.Close() + out := map[uuid.UUID]int64{} 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 id uuid.UUID + var cents int64 + if err := rows.Scan(&id, ¢s); err != nil { + return nil, err } - s.Total = int64(total*100.0 + 0.5) - summaries = append(summaries, s) + out[id] = cents } - return summaries, nil + return out, rows.Err() +} + +// RefundIntent is one committed claim to refund a tender at the gateway: the +// row that serializes the card refunds of a tender (sale id, tender id, +// amount, state pending) because the gateway is called before the money +// transaction (ADR 0005 section 11) and no row lock can span the call. +// Request carries a return claim's own request shape (JSON), so the operator +// resolution can replay the money transaction the lost act never ran. +type RefundIntent struct { + ID uuid.UUID + SaleID uuid.UUID + TenderID uuid.UUID + GatewayTxID string + AmountCents int64 + // Act names the act behind the refund ("void" or "return"). + Act string + // Request is the stored return request of a return claim, nil on a void. + Request []byte } -func (r *PostgresRepository) AddLineItem(ctx context.Context, item *POSLineItem) error { - if item.ID == uuid.Nil { - item.ID = uuid.New() +// InsertRefundIntent writes one PENDING refund intent through the caller's +// transaction (the claim's own short transaction: never inside the money +// transaction). The partial unique index (sale id, tender id) makes the +// claim exclusive: a second void of the sale, or a card return of the same +// tender, is refused here with 409 refund_in_flight before it ever reaches +// the terminal. The insert's foreign keys take only FOR KEY SHARE, so the +// claim never blocks behind a money transaction's sale row lock. +func (r *PostgresRepository) InsertRefundIntent(ctx context.Context, intent RefundIntent) error { + if intent.ID == uuid.Nil { + intent.ID = uuid.New() } - item.CreatedAt = time.Now() + _, err := r.ex(ctx).Exec(ctx, ` + INSERT INTO pos_refund_intents (id, sale_id, tender_id, gateway_tx_id, amount, act, state, request, answer_due_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5::numeric / 100, $6, 'PENDING', $7, NOW() + make_interval(secs => $8), NOW(), NOW())`, + intent.ID, intent.SaleID, intent.TenderID, intent.GatewayTxID, intent.AmountCents, intent.Act, intent.Request, + int64(refundAnswerLease.Seconds())) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return conflict("refund_in_flight", + "a card refund of this sale's tender is already in flight: only one refund of a tender runs at a time") + } + return fmt.Errorf("failed to open the refund intent: %w", mapWriteError(err)) + } + return nil +} - 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, - ) +// CompleteRefundIntent marks the tender's live intent done, inside the money +// transaction that posts the refund, carrying the gateway's own refund id. +// A refund the transaction then refuses rolls this back with everything +// else: the intent stays PENDING beside its orphaned refund row, recording +// the money that moved. A call that settles no row (the claim left PENDING +// underneath, a concurrent resolution winning it) refuses 409 +// intent_not_pending, so no booking ever stands beside a settled claim +// (ninth review P2-2, tenth review P2-1). +func (r *PostgresRepository) CompleteRefundIntent(ctx context.Context, saleID, tenderID uuid.UUID, refundTxID string) error { + tag, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET state = 'DONE', refund_tx_id = NULLIF($3, ''), updated_at = NOW() + WHERE sale_id = $1 AND tender_id = $2 AND state = 'PENDING'`, saleID, tenderID, refundTxID) if err != nil { - return fmt.Errorf("failed to add POS line item: %w", err) + return fmt.Errorf("failed to complete the refund intent: %w", mapWriteError(err)) + } + if tag.RowsAffected() == 0 { + return conflict("intent_not_pending", "the refund claim was settled by another resolution first") } return nil } -// RemoveLineItem deletes a line through its transaction's branch: a bound -// key's branch context (or a user's grants) hides a transaction of another -// branch, so its line deletes as missing and nothing is written. -func (r *PostgresRepository) RemoveLineItem(ctx context.Context, itemID uuid.UUID) error { - query := ` - DELETE FROM pos_line_items li - USING pos_transactions t - WHERE li.id = $1 - AND t.id = li.transaction_id - AND ` + middleware.Wall("t", 2, 3) + ` - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, itemID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) +// FailRefundIntent releases the tender's live intent after its gateway call +// failed: no money moved for it, so the tender may be refunded again. The +// decline is the terminal's own answer, so the claim's answer is stamped and +// its state leaves IN_FLIGHT. A call that settles no row answers the same +// intent_not_pending refusal; the decline path's caller treats it as settled +// by another hand, never as a failure of its own release. +func (r *PostgresRepository) FailRefundIntent(ctx context.Context, saleID, tenderID uuid.UUID) error { + tag, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET state = 'FAILED', answered_at = NOW(), answer_state = 'ANSWERED', updated_at = NOW() + WHERE sale_id = $1 AND tender_id = $2 AND state = 'PENDING'`, saleID, tenderID) if err != nil { - return fmt.Errorf("failed to remove POS line item: %w", err) + return fmt.Errorf("failed to release the refund intent: %w", mapWriteError(err)) + } + if tag.RowsAffected() == 0 { + return conflict("intent_not_pending", "the refund claim was settled by another resolution first") } return nil } -// TransactionBranch reads a transaction's own branch with no wall, for the -// act hold that must decide before the walled read hides the record. A -// transaction that does not exist answers found false, and one that exists -// with no branch answers found true and a nil branch. -func (r *PostgresRepository) TransactionBranch(ctx context.Context, id uuid.UUID) (*uuid.UUID, bool, error) { - var branch *uuid.UUID - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT branch_id FROM pos_transactions WHERE id = $1`, id).Scan(&branch) +// MarkRefundIntentRefunded writes the gateway's own refund id onto the +// claim, whatever state the claim is in (fourteenth review P1-1: a wrong early release can +// never orphan the terminal's id), and stamps the answer in its own committed +// write, made the moment the terminal answers and before the money +// transaction opens: a claim carrying a refund id is a refund the terminal +// moved, one without it is a refund that may not have moved, so a claim that +// outlives its act can be told apart and resolved (seventh review P2-3). +func (r *PostgresRepository) MarkRefundIntentRefunded(ctx context.Context, intentID uuid.UUID, refundTxID string) error { + tag, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET refund_tx_id = NULLIF($2, ''), answered_at = NOW(), answer_state = 'ANSWERED', updated_at = NOW() + WHERE id = $1`, intentID, refundTxID) + if err != nil { + return fmt.Errorf("failed to record the refund id on the intent: %w", mapWriteError(err)) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("the refund claim %s is gone: the terminal's refund id %s was not recorded", intentID, refundTxID) + } + return nil +} + +// RestampRefundIntentLease renews one claim's in flight lease to now plus +// the stated timeout, in its own committed write immediately before the +// claim's own terminal call: a void's calls run one after another, each +// bounded at a third of the lease, so a later tender's call would otherwise +// start after the common insert-time lease had run out and the resolve route +// could release a claim whose call is still on the wire (the sixteenth +// review's P1-2). +func (r *PostgresRepository) RestampRefundIntentLease(ctx context.Context, intentID uuid.UUID) error { + _, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET answer_due_at = NOW() + make_interval(secs => $2), updated_at = NOW() + WHERE id = $1`, intentID, int64(refundAnswerLease.Seconds())) + if err != nil { + return fmt.Errorf("failed to restamp the claim's in flight lease: %w", mapWriteError(err)) + } + return nil +} + +// StampRefundIntentAnswered records that the claim's own terminal call came +// back carrying no verdict (a transport error, a status that proves +// nothing): the call is off the wire, so the claim is stranded and +// resolvable, exactly as the unknown outcome row records it (fourteenth review P1-1). +func (r *PostgresRepository) StampRefundIntentAnswered(ctx context.Context, intentID uuid.UUID) error { + _, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET answered_at = NOW(), answer_state = 'ANSWERED', updated_at = NOW() + WHERE id = $1 AND answered_at IS NULL`, intentID) + if err != nil { + return fmt.Errorf("failed to stamp the claim's answer: %w", mapWriteError(err)) + } + return nil +} + +// MarkRefundIntentUnansweredUnknown marks a claim UNKNOWN when a resolution +// proceeds on its in flight lease expiring with no answer at all: the +// terminal never spoke, and the claim's own record must say so (fourteenth review P1-1). +// A claim the terminal did answer is left alone. +func (r *PostgresRepository) MarkRefundIntentUnansweredUnknown(ctx context.Context, intentID uuid.UUID) error { + _, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET answer_state = 'UNKNOWN', updated_at = NOW() + WHERE id = $1 AND answered_at IS NULL`, intentID) + if err != nil { + return fmt.Errorf("failed to mark the unanswered claim: %w", mapWriteError(err)) + } + return nil +} + +// AbandonRefundIntent records that the claim's act ended before the terminal +// was ever asked for it (a void that stopped at an earlier tender's refusal, +// fourteenth review P1-1): no answer can ever come back, so the claim is stranded from now +// and its record says the terminal never spoke. +func (r *PostgresRepository) AbandonRefundIntent(ctx context.Context, intentID uuid.UUID) error { + _, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET answered_at = NOW(), answer_state = 'UNKNOWN', updated_at = NOW() + WHERE id = $1 AND answered_at IS NULL`, intentID) + if err != nil { + return fmt.Errorf("failed to abandon the never called claim: %w", mapWriteError(err)) + } + return nil +} + +// RefundIntentRecord is one claim row read whole, for the operator resolving +// it: what it claimed, where it stands, and whether the terminal answered. +type RefundIntentRecord struct { + RefundIntent + State string + RefundTxID string + // The in flight lease (fourteenth review P1-1): AnswerDueAt is when the claim may be + // resolved if its own terminal call never settles it; AnsweredAt is + // stamped by every path that ends that call; AnswerState carries + // IN_FLIGHT, ANSWERED or UNKNOWN (a resolution on an expired lease). + AnswerDueAt time.Time + AnsweredAt *time.Time + AnswerState string + ResolvedAt *time.Time + CreatedAt time.Time +} + +// GetRefundIntent reads one claim whole. +func (r *PostgresRepository) GetRefundIntent(ctx context.Context, id uuid.UUID) (*RefundIntentRecord, error) { + var rec RefundIntentRecord + var request []byte + err := r.ex(ctx).QueryRow(ctx, ` + SELECT id, sale_id, tender_id, gateway_tx_id, ROUND(amount * 100)::bigint, act, state, + COALESCE(refund_tx_id, ''), answer_due_at, answered_at, answer_state, request, created_at, updated_at + FROM pos_refund_intents WHERE id = $1`, id). + Scan(&rec.ID, &rec.SaleID, &rec.TenderID, &rec.GatewayTxID, &rec.AmountCents, &rec.Act, &rec.State, + &rec.RefundTxID, &rec.AnswerDueAt, &rec.AnsweredAt, &rec.AnswerState, &request, &rec.CreatedAt, &rec.ResolvedAt) if errors.Is(err, pgx.ErrNoRows) { - return nil, false, nil + return nil, httpx.NotFound("refund claim not found") } if err != nil { - return nil, false, fmt.Errorf("failed to read transaction branch: %w", err) + return nil, fmt.Errorf("failed to read the refund intent: %w", err) } - return branch, true, nil + rec.Request = request + return &rec, nil } -// TillSessionBranch reads a till session's own branch with no wall, for the -// act hold that must decide before the walled read hides the record. A -// session that does not exist answers found false, and one that exists with -// no branch answers found true and a nil branch. -func (r *PostgresRepository) TillSessionBranch(ctx context.Context, id uuid.UUID) (*uuid.UUID, bool, error) { - var branch *uuid.UUID - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT branch_id FROM till_sessions WHERE id = $1`, id).Scan(&branch) +// PendingRefundIntentIDs lists the ids of the sale's standing claims in +// creation order: the per tender void resolution completes the sale's void +// only when the list is empty. +func (r *PostgresRepository) PendingRefundIntentIDs(ctx context.Context, saleID uuid.UUID) ([]uuid.UUID, error) { + rows, err := r.ex(ctx).Query(ctx, + `SELECT id FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING' ORDER BY created_at, id`, saleID) + if err != nil { + return nil, fmt.Errorf("failed to read the sale's standing claims: %w", err) + } + defer rows.Close() + var out []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// SaleHasVoidIntentDone answers whether the sale's void already booked a +// card tender's refund: the goods' money moved at the terminal, so no other +// method may pay the goods back. +func (r *PostgresRepository) SaleHasVoidIntentDone(ctx context.Context, saleID uuid.UUID) (bool, error) { + var done bool + err := r.ex(ctx).QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM pos_refund_intents WHERE sale_id = $1 AND act = 'void' AND state = 'DONE')`, saleID). + Scan(&done) + if err != nil { + return false, fmt.Errorf("failed to read the sale's void claims: %w", err) + } + return done, nil +} + +// LockRefundIntent reads one claim whole under its row lock: the operator +// resolution's transaction re-reads the claim it is about to settle, so a +// concurrent resolution of the same claim loses here instead of booking +// beside it. +func (r *PostgresRepository) LockRefundIntent(ctx context.Context, id uuid.UUID) (*RefundIntentRecord, error) { + var rec RefundIntentRecord + var request []byte + err := r.ex(ctx).QueryRow(ctx, ` + SELECT id, sale_id, tender_id, gateway_tx_id, ROUND(amount * 100)::bigint, act, state, + COALESCE(refund_tx_id, ''), answer_due_at, answered_at, answer_state, request, created_at, updated_at + FROM pos_refund_intents WHERE id = $1 FOR UPDATE`, id). + Scan(&rec.ID, &rec.SaleID, &rec.TenderID, &rec.GatewayTxID, &rec.AmountCents, &rec.Act, &rec.State, + &rec.RefundTxID, &rec.AnswerDueAt, &rec.AnsweredAt, &rec.AnswerState, &request, &rec.CreatedAt, &rec.ResolvedAt) if errors.Is(err, pgx.ErrNoRows) { - return nil, false, nil + return nil, httpx.NotFound("refund claim not found") } if err != nil { - return nil, false, fmt.Errorf("failed to read till session branch: %w", err) + return nil, fmt.Errorf("failed to lock the refund intent: %w", err) } - return branch, true, nil + rec.Request = request + return &rec, nil +} + +// FailRefundIntentByID releases one claim by its id: the operator +// resolution's no refund leg, which moves nothing. +func (r *PostgresRepository) FailRefundIntentByID(ctx context.Context, id uuid.UUID) error { + tag, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_refund_intents SET state = 'FAILED', updated_at = NOW() + WHERE id = $1 AND state = 'PENDING'`, id) + if err != nil { + return fmt.Errorf("failed to release the refund intent: %w", mapWriteError(err)) + } + if tag.RowsAffected() == 0 { + return conflict("intent_not_pending", "the refund claim was settled by another resolution first") + } + return nil +} + +// RefundIntentFacts is the intent table's authority on one tender: what its +// standing claims hold, whether a void's intent already refunded the tender +// whole, and the cents of refunds the terminal moved that never booked. +type RefundIntentFacts struct { + PendingCents int64 + DoneVoid bool + // UnbookedRefundedCents sums the settled claims that carry the terminal's + // refund id without a booking of their own (the fourteenth review's P1-1 defence in depth: + // a late refund behind a wrongly released claim). A PENDING claim's own + // booking names its refund id, so PENDING never counts here; the tender + // that paid once at the terminal is never asked to pay again. + UnbookedRefundedCents int64 +} + +// RefundIntentFacts reads the tender's intents: the cents its PENDING claims +// hold (claimed at the terminal, not yet booked, so no pos_returns row counts +// them), whether any DONE intent of act void refunded the tender whole, and +// the cents of recorded refunds that never booked. The DONE return intents +// need no field of their own: their booking commits the pos_returns row +// beside them in one transaction, so CardRefundedCents already counts them. +func (r *PostgresRepository) RefundIntentFacts(ctx context.Context, saleID, tenderID uuid.UUID) (RefundIntentFacts, error) { + var facts RefundIntentFacts + err := r.ex(ctx).QueryRow(ctx, ` + SELECT COALESCE(ROUND(SUM(CASE WHEN state = 'PENDING' THEN ABS(amount) ELSE 0 END) * 100)::bigint, 0), + COALESCE(BOOL_OR(act = 'void' AND state = 'DONE'), FALSE), + COALESCE(ROUND(SUM(CASE WHEN refund_tx_id IS NOT NULL AND state NOT IN ('DONE', 'PENDING') THEN ABS(amount) ELSE 0 END) * 100)::bigint, 0) + FROM pos_refund_intents WHERE sale_id = $1 AND tender_id = $2`, saleID, tenderID). + Scan(&facts.PendingCents, &facts.DoneVoid, &facts.UnbookedRefundedCents) + if err != nil { + return facts, fmt.Errorf("failed to read the tender's refund intents: %w", err) + } + return facts, nil +} + +// SaleHasPendingRefundIntent answers whether any card refund claim of the +// sale still stands: a claim whose terminal call or money transaction has not +// settled yet. +func (r *PostgresRepository) SaleHasPendingRefundIntent(ctx context.Context, saleID uuid.UUID) (bool, error) { + var pending bool + err := r.ex(ctx).QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING')`, saleID). + Scan(&pending) + if err != nil { + return false, fmt.Errorf("failed to read the sale's refund intents: %w", err) + } + return pending, nil +} + +// TenderCreditFacts is the declined void tender credit one payment still +// holds: the origin names the void's sale and the claim the void released, +// and Cents is what the payment still carries unapplied of it. RefundTxID, +// when set, is the terminal's own refund id on that claim: money that moved +// at the terminal and never booked. +type TenderCreditFacts struct { + IntentID uuid.UUID + TenderID uuid.UUID + SaleID uuid.UUID + SaleNumber string + Cents int64 + RefundTxID string +} + +// HeldTenderCreditOfPayment answers whether the payment's unapplied cash is +// a credit held to one of the counter's card tenders (C2-5, +// FROM-PRINCIPAL 60): the tender's sale was voided, the tender's own refund +// claim was released by the terminal's decline (or a recorded no refund), +// and the void's completion reversed the tender's application onto the +// payment, where the cents wait for their one way back. A payment nothing +// of the counter holds, or one whose credit was already paid back (its +// unapplied cash is gone), answers false. A credit whose released claim +// carries the terminal's refund id (money that moved, never booked: the +// sixteenth review's P1-2) answers false too, with the tender named: it is +// already refunded as far as the card is concerned, never a held credit, +// and every act on it (an application, the online refund route) is refused +// 409 tender_refunded until it is reconciled. +func (r *PostgresRepository) HeldTenderCreditOfPayment(ctx context.Context, paymentID uuid.UUID) (TenderCreditFacts, bool, error) { + var facts TenderCreditFacts + err := r.ex(ctx).QueryRow(ctx, ` + SELECT i.id, i.tender_id, i.sale_id, t.number, ROUND(p.amount_unapplied * 100)::bigint, COALESCE(i.refund_tx_id, '') + FROM payments p + JOIN pos_tenders d ON d.payment_id = p.id AND d.method = 'CARD' + JOIN pos_transactions t ON t.id = d.transaction_id AND t.status = 'VOIDED' + JOIN pos_refund_intents i ON i.sale_id = t.id AND i.tender_id = d.id + AND i.act = 'void' AND i.state = 'FAILED' + WHERE p.id = $1 AND p.status = 'POSTED' AND ROUND(p.amount_unapplied * 100)::bigint > 0 + ORDER BY i.created_at, i.id + LIMIT 1`, paymentID). + Scan(&facts.IntentID, &facts.TenderID, &facts.SaleID, &facts.SaleNumber, &facts.Cents, &facts.RefundTxID) + if errors.Is(err, pgx.ErrNoRows) { + return facts, false, nil + } + if err != nil { + return facts, false, fmt.Errorf("failed to read the payment's held tender credit: %w", err) + } + if facts.RefundTxID != "" { + return facts, false, conflict("tender_refunded", + "the terminal already refunded this tender under a recorded refund id that never booked: reconcile that claim before anything else is paid or spent on the tender") + } + return facts, true, nil +} + +// TenderPaymentFacts reads the tender's payment and the unapplied cents it +// carries: the payback route's credit read, before any terminal call. +func (r *PostgresRepository) TenderPaymentFacts(ctx context.Context, tenderID uuid.UUID) (uuid.UUID, int64, error) { + var paymentID uuid.UUID + var unapplied int64 + err := r.ex(ctx).QueryRow(ctx, ` + SELECT d.payment_id, ROUND(p.amount_unapplied * 100)::bigint + FROM pos_tenders d JOIN payments p ON p.id = d.payment_id + WHERE d.id = $1 AND d.payment_id IS NOT NULL AND p.status = 'POSTED'`, tenderID). + Scan(&paymentID, &unapplied) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, 0, conflict("no_held_credit", "the tender names no posted payment: no credit is held on it") + } + if err != nil { + return uuid.Nil, 0, fmt.Errorf("failed to read the tender's payment: %w", err) + } + return paymentID, unapplied, nil +} + +// LockTenderPaymentFacts is TenderPaymentFacts under the payment row's lock, +// for the payback's claim transaction: the credit is rechecked on the row +// the money transaction books it out of, so a payback whose earlier read saw +// the credit standing opens no claim beside a first payback's booking (the +// sixteenth review's P1-1). Runs through the caller's transaction. +func (r *PostgresRepository) LockTenderPaymentFacts(ctx context.Context, tenderID uuid.UUID) (uuid.UUID, int64, error) { + var paymentID uuid.UUID + var unapplied int64 + err := r.ex(ctx).QueryRow(ctx, ` + SELECT d.payment_id, ROUND(p.amount_unapplied * 100)::bigint + FROM pos_tenders d JOIN payments p ON p.id = d.payment_id + WHERE d.id = $1 AND d.payment_id IS NOT NULL AND p.status = 'POSTED' + FOR UPDATE OF p`, tenderID). + Scan(&paymentID, &unapplied) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, 0, conflict("no_held_credit", "the tender names no posted payment: no credit is held on it") + } + if err != nil { + return uuid.Nil, 0, fmt.Errorf("failed to lock the tender's payment: %w", err) + } + return paymentID, unapplied, nil +} + +// TenderHasDonePayback answers whether the tender's credit was already paid +// back: a DONE payback claim stands on it. The payback's claim transaction +// rechecks this under the payment row's lock, and migration 111's partial +// unique index makes the same rule structural. +func (r *PostgresRepository) TenderHasDonePayback(ctx context.Context, tenderID uuid.UUID) (bool, error) { + var done bool + err := r.ex(ctx).QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM pos_refund_intents WHERE tender_id = $1 AND act = 'payback' AND state = 'DONE')`, tenderID). + Scan(&done) + if err != nil { + return false, fmt.Errorf("failed to read the tender's payback claims: %w", err) + } + return done, 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) +// PendingVoidClaimsOfInvoice lists the standing void claims of the sale that +// owns the invoice, in creation order: the account core's application guard +// (C2-5, the fourteenth review's P2-1) names them when it refuses an application to +// the invoice's interim open cents. +func (r *PostgresRepository) PendingVoidClaimsOfInvoice(ctx context.Context, invoiceID uuid.UUID) ([]uuid.UUID, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT i.id FROM pos_refund_intents i + JOIN pos_transactions t ON t.id = i.sale_id + WHERE t.invoice_id = $1 AND i.act = 'void' AND i.state = 'PENDING' + ORDER BY i.created_at, i.id`, invoiceID) if err != nil { - return nil, fmt.Errorf("failed to get POS line items: %w", err) + return nil, fmt.Errorf("failed to read the invoice's void claims: %w", err) } defer rows.Close() + var out []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} - var items []POSLineItem +// SessionPendingRefundIntentIDs lists the ids of the card refund claims of +// the session's sales that still stand, in creation order: the drawer cannot +// be counted and closed while a terminal refund of one of its sales may +// still move money, and the refusal names the claims so the cashier knows +// who unblocks the drawer. +func (r *PostgresRepository) SessionPendingRefundIntentIDs(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT i.id FROM pos_refund_intents i + JOIN pos_transactions t ON t.id = i.sale_id + WHERE t.till_session_id = $1 AND i.state = 'PENDING' + ORDER BY i.created_at, i.id`, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to read the session's refund intents: %w", err) + } + defer rows.Close() + var out []uuid.UUID 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) + var id uuid.UUID + if err := rows.Scan(&id); 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) + out = append(out, id) } - return items, nil + return out, rows.Err() +} + +// InvoiceLineCost is one invoice line's id and the unit cost the sale +// relieved (ten thousandths). +type InvoiceLineCost struct { + ID uuid.UUID + UnitCost int64 } -func (r *PostgresRepository) AddTender(ctx context.Context, tender *POSTender) error { - if tender.ID == uuid.Nil { - tender.ID = uuid.New() +// InvoiceLineCosts reads an invoice's lines with their unit costs: a linked +// return finds its source line's cost by the invoice line id completion +// stored on the sale line (never by a position: a removed cart line leaves +// the sale's positions gapped while the invoice numbers its own from zero). +func (r *PostgresRepository) InvoiceLineCosts(ctx context.Context, invoiceID uuid.UUID) ([]InvoiceLineCost, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, 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.UnitCost); err != nil { + return nil, err + } + out = append(out, l) } - tender.CreatedAt = time.Now() + return out, rows.Err() +} - 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, - ) +// LinkInvoiceLines records on each sale line the invoice line completion +// built from it, inside the completion's transaction. +func (r *PostgresRepository) LinkInvoiceLines(ctx context.Context, saleID uuid.UUID, links map[uuid.UUID]uuid.UUID) error { + if len(links) == 0 { + return nil + } + saleLines := make([]uuid.UUID, 0, len(links)) + invLines := make([]uuid.UUID, 0, len(links)) + for saleLine, invLine := range links { + saleLines = append(saleLines, saleLine) + invLines = append(invLines, invLine) + } + if _, err := r.ex(ctx).Exec(ctx, ` + UPDATE pos_line_items l SET invoice_line_id = v.inv + FROM unnest($1::uuid[], $2::uuid[]) AS v(sid, inv) + WHERE l.id = v.sid AND l.transaction_id = $3`, saleLines, invLines, saleID); err != nil { + return fmt.Errorf("failed to link the sale lines to their invoice lines: %w", mapWriteError(err)) + } + return nil +} + +// InvoiceLineIDsBySaleLine reads the invoice line each sale line became. +func (r *PostgresRepository) InvoiceLineIDsBySaleLine(ctx context.Context, saleID uuid.UUID) (map[uuid.UUID]uuid.UUID, error) { + rows, err := r.ex(ctx).Query(ctx, ` + SELECT id, invoice_line_id FROM pos_line_items + WHERE transaction_id = $1 AND invoice_line_id IS NOT NULL`, saleID) + if err != nil { + return nil, fmt.Errorf("failed to read the sale lines' invoice links: %w", err) + } + defer rows.Close() + out := map[uuid.UUID]uuid.UUID{} + for rows.Next() { + var saleLine, invLine uuid.UUID + if err := rows.Scan(&saleLine, &invLine); err != nil { + return nil, err + } + out[saleLine] = invLine + } + return out, rows.Err() +} + +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, + refund_method, reason, credit_memo_id, created_at + FROM pos_returns WHERE id = $1 AND `+middleware.Wall("", 2, 3), id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(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, &createdAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, httpx.NotFound("return not found") + } if err != nil { - return fmt.Errorf("failed to add POS tender: %w", err) + 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, + 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() +} + +func (r *PostgresRepository) ListReturns(ctx context.Context, f ReturnFilter, limit int) ([]Return, error) { + 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 AND created_at < $3 + AND `+middleware.Wall("", 4, 5)+` + ORDER BY created_at DESC, id DESC LIMIT $6`, f.RegisterID, start, end, + middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx), limit) + if err != nil { + return nil, fmt.Errorf("failed to list returns: %w", err) + } + defer rows.Close() + out := []Return{} + for rows.Next() { + 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, + &ret.OriginalSaleID, &ret.CustomerID, &ret.CashierID, &ret.Currency, &ret.SubtotalCents, &ret.TaxCents, + &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) + } + 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) 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) GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) { + var branch *uuid.UUID + err := r.ex(ctx).QueryRow(ctx, `SELECT COALESCE(l.branch_id, pr.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) + } + // The register is held to the caller's branch context (PR 83's round 7 + // predicate): a register of another branch is refused by name, never a + // missing row, and nothing of the act is written. + if branch != nil { + if held := branchctx.IDForQuery(ctx); held != nil && *held != *branch { + return nil, httpx.Forbidden( + fmt.Sprintf("register %s belongs to a different branch than the request", registerID)) + } + } + // A caller with no branch in context is held to the caller's grants + // (SEC-NOHEADER-1): the header caller keeps the equality check above, an + // administrator keeps its reach, and only a no header user is held. + if err := r.grants.CheckGrantsBranch(ctx, branch); err != nil { + if errors.Is(err, middleware.ErrPayloadBranchRefused) { + return nil, httpx.Forbidden( + fmt.Sprintf("register %s belongs to a different branch than the request", registerID)) + } + return nil, err + } + return branch, nil +} + +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 nil, fmt.Errorf("failed to get POS tenders: %w", err) + return nil, err } 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() +} - var tenders []POSTender +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, 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.KitComponent + if err := rows.Scan(&c.KitProductID, &c.ComponentProductID, &c.Quantity, &c.Position); err != nil { + return nil, err } - t.Amount = int64(amount*100.0 + 0.5) - tenders = append(tenders, t) + out[c.KitProductID.String()] = append(out[c.KitProductID.String()], c) } - return tenders, nil + return out, rows.Err() +} + +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 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 + } + out[c.Code] = c + } + 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) + 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 { + 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() +} + +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 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`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CatalogProduct + for rows.Next() { + var c CatalogProduct + if err := rows.Scan(&c.ProductID, &c.SKU, &c.Description, &c.UnitPriceCents, &c.UOM, &c.InStock); err != nil { + return nil, err + } + 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'), + COALESCE(po_required, FALSE) + FROM customers WHERE id = $1`, customerID).Scan(&f.Name, &limit, &f.Currency, &f.PORequired) + if errors.Is(err, pgx.ErrNoRows) { + return f, httpx.NotFound("no such customer") + } + if err != nil { + return f, err + } + 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 is_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 nil, fmt.Errorf("failed to search products: %w", err) + 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 +} + +// CounterCustomer is a customer as the counter's lookup shows it: who, the +// standing of the account in figures and in words, and the jobs a charge sale +// may name. The wall (customer_branches held to the caller's reach) is the +// lookup's own; a cashier never sees a customer outside it. +type CounterCustomer struct { + ID uuid.UUID `json:"id"` + AccountNumber string `json:"account_number"` + Name string `json:"name"` + POrequired bool `json:"po_required"` + CreditLimitCents *httpx.Cents `json:"credit_limit_cents"` + OpenReceivableCents httpx.Cents `json:"open_receivable_cents"` + AvailableCreditCents *httpx.Cents `json:"available_credit_cents"` + // Standing is the account's state in a word the screen puts in its own + // words: good, no_limit, no_credit (a limit of zero: the account is on + // hold, no credit given) or over_limit. + Standing string `json:"standing"` + Jobs []CounterJob `json:"jobs"` +} + +// CounterJob is one of the customer's open jobs. +type CounterJob struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` +} + +const counterCustomerCols = `c.id, c.account_number, c.name, COALESCE(c.po_required, FALSE), + 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 || '%') + AND `+customer.Wall("c", 2, 3)+` + ORDER BY c.name, c.id LIMIT $4`, + q, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx), limit) + if err != nil { + return nil, fmt.Errorf("failed to search customers: %w", err) } defer rows.Close() + out := []CounterCustomer{} + for rows.Next() { + var c CounterCustomer + if err := rows.Scan(&c.ID, &c.AccountNumber, &c.Name, &c.POrequired, &c.CreditLimitCents); err != nil { + return nil, err + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + rows.Close() + for i := range out { + if out[i].Jobs, err = r.customerJobs(ctx, out[i].ID); err != nil { + return nil, err + } + } + return out, nil +} - var results []QuickSearchResult +func (r *PostgresRepository) CounterCustomerByID(ctx context.Context, id uuid.UUID) (*CounterCustomer, bool, error) { + var c CounterCustomer + err := r.ex(ctx).QueryRow(ctx, `SELECT `+counterCustomerCols+` FROM customers c + WHERE c.id = $1 AND COALESCE(c.is_active, TRUE) AND `+customer.Wall("c", 2, 3), + id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)). + Scan(&c.ID, &c.AccountNumber, &c.Name, &c.POrequired, &c.CreditLimitCents) + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("failed to read the customer: %w", err) + } + if c.Jobs, err = r.customerJobs(ctx, c.ID); err != nil { + return nil, false, err + } + return &c, true, nil +} + +func (r *PostgresRepository) customerJobs(ctx context.Context, customerID uuid.UUID) ([]CounterJob, error) { + rows, err := r.ex(ctx).Query(ctx, `SELECT id, name FROM projects + WHERE customer_id = $1 AND lower(status) = 'active' ORDER BY name, id LIMIT 50`, customerID) + if err != nil { + return nil, fmt.Errorf("failed to read the customer's jobs: %w", err) + } + defer rows.Close() + out := []CounterJob{} 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 j CounterJob + if err := rows.Scan(&j.ID, &j.Name); err != nil { + return nil, err } - results = append(results, r) + out = append(out, j) + } + return out, rows.Err() +} + +func (r *PostgresRepository) SetSaleAccount(ctx context.Context, saleID uuid.UUID, customerID *uuid.UUID, currency string, po *string, jobID *uuid.UUID) error { + _, err := r.ex(ctx).Exec(ctx, `UPDATE pos_transactions SET customer_id = $2, currency = $3, customer_po = $4, job_id = $5, + updated_at = NOW(), revision = revision + 1 WHERE id = $1`, saleID, customerID, currency, po, jobID) + if err != nil { + return fmt.Errorf("failed to name the sale's customer: %w", mapWriteError(err)) } - return results, nil + return nil } diff --git a/core/internal/pos/repository_noheader_walls_test.go b/core/internal/pos/repository_noheader_walls_test.go index 87c78dcab..5fc464ab5 100644 --- a/core/internal/pos/repository_noheader_walls_test.go +++ b/core/internal/pos/repository_noheader_walls_test.go @@ -5,21 +5,23 @@ package pos_test import ( "context" + "errors" + "net/http" "testing" - "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/internal/pos" "github.com/gablelbm/gable/internal/testutil" "github.com/gablelbm/gable/pkg/branchctx" "github.com/google/uuid" ) -// The write side walls the service holds cannot answer for (SEC-NOHEADER-1): -// the service's holdSaleBranch refuses an act on another branch's sale -// before the write, so a wire probe cannot tell whether the update wall -// still matches underneath. This drives the repository itself: a caller -// granted only A completes B's sale, and the wall leaves the row alone. -func TestUpdateTransactionWallHoldsTheGrants(t *testing.T) { +// The repository's own walls (SEC-NOHEADER-1): the service's act hold refuses +// an act on another branch's sale by name before any read, so a wire probe +// cannot tell whether the sale read and lock still carry the wall +// underneath. This drives the repository itself: a caller granted only A +// neither reads nor locks B's sale, and an administrator does. +func TestSaleReadAndLockWallHoldTheGrants(t *testing.T) { db := testutil.RequireDB(t) ctx := context.Background() @@ -44,7 +46,7 @@ func TestUpdateTransactionWallHoldsTheGrants(t *testing.T) { } sale := uuid.New() if _, err := db.Pool.Exec(ctx, - `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id) VALUES ($1, $2, $3, 'OPEN', $4)`, + `INSERT INTO pos_transactions (id, register_id, cashier_id, status, branch_id, currency) VALUES ($1, $2, $3, 'OPEN', $4, 'USD')`, sale, reg, uuid.New(), branchB); err != nil { t.Fatalf("seed sale: %v", err) } @@ -56,28 +58,24 @@ func TestUpdateTransactionWallHoldsTheGrants(t *testing.T) { }) repo := pos.NewRepository(db) - now := time.Now() - completed := sale - done := pos.POSTransaction{ID: completed, Status: pos.TransactionStatusCompleted, CompletedAt: &now} - if err := repo.UpdateTransaction(branchctx.With(ctx, &branchctx.Context{UserSub: sub}), &done); err != nil { - t.Fatalf("walled update: %v", err) + held := branchctx.With(ctx, &branchctx.Context{UserSub: sub}) + notFound := func(err error) bool { + var he *httpx.Error + return errors.As(err, &he) && he.Status == http.StatusNotFound } - var status string - if err := db.Pool.QueryRow(ctx, `SELECT status FROM pos_transactions WHERE id = $1`, sale).Scan(&status); err != nil { - t.Fatal(err) + if _, err := repo.GetSale(held, sale); !notFound(err) { + t.Errorf("a caller granted only A read B's sale: %v, want the missing sale's 404", err) } - if status != "OPEN" { - t.Errorf("the walled update completed B's sale (status %s), want it left OPEN", status) + if err := repo.LockSale(held, sale); !notFound(err) { + t.Errorf("a caller granted only A locked B's sale: %v, want the missing sale's 404", err) } - // The control: a caller the wall does not hold completes it. - if err := repo.UpdateTransaction(branchctx.With(ctx, &branchctx.Context{UserSub: "admin", IsAdmin: true}), &done); err != nil { - t.Fatalf("admin update: %v", err) + // The control: a caller the wall does not hold reads and locks it. + admin := branchctx.With(ctx, &branchctx.Context{UserSub: "admin", IsAdmin: true}) + if got, err := repo.GetSale(admin, sale); err != nil || got.ID != sale { + t.Errorf("the admin read of B's sale: %v %v, want the sale", got, err) } - if err := db.Pool.QueryRow(ctx, `SELECT status FROM pos_transactions WHERE id = $1`, sale).Scan(&status); err != nil { - t.Fatal(err) - } - if status != "COMPLETED" { - t.Errorf("the admin update left the sale %s, want COMPLETED", status) + if err := repo.LockSale(admin, sale); err != nil { + t.Errorf("the admin lock of B's sale: %v", err) } } diff --git a/core/internal/pos/resolve.go b/core/internal/pos/resolve.go new file mode 100644 index 000000000..3f8a0f758 --- /dev/null +++ b/core/internal/pos/resolve.go @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +// The operator resolution of a refund claim that outlived its act (seventh +// review P2-3, eighth review P3-2, FROM-PRINCIPAL 41): a PENDING intent left +// by a lost verdict, a crashed process or a refused transaction names money +// that may have left at the terminal, and nothing else in the counter can +// tell the two apart or move it. Finance reads the terminal, records what it +// answers, and the resolution books through the very rules any refund books +// through: a recorded refund replays the act's own money transaction with +// every cap and lock, an explicit no refund marks the claim FAILED and moves +// nothing. Both legs write one audit row naming the person, the intent, the +// outcome and the reason. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +// ResolveIntentIn is a parsed resolution request. +type ResolveIntentIn struct { + Outcome string + RefundTxID string + ReconciliationReference string + Reason string +} + +// RefundIntentResolved is the resolution's answer: where the claim stands +// now and what the operator recorded. +type RefundIntentResolved struct { + ID uuid.UUID `json:"id"` + SaleID uuid.UUID `json:"sale_id"` + TenderID uuid.UUID `json:"tender_id"` + Act string `json:"act"` + State string `json:"state"` + Outcome string `json:"outcome"` + RefundTxID string `json:"refund_tx_id"` + ReconciliationRef string `json:"reconciliation_reference"` +} + +// resolveAuditOf builds the audit row both legs write: the person, the +// intent, the outcome and the reason, one row, whatever moved or not. +func resolveAuditOf(rec *RefundIntentRecord, in ResolveIntentIn, actor string) audit.Entry { + return audit.Entry{ + Action: "pos.refund_intent.resolved", EntityType: "pos_transaction", EntityID: rec.SaleID, UserID: actor, + Changes: map[string]any{ + "intent_id": rec.ID, "act": rec.Act, "tender_id": rec.TenderID, + "amount_cents": rec.AmountCents, "outcome": in.Outcome, "reason": in.Reason, + "refund_tx_id": in.RefundTxID, "reconciliation_reference": in.ReconciliationReference, + }} +} + +// IntentSaleBranch answers the branch of the claim's sale, for the resolution +// route's branch hold (r12 P1-2): a caller whose context carries no branch +// (multi_branch_enabled on, default_branch_required off, no X-Branch-Id +// header) reads the sale through a repository filter that never fires, so +// the route holds the sale's own branch to the caller's grants before either +// leg is entered. A caller whose context names a branch the sale is outside +// still finds the missing row here, as the sale read already answered. +func (s *Service) IntentSaleBranch(ctx context.Context, intentID uuid.UUID) (uuid.UUID, error) { + rec, err := s.repo.GetRefundIntent(ctx, intentID) + if err != nil { + return uuid.Nil, err + } + sale, err := s.repo.GetSale(ctx, rec.SaleID) + if err != nil { + return uuid.Nil, err + } + return sale.BranchID, nil +} + +// ResolveRefundIntent resolves one PENDING claim (FROM-PRINCIPAL 41): the +// operator records the terminal's actual outcome. A recorded refund (the +// gateway's own refund id) books through the refund's money transaction with +// every cap and lock; an explicit no refund with a reconciliation reference +// marks the claim FAILED and moves nothing. A claim that is not PENDING is +// refused: it has already been settled, one way or the other. +func (s *Service) ResolveRefundIntent(ctx context.Context, intentID uuid.UUID, in ResolveIntentIn, actor string) (*RefundIntentResolved, error) { + if s.ar == nil || s.inventory == nil { + return nil, errNotConfigured + } + rec, err := s.repo.GetRefundIntent(ctx, intentID) + if err != nil { + return nil, err + } + // The branch wall (ninth review P1-3, tenth review P2-2): the claim is + // settled only inside the caller's branch. The sale read carries the + // branch context the middleware settled, so a caller held to another + // branch finds the missing row (404) before anything of the claim is + // touched, on either leg; the legs' transactions take the same scoped + // sale row lock. + if _, err := s.repo.GetSale(ctx, rec.SaleID); err != nil { + return nil, err + } + if rec.State != "PENDING" { + return nil, conflict("intent_not_pending", + fmt.Sprintf("the refund claim is %s: only a PENDING claim is resolved", rec.State)) + } + // The claim's own terminal call (fourteenth review P1-1): a claim with no answer inside + // its in flight lease is live, not stranded. Releasing it (a no refund) + // would free a tender whose money may still move, and the terminal's late + // answer would then book beside a settled claim. The legs recheck the + // same lease under the claim's row lock. + if err := s.refuseUnansweredClaim(rec); err != nil { + return nil, err + } + // The outcome the operator records cannot contradict the claim's own + // record (ninth review P2-1, tenth review P3-1): the claim's refund id + // was written in its own committed write the moment the terminal + // answered, so a PENDING claim carrying one is a refund that moved. A no + // refund over it would release a tender whose money is gone, and a + // refund under a different id would book money the terminal never moved. + if in.Outcome == "no_refund" && rec.RefundTxID != "" { + return nil, conflict("refund_recorded", + fmt.Sprintf("the claim carries the terminal's refund id %s: the money moved, record the refund, not a no refund", rec.RefundTxID)) + } + if in.Outcome == "refund" && rec.RefundTxID != "" && in.RefundTxID != rec.RefundTxID { + return nil, conflict("refund_tx_id_conflict", + fmt.Sprintf("the claim carries the terminal's refund id %s, not %s: record the terminal's own id", rec.RefundTxID, in.RefundTxID)) + } + out := &RefundIntentResolved{ID: rec.ID, SaleID: rec.SaleID, TenderID: rec.TenderID, Act: rec.Act, + Outcome: in.Outcome, RefundTxID: in.RefundTxID, ReconciliationRef: in.ReconciliationReference} + switch in.Outcome { + case "no_refund": + // nothing moved at the terminal: the claim is released and the money + // is reconciled by the reference the operator recorded. The sale row + // lock (scoped to the caller's branch) comes first, so the wall holds + // under the release too. + out.State = "FAILED" + if err := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, rec.SaleID); err != nil { + return err + } + sale, err := s.repo.GetSale(ctx, rec.SaleID) + if err != nil { + return err + } + // A void's release can complete the sale's void (r12 P1-1), so + // the drawer's session is held here too: this leg takes the sale, + // then the session, then the claim, while the refund leg + // (runVoidResolutionTx) takes the sale, the claim, then the + // session. Both hold the sale row first, so the two legs + // serialize against each other and against every other act on the + // sale before they ever reach the session or the claim, and the + // close's FOR UPDATE waits for this leg's FOR SHARE: the + // completion and the close serialize whichever leg finishes the + // void. + if rec.Act == "void" && 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") + } + } + // the claim row, locked under the sale: a contender resolution + // of the same claim loses here with the race named, never beside + // this release (ninth review P2-2), and a live claim (its own + // terminal call unanswered inside its lease) is refused, never + // released (fourteenth review P1-1) + held, err := s.repo.LockRefundIntent(ctx, intentID) + if err != nil { + return err + } + if held.State != "PENDING" { + return conflict("intent_not_pending", + fmt.Sprintf("the refund claim is %s: only a PENDING claim is resolved", held.State)) + } + if err := s.settleUnansweredClaim(ctx, held); err != nil { + return err + } + if err := s.repo.FailRefundIntentByID(ctx, intentID); err != nil { + return err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, resolveAuditOf(rec, in, actor)); err != nil { + return fmt.Errorf("failed to write the resolution audit row: %w", err) + } + } + // The completion runs from this leg too (r12 P1-1): when the + // release emptied the sale's standing claims and some void claim + // already booked a tender's refund, the void completes here the + // same way the refund leg completes it, under the same sale and + // claim locks, so the order finance resolves the claims in never + // decides the end state. A void whose every claim settled without + // a booked refund keeps the sale completable by a fresh void, as + // a terminal that declined every card does. + if rec.Act == "void" && sale.Status == StatusCompleted { + pending, err := s.repo.PendingRefundIntentIDs(ctx, rec.SaleID) + if err != nil { + return err + } + if len(pending) == 0 { + done, err := s.repo.SaleHasVoidIntentDone(ctx, rec.SaleID) + if err != nil { + return err + } + if done { + tenders, err := s.repo.GetTenders(ctx, rec.SaleID) + if err != nil { + return err + } + fxAll, err := s.completeVoidFromResolution(ctx, sale, tenders, in.Reason, actor, rec.ID) + if err != nil { + return err + } + if fxAll != nil { + for _, ev := range fxAll.Events() { + if err := s.recordEvent(ctx, ev); err != nil { + return err + } + } + } + } + } + } + return nil + }); err != nil { + return nil, err + } + return out, nil + case "refund": + // the terminal moved the claim's money: the books take it through the + // act's own money transaction, with every cap and lock, and the + // recorded refund id on the claim + out.State = "DONE" + entry := resolveAuditOf(rec, in, actor) + switch rec.Act { + case "void": + // a refused replay writes no orphan row (tenth review P3-2): the + // claim and the stranded act's own rows already record the money + // that moved, and finance reconciling orphans must not see the + // same refund once per attempt + if _, err := s.runVoidResolutionTx(ctx, rec, in, entry, actor); err != nil { + return nil, err + } + return out, nil + case "payback": + // a stranded payback claim (its terminal call answered no + // verdict) books through the payback's own booking: the credit + // leaves the tender's payment under the terminal's own id + if _, err := s.runPaybackTx(ctx, rec.RefundIntent, in.RefundTxID, in.Reason, actor, &entry); err != nil { + return nil, err + } + return out, nil + case "return": + var stored storedReturnRequest + 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(), 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 + // the answer then, never the caps the winner's own booking + // trips (the money transaction rechecks under the sale lock) + if fresh, rerr := s.repo.GetRefundIntent(ctx, intentID); rerr == nil && fresh.State != "PENDING" { + return nil, conflict("intent_not_pending", + "the refund claim was settled by another resolution first") + } + return nil, err + } + st.actor = actor + if id, err := uuid.Parse(actor); err == nil { + st.cashierID = id + } + tenders, err := s.repo.GetTenders(ctx, rec.SaleID) + if err != nil { + return nil, err + } + var tenderCap int64 + for i := range tenders { + if tenders[i].ID == rec.TenderID { + tenderCap = int64(tenders[i].AmountCents) + } + } + // the refund row names the recorded refund, and the claim the + // replay completes with is the operator's own record; the replay + // writes no orphan row on refusal (tenth review P3-2) + st.in.GatewayTxID = in.RefundTxID + claim := &refundClaim{intent: rec.RefundIntent, tenderCap: tenderCap, + refundTxID: in.RefundTxID, resolveAudit: &entry, replay: true} + if _, err := s.postReturn(ctx, st, claim); err != nil { + return nil, err + } + return out, nil + default: + return nil, &httpx.Error{Status: http.StatusConflict, Code: httpx.CodeConflict, + Message: "the claim names no act the counter can replay"} + } + default: + return nil, invalid("outcome", "must be refund or no_refund") + } +} + +// refuseUnansweredClaim is the in flight lease's read side (fourteenth review P1-1): a +// claim whose own terminal call has not answered and whose lease has not run +// out is live, and nothing may settle it. +func (s *Service) refuseUnansweredClaim(rec *RefundIntentRecord) error { + if rec.AnsweredAt != nil || !s.now().Before(rec.AnswerDueAt) { + return nil + } + return conflict("refund_in_flight", + "the claim's own terminal call has not answered: resolve it once the terminal answers or its in flight lease expires") +} + +// settleUnansweredClaim is the lease's write side, run under the claim's row +// lock inside a settling transaction (fourteenth review P1-1): an answered claim settles +// as it is; a claim whose lease expired with no answer at all is marked +// UNKNOWN first, so its own record says the terminal never spoke; a claim +// still inside its lease settles nothing. +func (s *Service) settleUnansweredClaim(ctx context.Context, held *RefundIntentRecord) error { + if held.AnsweredAt != nil { + return nil + } + if err := s.refuseUnansweredClaim(held); err != nil { + return err + } + return s.repo.MarkRefundIntentUnansweredUnknown(ctx, held.ID) +} + +// runVoidResolutionTx books one tender's leg of a stranded void (ninth review +// P1-1, tenth review P1-2): the void's own money transaction reverses and +// refunds every card tender, so replaying it on the word of one claim booked a +// refund of every card of the sale, including the cards the terminal declined. +// The resolution accounts for each tender separately. It books only the +// resolved tender: the reversal of its application and the payout of the money +// the terminal moved, the claim completing DONE with the recorded refund id. +// The sale's void (the cash legs, the declined tenders' reversals, the invoice +// void, the stock, the sale's own VOIDED) completes in the same transaction +// only when no claim of the sale is PENDING anymore; until then the sale +// stands completed between resolutions, and the no refund leg completes it +// the same way when it empties the claims (r12 P1-1), so the order the claims +// are resolved in never decides the end state. A tender whose refund was +// declined, or recorded as a no refund, is never paid out: its application is +// reversed and the money stays unapplied on the customer's account, the +// credit the dealer owes, and no method may return the sale's goods again +// once a tender's refund is booked (the return routes read the same table). +func (s *Service) runVoidResolutionTx(ctx context.Context, rec *RefundIntentRecord, in ResolveIntentIn, entry audit.Entry, actor string) (*Sale, error) { + var out *Sale + err := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, rec.SaleID); err != nil { + return err + } + sale, err := s.repo.GetSale(ctx, rec.SaleID) + if err != nil { + return err + } + // The claim row, locked and read right after the sale lock (r12 + // P3-1): the loser of a same outcome race is told the race here (the + // claim the winner settled), never a state refusal the winner's own + // completion of the void would answer below. A claim still inside its + // in flight lease with no answer settles nothing (fourteenth review P1-1), and the + // refund booked must be the terminal's own id when the claim carries + // one. + held, err := s.repo.LockRefundIntent(ctx, rec.ID) + if err != nil { + return err + } + if held.State != "PENDING" { + return conflict("intent_not_pending", + fmt.Sprintf("the refund claim is %s: only a PENDING claim is resolved", held.State)) + } + if err := s.settleUnansweredClaim(ctx, held); err != nil { + return err + } + if held.RefundTxID != "" && held.RefundTxID != in.RefundTxID { + return conflict("refund_tx_id_conflict", + fmt.Sprintf("the claim carries the terminal's refund id %s, not %s: record the terminal's own id", + held.RefundTxID, in.RefundTxID)) + } + if sale.Status != StatusCompleted { + return httpx.InvalidStateTransition("only a completed sale is voided") + } + if has, err := s.repo.SaleHasReturns(ctx, rec.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.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") + } + tenders, err := s.repo.GetTenders(ctx, rec.SaleID) + if err != nil { + return err + } + var tender *Tender + for i := range tenders { + if tenders[i].ID == rec.TenderID { + tender = &tenders[i] + } + } + if tender == nil || tender.PaymentID == nil { + return conflict("tender_refunded", "the claim names no card tender the sale still holds") + } + date, err := s.repo.BranchLocalDate(ctx, sale.BranchID, s.now()) + if err != nil { + return err + } + // The resolved tender's leg: the claim completes with the recorded + // refund, its application reverses, and the money the terminal moved + // is paid out under the refund id the operator recorded. + if err := s.repo.CompleteRefundIntent(ctx, rec.SaleID, rec.TenderID, in.RefundTxID); err != nil { + return err + } + applicationID, err := cardApplicationOf(ctx, s, *tender.PaymentID) + if err != nil { + return err + } + var fxAll *account.Effects + fx, err := s.ar.Reverse(ctx, account.ReverseIn{ + ApplicationID: applicationID, Reason: in.Reason, Actor: actor, On: date}) + if err != nil { + return err + } + mergeEffects(&fxAll, fx) + _, rfx, err := s.ar.RefundPayment(ctx, account.RefundPaymentIn{ + PaymentID: *tender.PaymentID, AmountCents: rec.AmountCents, Reason: in.Reason, + GatewayRefundID: in.RefundTxID, Actor: actor, On: date}) + if err != nil { + return err + } + mergeEffects(&fxAll, rfx) + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, entry); err != nil { + return fmt.Errorf("failed to write the resolution audit row: %w", err) + } + } + // The completion: only when no claim of the sale still stands, so the + // void never completes on the word of one tender while another's + // outcome is unknown. The tenders whose refunds were never made keep + // their charge, held unapplied on the account as the credit owed. + pending, err := s.repo.PendingRefundIntentIDs(ctx, rec.SaleID) + if err != nil { + return err + } + if len(pending) == 0 { + cfx, err := s.completeVoidFromResolution(ctx, sale, tenders, in.Reason, actor, rec.ID) + if err != nil { + return err + } + mergeEffects(&fxAll, cfx) + } + if fxAll != nil { + for _, ev := range fxAll.Events() { + if err := s.recordEvent(ctx, ev); err != nil { + return err + } + } + } + out, err = s.GetSale(ctx, rec.SaleID) + return err + }) + if err != nil { + return nil, err + } + return out, nil +} + +// completeVoidFromResolution finishes a stranded void's sale once every claim +// of the sale is settled (r12 P1-1): the cash and check payments voided, +// every live card application reversed to unapplied credit, the invoice +// voided, the stock back, the sale VOIDED, its audit row and its event. Both +// legs of the resolution call it under the same sale and claim locks (the +// refund leg after its own tender's booking, the no refund leg after its +// release emptied the claims beside a booked refund), so whatever order the +// claims are resolved in, the end state is this one completion. +func (s *Service) completeVoidFromResolution(ctx context.Context, sale *Sale, tenders []Tender, reason, actor string, claimID uuid.UUID) (*account.Effects, error) { + var fxAll *account.Effects + date, err := s.repo.BranchLocalDate(ctx, sale.BranchID, s.now()) + if err != nil { + return nil, err + } + for _, t := range tendersInIDOrder(tenders) { + 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 nil, err + } + mergeEffects(&fxAll, fx) + case TenderCard: + applicationID, live, err := cardApplicationLive(ctx, s, *t.PaymentID) + if err != nil { + return nil, err + } + if !live { + continue + } + fx, err := s.ar.Reverse(ctx, account.ReverseIn{ + ApplicationID: applicationID, Reason: reason, Actor: actor, On: date}) + if err != nil { + return nil, err + } + mergeEffects(&fxAll, fx) + } + } + 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 nil, err + } + fx, err := s.ar.VoidInvoice(ctx, account.VoidInvoiceIn{ + InvoiceID: *sale.InvoiceID, EntryID: entryID, Reason: reason, Actor: actor, On: date}) + if err != nil { + return nil, err + } + mergeEffects(&fxAll, fx) + lines, err := s.repo.GetLines(ctx, sale.ID) + if err != nil { + return nil, err + } + 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 nil, err + } + } + if err := s.repo.VoidSale(ctx, sale.ID); err != nil { + return nil, err + } + if s.auditLog != nil { + if err := s.auditLog.Log(ctx, audit.Entry{ + Action: "pos.transaction.voided", EntityType: "pos_transaction", EntityID: sale.ID, UserID: actor, + Changes: map[string]any{ + "number": sale.Number, "total_cents": int64(sale.TotalCents), "reason": reason, + "register_id": sale.RegisterID, "customer_id": sale.CustomerID, + "resolved_by_claim": claimID, + }}); err != nil { + return nil, fmt.Errorf("failed to write the audit row: %w", err) + } + } + branch := sale.BranchID + if err := s.recordEvent(ctx, outbox.Event{Type: EventSaleVoided, EntityType: "pos_transaction", EntityID: sale.ID, + 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 nil, err + } + return fxAll, nil +} diff --git a/core/internal/pos/resolve_branch_grants_test.go b/core/internal/pos/resolve_branch_grants_test.go new file mode 100644 index 000000000..b9d5c3d77 --- /dev/null +++ b/core/internal/pos/resolve_branch_grants_test.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The resolution's branch hold for a caller with no context branch (r12 +// P1-2): with multi_branch_enabled on and default_branch_required off, a +// finance user held to branch B who sends no X-Branch-Id header gets a +// context with no branch, so the sale read's repository filter never fired +// and the user settled branch A's claim, freeing a tender whose money may +// already have moved. The route holds the claim's sale to the caller's +// grants through the branch guard serve passes the document and purchase +// order handlers: a sale outside the grants is a missing row (404) on both +// legs, nothing moves, and the goods' money leaves through the terminal +// exactly once in the end. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +func TestTheResolutionHoldsTheSaleToTheCallersGrantsWithoutAHeader(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") + setPosSetting(t, db, "default_branch_required", "false") + branchB := uuid.New() + mustExecB := func(sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + mustExecB(`INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', 'WL-POS-G', 'grants wall B')`, branchB) + mustExecB(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-gr-b', $1, TRUE, 'test')`, branchB) + t.Cleanup(func() { + setPosSetting(t, db, "multi_branch_enabled", "false") + setPosSetting(t, db, "default_branch_required", "true") + mustExecB(`DELETE FROM user_locations WHERE user_sub IN ('pos-gr-b', 'pos-gr-a')`) + mustExecB(`DELETE FROM locations WHERE id = $1`, branchB) + }) + + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded, nil, context.DeadlineExceeded, nil}} + f := newFixture(t, db, func(f *fixture) { + f.service = f.service.WithGateway(gw) + // the in branch finance user is granted the sale's own branch, so the + // same no header path passes for a caller the grants cover + mustExecB(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-gr-a', $1, TRUE, 'test')`, f.branchID) + f.cashierSub = "pos-gr-a" + }) + f.openTill(0) + // the B finance user sends no X-Branch-Id header at all: the middleware + // answers a context with no branch (the setting is off) + noHeader := []string{"X-Test-Role", "finance", "X-Test-Sub", "pos-gr-b"} + + // the return claim: a card return of a branch A sale timed out at the + // terminal (nothing moved) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID != "" { + t.Fatalf("the stranded return claim = (%s, %q), want PENDING carrying no refund id", state, refundTxID) + } + // both legs through the door: 404, the claim stands, nothing moves + if r := f.do("POST", "/api/v1/pos/refund-intents/"+intentID+"/resolve", map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-GB", "reason": "out of my grants", + }, noHeader...); r.status != http.StatusNotFound { + t.Errorf("another branch's no refund without a header = %d, want 404: %s", r.status, r.raw) + } + if r := f.do("POST", "/api/v1/pos/refund-intents/"+intentID+"/resolve", map[string]any{ + "outcome": "refund", "refund_tx_id": "ref-op-gb", "reason": "out of my grants", + }, noHeader...); r.status != http.StatusNotFound { + t.Errorf("another branch's refund without a header = %d, want 404: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID); got != 1 { + t.Errorf("the claim was settled through the grants wall: %d PENDING rows, want 1", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' AND changes->>'intent_id' = $1`, intentID); got != 0 { + t.Errorf("%d resolution audit rows through the grants wall, want 0", got) + } + // a finance user the sale's branch is granted resolves with no header the + // same way: the grants cover the sale, so the hold passes + if r := f.do("POST", "/api/v1/pos/refund-intents/"+intentID+"/resolve", map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-GA", "reason": "the terminal log shows no refund", + }, "X-Test-Role", "finance", "X-Test-Sub", "pos-gr-a"); r.status != http.StatusOK { + t.Fatalf("the granted finance user's no refund without a header = %d, want 200: %s", r.status, r.raw) + } + // the goods' money then leaves through the terminal exactly once: the + // later card return of the same tender makes its own single call + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("the later card return = %d, want 201: %s", r.status, r.raw) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (charged 599, refunded 599 exactly once)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 1 { + t.Errorf("%d refund rows, want 1 (one booked refund of the one tender)", got) + } + + // the void claim: the same wall on the void act + saleID2, body2 := f.saleOf("1", withToken(tender("card", 599))) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID2+"/void", + map[string]any{"reason": "stranded", "revision": rev(t, body2)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out void = %d, want 500: %s", r.status, r.raw) + } + intentID2, state2, refundTxID2, _ := pendingIntent(t, f, saleID2) + if state2 != "PENDING" || refundTxID2 != "" { + t.Fatalf("the stranded void claim = (%s, %q), want PENDING carrying no refund id", state2, refundTxID2) + } + if r := f.do("POST", "/api/v1/pos/refund-intents/"+intentID2+"/resolve", map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-GB2", "reason": "out of my grants", + }, noHeader...); r.status != http.StatusNotFound { + t.Errorf("another branch's void no refund without a header = %d, want 404: %s", r.status, r.raw) + } + if r := f.do("POST", "/api/v1/pos/refund-intents/"+intentID2+"/resolve", map[string]any{ + "outcome": "refund", "refund_tx_id": "ref-op-gb2", "reason": "out of my grants", + }, noHeader...); r.status != http.StatusNotFound { + t.Errorf("another branch's void refund without a header = %d, want 404: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID2); got != 1 { + t.Errorf("the void claim was settled through the grants wall: %d PENDING rows, want 1", got) + } + // the granted user releases it and a fresh void of the same sale reaches + // the terminal once more, exactly as its own act + if r := f.do("POST", "/api/v1/pos/refund-intents/"+intentID2+"/resolve", map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-GA2", "reason": "the terminal log shows no refund", + }, "X-Test-Role", "finance", "X-Test-Sub", "pos-gr-a"); r.status != http.StatusOK { + t.Fatalf("the granted finance user's void no refund = %d, want 200: %s", r.status, r.raw) + } + cur := f.getSale(t, saleID2) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID2+"/void", + map[string]any{"reason": "fresh void", "revision": num(t, cur, "revision")}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the fresh void = %d, want 200: %s", r.status, r.raw) + } + // two sales, each refunded at the terminal exactly once + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 4 { + t.Errorf("%d gateway refund calls, want 4 (each sale's stranded call and its one later act)", calls) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 2 { + t.Errorf("%d refund rows, want 2 (one booked refund per sale)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/resolve_branch_wall_test.go b/core/internal/pos/resolve_branch_wall_test.go new file mode 100644 index 000000000..96285afed --- /dev/null +++ b/core/internal/pos/resolve_branch_wall_test.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The resolution's branch wall (ninth review P1-3, tenth review P2-2): the +// route settles a claim only inside the caller's branch. The claim's sale is +// read through the branch context the middleware settled, so a finance user +// held to branch B finds branch A's claim a missing row (404) on both legs, +// nothing moves, and no audit row names them; the same claim resolves for a +// caller inside its own branch. + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/pos" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/middleware" + "github.com/google/uuid" +) + +func TestTheResolutionStaysInsideTheCallersBranch(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") + setPosSetting(t, db, "default_branch_required", "false") + branchB := uuid.New() + mustExecB := func(sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + mustExecB(`INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', 'WL-POS-R', 'resolve wall B')`, branchB) + mustExecB(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-res-b', $1, TRUE, 'test')`, branchB) + t.Cleanup(func() { + setPosSetting(t, db, "multi_branch_enabled", "false") + setPosSetting(t, db, "default_branch_required", "true") + mustExecB(`DELETE FROM user_locations WHERE user_sub IN ('pos-res-b', 'pos-res-a')`) + mustExecB(`DELETE FROM locations WHERE id = $1`, branchB) + }) + + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, db, func(f *fixture) { + f.service = f.service.WithGateway(gw) + // the in branch control resolves through a user the sale's branch is + // granted: the route holds the sale to the caller's grants (r12 P1-2) + mustExecB(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-res-a', $1, TRUE, 'test')`, f.branchID) + f.cashierSub = "pos-res-a" + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID != "" { + t.Fatalf("the stranded claim = (%s, %q), want PENDING carrying no refund id", state, refundTxID) + } + // the B finance user's headers: the branch middleware scopes them to B + scoped := []string{"X-Test-Role", "finance", "X-Test-Sub", "pos-res-b", "X-Branch-Id", branchB.String()} + + // branch A's sale reads as a missing row through the wall + if r := f.do("GET", "/api/v1/pos/transactions/"+saleID, nil, scoped...); r.status != http.StatusNotFound { + t.Errorf("another branch's sale = %d, want 404: %s", r.status, r.raw) + } + // the no refund leg: 404, the claim stands, nothing moved, no audit row + r := f.do("POST", "/api/v1/pos/refund-intents/"+intentID+"/resolve", map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-B", "reason": "out of my branch", + }, scoped...) + if r.status != http.StatusNotFound { + t.Errorf("another branch's no refund resolution = %d, want 404: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID); got != 1 { + t.Errorf("the claim was released through the wall: %d PENDING rows, want 1", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' AND changes->>'intent_id' = $1`, intentID); got != 0 { + t.Errorf("%d resolution audit rows through the wall, want 0", got) + } + // the refund leg: 404 the same way, with the operator's own id recorded + r = f.do("POST", "/api/v1/pos/refund-intents/"+intentID+"/resolve", map[string]any{ + "outcome": "refund", "refund_tx_id": "ref-op-b", "reason": "out of my branch", + }, scoped...) + if r.status != http.StatusNotFound { + t.Errorf("another branch's refund resolution = %d, want 404: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID); got != 1 { + t.Errorf("the claim was settled through the wall: %d PENDING rows, want 1", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (nothing moved through the wall)", got) + } + f.assertARInvariants(t) + // the same claim resolves for a caller inside its own branch: a finance + // user the sale's branch is granted, sending no header (the grants hold + // passes for the caller it covers) + rr := f.do("POST", "/api/v1/pos/refund-intents/"+intentID+"/resolve", map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-A", "reason": "the terminal log shows no refund", + }, "X-Test-Role", "finance", "X-Test-Sub", "pos-res-a") + if rr.status != http.StatusOK { + t.Fatalf("the in branch resolution = %d, want 200: %s", rr.status, rr.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'FAILED'`, intentID); got != 1 { + t.Errorf("the in branch resolution did not release the claim") + } + f.assertARInvariants(t) +} + +// RULE: the claim routes fail closed when no branch guard is wired (the +// fifteenth review's P3-2): a handler mounted without its wall refuses the +// resolve and the payback rather than settle a claim nothing holds, and the +// claim stands untouched with no audit row. Serve always wires the wall, so +// no shipped behaviour moves. +func TestTheClaimRoutesRefuseWhenNoBranchGuardIsWired(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, state, _, _ := pendingIntent(t, f, saleID) + if state != "PENDING" { + t.Fatalf("the stranded claim = %s, want PENDING", state) + } + // the handler mounted bare, the way a future mount that forgets the + // wall would be + mux := http.NewServeMux() + pos.NewHandler(f.service).RegisterRoutes(mux) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + do := func(path string, payload string) int { + req, _ := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "req-pos-bare-test") + req.Header.Set("X-Test-Role", "finance") + req.Header.Set("X-Test-Sub", mustUUID(t)) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + return res.StatusCode + } + if got := do("/api/v1/pos/refund-intents/"+intentID+"/resolve", + `{"outcome":"no_refund","reconciliation_reference":"R","reason":"w"}`); got != http.StatusServiceUnavailable { + t.Errorf("the resolve on a handler with no wall = %d, want 503 (the route fails closed, never settles a claim nothing holds)", got) + } + if got := do("/api/v1/pos/refund-intents/"+intentID+"/payback", + `{"reason":"w"}`); got != http.StatusServiceUnavailable { + t.Errorf("the payback on a handler with no wall = %d, want 503 (the route fails closed the same way)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID); got != 1 { + t.Errorf("the claim was settled on a handler with no wall: %d PENDING rows, want 1", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action LIKE 'pos.refund_intent%' AND changes->>'intent_id' = $1`, intentID); got != 0 { + t.Errorf("%d claim audit rows on a handler with no wall, want 0", got) + } + f.assertARInvariants(t) +} + +// RULE: the resolution route runs the guard serve passes it (ninth review +// P1-3's probe: the route dropped the counter's guard and ran none of its +// own but the role check). A counting guard stands in for the branch +// middleware compose and must run once per resolution. +func TestTheResolutionRouteRunsTheBranchGuardItIsGiven(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDB(t), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, _, _, _ := pendingIntent(t, f, saleID) + + var ran int32 + wall := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&ran, 1) + next.ServeHTTP(w, r) + }) + } + // the handler carries the branch wall the way serve wires it (a bare + // mount now refuses: the claim routes fail closed), and the branch + // middleware settles the caller's context the way serve's stack does + mux := http.NewServeMux() + pos.NewHandler(f.service).WithBranchGuard(middleware.NewBranchGuard(f.db)).RegisterRoutes(mux, nil, wall) + server := httptest.NewServer(roleClaims(middleware.NewBranchMiddleware(f.db).Handler(mux))) + t.Cleanup(server.Close) + req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/pos/refund-intents/"+intentID+"/resolve", + strings.NewReader(`{"outcome":"no_refund","reconciliation_reference":"R","reason":"w"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "req-pos-wall-test") + req.Header.Set("X-Test-Role", "finance") + req.Header.Set("X-Test-Sub", mustUUID(t)) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("the resolution behind the guard = %d, want 200", res.StatusCode) + } + if n := atomic.LoadInt32(&ran); n != 1 { + t.Errorf("the guard ran %d times on the resolution route, want 1 (the route must run the branch middleware serve passes)", n) + } +} diff --git a/core/internal/pos/resolve_conflict_test.go b/core/internal/pos/resolve_conflict_test.go new file mode 100644 index 000000000..3f647b45c --- /dev/null +++ b/core/internal/pos/resolve_conflict_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The outcome the operator records cannot contradict the claim's own record +// (ninth review P2-1, tenth review P3-1): the whole point of writing the +// terminal's refund id onto the claim in its own committed write is that a +// PENDING claim carrying one is a refund that moved. A no refund recorded +// over such a claim would release the tender with the money gone; a refund +// recorded under a different id would book the money under an id the terminal +// never issued. Both are refused with 409 naming the conflict, and the claim +// stands. + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// strandedReturnWithID strands a card return whose terminal refund moved and +// whose money transaction refused, answering the sale, its first line and +// the standing claim (PENDING, carrying the gateway's refund id). +func strandedReturnWithID(t *testing.T, f *fixture) (saleID, lineID, intentID, refundTxID string) { + t.Helper() + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID = body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status == http.StatusCreated { + t.Fatalf("the return committed under a failing event write: %s", r.raw) + } + var id, rtx string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT id::text, COALESCE(refund_tx_id, '') FROM pos_refund_intents WHERE sale_id = $1 AND state = 'PENDING'`, saleID). + Scan(&id, &rtx); err != nil { + t.Fatal(err) + } + if rtx == "" { + t.Fatal("the stranded claim carries no refund id: the terminal's answer never landed") + } + return saleID, lineID, id, rtx +} + +// RULE: a no refund recorded over a claim that carries the terminal's refund +// id is refused 409 naming the conflict: the money moved, so finance must +// record the refund, never a release. +func TestANoRefundOverARecordedRefundIdIsRefused(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_return.completed" + }) + f.openTill(0) + _, _, intentID, refundTxID := strandedReturnWithID(t, f) + r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-X", "reason": "operator error", + }, "finance") + if r.status != http.StatusConflict { + t.Fatalf("the no refund over the recorded id = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_recorded" { + t.Errorf("blockers = %v, want refund_recorded naming the conflict", blockers) + } + if !strings.Contains(string(r.raw), refundTxID) { + t.Errorf("the refusal does not name the recorded id %s: %s", refundTxID, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID); got != 1 { + t.Errorf("the claim was released over its own record: %d PENDING rows, want 1", got) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("card balance = %d, want 599 (the charge stands beside the moved refund)", got) + } + f.assertARInvariants(t) +} + +// RULE: a refund recorded under an id the terminal never issued, when the +// claim carries the terminal's own, is refused 409 naming the conflict; the +// claim then settles under its own recorded id. +func TestARefundIdThatDiffersFromTheRecordedOneIsRefused(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_return.completed" + }) + f.openTill(0) + _, _, intentID, refundTxID := strandedReturnWithID(t, f) + r := f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": "some-other-id", "reason": "operator typo", + }, "finance") + if r.status != http.StatusConflict { + t.Fatalf("the differing refund id = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_tx_id_conflict" { + t.Errorf("blockers = %v, want refund_tx_id_conflict naming the conflict", blockers) + } + if !strings.Contains(string(r.raw), refundTxID) { + t.Errorf("the refusal does not name the recorded id %s: %s", refundTxID, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'PENDING'`, intentID); got != 1 { + t.Errorf("the claim was settled under the wrong id: %d PENDING rows, want 1", got) + } + // the claim's own recorded id settles it + f.events.fail = "" + rr := f.resolveOn(t, intentID, map[string]any{ + "outcome": "refund", "refund_tx_id": refundTxID, "reason": "the terminal's own id", + }, "finance") + if rr.status != http.StatusOK { + t.Fatalf("the resolution under the recorded id = %d, want 200: %s", rr.status, rr.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'DONE' AND refund_tx_id = $2`, intentID, refundTxID); got != 1 { + t.Errorf("the claim did not settle DONE under its own recorded id") + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds WHERE gateway_refund_id = $1`, refundTxID); got != 1 { + t.Errorf("the refund row does not name the terminal's own id") + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/resolve_gate_test.go b/core/internal/pos/resolve_gate_test.go new file mode 100644 index 000000000..0ead13c98 --- /dev/null +++ b/core/internal/pos/resolve_gate_test.go @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The gates the round 13 review found unproven: the void that completes only +// beside a booked refund (a void whose every claim settles with no refund +// anywhere keeps the sale completable), the card refund SQL's completed sale +// filter (one sale's booked refund must not net another sale's figure), and +// the no refund leg's session hold against the close. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE (the completion gate): a void whose every claim settles without a +// booked refund keeps the sale COMPLETED (its invoice paid, its stock out), +// and a fresh void may be made: no card charge turns into credit with no +// refund anywhere. +func TestAVoidWhoseEveryClaimSettlesWithoutARefundKeepsTheSaleCompletable(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded, nil, nil}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body, _ := f.twoCardSaleOf(t, "1", 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "both lost", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the void with both cards timed out = %d, want 500: %s", r.status, r.raw) + } + // both claims settle as no refund: no refund was ever booked + for _, in := range f.saleIntents(t, saleID) { + if r := f.resolveOn(t, in["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-74", "reason": "terminal log empty", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the no refund of claim %v = %d: %s", in["id"], r.status, r.raw) + } + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Fatalf("sale status = %q, want completed (no claim booked a refund: the void did not complete)", got) + } + if got := f.scalar(`SELECT amount_open::text FROM invoices WHERE id = (SELECT invoice_id FROM pos_transactions WHERE id = $1)`, saleID); got != "0.00" { + t.Errorf("the invoice's open amount = %v, want 0.00 (paid: no application was reversed)", got) + } + if got := f.stock(); got != "99.0000/0.0000" { + t.Errorf("stock = %s, want 99 on hand (the sale's unit is still out)", got) + } + f.assertARInvariants(t) + // a fresh void goes through: the terminal refunds both cards this time + fresh := resp{body: f.getSale(t, saleID)} + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "fresh", "revision": rev(t, fresh)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the fresh void = %d, want 200: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status after the fresh void = %q, want voided", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock after the fresh void = %s, want 100 (the unit came back)", got) + } + f.assertARInvariants(t) +} + +// RULE (the completed sale filter): the card refunds booked against one +// sale's tenders net only that sale's figure. A second, untouched card sale +// of the same session keeps its whole CARD expectation while the first +// sale's void refund is booked, and after the first sale's void completes. +func TestOneSalesBookedRefundDoesNotNetAnotherSalesCardFigure(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge(), approvedCharge()}, + refundErrs: []error{nil, context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + sessionID := f.openTill(10000) + // the untouched sale: 599 on one card + f.saleOf("1", withToken(tender("card", 599))) + // the M10 shape: cash 199 with two card tenders of 200, one refunded at + // the terminal, one timed out + 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) + } + body := f.completeSale(saleID, tender("cash", 199), withToken(tender("card", 200)), withToken(tender("card", 200))) + if body.status != http.StatusOK { + t.Fatalf("complete = %d: %s", body.status, body.raw) + } + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "one moved, one unknown", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the void with one card timed out = %d, want 500: %s", r.status, r.raw) + } + var moved, unknown map[string]any + for _, in := range f.saleIntents(t, saleID) { + if in["refund_tx_id"] != "" { + moved = in + } else if in["state"] == "PENDING" { + unknown = in + } + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 999 { + t.Errorf("the card figure before any resolution = %d, want 999 (599 and both 200 tenders)", got) + } + if r := f.resolveOn(t, moved["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": moved["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the moved tender's resolution = %d: %s", r.status, r.raw) + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 799 { + t.Errorf("the card figure with one refund booked = %d, want 799 (999 less the 200 booked refund of the other sale's tender)", got) + } + // the void completes: the voided sale's refund must not follow its + // tender out of the session's figure + if r := f.resolveOn(t, unknown["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-75", "reason": "the terminal log shows no refund", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the unknown claim's no refund = %d: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Fatalf("sale status = %q, want voided", got) + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 599 { + t.Errorf("the card figure after the void completed = %d, want 599 (the untouched sale's whole card; the voided sale's booked refund dropped with its tender)", got) + } + if got := f.expectedOf(t, sessionID, "CASH"); got != 10000 { + t.Errorf("the cash figure after the void completed = %d, want 10000", got) + } + f.assertARInvariants(t) +} + +// RULE (the no refund leg's session hold): the leg that completes a void +// from a no refund resolution holds the drawer's session row, so the close +// and the completion serialize: either the leg commits first and the close +// then counts a settled session, or the close commits first, refuses on the +// standing claim and the leg answers session_closed. No other pair, and no +// deadlock. +func TestTheNoRefundLegAndTheCloseSerializeOnTheSession(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{nil, context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + till := f.openTill(10000) + saleID, body, _ := f.twoCardSaleOf(t, "1", 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "one moved, one unknown", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the void with one card timed out = %d, want 500: %s", r.status, r.raw) + } + var moved, standing map[string]any + for _, in := range f.saleIntents(t, saleID) { + if in["refund_tx_id"] != "" { + moved = in + } else if in["state"] == "PENDING" { + standing = in + } + } + if moved == nil || standing == nil { + t.Fatal("no moved and standing claim pair to resolve") + } + // the moved tender's refund books first, so the no refund leg that follows + // is the one that completes the void + if r := f.resolveOn(t, moved["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": moved["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the moved tender's resolution = %d: %s", r.status, r.raw) + } + legDone := make(chan resp, 1) + closeDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + legDone <- f.resolveOn(t, standing["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-76", "reason": "terminal log empty", + }, "finance") + }() + go func() { + closeDone <- f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + leg := awaitResp(t, legDone) + closed := awaitResp(t, closeDone) + <-fin + // The leg always completes (the close's refusal rolls back, so the + // session row it read stays open under the leg's share): the pair is + // either both 200 (the close waited for the leg and counted the settled + // session) or the leg 200 with the close 409 refund_in_flight (the close + // read the standing claim before the leg committed and rolled back). + if leg.status != http.StatusOK { + t.Fatalf("the leg = %d, want 200 (the close's refusal rolls back, the session stays open): %s", leg.status, leg.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the leg completed the void)", got) + } + switch closed.status { + case http.StatusOK: + case http.StatusConflict: + if _, blockers, _ := errorOf(t, closed); len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("the close's blockers = %v, want refund_in_flight", blockers) + } + // the settled session closes on the retry + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the close retry after the completed void = %d, want 200: %s", r.status, r.raw) + } + default: + t.Fatalf("the close = %d, want 200 or 409: %s", closed.status, closed.raw) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/resolve_inflight_test.go b/core/internal/pos/resolve_inflight_test.go new file mode 100644 index 000000000..caf8dadc9 --- /dev/null +++ b/core/internal/pos/resolve_inflight_test.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The claim's own terminal call (the fourteenth review's P1-1): a resolution that +// lands while the act's refund is still on the wire releases a tender whose +// money has not answered yet, the terminal then refunds it, and a later act +// refunds the same tender again. A live claim holds the resolve and the +// close until its call answers or its in flight lease expires. + +import ( + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// RULE: a no refund resolution of a claim whose own terminal call is parked +// on the wire is refused with 409 refund_in_flight; the act's own answer then +// books the money once, and a fresh card return of the same tender moves +// nothing at the terminal (one refund of one tender, ever). +func TestAResolutionCannotReleaseAClaimWhoseActIsStillAtTheTerminal(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + parked, release := parkRefund(t, gw) + done := make(chan resp, 1) + fin := make(chan struct{}) + go func() { defer close(fin); done <- f.cardReturnOn(t, saleID, lineID, "1") }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + id, _, _, _ := pendingIntent(t, f, saleID) + r := f.resolveOn(t, id, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "R-1", "reason": "log empty"}, "finance") + if r.status != http.StatusConflict { + t.Fatalf("the resolve of a live claim = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the claim's own call is on the wire)", blockers) + } + f.assertARInvariants(t) + release() + if rr := awaitResp(t, done); rr.status != http.StatusCreated { + t.Fatalf("the parked card return = %d: %s", rr.status, rr.raw) + } + f.assertARInvariants(t) + // the tender was refunded once at the terminal: a fresh card return of the + // same unit never reaches the gateway again + if rr := f.cardReturnOn(t, saleID, lineID, "1"); rr.status != http.StatusConflict { + t.Fatalf("the second card return of the refunded tender = %d, want 409: %s", rr.status, rr.raw) + } + gw.mu.Lock() + refunds := len(gw.refunds) + cents := int64(0) + for _, c := range gw.refundCents { + cents += c + } + gw.mu.Unlock() + if refunds != 1 || cents != 599 { + t.Fatalf("the terminal refunded %d times for %d cents, want 1 for 599 (one refund of one tender)", refunds, cents) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (599 charged, 599 refunded once)", got) + } + f.assertARInvariants(t) +} + +// RULE: the same lease holds the drawer: while the void's own terminal call +// is on the wire the claim is not resolvable and the close refuses 409 +// refund_in_flight; once the call answers the void completes and the close +// goes through. +func TestTheCloseHoldsWhileAVoidsTerminalCallIsOnTheWire(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + till := f.openTill(10000) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + parked, release := parkRefund(t, gw) + done := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + done <- f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "parked", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + t.Cleanup(func() { release(); <-fin }) + awaitParked(t, parked) + id, _, _, _ := pendingIntent(t, f, saleID) + if r := f.resolveOn(t, id, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "R-2", "reason": "log empty"}, "finance"); r.status != http.StatusConflict { + t.Fatalf("the resolve of a live void claim = %d, want 409: %s", r.status, r.raw) + } + // the drawer cannot close over a claim whose terminal call may still move + // money after the counting + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusConflict { + t.Fatalf("the close under a live void claim = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t))) + if len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight", blockers) + } + f.assertARInvariants(t) + release() + if rr := awaitResp(t, done); rr.status != http.StatusOK { + t.Fatalf("the parked void = %d: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the void's own flow completed)", got) + } + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the close after the void completed = %d: %s", r.status, r.raw) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 1 { + t.Fatalf("%d gateway refunds, want 1", refunds) + } + f.assertARInvariants(t) +} + +// RULE: once the in flight lease expires with no answer (a crashed act whose +// terminal call never returned), the claim is resolvable again and records +// that the terminal never answered: the claim is marked UNKNOWN and settles +// as the operator recorded. +func TestAnUnansweredClaimBecomesResolvableWhenItsLeaseExpires(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, _ := f.saleOf("1", withToken(tender("card", 599))) + // what a crashed act leaves: the PENDING claim, its call never returned, + // nothing booked and no answer stamped + mustExec(t, f.db, ` + INSERT INTO pos_refund_intents (id, sale_id, tender_id, gateway_tx_id, amount, act, state, request) + SELECT $1, t.id, d.id, d.gateway_tx_id, d.amount, 'return', 'PENDING', '{}'::jsonb + FROM pos_transactions t JOIN pos_tenders d ON d.transaction_id = t.id + WHERE t.id = $2 AND d.method = 'CARD'`, uuid.New(), saleID) + id, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID != "" { + t.Fatalf("the crashed claim = (%s, %q), want PENDING with no refund id", state, refundTxID) + } + // the lease has not expired: the resolve is refused + if r := f.resolveOn(t, id, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "R-3", "reason": "log empty"}, "finance"); r.status != http.StatusConflict { + t.Fatalf("the resolve inside the lease = %d, want 409: %s", r.status, r.raw) + } + // the lease expires with the terminal never answering + mustExec(t, f.db, `UPDATE pos_refund_intents SET answer_due_at = NOW() - INTERVAL '1 second' WHERE id = $1`, id) + if r := f.resolveOn(t, id, map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "R-3", "reason": "log empty"}, "finance"); r.status != http.StatusOK { + t.Fatalf("the resolve after the lease = %d, want 200: %s", r.status, r.raw) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'FAILED' AND answer_state = 'UNKNOWN'`, id); got != 1 { + t.Errorf("the expired claim is not marked UNKNOWN: %d rows", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/resolve_race_test.go b/core/internal/pos/resolve_race_test.go new file mode 100644 index 000000000..36cc0aab0 --- /dev/null +++ b/core/internal/pos/resolve_race_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The resolution race (ninth review P2-2, tenth review P2-1): two operators +// resolving one claim concurrently with opposite outcomes both answered 200, +// because the claim was read PENDING outside the transaction and every state +// change ignored its rows affected. The claim row is locked inside the +// resolution's transaction and every state change refuses 409 +// intent_not_pending when it settles no row, so of any number of contenders +// exactly one wins, the books move at most once, and the losers are told the +// claim left PENDING. + +import ( + "context" + "net/http" + "sync" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE: three concurrent resolutions of one stranded return claim (two +// refunds with different recorded ids and a no refund): exactly one answers +// 200, the others 409 intent_not_pending naming the race, the claim's final +// state matches the winner, the books moved at most once, and the terminal +// was asked for nothing (the resolutions make no gateway call). +func TestThreeConcurrentResolutionsOfOneClaimSettleItOnce(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out card return = %d, want 500: %s", r.status, r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID != "" { + t.Fatalf("the stranded claim = (%s, %q)", state, refundTxID) + } + + bodies := []map[string]any{ + {"outcome": "refund", "refund_tx_id": "ref-op-1", "reason": "first operator read the terminal"}, + {"outcome": "refund", "refund_tx_id": "ref-op-2", "reason": "second operator read the terminal"}, + {"outcome": "no_refund", "reconciliation_reference": "REC-RACE", "reason": "third operator read the terminal"}, + } + res := make(chan resp, len(bodies)) + var wg sync.WaitGroup + for _, b := range bodies { + wg.Add(1) + go func(b map[string]any) { + defer wg.Done() + res <- f.resolveOn(t, intentID, b, "finance") + }(b) + } + wg.Wait() + close(res) + var ok, refused, other int + for r := range res { + switch { + case r.status == http.StatusOK: + ok++ + case r.status == http.StatusConflict: + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "intent_not_pending" { + t.Errorf("a losing resolution names %v, want intent_not_pending: %s", blockers, r.raw) + other++ + } else { + refused++ + } + default: + other++ + t.Errorf("a contender answered %d: %s", r.status, r.raw) + } + } + if ok != 1 || refused != len(bodies)-1 || other != 0 { + t.Fatalf("of %d contenders, %d answered 200, %d answered 409 intent_not_pending and %d answered otherwise: want exactly one 200", len(bodies), ok, refused, other) + } + // the claim's final state is one settled state, whichever outcome won + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state IN ('DONE', 'FAILED')`, intentID); got != 1 { + t.Errorf("the claim is not settled once: %d rows", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' AND changes->>'intent_id' = $1`, intentID); got != 1 { + t.Errorf("%d resolution audit rows, want 1 (the winners' record alone)", got) + } + // the books moved at most once and the ties hold whichever way it went + done := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'DONE'`, intentID) + refunds := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`) + if done != refunds { + t.Errorf("%d booked refunds beside a claim settled DONE %d times: the money follows the winning outcome alone", refunds, done) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1 (the stranded act's own call: the resolutions ask the terminal for nothing)", calls) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/resolve_void_race_test.go b/core/internal/pos/resolve_void_race_test.go new file mode 100644 index 000000000..88c1349c7 --- /dev/null +++ b/core/internal/pos/resolve_void_race_test.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The same outcome race on a void claim (r12 P3-1): two operators recording +// the same refund of one stranded void claim concurrently. Exactly one books +// it (the sale voids), and the loser is told the race (409 +// intent_not_pending, the claim already settled), never a misleading state +// refusal the winner's own completion caused: the claim row is locked and +// read right after the sale lock, before any guard of the booking. + +import ( + "context" + "net/http" + "sync" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE (r12 P3-1): of two concurrent refund resolutions of one stranded void +// claim recording the same terminal refund, exactly one answers 200 and +// voids the sale; the loser answers 409 intent_not_pending naming the race, +// not invalid_state_transition (the winner's VOIDED sale seen before the +// claim's own settled row). The books move once and the terminal is asked +// for nothing by either resolution. +func TestTwoConcurrentRefundResolutionsOfAVoidClaimNameTheRace(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}, + refundErrs: []error{context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body := f.saleOf("1", withToken(tender("card", 599))) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "stranded void", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out void = %d, want 500: %s", r.status, r.raw) + } + intentID, state, refundTxID, _ := pendingIntent(t, f, saleID) + if state != "PENDING" || refundTxID != "" { + t.Fatalf("the stranded void claim = (%s, %q), want PENDING carrying no refund id", state, refundTxID) + } + + same := map[string]any{"outcome": "refund", "refund_tx_id": "ref-op-same", "reason": "both operators read the terminal"} + res := make(chan resp, 2) + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + res <- f.resolveOn(t, intentID, same, "finance") + }() + } + wg.Wait() + close(res) + var ok, named int + for r := range res { + switch { + case r.status == http.StatusOK: + ok++ + case r.status == http.StatusConflict: + code, blockers, _ := errorOf(t, r) + if code != "conflict" || len(blockers) == 0 || blockers[0] != "intent_not_pending" { + t.Errorf("the losing void resolution answers (%s, %v), want conflict intent_not_pending: %s", code, blockers, r.raw) + } else { + named++ + } + default: + t.Errorf("a contender answered %d: %s", r.status, r.raw) + } + } + if ok != 1 || named != 1 { + t.Fatalf("of 2 contenders, %d answered 200 and %d named the race: want one of each", ok, named) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the winning resolution completed the void)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (the terminal's refund booked exactly once)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 1 { + t.Errorf("%d refund rows, want 1", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 1 { + t.Errorf("%d gateway refund calls, want 1 (the stranded void's own call)", calls) + } + f.assertARInvariants(t) +} + +// RULE (r11 P3-1): three concurrent resolutions over the two claims of a +// stranded two tender void (the refunded tender's claim recorded once, the +// timed out tender's claim fought over by a refund and a no refund): exactly +// one contender answers 200 per claim, the loser names the race (409 +// intent_not_pending), the books move once per settled refund, and the void +// completes exactly once whichever leg empties the claims, so the contender +// that wins the last claim never decides the end state twice. The +// resolutions reach the terminal for nothing: the void's own two calls are +// all the gateway sees. +func TestThreeConcurrentResolutionsOfASplitVoidsClaimsSettleEachOnce(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{nil, context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body, _ := f.twoCardSaleOf(t, "1", 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "split void", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the timed out void = %d, want 500: %s", r.status, r.raw) + } + // the terminal refunded the first claim and timed out on the second, so + // one claim carries its refund id and the other stands uncalled + var withID, withoutID map[string]any + for _, in := range f.saleIntents(t, saleID) { + if in["state"] != "PENDING" { + continue + } + if in["refund_tx_id"] != "" { + withID = in + } else { + withoutID = in + } + } + if withID == nil || withoutID == nil { + t.Fatalf("the standing claims = %v, want one carrying the refund id and one without", f.saleIntents(t, saleID)) + } + + // three contenders: the refunded claim recorded once, the timed out claim + // fought over by a refund and a no refund + contenders := []struct { + intentID string + body map[string]any + }{ + {withID["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": withID["refund_tx_id"].(string), "reason": "the terminal refunded this card"}}, + {withoutID["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": "ref-op-2", "reason": "the terminal log shows this refund"}}, + {withoutID["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-RACE", "reason": "the terminal log shows no refund"}}, + } + asked := map[string]int{} + for _, c := range contenders { + asked[c.intentID]++ + } + type answer struct { + intentID string + r resp + } + res := make(chan answer, len(contenders)) + var wg sync.WaitGroup + for _, c := range contenders { + wg.Add(1) + go func(c struct { + intentID string + body map[string]any + }) { + defer wg.Done() + res <- answer{c.intentID, f.resolveOn(t, c.intentID, c.body, "finance")} + }(c) + } + wg.Wait() + close(res) + counts := map[string]*struct{ ok, refused, other int }{} + for a := range res { + c, ok := counts[a.intentID] + if !ok { + c = &struct{ ok, refused, other int }{} + counts[a.intentID] = c + } + switch { + case a.r.status == http.StatusOK: + c.ok++ + case a.r.status == http.StatusConflict: + _, blockers, _ := errorOf(t, a.r) + if len(blockers) == 0 || blockers[0] != "intent_not_pending" { + t.Errorf("a losing resolution names %v, want intent_not_pending: %s", blockers, a.r.raw) + c.other++ + } else { + c.refused++ + } + default: + c.other++ + t.Errorf("a contender answered %d: %s", a.r.status, a.r.raw) + } + } + // exactly one 200 per claim, the loser of the fought claim names the race + for intentID, n := range asked { + c := counts[intentID] + if c == nil || c.ok != 1 || c.other != 0 || c.ok+c.refused+c.other != n { + t.Fatalf("a claim settled %d x 200, %d x 409 intent_not_pending and %d otherwise of %d contenders: want exactly one 200 per claim", + c.ok, c.refused, c.other, n) + } + } + // the books moved once: every booked refund names a claim settled DONE + done := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE sale_id = $1 AND state = 'DONE'`, saleID) + refunds := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`) + if done != refunds { + t.Errorf("%d booked refunds beside %d claims settled DONE: the money follows the winning outcome alone", refunds, done) + } + if done < 1 || done > 2 { + t.Errorf("%d claims settled DONE, want 1 or 2 (the refunded claim always, the fought claim per its winner)", done) + } + // the void completed exactly once, whichever leg emptied the claims + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the completion ran from the leg that emptied the claims)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.transaction.voided' AND entity_id = $1`, saleID); got != 1 { + t.Errorf("%d voided audit rows, want 1 (the completion is one act whatever resolved last)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.resolved' + AND changes->>'intent_id' = ANY($1::text[])`, []string{withID["id"].(string), withoutID["id"].(string)}); got != 2 { + t.Errorf("%d resolution audit rows, want 2 (the winners' records alone)", got) + } + // which tender carries the terminal's refund follows the tenders' own + // order, so the booked total is read from the claims: the recorded claim + // always, the fought claim when its refund won + booked := withID["amount"].(int64) + if countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'DONE'`, withoutID["id"].(string)) == 1 { + booked += withoutID["amount"].(int64) + } + if got := f.accountBalance("1010"); got != 599-booked { + t.Errorf("card balance = %d, want %d (599 charged, %d booked back: the recorded claim always, the fought tender per its winner)", got, 599-booked, booked) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100 (the void restocked the unit once)", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 2 { + t.Errorf("%d gateway refund calls, want 2 (the void's own calls: no resolution reaches the terminal)", calls) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/return_cost_test.go b/core/internal/pos/return_cost_test.go new file mode 100644 index 000000000..c7c127985 --- /dev/null +++ b/core/internal/pos/return_cost_test.go @@ -0,0 +1,133 @@ +// 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" + "github.com/google/uuid" +) + +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) +} + +// RULE (fourth review P1-2): the return's link to its invoice line follows +// the stored invoice line id, never a position. A cart line removed before +// completion leaves the sale's positions gapped while the invoice numbers its +// own lines from zero, so a position match linked (and costed) the wrong +// line, or none: the return relieved COGS at today's average and its memo +// line named no invoice line. +func TestAReturnAfterARemovedLineLinksItsOwnInvoiceLine(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + // A second product, dearer than the first, so a wrong link shows in the + // money and not only in the id. + nails := uuid.New() + mustExec(t, f.db, `INSERT INTO products (id, sku, description, uom_primary, base_price, average_unit_cost) VALUES ($1, $2, 'nails', 'PCS', 5.50, 12.00)`, + nails, "NAIL-"+uuid.NewString()[:8]) + t.Cleanup(func() { mustExec(t, f.db, `DELETE FROM products WHERE id = $1`, nails) }) + + saleID := f.startSale(&f.customerID) + add := func(product uuid.UUID) { + if r := f.addLine(saleID, map[string]any{"product_id": product.String(), "quantity": "2"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + } + add(f.productID) // position 0 + add(nails) // position 1, removed below + add(f.productID) // position 2: the line the return names + lines := f.getSale(t, saleID)["lines"].([]any) + studs2 := lines[2].(map[string]any)["id"].(string) + removed := lines[1].(map[string]any)["id"].(string) + if r := f.do("DELETE", "/api/v1/pos/transactions/"+saleID+"/items/"+removed, nil, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("remove line = %d: %s", r.status, r.raw) + } + // total: 4 x 5.50 = 2200 + 195 tax = 2395 + if r := f.completeSale(saleID, tender("cash", 2395)); r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + // the average of both products moves after the sale + mustExec(t, f.db, `UPDATE products SET average_unit_cost = 9.00 WHERE id IN ($1, $2)`, f.productID, nails) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE products SET average_unit_cost = 3.25 WHERE id = $1`, f.productID) + mustExec(t, f.db, `UPDATE products SET average_unit_cost = 12.00 WHERE id = $1`, nails) + }) + r := f.returnOn(t, saleID, studs2, "2") + if r.status != http.StatusCreated { + t.Fatalf("return = %d: %s", r.status, r.raw) + } + memoID := str(t, r.body, "credit_memo_id") + // the entry relieves at the sale's own cost (never the nails line at + // 12.00 through a position match, never today's 9.00 average) + _, legs := f.legsFor(memoID) + if legs["1030"].debit != 650 || legs["5010"].credit != 650 { + t.Errorf("restock legs = 1030 %+v, 5010 %+v, want 650 each (2 at the 3.25 the sale relieved)", legs["1030"], legs["5010"]) + } + // the stored link: the sale line's own invoice line, and the memo line + // names it + var invLine string + var cost int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT l.invoice_line_id::text, ROUND(il.unit_cost * 10000) FROM pos_line_items l + JOIN invoice_lines il ON il.id = l.invoice_line_id WHERE l.id = $1`, studs2).Scan(&invLine, &cost); err != nil { + t.Fatalf("the sale line carries no stored invoice line link: %v", err) + } + 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 line's own %s", memoInvLine, invLine) + } + if got := f.stock(); got != "98.0000/0.0000" { + t.Errorf("stock = %s, want 98/0 (the 2 back on hand)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/returns.go b/core/internal/pos/returns.go index 07ed52fc4..d76a7bef7 100644 --- a/core/internal/pos/returns.go +++ b/core/internal/pos/returns.go @@ -5,293 +5,1335 @@ package pos import ( "context" - "errors" + "encoding/json" "fmt" - "math" - "strings" - "time" + "sort" - "github.com/gablelbm/gable/internal/inventory" - "github.com/gablelbm/gable/internal/payment" + "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/audit" - "github.com/gablelbm/gable/pkg/branchctx" - "github.com/gablelbm/gable/pkg/middleware" + "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"` +// 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 +} - Lines []POSReturnLine `json:"lines,omitempty"` +// 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 } -// 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"` +// 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 + // 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 + // chargeCodeID and lineType carry a linked charge line's own shape. + chargeCodeID *uuid.UUID + lineType string } -// 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 +// returnState is a priced, guarded return ready for its money transaction: +// every figure the transaction restates under the sale row lock lives here, +// so the operator resolution replays a stranded claim's booking through the +// same state and the same rules. +type returnState struct { + in *ReturnIn + cashierID uuid.UUID + customerID *uuid.UUID + branchID *uuid.UUID + currency string + actor string + saleLines []salesdoc.Line + byID map[uuid.UUID]salesdoc.Line + legacyBearer map[uuid.UUID]uuid.UUID + refs map[string]salesdoc.ProductRef + priced []returnLinePriced + saleTaxCents int64 + saleTaxable int64 + // the restated figures: read before the transaction, restated under the + // sale row lock inside it, and written from + taxAlreadyBack int64 + backByLine map[uuid.UUID]int64 + completesTaxable bool + subtotal int64 + taxCents int64 + total int64 + taxRate *string } -// 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"` +// refundClaim is the committed intent a card refund stands on: what it +// claimed, what the tender kept, and the gateway's own refund id once the +// terminal answered. resolveAudit, when set, is written inside the money +// transaction: the operator resolution's own record, rolling back with the +// booking it names. replay marks the operator resolution's replay of a +// stranded claim: its money moved long before the act stranded, so a refused +// replay writes no orphan row (the claim and the act's own rows already +// record it; tenth review P3-2). +type refundClaim struct { + intent RefundIntent + tenderCap int64 + refundTxID string + resolveAudit *audit.Entry + replay bool } -// 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. +// priceReturn resolves the return's customer, prices and guards its lines and +// 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. // -// 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") +// 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 + } + // The customer: named, the original sale's, or the walk-in. + customerID := in.CustomerID + var original *Sale + if in.OriginalSaleID != nil { + 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())) + } + // Every refund method but the card's own consults the intent table + // before the return prices (ninth review P1-2): a card claim PENDING + // at the terminal may already have paid these goods back, so neither + // the drawer nor the account pays them a second time. The money + // transaction rechecks it under the sale row lock. A tender the sale's + // void already refunded (a partly resolved void) blocks every method + // the same way. + if in.RefundMethod != RefundCard { + if pending, err := s.repo.SaleHasPendingRefundIntent(ctx, original.ID); err != nil { + return nil, err + } else if pending { + return nil, conflict("refund_in_flight", + "a card refund of this sale is still in flight: settle it before refunding by any other method") + } + if doneVoid, err := s.repo.SaleHasVoidIntentDone(ctx, original.ID); err != nil { + return nil, err + } else if doneVoid { + return nil, conflict("void_refund_recorded", + "this sale's void already refunded a card tender at the terminal: reconcile the void before returning goods against this sale") + } + } + // A return that names the sale names its lines (third review P1-1): + // a product line at a client price would stand outside every cap, so + // every line of a named sale carries the sale line it returns. Only + // a return that names no sale stands free. + for i := range in.Lines { + if in.Lines[i].SaleLineID == nil { + return nil, invalid(fmt.Sprintf("lines[%d].line_id", i), + "a return that names the original sale names the line of it: every line carries line_id") + } + } + // 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") + } } - if len(req.Lines) == 0 { - return nil, fmt.Errorf("a return needs at least one line") + isWalkIn := false + if customerID == nil { + walkInID, _, err := s.repo.WalkInCustomer(ctx) + if err != nil { + return nil, err + } + customerID = &walkInID + isWalkIn = true + } else if walkInID, _, err := s.repo.WalkInCustomer(ctx); err != nil { + return nil, err + } else if walkInID == *customerID { + // naming the walk-in customer's id explicitly is still the walk-in + // (third review P3-D): the credit a return leaves open cannot sit + // on it whatever way it was named + isWalkIn = true } - method := ReturnRefundMethod(strings.ToUpper(strings.TrimSpace(req.RefundMethod))) - if method == "" { - method = RefundCash + // 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") } - switch method { - case RefundCash, RefundCard, RefundAccount: - default: - return nil, fmt.Errorf("invalid refund_method %q (want CASH, CARD, or ACCOUNT)", req.RefundMethod) + 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 } - if method == RefundAccount && req.CustomerID == nil { - return nil, fmt.Errorf("ACCOUNT refund requires a customer on the return") + // 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 { + 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) - } - 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 - } - uom := l.UOM - if uom == "" { - uom = "EA" - } - 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 - } - if subtotal <= 0 { - return nil, fmt.Errorf("return total must be positive") + byID := map[uuid.UUID]salesdoc.Line{} + for i := range saleLines { + byID[saleLines[i].ID] = saleLines[i] } - - // Resolve the register's branch (for tax and stamping); best-effort. The - // caller's branch context is held against it the way the sale does, so a - // request for another branch's register is refused before anything is - // written, and a caller with no branch in context is held to the - // caller's grants (SEC-NOHEADER-1). - branchID, _ := s.repo.GetRegisterBranch(ctx, req.RegisterID) - if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && branchID != nil && *reqBranch != *branchID { - return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} - } - if err := s.grants.CheckGrantsBranch(ctx, branchID); err != nil { - if errors.Is(err, middleware.ErrPayloadBranchRefused) { - return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} + // The bearer of the legacy (unlinked) returns of each product: its first + // line by position, stable across requests (sixth review P1-1 and P3-2). + // A legacy return names no sale line, so its quantity and cents are + // attributed here once, to one line of the product, instead of counting + // against every line of it; whichever line bears them, the sale's goods + // bound its refunds. + legacyBearer := map[uuid.UUID]uuid.UUID{} + for i := range saleLines { + if saleLines[i].ProductID != nil { + if _, ok := legacyBearer[*saleLines[i].ProductID]; !ok { + legacyBearer[*saleLines[i].ProductID] = saleLines[i].ID + } + } + } + // First pass: fill each line from the sale line it names, so the product + // lookup covers the resolved products too. One request names one sale + // line at most once (sixth review P2-2, fifth review P2-2): each + // duplicate would price its own rounded share against the same earlier + // total, refunding a cent more per duplicate than the sale took; the + // quantity belongs in one line. + seenLine := map[uuid.UUID]bool{} + resolved := make([]ReturnLineIn, len(in.Lines)) + for i := range in.Lines { + rl := in.Lines[i] + if rl.SaleLineID != nil { + if seenLine[*rl.SaleLineID] { + return nil, invalid(fmt.Sprintf("lines[%d].line_id", i), + "names the sale line twice: return it once, with the whole quantity in one line") + } + seenLine[*rl.SaleLineID] = true + 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 { + 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 + } + 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(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") + } + } + var src salesdoc.Line + if rl.SaleLineID != nil { + src = byID[*rl.SaleLineID] + // A kit's components never restock on their own line: the + // component carries no price, the kit line carries the return. + if src.LineType == salesdoc.LineComponent && rl.Restock { + return nil, invalid(fmt.Sprintf("lines[%d].restock", i), + "a kit component returns with its kit line: name the kit line, its components follow") + } + // 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, + chargeCodeID: src.ChargeCodeID, lineType: string(src.LineType)} + 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") + } + taxable := true + 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}) + } + // 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 + } + // 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) + } + } + } + st := &returnState{in: in, branchID: branchID, customerID: customerID, currency: facts.Currency, + saleLines: saleLines, byID: byID, legacyBearer: legacyBearer, refs: refs, priced: priced, + saleTaxCents: saleTaxCents, saleTaxable: saleTaxable} + if err := s.applyReturnRemainders(ctx, st); err != nil { + return nil, err + } + if err := s.resolveReturnMoney(ctx, st); err != nil { + return nil, err + } + return st, nil +} - // 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 - } - 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 +// applyReturnRemainders restates each linked line's refund against what +// earlier returns already took (fourth review P2-1): the return that +// completes a line takes the line's whole total less every cent already +// refunded, so the rounded per unit shares of repeated partial returns never +// sum past what the sale was paid; the tax is capped the same way. Read +// before the transaction (the guards) and again under the sale row lock (the +// authority the memo is written from). +func (s *Service) applyReturnRemainders(ctx context.Context, st *returnState) error { + if st.in.OriginalSaleID == nil { + return nil + } + returned, err := s.repo.ReturnedQtyByLine(ctx, *st.in.OriginalSaleID) + if err != nil { + return err + } + refunded, err := s.repo.RefundedCentsByLine(ctx, *st.in.OriginalSaleID) + if err != nil { + return err } + if st.taxAlreadyBack, err = s.repo.RefundedTaxCents(ctx, *st.in.OriginalSaleID); err != nil { + return err + } + legacyCents, err := s.repo.LegacyRefundedCentsByProduct(ctx, *st.in.OriginalSaleID) + if err != nil { + return err + } + legacyQty, err := s.repo.LegacyReturnedQtyByProduct(ctx, *st.in.OriginalSaleID) + if err != nil { + return err + } + // the legacy quantity of a product bears on its bearer line, so a line + // the legacy return partly brought back completes with the linked + // returns that bring the rest (eighth review P3-1) + legacyBack := func(l *salesdoc.Line) int64 { + if l.ProductID != nil && st.legacyBearer[*l.ProductID] == l.ID { + return int64(legacyQty[*l.ProductID]) + } + return 0 + } + st.backByLine = make(map[uuid.UUID]int64, len(returned)) + for id, qty := range returned { + st.backByLine[id] = int64(qty) + } + // does this return finish every taxable line of the sale, counting + // what the earlier returns already brought back? + st.completesTaxable = false + for i := range st.saleLines { + l := &st.saleLines[i] + if !l.Taxable || l.Quantity == nil { + continue + } + st.completesTaxable = true + back := int64(returned[l.ID]) + legacyBack(l) + for j := range st.priced { + if st.priced[j].saleLine != nil && st.priced[j].saleLine.ID == l.ID { + back += int64(st.priced[j].in.Quantity) + } + } + if back < int64(*l.Quantity) { + st.completesTaxable = false + break + } + } + for i := range st.priced { + p := &st.priced[i] + if p.saleLine == nil || p.saleLine.Quantity == nil || p.saleLine.LineTotal == nil { + continue + } + sold, back := int64(*p.saleLine.Quantity), int64(returned[p.saleLine.ID])+legacyBack(p.saleLine) + remaining := int64(*p.saleLine.LineTotal) - refunded[p.saleLine.ID] + // the legacy refund of the product lowers the bearer line's + // remaining money, so the rounded shares of later linked + // returns cannot pass what the sale took + if p.saleLine.ProductID != nil && st.legacyBearer[*p.saleLine.ProductID] == p.saleLine.ID { + remaining -= legacyCents[*p.saleLine.ProductID] + } + if back+int64(p.in.Quantity) >= sold { + p.lineTotal = remaining + } else if p.lineTotal > remaining { + p.lineTotal = remaining + } + if p.lineTotal < 0 { + p.lineTotal = 0 + } + } + return nil +} - 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, +// resolveReturnMoney restates the memo's totals from the lines' money; the +// transaction runs it again under the sale row lock after the caps recheck, +// and the memo, the legs, the refund and the return row are all written from +// its latest answer. +func (s *Service) resolveReturnMoney(ctx context.Context, st *returnState) error { + var linkedTaxable int64 + for i := range st.priced { + if st.priced[i].saleLine != nil && st.priced[i].taxable { + linkedTaxable += st.priced[i].lineTotal + } + } + st.subtotal = linkedTaxable + var freeTaxable int64 + var freeNontaxable int64 + for i := range st.priced { + if st.priced[i].saleLine == nil { + if st.priced[i].taxable { + freeTaxable += st.priced[i].lineTotal + } else { + freeNontaxable += st.priced[i].lineTotal + } + } else if !st.priced[i].taxable { + st.subtotal += st.priced[i].lineTotal + } } + st.subtotal += freeTaxable + freeNontaxable + st.taxCents = 0 + st.taxRate = nil + if linkedTaxable > 0 && st.saleTaxable > 0 && st.saleTaxCents > 0 { + st.taxCents += divRound(linkedTaxable*st.saleTaxCents, st.saleTaxable) + // never more tax back than the sale collected less what earlier + // returns took (the same remainder rule) + if remaining := st.saleTaxCents - st.taxAlreadyBack; st.taxCents > remaining { + st.taxCents = remaining + } + // the return that finishes every taxable line takes the rest of + // the tax exactly (sixth review P3-1): the rounded shares of + // repeated partial returns cannot leave cents owed to the + // customer + if st.completesTaxable { + if remaining := st.saleTaxCents - st.taxAlreadyBack; remaining > st.taxCents { + st.taxCents = remaining + } + } + } + if freeTaxable > 0 { + exempt, err := s.repo.CustomerExempt(ctx, *st.customerID) + if err != nil { + return err + } + if exempt { + zero := "0" + st.taxRate = &zero + } else { + rate, ok, err := s.repo.BranchTaxRate(ctx, st.branchID) + if err != nil { + return err + } + if !ok { + return conflict("tax_rate_not_configured", "set the branch's default tax rate before returning goods") + } + scaled, _, err := salesdoc.ParseTaxRate(rate) + if err != nil { + return err + } + st.taxCents += int64(salesdoc.TaxAt(httpx.Cents(freeTaxable), scaled)) + st.taxRate = &rate + } + } + st.total = -(st.subtotal + st.taxCents) + return nil +} - // 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 != "" { +// 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 (section 11, as every gateway +// call does), claimed first by a committed refund intent unique per sale +// and tender so a concurrent card return or a void of the same tender never +// reaches the terminal twice; 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 + } + st, err := s.priceReturn(ctx, in, true) + if err != nil { + return nil, err + } + st.cashierID, st.actor = cashierID, actor + var claim *refundClaim + if in.RefundMethod == RefundCard { + if in.GatewayTxID != "" { + return nil, invalid("gateway_tx_id", + "the card refund goes to the card the sale was paid with: drop gateway_tx_id") + } + if in.OriginalSaleID == nil { + return nil, conflict("card_refund_needs_sale", + "a card return needs the sale it refunds: no sale, no card of the business to refund") + } + cardTenders, err := s.repo.GetTenders(ctx, *in.OriginalSaleID) + if err != nil { + return nil, err + } + var target *Tender + for i := range cardTenders { + t := &cardTenders[i] + if t.Method != TenderCard || t.GatewayTxID == nil || *t.GatewayTxID == "" { + continue + } + if target != nil { + return nil, conflict("card_tender", + "the sale was paid with several card tenders: a card return cannot pick one; refund it from the drawer") + } + target = t + } + if target == nil { + return nil, conflict("card_tender", + "the sale was not paid by card: refund it from the drawer or leave it on the account") + } 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") + } + // The claim, in one short transaction of its own (ADR 0005 section + // 11 and 14.2 C2-5: "The card gateway is called before the + // transaction and its result written inside it"): the sale row is + // locked, the status and the caps are rechecked under it, and the + // committed refund intent, unique per sale and tender while pending, + // refuses a concurrent card return of the same tender, or a void of + // the sale, with 409 before it reaches the terminal. No lock of the + // money transaction is held across the network call. The claim carries + // its own request shape, so an operator can resolve a claim that + // outlives its act by replaying this booking. + intent := RefundIntent{ID: uuid.New(), SaleID: *in.OriginalSaleID, TenderID: target.ID, + GatewayTxID: *target.GatewayTxID, AmountCents: -st.total, Act: "return", + Request: storeReturnRequest(in)} + claim = &refundClaim{intent: intent, tenderCap: int64(target.AmountCents)} + if err := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, *in.OriginalSaleID); err != nil { + return err + } + held, err := s.repo.GetSale(ctx, *in.OriginalSaleID) + if err != nil { + return err + } + // the status recheck under the lock (seventh review P1-1): a void + // that committed while this return priced its lines refunded the + // tender already, and the claim must refuse before the terminal + if held.Status != StatusCompleted { + return httpx.InvalidStateTransition( + fmt.Sprintf("cannot return against a %s sale: a voided sale's goods came back with the void", held.Status.Status())) + } + if err := s.checkReturnCaps(ctx, in.OriginalSaleID, st.priced, st.byID); err != nil { + return err + } + // The remainders and the totals, restated under the claim's lock + // (seventh review P2-1): the terminal is asked for the figure the + // lock sees, and the money transaction compares its own restated + // total with that claim. + if err := s.applyReturnRemainders(ctx, st); err != nil { + return err + } + if err := s.resolveReturnMoney(ctx, st); err != nil { + return err + } + if err := s.checkCardClaim(ctx, held.ID, target.ID, -st.total, claim.tenderCap); err != nil { + return err + } + claim.intent.AmountCents = -st.total + return s.repo.InsertRefundIntent(ctx, claim.intent) + }); err != nil { + return nil, err } - res, err := s.gateway.Refund(ctx, req.GatewayTxID, total) + // The call is bounded (fourteenth review P1-1): every path it can take ends inside + // the bound, so the claim it holds never sits in flight past its own + // settle path. + callCtx, cancel := context.WithTimeout(ctx, refundCallTimeout) + res, err := s.gateway.Refund(callCtx, *target.GatewayTxID, claim.intent.AmountCents) + cancel() if err != nil { + // no verdict came back (a timeout, a cancel): the terminal may + // have moved the money, so the claim stands and the unknown + // outcome is recorded for reconciliation (seventh review P2-2) + s.unknownRefundOutcome(ctx, claim.intent, err) 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) + switch refundVerdictOf(res) { + case refundAccepted: + case refundDeclined: + // a definite refusal: the terminal's own no proves nothing moved + s.failRefundIntent(ctx, claim.intent) + return nil, conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) + default: + // a status that is neither a success nor the terminal's own no + // proves nothing about the money (tenth review P2-3): the claim + // stands as an error leaves it, with the unknown outcome row + s.unknownRefundOutcome(ctx, claim.intent, noVerdictOf(res)) + return nil, conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) + } + // the gateway's own refund id, on the standing claim the moment the + // terminal answers and in its own committed write (seventh review + // P2-3), whatever state the claim is in (fourteenth review P1-1): a claim carrying + // a refund id is a refund that moved, one without it is a refund that + // may not have + if err := s.repo.MarkRefundIntentRefunded(ctx, claim.intent.ID, res.TransactionID); err != nil { + s.logger.Error("CRITICAL: the gateway's refund id was not written to the claim", + "intent_id", claim.intent.ID, "sale_id", claim.intent.SaleID, "error", err) } + claim.refundTxID = res.TransactionID + // the refund row names the tender's own gateway transaction, so the + // money is reconcilable against the charge it came from + in.GatewayTxID = *target.GatewayTxID } + return s.postReturn(ctx, st, claim) +} - // 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 { +// postReturn runs the return's money transaction: the sale row lock, the +// guards restated under it, the credit memo posted with its entry and its +// subledger row, the stock returns, the refund and the events, all one +// transaction. The claim, when the refund is a card's, completes with the +// posting; a refusing transaction rolls it back to PENDING and the refund +// the terminal already made is recorded in a committed row of its own. +func (s *Service) postReturn(ctx context.Context, st *returnState, claim *refundClaim) (*Return, error) { + in := st.in + customerID := st.customerID + branchID := st.branchID + priced := st.priced + saleLines := st.saleLines + refs := st.refs + var tillSessionID *uuid.UUID + var out *Return + var invoiceLines []InvoiceLineCost + var invoiceLineIDs map[uuid.UUID]uuid.UUID + 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())) + } + // A return refunded by any method but the card's own, of a sale + // whose card refund claim still stands, is refused (eighth review + // P2-1, ninth review P1-2): the claim may already have taken the + // terminal's money, and neither the drawer nor the account may + // pay the same goods a second time. Under the sale row lock, + // before any money of this return moves. + if in.RefundMethod != RefundCard { + if pending, err := s.repo.SaleHasPendingRefundIntent(ctx, sale.ID); err != nil { + return err + } else if pending { + return conflict("refund_in_flight", + "a card refund of this sale is still in flight: settle it before refunding by any other method") + } + if doneVoid, err := s.repo.SaleHasVoidIntentDone(ctx, sale.ID); err != nil { + return err + } else if doneVoid { + return conflict("void_refund_recorded", + "this sale's void already refunded a card tender at the terminal: reconcile the void before returning goods against this sale") + } + } + // The claim's own state, under the sale row lock and before the + // caps: a replay whose claim was settled by a concurrent + // resolution answers the race (tenth review P3-3), never the + // caps the winner's own booking trips. + if claim != nil { + cf, err := s.repo.RefundIntentFacts(ctx, claim.intent.SaleID, claim.intent.TenderID) + if err != nil { + return err + } + if cf.DoneVoid { + return conflict("tender_refunded", + "the card tender was already refunded whole by this sale's void: no second refund of it can be made") + } + if cf.PendingCents == 0 { + return conflict("intent_not_pending", + "the refund claim was settled by another resolution first") + } + } + // 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, st.priced, st.byID); err != nil { + return err + } + // The remainder rule and the totals, restated under the lock: the + // money is written from these answers, never from the pre + // transaction read (a card refund already taken keeps the amount + // it took; the race window is the cents a concurrent return's + // rounding moves). + if err := s.applyReturnRemainders(ctx, st); err != nil { + return err + } + if err := s.resolveReturnMoney(ctx, st); 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 { + return err + } + // The sale's own invoice lines, by the stored link on each sale + // line: 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 and never a position match (a removed cart line leaves + // the sale's positions gapped). + if sale.InvoiceID != nil { + if invoiceLines, err = s.repo.InvoiceLineCosts(ctx, *sale.InvoiceID); err != nil { + return err + } + } + if invoiceLineIDs, err = s.repo.InvoiceLineIDsBySaleLine(ctx, sale.ID); err != nil { + return err + } + } + // The drawer the refund pays out of (fourth review P2-2), whatever + // the return is linked to: a cash return locks the register's open + // session FOR SHARE (against the close's FOR UPDATE) inside the + // transaction and is refused when none is open, so it never lands in + // a drawer already counted (the close waits on the same lock and + // aggregates the payout) or in no drawer at all. A card or account + // return may live sessionless. + session, err := s.repo.GetOpenTillSession(ctx, in.RegisterID) + if err != nil { + return err + } + if session != nil { + if err := s.repo.LockTillSession(ctx, session.ID, false); err != nil { + return err + } + held, err := s.repo.GetTillSession(ctx, session.ID) + if err != nil { + return err + } + if held.Status == TillOpen { + tillSessionID = &session.ID + } else if in.RefundMethod == RefundCash { + return conflict("session_closed", + "the till session closed while the return was made: open the drawer and take it again") + } + } else if in.RefundMethod == RefundCash { + return conflict("no_open_session", + "a cash return pays out of the drawer: open the till session before refunding cash") + } + // The claim's rules, rechecked under the sale row lock as the refund + // posts (seventh review P2-1): the intent table is the authority. The + // books take exactly what the terminal took: a restated total that + // differs from the claimed amount refuses, the intent stays PENDING + // beside the orphan row that records the gateway's figure, and the + // books never record a cent less than the terminal moved. + if claim != nil { + // The claim row locked and read whole under the sale row lock + // (fourteenth review P1-1): a replay may only settle a claim whose own terminal + // call has answered (or whose in flight lease expired with no + // answer at all, marked UNKNOWN then), never a live one, and the + // id it books must be the terminal's own. + held, err := s.repo.LockRefundIntent(ctx, claim.intent.ID) + if err != nil { + return err + } + if held.State != "PENDING" { + return conflict("intent_not_pending", + fmt.Sprintf("the refund claim is %s: only a PENDING claim is resolved", held.State)) + } + if err := s.settleUnansweredClaim(ctx, held); err != nil { + return err + } + if held.RefundTxID != "" && held.RefundTxID != claim.refundTxID { + return conflict("refund_tx_id_conflict", + fmt.Sprintf("the claim carries the terminal's refund id %s, not %s: record the terminal's own id", + held.RefundTxID, claim.refundTxID)) + } + cf, err := s.repo.RefundIntentFacts(ctx, claim.intent.SaleID, claim.intent.TenderID) + if err != nil { + return err + } + if cf.DoneVoid { + return conflict("tender_refunded", + "the card tender was already refunded whole by this sale's void: no second refund of it can be made") + } + if cf.UnbookedRefundedCents > 0 { + return conflict("tender_refunded", + "the terminal already refunded this tender under a recorded refund id that never booked: reconcile that claim before refunding the tender again") + } + if cf.PendingCents == 0 { + // this return's own claim left PENDING underneath it (a + // concurrent resolution won the claim): the race is lost, not + // in flight (ninth review P2-2, tenth review P3-3) + return conflict("intent_not_pending", + "the refund claim was settled by another resolution first") + } + if heldOthers := cf.PendingCents - claim.intent.AmountCents; heldOthers != 0 { + return conflict("refund_in_flight", + "a card refund of this sale's tender is already in flight: only one refund of a tender runs at a time") + } + if -st.total != claim.intent.AmountCents { + return conflict("refund_amount_changed", + fmt.Sprintf("the terminal refunded %d cents but the return restates to %d under the lock: the intent stands beside the orphan row until it is reconciled", + claim.intent.AmountCents, -st.total)) + } + already, err := s.repo.CardRefundedCents(ctx, claim.intent.SaleID) + if err != nil { + return err + } + if -st.total > claim.tenderCap-already { + return conflict("exceeds_card_tender", + fmt.Sprintf("the card tender kept %d cents less the %d already refunded to the card: refund the rest from the drawer", + claim.tenderCap, already)) + } + if err := s.repo.CompleteRefundIntent(ctx, claim.intent.SaleID, claim.intent.TenderID, claim.refundTxID); err != nil { + return err + } + if claim.resolveAudit != nil && s.auditLog != nil { + if err := s.auditLog.Log(ctx, *claim.resolveAudit); err != nil { + return fmt.Errorf("failed to write the resolution audit row: %w", err) + } + } + } + date, err := s.repo.BranchLocalDate(ctx, *branchID, s.now()) + if err != nil { return err } - for _, l := range ret.Lines { - if !l.Restock { + invoiceLineAt := func(saleLineID uuid.UUID) *InvoiceLineCost { + id, ok := invoiceLineIDs[saleLineID] + if !ok { + return nil + } + for i := range invoiceLines { + if invoiceLines[i].ID == id { + 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 + // 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 + // restockAct is one physical restock the return makes: a product + // line's own units, or a returned kit's components. + type restockAct struct { + productID uuid.UUID + qty httpx.Quantity + } + var restocks []restockAct + for i := range priced { + p := &priced[i] + 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 { + 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 + } + if src := invoiceLineAt(p.saleLine.ID); src != nil { + id := src.ID + invoiceLineID = &id + restockUnitCost = httpx.Price(src.UnitCost) + } + } + unitPrice := p.in.UnitPrice + 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 { 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) + // A returned kit line restocks its components and never itself: + // the kit product has no inventory row, and the goods that left + // were the components. Each component comes back at the returned + // fraction of its quantity, at its own invoice line's cost (the + // cost the sale relieved, 8.4). + if p.saleLine != nil && p.saleLine.LineType == salesdoc.LineKit && p.saleLine.Quantity != nil { + // the component quantity and cost telescope on the kit + // line's cumulative returned quantity (sixth review P3-1): + // each return restocks the difference between the + // cumulative position after it and before it, so repeated + // partial returns put exactly the cost the sale relieved + // back, never the rounded shares' drift + back := st.backByLine[p.saleLine.ID] + for j := range saleLines { + comp := &saleLines[j] + if comp.ParentLineID == nil || *comp.ParentLineID != p.saleLine.ID || + comp.Quantity == nil || comp.ProductID == nil { + continue + } + before := divRound(int64(*comp.Quantity)*back, int64(*p.saleLine.Quantity)) + after := divRound(int64(*comp.Quantity)*(back+int64(p.in.Quantity)), int64(*p.saleLine.Quantity)) + qtyBack := httpx.Quantity(after - before) + if qtyBack <= 0 { + continue + } + cost := httpx.Price(0) + if src := invoiceLineAt(comp.ID); src != nil { + cost = httpx.Price(src.UnitCost) + } + if cost <= 0 { + if ref, ok := refs[comp.ProductID.String()]; ok { + cost = ref.AverageCost + } + } + if cost > 0 { + restockCost += int64(salesdoc.CostOf(httpx.Quantity(after), cost)) - + int64(salesdoc.CostOf(httpx.Quantity(before), cost)) + } + restocks = append(restocks, restockAct{productID: *comp.ProductID, qty: qtyBack}) + } + continue + } + if p.in.ProductID != nil { + // a free line (no sale) restocks at today's average: it has + // no source invoice line to read, and no earlier return to + // telescope on; a linked line's cost telescopes on its + // cumulative returned quantity + cost := restockUnitCost + if cost <= 0 { + if ref, ok := refs[p.in.ProductID.String()]; ok { + cost = ref.AverageCost + } + } + if cost > 0 && p.saleLine == nil { + restockCost += int64(salesdoc.CostOf(p.in.Quantity, cost)) + } else if cost > 0 { + back := httpx.Quantity(st.backByLine[p.saleLine.ID]) + restockCost += int64(salesdoc.CostOf(back+p.in.Quantity, cost)) - + int64(salesdoc.CostOf(back, cost)) + } + restocks = append(restocks, restockAct{productID: *p.in.ProductID, qty: p.in.Quantity}) + } + } + if _, err := s.db.GetExecutor(ctx).Exec(ctx, ` + 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, st.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 { + 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, 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, $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, lineType, l.ChargeCodeID); 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 + } + // 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 st.taxCents > 0 { + legs = append(legs, gl.Leg{AccountCode: gl.AccountCodeSalesTax, Description: "Sales Tax Payable", Debit: st.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, Number: memoNumber, Currency: st.currency, MemoDate: date, + SubtotalCents: -st.subtotal, TaxCents: -st.taxCents, TotalCents: st.total, TaxRate: st.taxRate, Actor: st.actor, Legs: legs}) + if err != nil { + return err + } + var fxAll *account.Effects + mergeEffects(&fxAll, fxPost) + // The stock returns: each restock act back on hand (a product line's + // own units, a kit's components; the kit product itself never stocks). + for i := range restocks { + if err := s.inventory.RestockQty(ctx, restocks[i].productID, *branchID, restocks[i].qty); err != nil { + return err } } - // Audit log: inside the transaction, so it shares the return's fate - // — a rolled back return leaves no audit row. + // 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: -st.total, Reason: in.Reason, Method: string(in.RefundMethod), + GatewayRefundID: in.GatewayTxID, Actor: st.actor, On: date}) + if err != nil { + return err + } + mergeEffects(&fxAll, rfx) + } + // The return row, linked to its memo. + ret := &Return{ + RegisterID: in.RegisterID, TillSessionID: tillSessionID, OriginalSaleID: in.OriginalSaleID, + CustomerID: customerID, BranchID: branchID, CashierID: st.cashierID, Currency: st.currency, + SubtotalCents: httpx.Cents(-st.subtotal), TaxCents: httpx.Cents(-st.taxCents), TotalCents: httpx.Cents(st.total), + RefundMethod: in.RefundMethod, Reason: in.Reason, CreditMemoID: &memoID, Lines: lines, + } + 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, - 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) + Action: "pos.return.completed", EntityType: "pos_return", EntityID: ret.ID, UserID: st.actor, + Changes: map[string]any{ + "number": ret.Number, "total_cents": st.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 + 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": st.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": st.currency, "total_cents": st.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": st.currency, + "amount_cents": -st.total, "method": in.RefundMethod.Status(), + })}); 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: EventReturnCompleted, EntityType: "pos_return", EntityID: ret.ID, + BranchID: &branch, Data: eventJSON(map[string]any{ + "number": ret.Number, "customer_id": customerID, "status": "completed", "currency": st.currency, + "total_cents": st.total, "refund_method": in.RefundMethod.Status(), "credit_memo_id": memoID, + })}); err != nil { + return err + } + out = ret return nil - }); err != 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 claim != nil && !claim.replay && claim.refundTxID != "" { + s.recordOrphanRefund(ctx, gatewayRefund{gatewayTxID: claim.intent.GatewayTxID, refundTxID: claim.refundTxID, + amountCents: claim.intent.AmountCents, tenderID: claim.intent.TenderID, act: claim.intent.Act, + entity: claim.intent.SaleID, actor: st.actor}, err) + } return nil, err } + return out, nil +} - // 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) - } - } 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) - } - } +// storedReturnLine is one line of a return claim's stored request shape. +type storedReturnLine struct { + SaleLineID *uuid.UUID `json:"sale_line_id,omitempty"` + ProductID *uuid.UUID `json:"product_id,omitempty"` + Description string `json:"description"` + Quantity int64 `json:"quantity"` + Restock bool `json:"restock"` +} + +// storedReturnRequest is a return claim's own request, carried on the intent +// so the operator resolution can replay the money transaction the lost act +// never ran. Quantities ride at their wire scale of four. +type storedReturnRequest struct { + RegisterID string `json:"register_id"` + CustomerID *uuid.UUID `json:"customer_id,omitempty"` + OriginalSaleID *uuid.UUID `json:"original_sale_id,omitempty"` + Reason string `json:"reason"` + Lines []storedReturnLine `json:"lines"` +} + +func storeReturnRequest(in *ReturnIn) []byte { + out := storedReturnRequest{RegisterID: in.RegisterID, CustomerID: in.CustomerID, + OriginalSaleID: in.OriginalSaleID, Reason: in.Reason} + for i := range in.Lines { + l := in.Lines[i] + out.Lines = append(out.Lines, storedReturnLine{SaleLineID: l.SaleLineID, ProductID: l.ProductID, + Description: l.Description, Quantity: int64(l.Quantity), Restock: l.Restock}) } + raw, err := json.Marshal(out) + if err != nil { + return nil + } + return raw +} - s.logger.Info("POS return completed", "id", ret.ID, "register", ret.RegisterID, "method", ret.RefundMethod, "total_cents", ret.Total) - return ret, nil +// toReturnIn rebuilds the return request the claim stored. The refund method +// is card by construction: a claim exists only for a card refund. +func (r *storedReturnRequest) toReturnIn() *ReturnIn { + in := &ReturnIn{RegisterID: r.RegisterID, CustomerID: r.CustomerID, OriginalSaleID: r.OriginalSaleID, + RefundMethod: RefundCard, Reason: r.Reason} + for i := range r.Lines { + l := r.Lines[i] + in.Lines = append(in.Lines, ReturnLineIn{ProductID: l.ProductID, SaleLineID: l.SaleLineID, + Description: l.Description, Quantity: httpx.Quantity(l.Quantity), Restock: l.Restock}) + } + return in } -// 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 +func ptrC(v int64) *httpx.Cents { + c := httpx.Cents(v) + 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 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 + if num >= 0 { + return (num + den/2) / den } - ret.GLEntryID = &glID + return -((-num + den/2) / den) } -// GetReturn returns a full return with its lines. -func (s *Service) GetReturn(ctx context.Context, id uuid.UUID) (*POSReturn, error) { +// 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 + } + legacy, err := s.repo.LegacyReturnedQtyByProduct(ctx, *saleID) + if err != nil { + return err + } + // The bearer of each product's legacy returns: its first line by + // position among the sale's lines (the same rule the caller priced by), + // so a sale of several lines of one product loses headroom on one line + // only, and always the same one. + ordered := make([]salesdoc.Line, 0, len(byID)) + for _, l := range byID { + ordered = append(ordered, l) + } + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].Position != ordered[j].Position { + return ordered[i].Position < ordered[j].Position + } + return ordered[i].ID.String() < ordered[j].ID.String() + }) + bearer := map[uuid.UUID]uuid.UUID{} + for _, l := range ordered { + if l.ProductID != nil { + if _, ok := bearer[*l.ProductID]; !ok { + bearer[*l.ProductID] = l.ID + } + } + } + for lineID, want := range requested { + src, ok := byID[lineID] + if !ok || src.Quantity == nil { + continue + } + already := int64(returned[lineID]) + if src.ProductID != nil && bearer[*src.ProductID] == lineID { + already += int64(legacy[*src.ProductID]) + } + 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 + if err := s.db.GetExecutor(ctx).QueryRow(ctx, `SELECT number FROM credit_memos WHERE id = $1`, memoID).Scan(&number); err != nil { + return "" + } + return number +} + +// 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) +} + +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/returns_bounds_test.go b/core/internal/pos/returns_bounds_test.go new file mode 100644 index 000000000..1f366802e --- /dev/null +++ b/core/internal/pos/returns_bounds_test.go @@ -0,0 +1,352 @@ +// 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 ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// returnOn is a linked cash return of qty of the sale's first line. A cash +// return pays out of the drawer, so the register needs an open till session; +// the fixture opens one when the test has not. +func (f *fixture) returnOn(t *testing.T, saleID, lineID, qty string, extra ...map[string]any) resp { + t.Helper() + f.ensureOpenTill(t) + 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, "original_sale_id": saleID, + "refund_method": "cash", "reason": "bounds", "lines": []map[string]any{line}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) +} + +// ensureOpenTill opens a till session when the register has none. +func (f *fixture) ensureOpenTill(t *testing.T) { + t.Helper() + var n int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT count(*) FROM till_sessions WHERE register_id = $1 AND status = 'OPEN'`, f.register).Scan(&n); err != nil { + t.Fatal(err) + } + if n == 0 { + f.openTill(0) + } +} + +// RULE (fourth review P2-2): a cash return pays out of a drawer that is open +// and counted live: it locks the register's open session FOR SHARE inside +// its transaction (against the close's FOR UPDATE) and is refused when no +// session is open, so it never lands in a drawer that was already counted, +// or in no drawer at all. +func TestACashReturnNeedsAnOpenTillSession(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, body := f.saleOf("1", tender("cash", 599)) + 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, "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)) + } + // no session open: the drawer cannot pay + r := cashReturn() + if r.status != http.StatusConflict { + t.Fatalf("cash return with no open session = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "no_open_session" { + t.Errorf("blockers = %v, want no_open_session", blockers) + } + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("cash balance = %d, want 599 (nothing paid)", got) + } + f.assertARInvariants(t) + // an open session pays, and the return lands in it + sessionID := f.openTill(0) + r = cashReturn() + if r.status != http.StatusCreated { + t.Fatalf("cash return in an open session = %d: %s", r.status, r.raw) + } + if got := str(t, r.body, "till_session_id"); got != sessionID { + t.Errorf("till_session_id = %s, want the open session %s", got, sessionID) + } + f.assertARInvariants(t) + // after the close the drawer was counted: the rest comes back another + // way, never out of the counted drawer + saleID2, body2 := f.saleOf("1", tender("cash", 599)) + lineID2 := body2.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if 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)); r.status != http.StatusOK { + t.Fatalf("close = %d: %s", r.status, r.raw) + } + r = f.do("POST", "/api/v1/pos/returns", map[string]any{ + "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)) + if r.status != http.StatusConflict { + t.Fatalf("cash return after the close = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ = errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "no_open_session" { + t.Errorf("blockers = %v, want no_open_session (the counted drawer pays nothing)", blockers) + } + f.assertARInvariants(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) +} + +// RULE (third review P1-1): a return that names a sale names its lines: a +// product line with a client price on a named sale is refused, so every line +// of a named sale is bounded by the line it returns. Only a return that names +// no sale stands free. +func TestAProductLineOnANamedSaleIsRefused(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(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, "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, + }}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusBadRequest { + t.Fatalf("product line on a named sale = %d, want 400: %s", r.status, r.raw) + } + _, _, fields := errorOf(t, r) + if len(fields) == 0 || fields[0] != "lines[0].line_id" { + t.Errorf("fields = %v, want lines[0].line_id", fields) + } + // nothing moved: the drawer still holds the sale, the stock still 99 + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("cash balance = %d, want 599 (the refused return paid nothing)", got) + } + if got := f.stock(); got != "99.0000/0.0000" { + t.Errorf("stock = %s, want 99", got) + } + f.assertARInvariants(t) +} + +// RULE (fourth review P2-1): repeated partial returns of one line never +// refund more than the sale took: the return that completes the line takes +// the remainder (the whole line total and the whole tax less what earlier +// returns already refunded), so the rounded shares of the earlier returns +// cannot drift the total past what was paid. +func TestRepeatedPartialReturnsRefundNoMoreThanTheSale(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + // 3 at 5.50 less one cent: line 1649, tax 146, total 1795 in cash + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "3", + "discount_cents": 1, "discount_reason": "penny off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 1795)); 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) + + // three returns of one unit each: 599, 599, then the remainder 597 + var refunded int64 + for i, want := range []int64{599, 599, 597} { + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("partial return %d = %d: %s", i+1, r.status, r.raw) + } + if got := num(t, r.body, "total_cents"); got != -want { + t.Errorf("partial return %d total = %d, want %d", i+1, got, -want) + } + refunded += want + f.assertARInvariants(t) + } + if refunded != 1795 { + t.Errorf("%d refunded over the three returns, want exactly the 1795 taken", refunded) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("cash balance = %d, want 0 (1795 in, 1795 out, never a cent more)", got) + } + // the line is fully returned: nothing more comes back + if r := f.returnOn(t, saleID, lineID, "1"); r.status != http.StatusConflict { + t.Fatalf("return past the sold quantity = %d, want 409: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} + +// RULE (fourth review P3-3, sixth review P1-1): a legacy return line the +// migration could not link to a sale line still counts against the cap, by +// its product: a sale half returned before the contract cannot be returned +// in full again through the linked route. The legacy rows sit in the shape +// migration 105 leaves them (positive quantities and cents: 105 negates +// only while copying into credit memos), and the return is a card return so +// the card tender's cap reads the legacy cents with their own sign too. +func TestALegacyUnlinkedReturnCountsAgainstTheCap(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("1", withToken(tender("card", 599))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + // a legacy return of half the unit, in the base shape 105 leaves: no sale + // line link, positive quantity and cents + legacy := uuid.New() + mustExec(t, f.db, `INSERT INTO pos_returns (id, register_id, cashier_id, original_transaction_id, subtotal, tax_amount, total, + refund_method, reason, currency) VALUES ($1, $2, $3, $4, 2.75, 0.24, 2.99, 'CARD', 'legacy half', 'USD')`, + legacy, f.register, mustUUID(t), saleID) + mustExec(t, f.db, `INSERT INTO pos_return_lines (id, return_id, position, line_type, product_id, description, quantity, + uom, unit_price, line_total) VALUES ($1, $2, 0, 'PRODUCT', $3, 'legacy half', 0.5, 'PCS', 5.5, 2.75)`, + uuid.New(), legacy, f.productID) + t.Cleanup(func() { + mustExec(t, f.db, `DELETE FROM pos_return_lines WHERE return_id = $1`, legacy) + mustExec(t, f.db, `DELETE FROM pos_returns WHERE id = $1`, legacy) + }) + // the whole unit back is refused: half already came home + r := f.cardReturnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("card return over a legacy half = %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) + } + if len(gw.refunds) != 0 { + t.Fatalf("%d gateway refunds over a legacy half, want 0", len(gw.refunds)) + } + // the other half still comes back, to the card, within the tender less + // the legacy card refund: 599 tendered less the 299 the legacy return + // took leaves 300 (275 of line and 25 of tax) + r = f.cardReturnOn(t, saleID, lineID, "0.5") + if r.status != http.StatusCreated { + t.Fatalf("card return of the other half = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "total_cents"); got != -300 { + t.Errorf("card return of the other half total = %d, want -300 (the tender less the legacy card refund)", got) + } + if len(gw.refunds) != 1 { + t.Fatalf("%d gateway refunds, want 1 (the other half)", len(gw.refunds)) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/returns_legacy_test.go b/core/internal/pos/returns_legacy_test.go new file mode 100644 index 000000000..8c59399e8 --- /dev/null +++ b/core/internal/pos/returns_legacy_test.go @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The legacy returns of a sale (sixth review P1-1 and P3-2): migration 105 +// leaves their quantities and cents positive (it negates only while copying +// into credit memos), so every read takes each source with its own sign; the +// legacy quantity and cents bear on the product's first line by position, +// once, so a sale of several lines of one product loses headroom on one line +// only; and the legacy cents lower that line's remaining money, so the +// rounded shares of later linked returns can never refund past what the sale +// took, and together they sum to it exactly. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// legacyReturnOf writes a legacy (unlinked) return beside a sale in the base +// shape migration 105 leaves: positive quantity, cents and totals. +func legacyReturnOf(t *testing.T, f *fixture, saleID string, qty, unit, line, subtotal, tax, total string, method string) { + t.Helper() + legacy := uuid.New() + mustExec(t, f.db, `INSERT INTO pos_returns (id, register_id, cashier_id, original_transaction_id, subtotal, tax_amount, total, + refund_method, reason, currency) VALUES ($1, $2, $3, $4, $5::numeric, $6::numeric, $7::numeric, $8, 'legacy', 'USD')`, + legacy, f.register, mustUUID(t), saleID, subtotal, tax, total, method) + mustExec(t, f.db, `INSERT INTO pos_return_lines (id, return_id, position, line_type, product_id, description, quantity, + uom, unit_price, line_total) VALUES ($1, $2, 0, 'PRODUCT', $3, 'legacy', $4::numeric, 'PCS', $5::numeric, $6::numeric)`, + uuid.New(), legacy, f.productID, qty, unit, line) + t.Cleanup(func() { + mustExec(t, f.db, `DELETE FROM pos_return_lines WHERE return_id = $1`, legacy) + mustExec(t, f.db, `DELETE FROM pos_returns WHERE id = $1`, legacy) + }) +} + +// RULE (sixth review P3-2): the legacy quantity bears on the product's first +// line by position, once: a sale of lines 2 and 1 of one product with half a +// unit legacy returned keeps the headroom of the second line whole and loses +// it on the first only. +func TestALegacyReturnBearsOnTheFirstLineOfItsProduct(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, f.productLine("2")); r.status != http.StatusOK { + t.Fatalf("add line 1 = %d: %s", r.status, r.raw) + } + if r := f.addLine(saleID, f.productLine("1")); r.status != http.StatusOK { + t.Fatalf("add line 2 = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 3593)); r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + lines := f.getSale(t, saleID)["lines"].([]any) + first := lines[0].(map[string]any)["id"].(string) + second := lines[1].(map[string]any)["id"].(string) + legacyReturnOf(t, f, saleID, "0.5", "5.50", "2.75", "2.75", "0.24", "2.99", "CASH") + + // the second line keeps its whole headroom: its one unit comes back + if r := f.returnOn(t, saleID, second, "1"); r.status != http.StatusCreated { + t.Fatalf("return of the second line's unit = %d: %s", r.status, r.raw) + } + // the first line carries the legacy half: two more of it are refused + r := f.returnOn(t, saleID, first, "2") + if r.status != http.StatusConflict { + t.Fatalf("return of the first line whole = %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) + } + // one and a half of it still come back: 2 sold less the legacy half + if r := f.returnOn(t, saleID, first, "1.5"); r.status != http.StatusCreated { + t.Fatalf("return of the first line's remainder = %d: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} + +// RULE (sixth review P3-1): repeated partial returns of fractional +// quantities refund the sale exactly: the return that finishes every +// taxable line takes the rest of the tax (the rounded shares left two +// cents owed), and each restock costs the difference of the cumulative +// positions (the rounded shares restocked three cents past what the sale +// relieved). +func TestSixHalfReturnsOfALineRefundItExactly(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + // 3 at 5.50: line 1650, tax 146, total 1796 + saleID := f.startSale(&f.customerID) + 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", 1796)); 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) + // five shares of 275 line and 24 tax, then the completing half takes the + // remainder: 275 of line and 26 of tax + for i, want := range []int64{299, 299, 299, 299, 299, 301} { + r := f.returnOn(t, saleID, lineID, "0.5") + if r.status != http.StatusCreated { + t.Fatalf("half return %d = %d: %s", i+1, r.status, r.raw) + } + if got := num(t, r.body, "total_cents"); got != -want { + t.Errorf("half return %d total = %d, want %d", i+1, got, -want) + } + f.assertARInvariants(t) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("cash balance = %d, want 0 (1796 in, exactly 1796 out)", got) + } + // the restocks relieved exactly the cost the sale relieved: 3 at 3.25 + var cogs int64 + if err := f.db.Pool.QueryRow(context.Background(), ` + SELECT COALESCE(ROUND(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 = '5010' AND e.source_ref_id::text IN + (SELECT credit_memo_id::text FROM pos_returns WHERE original_transaction_id = $1)`, saleID).Scan(&cogs); err != nil { + t.Fatal(err) + } + if cogs != 975 { + t.Errorf("the six restocks relieved %d of cost of goods sold, want exactly the 975 the sale relieved", cogs) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100 (the three units back once)", got) + } +} + +// RULE (sixth review P2-2, fifth review P2-2): one request naming the same +// sale line several times cannot escape the remainder rule: each duplicate +// would take its own rounded share against the same earlier total, refunding +// a cent more per duplicate than the sale took. The second naming is a 400 +// naming the field. +func TestARequestNamingOneSaleLineTwiceIsRefused(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + // 3 at 5.50 less one cent: line 1649, tax 146, total 1795 + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "3", + "discount_cents": 1, "discount_reason": "penny off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 1795)); 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.do("POST", "/api/v1/pos/returns", map[string]any{ + "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}, + {"line_id": lineID, "quantity": "1", "restock": false}, + }, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusBadRequest { + t.Fatalf("a request naming one sale line three times = %d, want 400: %s", r.status, r.raw) + } + _, _, fields := errorOf(t, r) + if len(fields) == 0 || fields[0] != "lines[1].line_id" { + t.Errorf("fields = %v, want lines[1].line_id (the second naming)", fields) + } + // nothing moved: the drawer keeps the sale, the stock keeps its 97 + if got := f.accountBalance("1010"); got != 1795 { + t.Errorf("cash balance = %d, want 1795 (the refused request paid nothing)", got) + } + if got := f.stock(); got != "97.0000/0.0000" { + t.Errorf("stock = %s, want 97", got) + } + f.assertARInvariants(t) + // the same quantity in one line is the return that was meant + if r := f.returnOn(t, saleID, lineID, "3"); r.status != http.StatusCreated { + t.Fatalf("the whole line in one naming = %d: %s", r.status, r.raw) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("cash balance = %d, want 0 (the sale in, the whole line out)", got) + } + f.assertARInvariants(t) +} + +// RULE (fifth review P3-1, sixth review P2-2): the remainder rule's +// completing branch is provable alone: on a line whose rounded per unit +// shares sum below it (3 units totalling 1648 round to 549 each), the return +// that completes the line takes the remainder (550, not its own 549), so +// repeated partial returns refund exactly what the sale took. With the +// completing branch removed this test goes red on the under refund. +func TestTheReturnThatCompletesALineTakesTheRemainderExactly(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + // 3 at 5.50 less two cents: line 1648, tax 146, total 1794 + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "3", + "discount_cents": 2, "discount_reason": "two off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 1794)); 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) + // two returns of one unit: the rounded shares 549 and 549 + for i, want := range []int64{598, 598} { + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("partial return %d = %d: %s", i+1, r.status, r.raw) + } + if got := num(t, r.body, "total_cents"); got != -want { + t.Errorf("partial return %d total = %d, want %d", i+1, got, -want) + } + f.assertARInvariants(t) + } + // the completing return takes the remainder: 1648 less the 1098 the + // earlier returns took is 550, a cent above its own rounded share + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("the completing return = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "subtotal_cents"); got != -550 { + t.Errorf("the completing return subtotal = %d, want -550 (the line's remainder, not the rounded share)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("cash balance = %d, want 0 (1794 in, exactly 1794 out)", got) + } + f.assertARInvariants(t) +} + +// RULE (sixth review P1-1): the legacy cents lower the bearer line's +// remaining money, so the return that would round a share past the line +// (0.9 of a 3 unit line whose earlier returns rounded up) is clamped, and +// everything the sale took comes back exactly: the linked shares, the +// clamped last share and the legacy refund sum to the sale. This is also the +// remainder rule's cap branch proved alone: with the clamp removed the last +// return refunds a cent past the sale. +func TestALegacyRefundBoundsTheLinesRemainingMoney(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + // 3 at 5.50 less one cent: line 1649, tax 146, total 1795 + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "3", + "discount_cents": 1, "discount_reason": "penny off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 1795)); 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) + // a tenth of a unit came back before the contract, at the list price + legacyReturnOf(t, f, saleID, "0.1", "5.50", "0.55", "0.55", "0.04", "0.59", "CASH") + + // one unit, then another: each rounds its share up to 550 + for i, want := range []int64{599, 599} { + r := f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusCreated { + t.Fatalf("partial return %d = %d: %s", i+1, r.status, r.raw) + } + if got := num(t, r.body, "total_cents"); got != -want { + t.Errorf("partial return %d total = %d, want %d", i+1, got, -want) + } + } + // nine tenths: the rounded share is 495, but only 494 remain of the line + // (1649 less the 1100 the earlier returns took less the 55 the legacy + // return took) + r := f.returnOn(t, saleID, lineID, "0.9") + if r.status != http.StatusCreated { + t.Fatalf("the clamped return = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "subtotal_cents"); got != -494 { + t.Errorf("the clamped return subtotal = %d, want -494 (the share clamped to what remains of the line)", got) + } + // everything the sale took came back exactly: the linked refunds sum to + // the sale less the legacy refund (whose cents never left this drawer, + // the row was written by hand), so the drawer keeps exactly the legacy + // 59 and never a cent more was paid + if got := f.accountBalance("1010"); got != 59 { + t.Errorf("cash balance = %d, want 59 (the sale in, the linked refunds out to the cent of the sale less the legacy refund)", got) + } + f.assertARInvariants(t) +} + +// RULE (eighth review P3-1): a line the legacy return partly brought back +// completes when the linked returns bring the rest: the return that does it +// takes the line's whole remaining money and the sale's whole remaining tax, +// so no cent is left owed. 3 at 5.50 less one cent (line 1649, tax 146, total +// 1795) with a tenth of a unit legacy returned for 50 cents and 4 of tax: the +// return of the other 2.9 takes 1599 and 142, and with the legacy 54 the sale +// comes back whole. +func TestAPartlyLegacyLineCompletesWithTheLinkedReturn(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.openTill(0) + saleID := f.startSale(&f.customerID) + if r := f.addLine(saleID, map[string]any{"product_id": f.productID.String(), "quantity": "3", + "discount_cents": 1, "discount_reason": "penny off"}); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + if r := f.completeSale(saleID, tender("cash", 1795)); 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) + legacyReturnOf(t, f, saleID, "0.1", "5.50", "0.50", "0.50", "0.04", "0.54", "CASH") + f.assertARInvariants(t) + r := f.returnOn(t, saleID, lineID, "2.9") + if r.status != http.StatusCreated { + t.Fatalf("the completing return = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "subtotal_cents"); got != -1599 { + t.Errorf("the completing return subtotal = %d, want -1599 (the line's remaining money)", got) + } + if got := num(t, r.body, "tax_cents"); got != -142 { + t.Errorf("the completing return tax = %d, want -142 (the sale's tax less the legacy 4)", got) + } + if got := f.accountBalance("1010"); got != 54 { + t.Errorf("cash balance = %d, want 54 (the sale in, the linked refund out; the legacy 54 never left this drawer)", got) + } + f.assertARInvariants(t) +} + +// RULE (ninth review P3-2, the tax completion): the legacy quantity counts in +// the test that the return finishes every taxable line, so the return that +// brings the last goods back takes the sale's whole remaining tax exactly, +// rounded shares and all. A legacy return of one unit carrying one cent less +// tax than the per unit share leaves the completing return a remainder the +// rounded share alone would strand. +func TestTheLegacyQuantityCompletesTheTaxableLine(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) + // 3 at 5.50: line 1650, tax 146, total 1796 + 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, withToken(tender("card", 1796))); 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) + // a legacy return of one unit whose recorded tax is 48, one cent under + // the per unit 49: the linked return of the other two completes the line + legacyReturnOf(t, f, saleID, "1", "5.50", "5.50", "5.50", "0.48", "5.98", "CASH") + f.assertARInvariants(t) + r := f.cardReturnOn(t, saleID, lineID, "2") + if r.status != http.StatusCreated { + t.Fatalf("the completing card return = %d: %s", r.status, r.raw) + } + if got := num(t, r.body, "subtotal_cents"); got != -1100 { + t.Errorf("the completing return subtotal = %d, want -1100 (the line's remaining money)", got) + } + if got := num(t, r.body, "tax_cents"); got != -98 { + t.Errorf("the completing return tax = %d, want -98 (the sale's 146 less the legacy 48: the rounded share 97 would strand a cent)", got) + } + if got := f.scalar(`SELECT ROUND(amount * 100)::bigint FROM pos_refund_intents WHERE sale_id = $1 AND act = 'return'`, saleID).(int64); got != 1198 { + t.Errorf("the claim holds %d cents, want 1198", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/returns_repository.go b/core/internal/pos/returns_repository.go deleted file mode 100644 index 15da55d3d..000000000 --- a/core/internal/pos/returns_repository.go +++ /dev/null @@ -1,168 +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/gablelbm/gable/pkg/middleware" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// GetRegisterBranch resolves a register's branch (its location's branch, else its own pos_registers.branch_id). -// Returns (nil, nil) when the register does not exist. -func (r *PostgresRepository) GetRegisterBranch(ctx context.Context, registerID string) (*uuid.UUID, error) { - var branchID *uuid.UUID - err := r.db.GetExecutor(ctx).QueryRow(ctx, - `SELECT COALESCE(l.branch_id, r.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 AND `+middleware.Wall("", 2, 3), - id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) - 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) AND ` + middleware.Wall("", 2, 3) - args := []any{registerID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)} - if !date.IsZero() { - start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - q += ` AND created_at >= $4 AND created_at < $5` - 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/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 435f37bcd..9c4d1ee3d 100644 --- a/core/internal/pos/service.go +++ b/core/internal/pos/service.go @@ -5,817 +5,723 @@ 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/middleware" + "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) -// 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. +// EventRecorder writes the act's events, last, in its transaction. +type EventRecorder interface { + Write(ctx context.Context, ev outbox.Event) error +} + +// TxRunner runs fn inside one transaction. +type TxRunner interface { + RunInTx(ctx context.Context, fn func(ctx context.Context) error) error +} + +// 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 - 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 - grants *middleware.BranchGuard -} - -// holdRecordBranch refuses an act on a record whose own branch is outside -// what the caller may target while the context carries no branch, the -// record act arm of the no header rule (SEC-NOHEADER-1): a caller that -// sends the header keeps the repository's equality answer, an administrator -// keeps its reach, and a record that does not exist never reaches this -// hold (the branch readers answer it separately, so the act serves the -// module's 404 rather than a refusal naming a branch a missing record -// cannot carry). A record of no branch is refused for a no header user, -// because no grant decides it (the same clause that refuses a user with no -// grants at all); an administrator and a header caller keep the reach the -// rule gives them. -func (s *Service) holdRecordBranch(ctx context.Context, what string, branch *uuid.UUID, id uuid.UUID) error { - if err := s.grants.CheckGrantsBranch(ctx, branch); err != nil { - if errors.Is(err, middleware.ErrPayloadBranchRefused) { - return &RecordBranchRefusedError{What: what, ID: id} - } - return err + db *database.DB + grants *middleware.BranchGuard + 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 +} + +// 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) +} + +// 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 builds the counter's service. +func NewService(db *database.DB, repo Repository, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() } - return nil + return &Service{db: db, repo: repo, logger: logger, now: time.Now, grants: middleware.NewBranchGuard(db)} } -// holdSaleBranch holds a sale's own branch to the caller before any act on -// it runs. The branch is read with no wall, because the walled read a -// no header user's grant hides the record with would answer before the -// refusal could (a hidden record reads as missing, an unheld one must -// refuse by name, SEC-NOHEADER-1). A sale that does not exist answers the -// module's 404. -func (s *Service) holdSaleBranch(ctx context.Context, txID uuid.UUID) error { - branch, found, err := s.repo.TransactionBranch(ctx, txID) +// holdSaleBranch refuses an act on a sale whose own branch is outside what +// the caller may target while the context carries no branch, the record act +// arm of the no header rule (SEC-NOHEADER-1). The branch is read with no +// wall, because the walled read a no header user's grant hides the record +// with would answer before the refusal could: a hidden record reads as +// missing (404), an unheld one refuses by name (403). A sale that does not +// exist answers the module's 404. A caller that sends the header keeps the +// walled read's answer, and an administrator keeps its reach. +func (s *Service) holdSaleBranch(ctx context.Context, saleID uuid.UUID) error { + branch, found, err := s.repo.SaleBranch(ctx, saleID) if err != nil { return err } if !found { - return &MissingRecordError{What: "transaction", ID: txID} + return httpx.NotFound("sale not found") } - return s.holdRecordBranch(ctx, "transaction", branch, txID) + return s.holdRecordBranch(ctx, "sale", &branch, saleID) } -// getTransaction reads a sale for an act, answering a sale the caller's -// wall hides exactly like one that does not exist: the module's 404, so a -// hidden record is missing (never the record refusal's 403, which would -// name a branch, and never the bare 500 a raw no rows error maps to). -func (s *Service) getTransaction(ctx context.Context, txID uuid.UUID) (*POSTransaction, error) { - tx, err := s.repo.GetTransaction(ctx, txID) - if errors.Is(err, pgx.ErrNoRows) { - return nil, &MissingRecordError{What: "transaction", ID: txID} +// holdTillBranch is holdSaleBranch for a till session. +func (s *Service) holdTillBranch(ctx context.Context, sessionID uuid.UUID) error { + branch, found, err := s.repo.TillSessionBranch(ctx, sessionID) + if err != nil { + return err + } + if !found { + return httpx.NotFound("till session not found") } - return tx, err + return s.holdRecordBranch(ctx, "till session", branch, sessionID) } -// WithTillLedger wires GL posting of till drawer over/short at close. -func (s *Service) WithTillLedger(p TillLedgerPoster) *Service { - s.tillLedger = p - return s +func (s *Service) holdRecordBranch(ctx context.Context, what string, branch *uuid.UUID, id uuid.UUID) error { + if err := s.grants.CheckGrantsBranch(ctx, branch); err != nil { + if errors.Is(err, middleware.ErrPayloadBranchRefused) { + return httpx.Forbidden(fmt.Sprintf("%s %s belongs to a branch this caller may not target", what, id)) + } + return err + } + return nil } -// WithPricing enables customer-specific pricing resolution for POS line items. -func (s *Service) WithPricing(calc PriceCalculator) { - s.priceCalc = calc -} +// WithPriceEngine wires the pricing engine. +func (s *Service) WithPriceEngine(p PriceEngine) *Service { s.pricer = p; return s } -// 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 -} +// WithTaxProvider wires the configured tax provider behind the resolver. +func (s *Service) WithTaxProvider(p TaxProvider) *Service { s.tax = p; return s } -// WithGateway enables card processing for CARD tenders carrying a token. -func (s *Service) WithGateway(gw payment.PaymentGateway) *Service { - s.gateway = gw - return s -} +// WithInvoices wires the invoice writer. +func (s *Service) WithInvoices(w InvoiceWriter) *Service { s.invoices = w; return s } -// 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 -} +// WithLedger wires the till over/short poster. +func (s *Service) WithLedger(p LedgerPoster) *Service { s.ledger = p; return s } -// 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, - grants: middleware.NewBranchGuard(db), - } -} +// WithGateway wires the card-present terminal gateway. +func (s *Service) WithGateway(g PaymentGateway) *Service { s.gateway = g; return s } -// 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, - } +// WithAuditLog attaches the audit logger. +func (s *Service) WithAuditLog(l *audit.Logger) *Service { s.auditLog = l; return s } - if session, err := s.repo.GetOpenTillSession(ctx, registerID); err == nil && session != nil { - tx.TillSessionID = &session.ID - } +// WithOutbox wires the event recorder. +func (s *Service) WithOutbox(e EventRecorder) *Service { s.events = e; return s } - if err := s.repo.CreateTransaction(ctx, tx); err != nil { - return nil, err - } +// 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 } - s.logger.Info("POS transaction started", "id", tx.ID, "register", registerID, "till_session", tx.TillSessionID) - return tx, nil +// PendingVoidClaimsOfInvoice answers the standing void claims of the sale +// that owns the invoice, so the AR core's application guard can refuse an +// application to the invoice's interim open cents while the void's outcome +// is unsettled (C2-5, the fourteenth review's P2-1): an application would leave a +// live row the void's completion cannot reverse, stranding the last claim +// and the drawer. The service satisfies account.InvoiceApplyGuard; serve +// wires the two. +func (s *Service) PendingVoidClaimsOfInvoice(ctx context.Context, invoiceID uuid.UUID) ([]uuid.UUID, error) { + return s.repo.PendingVoidClaimsOfInvoice(ctx, invoiceID) } -// AddItem adds a product to the transaction cart. -func (s *Service) AddItem(ctx context.Context, txID uuid.UUID, req AddLineItemRequest) (*POSTransaction, error) { - // An act on a record of a branch outside the caller's grants is refused - // by name before anything runs (SEC-NOHEADER-1). - if err := s.holdSaleBranch(ctx, txID); err != nil { - return nil, err - } - // The transaction must be visible before anything is written: the read - // carries the branch predicate, so a transaction of a branch the caller's - // branch context may not target (or a missing one) names nothing the - // caller may add to, and the line insert never happens (round 6 P1-2: - // the insert used to land and the recalculation fail after it). - tx, err := s.getTransaction(ctx, txID) - if err != nil { - return nil, err - } - // Get the product to populate description and pricing - prod, err := s.productSvc.GetProduct(ctx, req.ProductID) - if err != nil { - return nil, fmt.Errorf("product not found: %w", err) - } +// HeldTenderCredit answers whether a payment's unapplied cash is a declined +// void tender's credit the counter still holds (C2-5, FROM-PRINCIPAL 60), +// so the AR core's held credit guard can refuse any application of it to an +// invoice: the credit is payable back only to its card. The service +// satisfies account.TenderCreditGuard; serve wires the two. +func (s *Service) HeldTenderCredit(ctx context.Context, paymentID uuid.UUID) (account.HeldTenderCredit, bool, error) { + facts, ok, err := s.repo.HeldTenderCreditOfPayment(ctx, paymentID) + if err != nil || !ok { + return account.HeldTenderCredit{}, ok, err + } + return account.HeldTenderCredit{Cents: facts.Cents, SaleID: facts.SaleID, SaleNumber: facts.SaleNumber, + TenderID: facts.TenderID, IntentID: facts.IntentID}, true, nil +} - // Resolve effective price (customer-specific if available) - effectivePrice := prod.BasePrice - if s.priceCalc != nil && tx.CustomerID != nil { - if resolved, pErr := s.priceCalc.CalculateItemPrice(ctx, *tx.CustomerID, req.ProductID, prod.BasePrice, req.Quantity); pErr == nil { - effectivePrice = resolved - } +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) +} - unitPriceCents := int64(effectivePrice*100.0 + 0.5) - lineTotalCents := int64(math.Round(float64(unitPriceCents) * req.Quantity)) +// actorOf reads the acting user from the request context's claims. +func actorOf(ctx context.Context) string { return "" } - item := &POSLineItem{ - TransactionID: txID, - ProductID: req.ProductID, - Description: prod.Description, - Quantity: req.Quantity, - UOM: req.UOM, - UnitPrice: unitPriceCents, - LineTotal: lineTotalCents, +// eventJSON marshals an event's data. +func eventJSON(v map[string]any) []byte { + raw, err := json.Marshal(v) + if err != nil { + return []byte(`{}`) } + return raw +} - if item.UOM == "" { - item.UOM = string(prod.UOMPrimary) +// 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 } - - if err := s.repo.AddLineItem(ctx, item); err != nil { - return nil, err + scaled, _, err := salesdoc.ParseTaxRate(rate) + if err != nil { + return 0 } + return int64(salesdoc.TaxAt(taxableCents, scaled)) +} - // Recalculate totals - return s.recalculateTotals(ctx, txID) +func (s *Service) recordEvent(ctx context.Context, ev outbox.Event) error { + if s.events == nil { + return nil + } + return s.events.Write(ctx, ev) } -// 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.holdSaleBranch(ctx, txID); 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 } - // The sale must be visible before a line goes: a sale the caller's wall - // hides is a missing record (the module's 404), and nothing is deleted. - if _, err := s.getTransaction(ctx, txID); err != nil { - return nil, err + currency := defaultCurrency + if in.CustomerID != nil { + _, facts, err := s.namedCustomer(ctx, *in.CustomerID) + if err != nil { + return nil, err + } + currency = facts.Currency } - if err := s.repo.RemoveLineItem(ctx, itemID); err != nil { + 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, + Lines: []salesdoc.Line{}, Tenders: []Tender{}} + 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 s.recalculateTotals(ctx, txID) + return 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 +// 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 +} - if err := s.holdSaleBranch(ctx, txID); 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 + } 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 { + 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 + } + } } + 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 +} - // 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.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.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) { + if err := s.holdSaleBranch(ctx, saleID); err != nil { + return nil, err + } + 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 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 } - if err := s.repo.AddTender(ctx, tender); err != nil { + sale.WalkInID = walkInID + } + 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 } - - // 6. Complete the transaction - now := time.Now() - tx.Status = TransactionStatusCompleted - tx.CompletedAt = &now - if err := s.repo.UpdateTransaction(ctx, tx); err != nil { + // the new lines keep the order Explode built (a kit before its + // components), numbered after the cart's own + for i := range lines { + lines[i].Position = len(existing) + i + } + 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 } + return out, nil +} - // 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 -} - -// 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 - - if err := s.holdSaleBranch(ctx, txID); err != nil { +// RemoveLine removes a line from an open cart. +func (s *Service) RemoveLine(ctx context.Context, saleID, lineID uuid.UUID) (*Sale, error) { + if err := s.holdSaleBranch(ctx, saleID); err != nil { return nil, err } - - err := s.db.RunInTx(ctx, func(ctx context.Context) error { - tx, err := s.getTransaction(ctx, txID) + 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.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.getTransaction(ctx, txID) - if err != nil { + if sale.Lines, err = s.repo.GetLines(ctx, saleID); err != nil { return nil, err } - - items, err := s.repo.GetLineItems(ctx, txID) - if err != nil { + if sale.Tenders, err = s.repo.GetTenders(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 { - 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, - } - - // The batch register is the register the client says the batch ran on, - // and the log row names it: hold it to the caller's branch context the - // way each replayed create is held, before any item runs and before the - // batch is logged, so neither the log nor a replay names another branch's - // register for a caller held to one branch. A batch register that does - // not resolve is refused the same way (r17 P3-3): a log row under a - // register nobody made is never written, and a lookup failure answers an - // error rather than skipping the hold. An empty register_id names no - // register either (r19 P3-1), so it is refused the same 400 rather than - // logged under a register nobody made. - if req.RegisterID == "" { - return nil, &UnknownRegisterError{RegisterID: req.RegisterID} - } - branch, err := s.repo.GetRegisterBranch(ctx, req.RegisterID) - if err != nil { - return nil, fmt.Errorf("sync: register branch lookup: %w", err) - } - if branch == nil { - return nil, &UnknownRegisterError{RegisterID: req.RegisterID} - } - if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && *reqBranch != *branch { - return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} - } - // A caller with no branch in context is held to the caller's grants. - if err := s.grants.CheckGrantsBranch(ctx, branch); err != nil { - if errors.Is(err, middleware.ErrPayloadBranchRefused) { - return nil, &RegisterBranchRefusedError{RegisterID: req.RegisterID} - } - return nil, err - } - - 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 - } +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}}} +} - tx := &POSTransaction{ - ID: offlineTx.ClientID, - RegisterID: registerID, - CashierID: offlineTx.CashierID, - CustomerID: offlineTx.CustomerID, - Status: TransactionStatusOpen, - SyncedFrom: &syncTag, - ClientCreatedAt: &offlineTx.ClientCreatedAt, - } +func lineField(name string) string { return "line." + name } - 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 - } - - // 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/session_lock_test.go b/core/internal/pos/session_lock_test.go new file mode 100644 index 000000000..6b3ea2ec2 --- /dev/null +++ b/core/internal/pos/session_lock_test.go @@ -0,0 +1,253 @@ +// 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" + "time" + + "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 := f.counterInvoices(t); 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) +} + +// RULE (fourth review P2-2, fifth review P2-1): a cash return locks the +// register's open session FOR SHARE inside its transaction and refuses when +// the session closed under that lock, so it never lands in a counted drawer. +// The proof is the discriminating one: a plain timing probe cannot tell the +// FOR SHARE from its absence (the return's till_session_id foreign key +// insert blocks on the holder too), but a return parked on the session row +// while the holder closes the drawer and commits must answer 409 +// session_closed; without the lock it would commit into the closed session, +// and without the refusal branch it would commit sessionless. +func TestACashReturnParkedOnTheSessionRefusesWhenItCloses(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)) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + ctx := context.Background() + holder, err := f.db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + // a failure after this point must never leave the holder open: the + // package would hang on the session row + t.Cleanup(func() { _ = holder.Rollback(context.Background()) }) + if _, err := holder.Exec(ctx, `SELECT 1 FROM till_sessions WHERE id = $1 FOR UPDATE`, sessionID); err != nil { + t.Fatal(err) + } + done := make(chan resp, 1) + go func() { + done <- f.returnOn(t, saleID, lineID, "1") + }() + select { + case r := <-done: + t.Fatalf("the cash return finished beside the lock holder (status %d): its FOR SHARE of the session row is missing", r.status) + case <-time.After(400 * time.Millisecond): + } + // the holder closes the drawer and commits: the parked return wakes + // under its FOR SHARE, re-reads the session, and refuses + if _, err := holder.Exec(ctx, `UPDATE till_sessions SET status = 'CLOSED', closed_at = NOW() WHERE id = $1`, sessionID); err != nil { + t.Fatal(err) + } + if err := holder.Commit(ctx); err != nil { + t.Fatal(err) + } + r := <-done + if r.status != http.StatusConflict { + t.Fatalf("the cash return parked on a closing 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 (the session closed under the return's lock)", blockers) + } + // nothing left the counted drawer and no return row exists + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("cash balance = %d, want 599 (the refused return paid nothing)", got) + } + if got := f.counterReturns(t); got != 0 { + t.Errorf("%d returns after the refusal, want 0", got) + } + 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 +} + +// RULE (third review P3-E): the completion's FOR SHARE and the close's FOR +// UPDATE on the session row are real locks, proven deterministically: with +// the row held FOR UPDATE by another transaction, both acts block until it +// releases, then run. A completion that skipped the lock would finish beside +// the holder; so would a close that aggregated without the lock. +func TestTheCompletionAndTheCloseBlockOnTheSessionRowLock(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) + ctx := context.Background() + + // the completion blocks on its FOR SHARE of the session row + 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) + } + holder, err := f.db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + // a failure after this point must never leave the holder open: the + // package would hang on the session row + t.Cleanup(func() { _ = holder.Rollback(context.Background()) }) + if _, err := holder.Exec(ctx, `SELECT 1 FROM till_sessions WHERE id = $1 FOR UPDATE`, sessionID); err != nil { + t.Fatal(err) + } + done := make(chan int, 1) + go func() { + done <- f.completeSale(saleID, tender("cash", 599)).status + }() + select { + case st := <-done: + t.Fatalf("the completion finished beside the lock holder (status %d): its FOR SHARE of the session row is missing", st) + case <-time.After(400 * time.Millisecond): + } + if err := holder.Rollback(ctx); err != nil { + t.Fatal(err) + } + if st := <-done; st != http.StatusOK { + t.Fatalf("the completion after the release = %d, want 200", st) + } + f.assertARInvariants(t) + if 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)); r.status != http.StatusOK { + t.Fatalf("close the first session = %d: %s", r.status, r.raw) + } + + // the close blocks on its FOR UPDATE of the session row + sessionID2 := f.openTill(0) + holder2, err := f.db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = holder2.Rollback(context.Background()) }) + if _, err := holder2.Exec(ctx, `SELECT 1 FROM till_sessions WHERE id = $1 FOR UPDATE`, sessionID2); err != nil { + t.Fatal(err) + } + done2 := make(chan int, 1) + go func() { + done2 <- f.do("POST", "/api/v1/pos/till/"+sessionID2+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 0}}, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)).status + }() + select { + case st := <-done2: + t.Fatalf("the close finished beside the lock holder (status %d): its FOR UPDATE of the session row is missing", st) + case <-time.After(400 * time.Millisecond): + } + if err := holder2.Rollback(ctx); err != nil { + t.Fatal(err) + } + if st := <-done2; st != http.StatusOK { + t.Fatalf("the close after the release = %d, want 200", st) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/sync.go b/core/internal/pos/sync.go new file mode 100644 index 000000000..e94dedab5 --- /dev/null +++ b/core/internal/pos/sync.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "context" + "errors" + "net/http" + + "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) { + // The batch register is held to the caller before any item runs and + // before the log row is written (PR 83's rounds 7 to 9): an empty or + // unknown register is 400, a register of another branch is the 403 the + // register rule answers, and a refused batch writes nothing at all. + if batch.RegisterID == "" { + return nil, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "register_id", Message: "the batch names no register"}) + } + if _, err := s.repo.GetRegisterBranch(ctx, batch.RegisterID); err != nil { + var hx *httpx.Error + if errors.As(err, &hx) && hx.Status == http.StatusNotFound { + return nil, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "register_id", Message: "the batch names no register the counter knows"}) + } + return nil, err + } + 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] + 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 { + // 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 + } + // 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 + } + // 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 + 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.namedCustomer(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/tender_live_test.go b/core/internal/pos/tender_live_test.go new file mode 100644 index 000000000..7d9bb3558 --- /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 := f.counterInvoices(t) + 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 := f.counterInvoices(t); 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) +} diff --git a/core/internal/pos/till.go b/core/internal/pos/till.go index d5fec1a4d..a5c44be7d 100644 --- a/core/internal/pos/till.go +++ b/core/internal/pos/till.go @@ -5,81 +5,41 @@ package pos import ( "context" - "errors" "fmt" + "strings" "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/outbox" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) -// 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 CARD figure, informational (cards are not counted into +// the drawer), names what the session net collected on cards, its still +// completed sales' card tenders less the void refunds a resolution booked +// (r12 P3-2), the same net the CASH figure carries for returns; 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, cardRefunds 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 + // The CARD figure is written only where the session collected on cards: + // a cash only session's report carries no card figure, as before (a + // booked card refund always implies the tender that was collected). + if cardTendered, ok := tendered["CARD"]; ok { + expected["CARD"] = cardTendered - cardRefunds + } 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 @@ -89,44 +49,51 @@ 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)) + return nil, conflict("till_open", fmt.Sprintf("register %s already has an open till session", registerID)) } - - session := &TillSession{ - RegisterID: registerID, - CashierID: cashierID, - Status: TillSessionOpen, - OpeningFloat: openingFloatCents, - } - 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, + ExpectedByMethod: map[string]int64{}, CountedByMethod: map[string]int64{}} + 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) } @@ -144,95 +111,126 @@ 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, agg.CardRefundsCents), }, 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) { +// 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) { // An act on a session of a branch outside the caller's grants is refused - // by name before anything runs (SEC-NOHEADER-1); the branch is read with - // no wall, because the walled read would hide the session before the - // refusal could name it. A session that does not exist answers the - // module's 404 (r4 P3-1), never the refusal. - branch, found, err := s.repo.TillSessionBranch(ctx, sessionID) - if err != nil { - return nil, err - } - if !found { - return nil, &MissingRecordError{What: "till session", ID: sessionID} - } - if err := s.holdRecordBranch(ctx, "till session", branch, sessionID); err != nil { + // by name before anything runs (SEC-NOHEADER-1); a session that does not + // exist, or that the caller's branch context hides, answers the 404. + if err := s.holdTillBranch(ctx, sessionID); err != nil { return nil, err } - report, err := s.TillReport(ctx, sessionID) - if errors.Is(err, pgx.ErrNoRows) { - // The walled read hides a session outside the caller's branch - // context: it is missing for this caller, the module's 404. - return nil, &MissingRecordError{What: "till session", ID: 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 { + 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 + } + if report.Session.Status != TillOpen { + return httpx.InvalidStateTransition("the till session is not open") + } + // A card refund claim of one of the session's sales still standing + // refuses the close (eighth review P2-2): its terminal call may still + // move money after the drawer was counted, and the void or return + // behind it would then be refused session_closed with the card + // already refunded. Under the session lock, before anything is + // counted. + claims, err := s.repo.SessionPendingRefundIntentIDs(ctx, sessionID) + if err != nil { + return err + } + if len(claims) > 0 { + ids := make([]string, 0, len(claims)) + for _, id := range claims { + ids = append(ids, id.String()) + } + return conflict("refund_in_flight", + fmt.Sprintf("a card refund claim of one of this session's sales is still in flight (claims %s): settle each at POST /api/v1/pos/refund-intents/{id}/resolve before closing the drawer", + strings.Join(ids, ", "))) + } + 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_card_figure_test.go b/core/internal/pos/till_card_figure_test.go new file mode 100644 index 000000000..76595f2d9 --- /dev/null +++ b/core/internal/pos/till_card_figure_test.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The till report's card figure through a partly resolved void (r12 P3-2): +// the CARD entry of expected_by_method is the pass-through of what the +// session's sales collected, so a void refund a resolution booked had left it +// naming the full tender. The figure subtracts the booked void refunds of the +// session's sales the same way the CASH figure subtracts the session's cash +// refunds, and it drops with the sale when the void completes. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// RULE (r12 P3-2, the M10 shape): a sale of cash 199 with two card tenders +// of 200 each, voided with one card refunded at the terminal and one timed +// out. While the sale still stands completed between the resolutions, the +// report's CARD figure names what the session net collected on cards (400 +// tendered less the 200 the resolution booked), and once the last claim is +// resolved and the void completes, both figures name the voided sale's +// absence: the card figure 0 and the cash back to the float. +func TestTheTillReportCardFigureSubtractsBookedVoidRefunds(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{nil, context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + sessionID := f.openTill(10000) + 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) + } + body := f.completeSale(saleID, tender("cash", 199), withToken(tender("card", 200)), withToken(tender("card", 200))) + if body.status != http.StatusOK { + t.Fatalf("complete = %d: %s", body.status, body.raw) + } + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "one moved, one unknown", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the void with one card timed out = %d, want 500: %s", r.status, r.raw) + } + intents := f.saleIntents(t, saleID) + var moved, unknown map[string]any + for _, in := range intents { + if in["refund_tx_id"] != "" { + moved = in + } else if in["state"] == "PENDING" { + unknown = in + } + } + if moved == nil || unknown == nil { + t.Fatalf("the claims are %v, want one carrying the refund id and one standing without it", intents) + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 400 { + t.Errorf("the card figure before any resolution = %d, want 400 (both tenders still counted)", got) + } + if got := f.expectedOf(t, sessionID, "CASH"); got != 10199 { + t.Errorf("the cash figure before any resolution = %d, want 10199 (the float and the cash tender)", got) + } + // the moved card's refund books: the card figure names the net collection + if rr := f.resolveOn(t, moved["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": moved["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance"); rr.status != http.StatusOK { + t.Fatalf("the moved tender's resolution = %d: %s", rr.status, rr.raw) + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 200 { + t.Errorf("the card figure with one refund booked = %d, want 200 (the 400 tendered less the 200 refunded)", got) + } + if got := f.expectedOf(t, sessionID, "CASH"); got != 10199 { + t.Errorf("the cash figure with one refund booked = %d, want 10199 (a card refund pays out of no drawer)", got) + } + // the last claim resolves and the void completes: both figures drop with + // the sale + if rr := f.resolveOn(t, unknown["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-72-T", "reason": "the terminal log shows no refund", + }, "finance"); rr.status != http.StatusOK { + t.Fatalf("the unknown claim's no refund = %d: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the last resolution completes the void)", got) + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 0 { + t.Errorf("the card figure after the void completed = %d, want 0 (the voided sale left the session's figures)", got) + } + if got := f.expectedOf(t, sessionID, "CASH"); got != 10000 { + t.Errorf("the cash figure after the void completed = %d, want 10000 (the voided cash payment left the expectation)", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != 2 { + t.Errorf("%d gateway refund calls, want 2 (the void's own two card calls)", calls) + } + f.assertARInvariants(t) +} + +// RULE (fourteenth review P3-2): the CARD figure subtracts the session's card returns the +// same way the CASH figure subtracts its cash returns: a return's card +// refund rides its credit memo, so the figure joins it there. +func TestTheTillReportCardFigureSubtractsCardReturns(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + sessionID := f.openTill(10000) + saleID, body := f.saleOf("3", withToken(tender("card", 1796))) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + if got := f.expectedOf(t, sessionID, "CARD"); got != 1796 { + t.Fatalf("the card figure after the sale = %d, want 1796", got) + } + // two card returns of one unit each: 599 back to the card twice + for i := 0; i < 2; i++ { + if r := f.cardReturnOn(t, saleID, lineID, "1"); r.status != http.StatusCreated { + t.Fatalf("card return %d = %d: %s", i+1, r.status, r.raw) + } + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 598 { + t.Errorf("the card figure after two card returns = %d, want 598 (1796 collected less the 1198 refunded to the card)", got) + } + if got := f.expectedOf(t, sessionID, "CASH"); got != 10000 { + t.Errorf("the cash figure after two card returns = %d, want 10000 (a card return pays out of no drawer)", got) + } + f.assertARInvariants(t) +} + +// RULE (the report's shape): the CARD figure is written only where the +// session collected on cards. A cash only session's report carries no CARD +// entry at all (a zero figure would name a collection that never happened), +// and the close stores the same map. +func TestTheTillReportCarriesNoCardFigureForACashOnlySession(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + sessionID := f.openTill(10000) + f.saleOf("1", tender("cash", 599)) + r := f.do("GET", "/api/v1/pos/till/"+sessionID+"/report", nil, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("the till report = %d: %s", r.status, r.raw) + } + byMethod, ok := r.body["expected_by_method"].(map[string]any) + if !ok { + t.Fatalf("the report carries no expected_by_method: %s", r.raw) + } + if _, ok := byMethod["CARD"]; ok { + t.Errorf("the cash only session's report carries a CARD figure (%v), want none", byMethod["CARD"]) + } + if v := int64Of(byMethod["CASH"]); v != 10000+599 { + t.Errorf("expected CASH = %d, want %d (the float and the cash tender)", v, 10000+599) + } + if cr := f.do("POST", "/api/v1/pos/till/"+sessionID+"/close", map[string]any{ + "counted_by_method": map[string]any{"cash": 10599}, + }, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); cr.status != http.StatusOK { + t.Fatalf("the close = %d: %s", cr.status, cr.raw) + } + if _, ok := f.tillExpected(t, sessionID)["CARD"]; ok { + t.Errorf("the closed session's stored expected_by_method carries a CARD figure, want none") + } +} diff --git a/core/internal/pos/till_repository.go b/core/internal/pos/till_repository.go index b64a23fab..2ec8d58ac 100644 --- a/core/internal/pos/till_repository.go +++ b/core/internal/pos/till_repository.go @@ -10,278 +10,297 @@ import ( "fmt" "time" - "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/middleware" "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 COALESCE(l.branch_id, r.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) - } - // Hold the caller's branch context against the register's branch the - // way the sale does: nothing is written on a mismatch, and a caller - // with no branch in context is held to the caller's grants. - if reqBranch := branchctx.IDForQuery(ctx); reqBranch != nil && registerBranch != nil && *reqBranch != *registerBranch { - return &RegisterBranchRefusedError{RegisterID: s.RegisterID} - } - if err := r.holdRegisterBranch(ctx, s.RegisterID, registerBranch); err != nil { - return 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 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.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 openedAt != nil { + s.OpenedAt = httpx.TimestampOf(*openedAt) } - if len(expectedJSON) > 0 { - _ = json.Unmarshal(expectedJSON, &s.ExpectedByMethod) + s.ClosedAt = httpx.PtrTimestamp(closedAt) + s.Status = TillSessionStatus(status) + if s.ExpectedByMethod == nil { + s.ExpectedByMethod = map[string]int64{} } - if len(countedJSON) > 0 { - _ = json.Unmarshal(countedJSON, &s.CountedByMethod) + if s.CountedByMethod == nil { + s.CountedByMethod = map[string]int64{} } 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` + +// 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 `+middleware.Wall("", 2, 3)+` `+mode, id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return httpx.NotFound("till session not found") + } + return err +} + +// TillSessionBranch reads a till session's own branch with no wall, for the +// act hold that must decide before the walled read hides the record. A +// session that does not exist answers found false, and one that exists with +// no branch answers found true and a nil branch. +func (r *PostgresRepository) TillSessionBranch(ctx context.Context, id uuid.UUID) (*uuid.UUID, bool, error) { + var branch *uuid.UUID + err := r.ex(ctx).QueryRow(ctx, `SELECT branch_id FROM till_sessions WHERE id = $1`, id).Scan(&branch) + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("failed to read the till session's branch: %w", err) + } + return branch, true, nil +} 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 AND `+middleware.Wall("", 2, 3), - id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) - 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 AND `+middleware.Wall("t", 2, 3), id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx))) + 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). A -// session of a branch the caller's branch context may not target, or of a -// branch outside the grants a caller with no branch in context is held to, -// reads as none (the branch predicate the transaction reads carry). 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' AND `+middleware.Wall("", 2, 3), - registerID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) - 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' + AND `+middleware.Wall("t", 2, 3), registerID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx))) + 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), the cash refunds of the session's returns through their credit +// memos, and the card refunds booked against the still completed sales' card +// tenders (the void refunds resolutions book). 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) + return nil, fmt.Errorf("failed to aggregate the session's 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) + 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 } - return agg, rows.Err() + 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) + } + // The card refunds booked against the session's still completed sales' + // card tenders (r12 P3-2): a void refund a resolution booked is written + // straight against the tender's payment (a return's refund goes through + // its credit memo and never joins here), and the figure drops with the + // tender when the void completes, the same completed sale filter the + // tendered sum carries. + if err := r.ex(ctx).QueryRow(ctx, ` + SELECT COALESCE(SUM(ROUND(rf.amount * 100)::bigint), 0) + FROM payment_refunds rf + JOIN payments p ON p.id = rf.payment_id + 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' AND rf.method = 'CARD'`, sessionID). + Scan(&agg.CardRefundsCents); err != nil { + return nil, fmt.Errorf("failed to aggregate the session's card refunds: %w", err) + } + // The session's card returns, through their credit memos, as the CASH + // figure counts its cash returns (fourteenth review P3-2): a card return's refund rides + // its memo, never the tender's payment, so without this the CARD figure + // disagreed with the books by every card return of the session. + var cardReturnRefunds int64 + 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 = 'CARD'`, sessionID).Scan(&cardReturnRefunds); err != nil { + return nil, fmt.Errorf("failed to aggregate the session's card return refunds: %w", err) + } + agg.CardRefundsCents += cardReturnRefunds + 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)) + 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(&generated) + z.GeneratedAt = httpx.TimestampOf(generated) 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 AND `+middleware.Wall("", 2, 3), - sessionID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) - z, err := r.scanZReport(row) + 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 AND `+middleware.Wall("", 2, 3), sessionID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)). + 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") + } 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) AND ` + middleware.Wall("", 2, 3) - args := []any{registerID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)} - if !date.IsZero() { - start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) - q += ` AND generated_at >= $4 AND generated_at < $5` - 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...) + // 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 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) } defer rows.Close() - var out []ZReport + 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 + 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 } - out = append(out, *z) + z.GeneratedAt = httpx.TimestampOf(generatedAt) + 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/tx_test.go b/core/internal/pos/tx_test.go new file mode 100644 index 000000000..c45935380 --- /dev/null +++ b/core/internal/pos/tx_test.go @@ -0,0 +1,428 @@ +// 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" + "net/http" + "sync" + "testing" + "time" + + "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.RequireDBMaxConns(t, 4), func(f *fixture) { + f.events.fail = "pos_transaction.completed" + }) + 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 := f.counterInvoices(t) + paymentsBefore := f.counterPayments(t) + entriesBefore := f.counterEntries(t) + 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. Every count is this fixture's own (the database + // is shared with whatever runs beside it). + for _, c := range []struct { + what string + got int64 + want int64 + }{ + {"invoices", f.counterInvoices(t), invoicesBefore}, + {"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' AND entity_id = $1`, saleID), 0}, + } { + if c.got != c.want { + t.Errorf("%s = %d, want %d", c.what, c.got, c.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 := f.counterInvoices(t); got != invoicesBefore+1 { + t.Errorf("%d invoices after the retry, want %d", got, invoicesBefore+1) + } + _ = saleID2 + + // The same proof for the void. + f.events.fail = "pos_transaction.voided" + f2 := newFixture(t, testutil.RequireDBMaxConns(t, 4), 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.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) + 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 := f.counterInvoices(t); 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.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)`, + 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) + } + // 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() + 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++ { + 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) + } + } + // 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.id IN (SELECT invoice_id FROM pos_transactions WHERE invoice_id IS NOT 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 TestVoidRacingTwoReturns(t *testing.T) { + testutil.LockOutboxTables(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 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 + }() + 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, "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() + won := func(status int) bool { return status >= 200 && status < 300 } + 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: 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) + } +} + +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) 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 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)) +} + +// RULE (third review P2-3): the return cap's in-transaction recheck is the +// guard that holds under a race: three concurrent returns of the one unit a +// sale sold let exactly one through (the pre-transaction check passes for +// every contender; the recheck under the sale row lock counts the winner). +func TestThreeConcurrentReturnsOfOneUnitLetOneThrough(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4)) + f.ensureOpenTill(t) + saleID, body := f.saleOf("1", tender("cash", 599)) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + // Park every contender past its pre-transaction check: hold the sale row + // FOR UPDATE, let the three returns run their guards (they read committed + // rows and pass), then release. All three reach the in-transaction cap + // recheck under the row lock, where only one may pass. + ctx := context.Background() + park, err := f.db.Pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := park.Exec(ctx, `SELECT 1 FROM pos_transactions WHERE id = $1 FOR UPDATE`, saleID); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + codes := make([]int, 3) + blockersOf := make([]string, 3) + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + r := f.returnOn(t, saleID, lineID, "1") + mu.Lock() + codes[i] = r.status + if r.status != http.StatusCreated { + _, blockers, _ := errorOf(t, r) + if len(blockers) > 0 { + blockersOf[i] = blockers[0] + } + if blockersOf[i] == "" { + t.Errorf("loser %d carried no blocker: %s", i, string(r.raw)) + } + } + mu.Unlock() + }(i) + } + time.Sleep(500 * time.Millisecond) + if err := park.Rollback(ctx); err != nil { + t.Fatal(err) + } + wg.Wait() + wins := 0 + for _, st := range codes { + if st == http.StatusCreated { + wins++ + } + } + if wins != 1 || len(codes) != 3 { + t.Fatalf("return codes = %v, want exactly one 201 and two 409", codes) + } + // the refusals are the cap's, named: the recheck under the sale row lock + // counted the winner before these were allowed + for i, st := range codes { + if st == http.StatusCreated { + continue + } + if st != http.StatusConflict || blockersOf[i] != "exceeds_sold" { + t.Errorf("loser %d = %d %q, want 409 exceeds_sold (the in-transaction cap recheck)", i, st, blockersOf[i]) + } + } + // the drawer paid one refund: the sale's 599 in, one 599 out + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("cash balance = %d, want 0 (the sale in, exactly one refund out)", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100 (the one unit back once)", got) + } + f.assertARInvariants(t) +} + +// RULE (fourth review P2-5): the fixture's balance and count reads scope to +// the fixture's own documents, so the pos suite passes beside the other +// packages writing the same tables in one database (the way CI runs it): a +// foreign package's invoice, memo, payment or entry never enters this +// fixture's account balance. +func TestTheFixturesBalanceReadsOnlyItsOwnDocuments(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + f.ensureOpenTill(t) + f.saleOf("1", tender("cash", 599)) + if got := f.accountBalance("1010"); got != 599 { + t.Fatalf("cash balance = %d, want 599", got) + } + // another package's shape, written beside the fixture: a posted invoice + // of another customer with its own 1010 entry + foreignCustomer := uuid.New() + foreignInvoice := uuid.New() + mustExec(t, f.db, `INSERT INTO customers (id, name, account_number, primary_branch_id) VALUES ($1, 'Elsewhere Co', $2, $3)`, + foreignCustomer, "WOTHER-"+uuid.NewString()[:8], f.branchID) + mustExec(t, f.db, `INSERT INTO invoices (id, customer_id, branch_id, number, status, invoice_date, currency, subtotal, tax_amount, total_amount, amount_open, created_at, updated_at) + VALUES ($1, $2, $3, 'INV-OTHER-1', 'PAID', CURRENT_DATE, 'USD', 4000, 0, 4000, 0, NOW(), NOW())`, foreignInvoice, foreignCustomer, f.branchID) + entry := uuid.New() + mustExec(t, f.db, `INSERT INTO gl_journal_entries (id, memo, source, source_ref_id, status, currency) + VALUES ($1, 'another package''s entry', 'INVOICE', $2, 'POSTED', 'USD')`, entry, foreignInvoice) + mustExec(t, f.db, `INSERT INTO gl_journal_lines (journal_entry_id, account_id, debit, credit, description) + SELECT $1, a.id, 0.40, 0, 'foreign cash' FROM gl_accounts a WHERE a.code = '1010'`, entry) + t.Cleanup(func() { + mustExec(t, f.db, `DELETE FROM gl_journal_lines WHERE journal_entry_id = $1`, entry) + mustExec(t, f.db, `DELETE FROM gl_journal_entries WHERE id = $1`, entry) + mustExec(t, f.db, `DELETE FROM invoices WHERE id = $1`, foreignInvoice) + mustExec(t, f.db, `DELETE FROM customers WHERE id = $1`, foreignCustomer) + }) + if got := f.accountBalance("1010"); got != 599 { + t.Errorf("cash balance = %d with a foreign entry beside it, want 599 (only the fixture's documents count)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/void.go b/core/internal/pos/void.go new file mode 100644 index 000000000..50f688fe2 --- /dev/null +++ b/core/internal/pos/void.go @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// 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 +// (ADR 0005 section 11: "The card gateway is called before the transaction +// and its result written inside it"; section 14.2 C2-5), claimed first +// through a committed refund intent unique per sale and tender, so a second +// void of the sale, or a card return of the same tender, is refused before +// it reaches the terminal and no lock of the money transaction is held +// across the network call; the transaction then reverses the payment's +// application, records the amount as a refund from its unapplied cash and +// marks the intent done; 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 + } + if err := s.holdSaleBranch(ctx, saleID); err != nil { + return nil, err + } + 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())) + } + // 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, and by the intent the refund + // leaves standing. + // 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 { + 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") + } + } + if pre.InvoiceID == nil { + return nil, conflict("no_invoice", "the sale carries no invoice to void") + } + if err := httpx.CheckRevision(pre.Revision, ifMatch, bodyRevision); err != nil { + return nil, err + } + // The card refunds, claimed and taken BEFORE the transaction (ADR 0005 + // section 11 and 14.2 C2-5: the gateway is called before the + // transaction, as every gateway call is). The claim is a committed + // refund intent per card tender, opened in its own short transaction: + // its unique (sale, tender) index is what refuses a second void, or a + // card return of the same tender, before the gateway, because the sale + // row lock cannot span the call. The intent's foreign keys take only FOR + // KEY SHARE, so the claim never parks behind a money transaction. + tenders, err := s.repo.GetTenders(ctx, saleID) + if err != nil { + return nil, err + } + var intents []RefundIntent + for i := range tenders { + t := &tenders[i] + // only a card tender that became a payment is refunded (the AR legs + // follow its payment), and only one behind a gateway transaction + if t.Method != TenderCard || t.PaymentID == nil || t.GatewayTxID == nil || *t.GatewayTxID == "" { + continue + } + intents = append(intents, RefundIntent{ID: uuid.New(), SaleID: saleID, TenderID: t.ID, GatewayTxID: *t.GatewayTxID, + AmountCents: int64(t.AmountCents), Act: "void"}) + } + if len(intents) > 0 && s.gateway == nil { + return nil, conflict("card_terminal", "this register has no card terminal gateway to refund the card tender") + } + // The claims, committed in one short transaction of their own before + // any terminal is reached. The sale row is locked and every guard + // rechecked under it first: a contender whose earlier check read stale + // must not claim and refund a sale the lock now sees voided (or + // returned against, or closed); and in the window between one claim's + // commit and its money transaction, the standing intent's unique index + // is what refuses the next claim. No lock is held across the gateway + // call: this transaction commits before the terminal is reached. + claimErr := s.inTx(ctx, func(ctx context.Context) error { + if err := s.repo.LockSale(ctx, saleID); err != nil { + return err + } + held, err := s.repo.GetSale(ctx, saleID) + if err != nil { + return err + } + if err := httpx.CheckRevision(held.Revision, ifMatch, bodyRevision); err != nil { + return err + } + if held.Status != StatusCompleted { + return httpx.InvalidStateTransition("only a completed sale is voided") + } + 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 held.TillSessionID != nil { + if err := s.repo.LockTillSession(ctx, *held.TillSessionID, false); err != nil { + return err + } + session, err := s.repo.GetTillSession(ctx, *held.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") + } + } + for i := range intents { + if err := s.checkCardClaim(ctx, saleID, intents[i].TenderID, intents[i].AmountCents, intents[i].AmountCents); err != nil { + return err + } + if err := s.repo.InsertRefundIntent(ctx, intents[i]); err != nil { + return err + } + } + return nil + }) + if claimErr != nil { + return nil, claimErr + } + // refunds collects each gateway refund taken outside the transaction: a + // refusing transaction (a failed step, or a guard that lost a race) + // records every refund already made in committed rows of its own, and + // the claims it leaves standing keep the tender from being refunded + // again until that is reconciled (fourth review P1-4). + var refunds []gatewayRefund + for i := range intents { + // The lease is stamped per call (the sixteenth review's P1-2): a + // committed write immediately before this claim's own terminal call + // renews answer_due_at to now plus the lease, so a later tender's + // call in this sequence never runs under a lease the earlier calls + // already spent, and the resolve route cannot release a claim whose + // call is still on the wire. A claim whose stamp fails is never + // called: it is abandoned exactly as the tenders after a refusal + // are, and the refunds already taken are recorded beside it. + if err := s.repo.RestampRefundIntentLease(ctx, intents[i].ID); err != nil { + for j := i; j < len(intents); j++ { + s.abandonRefundIntent(ctx, intents[j]) + } + for _, r := range refunds { + s.recordOrphanRefund(ctx, r, err) + } + return nil, fmt.Errorf("failed to stamp the claim's in flight lease before its terminal call: %w", err) + } + // The call is bounded (fourteenth review P1-1): every path it can take ends inside + // the bound, so the claim it holds never sits in flight past its own + // settle path. + callCtx, cancel := context.WithTimeout(ctx, refundCallTimeout) + res, err := s.gateway.Refund(callCtx, intents[i].GatewayTxID, intents[i].AmountCents) + cancel() + refundErr := err + verdict := refundAccepted + if err == nil { + verdict = refundVerdictOf(res) + if verdict != refundAccepted { + refundErr = conflict("card_refund", fmt.Sprintf("the card refund was not accepted (%s)", res.Status)) + } + } + if refundErr != nil { + // this tender's refund did not go through: an error, or a status + // that is neither a success nor the terminal's own no, carries no + // verdict (the terminal may have moved the money: the claim + // stands and the unknown outcome is recorded, seventh review P2-2 + // and tenth review P2-3), while a definite refusal status proves + // nothing moved (the claim is released). The refunds already + // taken are recorded in committed rows with their claims left + // standing, because the money transaction never opens now. The + // tenders after this one are never asked (fourteenth review P1-1): their + // claims are abandoned, stranded from now rather than held on a + // lease for calls that will never be made. + if err != nil { + s.unknownRefundOutcome(ctx, intents[i], err) + } else if verdict == refundDeclined { + s.failRefundIntent(ctx, intents[i]) + } else { + s.unknownRefundOutcome(ctx, intents[i], noVerdictOf(res)) + } + for j := i + 1; j < len(intents); j++ { + s.abandonRefundIntent(ctx, intents[j]) + } + for _, r := range refunds { + s.recordOrphanRefund(ctx, r, refundErr) + } + if err != nil { + return nil, fmt.Errorf("card refund failed: %w", err) + } + return nil, refundErr + } + // the gateway's own refund id, on the standing claim the moment the + // terminal answers and in its own committed write (seventh review + // P2-3), whatever state the claim is in (fourteenth review P1-1): a claim carrying + // a refund id is a refund that moved + if err := s.repo.MarkRefundIntentRefunded(ctx, intents[i].ID, res.TransactionID); err != nil { + s.logger.Error("CRITICAL: the gateway's refund id was not written to the claim", + "intent_id", intents[i].ID, "sale_id", saleID, "error", err) + } + refunds = append(refunds, gatewayRefund{gatewayTxID: intents[i].GatewayTxID, refundTxID: res.TransactionID, + amountCents: intents[i].AmountCents, tenderID: intents[i].TenderID, act: "void", entity: saleID, actor: actor}) + } + out, err := s.runVoidTx(ctx, saleID, ifMatch, bodyRevision, reason, actor, refunds, nil) + 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 +} + +// runVoidTx runs the void's money transaction: the sale row lock, every guard +// restated under it, the payments reversed, the invoice voided, the stock +// back, the events last, all one transaction. The claims complete with the +// posting; a refusing transaction rolls them back to PENDING with everything +// else and the caller records the refunds that moved. operatorReplay, when +// set, lifts the client revision precondition (the operator's resolution is +// its own authority) and writes resolveAudit inside the transaction. +func (s *Service) runVoidTx(ctx context.Context, saleID uuid.UUID, ifMatch string, bodyRevision *int64, + reason, actor string, refunds []gatewayRefund, resolveAudit *audit.Entry) (*Sale, error) { + 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 resolveAudit == nil { + 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") + } + // The returns check, again under the lock: a return that raced this + // void and won leaves the sale with returns, and this void refuses + // (the refund it already took at the gateway is recorded above, and + // its intent stays standing). + 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") + } + // 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") + } + lines, err := s.repo.GetLines(ctx, saleID) + if err != nil { + return err + } + tenders, err := s.repo.GetTenders(ctx, saleID) + if err != nil { + return err + } + // The claims complete with the posting: the transaction owns them + // now, and a refusal rolls them back to PENDING with everything + // else, recording the refunds that moved. + for i := range refunds { + if err := s.repo.CompleteRefundIntent(ctx, saleID, refunds[i].tenderID, refunds[i].refundTxID); err != nil { + return err + } + } + if resolveAudit != nil && s.auditLog != nil { + if err := s.auditLog.Log(ctx, *resolveAudit); err != nil { + return fmt.Errorf("failed to write the resolution audit row: %w", 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: + applicationID, err := cardApplicationOf(ctx, s, *t.PaymentID) + if err != nil { + return err + } + fx, err := s.ar.Reverse(ctx, account.ReverseIn{ + ApplicationID: applicationID, 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 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 { + 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 +} + +// 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 := 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 +} + +// cardApplicationOf finds the payment's live application on the invoice. A +// read failure is returned, never swallowed into a nil id that would +// surface as a confusing Reverse refusal. +func cardApplicationOf(ctx context.Context, s *Service, paymentID uuid.UUID) (uuid.UUID, error) { + 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, fmt.Errorf("failed to read the card payment's application: %w", err) + } + return id, nil +} + +// cardApplicationLive answers the payment's live application when one stands: +// the per tender void resolution skips a tender whose leg an earlier +// resolution already reversed. +func cardApplicationLive(ctx context.Context, s *Service, paymentID uuid.UUID) (uuid.UUID, bool, error) { + 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 errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, fmt.Errorf("failed to read the card payment's application: %w", err) + } + return id, true, nil +} diff --git a/core/internal/pos/void_credit_hold_test.go b/core/internal/pos/void_credit_hold_test.go new file mode 100644 index 000000000..71967362b --- /dev/null +++ b/core/internal/pos/void_credit_hold_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The declined void tender's credit is held to its own tender +// (FROM-PRINCIPAL 60a): a tender whose refund the terminal declined keeps +// its charge as unapplied credit on the shared walk-in customer once the +// void completes, and that credit may never be spent on another invoice. +// The payment apply route answered 200 for it (the fourteenth review's +// Area 5 shape: a later walk-in invoice absorbed an earlier walk-in's +// declined card credit), so the refusal is proved on an open walk-in +// invoice no void holds, the sharpest case the shared customer allows. + +import ( + "context" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/gablelbm/gable/internal/account" + "github.com/gablelbm/gable/internal/gl" + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// openWalkInInvoice inserts and posts an open invoice of total cents for +// the shared walk-in customer, the AR desk's own document: no sale backs +// it, so no void claim can hold it, and its subledger row keeps the ties. +func (f *fixture) openWalkInInvoice(t *testing.T, total int64) string { + t.Helper() + walkIn := f.scalar(`SELECT id::text FROM customers WHERE account_number = 'WALK-IN'`).(string) + id := uuid.New() + number := "IN-" + id.String()[:8] + mustExec(t, f.db, `INSERT INTO invoices (id, customer_id, branch_id, status, origin, number, currency, total_amount, subtotal, + tax_rate, tax_amount, amount_open, due_date, invoice_date) + VALUES ($1, $2, $3, 'UNPAID', 'POS', $4, 'USD', $5::bigint::numeric / 100, $5::bigint::numeric / 100, + 0, 0, $5::bigint::numeric / 100, CURRENT_DATE + 30, CURRENT_DATE - 5)`, id, walkIn, f.branchID, number, total) + acct := account.NewService(f.db, gl.NewService(gl.NewRepository(f.db), nil, slog.Default()), slog.Default()) + if err := f.db.RunInTx(context.Background(), func(ctx context.Context) error { + _, err := acct.PostInvoice(ctx, account.PostInvoiceIn{InvoiceID: id, CustomerID: uuid.MustParse(walkIn), + Number: number, Currency: "USD", TotalCents: total, On: time.Now(), Actor: "u-test", + Legs: []gl.Leg{{AccountCode: "4010", Description: "Sales Revenue", Credit: total}}}) + return err + }); err != nil { + t.Fatalf("post the open walk-in invoice: %v", err) + } + return id.String() +} + +// invoiceOpenOf reads the invoice's open cents. +func (f *fixture) invoiceOpenOf(t *testing.T, invoiceID string) int64 { + t.Helper() + var open int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT ROUND(amount_open * 100)::bigint FROM invoices WHERE id = $1`, invoiceID).Scan(&open); err != nil { + t.Fatal(err) + } + return open +} + +// RULE (FROM-PRINCIPAL 60a): a walk-in sale paid by two cards whose void +// refunded one and declined the other leaves the declined card's charge as +// unapplied credit on the shared walk-in customer; the payment apply route +// applied it to another walk-in invoice, so the refusal must name the +// credit's origin (the void and its claim) and nothing may be written: the +// credit stays on its own payment, held to its tender. +func TestADeclinedVoidTendersCreditAppliesToNoInvoice(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(10000) + saleID, body, _ := f.twoCardSaleOf(t, "1", 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "declined card", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusConflict { + t.Fatalf("the void with one tender declined = %d, want 409: %s", r.status, r.raw) + } + var standing, declined map[string]any + for _, in := range f.saleIntents(t, saleID) { + if in["state"] == "PENDING" { + standing = in + } + if in["state"] == "FAILED" { + declined = in + } + } + if standing == nil || declined == nil { + t.Fatalf("the claims = %v, want one PENDING and one FAILED", f.saleIntents(t, saleID)) + } + if r := f.resolveOn(t, standing["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": standing["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the resolution of the refunded tender = %d, want 200: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Fatalf("sale status = %q, want voided", got) + } + credit := f.unappliedOf(t, declined["tender_id"].(string)) + if credit != declined["amount"].(int64) { + t.Fatalf("the declined tender's payment holds %d unapplied cents, want %d (the credit owed)", credit, declined["amount"].(int64)) + } + f.assertARInvariants(t) + + // an open walk-in invoice no void holds: the only invoice the shared + // customer's credit could be spent on + invoiceID := f.openWalkInInvoice(t, 500) + payID, payRev := f.paymentOfTender(t, declined["tender_id"].(string)) + r := f.do("POST", "/api/v1/payments/"+payID+"/applications", map[string]any{ + "revision": payRev, + "applications": []map[string]any{ + {"invoice_id": invoiceID, "amount_cents": credit}, + }, + }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the credit applied to another walk-in invoice = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "tender_credit_held" { + t.Errorf("blockers = %v, want tender_credit_held (the credit is held to its tender)", blockers) + } + // the refusal names the origin: the void and the claim it released + if !containsClaim(r.raw, declined["id"].(string)) { + t.Errorf("the refusal does not name the declined claim %s: %s", declined["id"], r.raw) + } + var saleNumber string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT number FROM pos_transactions WHERE id = $1`, saleID).Scan(&saleNumber); err != nil { + t.Fatal(err) + } + if !containsClaim(r.raw, saleNumber) { + t.Errorf("the refusal does not name the void's sale %s: %s", saleNumber, r.raw) + } + // nothing was written: the credit stays on its own payment, the invoice + // stays open, and no application row exists + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != credit { + t.Errorf("the credit after the refused apply = %d, want %d (nothing was absorbed)", got, credit) + } + if got := f.invoiceOpenOf(t, invoiceID); got != 500 { + t.Errorf("the invoice's open cents after the refused apply = %d, want 500", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM ar_applications WHERE payment_id = $1 AND reversed_at IS NULL`, payID); got != 0 { + t.Errorf("%d live application rows for the held credit's payment, want 0", got) + } + if got := f.walkInBalance(t); got != 500 { + t.Errorf("walk-in balance = %d, want 500 (the open invoice only; the credit owes nothing)", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/void_credit_payback_test.go b/core/internal/pos/void_credit_payback_test.go new file mode 100644 index 000000000..982b31248 --- /dev/null +++ b/core/internal/pos/void_credit_payback_test.go @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The declined void tender's credit and its one way back +// (FROM-PRINCIPAL 60b): a tender whose refund the terminal declined keeps +// its charge as unapplied credit on the customer (held to its tender, +// round 9's apply refusal), and it is paid back only to that card through +// the counter terminal rail, by the small payback route beside the resolve +// route. The payment refund route through the online gateway is never the +// way back for a counter tender: that rail is not the terminal the charge +// came from, and the route is refused for such a credit. The walk-in sale +// is the sharpest case (the credit lands on the shared walk-in customer), +// so the proof runs there. + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +// declinedCreditVoid completes a walk-in two card sale whose void refunded +// one card and declined the other: the standing claim is resolved, the void +// completes, and the declined tender's payment holds its charge as the +// credit. It answers the sale id, the declined claim and the credit cents. +func declinedCreditVoid(t *testing.T, f *fixture, gw *fakeGateway, resolveSub string) (string, map[string]any, int64) { + t.Helper() + saleID, body, _ := f.twoCardSaleOf(t, "1", 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "declined card", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusConflict { + t.Fatalf("the void with one tender declined = %d, want 409: %s", r.status, r.raw) + } + var standing, declined map[string]any + for _, in := range f.saleIntents(t, saleID) { + if in["state"] == "PENDING" { + standing = in + } + if in["state"] == "FAILED" { + declined = in + } + } + if standing == nil || declined == nil { + t.Fatalf("the claims = %v, want one PENDING and one FAILED", f.saleIntents(t, saleID)) + } + if r := f.resolveOnSub(t, standing["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": standing["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance", resolveSub); r.status != http.StatusOK { + t.Fatalf("the resolution of the refunded tender = %d, want 200: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Fatalf("sale status = %q, want voided", got) + } + credit := f.unappliedOf(t, declined["tender_id"].(string)) + if credit != declined["amount"].(int64) { + t.Fatalf("the declined tender's payment holds %d unapplied cents, want %d (the credit owed)", credit, declined["amount"].(int64)) + } + f.assertARInvariants(t) + return saleID, declined, credit +} + +// gatewayRefundCalls reads the stub terminal's refund call count. +func gatewayRefundCalls(t *testing.T, gw *fakeGateway) int { + t.Helper() + gw.mu.Lock() + defer gw.mu.Unlock() + return len(gw.refunds) +} + +// RULE (FROM-PRINCIPAL 60b): the declined tender's credit is paid back to +// its card through the counter terminal rail alone, by the payback route +// beside the resolve route (finance and owner only): the terminal is asked +// once for the credit, the credit's unapplied cash falls to zero, the cash +// account moves by the refund, and a second payback answers 409 without +// reaching the terminal. The payment refund route through the online +// gateway is refused for such a credit (the wrong rail), red first. +func TestADeclinedVoidTendersCreditIsPaidBackThroughTheCounterTerminal(t *testing.T) { + testutil.LockOutboxTables(t) + // the counter terminal and the online gateway are SEPARATE stubs (the + // sixteenth review's P3-2), each counting its own rail's refund calls, + // so the counts prove which rail was asked and which was never asked + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}} + online := &fakeGateway{} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + f.paymentGateway = online + }) + f.openTill(10000) + _, declined, credit := declinedCreditVoid(t, f, gw, mustUUID(t)) + callsAfterVoid := gatewayRefundCalls(t, gw) + if callsAfterVoid != 2 { + t.Fatalf("%d terminal refund calls after the void, want 2 (one accepted, one declined)", callsAfterVoid) + } + payID, payRev := f.paymentOfTender(t, declined["tender_id"].(string)) + card1010 := f.accountBalance("1010") + + // the online gateway's payment refund route is never the way back for a + // counter tender: refused for the credit, and the online rail is never + // asked (its own stub's count stays at zero, and the terminal's count + // stands still: neither rail moved) + r := f.do("POST", "/api/v1/payments/"+payID+"/refunds", map[string]any{ + "amount_cents": credit, "reason": "the declined tender's credit back to its card", "revision": payRev, + }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the payment refund route on the held credit = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "tender_credit_held" { + t.Errorf("blockers = %v, want tender_credit_held (a counter tender's credit rides the terminal rail, never the online gateway)", blockers) + } + if got := gatewayRefundCalls(t, online); got != 0 { + t.Errorf("%d online gateway refund calls after the refused online refund, want 0 (the online rail was never asked)", got) + } + if got := gatewayRefundCalls(t, gw); got != callsAfterVoid { + t.Errorf("%d terminal refund calls after the refused online refund, want %d (the terminal stands still too)", got, callsAfterVoid) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != credit { + t.Errorf("the credit after the refused online refund = %d, want %d", got, credit) + } + f.assertARInvariants(t) + + // the way back: the counter terminal rail, through the payback route, + // finance only (a cashier is refused) + if r := f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "the customer is at the counter for their card"}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusForbidden { + t.Fatalf("a cashier's payback = %d, want 403: %s", r.status, r.raw) + } + r = f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "the customer is at the counter for their card"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("the payback = %d, want 200: %s", r.status, r.raw) + } + // the terminal was asked exactly once for the credit, the online rail + // never, and the books took it + if got := gatewayRefundCalls(t, gw); got != callsAfterVoid+1 { + t.Errorf("%d terminal refund calls after the payback, want %d (one payback call)", got, callsAfterVoid+1) + } + if got := gatewayRefundCalls(t, online); got != 0 { + t.Errorf("%d online gateway refund calls after the payback, want 0 (the payback rides the terminal rail alone)", got) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != 0 { + t.Errorf("the credit after the payback = %d, want 0 (paid back to the card)", got) + } + if got := f.accountBalance("1010"); got != card1010-credit { + t.Errorf("card balance = %d, want %d (the cash account moved by the refund)", got, card1010-credit) + } + if got := f.walkInBalance(t); got != 0 { + t.Errorf("walk-in balance = %d, want 0 (nothing owed, nothing held on the customer)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 2 { + t.Errorf("%d refund rows, want 2 (the void's booked refund and the credit's payback)", got) + } + // the terminal's outcome is recorded the way the resolve route records + // it: one audit row naming the person, the intent, the outcome and the + // reason + if got := countOf(t, f.db, `SELECT count(*) FROM audit_log WHERE action = 'pos.refund_intent.paid_back' + AND changes->>'intent_id' = $1 AND changes->>'tender_id' = $2 AND changes->>'outcome' = 'refund' + AND changes->>'reason' = 'the customer is at the counter for their card'`, declined["id"], declined["tender_id"]); got != 1 { + t.Errorf("%d payback audit rows naming the intent, the tender, the outcome and the reason, want 1", got) + } + // the payback's own claim completes DONE carrying the terminal's refund + // id, beside the void's FAILED claim (the void's own record) + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE act = 'payback' AND state = 'DONE' + AND tender_id = $1 AND refund_tx_id IS NOT NULL`, declined["tender_id"]); got != 1 { + t.Errorf("%d DONE payback claims of the tender, want 1", got) + } + f.assertARInvariants(t) + + // exactly one resolution per tender: a second payback answers 409 and + // never reaches the terminal + r = f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "again"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the second payback = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ = errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "no_held_credit" { + t.Errorf("blockers = %v, want no_held_credit (the credit was paid back)", blockers) + } + if got := gatewayRefundCalls(t, gw); got != callsAfterVoid+1 { + t.Errorf("%d terminal refund calls after the second payback, want %d (the terminal was not asked again)", got, callsAfterVoid+1) + } + if got := gatewayRefundCalls(t, online); got != 0 { + t.Errorf("%d online gateway refund calls after the second payback, want 0 (no rail was asked)", got) + } + f.assertARInvariants(t) +} + +// RULE: a payback whose terminal call answers no verdict stands as a claim +// like any act (the money may have moved), and settles through the resolve +// route: a recorded refund books the credit out to the card; a no refund +// releases the claim and the credit stays held, payable again at the +// counter. +func TestAPaybackWhoseCallAnswersNoVerdictSettlesThroughTheResolveRoute(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}, + refundErrs: []error{nil, nil, errDeadline()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(10000) + _, declined, credit := declinedCreditVoid(t, f, gw, mustUUID(t)) + + // the payback's own terminal call loses its verdict: the route answers + // the error and the payback claim stands PENDING, stamped answered + r := f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "the customer is at the counter for their card"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusInternalServerError { + t.Fatalf("the payback whose call lost its verdict = %d, want 500: %s", r.status, r.raw) + } + var paybackID string + var paybackState string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT id::text, state FROM pos_refund_intents WHERE act = 'payback' AND tender_id = $1`, declined["tender_id"]). + Scan(&paybackID, &paybackState); err != nil { + t.Fatal(err) + } + if paybackState != "PENDING" { + t.Fatalf("the payback claim = %s, want PENDING (the terminal may have moved the money)", paybackState) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != credit { + t.Errorf("the credit while the payback claim stands = %d, want %d (nothing books until the claim settles)", got, credit) + } + f.assertARInvariants(t) + + // a recorded refund books the credit out to the card and completes the + // claim DONE with the terminal's own id + if r := f.resolveOn(t, paybackID, map[string]any{ + "outcome": "refund", "refund_tx_id": "rf-pay-1", "reason": "the terminal log shows the payback refund", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the payback claim's refund resolution = %d, want 200: %s", r.status, r.raw) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != 0 { + t.Errorf("the credit after the resolution = %d, want 0", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE id = $1 AND state = 'DONE' AND refund_tx_id = 'rf-pay-1'`, paybackID); got != 1 { + t.Errorf("the payback claim did not complete DONE with the terminal's own id") + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds WHERE gateway_refund_id = 'rf-pay-1'`); got != 1 { + t.Errorf("%d refund rows under the payback's terminal id, want 1", got) + } + f.assertARInvariants(t) +} + +// RULE: a payback the terminal declined releases its claim and moves +// nothing: the credit stays held on its tender, and the counter may ask +// again. +func TestAPaybackTheTerminalDeclinedReleasesItsClaim(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined, + payment.GatewayStatusDeclined, payment.GatewayStatusRefunded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(10000) + _, declined, credit := declinedCreditVoid(t, f, gw, mustUUID(t)) + + // the terminal declines the payback: nothing moved, the claim is + // released, the credit stays held + r := f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "the customer is at the counter for their card"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the declined payback = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "card_refund" { + t.Errorf("blockers = %v, want card_refund (the terminal's own no)", blockers) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE act = 'payback' AND state = 'PENDING'`); got != 0 { + t.Errorf("%d PENDING payback claims after the decline, want 0 (released)", got) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != credit { + t.Errorf("the credit after the declined payback = %d, want %d (nothing moved)", got, credit) + } + f.assertARInvariants(t) + + // the counter asks again and the card is paid + r = f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "the terminal accepted it this time"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("the retried payback = %d, want 200: %s", r.status, r.raw) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != 0 { + t.Errorf("the credit after the retried payback = %d, want 0", got) + } + f.assertARInvariants(t) +} + +// RULE: the payback route holds the credit's sale to the caller's branch +// wall (and the no context hold), the same wall the resolve route runs: +// another branch's caller finds a missing row (404, nothing moved, the +// terminal never asked), and a caller whose grants do not carry the sale's +// branch is refused (403). +func TestThePaybackStaysInsideTheCallersBranch(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + setPosSetting(t, db, "multi_branch_enabled", "true") + setPosSetting(t, db, "default_branch_required", "false") + branchB := uuid.New() + mustExecB := func(sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(context.Background(), sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + mustExecB(`INSERT INTO locations (id, type, code, name) VALUES ($1, 'BRANCH', 'WL-POS-P', 'payback wall B')`, branchB) + mustExecB(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-pay-b', $1, TRUE, 'test')`, branchB) + t.Cleanup(func() { + setPosSetting(t, db, "multi_branch_enabled", "false") + setPosSetting(t, db, "default_branch_required", "true") + mustExecB(`DELETE FROM user_locations WHERE user_sub IN ('pos-pay-b', 'pos-pay-a')`) + mustExecB(`DELETE FROM locations WHERE id = $1`, branchB) + }) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}} + f := newFixture(t, db, func(f *fixture) { + f.service = f.service.WithGateway(gw) + mustExecB(`INSERT INTO user_locations (user_sub, branch_id, is_home, granted_by) VALUES ('pos-pay-a', $1, TRUE, 'test')`, f.branchID) + f.cashierSub = "pos-pay-a" + }) + f.openTill(10000) + _, declined, _ := declinedCreditVoid(t, f, gw, "pos-pay-a") + callsAfterVoid := gatewayRefundCalls(t, gw) + + scoped := []string{"X-Test-Role", "finance", "X-Test-Sub", "pos-pay-b", "X-Branch-Id", branchB.String()} + // the B caller, header or not, finds a missing row: nothing moved and + // the terminal was never asked + for _, headers := range [][]string{scoped, + {"X-Test-Role", "finance", "X-Test-Sub", "pos-pay-b"}} { + if r := f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "out of my branch"}, headers...); r.status != http.StatusNotFound { + t.Errorf("another branch's payback (%v) = %d, want 404: %s", headers, r.status, r.raw) + } + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE act = 'payback'`); got != 0 { + t.Errorf("%d payback claims through the wall, want 0", got) + } + if got := gatewayRefundCalls(t, gw); got != callsAfterVoid { + t.Errorf("%d terminal refund calls through the wall, want %d (the terminal was never asked)", got, callsAfterVoid) + } + // a caller granted the sale's branch only, heading another branch's + // header, is refused by the wall + if r := f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "wrong header"}, + "X-Test-Role", "finance", "X-Test-Sub", "pos-pay-a", "X-Branch-Id", branchB.String()); r.status != http.StatusForbidden { + t.Errorf("the granted caller under another branch's header = %d, want 403: %s", r.status, r.raw) + } + f.assertARInvariants(t) + + // the in branch caller pays the credit back + if r := f.do("POST", "/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + map[string]any{"reason": "the customer is at the counter for their card"}, + "X-Test-Role", "finance", "X-Test-Sub", "pos-pay-a"); r.status != http.StatusOK { + t.Fatalf("the in branch payback = %d, want 200: %s", r.status, r.raw) + } + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != 0 { + t.Errorf("the credit after the in branch payback = %d, want 0", got) + } + f.assertARInvariants(t) +} + +// RULE: a machine key never pays a credit back: the payback is a person's +// act, refused in the route itself, bound or not. +func TestAMachineKeyNeverPaysBackAHeldCredit(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(10000) + _, declined, _ := declinedCreditVoid(t, f, gw, mustUUID(t)) + keyed := keyedServer(f, uuid.NewString()) + req, _ := http.NewRequest(http.MethodPost, keyed.URL+"/api/v1/pos/refund-intents/"+declined["id"].(string)+"/payback", + strings.NewReader(`{"reason":"keyed"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Request-ID", "req-pos-key-test") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusForbidden { + t.Fatalf("a machine key's payback = %d, want 403", res.StatusCode) + } + if got := countOf(t, f.db, `SELECT count(*) FROM pos_refund_intents WHERE act = 'payback'`); got != 0 { + t.Errorf("%d payback claims by a machine key, want 0", got) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/void_interim_apply_test.go b/core/internal/pos/void_interim_apply_test.go new file mode 100644 index 000000000..5b9f13803 --- /dev/null +++ b/core/internal/pos/void_interim_apply_test.go @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The split void's interim invoice (the fourteenth review's P2-1): while a claim of +// the sale's void still stands, the booked cents show as owed on the +// statement, but nothing may be applied to that invoice: an application +// would leave a live row the void's completion cannot reverse, stranding the +// last claim and the drawer. The apply routes refuse with 409 +// void_claim_pending naming the standing claim, and the void always +// completes. A declined tender's credit is refused one guard earlier (409 +// tender_credit_held, its own hold, before any invoice is read), so the +// named customer's receipt is the shape that pins the invoice's own +// refusal; the walk-in test below names its guard. + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// invoiceOf reads the sale's invoice id. +func (f *fixture) invoiceOf(t *testing.T, saleID string) string { + t.Helper() + var id string + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT invoice_id::text FROM pos_transactions WHERE id = $1`, saleID).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + +// namedTwoCardSaleOf makes a named customer's one line sale paid by two cards. +func (f *fixture) namedTwoCardSaleOf(t *testing.T, cents1, cents2 int64) (string, resp) { + t.Helper() + 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) + } + r := f.completeSale(saleID, withToken(tender("card", cents1)), withToken(tender("card", cents2))) + if r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + return saleID, r +} + +// paymentOfTender reads the tender's payment id and revision. +func (f *fixture) paymentOfTender(t *testing.T, tenderID string) (string, int64) { + t.Helper() + var id string + var rev int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT p.id::text, p.revision FROM pos_tenders d JOIN payments p ON p.id = d.payment_id WHERE d.id = $1`, + tenderID).Scan(&id, &rev); err != nil { + t.Fatal(err) + } + return id, rev +} + +// RULE: the customer's own receipt applied to the interim invoice (the AR +// desk's check) is refused with 409 void_claim_pending naming the standing +// claim; the last claim then resolves either way, the void completes and the +// drawer closes. +func TestTheCustomersOwnReceiptCannotApplyToAnInterimVoidInvoice(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{nil, errDeadline()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + till := f.openTill(10000) + saleID, body := f.namedTwoCardSaleOf(t, 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "stranded", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("the split void = %d, want 500: %s", r.status, r.raw) + } + // resolve the refunded tender: the sale stands completed with its invoice + // open the refunded cents (the interim), the other claim still standing + var standing, refunded map[string]any + for _, in := range f.saleIntents(t, saleID) { + if in["state"] == "PENDING" && in["refund_tx_id"] != "" { + refunded = in + } + if in["state"] == "PENDING" && in["refund_tx_id"] == "" { + standing = in + } + } + if refunded == nil || standing == nil { + t.Fatalf("the claims = %v, want one refunded PENDING and one unanswered PENDING", f.saleIntents(t, saleID)) + } + if r := f.resolveOn(t, refunded["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": refunded["refund_tx_id"].(string), "reason": "terminal log shows it", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the refund resolution = %d: %s", r.status, r.raw) + } + owed := refunded["amount"].(int64) + if got := f.balance(); got != owed { + t.Fatalf("the interim balance = %d, want %d owed (the booked cents reopened the invoice)", got, owed) + } + f.assertARInvariants(t) + + // the customer pays the open cents by check at the AR desk, applied to + // that invoice: refused, the void must stay completable + r := f.do("POST", "/api/v1/payments", map[string]any{ + "customer_id": f.customerID, "method": "check", "amount_cents": owed, + "reference": "CHK-72", "received_on": "2030-01-01", + "applications": []map[string]any{{"invoice_id": f.invoiceOf(t, saleID), "amount_cents": owed}}, + }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the receipt applied to the interim invoice = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "void_claim_pending" { + t.Errorf("blockers = %v, want void_claim_pending (the standing claim holds the invoice)", blockers) + } + if !containsClaim(r.raw, standing["id"].(string)) { + t.Errorf("the refusal does not name the standing claim %s: %s", standing["id"], r.raw) + } + // nothing of the refused receipt landed + if got := f.balance(); got != owed { + t.Errorf("the balance after the refused receipt = %d, want %d (nothing applied)", got, owed) + } + f.assertARInvariants(t) + + // the void completes from the last claim, either way + if r := f.resolveOn(t, standing["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-72", "reason": "terminal log empty", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the last claim's resolution = %d: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the void is always completable)", got) + } + if got := f.balance(); got != 0 { + t.Errorf("the balance after the void = %d, want 0", got) + } + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the close = %d: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} + +// RULE: another walk-in sale's declined card credit applied to the interim +// invoice through the payment apply route is refused the same way; the +// credit stays unapplied on its own payment, payable back to its card, and +// both sales and the drawer settle. +func TestAnotherWalkInCreditCannotApplyToAnInterimVoidInvoice(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge(), approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}, + refundErrs: []error{nil, nil, nil, errDeadline()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + f.paymentGateway = gw + }) + till := f.openTill(10000) + // S1: the void refunds one card and declines the other; resolving the + // refunded card completes the void and leaves the declined card's charge + // as unapplied credit on the walk-in customer + s1, body1, _ := f.twoCardSaleOf(t, "1", 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+s1+"/void", + map[string]any{"reason": "declined card", "revision": rev(t, body1)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusConflict { + t.Fatalf("S1's void = %d, want 409: %s", r.status, r.raw) + } + var standing1, declined1 map[string]any + for _, in := range f.saleIntents(t, s1) { + if in["state"] == "PENDING" { + standing1 = in + } + if in["state"] == "FAILED" { + declined1 = in + } + } + if r := f.resolveOn(t, standing1["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": standing1["refund_tx_id"].(string), "reason": "terminal log shows it", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("S1's resolution = %d: %s", r.status, r.raw) + } + credit := f.unappliedOf(t, declined1["tender_id"].(string)) + if credit != declined1["amount"].(int64) { + t.Fatalf("S1's declined card credit = %d, want the declined tender's own %d", credit, declined1["amount"].(int64)) + } + + // S2: the void refunds one card and loses the other's verdict; resolving + // the refunded card opens S2's interim invoice + s2, body2, _ := f.twoCardSaleOf(t, "1", 300, 299) + if r := f.do("POST", "/api/v1/pos/transactions/"+s2+"/void", + map[string]any{"reason": "stranded", "revision": rev(t, body2)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusInternalServerError { + t.Fatalf("S2's void = %d, want 500: %s", r.status, r.raw) + } + var standing2, refunded2 map[string]any + for _, in := range f.saleIntents(t, s2) { + if in["state"] == "PENDING" && in["refund_tx_id"] != "" { + refunded2 = in + } + if in["state"] == "PENDING" && in["refund_tx_id"] == "" { + standing2 = in + } + } + if r := f.resolveOn(t, refunded2["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": refunded2["refund_tx_id"].(string), "reason": "terminal log shows it", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("S2's refund resolution = %d: %s", r.status, r.raw) + } + f.assertARInvariants(t) + + // S1's credit applied to S2's interim invoice: refused, and by the + // payment's own hold first (FROM-PRINCIPAL 60a): the credit is held to + // its tender before any invoice is even read, so the refusal names the + // origin (S1's released claim), never S2's standing one. The interim + // invoice's own guard (void_claim_pending) is never reached for this + // shape: it is pinned by the named customer's receipt test above, whose + // check is no held credit and so reaches the invoice's standing claims + payID, payRev := f.paymentOfTender(t, declined1["tender_id"].(string)) + r := f.do("POST", "/api/v1/payments/"+payID+"/applications", map[string]any{ + "revision": payRev, + "applications": []map[string]any{ + {"invoice_id": f.invoiceOf(t, s2), "amount_cents": credit}, + }, + }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the credit applied to the interim invoice = %d, want 409: %s", r.status, r.raw) + } + _, blockers, _ := errorOf(t, r) + if len(blockers) == 0 || blockers[0] != "tender_credit_held" { + t.Errorf("blockers = %v, want tender_credit_held (the credit's own hold answers before the interim invoice's guard is read)", blockers) + } + if !containsClaim(r.raw, declined1["id"].(string)) { + t.Errorf("the refusal does not name the declined claim %s: %s", declined1["id"], r.raw) + } + // the credit is still on its own payment, and its one way back is the + // counter terminal rail: the payback route pays it to the card while + // S2's claim still stands (the credit belongs to S1's completed void, + // not to the invoice it might have been spent on) + if got := f.unappliedOf(t, declined1["tender_id"].(string)); got != credit { + t.Errorf("S1's credit after the refused apply = %d, want %d (nothing was absorbed)", got, credit) + } + if r := f.do("POST", "/api/v1/pos/refund-intents/"+declined1["id"].(string)+"/payback", + map[string]any{"reason": "the declined tender's credit back to its card"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the credit's payback to its card = %d, want 200: %s", r.status, r.raw) + } + if got := f.unappliedOf(t, declined1["tender_id"].(string)); got != 0 { + t.Errorf("S1's credit after the payback = %d, want 0", got) + } + f.assertARInvariants(t) + + // S2 completes and the drawer closes + if r := f.resolveOn(t, standing2["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-73", "reason": "terminal log empty", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("S2's last resolution = %d: %s", r.status, r.raw) + } + if got := str(t, f.getSale(t, s2), "status"); got != "voided" { + t.Errorf("S2 status = %q, want voided", got) + } + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the close = %d: %s", r.status, r.raw) + } + if got := f.walkInBalance(t); got != 0 { + t.Errorf("walk-in balance = %d, want 0", got) + } + f.assertARInvariants(t) +} + +// errDeadline is the terminal call's lost verdict. +func errDeadline() error { return context.DeadlineExceeded } + +// containsClaim answers whether the refusal body names the claim id. +func containsClaim(raw []byte, claimID string) bool { + return strings.Contains(string(raw), claimID) +} 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/internal/pos/void_order_independence_test.go b/core/internal/pos/void_order_independence_test.go new file mode 100644 index 000000000..c3522f386 --- /dev/null +++ b/core/internal/pos/void_order_independence_test.go @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The order independence of a split void's resolutions (r12 P1-1): a void +// with several card claims ends in the same state whatever order its claims +// are resolved in. Each shape below strands a void of one walk-in unit (599) +// with some claims refunded at the terminal and the others unknown or +// declined, resolves every standing claim in EVERY order, and asserts one +// identical end state per shape: the sale status, the 1010, 1020 and 2200 +// balances, the walk-in customer's balance, the stock and the drawer's +// expected cash. The resolutions themselves never reach the terminal: the +// stub gateway's refund call count stays what the void itself made. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// orderTender is one tender of a shape: its method and cents. +type orderTender struct { + method string + cents int64 +} + +// orderShape is one stranded void shape: the tenders of the sale, the +// terminal's answer per card call the void makes ("ok", "timeout" or +// "decline"), and how many calls the void makes before it is refused. Which +// tender takes which answer follows the tenders' own order, so the moved +// total is read back from the claims each subtest strands. +type orderShape struct { + name string + tenders []orderTender + answers []string + gwCalls int +} + +// orderShapes are r12's Area 2 shapes: M3a against M3b, M5a against M5b, M6a +// to M6c, and M10 with cash. A declined answer releases its own claim and +// stops the void, so the cards after it are never called and their claims +// stand uncalled; every other standing claim is resolved per its own record. +var orderShapes = []orderShape{ + {name: "two cards, one refunded, one timed out (M3)", + tenders: []orderTender{{"card", 300}, {"card", 299}}, answers: []string{"ok", "timeout"}, + gwCalls: 2}, + {name: "three cards, one refunded, one declined, one never called (M5)", + tenders: []orderTender{{"card", 200}, {"card", 200}, {"card", 199}}, answers: []string{"ok", "decline"}, + gwCalls: 2}, + {name: "three cards, two refunded, one timed out (M6)", + tenders: []orderTender{{"card", 200}, {"card", 200}, {"card", 199}}, answers: []string{"ok", "ok", "timeout"}, + gwCalls: 3}, + {name: "cash with two cards, one refunded, one timed out (M10)", + tenders: []orderTender{{"cash", 199}, {"card", 200}, {"card", 200}}, answers: []string{"ok", "timeout"}, + gwCalls: 2}, +} + +// standingOf answers how many claims of the shape stand for the operator to +// resolve: a declined call releases its claim, an ok or a timeout leaves it +// standing, and a card never called (after a decline) stands uncalled. +func (s orderShape) standingOf() int { + cards := 0 + for _, td := range s.tenders { + if td.method == "card" { + cards++ + } + } + declined := 0 + for _, a := range s.answers { + if a == "decline" { + declined++ + } + } + return cards - declined +} + +// permutations lists every order of the integers 0 to n-1. +func permutations(n int) [][]int { + perms := [][]int{{}} + for i := 0; i < n; i++ { + var next [][]int + for _, p := range perms { + for j := 0; j <= len(p); j++ { + q := append([]int(nil), p[:j]...) + q = append(q, i) + q = append(q, p[j:]...) + next = append(next, q) + } + } + perms = next + } + return perms +} + +// orderGateway builds the stub gateway for one shape: one approved charge per +// card tender, and the terminal's answers scripted per refund call in call +// order (the scripts are positional, so every call before a scripted answer +// carries an explicit nil or the default status). +func orderGateway(t *testing.T, shape orderShape) *fakeGateway { + t.Helper() + cards := 0 + for _, td := range shape.tenders { + if td.method == "card" { + cards++ + } + } + gw := &fakeGateway{charges: make([]*payment.GatewayResult, 0, cards)} + for i := 0; i < cards; i++ { + gw.charges = append(gw.charges, approvedCharge()) + } + for i, answer := range shape.answers { + for len(gw.refundErrs) < i { + gw.refundErrs = append(gw.refundErrs, nil) + } + for len(gw.refundStatuses) < i { + gw.refundStatuses = append(gw.refundStatuses, payment.GatewayStatusRefunded) + } + switch answer { + case "timeout": + gw.refundErrs = append(gw.refundErrs, context.DeadlineExceeded) + gw.refundStatuses = append(gw.refundStatuses, payment.GatewayStatusRefunded) + case "decline": + gw.refundErrs = append(gw.refundErrs, nil) + gw.refundStatuses = append(gw.refundStatuses, payment.GatewayStatusDeclined) + default: + gw.refundErrs = append(gw.refundErrs, nil) + gw.refundStatuses = append(gw.refundStatuses, payment.GatewayStatusRefunded) + } + } + return gw +} + +// walkInBalance reads the walk-in customer's subledger balance in cents. +func (f *fixture) walkInBalance(t *testing.T) int64 { + t.Helper() + var n int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT ROUND(balance_due * 100)::bigint FROM customers WHERE account_number = 'WALK-IN'`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// expectedOf reads the open session's expected_by_method figure for one +// method through the report route. +func (f *fixture) expectedOf(t *testing.T, sessionID, method string) int64 { + t.Helper() + r := f.do("GET", "/api/v1/pos/till/"+sessionID+"/report", nil, "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusOK { + t.Fatalf("the till report = %d: %s", r.status, r.raw) + } + byMethod, ok := r.body["expected_by_method"].(map[string]any) + if !ok { + t.Fatalf("the report carries no expected_by_method: %s", r.raw) + } + v, ok := byMethod[method] + if !ok { + return 0 + } + return int64Of(v) +} + +// orderSale completes one sale of the shape's tenders on a fresh fixture with +// the drawer open at a 10000 float, answering the fixture, the sale id, the +// completion body and the stub gateway. +func orderSale(t *testing.T, shape orderShape) (*fixture, string, resp, *fakeGateway, string) { + t.Helper() + gw := orderGateway(t, shape) + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + sessionID := f.openTill(10000) + tenders := make([]map[string]any, 0, len(shape.tenders)) + for _, td := range shape.tenders { + tk := tender(td.method, td.cents) + if td.method == "card" { + tk = withToken(tk) + } + tenders = append(tenders, tk) + } + 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) + } + body := f.completeSale(saleID, tenders...) + if body.status != http.StatusOK { + t.Fatalf("complete = %d: %s", body.status, body.raw) + } + return f, saleID, body, gw, sessionID +} + +// RULE (r12 P1-1): a void with several card claims ends in the same state +// whatever order its claims are resolved in. The natural order (book the +// refund that moved first, the no refund last) and every other order void +// the sale, restock the unit, leave the unrefunded cards' charge as unapplied +// credit and the drawer expecting its float back, and no resolution reaches +// the terminal. +func TestASplitVoidEndsTheSameStateWhateverOrderItsClaimsAreResolved(t *testing.T) { + testutil.LockOutboxTables(t) + for _, shape := range orderShapes { + for _, order := range permutations(shape.standingOf()) { + t.Run(shape.name+" / resolves "+orderName(order), func(t *testing.T) { + f, saleID, body, gw, sessionID := orderSale(t, shape) + if r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "split void", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status == http.StatusOK { + t.Fatalf("the stranded void committed: %s", r.raw) + } + // the standing claims, sorted so the refund carrying claims + // come first: the order names stay deterministic (0-1 is + // always refund then no refund) whatever order the claims were + // created in. The resolutions run in the order this case + // names. The moved total is read from the claims themselves: + // which tender took the terminal's ok follows the tenders' + // own order, not the shape's list. + var withID, withoutID []map[string]any + var refunded int64 + for _, in := range f.saleIntents(t, saleID) { + if in["state"] != "PENDING" { + continue + } + if in["refund_tx_id"] != "" { + withID = append(withID, in) + refunded += in["amount"].(int64) + } else { + withoutID = append(withoutID, in) + } + } + standing := append(withID, withoutID...) + if len(standing) != len(order) { + t.Fatalf("%d standing claims, want %d", len(standing), len(order)) + } + for _, i := range order { + in := standing[i] + if in["refund_tx_id"] != "" { + if rr := f.resolveOn(t, in["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": in["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance"); rr.status != http.StatusOK { + t.Fatalf("the refund resolution = %d: %s", rr.status, rr.raw) + } + } else if rr := f.resolveOn(t, in["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-ORDER", "reason": "the terminal log shows no refund", + }, "finance"); rr.status != http.StatusOK { + t.Fatalf("the no refund resolution = %d: %s", rr.status, rr.raw) + } + } + // one identical end state per shape, whatever the order was + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided", got) + } + // the drawer methods' charge leaves the books with the void's + // completion (the cash payment voids whole), so the unrefunded + // figure the shape asserts is the card charge the terminal + // never paid back + var drawerCents int64 + for _, td := range shape.tenders { + if td.method == "cash" || td.method == "check" { + drawerCents += td.cents + } + } + unrefunded := 599 - drawerCents - refunded + if got := f.accountBalance("1010"); got != unrefunded { + t.Errorf("card balance = %d, want %d (exactly the %d the terminal moved paid back)", got, unrefunded, refunded) + } + if got := f.accountBalance("1020"); got != 0 { + t.Errorf("receivable balance = %d, want 0 (the invoice voided)", got) + } + if got := f.accountBalance("2200"); got != -unrefunded { + t.Errorf("deposit balance = %d, want %d (the unrefunded charge as unapplied credit)", got, -unrefunded) + } + if got := f.walkInBalance(t); got != 0 { + t.Errorf("walk-in balance = %d, want 0 (the credit sits unapplied, never owed)", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100 (the void restocked the unit)", got) + } + if got := f.expectedOf(t, sessionID, "CASH"); got != 10000 { + t.Errorf("the drawer's expected cash = %d, want 10000 (the float: the voided sale's cash payment left the expectation)", got) + } + if got := f.expectedOf(t, sessionID, "CARD"); got != 0 { + t.Errorf("the drawer's expected card = %d, want 0 (the voided sale's card tenders left the figure)", got) + } + gw.mu.Lock() + calls := len(gw.refunds) + gw.mu.Unlock() + if calls != shape.gwCalls { + t.Errorf("%d gateway refund calls, want %d (the void's own calls: a resolution never reaches the terminal)", calls, shape.gwCalls) + } + f.assertARInvariants(t) + }) + } + } +} + +// orderName names one order for the subtest's title. +func orderName(order []int) string { + out := make([]byte, 0, len(order)*2) + for _, i := range order { + if len(out) > 0 { + out = append(out, '-') + } + out = append(out, byte('0'+i)) + } + return string(out) +} diff --git a/core/internal/pos/void_resolve_tender_test.go b/core/internal/pos/void_resolve_tender_test.go new file mode 100644 index 000000000..3c22327d0 --- /dev/null +++ b/core/internal/pos/void_resolve_tender_test.go @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The per tender void resolution (ninth review P1-1, tenth review P1-2): the +// resolution's void leg replayed the void's whole money transaction, which +// reverses and refunds every card tender, so on a sale paid by two cards, +// where the terminal refunded one and declined the other, the books recorded +// a refund of both. The resolution accounts for each tender separately: it +// books only the resolved tender's refund, and the sale's void completes only +// when no claim of the sale is PENDING anymore. A tender whose refund was +// declined, or recorded as no refund, keeps its charge: its application is +// reversed and the money stays unapplied on the customer's account, the +// credit the dealer owes, never a refund the terminal did not make. + +import ( + "context" + "net/http" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// twoCardSaleOf completes a sale of qty paid by two card tenders, cents1 and +// cents2, answering the sale id, its completion body and the first line's id. +func (f *fixture) twoCardSaleOf(t *testing.T, qty string, cents1, cents2 int64) (string, resp, string) { + t.Helper() + saleID := f.startSale(nil) + if r := f.addLine(saleID, f.productLine(qty)); r.status != http.StatusOK { + t.Fatalf("add line = %d: %s", r.status, r.raw) + } + r := f.completeSale(saleID, withToken(tender("card", cents1)), withToken(tender("card", cents2))) + 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) + return saleID, r, lineID +} + +// saleIntents reads the sale's claim rows whole: id, state, refund id, tender +// id and amount, in creation order. +func (f *fixture) saleIntents(t *testing.T, saleID string) []map[string]any { + t.Helper() + rows, err := f.db.Pool.Query(context.Background(), + `SELECT id::text, state, COALESCE(refund_tx_id, ''), tender_id::text, ROUND(amount * 100)::bigint + FROM pos_refund_intents WHERE sale_id = $1 ORDER BY created_at, id`, saleID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []map[string]any + for rows.Next() { + var id, state, refundTxID, tenderID string + var amount int64 + if err := rows.Scan(&id, &state, &refundTxID, &tenderID, &amount); err != nil { + t.Fatal(err) + } + out = append(out, map[string]any{"id": id, "state": state, "refund_tx_id": refundTxID, + "tender_id": tenderID, "amount": amount}) + } + return out +} + +// unappliedOf reads the tender's payment's unapplied cents. +func (f *fixture) unappliedOf(t *testing.T, tenderID string) int64 { + t.Helper() + var n int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT ROUND(p.amount_unapplied * 100)::bigint FROM pos_tenders d JOIN payments p ON p.id = d.payment_id + WHERE d.id = $1`, tenderID).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// RULE (tenth review P1-2, the M1 shape): a sale of 1 paid by two cards; the +// terminal refunded the first tender and declined the second. Resolving the +// refunded tender's claim books exactly that tender's refund: the declined +// card is never refunded in the books, its charge is reversed to unapplied +// credit the dealer owes the customer, and the sale's void completes. +func TestAVoidResolutionOfOneTenderOfTwoBooksOnlyThatTender(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusDeclined}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body, _ := f.twoCardSaleOf(t, "1", 300, 299) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "two cards", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusConflict { + t.Fatalf("the void with one tender declined = %d, want 409: %s", r.status, r.raw) + } + intents := f.saleIntents(t, saleID) + if len(intents) != 2 { + t.Fatalf("%d claims after the refused void, want 2", len(intents)) + } + var refunded, declined map[string]any + for _, in := range intents { + if in["state"] == "PENDING" && in["refund_tx_id"] != "" { + refunded = in + } + if in["state"] == "FAILED" { + declined = in + } + } + if refunded == nil || declined == nil { + t.Fatalf("the claims are %v, want one PENDING carrying the refund id and one FAILED", intents) + } + paid := refunded["amount"].(int64) + if paid != 300 && paid != 299 { + t.Fatalf("the refunded tender's claim holds %d cents, want one of the two cards (300 or 299)", paid) + } + // the books move exactly the refunded card + rr := f.resolveOn(t, refunded["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": refunded["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance") + if rr.status != http.StatusOK { + t.Fatalf("the resolution of the refunded tender = %d, want 200: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the last claim resolved: the void completes)", got) + } + if got := f.accountBalance("1010"); got != 599-paid { + t.Errorf("card balance = %d, want %d (599 charged, exactly the %d the terminal refunded paid back)", got, 599-paid, paid) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds WHERE gateway_refund_id = $1`, refunded["refund_tx_id"]); got != 1 { + t.Errorf("%d refund rows naming the terminal's refund, want 1", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 1 { + t.Errorf("%d refund rows in all, want 1 (the declined card was never refunded)", got) + } + // the declined tender's charge is the credit the dealer owes the customer + if got := f.unappliedOf(t, declined["tender_id"].(string)); got != 599-paid { + t.Errorf("the declined tender's payment holds %d unapplied cents, want %d (the credit owed, never a refund)", got, 599-paid) + } + if got := f.balance(); got != 0 { + t.Errorf("customer balance = %d, want 0 (the credit sits as unapplied cash)", got) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100 (the void restocked the unit)", got) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 2 { + t.Fatalf("%d gateway refund calls, want 2 (the refunded one and the declined one)", refunds) + } + f.assertARInvariants(t) +} + +// RULE (ninth review P1-1, both tenders refunded): a void whose transaction +// refused after the terminal refunded both cards leaves both claims PENDING +// with their refund ids. Each resolution books only its own tender's leg; the +// sale's void stays incomplete between them and completes with the last. +func TestTheStrandedVoidOfTwoTendersResolvesOneTenderAtATime(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + f.events.fail = "pos_transaction.voided" + }) + f.openTill(0) + saleID, body, _ := f.twoCardSaleOf(t, "1", 300, 299) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "stranded", "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) + } + intents := f.saleIntents(t, saleID) + if len(intents) != 2 { + t.Fatalf("%d claims after the stranded void, want 2", len(intents)) + } + f.events.fail = "" + // the first resolution books its own tender only: the sale's void waits + rr := f.resolveOn(t, intents[0]["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": intents[0]["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance") + if rr.status != http.StatusOK { + t.Fatalf("the first tender's resolution = %d, want 200: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "completed" { + t.Errorf("sale status = %q after one tender resolved, want completed (the void waits for the other claim)", got) + } + if got := f.accountBalance("1010"); got != 599-intents[0]["amount"].(int64) { + t.Errorf("card balance = %d after one tender resolved, want %d (only the first card's %d paid back)", + got, 599-intents[0]["amount"].(int64), intents[0]["amount"].(int64)) + } + // the cash and account routes cannot pay the goods while the claim stands + if got := f.balance(); got != 0 { + t.Errorf("customer balance = %d, want 0", got) + } + f.assertARInvariants(t) + // the second resolution completes the void + rr = f.resolveOn(t, intents[1]["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": intents[1]["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance") + if rr.status != http.StatusOK { + t.Fatalf("the second tender's resolution = %d, want 200: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the last claim resolved: the void completes)", got) + } + if got := f.accountBalance("1010"); got != 0 { + t.Errorf("card balance = %d, want 0 (both cards refunded exactly once)", got) + } + if got := countOf(t, f.db, `SELECT count(*) FROM payment_refunds`); got != 2 { + t.Errorf("%d refund rows, want 2 (one per card)", got) + } + gw.mu.Lock() + refunds := len(gw.refunds) + gw.mu.Unlock() + if refunds != 2 { + t.Fatalf("%d gateway refund calls, want 2 (the resolutions make none: the terminal was asked once per card)", refunds) + } + f.assertARInvariants(t) +} + +// RULE (the half resolved void, r12 P1-1): a sale whose first tender's refund +// moved and whose second tender's call answered no verdict leaves one claim +// PENDING without an id. After the first is resolved as a refund and the +// second as a no refund, the void completes the same way it would have +// completed in the other order: the sale is VOIDED, the stock is back, the +// no refund tender's charge is unapplied credit the dealer owes, and no +// method may pay the goods again (the goods came back with the void). +func TestAGoodCannotBeReturnedAgainAfterAPartlyResolvedVoid(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge()}, + refundErrs: []error{nil, context.DeadlineExceeded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + f.openTill(0) + saleID, body, lineID := f.twoCardSaleOf(t, "1", 300, 299) + r := f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "one moved, one unknown", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + if r.status != http.StatusInternalServerError { + t.Fatalf("the void with one tender timed out = %d, want 500: %s", r.status, r.raw) + } + intents := f.saleIntents(t, saleID) + var moved, unknown map[string]any + for _, in := range intents { + if in["refund_tx_id"] != "" { + moved = in + } else { + unknown = in + } + } + if moved == nil || unknown == nil { + t.Fatalf("the claims are %v, want one carrying the refund id and one without it", intents) + } + // the moved tender books its leg; the unknown claim holds the sale + if rr := f.resolveOn(t, moved["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": moved["refund_tx_id"].(string), "reason": "the terminal refunded this card", + }, "finance"); rr.status != http.StatusOK { + t.Fatalf("the moved tender's resolution = %d: %s", rr.status, rr.raw) + } + if got := f.accountBalance("1010"); got != 599-moved["amount"].(int64) { + t.Errorf("card balance = %d, want %d (exactly the moved card refunded)", got, 599-moved["amount"].(int64)) + } + if rr := f.returnOn(t, saleID, lineID, "1"); rr.status != http.StatusConflict { + t.Fatalf("the cash return while the unknown claim stands = %d, want 409: %s", rr.status, rr.raw) + } + // the terminal never moved the second card: finance records the no refund + if rr := f.resolveOn(t, unknown["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-72", "reason": "the terminal log shows no refund", + }, "finance"); rr.status != http.StatusOK { + t.Fatalf("the unknown claim's no refund = %d: %s", rr.status, rr.raw) + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Errorf("sale status = %q, want voided (the no refund that empties the claims completes the void, whatever order the claims were resolved in)", got) + } + // the goods came back with the void: no method pays them again + r = f.returnOn(t, saleID, lineID, "1") + if r.status != http.StatusConflict { + t.Fatalf("the cash return after the completed void = %d, want 409: %s", r.status, r.raw) + } + if code, _, _ := errorOf(t, r); code != "invalid_state_transition" { + t.Errorf("the cash return's code = %s, want invalid_state_transition (the voided sale's goods came back with it)", code) + } + if rr := f.accountReturnOn(t, saleID, lineID, "1"); rr.status != http.StatusConflict { + t.Fatalf("the account return after the completed void = %d, want 409: %s", rr.status, rr.raw) + } + // a card return cannot pick a tender of a two card sale either + if rr := f.cardReturnOn(t, saleID, lineID, "1"); rr.status != http.StatusConflict { + t.Fatalf("the card return after the completed void = %d, want 409: %s", rr.status, rr.raw) + } + if got := f.accountBalance("1010"); got != 599-moved["amount"].(int64) { + t.Errorf("card balance = %d, want %d (nothing further moved)", got, 599-moved["amount"].(int64)) + } + // the no refund tender's charge is the credit the dealer owes the customer + if got := f.unappliedOf(t, unknown["tender_id"].(string)); got != 599-moved["amount"].(int64) { + t.Errorf("the no refund tender's payment holds %d unapplied cents, want %d (the credit owed)", got, 599-moved["amount"].(int64)) + } + if got := f.stock(); got != "100.0000/0.0000" { + t.Errorf("stock = %s, want 100 (the completed void restocked the unit)", got) + } + if got := f.counterReturns(t); got != 0 { + t.Errorf("%d returns booked, want 0", got) + } + f.assertARInvariants(t) +} 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) +} diff --git a/core/internal/pos/void_sequential_lease_test.go b/core/internal/pos/void_sequential_lease_test.go new file mode 100644 index 000000000..fe3cabfb9 --- /dev/null +++ b/core/internal/pos/void_sequential_lease_test.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package pos_test + +// The void's sequential terminal calls and the lease they run under (the +// sixteenth review's P1-2): every claim of a multi tender void was stamped +// one lease at its insert, but the calls run one after another, each bounded +// at 30 seconds, so a later tender's call could start after the common lease +// had run out. The resolve route then released the live claim on the expired +// lease, the terminal's late answer landed on the FAILED claim, and the +// payback paid the tender a second time: the recorded refund that never +// booked did not hold the tender. The lease is now stamped per call (a +// committed write immediately before each sequential call), and a FAILED +// void claim carrying the terminal's refund id is money that moved: the +// payback refuses it (409 tender_refunded) and it is never a held credit. + +import ( + "context" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/testutil" +) + +// fourCardSaleOf completes a walk-in one line sale paid by four card tenders +// of the given cents, answering the sale id and body. +func (f *fixture) fourCardSaleOf(t *testing.T, cents [4]int64) (string, resp) { + t.Helper() + 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) + } + r := f.completeSale(saleID, + withToken(tender("card", cents[0])), withToken(tender("card", cents[1])), + withToken(tender("card", cents[2])), withToken(tender("card", cents[3]))) + if r.status != http.StatusOK { + t.Fatalf("complete = %d: %s", r.status, r.raw) + } + return saleID, r +} + +// RULE: a multi tender void's terminal calls run one after another, and each +// claim's in flight lease is stamped immediately before its own call, so a +// later tender's call never runs under a lease the earlier calls already +// spent; a resolve on a claim whose call is live is refused inside that +// lease; and once a claim is (wrongly) released and the terminal answers +// late, the recorded refund id holds the tender: the payback and the online +// refund route refuse 409 tender_refunded, the terminal is never asked +// again, and the drawer closes. +func TestAVoidsLaterTenderCallRunsUnderItsOwnLeaseAndALateAnswerNeverPaysTwice(t *testing.T) { + testutil.LockOutboxTables(t) + gw := &fakeGateway{charges: []*payment.GatewayResult{approvedCharge(), approvedCharge(), approvedCharge(), approvedCharge()}, + refundStatuses: []payment.GatewayStatus{payment.GatewayStatusRefunded, payment.GatewayStatusRefunded, + payment.GatewayStatusRefunded, payment.GatewayStatusRefunded}} + f := newFixture(t, testutil.RequireDBMaxConns(t, 4), func(f *fixture) { + f.service = f.service.WithGateway(gw) + }) + till := f.openTill(10000) + // four card tenders: the first three calls take their turn at the + // terminal, the fourth parks on it, so the common insert-time lease + // would be spent by the earlier calls' turns (simulated below by the + // lapse move). The calls run in the claims' order, which is the tenders' + // read order, and the tenders share one created_at, so the parked call's + // claim is whichever tender sorts last: the test reads that claim's own + // charge and never assumes which one it is. + saleID, body := f.fourCardSaleOf(t, [4]int64{150, 150, 150, 149}) + callsAfterSale := gatewayRefundCalls(t, gw) + + var refundCalls int32 + parked := make(chan struct{}, 1) + releaseTerminal := make(chan struct{}) + gw.onRefund = func() { + if atomic.AddInt32(&refundCalls, 1) <= 3 { + // the first three calls take their turn at the terminal, so the + // fourth call starts after them, never with them + time.Sleep(300 * time.Millisecond) + return + } + select { + case parked <- struct{}{}: + default: + } + <-releaseTerminal + } + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseTerminal) }) } + + voidDone := make(chan resp, 1) + fin := make(chan struct{}) + go func() { + defer close(fin) + voidDone <- f.do("POST", "/api/v1/pos/transactions/"+saleID+"/void", + map[string]any{"reason": "the customer left the card", "revision": rev(t, body)}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)) + }() + t.Cleanup(func() { release(); <-fin }) + select { + case <-parked: + case <-time.After(requestDeadline): + t.Fatalf("the void's fourth terminal call never parked") + } + + // the four claims stand; the first three carry their refund ids and the + // fourth is the parked one, unanswered + intents := f.saleIntents(t, saleID) + if len(intents) != 4 { + t.Fatalf("the void opened %d claims, want 4", len(intents)) + } + var latest map[string]any + for _, in := range intents { + if in["state"] == "PENDING" && in["refund_tx_id"] == "" { + latest = in + } + } + if latest == nil { + t.Fatalf("no unanswered claim among %v: want the parked fourth call's", f.saleIntents(t, saleID)) + } + // the lease is stamped per call: the parked claim's lease sits after the + // three earlier calls' turns, not at the common insert + var spanSeconds float64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT EXTRACT(EPOCH FROM (max(answer_due_at) - min(answer_due_at))) FROM pos_refund_intents WHERE sale_id = $1 AND act = 'void'`, + saleID).Scan(&spanSeconds); err != nil { + t.Fatal(err) + } + if spanSeconds < 0.7 { + t.Errorf("the void claims' leases span %.3f seconds, want at least the three earlier calls' turns end to end (each claim's lease is stamped immediately before its own call, never one common lease at the insert)", spanSeconds) + } + + // a resolve on the parked claim while its call is live is refused inside + // its own lease + if r := f.resolveOn(t, latest["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-LATE", "reason": "the terminal is still speaking", + }, "finance"); r.status != http.StatusConflict { + t.Fatalf("the resolve while the fourth call is live = %d, want 409: %s", r.status, r.raw) + } else if _, blockers, _ := errorOf(t, r); len(blockers) == 0 || blockers[0] != "refund_in_flight" { + t.Errorf("blockers = %v, want refund_in_flight (the live claim's own lease runs)", blockers) + } + + // the lapse the unfixed stamping would let happen: the insert-time lease + // runs out while the call is still on the wire (moved by hand here, the + // stand-in for the three earlier calls' turns under one common lease) + if _, err := f.db.Pool.Exec(context.Background(), + `UPDATE pos_refund_intents SET answer_due_at = NOW() - INTERVAL '1 second' WHERE id = $1`, latest["id"]); err != nil { + t.Fatal(err) + } + if r := f.resolveOn(t, latest["id"].(string), map[string]any{ + "outcome": "no_refund", "reconciliation_reference": "REC-LATE", "reason": "the lease moved", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the resolve on the lapsed lease = %d, want 200 (the release the restamp prevents): %s", r.status, r.raw) + } + + // the terminal answers late: the refund id lands on the released claim, + // and the void's money transaction refuses it (the claim is no longer + // PENDING) + release() + vr := awaitResp(t, voidDone) + if vr.status != http.StatusConflict { + t.Fatalf("the void under the late answer = %d, want 409: %s", vr.status, vr.raw) + } + + // the other three claims resolve as refunds; the last completes the void + // and the sale stands VOIDED with the fourth tender's charge as unapplied + // credit on its payment + for _, in := range f.saleIntents(t, saleID) { + if in["state"] != "PENDING" || in["refund_tx_id"] == "" { + continue + } + if r := f.resolveOn(t, in["id"].(string), map[string]any{ + "outcome": "refund", "refund_tx_id": in["refund_tx_id"].(string), "reason": "the terminal log shows it", + }, "finance"); r.status != http.StatusOK { + t.Fatalf("the refunded claim's resolution = %d: %s", r.status, r.raw) + } + } + if got := str(t, f.getSale(t, saleID), "status"); got != "voided" { + t.Fatalf("sale status = %q, want voided", got) + } + charge := latest["amount"].(int64) + credit := f.unappliedOf(t, latest["tender_id"].(string)) + if credit != charge { + t.Fatalf("the parked tender's unapplied credit = %d, want %d (its own charge, reversed onto its payment)", credit, charge) + } + f.assertARInvariants(t) + + // the late answer's money moved under the released claim's refund id: + // the credit is never a held credit and never payable again. The online + // refund route refuses it with the tender named, and the payback refuses + // it the same way, never reaching the terminal + payID, payRev := f.paymentOfTender(t, latest["tender_id"].(string)) + if r := f.do("POST", "/api/v1/payments/"+payID+"/refunds", map[string]any{ + "amount_cents": credit, "reason": "the late answer already refunded this card", "revision": payRev, + }, "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)); r.status != http.StatusConflict { + t.Fatalf("the online refund of the refunded tender = %d, want 409: %s", r.status, r.raw) + } else if _, blockers, _ := errorOf(t, r); len(blockers) == 0 || blockers[0] != "tender_refunded" { + t.Errorf("blockers = %v, want tender_refunded (the recorded refund that never booked holds the tender)", blockers) + } + if r := f.do("POST", "/api/v1/pos/refund-intents/"+latest["id"].(string)+"/payback", + map[string]any{"reason": "the customer is at the counter for their card"}, + "X-Test-Role", "finance", "X-Test-Sub", mustUUID(t)); r.status != http.StatusConflict { + t.Fatalf("the payback of the refunded tender = %d, want 409: %s", r.status, r.raw) + } else if _, blockers, _ := errorOf(t, r); len(blockers) == 0 || blockers[0] != "tender_refunded" { + t.Errorf("blockers = %v, want tender_refunded (the terminal already refunded this tender: the card is never paid twice)", blockers) + } + if got := gatewayRefundCalls(t, gw); got != callsAfterSale+4 { + t.Errorf("%d terminal refund calls after the late answer, want %d (the four void calls alone: the payback never reached the terminal)", got, callsAfterSale+4) + } + if got := f.unappliedOf(t, latest["tender_id"].(string)); got != credit { + t.Errorf("the credit after the refused payback = %d, want %d (nothing was booked or spent)", got, credit) + } + f.assertARInvariants(t) + + // the drawer closes: the released claim settled and nothing stands + if r := f.do("POST", "/api/v1/pos/till/"+till+"/close", + map[string]any{"counted_by_method": map[string]any{"cash": 10000}}, + "X-Test-Role", "cashier", "X-Test-Sub", mustUUID(t)); r.status != http.StatusOK { + t.Fatalf("the close = %d, want 200: %s", r.status, r.raw) + } + f.assertARInvariants(t) +} diff --git a/core/internal/pos/walkin_return_test.go b/core/internal/pos/walkin_return_test.go new file mode 100644 index 000000000..c0197b5a7 --- /dev/null +++ b/core/internal/pos/walkin_return_test.go @@ -0,0 +1,106 @@ +// 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" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +func TestAWalkInReturnRefusesAccount(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, "original_sale_id": saleID, "refund_method": "account", + "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.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 { + t.Errorf("%d memos on the walk-in customer, want 0", got) + } + if got := f.counterReturns(t); 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) +} + +// RULE (third review P3-D): naming the walk-in customer's id explicitly is +// still the walk-in: the account refund is refused whatever way the customer +// was named. +func TestAWalkInReturnNamedAsTheWalkInIsRefusedToo(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + saleID, body := f.saleOf("1", tender("cash", 599)) + lineID := body.body["lines"].([]any)[0].(map[string]any)["id"].(string) + walkIn := f.scalar(`SELECT id::text FROM customers WHERE account_number = 'WALK-IN'`).(string) + r := f.do("POST", "/api/v1/pos/returns", map[string]any{ + "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.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 new file mode 100644 index 000000000..635c7a67a --- /dev/null +++ b/core/internal/pos/wire_test.go @@ -0,0 +1,988 @@ +// 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" + "sync" + "testing" + + "github.com/gablelbm/gable/internal/payment" + "github.com/gablelbm/gable/internal/tax" + "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)) + f.ensureOpenTill(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, "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 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 account legs are scoped the same way, +// each read in one statement so the snapshot is consistent even while other +// packages' tests run against the shared database: the 1020 legs of exactly +// those customers' documents equal the sum of their balances, and the 2200 +// legs of their payments and applications equal their unapplied cash. +func (f *fixture) assertARInvariants(t *testing.T) { + t.Helper() + walkIn := f.scalar(`SELECT id::text FROM customers WHERE account_number = 'WALK-IN'`).(string) + customers := []string{f.customerID.String(), walkIn} + 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[])`, customers) + if err != nil { + t.Fatal(err) + } + 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) + } + // The control accounts, over the documents of the same two customers: + // every entry the counter's acts post names its document, so the 1020 + // legs of these customers' invoices, memos, payments, applications and + // refunds tie to the sum of their balances, and the 2200 legs to their + // unapplied cash. + docSQL := `(SELECT id::text FROM invoices WHERE customer_id = ANY($1::uuid[])) + UNION (SELECT id::text FROM credit_memos WHERE customer_id = ANY($1::uuid[])) + UNION (SELECT id::text FROM payments WHERE customer_id = ANY($1::uuid[])) + UNION (SELECT ap.id::text FROM ar_applications ap JOIN invoices i ON i.id = ap.invoice_id + WHERE i.customer_id = ANY($1::uuid[])) + UNION (SELECT rf.id::text FROM payment_refunds rf + LEFT JOIN payments p ON p.id = rf.payment_id + LEFT JOIN credit_memos m ON m.id = rf.credit_memo_id + WHERE p.customer_id = ANY($1::uuid[]) OR m.customer_id = ANY($1::uuid[]))` + 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 WHERE c.id = ANY($1::uuid[])) + = (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 + JOIN gl_journal_entries e ON e.id = l.journal_entry_id + WHERE a.code = '1020' AND (e.source_ref_id::text IN (`+docSQL+`) + OR e.reverses_entry_id::text IN (SELECT o2.id::text FROM gl_journal_entries o2 WHERE o2.source_ref_id::text IN (`+docSQL+`))))`, customers).Scan(&ties); err != nil { + t.Fatal(err) + } + if !ties { + t.Error("GL 1020 does not tie to the sum of the touched customers' balances") + } + if err := f.db.Pool.QueryRow(context.Background(), ` + SELECT (SELECT COALESCE(SUM(ROUND(p.amount_unapplied * 100)::bigint), 0) FROM payments p + WHERE p.status = 'POSTED' AND p.customer_id = ANY($1::uuid[])) + = (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 + JOIN gl_journal_entries e ON e.id = l.journal_entry_id + WHERE a.code = '2200' AND (e.source_ref_id::text IN (`+docSQL+`) + OR e.reverses_entry_id::text IN (SELECT o2.id::text FROM gl_journal_entries o2 WHERE o2.source_ref_id::text IN (`+docSQL+`))))`, customers).Scan(&ties); err != nil { + t.Fatal(err) + } + if !ties { + t.Error("GL 2200 does not tie to the touched customers' unapplied cash (2200 carries it as a credit)") + } +} + +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 + refundCents []int64 + voids []string + chargeCalls int + currency []string + // refundErrs scripts one error per refund call (nil answers success); + // voidErrs the same for voids (a reversal that can neither void nor + // refund answers 502). refundStatuses scripts one definite refusal + // status per refund call, the terminal's own no (a decline), which no + // error carries. + refundErrs []error + voidErrs []error + refundStatuses []payment.GatewayStatus + // onRefund runs at the start of every Refund call, outside the fake's + // 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 +} + +func approvedCharge() *payment.GatewayResult { + return &payment.GatewayResult{Status: payment.GatewayStatusApproved, TransactionID: "gw-1", AuthCode: "A1", + CardLast4: "4242", CardBrand: "VISA"} +} + +// declinedCharge is a card the gateway refused. +func declinedCharge() *payment.GatewayResult { + return &payment.GatewayResult{Status: payment.GatewayStatusDeclined} +} + +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++ + 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) { + g.mu.Lock() + defer g.mu.Unlock() + g.voids = append(g.voids, id) + var err error + if len(g.voidErrs) > 0 { + err, g.voidErrs = g.voidErrs[0], g.voidErrs[1:] + } + if err != nil { + return nil, err + } + return &payment.GatewayResult{Status: payment.GatewayStatusVoided}, nil +} + +func (g *fakeGateway) Refund(ctx context.Context, id string, cents int64) (*payment.GatewayResult, error) { + if g.onRefund != nil { + g.onRefund() + } + g.mu.Lock() + defer g.mu.Unlock() + g.refunds = append(g.refunds, id) + g.refundCents = append(g.refundCents, cents) + var err error + if len(g.refundErrs) > 0 { + err, g.refundErrs = g.refundErrs[0], g.refundErrs[1:] + } + if err != nil { + return nil, err + } + status := payment.GatewayStatusRefunded + if len(g.refundStatuses) > 0 { + status, g.refundStatuses = g.refundStatuses[0], g.refundStatuses[1:] + } + return &payment.GatewayResult{Status: status, TransactionID: fmt.Sprintf("ref-%d", len(g.refunds))}, nil +} + +func withToken(t map[string]any) map[string]any { + t["token_id"] = "tok-1" + 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 := f.counterInvoices(t); 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 := f.counterInvoices(t); 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) +} 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/internal/purchase_order/receive_location_row_test.go b/core/internal/purchase_order/receive_location_row_test.go new file mode 100644 index 000000000..52d31240f --- /dev/null +++ b/core/internal/purchase_order/receive_location_row_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package purchase_order_test + +// The receipt's row (the fourteenth review's P1-2): a branch scoped caller that +// receives a line into a named yard writes that yard's row, never the +// branch's largest row. The receive goes through the same inventory read the +// stock routes use, so this pins the only other caller of the adjust. + +import ( + "context" + "testing" + + "github.com/gablelbm/gable/internal/inventory" + "github.com/gablelbm/gable/internal/purchase_order" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +// RULE: a receipt of 3 at the branch's small yard lands there; the large yard +// stands untouched. +func TestAReceiptLandsInTheNamedYardNotTheBranchsLargestRow(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDB(t) + ctx := context.Background() + + branch := `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` + var branchID uuid.UUID + if err := db.Pool.QueryRow(ctx, `SELECT `+branch).Scan(&branchID); err != nil { + t.Fatal(err) + } + smallYard, bigYard := uuid.New(), uuid.New() + vendor, product, po, line := uuid.New(), uuid.New(), uuid.New(), uuid.New() + tag := "POR-" + uuid.NewString()[:8] + must := func(sql string, args ...any) { + t.Helper() + if _, err := db.Pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + must(`INSERT INTO locations (id, type, code, parent_id, branch_id) + VALUES ($1, 'YARD', $2, $4, $4), ($3, 'YARD', $5, $4, $4)`, smallYard, tag+"-S", bigYard, branchID, tag+"-B") + must(`INSERT INTO vendors (id, name) VALUES ($1, $2)`, vendor, tag) + must(`INSERT INTO products (id, sku, description, uom_primary, base_price) VALUES ($1, $2, 'sheet', 'SF', 12.25)`, product, tag+"-P") + // the small yard holds 5, the big yard 100: a read that picks the branch's + // largest row would land the receipt in the big yard + must(`INSERT INTO inventory (id, product_id, location_id, location, quantity) VALUES ($1, $2, $3, 'S', 5), ($4, $2, $5, 'B', 100)`, + uuid.New(), product, smallYard, uuid.New(), bigYard) + must(`INSERT INTO purchase_orders (id, vendor_id, status, source, branch_id) VALUES ($1, $2, 'SENT', 'MANUAL', `+branch+`)`, po, vendor) + must(`INSERT INTO purchase_order_lines (id, po_id, product_id, description, quantity, cost) VALUES ($1, $2, $3, 'sheet', 10, 9.00)`, line, po, product) + t.Cleanup(func() { + _, _ = db.Pool.Exec(ctx, `DELETE FROM events_outbox WHERE entity_type = 'purchase_order' AND entity_id = $1`, po) + _, _ = db.Pool.Exec(ctx, `DELETE FROM purchase_order_lines WHERE po_id = $1`, po) + _, _ = db.Pool.Exec(ctx, `DELETE FROM purchase_orders WHERE id = $1`, po) + _, _ = db.Pool.Exec(ctx, `DELETE FROM inventory WHERE product_id = $1`, product) + _, _ = db.Pool.Exec(ctx, `DELETE FROM products WHERE id = $1`, product) + _, _ = db.Pool.Exec(ctx, `DELETE FROM vendors WHERE id = $1`, vendor) + _, _ = db.Pool.Exec(ctx, `DELETE FROM locations WHERE id IN ($1, $2)`, smallYard, bigYard) + }) + + qty := func(yard uuid.UUID) string { + var out string + if err := db.Pool.QueryRow(ctx, `SELECT quantity::text FROM inventory WHERE product_id = $1 AND location_id = $2`, product, yard).Scan(&out); err != nil { + t.Fatal(err) + } + return out + } + // a caller scoped to the PO's branch receives 3 into the small yard + scoped := branchctx.With(ctx, &branchctx.Context{BranchID: &branchID}) + svc := purchase_order.NewService(purchase_order.NewRepository(db), db, nil, + inventory.NewService(inventory.NewRepository(db)), nil, nil). + WithOutbox(outbox.NewWriter(db, "")) + if err := svc.ReceivePO(scoped, po, []purchase_order.ReceiveLineInput{ + {LineID: line.String(), LocationID: smallYard.String(), QtyReceived: 3}, + }); err != nil { + t.Fatalf("receive: %v", err) + } + if got := qty(smallYard); got != "8.0000" { + t.Errorf("the named small yard = %s, want 8.0000 (the receipt landed somewhere else)", got) + } + if got := qty(bigYard); got != "100.0000" { + t.Errorf("the big yard = %s, want 100.0000 (the receipt touched the branch's largest row)", got) + } +} diff --git a/core/migrations/105_pos_wire_contract.sql b/core/migrations/105_pos_wire_contract.sql new file mode 100644 index 000000000..b001b32b7 --- /dev/null +++ b/core/migrations/105_pos_wire_contract.sql @@ -0,0 +1,423 @@ +-- 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, 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 +-- 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. +-- 4b the sale lines' invoice line links are backfilled from the sale's +-- invoice (by product and order within it), so the sales completed +-- before this item, and any down then up cycle, keep them; the down +-- keeps the sale's invoice_id link column for the same reason. +-- 5 pos_refund_intents: the committed claim that serializes every card +-- refund of a tender (one live intent per sale and tender), because the +-- gateway is called before the transaction (ADR 0005 section 11) and +-- the claim is what refuses a second void or card return before it +-- reaches the terminal. The row carries the gateway's own refund id the +-- moment the terminal answers, and a return claim carries its request +-- shape, so an operator can resolve a claim that outlives its act at +-- the guarded admin route; the (state, created_at) index is the finder. +-- +-- 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 OR duplicate_table 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 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 OR duplicate_table 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 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 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 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 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); +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 OR duplicate_table 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 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 OR duplicate_table 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 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 OR duplicate_table 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); + +-- 5. The refund intents. ADR 0005 section 11 keeps the card gateway outside +-- the money transaction ("The card gateway is called before the transaction +-- and its result written inside it"), so nothing else serializes two refunds +-- of one tender: the committed intent row does. One live (PENDING) intent +-- per sale and tender; the money transaction marks it DONE when the refund +-- posts, a failed gateway call marks it FAILED, and a refund whose +-- transaction refused stays PENDING beside its orphaned refund row, +-- refusing the next refund of the tender until it is reconciled. +CREATE TABLE IF NOT EXISTS pos_refund_intents ( + id UUID PRIMARY KEY, + sale_id UUID NOT NULL REFERENCES pos_transactions (id), + tender_id UUID NOT NULL REFERENCES pos_tenders (id), + gateway_tx_id TEXT NOT NULL, + amount NUMERIC(12, 2) NOT NULL, + act TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'PENDING', + refund_tx_id TEXT, + request JSONB NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pos_refund_intents_state_check CHECK (state IN ('PENDING', 'DONE', 'FAILED')), + CONSTRAINT pos_refund_intents_act_check CHECK (act IN ('void', 'return')) +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_pos_refund_intents_pending + ON pos_refund_intents (sale_id, tender_id) WHERE state = 'PENDING'; +-- The finder of the reconciliation: the PENDING claims by age (a claim that +-- outlives its act), whatever wrote them. The request column carries a +-- return claim's own request shape, so the operator resolution can replay +-- the money transaction the lost act never ran. Both idempotent: a second +-- apply changes nothing. +ALTER TABLE pos_refund_intents ADD COLUMN IF NOT EXISTS request JSONB NULL; +CREATE INDEX IF NOT EXISTS idx_pos_refund_intents_state_created + ON pos_refund_intents (state, created_at); + +-- The invoice line completion built from each sale line: a linked return +-- reads its cost link from the stored id, never from a position (a removed +-- cart line leaves the sale's positions gapped while the invoice numbers its +-- own lines from zero). The links of the sales completed before this item +-- are backfilled from their invoices, by product and order within it (the +-- invoices were built from those lines in order), product lines to product +-- invoice lines only: a kit, component, charge or text line is never paired +-- by position with a line of another type. The same backfill runs on +-- every apply, so a down then up cycle restores them, because the down +-- drops the column with the rest of this step. +ALTER TABLE pos_line_items ADD COLUMN IF NOT EXISTS invoice_line_id UUID NULL REFERENCES invoice_lines (id); +WITH sale_lines AS ( + SELECT l.id, t.invoice_id, l.product_id, + row_number() OVER (PARTITION BY t.invoice_id, l.product_id + ORDER BY l.position, l.created_at, l.id) AS n + FROM pos_line_items l + JOIN pos_transactions t ON t.id = l.transaction_id + WHERE t.invoice_id IS NOT NULL AND l.product_id IS NOT NULL AND l.invoice_line_id IS NULL + AND l.line_type = 'PRODUCT' +), +inv_lines AS ( + SELECT il.id, i.id AS invoice_id, il.product_id, + row_number() OVER (PARTITION BY i.id, il.product_id + ORDER BY il.position, il.created_at, il.id) AS n + FROM invoice_lines il JOIN invoices i ON i.id = il.invoice_id + WHERE il.line_type = 'PRODUCT' +) +UPDATE pos_line_items l +SET invoice_line_id = v.id +FROM sale_lines s +JOIN inv_lines v ON v.invoice_id = s.invoice_id AND v.product_id = s.product_id AND v.n = s.n +WHERE l.id = s.id; +CREATE INDEX IF NOT EXISTS idx_pos_line_items_invoice_line ON pos_line_items (invoice_line_id) + WHERE invoice_line_id IS NOT NULL; + +-- 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 OR duplicate_table 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 OR duplicate_table 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 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 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 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; +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; +-- 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; + +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; + 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, + 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/109_refund_intent_in_flight_lease.sql b/core/migrations/109_refund_intent_in_flight_lease.sql new file mode 100644 index 000000000..68f4ec4f0 --- /dev/null +++ b/core/migrations/109_refund_intent_in_flight_lease.sql @@ -0,0 +1,32 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 109: the refund claim's in flight lease (C2-5, the fourteenth review's +-- P1-1). A claim is a tender held while its act's terminal refund runs; the +-- resolve route and the drawer close must be able to tell a live claim (its +-- call is on the wire, the terminal may still move the tender's money) from +-- a stranded one (its act ended: a lost verdict, a crash, a refused +-- transaction). Three columns give the claim that record: +-- +-- answer_due_at the lease: the claim stays unresolvable until this +-- instant even if nothing else settles it, so a crashed or +-- hung act can never hold a drawer forever. Written by the +-- insert as now plus the service's stated in flight timeout +-- (the refundAnswerLease constant); the default carries rows +-- that predate the column, whose lease then expires from +-- the migration on. +-- answered_at stamped by every path that ends the claim's own terminal +-- call: the refund id's committed write, the decline +-- release, and the unknown outcome record. +-- answer_state IN_FLIGHT until the call answers, ANSWERED when it has, +-- UNKNOWN when a resolution proceeds on the lease expiring +-- with no answer at all (the terminal never spoke). +-- +-- The resolve and the close read these: a claim with no answer inside its +-- lease answers 409 refund_in_flight, so a tender whose money has not +-- answered cannot be released and then refunded twice. + +ALTER TABLE pos_refund_intents + ADD COLUMN IF NOT EXISTS answer_due_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '90 seconds'), + ADD COLUMN IF NOT EXISTS answered_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS answer_state TEXT NOT NULL DEFAULT 'IN_FLIGHT'; diff --git a/core/migrations/110_refund_intent_payback.sql b/core/migrations/110_refund_intent_payback.sql new file mode 100644 index 000000000..517c2a3f4 --- /dev/null +++ b/core/migrations/110_refund_intent_payback.sql @@ -0,0 +1,15 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 110: the payback claim (C2-5, FROM-PRINCIPAL 60b). The counter's payback +-- route pays a declined void tender's credit back to its card through the +-- terminal rail, and its terminal call runs under the same claim discipline +-- every terminal refund runs under: one live (PENDING) claim per tender and +-- the in flight lease, so a hung call can never be paid twice. The intents +-- table's act vocabulary gains 'payback' for those claims: a claim whose act +-- is the payback itself, opened beside the void's own released claim on the +-- same tender (which stays FAILED: it is the void's own record). + +ALTER TABLE pos_refund_intents DROP CONSTRAINT IF EXISTS pos_refund_intents_act_check; +ALTER TABLE pos_refund_intents + ADD CONSTRAINT pos_refund_intents_act_check CHECK (act IN ('void', 'return', 'payback')); diff --git a/core/migrations/111_payback_one_per_tender.sql b/core/migrations/111_payback_one_per_tender.sql new file mode 100644 index 000000000..d2d38c2fb --- /dev/null +++ b/core/migrations/111_payback_one_per_tender.sql @@ -0,0 +1,18 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 111: one payback per tender, made structural (C2-5, the sixteenth +-- review's P1-1). The payback route opens its claim in a transaction that +-- locks the tender's payment row and rechecks the credit under that lock, +-- so a contender whose earlier read saw the credit standing cannot open a +-- second claim beside the first's booking; this index is the structure +-- behind that recheck: a tender holds at most one payback claim in the +-- states that mean money (PENDING, the claim whose terminal call is on the +-- wire, and DONE, the claim whose credit was booked). A FAILED payback +-- claim (the terminal's own decline, or a recorded no refund) stays outside +-- the index, so the counter may ask again for a credit the terminal +-- refused; and the void's and the return's claims never enter it, their own +-- (sale, tender) index governing them. + +CREATE UNIQUE INDEX IF NOT EXISTS uq_pos_refund_intents_payback_tender + ON pos_refund_intents (tender_id) WHERE act = 'payback' AND state IN ('PENDING', 'DONE'); diff --git a/core/migrations/114_pos_sale_account_refs.sql b/core/migrations/114_pos_sale_account_refs.sql new file mode 100644 index 000000000..bbb5e0c4a --- /dev/null +++ b/core/migrations/114_pos_sale_account_refs.sql @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 114: the counter's charge sale names the customer's purchase order and job. +-- +-- An account that asks for a purchase order (customers.po_required) is sold +-- to only with one: the counter keeps the number on the sale and the invoice +-- it becomes, and the job the sale belongs to is the invoice's project. +-- Nullable and additive: a sale that names neither is exactly what it was. + +ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS customer_po TEXT; +ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS job_id UUID REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS customer_po TEXT; 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..da33f7d24 --- /dev/null +++ b/core/migrations/down/105_pos_wire_contract_down.sql @@ -0,0 +1,159 @@ +-- 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. + +-- The down deletes the kit, component, charge and text lines the old shape +-- cannot hold. A completed or voided sale's lines are history the invoice +-- and the returns read, so the down refuses while any belongs to one, naming +-- the count, and changes nothing. +DO $$ +DECLARE held BIGINT; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'pos_line_items' AND column_name = 'line_type') THEN + EXECUTE 'SELECT count(*) FROM pos_line_items l JOIN pos_transactions t ON t.id = l.transaction_id + WHERE l.line_type IN (''KIT'', ''COMPONENT'', ''CHARGE'', ''TEXT'') AND t.status IN (''COMPLETED'', ''VOIDED'')' + INTO held; + IF held > 0 THEN + RAISE EXCEPTION '105 down refused: % kit, component, charge or text lines belong to completed or voided sales; the old line shape cannot hold them', held; + END IF; + END IF; +END $$; + +-- The down drops pos_refund_intents with its rows, and a PENDING claim +-- dropped that way silently releases its tender (the next refund of the +-- card would pay it twice), so the down refuses while any claim is still +-- pending, naming the count and changing nothing. +DO $$ +DECLARE pending BIGINT; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = 'pos_refund_intents') THEN + SELECT count(*) INTO pending FROM pos_refund_intents WHERE state = 'PENDING'; + IF pending > 0 THEN + RAISE EXCEPTION '105 down refused: % refund claims are still pending; dropping the intents table would release their tenders', pending; + END IF; + END IF; +END $$; + +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. 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; + +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'; +-- 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); +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. The refund intents go first (their foreign keys +-- block the tender and sale deletes): the table goes with its rows, DONE and +-- FAILED alike, which is why the refusal above holds while any claim is +-- still PENDING (a dropped PENDING claim releases its tender). +DROP TABLE IF EXISTS pos_refund_intents; +DROP INDEX IF EXISTS idx_pos_tenders_payment; +ALTER TABLE pos_tenders DROP COLUMN IF EXISTS payment_id; + +DROP INDEX IF EXISTS idx_pos_line_items_invoice_line; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS invoice_line_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 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; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS position; +ALTER TABLE pos_line_items DROP COLUMN IF EXISTS taxable; + +-- 1. the sales. The invoice link column stays: the invoices themselves +-- survive this down (105 never created them), so the link is history, and +-- keeping it lets the up's backfill restore each sale line's invoice line +-- link after a down then up cycle (the r6 note on P3-3). +-- 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 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; diff --git a/core/migrations/down/109_refund_intent_in_flight_lease_down.sql b/core/migrations/down/109_refund_intent_in_flight_lease_down.sql new file mode 100644 index 000000000..e2d2d4770 --- /dev/null +++ b/core/migrations/down/109_refund_intent_in_flight_lease_down.sql @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Down of 109_refund_intent_in_flight_lease: drops the lease columns. The +-- up wrote no data beyond the columns themselves (every existing row's +-- answer_state stays IN_FLIGHT and every lease expiry is recomputed by the +-- default on a re-apply), so nothing of a dealer's data maps back. + +ALTER TABLE pos_refund_intents DROP COLUMN IF EXISTS answer_state; +ALTER TABLE pos_refund_intents DROP COLUMN IF EXISTS answered_at; +ALTER TABLE pos_refund_intents DROP COLUMN IF EXISTS answer_due_at; diff --git a/core/migrations/down/110_refund_intent_payback_down.sql b/core/migrations/down/110_refund_intent_payback_down.sql new file mode 100644 index 000000000..02bba5ed3 --- /dev/null +++ b/core/migrations/down/110_refund_intent_payback_down.sql @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Down of 110_refund_intent_payback: the payback claims are this item's own +-- claims, not valid old shape data, so they go first (their refunds live on +-- in payment_refunds and the audit log, which stay), then the act +-- vocabulary narrows back. A dealer who paid credits back and then runs the +-- down loses the claim rows only, never a booked refund. + +DELETE FROM pos_refund_intents WHERE act = 'payback'; +ALTER TABLE pos_refund_intents DROP CONSTRAINT IF EXISTS pos_refund_intents_act_check; +ALTER TABLE pos_refund_intents + ADD CONSTRAINT pos_refund_intents_act_check CHECK (act IN ('void', 'return')); diff --git a/core/migrations/down/111_payback_one_per_tender_down.sql b/core/migrations/down/111_payback_one_per_tender_down.sql new file mode 100644 index 000000000..687df6e1f --- /dev/null +++ b/core/migrations/down/111_payback_one_per_tender_down.sql @@ -0,0 +1,9 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Down of 111_payback_one_per_tender: the index was this item's own rule +-- (one payback per tender), not a shape older rows depend on, and the claim +-- rows themselves survive untouched with their refunds, so the down drops +-- only the index and applies again cleanly. + +DROP INDEX IF EXISTS uq_pos_refund_intents_payback_tender; diff --git a/core/migrations/down/114_pos_sale_account_refs_down.sql b/core/migrations/down/114_pos_sale_account_refs_down.sql new file mode 100644 index 000000000..934a61471 --- /dev/null +++ b/core/migrations/down/114_pos_sale_account_refs_down.sql @@ -0,0 +1,6 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +ALTER TABLE invoices DROP COLUMN IF EXISTS customer_po; +ALTER TABLE pos_transactions DROP COLUMN IF EXISTS job_id; +ALTER TABLE pos_transactions DROP COLUMN IF EXISTS customer_po; diff --git a/core/pkg/middleware/keybranch_allow.go b/core/pkg/middleware/keybranch_allow.go index 2649a8f4f..c08a3d2f3 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"}, @@ -397,11 +398,13 @@ var keyBranchRoutes = map[string]KeyBranchRouteNote{ "POST /api/v1/payments/{id}/applications": {KeyBranchClassWalled, "payments.branch_id beside InvoiceVisible"}, "POST /api/v1/payments/{id}/refunds": {KeyBranchClassWalled, "payments.branch_id (MemoFactsFor and the refund wall)"}, "POST /api/v1/payments/{id}/transitions": {KeyBranchClassWalled, "payments.branch_id (the payment repository wall)"}, - "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/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); 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/{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)"}, @@ -425,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/adr/0005-sales-and-money-core.md b/docs/adr/0005-sales-and-money-core.md index d0166733e..ba359407f 100644 --- a/docs/adr/0005-sales-and-money-core.md +++ b/docs/adr/0005-sales-and-money-core.md @@ -1332,6 +1332,98 @@ The card gateway is called before the transaction and its result written inside it, as today; a database failure after a successful charge is logged as critical for reconciliation, as today. +Every card refund (a void's, a card return's) is claimed first in a committed +refund intent, and the intent table is the one authority on what a card +tender has refunded or may be refunding: a claim is refused when a void intent +of the tender is done, when the tender's done and pending amounts plus the +claim pass the tender, or when the sale is no longer completed, and a return +refunded by any method but the card's own and the till close are refused +while an intent of the sale or session is pending (and such a return is +refused while the sale's void has a booked tender refund, its goods already +paid back at the terminal). A gateway error with no verdict leaves the +intent pending (the terminal may have moved the money) with an audit row, +and only the terminal's explicit decline releases it: an answer of PENDING +or ERROR proves nothing either way and leaves the claim standing the same +way. The gateway's refund id is written onto the intent the +moment the terminal answers, in its own committed write, so a pending intent +that carries one is a refund that moved and one without it may not have. A +claim also records its own terminal call: the call is bounded, every path it +can take (the refund id's committed write, the decline release, the no +verdict record, and an abandon stamp for a tender whose call was never +reached) stamps the answer, and the claim carries an in flight lease from +the moment it opens, restamped in its own committed write immediately +before each of its act's terminal calls, so a later tender's call in a +sequence (a void's calls run one tender after another) never runs under a +lease the earlier calls already spent. While the terminal has not answered +and the lease runs, the claim is live, not stranded: the resolve route and +the till close refuse it (409 `refund_in_flight`), so a tender whose money +has not answered cannot be released and then refunded twice; once the lease +expires with no answer at all (a crashed or hung act) the claim is marked +UNKNOWN and settles as the operator records. The terminal's refund id is +written onto the claim whatever state it is in, and a recorded refund that +never booked holds the tender against every later act (the payback and +every act on the credit, an application or the online refund route alike, +refuse 409 `tender_refunded` until the claim is reconciled), so a late +answer behind an early release can never be paid twice. A +pending intent is resolved by one guarded admin route, for the finance and +owner roles and never a machine key, inside the caller's branch (the claim's +sale is read through the branch context, so a caller outside the branch +finds a missing row; and a caller whose context carries no branch, the +default branch setting off and no header sent, has the sale's own branch +held to the caller's grants, the same missing row outside them): the +operator records the terminal's actual +outcome, either its refund id (the act's own money transaction then books it +with every cap and lock) or an explicit "no refund" with a reconciliation +reference (the intent is released and nothing moves), and one audit row names +the person, the intent, the outcome and the reason. The record cannot +contradict the claim's own: a no refund over a claim carrying the terminal's +refund id, and a refund recorded under a different id, are refused. The +claim row is locked inside the resolution's transaction, read right after +the sale row it settles under, and every state +change refuses when it settles no row, so of any number of concurrent +resolutions exactly one books and the losers are told the race, never a +state refusal the winner's own completion caused. A void's resolution +accounts for each card tender separately: it books only the resolved +tender's refund, and the sale's void completes when no claim of the sale is +pending anymore, from whichever leg empties them (a booked refund or a no +refund of the last claim alike), so the order the claims are resolved in +never decides the end state; a void whose every claim settles without a +booked refund keeps the sale completable by a fresh void. Between one +tender's booked refund and the last claim's resolution the sale stands +completed in an interim state: the booked refund reversed its application, +so the invoice is open again for the booked cents and the customer's balance +carries them as owed, until the last claim resolves and the void completes, +and the till close is refused while any claim of the session is pending, so +the interim never slips into a counted drawer. Nothing may be applied to the +interim invoice while the void's claims stand: every application path (a +receipt's applications, the apply route, a credit memo, a write off) refuses +with 409 `void_claim_pending` naming the claims, so the void is always +completable and a credit that might have been absorbed stays on its own +payment. The tenders whose refunds were declined, or were recorded as a no +refund, keep their charge as unapplied credit owed to the customer, and +that credit is held to its own tender: it applies to no invoice, whatever +the customer. The account core asks the counter through a guard (the +origin is readable through `pos_tenders.payment_id`, which the account +core alone cannot read), and the refusal names the origin, the void and +the claim it released; a counter card tender is exactly its cents, so the +unapplied cash on its payment can only be the void's reversal, and on the +shared walk-in customer one card holder's credit must never pay another +walk-in's invoice. The credit is paid back only to that card, through the +counter terminal rail the charge came from, by the payback route beside +the resolve route (`POST /api/v1/pos/refund-intents/{id}/payback`, finance +and owner only, the branch wall, exactly one payback per tender, made +structural by a partial unique index on the tender's payback claims +(PENDING or DONE; a FAILED claim stays outside it so the counter may ask +again after a decline), with the payback's claim opened in a transaction +that locks the tender's payment row and rechecks the credit under that +lock, so of any number of concurrent paybacks exactly one reaches the +terminal and the card is never paid twice, its +terminal call under the same claim discipline every terminal refund runs +under); the payment refund route through the online gateway is never the +way back for a counter tender and refuses the credit (409 +`tender_credit_held`). Until the card is paid back the credit stays held: +nothing else can spend it. + ### 12. Events Every event is written through the outbox in the act's transaction, last, @@ -1763,7 +1855,11 @@ Inputs proved: IN-1.1, IN-1.2, IN-1.3, IN-1.4, IN-3.2, IN-3.7. Tests: a split tender sale posts one invoice entry, one receipt per tender and one application entry per tender; the till's expected cash equals the sum of the session's cash payments (already net of change; change is never subtracted -a second time) plus the opening float less cash refunds; a void of a sale with +a second time) plus the opening float less cash refunds, and the expected +CARD figure names the session's net card collection, its completed sales' +card tenders less the booked card refunds (a void's resolutions book against +the tender's payment) and its card returns' refunds through their credit +memos, dropping with the tender when a void completes; a void of a sale with a card tender refunds through the gateway and leaves the ledger at zero for the sale; a return restocks and reverses COGS; the offline sync completes each sale through the same path; a sale whose event write fails leaves no diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index d69848838..9dbe2872a 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -234,6 +234,59 @@ get an entry. | 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 | | CORE-migrate | the one binary's `core migrate` (operational, no route) | `core migrate` refused every flag (`flag provided but not defined: -report`), so the read only units pre flight report that ADR 0006 section 8, migration 099 and the C3-2A-units row name as `core migrate -report units` ran only through the old `go run ./cmd/migrate -report units` entry | `core migrate` parses its own flags: `-report units` runs the pre flight report (the same output as the old entry), no flag applies the migrations as before, and any other flag, report or argument, or `-report` with an empty value, is refused with exit 2 (`-h` prints the usage and exits 0); the old `go run ./cmd/migrate` entry parses the same way, so a flag it once ignored and migrated past is now refused; the other subcommands still refuse every flag | +| 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 and the walk-in customer, returns `unit_price` to scale 2, and the up applies again after it. It deletes the kit, component, charge and text lines the old shape cannot hold only while no completed sale holds one: it refuses (an exception naming the count) otherwise and changes nothing (C2-5r4 row below) | +| C2-5r1 | returns: `POST /api/v1/pos/returns` bounded by the sale it names | A line naming a sale line copied the sale line's list `unit_price` (before any discount), took the request's quantity and even the request's own price, and nothing capped the cumulative return: one sold line refunded any quantity, again and again, at any price | A line that names a sale line (`line_id`) refunds at that line's own extension per unit (`line_total / quantity`, the discount already inside it) with its share of the tax the sale actually collected (the sale's tax over its taxable base, so a provider priced or exempt sale returns exactly what it charged); a client `unit_price_ten_thousandths` on a linked line is a 400 naming `lines[i].unit_price_ten_thousandths`, and a quantity that with every earlier return of the line exceeds what it sold is refused 409 with the blocker `exceeds_sold`, checked before the act and again under the sale row lock. A free line (no sale named) keeps its own price at today's branch rate | +| C2-5r1 | returns: restock cost and revenue account of a linked line | A restocking return line relieved COGS at the product's today average cost (a unit sold at one cost relieved at another after the average moved), and the memo's entry debited all revenue to 4010 whatever the line's account | A linked restocking line takes its source invoice line's `unit_cost` (found by position through the sale's invoice) and records the link on `credit_memo_lines.invoice_line_id`; the entry's revenue legs are per revenue group like the sale's, so a returned FREIGHT charge debits 4020 back. A linked charge line carries its own shape (line type and charge code on the memo line); a free line still restocks at today's average | +| C2-5r1 | void: `POST /api/v1/pos/transactions/{id}/void` after a return | The return bumped the sale's revision but left it COMPLETED, so a void sent on the fresh revision paid the whole sale out a second time and restocked the returned units again | A void of a sale with any return against it is refused 409 with the blocker `has_returns` ("return the rest instead"), checked before the gateway calls and again under the sale row lock | +| C2-5r1 | complete and void: the session inside the transaction | A sale completed into a closed till session (carrying the closed session's id, 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 completion and the void lock the sale's till session row FOR SHARE inside their transactions and refuse 409 `session_closed` when it is not open; the close takes the same row FOR UPDATE before it aggregates, so the acts serialize and the drawer's count is coherent either way | +| C2-5r1 | complete: the tender check against the live total | The pre-transaction check priced the branch rate only (an exempt customer paying exactly was refused), and the in-transaction check reduced to `min(tendered, total) < 0` and never refused: a provider tax above the estimate completed the sale underpaid with a negative `change_cents` and an open balance on the walk-in customer | The pre-check prices with the same resolver the transaction uses (exemption first, then the provider or the branch rate); inside the transaction, once the tax is known on the locked lines, `change = tendered - total`: a negative change is refused `insufficient_tender`, a change above the cash taken `change_from_cash`, and a negative `change_cents` is never written. The ACCOUNT credit check runs under the customer's credit advisory lock (section 11 step 1a, the order module's own key), so two concurrent ACCOUNT sales for one customer cannot both pass over the limit | +| C2-5r1 | complete, void and returns: the gateway order and refused gateway money | Every guard that gates the act ran inside the transaction, after the gateway had already charged or refunded: a stale revision void refunded the card and left the sale completed, a 428 answered with the money moved, and a transaction that failed after a gateway success left the charge or refund standing with nothing recorded | Every lock free guard (the revision, the status, the session, the returns, the caps, the credit read) runs before any gateway call, so a refusal never moves money at the terminal. A charge the transaction then refuses is reversed the C2-4 card route's way (the same day void first, the refund when the void is refused, audited `pos.card_charge_reversal` in its own committed write) and answers 502 `charge_not_reversed` when neither works; a refund the transaction refuses is recorded in a committed audit row of its own (`pos.gateway_refund_orphaned`) carrying the gateway transaction and refund ids beside a CRITICAL log | +| C2-5r1 | counter and till routes: the branch wall (behaviour, no route change) | The free return took its branch from the request's `register_id` 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 | The register and session reads resolve through the branch wall (`branchctx`): a register or session outside the caller's branch is a 404, like the sale, void and linked return reads already were | +| C2-5r1 | returns: the walk-in customer refunded to account | A return resolved to the walk-in customer with `refund_method: account` created an open credit memo on a customer that cannot hold an ACCOUNT tender: the credit was stranded where nobody could apply it | Refused 409 `walk_in_account`, as the sale side refuses an ACCOUNT tender for the walk-in; the walk-in's returns are paid from the drawer or to the card | +| C2-5r1 | migration 105 `pos_wire_contract`, second cut (operational, no route) | `pos_return_lines` had no link to the sale line (a return could not be bounded or costed by what it returned) and its `product_id` was NOT NULL (a returned charge line was a 500); the up died midway on a legacy ACCOUNT return with no customer; the down left the credit memo counter advanced, so a down then up renumbered the migrated memo and left a gap in the gapless series | `pos_return_lines.sale_line_id` links each return line to the sale line it returns (the cap and the restock cost read it) and `product_id` is NULL-able for charge lines, both returned by the down; the up preflights and refuses a legacy ACCOUNT return with no customer before it writes anything; the down returns the credit memo counter as the C2-4 down does | +| C2-5r2 | returns: a line of a named sale must name the sale line | A return line could carry `product_id` with its own `unit_price_ten_thousandths` while the request named `original_sale_id`: the line stood outside every cap (a product line on a named sale refunded any quantity, again and again, at the client's price) | When `original_sale_id` is named, every line carries `line_id` (a line without it is a 400 naming `lines[i].line_id`); only a return that names no sale stands free on its own product and price | +| C2-5r2 | returns: the invoice line link by identity, not position | The linked return found its source invoice line by matching the sale line's stored position against the invoice's positions: a cart line removed before completion left the sale's positions gapped while the invoice numbered its own lines from zero, so the return linked (and relieved COGS at) the wrong line, or none and fell back to today's average cost | Completion stores the invoice line id it built from each sale line on the sale line itself (`pos_line_items.invoice_line_id`, written in the completion's transaction, migration 105); the linked return reads the stored id, takes that line's `unit_cost` and records it on `credit_memo_lines.invoice_line_id` | +| C2-5r2 | returns: a kit line returns its components | A returned kit line restocked the kit product, which has no inventory row: the act answered 500 and the goods could never come back through a return (without restock the components stayed out and the 1030/5010 legs never moved) | A returned kit line restocks the sale's component lines under it, each at the returned fraction of its quantity and at the component's own invoice line `unit_cost` for the 1030/5010 legs; the kit line itself carries the refund at its own price and no stock. A return line that names a component of a kit sale with `restock` is a 400 naming `lines[i].restock` (the kit line carries the return) | +| C2-5r2 | returns: repeated partial returns of a line refund no more than the line was paid | Each partial return refunded its own rounded share (`line_total x returned / sold`, the tax share rounded the same way), so repeated partial returns summed past what the sale took (three returns of one unit on a 1649 line at 146 tax refunded 1797 for 1795, and the drawer paid the difference) | The refund of each linked line is capped at the line's total less every cent earlier returns refunded, and the return that completes the line takes that remainder exactly; the tax is capped the same way against the sale's tax less the tax earlier returns took. The refunded cents per line and the tax are summed under the sale row lock beside the quantity cap, and the memo, its legs and the refund are written from that in-transaction answer | +| C2-5r2 | returns: a card refund targets the sale's own card tender | The card return refunded whatever gateway transaction the request's `gateway_tx_id` named, for the return's whole amount: a cash sale was "refunded" to a stranger's charge id, and a free return (no sale) refunded a named id too | A card return refunds the original sale's own card tender's gateway transaction, capped at that tender's net amount less every cent earlier card returns of the sale took (409 `exceeds_card_tender` past it). A client `gateway_tx_id` is a 400 naming the field; a sale with no card tender behind a gateway transaction is a 409 `card_tender` (refund it from the drawer), a sale paid with several card tenders a 409 `card_tender` (a return cannot pick one), and a return that names no sale a 409 `card_refund_needs_sale`: the money never leaves through an id the client chose. The refund row (`payment_refunds.gateway_refund_id`) names the tender's own gateway transaction | +| C2-5r2 | returns: a cash return pays out of an open drawer | The return read the register's open session before its transaction and never locked or rechecked it: a return racing the close committed into the counted session (the drawer went short of its posted Z report), and a return with no session open paid cash out of no drawer at all (`till_session_id` null) | Inside the transaction the register's open session is locked FOR SHARE (the close's FOR UPDATE waits on it, so a close that follows aggregates the payout) and a cash return is refused 409 `no_open_session` when none is open (or the session closed under the lock: 409 `session_closed`). A card or account return may live sessionless and attaches the open session when one is open | +| C2-5r2 | returns: a legacy unlinked return counts against the cap | Migration 105 could not link a pre contract return line to its sale line, so the quantity cap ignored it: a sale half returned before the contract could be returned in full again through the linked route | The cap counts the sale's unlinked (legacy) return lines by product beside the linked ones, so the goods bound the refunds whichever route brought them back | +| C2-5r2 | returns: naming the walk-in customer explicitly is still the walk-in | The walk-in refusal keyed on "no customer resolved", so a request naming the walk-in customer's id with `refund_method: account` created the open credit memo the refusal exists to prevent | The resolved customer is compared with the walk-in's id: naming it explicitly refuses 409 `walk_in_account` the same way | +| C2-5r2 | void and complete: one gateway movement per sale, and every movement recorded | Three concurrent voids of one card sale each passed the pre-transaction guards and each called the gateway, so one voided sale made three refunds (two only in orphan rows); a completion whose second card charge was declined left the first charge standing with nothing recorded; a void whose second card refund failed left the first refund made with nothing recorded | The void claims its card refunds through a committed refund intent (migration 105's pos_refund_intents, one live claim per sale and tender) in its own short transaction, rechecking every guard under the sale row lock, then calls the gateway outside every transaction (ADR 0005 section 11) and writes the result inside its money transaction: a second void, or a card return of the same tender, is refused 409 refund_in_flight by the claim's unique index before it reaches the terminal, so concurrent claims make exactly one refund (the losing contenders answer 409 and move no money). A tender whose charge is declined (or fails) makes the completion reverse every charge the tenders before it already took, answering 502 `charge_not_reversed` when a reversal fails too; a refund that fails mid-way leaves every refund already taken recorded in committed `pos.gateway_refund_orphaned` rows while the sale stands as it was | +| C2-5r3 | void and card return: the refund intent (behaviour, no route change) | The card return called the gateway before its transaction with nothing serializing it: three concurrent card returns of one unit refunded the card three times, a void racing a card return refunded twice, and the card tender's cap was bypassed by two returns in flight. The void's refund, moved inside the transaction by the previous round, held the sale row and the session's FOR SHARE lock across the network call, against ADR 0005 section 11 and 14.2 C2-5 | Every card refund (the void's and the card return's) is claimed first through a committed refund intent, unique per sale and tender while pending (migration 105's pos_refund_intents): the claim's own short transaction rechecks every guard under the sale row lock (the return: the quantity caps and the card tender cap; the void: the status, revision, returns and session guards), then the gateway is called outside every transaction, and the money transaction marks the claim done as it posts. A second void or card return of the same tender answers 409 refund_in_flight before the terminal; a gateway failure releases the claim having moved no money; a refund whose transaction refused leaves the claim standing and the orphaned refund row recording it, refusing the next refund of the tender until it is reconciled | +| C2-5r3 | returns: a legacy return read with its own sign | Migration 105 leaves the legacy pos_return_lines and pos_returns positive (it negates only while copying into credit memos), and the cap reads negated them, so a legacy return RAISED the caps: a sale partly returned before the contract could be returned for more than it sold, to the card included | Each source is read with its own sign (ABS): the legacy quantity, the refunded tax and the card refunds. The legacy quantity and cents bear on the product's first sale line by position, once, so a sale of several lines of one product loses headroom on one line only; and the legacy cents lower that line's remaining money, so the rounded shares of later linked returns can never pass what the sale took | +| C2-5r3 | returns: one request names one sale line once | A request that named the same sale line several times priced each duplicate its own rounded share against the same earlier total, so three namings of a 1649 line refunded 1650 | The second naming of a sale line is a 400 naming lines[i].line_id; the quantity belongs in one line | +| C2-5r3 | returns: fractional partial returns refund the sale exactly | The tax remainder capped but never completed (six half returns of a 1650 + 146 line refunded 144 of the 146 tax, two cents stayed owed), and each restock cost its own rounded share (the six restocks relieved 978 of cost of goods sold against the 975 the sale relieved) | The return that finishes every taxable line of the sale takes the rest of the tax exactly, and each restocking line and kit component costs and stocks the difference between its cumulative returned position before and after the return, so repeated fractional returns sum to the sale's own tax and relieved cost | +| C2-5r3 | complete: a refused completion reverses every charge | The reversal loop answered the first failed reversal and never attempted the later charges, leaving them charged behind a sale that never was | Every charge already taken is attempted (each recorded in its own reversal row); the first failure is the 502 charge_not_reversed answered | +| C2-5r3 | migration 105: the invoice line links survive a down then up | The down dropped both the sale's invoice_id link and the sale lines' invoice_line_id, so a down then up cycle left every sale completed under 105 unlinked: a linked return fell back to today's average cost and a null invoice_line_id | The up backfills each sale line's invoice line from the sale's invoice (by product and order within it, product sale lines to product invoice lines only, so a kit, component, charge or text line is never paired by position with a line of another type), and the down keeps the invoice_id link column (the invoices survive the down; 105 never created them), so the cycle restores the links; said in both files' notes | +| C2-5r4 | pos: `POST /api/v1/pos/refund-intents/{id}/resolve` (new route) | A refund intent whose act died between the claim and the money transaction (or whose terminal call answered no verdict) stayed PENDING with nothing able to tell a refund that moved from one that did not, and nothing able to resolve it; it refused every later void and card return of the tender | The guarded admin route of FROM-PRINCIPAL 41: the finance and owner roles only, behind the branch middleware, and a machine key refused in the route itself whether bound or not (403 `forbidden`; PR 83's key allowlist, not merged here, will list it REFUSED). The body is `outcome` (`refund` with the gateway's `refund_tx_id`, or `no_refund` with the `reconciliation_reference` finance filed) and a `reason`; a bare resolve, a half record and an unknown outcome are 400 naming the field, an unknown claim 404, a claim that is not PENDING 409 `intent_not_pending`. The claim's sale is read through the caller's branch context, so a caller outside the branch finds a missing row (404) on both legs; and a caller whose context carries no branch (multi_branch_enabled on, default_branch_required off, no header) has the sale's own branch held to the caller's grants through the branch guard serve passes the document and purchase order handlers, the same missing row (404, nothing moved) outside them, so the route acts only on the caller's branch, header or not. The record cannot contradict the claim's own: a no refund over a claim carrying the terminal's refund id is 409 `refund_recorded`, a refund id that differs from the recorded one 409 `refund_tx_id_conflict`. A recorded refund books through the act's own money transaction with every cap and lock and completes the claim DONE with the recorded id; a void's resolution books only the resolved tender's refund, and the sale's void completes when no claim of the sale is PENDING anymore, from whichever leg empties them (a booked refund or a no refund alike, under the same sale and claim locks), so the order the claims are resolved in never decides the end state, while a void whose every claim settles without a booked refund keeps the sale completable by a fresh void (a declined tender keeps its charge as unapplied credit owed); between one tender's booked refund and the last claim's resolution the sale stands completed with the booked cents owed (the refund reversed its application, so the invoice is open again for them and the customer's balance carries them) until the last claim resolves and the void completes, and the close refuses while a claim of the session is pending, so the interim never slips into a counted drawer; `no_refund` marks the claim FAILED and moves nothing else. Both legs write one `pos.refund_intent.resolved` audit row naming the person, the intent, the outcome and the reason, inside the leg's transaction, and a refused replay writes no orphan row. The claim row is locked inside the resolution and read right after the sale row it settles under, and every claim state change refuses 409 `intent_not_pending` when it settles no row, so of concurrent resolutions exactly one books and a same outcome loser is told the race, never a state refusal the winner's own completion caused. Migration 105 gains `pos_refund_intents.request` (a return claim's own request shape) and an index on `(state, created_at)` for the finder; the gateway's refund id is written onto the claim in its own committed write the moment the terminal answers | +| C2-5r4 | void, card return, every other refund method and till close: the intent table is the one authority (behaviour, no route change) | A card return whose claim came after a committed void asked the terminal for a second refund of the same charge; a cash or account return paid goods a pending card claim had already paid at the terminal; the card return's gateway amount could differ from the booked amount by the remainder; a card void parked at the terminal let the close commit and was then refused `session_closed` after the card was refunded; every gateway error, a timeout included, freed the tender | Every claim (void and card return) is refused under the sale row lock, before the terminal, when a void intent of the tender is DONE (409 `tender_refunded`), when another claim of the tender stands (409 `refund_in_flight`), when it passes the tender less the booked card refunds (409 `exceeds_card_tender`), or when the sale is no longer completed (409 `invalid_state_transition`). A return refunded by any method but the card's own is 409 `refund_in_flight` while any intent of the sale is PENDING, and 409 `void_refund_recorded` while the sale's void has a booked tender refund (its goods were paid back at the terminal); the till close is 409 `refund_in_flight` while any intent of the session's sales is PENDING, naming the claims and the resolve route, and the till report's `expected_by_method` CARD figure subtracts the card refunds booked against the session's still completed sales' card tenders (the void refunds resolutions book, written straight against the tender's payment; a return's refund goes through its credit memo and never joins), dropping with the tender when the void completes, the same net the CASH figure carries for the session's returns' cash refunds. The card return restates its remainders and totals inside the claim, so the terminal is asked for the figure the lock sees, and the money transaction refuses (`refund_amount_changed`, the intent staying PENDING beside the orphan row that records the gateway's figure) when its restated total differs from the amount the terminal took, so the books never record a cent less than the terminal moved. A gateway error with no verdict leaves the intent PENDING with a `pos.refund_outcome_unknown` audit row (the intent id, outcome unknown); only the terminal's explicit DECLINED releases the claim (a PENDING or ERROR answer stands the same way). The gateway interface carries no idempotency key parameter, so none is passed | +| C2-5r4 | returns: a partly legacy line completes (behaviour, no route change) | A line a legacy return partly brought back never counted as complete: the linked return that brought the rest took its rounded share, so the remainder and the tax left cents owed to the customer | The legacy quantity the product's bearer line carries counts in the line's returned quantity and in the test that every taxable line is finished, so the return that brings the rest takes the line's whole remaining money and the sale's whole remaining tax | +| C2-5r4 | migration 105: the down refuses over settled sales' contract era lines and standing claims, and the backfill pairs product lines only (operational, no route) | The down deleted the kit, component, charge and text lines of completed sales along with the rest, losing history the invoice and the returns read; it dropped the refund intents table with any PENDING rows, silently releasing their tenders; and the up's backfill paired sale lines and invoice lines of one product by position whatever their type, so a kit line took a product invoice line's link and the product line was left unlinked | The down refuses with an exception naming the count while any KIT, COMPONENT, CHARGE or TEXT line belongs to a completed or voided sale and changes nothing (the lines of a sale that is neither are still deleted), and refuses while any refund claim is still PENDING (dropping the table would release its tender); the backfill numbers and matches PRODUCT sale lines to PRODUCT invoice lines only, on both sides. Both are tests on a scratch database | +| C2-5r8 | inventory: `POST /api/v1/inventory/adjust`, `POST /api/v1/inventory/transfer` and the purchase order receive's stock write (behaviour, no route change) | A request whose context carried a branch (the multi branch setting on with the header, or a branch bound key) that named a location acted on the branch's row with the most quantity instead: a cycle count of one yard overwrote another yard's units, a receipt landed in the largest yard, and a transfer moved the wrong rows | A named location reads exactly that location's row, refused with 403 on the stock routes (`ErrLocationOutsideBranch` in process, so the receive path is held too) when the row sits in a branch the caller does not carry, never substituted; a first receipt at a yard of the caller's branch still creates its row; only a request naming no location lets the branch rule pick a row, and it takes the branch's first row by creation, a stated rule, never the largest quantity silently; an unbound request keeps the legacy row with no location. The purchase order receive writes through the same read and is pinned beside the routes | +| C2-5r8 | pos: `POST /api/v1/pos/refund-intents/{id}/resolve`, the till close, void and card return claim rules (behaviour, no route change) | A no refund resolution could settle a claim whose own act's terminal refund call was still on the wire, freeing the tender; the terminal then refunded it, a later card return refunded the same tender again, and the drawer could close over the open call | Every claim carries an in flight lease (migration 109's `answer_due_at`, the `refundAnswerLease` constant) and an answer record (`answered_at`, `answer_state`): the terminal call is bounded, every path it can take stamps the answer (the refund id's committed write, the decline release, the no verdict record, and an abandon stamp for a tender whose call a refusal never reached), and the resolve refuses 409 `refund_in_flight` on an unanswered claim inside its lease under the claim's row lock; once the lease expires with no answer at all the claim is marked UNKNOWN and settles as recorded. The terminal's refund id is written onto the claim whatever state it is in, a recorded refund that never booked holds the tender against every later act (409 `tender_refunded`), and the refund booked must be the terminal's own id (`refund_tx_id_conflict`), so a late answer behind an early release can never be paid twice; the close keeps refusing while any claim of the session stands, live or stranded | +| C2-5r8 | payments: `POST /api/v1/payments`, `POST /api/v1/payments/{id}/applications`, the credit memo apply, the write off and the deposit paths (behaviour at a seam the counter owns) | An application could land on the interim invoice of a split void (its open booked cents), leaving a live application the void's completion could not reverse: the last claim then answered 409 `has_applications` on every attempt, the drawer could not close, and an absorbed walk-in credit could no longer be paid back to its card | While the invoice's sale has a standing void claim, every application path is refused with 409 `void_claim_pending` naming the claims (the AR core asks the counter through a guard serve wires), so the void always completes and a credit that might have been absorbed stays unapplied on its own payment; the declined tender's credit limit the ADR stated then was round 7's fungibility argument, replaced by round 9's held credit rule (the C2-5r9 rows below) | +| C2-5r8 | till: the CARD figure of `expected_by_method` (behaviour, no route change) | The CARD figure subtracted only the card refunds booked against the tenders' payments (a void's resolutions); a card return's refund rides its credit memo, so the figure disagreed with the books by every card return of the session | The figure also subtracts the session's card return refunds through their credit memos, the same join the CASH figure always used for its cash returns | +| C2-5r8 | till: `GET /api/v1/pos/till/{id}/zreport`, `GET /api/v1/pos/zreports` | The counter's first cut onto the wire contract changed the Z report's `payload` from a JSON object to a base64 encoded string, unnamed in this file: any reader of the frozen report had to decode it before parsing it | The `payload` is a JSON object again (the fragment declares `PosTillReport`, as before the cut) and the characterization golden stops masking the field, so the frozen report's figures are pinned | +| C2-5r8 | migration 109 `refund_intent_in_flight_lease` (operational, no route; numbered 109 from round 9 so PR 83's 107 and 108 stay free and the two branches merge in either order) | `pos_refund_intents` carried no record of its own terminal call | Three columns: `answer_due_at` (the in flight lease, defaulted for existing rows so their lease expires from the migration on), `answered_at` (stamped by every path that ends the claim's call) and `answer_state` (`IN_FLIGHT`, `ANSWERED`, `UNKNOWN`). The down drops the three columns; the up writes no data beyond them and applies again after it | +| C2-5r9 | payments: `POST /api/v1/payments/{id}/applications`, a receipt's own applications and the deposit paths (behaviour at a seam the counter owns) | A declined void tender's credit sat as unapplied cash on the tender's payment, and the apply route answered 200 for it: on the shared walk-in customer a later walk-in invoice absorbed an earlier walk-in's declined card credit (the fourteenth review's Area 5 shape), and the books then owed the wrong card holder | FROM-PRINCIPAL 60a: the credit is held to its own tender. The AR core asks the counter through a held credit guard serve wires (the origin read from `pos_tenders.payment_id`, the voided sale and the claim the void released), and while the tender's payment still carries the credit unapplied, every path that applies a payment's unapplied cash (all through the AR core's `applyLocked`: the apply route, a receipt's own applications, the deposit paths) refuses 409 `tender_credit_held` naming the origin, the void's sale and its claim, and nothing is written. The refusal precedes the invoice's own hold, so a credit bound for a held interim invoice is refused by its payment first | +| C2-5r9 | pos: `POST /api/v1/pos/refund-intents/{id}/payback` (new route) | A declined void tender's credit had no way back that rode the rail the charge came from: the payment refund route's gateway is the online rail, and the counter's card rides the terminal rail serve wires no gateway for at all (the thirteenth review's P3-4), so the way back the ADR named asked the wrong rail in production | FROM-PRINCIPAL 60b: the credit's one way back, a small sibling of the resolve route on its own mount (the finance and owner roles only, a machine key refused in the route itself bound or not, the branch wall and the no context hold through the same sale read, audited). Keyed on the void's own released claim: only a FAILED void claim of a voided sale whose tender's payment still carries the credit unapplied is paid back (any other claim 409 `intent_not_failed` or 409 `no_held_credit`, a claim of a sale whose void has not completed 409 `no_held_credit`, an already paid back credit 409 `no_held_credit`), and every guard runs before the terminal is asked, so a refusal never moves money at the terminal. The terminal call runs under the claim discipline every terminal refund runs under: migration 110 widens `pos_refund_intents.act` to `payback`, the payback opens its own live claim before the call (a concurrent payback 409 `refund_in_flight` through the unique live (sale, tender) index, the in flight lease holding the resolve route and the drawer close while the call is on the wire), the terminal's refund id lands on the claim in its own committed write the moment it answers, and the money transaction books the credit out through the AR core's refund (`RefundPayment`: the credit's unapplied cash falls to zero, the cash account moves by the refund) completing the claim DONE with one `pos.refund_intent.paid_back` audit row in the resolve route's shape (the person, the void's claim, the outcome, the reason; the payback's own claim rides beside it). A terminal decline is 409 `card_refund` (nothing moved, the claim is released, the credit stays held and the counter may ask again); a call that answers no verdict leaves the payback's claim standing PENDING for the resolve route, whose refund leg books it under the terminal's own id and whose no refund leg releases it (the resolve fragment's `act` enum gains `payback`); a booking the books refuse after the terminal moved the money writes the orphan row the void's path writes. Exactly one payback per tender | +| C2-5r9 | payments: `POST /api/v1/payments/{id}/refunds` (behaviour at a seam the counter owns) | The online gateway's refund route paid a counter tender's held credit back through the wrong rail: the route's gateway is the online one, the credit's card is the counter terminal's | The route asks the counter's held credit guard before its gateway is ever asked and refuses a held credit 409 `tender_credit_held` naming the origin and the payback route; the credit stays payable at the counter, to the card that paid it | +| C2-5r9 | migration 110 `refund_intent_payback` (operational, no route) | `pos_refund_intents.act` allowed only `void` and `return`, so the payback's terminal call could carry no claim of its own | The act vocabulary gains `payback`: the payback's claims open beside the void's own released claim on the same tender (which stays FAILED, the void's own record). The down deletes the payback claims (this item's own, not valid old shape data; their refunds live on in `payment_refunds` and the audit log) and narrows the check back | +| C2-5r10 | pos: `POST /api/v1/pos/refund-intents/{id}/payback` (behaviour, no route change) and migration 111 `payback_one_per_tender` (operational, no route) | The payback's credit read and its claim insert were two unlocked steps: a second payback that had read the credit standing opened its claim beside the first's booking once the first's claim left PENDING, called the terminal a second time (the card was paid twice), and left a PENDING claim carrying the terminal's refund id that neither resolve outcome could settle, so the drawer never closed again | The payback's claim opens in a transaction that locks the sale row and the tender's payment row, in the money transaction's own order, and rechecks under that lock that the credit is still unapplied and no DONE payback claim stands on the tender (the loser answers 409 `no_held_credit` and never reaches the terminal, and no claim is left PENDING, so the drawer still closes); migration 111 makes exactly one payback per tender structural, a partial unique index on `pos_refund_intents (tender_id) WHERE act = 'payback' AND state IN ('PENDING', 'DONE')` (a FAILED payback claim, the terminal's decline, stays outside it so the counter may ask again). The index is idempotent up and down; the down drops only the index, the claim rows survive it, and the up applies again cleanly after it | +| C2-5r10 | pos: `POST /api/v1/pos/transactions/{id}/void`, `POST /api/v1/pos/refund-intents/{id}/resolve` (behaviour, no route change) | Every claim of a multi tender void carried one in flight lease stamped at its insert, but the void's terminal calls run one tender after another: a later tender's call started after the common lease had run out, the resolve route released the claim whose call was still on the wire, the terminal's late answer landed on the released claim, and the payback then paid the tender a second time | Each claim's lease is restamped in its own committed write immediately before its own terminal call, so a later tender's call never runs under a lease the earlier calls already spent and the resolve holds the claim while its call is live; a claim whose stamp fails is never called (it is abandoned exactly as the tenders after a refusal are, the refunds already taken recorded beside it). The return path makes a single terminal call (a card return refuses a multi card sale), so it needs no restamp | +| C2-5r10 | pos: `POST /api/v1/pos/refund-intents/{id}/payback`, payments: `POST /api/v1/payments/{id}/refunds` and the apply paths behind `POST /api/v1/payments/{id}/applications` (behaviour, no route change) | A settled void claim carrying the terminal's refund id (money that moved at the terminal and never booked, the shape a late answer behind an early release leaves) still counted as a held credit: the payback paid the tender a second time, and the online refund route and the apply paths answered the credit's own holds, never the refund that already moved | A settled claim that carries the terminal's refund id is money that moved: the payback refuses it 409 `tender_refunded` before the terminal is asked (its booking restates the refusal under the sale lock), and the held credit guard answers no held credit with the same refusal, so every act on the credit (an application, the online refund route) is refused 409 `tender_refunded` until the claim is reconciled | +| C2-5r10 | inventory: the service read behind `POST /api/v1/inventory/adjust`, `POST /api/v1/inventory/transfer` and the purchase order receive's stock write (behaviour at a service seam, no route change) | A named location that held no inventory row yet answered nil before the branch check ran, so a service caller could seed a first row on another branch's yard (the HTTP routes were already covered by the handler's location guard; the merge base had the same gap) | The missing row's branch is read from the location itself and a caller outside it is refused `ErrLocationOutsideBranch`, nothing created; a first receipt at a yard of the caller's branch still creates its row | | C5-1d | delivery: `GET /api/v1/delivery/vehicles` | A bare array (null in the bytes when the fleet was empty) ordered by name; every query parameter was silently ignored | The list envelope of ADR 0001 section 1, newest first on `(created_at, id)` with the opaque cursor; `include=total` counts; an unknown parameter is a 400 `unsupported_query_parameter`, `limit` outside 1 to 200 is a 400 naming it, a malformed cursor is a 400 naming `cursor`; errors are the one envelope with `meta.request_id` | | C5-1d | delivery: `POST /api/v1/delivery/vehicles` | Nothing was validated (an unparseable date was silently dropped, an unknown field ignored, `vehicle_type` free text); `vehicle_type` was the storage spelling; dates serialized as date-times; no revision, no `Location`, no `ETag`, no audit row, no event | `name`, `vehicle_type` (the lowercase vocabulary only: an uppercase value is a 400) and `license_plate` required; `insurance_expiry` and `next_service_date` are business dates as `YYYY-MM-DD`; every field problem is collected into one 400 `validation_failed`; 201 with `Location` and the `ETag`; the response carries `revision` and optional fields present as `null`; the audit row `vehicle.created` and the outbox event `vehicle.created` are written in the create's transaction, the event last | | C5-1d | delivery: `GET /api/v1/delivery/vehicles/{id}` | Any repository error answered 404 in a legacy `{error}` body; dates serialized as date-times | The vehicle with its `revision` and the `ETag`, business dates as `YYYY-MM-DD`; a missing or soft deleted vehicle is a 404 in the one error envelope; an unparseable id is a 400 naming `id`; a query parameter is a 400 | @@ -331,6 +384,10 @@ get an entry. | SEC-NOHEADER-1 | the tax exemption routes: `GET /tax/exemptions/{customerID}`, `POST /tax/exemptions`, `DELETE /tax/exemptions/{id}`, `POST /tax/preview` (its `customer_id` and the customer of its `ship_to_id`), for a finance user with or without `X-Branch-Id` and for a user with no grants | The routes mounted no branch middleware, so a finance user granted only A read, created and deleted the exemptions of B's customer whatever header was sent (200, 201, 204), moving tax on B's documents, and previewed tax for B's customer and ship to | The routes run behind the branch middleware and hold the customer they name to the customer family's wall (`customer_branches` against the context branch or the caller's grants): a read of a customer outside the reach and a delete of an exemption whose customer is outside it answer 404 with nothing deleted, and a create or a preview naming such a customer or ship to is refused 403 naming `customer_id` with nothing written. A user with no grants meets the same answers. Administrators and owners with no header, an unbound key and every caller while the single branch switch is off keep every customer; the bound key wall is unchanged. A caller that sends the header now meets these answers for another branch's customer where the header was never read before | | SEC-NOHEADER-1 | every route behind the JWT auth layer, for a user token that bears a role (`role` or `roles`) and carries no subject | The branch rule reads an empty subject as "no user" and takes the every branch arm, so such a token read and wrote every branch (a hardening gap: identity provider tokens carry `sub`, and this repository mints no user token) | The auth layer refuses a role bearing user token with an empty subject with a 401 (`invalid token`) before any role or branch check; a token with a subject, a token that bears no role, the portal's own tokens (a separate middleware, subject always the portal user) and machine keys are unchanged | | 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 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 | diff --git a/docs/refactor/ROUTE-CENSUS.md b/docs/refactor/ROUTE-CENSUS.md index 4002e5539..862367455 100644 --- a/docs/refactor/ROUTE-CENSUS.md +++ b/docs/refactor/ROUTE-CENSUS.md @@ -100,11 +100,11 @@ rejected for two reasons. ## The counts -345 distinct routes. By prefix: +346 distinct routes. By prefix: | Prefix | Routes | |---|---| -| `/api/v1` (excluding `/api/v1/a2a`) | 288 | +| `/api/v1` (excluding `/api/v1/a2a`) | 289 | | `/api/portal/v1` | 38 | | `/api/integration` | 10 | | `/api/v1/a2a` | 1 | @@ -112,16 +112,16 @@ rejected for two reasons. | `/health`, `/healthz/live`, `/healthz/ready` | 3 | | `/metrics` | 1 | | `/uploads/` (no method prefix, answers every method) | 1 | -| Total | 345 | +| Total | 346 | -By method: GET 164, POST 125, PUT 29, DELETE 21, PATCH 5, and the one +By method: GET 164, POST 126, PUT 29, DELETE 21, PATCH 5, and the one method-less `/uploads/` pattern. 40 packages register routes: 38 packages under `internal/`, plus `pkg/apps` (the Apps API platform surface) and the serve package, reported under its historical `cmd/server` label (health, metrics, uploads, the A2A receiver). The largest modules are pricing and portal with -34 routes each, then delivery 25 and pos 19. +34 routes each, then delivery 25 and pos 20. ### Conditional registrations diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts index 4b9879945..c84931786 100644 --- a/web/apps/desk/src/pages/pos/POSTerminal.ts +++ b/web/apps/desk/src/pages/pos/POSTerminal.ts @@ -3,36 +3,54 @@ 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, CounterCustomer } from '../../types/pos'; +import { chargeRefusal, standingOf, money } from './standing'; 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 sale's customer: the lookup, the account's standing and what a charge + // to the account carries (the purchase order and the job). + @state() private _customer: CounterCustomer | null = null; + @state() private _custQuery = ''; + @state() private _custResults: CounterCustomer[] = []; + @state() private _poText = ''; + @state() private _jobId = ''; + + // 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; @@ -48,11 +66,12 @@ export class POSTerminal extends LitElement { private _newTxTimer: ReturnType | null = null; private _errorTimer: ReturnType | null = null; private _searchDebounce: ReturnType | null = null; + private _custDebounce: ReturnType | null = null; connectedCallback() { super.connectedCallback(); void this._loadTill(); - this._startNewTransaction(); + this._startNewSale(); } /* ---- Till (drawer) lifecycle ---- */ @@ -66,20 +85,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 +111,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 +129,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; @@ -129,12 +148,16 @@ export class POSTerminal extends LitElement { if (this._newTxTimer) clearTimeout(this._newTxTimer); if (this._errorTimer) clearTimeout(this._errorTimer); if (this._searchDebounce) clearTimeout(this._searchDebounce); + if (this._custDebounce) clearTimeout(this._custDebounce); } updated(changed: Map) { if (changed.has('_searchQuery')) { this._debounceSearch(); } + if (changed.has('_custQuery')) { + this._debounceCustomers(); + } } /* ---- Barcode scanning ---- */ @@ -166,49 +189,189 @@ 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 ---- */ + /* ---- The sale's customer ---- */ + + private _debounceCustomers() { + if (this._custDebounce) clearTimeout(this._custDebounce); + if (this._custQuery.trim().length < 2) { + this._custResults = []; + return; + } + this._custDebounce = setTimeout(async () => { + try { + this._custResults = await posService.findCustomers(this._custQuery); + } catch { + this._custResults = []; + } + }, 200); + } + + /** Names the customer on the open sale; the lines then price for them. */ + private async _chooseCustomer(c: CounterCustomer) { + if (!this._sale) return; + try { + this._error = null; + this._sale = await posService.attachCustomer(this._sale.id, { customer_id: c.id }); + this._customer = c; + this._custQuery = ''; + this._custResults = []; + this._poText = ''; + this._jobId = ''; + } catch (err: unknown) { + this._error = err instanceof Error ? err.message : 'Failed to name the customer'; + } + } + + /** Back to a walk-in sale (only before the first line). */ + private async _clearCustomer() { + if (!this._sale) return; + try { + this._error = null; + this._sale = await posService.attachCustomer(this._sale.id, { customer_id: null }); + this._customer = null; + this._poText = ''; + this._jobId = ''; + } catch (err: unknown) { + this._error = err instanceof Error ? err.message : 'Failed to clear the customer'; + } + } + + /** Saves the purchase order and the job the charge will carry. */ + private async _saveAccountRefs() { + if (!this._sale || !this._customer) return; + try { + this._error = null; + this._sale = await posService.attachCustomer(this._sale.id, { + customer_id: this._customer.id, + customer_po: this._poText.trim() || null, + job_id: this._jobId || null, + }); + } catch (err: unknown) { + this._error = err instanceof Error ? err.message : 'Failed to save the purchase order'; + } + } + + /** Why the charge to the account is not offered, or null when it is. */ + private get _accountRefusal(): string | null { + // What is left to charge once the cash, check or card tenders are taken. + const owed = (this._sale?.total_cents ?? 0) - this._tenderedSoFar; + return chargeRefusal(this._customer, Math.max(owed, 0), this._poText); + } + + private _renderCustomerPanel() { + const sale = this._sale; + const hasLines = (sale?.lines.length ?? 0) > 0; + const c = this._customer; + const toneClass = (tone: string) => + tone === 'stop' ? 'text-safety-red' : tone === 'warn' ? 'text-amber-400' : 'text-gable-green'; + if (c) { + const st = standingOf(c); + return html` +
+
+
+
${c.name}
+
${c.account_number}
+
+ +
+
${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 _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._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); + 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 +381,65 @@ 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; + if (method === 'account') { + const refusal = this._accountRefusal; + if (refusal) { + this._error = refusal; + 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 - ${fmt(completed.total_cents)} \u00b7 CHANGE DUE ${fmt(change)}` + : `Sale ${completed.number} complete - ${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 +448,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 - 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 - ${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 +534,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 +546,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 - Till Closed

Over / Short
${osLabel}
- + ${Object.keys(r.session.expected_by_method || {}).map((m) => { @@ -339,7 +572,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 +581,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 - 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 - 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 +596,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 - ${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 +640,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 +657,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 +688,7 @@ export class POSTerminal extends LitElement { ${this._renderTillOpenModal()} ${this._renderTillCloseModal()} + ${this._renderReturnModal()} ${this._error ? html` @@ -468,7 +741,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 +749,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 +768,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) : '-'} - $${(item.line_total / 100).toFixed(2)} + ${item.line_total_cents !== null ? fmt(item.line_total_cents) : '-'} - - - + ${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 +889,19 @@ export class POSTerminal extends LitElement {
+
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 f60e7b7af..33ba5f950 100644 --- a/web/apps/desk/src/services/POSService.ts +++ b/web/apps/desk/src/services/POSService.ts @@ -1,133 +1,234 @@ // 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, + CounterCustomer, +} 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(); + }, + + // --- 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: { + 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; - }, - - 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'); - } + /** 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(); }, - 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 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(); }, - 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 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(); + }, + + 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..0e2a389b2 100644 --- a/web/apps/desk/src/types/pos.ts +++ b/web/apps/desk/src/types/pos.ts @@ -1,106 +1,205 @@ // 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[]; + /** The purchase order and job a charge sale names (null until named). */ + customer_po: string | null; + job_id: string | null; + lines: SaleLine[]; + tenders: Tender[]; } -// --- Till sessions (drawer lifecycle) --- +export type AccountStanding = 'good' | 'no_limit' | 'no_credit' | 'over_limit'; -export type TillSessionStatus = 'OPEN' | 'CLOSED'; +export interface CounterJob { + id: string; + name: string; +} -export interface TillSession { +/** 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; + revision: number; + branch_id: string; register_id: string; cashier_id: string; - status: TillSessionStatus; - opening_float: number; // cents - opened_at: string; - closed_at?: string; - expected_by_method?: Record; // cents - counted_by_method?: Record; // cents - over_short?: number; // cents; negative = short - notes: 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 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 +export interface SalePage { + items: SaleSummary[]; + next_cursor: string | null; + limit: number; + total?: number; } -export interface POSLineItem { +// --- Returns --- + +export interface ReturnLine { id: string; - transaction_id: string; - product_id: string; + position: number; + product_id: string | null; description: string; - quantity: number; - uom: string; - unit_price: number; // cents - line_total: number; // cents - created_at: string; + quantity: string | null; + uom: string | null; + unit_price_ten_thousandths: number | null; + line_total_cents: number | null; + restock: boolean; } -export interface POSTender { +export interface CounterReturn { id: string; - transaction_id: string; - method: string; - amount: number; // cents - reference?: string; - card_last4?: string; - card_brand?: 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 interface QuickSearchResult { - product_id: string; - sku: string; - description: string; - unit_price: number; - uom: string; - in_stock: number; -} +// --- Till sessions (drawer lifecycle) --- -export interface AddLineItemRequest { - product_id: string; - quantity: number; - uom: string; +export interface TillSession { + id: string; + register_id: string; + branch_id: string | null; + cashier_id: string; + status: TillSessionStatus; + opening_float_cents: number; + opened_at: string; + 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 AddTenderRequest { - method: string; - amount: number; - reference?: string; - token_id?: string; +export interface TillReport { + session: TillSession; + sale_count: number; + sales_total_cents: number; + tax_total_cents: number; + change_cents: number; + tendered_by_method: Record; + expected_by_method: Record; } -export interface TransactionSummary { - id: string; - register_id: string; - total: number; - status: TransactionStatus; - item_count: number; - completed_at?: string; - created_at: string; +// --- Typeahead and offline catalog --- + +export interface QuickSearchResult { + product_id: string; + sku: string; + description: string; + unit_price_cents: number; + uom: string; + in_stock: string; } diff --git a/web/e2e/pos.spec.ts b/web/e2e/pos.spec.ts new file mode 100644 index 000000000..58e2bf952 --- /dev/null +++ b/web/e2e/pos.spec.ts @@ -0,0 +1,206 @@ +// 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: { 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'); +} + +/** 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); +}); + +/** 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); +}); diff --git a/web/packages/api-client/src/schema.d.ts b/web/packages/api-client/src/schema.d.ts index 92b5b5627..67ef350e5 100644 --- a/web/packages/api-client/src/schema.d.ts +++ b/web/packages/api-client/src/schema.d.ts @@ -3676,7 +3676,7 @@ export interface paths { put?: never; /** * Record a payment - * @description Records a posted payment for a customer (cash, check, ach or other; a card is taken through POST /payments/card, and account is not a payment). The whole amount is posted to 2200 (DR 1010 / CR 2200). Without applications the payment is unapplied cash. With applications each invoice takes its cash (and an early pay discount, on or before the invoice's discount date, up to what its terms allow) in the same transaction, each application its own entry (DR 2200 / CR 1020) and subledger row. Refused with 409 and a blocker: exceeds_unapplied, exceeds_open_amount, invoice_void, customer_mismatch, currency_mismatch, discount_not_available, period_closed. Writes payment.recorded, then payment.applied and invoice.partial or invoice.paid per invoice, then customer.updated (part balance). + * @description Records a posted payment for a customer (cash, check, ach or other; a card is taken through POST /payments/card, and account is not a payment). The whole amount is posted to 2200 (DR 1010 / CR 2200). Without applications the payment is unapplied cash. With applications each invoice takes its cash (and an early pay discount, on or before the invoice's discount date, up to what its terms allow) in the same transaction, each application its own entry (DR 2200 / CR 1020) and subledger row. Refused with 409 and a blocker: exceeds_unapplied, exceeds_open_amount, invoice_void, customer_mismatch, currency_mismatch, discount_not_available, period_closed, and void_claim_pending when the invoice's sale has a void whose refund claims still stand (the counter's interim; the refusal names the claims). Writes payment.recorded, then payment.applied and invoice.partial or invoice.paid per invoice, then customer.updated (part balance). */ post: operations["paymentCreate"]; delete?: never; @@ -3713,7 +3713,7 @@ export interface paths { put?: never; /** * Apply a payment's unapplied cash to invoices - * @description Applies unapplied cash later, partly, across many invoices; each application posts its own DR 2200 / CR 1020 entry and a PAYMENT subledger row. The precondition is the payment's revision. Refused with 409 exceeds_unapplied, exceeds_open_amount, invoice_void, payment_voided, customer_mismatch, currency_mismatch, discount_not_available or period_closed. Writes payment.applied, invoice.partial or invoice.paid per invoice, and customer.updated. + * @description Applies unapplied cash later, partly, across many invoices; each application posts its own DR 2200 / CR 1020 entry and a PAYMENT subledger row. The precondition is the payment's revision. Refused with 409 exceeds_unapplied, exceeds_open_amount, invoice_void, payment_voided, customer_mismatch, currency_mismatch, discount_not_available, period_closed, or void_claim_pending when the invoice's sale has a void whose refund claims still stand (the counter's interim; the refusal names the claims). Writes payment.applied, invoice.partial or invoice.paid per invoice, and customer.updated. */ post: operations["paymentApply"]; delete?: never; @@ -4445,12 +4445,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; @@ -4466,7 +4469,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; @@ -4476,6 +4482,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/pos/customers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * The counter's customer lookup + * @description Customers matching the q text (name or account number, at most 10) that the caller's branch context or wall reaches, each with the account's standing the charge tender will read: the credit limit (null for none, 0 for no credit), the open receivable, the available credit and a standing word (good, no_limit, no_credit, over_limit), whether the account asks for a purchase order and its open jobs. A bare array, never null; q under two characters answers []. + */ + get: operations["posCustomerFind"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/pos/transactions/{id}/customer": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Name the sale's customer, purchase order and job + * @description Names who the sale is for. The customer is named before the first line (the lines price for the customer: 409 invalid_state_transition once a line exists and the customer would change); the purchase order and the job may change while the sale is open. A null customer_id makes the sale a walk-in sale again. The job must be an open job of the customer. The sale's revision moves; the response is the whole sale. A sale or customer that does not exist, or that the caller's branch context or wall hides, answers 404, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. + */ + put: operations["posTransactionCustomer"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/pos/transactions/{id}/items": { parameters: { query?: never; @@ -4486,8 +4532,8 @@ export interface paths { get?: never; put?: never; /** - * Add a line item - * @description A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both; nothing is written. A caller with no branch in context who holds no grant for the transaction's branch is refused 403 by name. + * 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. A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. */ post: operations["posTransactionAddItem"]; delete?: never; @@ -4507,8 +4553,8 @@ export interface paths { put?: never; post?: never; /** - * Remove a line item - * @description A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both, and no line is removed. + * 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. A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. */ delete: operations["posTransactionRemoveItem"]; options?: never; @@ -4526,8 +4572,8 @@ export interface paths { get?: never; put?: never; /** - * Complete a transaction with tenders - * @description An empty tenders list answers 400. A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both. Every other 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). A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. */ post: operations["posTransactionComplete"]; delete?: never; @@ -4546,8 +4592,8 @@ export interface paths { get?: never; put?: never; /** - * Void a transaction - * @description A transaction that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both; nothing is written. + * 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). A sale that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the sale's branch is refused 403 by name. */ post: operations["posTransactionVoid"]; delete?: never; @@ -4563,7 +4609,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; @@ -4583,10 +4632,10 @@ export interface paths { get?: never; put?: never; /** - * Replay offline transactions - * @description A missing batch_id, an empty items list, or a register_id that is empty or names no register answers 400 for every caller. + * 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. The batch register is read before any item runs and before the sync log row is written: a register_id that is empty or names no register the counter knows is a 400 for every caller, and a register of another branch is the branch wall's 403. 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; @@ -4600,8 +4649,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; @@ -4620,8 +4672,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; @@ -4637,7 +4689,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; @@ -4655,8 +4710,8 @@ export interface paths { cookie?: never; }; /** - * Live X report of a session - * @description An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read. + * 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. An unknown session, or a session the caller's branch context hides, answers 404 like the Z report read. */ get: operations["posTillReport"]; put?: never; @@ -4677,8 +4732,8 @@ export interface paths { get?: never; put?: never; /** - * Close a till session - * @description Every service failure, such as a session not OPEN, answers 409; a session that does not exist, or sits outside the caller's branch context or wall, answers 404, the same for both; nothing is written. A caller with no branch in context who holds no grant for the session's branch is refused 403 by name. + * 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. A session that does not exist, or that the caller's branch context or wall hides, answers 404, the same for both, and nothing is written; a caller with no branch in context who holds no grant for the session's branch is refused 403 by name. */ post: operations["posTillClose"]; delete?: never; @@ -4694,7 +4749,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, and 404 for a session the caller's branch context hides, the read held to the caller's branch like the X report. + */ get: operations["posTillZReport"]; put?: never; post?: never; @@ -4711,7 +4769,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; @@ -4728,12 +4789,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; @@ -4749,7 +4813,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; @@ -4759,6 +4826,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/pos/refund-intents/{id}/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resolve a stranded card refund claim + * @description The guarded operator route for a refund claim that outlived its act (a lost terminal verdict, a crashed process, a transaction that refused after the terminal answered): finance reads the terminal and records its actual outcome. outcome refund carries the gateway's own refund id (the money moved) and books it through the refund's own money transaction with every cap and lock, completing the claim DONE; outcome no_refund carries the reconciliation reference finance filed, marks the claim FAILED and moves nothing. A payback claim (a payback whose terminal call answered no verdict) settles the same way: a recorded refund books the credit out to its card, a no refund releases the claim and the credit stays held at the counter. Both write one audit row naming the person, the intent, the outcome and the reason. A claim whose own terminal call has not answered is live, not stranded: while its in flight lease runs the resolve answers 409 refund_in_flight, and once the lease expires with no answer at all the claim is marked UNKNOWN and settles as recorded. Only the finance and owner roles; a machine key is refused in the route itself, bound or not. A bare resolve, a refund without its refund id and a no refund without its reference are 400s naming the field; a claim that is not PENDING is 409 intent_not_pending, a no refund over a claim carrying the terminal's refund id and a refund id that differs from it are 409s naming the conflict, and the claim's sale is read through the caller's branch context, so a caller outside the branch finds a missing row (404). In a configured deployment the actor is the JWT's subject; with authentication off (dev mode) the route runs with no subject and the audit row names nobody, which no configured deployment allows. + */ + post: operations["posRefundIntentResolve"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/pos/refund-intents/{id}/payback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pay a declined void tender's credit back to its card + * @description The one way back for a declined void tender's credit (FROM-PRINCIPAL 60): a tender whose refund the terminal declined keeps its charge as unapplied credit once the void completes, held to its own tender (it applies to no invoice, 409 tender_credit_held), and this route pays it back to that card through the counter terminal rail, the rail the charge came from. The route keys on the void's own released claim (the id the resolve route would key on): only a FAILED void claim of a voided sale whose tender's payment still carries the credit unapplied is paid back, exactly once. Its terminal call runs under the claim discipline every terminal refund runs under: one live claim per tender and the in flight lease, so a concurrent payback is 409 refund_in_flight and a hung call holds the drawer until it is resolved. Only the finance and owner roles; a machine key is refused in the route itself, bound or not. The claim's sale is read through the caller's branch context, so a caller outside the branch finds a missing row (404), header or not. A claim that is not FAILED (409 intent_not_failed), a claim of a sale whose void has not completed and a tender whose credit was already paid back (409 no_held_credit) are refused before the terminal is reached, so a refusal never moves money at the terminal; a terminal decline is 409 card_refund (nothing moved, the credit stays held and the counter may ask again), and a call that answers no verdict leaves the payback's own claim standing for the resolve route. The booking writes one pos.refund_intent.paid_back audit row naming the person, the void's claim, the outcome and the reason, the credit's unapplied cash falls to zero and the cash account moves by the refund. The payment refund route through the online gateway is never the way back for such a credit (409 tender_credit_held). + */ + post: operations["posRefundIntentPayback"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/pricing/calculate": { parameters: { query?: never; @@ -11459,405 +11566,610 @@ 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: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - transaction_id: string; - /** Format: uuid */ - product_id: string; - description: string; - quantity: number; - uom: string; + 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: int64 - * @description Cents. + * Format: uuid + * @description Defaults the JWT's subject. */ - unit_price: number; + cashier_id?: string | null; /** - * Format: int64 - * @description Cents. + * Format: uuid + * @description Absent means the walk-in customer. */ - line_total: number; - /** Format: date-time */ - created_at: string; + customer_id?: string | null; }; - /** @description POSTender. The reference and card fields are omitted when empty. */ - PosTender: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - transaction_id: string; - /** @description CASH, CARD, CHECK or ACCOUNT. */ - method: string; - /** - * Format: int64 - * @description Cents. - */ - amount: number; - reference?: string; - card_last4?: string; - card_brand?: string; - gateway_tx_id?: string; - auth_code?: string; - /** Format: date-time */ - created_at: string; + PosAddLineRequest: { + line: components["schemas"]["PosLineRequest"]; }; - /** @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. */ - PosTransaction: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - branch_id: string; - register_id: string; - /** Format: uuid */ - cashier_id: string; - /** Format: uuid */ - customer_id?: string; + PosCompleteRequest: { + tenders: components["schemas"]["PosTenderRequest"][]; + /** @description Who collected the basket, at most 200 characters. */ + picked_up_by?: string | null; /** * Format: int64 - * @description Cents. - */ - subtotal: number; - /** - * Format: int64 - * @description Cents. + * @description The sale's revision; or send If-Match. */ - tax_amount: number; + 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 Cents. + * @description The sale's revision; or send If-Match. */ - total: number; + revision?: number; + }; + PosResolveIntentRequest: { /** - * Format: int64 - * @description Cents, set at completion. + * @description The terminal's actual outcome: refund (the gateway's own refund id, the money moved) or no_refund (an explicit no at the terminal). + * @enum {string} */ - change_due: number; - /** Format: uuid */ - till_session_id?: string; - status: components["schemas"]["PosTransactionStatus"]; - /** Format: date-time */ - completed_at?: string; - /** 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"][]; + outcome: "refund" | "no_refund"; + /** @description Required when the outcome is refund: the gateway's own refund id for the claim. */ + refund_tx_id?: string; + /** @description Required when the outcome is no_refund: the reference finance filed for the reconciliation. */ + reconciliation_reference?: string; + /** @description Why the record was made */ + reason: string; }; - /** @description TransactionSummary. */ - PosTransactionSummary: { + PosRefundIntentResolved: { /** Format: uuid */ id: string; - register_id: string; - /** - * Format: int64 - * @description Cents. - */ - total: number; - status: components["schemas"]["PosTransactionStatus"]; - item_count: number; - /** Format: date-time */ - completed_at?: string; - /** Format: date-time */ - created_at: string; - }; - /** @description QuickSearchResult. */ - PosSearchResult: { /** Format: uuid */ - product_id: string; - sku: string; - description: string; - /** @description Float dollars. */ - unit_price: number; - uom: string; - in_stock: number; - }; - /** @description CatalogProduct. */ - PosCatalogProduct: { + sale_id: string; /** Format: uuid */ - product_id: string; - sku: string; - description: string; - /** @description Float dollars. */ - price: number; - uom: string; - in_stock: number; + tender_id: string; + /** @enum {string} */ + act: "void" | "return" | "payback"; + /** + * @description DONE when a recorded refund booked + * @enum {string} + */ + state: "DONE" | "FAILED"; + /** @enum {string} */ + outcome: "refund" | "no_refund"; + /** @description The gateway refund id the operator recorded. */ + refund_tx_id?: string | null; + /** @description The reference the operator filed. */ + reconciliation_reference?: string | null; + }; + PosPaybackIntentRequest: { + /** @description Why the credit went back to its card */ + reason: string; }; - /** @description TillSession. Everything set at close, and the branch, is omitted when empty. */ - PosTillSession: { - /** Format: uuid */ + PosRefundIntentPaidBack: { + /** + * Format: uuid + * @description The void's own released claim the route keyed on. + */ id: string; - register_id: string; + /** + * Format: uuid + * @description The payback's own claim + */ + payback_id: string; /** Format: uuid */ - branch_id?: string; + sale_id: string; /** Format: uuid */ - cashier_id: string; - /** @enum {string} */ - status: "OPEN" | "CLOSED"; + tender_id: string; /** * Format: int64 - * @description Cents. + * @description The credit paid back */ - opening_float: 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?: { - [key: string]: number; - }; + amount_cents: number; + /** @description The terminal's own refund id for the payback. */ + refund_tx_id: string; + /** @enum {string} */ + state: "DONE"; + }; + PosReturnRequest: { + /** @description Defaults REG-01. */ + register_id?: string; /** - * Format: int64 - * @description Cents; negative means short. + * Format: uuid + * @description The sale the goods came from; absent is a no-receipt return. */ - over_short?: number; + original_sale_id?: string | null; /** Format: uuid */ - gl_entry_id?: string; - notes: string; - }; - /** @description The current session wrapper. */ - PosTillCurrent: { - session: components["schemas"]["PosTillSession"] | null; + customer_id?: string | null; + refund_method?: components["schemas"]["PosRefundMethod"]; + reason: string; + /** @description Refused on a card return: the refund goes to the sale's own card tender, never a transaction the client names. */ + gateway_tx_id?: string | null; + lines: components["schemas"]["PosReturnLineRequest"][]; }; - /** @description TillReport. */ - PosTillReport: { - session: components["schemas"]["PosTillSession"]; - sale_count: number; + PosReturnLineRequest: { /** - * Format: int64 - * @description Cents. + * Format: uuid + * @description Required when the line names no sale line. */ - sales_total: number; + product_id?: string | null; /** - * Format: int64 - * @description Cents. + * Format: uuid + * @description The sale line returned; its price and cost follow. Required on every line when original_sale_id is named, and named at most once: a request naming one sale line twice is refused naming the second. */ - tax_total: number; + line_id?: string | null; + description?: string | null; + /** @description A positive decimal string with at most 4 fraction digits. */ + quantity: string; /** * Format: int64 - * @description Cents. + * @description Required when the line names no sale line. */ - change_given: number; - /** @description Cents keyed by tender method. */ - tendered_by_method: { - [key: string]: number; - }; - /** @description Cents keyed by tender method. */ - expected_by_method: { + 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; }; - /** @description ZReport. */ - PosZReport: { + 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 */ + cashier_id?: string | null; + /** Format: uuid */ + customer_id?: string | null; + items: components["schemas"]["PosLineRequest"][]; + tenders: components["schemas"]["PosTenderRequest"][]; + /** + * Format: date-time + * @description When the register made the sale offline. + */ + 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 Defaults the product's price. + */ + 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 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 */ - till_session_id: string; + branch_id: string; register_id: string; /** Format: uuid */ - branch_id?: string; + cashier_id: string; + /** Format: uuid */ + 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. */ - over_short: number; - /** @description The frozen closing report, a JSON object. */ - payload: components["schemas"]["PosTillReport"]; + invoice_id: string | null; /** Format: date-time */ - generated_at: string; - }; - PosZReportList: { - z_reports: components["schemas"]["PosZReport"][] | null; + completed_at: string | null; + /** Format: date-time */ + created_at: string; + item_count: number; }; - /** @description POSReturnLine. */ - PosReturnLine: { + /** @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 */ - return_id: string; + branch_id: string; + register_id: string; /** Format: uuid */ - product_id: string; - description: string; - quantity: number; - uom: string; + cashier_id: string; + /** Format: uuid */ + 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. */ - unit_price: number; + change_cents: number; + /** Format: uuid */ + till_session_id: string | null; + status: components["schemas"]["PosStatus"]; /** - * Format: int64 - * @description Cents. + * Format: uuid + * @description The invoice the completion posted. */ - line_total: number; - restock: boolean; + invoice_id: string | null; + /** Format: date-time */ + completed_at: string | null; + /** Format: date-time */ + created_at: string; + /** @description The purchase order a charge sale names. */ + customer_po: string | null; + /** + * Format: uuid + * @description The customer's job the sale belongs to. + */ + job_id: string | null; + lines: components["schemas"]["PosLineItem"][]; + tenders: components["schemas"]["PosTender"][]; }; - /** @description POSReturn. The till session, original transaction, customer, branch, ledger entry and lines are omitted when empty. */ - PosReturn: { + PosCounterJob: { /** Format: uuid */ id: string; - register_id: string; + name: string; + }; + PosCounterCustomer: { /** Format: uuid */ - till_session_id?: string; + id: string; + account_number: string; + name: string; + /** @description The account asks for a purchase order on a charge sale (409 po_required at completion without one). */ + po_required: boolean; + /** + * Format: int64 + * @description Null: no limit. 0: no credit. + */ + credit_limit_cents: number | null; + /** + * Format: int64 + * @description What the credit check reads: open invoices less open credit memos and unapplied cash. + */ + open_receivable_cents: number; + /** + * Format: int64 + * @description The limit less the open receivable; negative when over; null with no limit. + */ + available_credit_cents: number | null; + /** @enum {string} */ + standing: "good" | "no_limit" | "no_credit" | "over_limit"; + jobs: components["schemas"]["PosCounterJob"][]; + }; + PosSaleCustomerRequest: { /** Format: uuid */ - original_transaction_id?: string; + customer_id?: string | null; + customer_po?: string | null; /** Format: uuid */ - customer_id?: string; + job_id?: string | null; + }; + 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 */ - branch_id?: string; + 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 */ - cashier_id: string; + 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. */ - subtotal: number; + unit_price_ten_thousandths: number | null; /** * Format: int64 - * @description Cents. + * @description The price before an override or discount moved it. */ - tax_amount: 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. + * @description The extension, rounded once; null on a text line. */ - total: number; - /** @description CASH, CARD or ACCOUNT. */ - refund_method: string; - reason: string; - status: string; + 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 */ - gl_entry_id?: string; + vendor_id: string | null; + /** Format: int64 */ + special_order_unit_cost_ten_thousandths: number | null; /** Format: date-time */ created_at: string; - lines?: components["schemas"]["PosReturnLine"][]; - }; - PosReturnList: { - returns: components["schemas"]["PosReturn"][] | null; }; - /** @description SyncError. */ - PosSyncError: { - client_id: string; - reason: string; - }; - /** @description OfflineSyncResponse. errors is omitted when empty. */ - PosSyncResponse: { - batch_id: string; - synced_count: number; - duplicate_count: number; - error_count: number; - errors?: components["schemas"]["PosSyncError"][]; - }; - PosTransactionCreate: { - /** @description Defaults to REG-01. */ - register_id?: string; + /** @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 */ - cashier_id?: string; + id: string; /** Format: uuid */ - customer_id?: string; + sale_id: string; + method: components["schemas"]["PosTenderMethod"]; + /** + * Format: int64 + * @description Net of change. + */ + 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; }; - PosAddItem: { + /** @description One counter return: a credit memo created, posted, refunded and optionally restocked in one act. */ + PosReturn: { /** 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 OfflineTransaction. */ - PosOfflineTransaction: { + id: string; + /** @description RTN-000001, human readable. */ + number: string; + /** Format: int64 */ + revision: number; /** Format: uuid */ - client_id: string; + branch_id: string | null; register_id: string; /** Format: uuid */ - cashier_id: string; + till_session_id: string | null; /** Format: uuid */ - customer_id?: string; - items: components["schemas"]["PosAddItem"][]; - tenders: components["schemas"]["PosTenderInput"][]; + 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 */ - client_created_at: string; - }; - /** @description OfflineSyncRequest. */ - PosSyncRequest: { - batch_id: string; - register_id?: string; - items: components["schemas"]["PosOfflineTransaction"][]; + created_at: string; + lines: components["schemas"]["PosReturnLine"][]; }; - PosTillOpen: { - /** @description Defaults to REG-01. */ - register_id?: string; - /** @description Float dollars; converted to cents on the server. */ - opening_float: number; + /** @description One returned line, its quantity and extension negative. */ + PosReturnLine: { + /** Format: uuid */ + id: string; + position: number; + /** Format: uuid */ + product_id: string | null; + description: string; + /** @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; }; - PosTillClose: { - /** @description Float dollars keyed by tender 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. */ + PosTillSession: { + /** Format: uuid */ + id: string; + register_id: string; + /** Format: uuid */ + branch_id: string | null; + /** Format: uuid */ + cashier_id: string; + status: components["schemas"]["PosTillStatus"]; + /** Format: int64 */ + opening_float_cents: number; + /** Format: date-time */ + opened_at: string; + /** Format: date-time */ + closed_at: string | null; + /** @description The expected cents per lowercase method; present once the session has sales. */ + expected_by_method: { + [key: string]: number; + }; counted_by_method: { [key: string]: number; }; - notes?: string; + /** + * Format: int64 + * @description Set by the close. + */ + over_short_cents: number | null; + /** + * Format: uuid + * @description The 5030 over/short entry the close booked. + */ + gl_entry_id: string | null; + notes: string; }; - /** @description ReturnLineRequest. */ - PosReturnLineRequest: { + 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; + }; + expected_by_method: { + [key: string]: number; + }; + }; + /** @description The immutable end-of-day snapshot written once when the session closes. */ + PosZReport: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + till_session_id: string; + register_id: string; + /** Format: uuid */ + branch_id: string | null; + /** Format: int64 */ + over_short_cents: number; + /** @description The frozen closing report, a JSON object. */ + payload: components["schemas"]["PosTillReport"]; + /** Format: date-time */ + generated_at: string; + }; + PosSearchResult: { /** 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; + sku: string; + description: string; + /** Format: int64 */ + unit_price_cents: number; + uom: string; + /** @description A decimal string of the on hand quantity. */ + in_stock: string; + }; + PosCatalogProduct: { + /** Format: uuid */ + 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; + }; + PosSyncResponse: { + batch_id: string; + synced_count: number; + /** @description Sales already synced, by client_id. */ + duplicate_count: number; + error_count: number; + /** @description Sales waiting on the tax provider; the sync retries them. */ + pending_count: number; + errors: components["schemas"]["PosSyncItemResult"][]; + pending: components["schemas"]["PosSyncItemResult"][]; }; - /** @description ReturnRequest. */ - PosReturnRequest: { - /** @description Defaults to REG-01. */ - register_id?: string; + PosSyncItemResult: { /** 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"][]; + client_id: string; + reason: string; }; /** * @description Where a price came from, lowercase (ADR 0006 section 7.3). @@ -23483,10 +23795,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. */ @@ -23497,15 +23814,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"]; @@ -23525,13 +23843,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: { @@ -23540,48 +23862,109 @@ 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: { + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + 500: components["responses"]["InternalError"]; + }; + }; + posTransactionGet: { + parameters: { + query?: never; + 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. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The sale. */ + 200: { headers: { + /** @description The document's revision. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["PosCashierRefusal"]; + "application/json": components["schemas"]["PosTransaction"]; }; }; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; 500: components["responses"]["InternalError"]; }; }; - posTransactionGet: { + posCustomerFind: { + parameters: { + query?: { + q?: string; + }; + 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. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The matching customers. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PosCounterCustomer"][]; + }; + }; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 500: components["responses"]["InternalError"]; + }; + }; + posTransactionCustomer: { parameters: { query?: never; 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. */ "X-Branch-Id"?: components["parameters"]["XBranchId"]; + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; }; path: { id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["PosSaleCustomerRequest"]; + }; + }; responses: { - /** @description The transaction. */ + /** @description The sale with its customer named. */ 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"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 500: components["responses"]["InternalError"]; }; }; posTransactionAddItem: { @@ -23600,13 +23983,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: { @@ -23616,10 +24001,8 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; 500: components["responses"]["InternalError"]; }; }; @@ -23638,19 +24021,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"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; 500: components["responses"]["InternalError"]; }; }; @@ -23670,13 +24056,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: { @@ -23686,18 +24074,11 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; - 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: { @@ -23714,11 +24095,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: { @@ -23728,18 +24115,17 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; - 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. */ @@ -23750,7 +24136,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Up to 20 matches; a bare array. */ + /** @description The matching products. */ 200: { headers: { [name: string]: unknown; @@ -23759,12 +24145,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?: { @@ -23782,7 +24169,7 @@ export interface operations { }; }; responses: { - /** @description The batch result. */ + /** @description The batch's outcome. */ 200: { headers: { [name: string]: unknown; @@ -23794,13 +24181,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?: { @@ -23812,7 +24196,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The whole catalog as a bare array. */ + /** @description The catalog. */ 200: { headers: { [name: string]: unknown; @@ -23821,6 +24205,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"]; @@ -23840,13 +24225,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: { @@ -23855,25 +24242,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?: { @@ -23885,17 +24262,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"]; }; }; @@ -23922,10 +24301,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"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; 500: components["responses"]["InternalError"]; }; }; @@ -23945,11 +24324,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; @@ -23961,10 +24340,9 @@ export interface operations { 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; 409: components["responses"]["ConflictEither"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 500: components["responses"]["InternalError"]; }; }; posTillZReport: { @@ -23981,7 +24359,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The frozen Z snapshot. */ + /** @description The Z report. */ 200: { headers: { [name: string]: unknown; @@ -23990,17 +24368,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?: { @@ -24012,15 +24390,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"]; @@ -24030,7 +24409,6 @@ export interface operations { parameters: { query?: { register_id?: string; - /** @description An unparseable date is ignored. */ date?: string; }; header?: { @@ -24042,15 +24420,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"]; @@ -24074,9 +24453,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: { @@ -24085,48 +24466,114 @@ 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: { + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 500: components["responses"]["InternalError"]; + }; + }; + posReturnGet: { + parameters: { + query?: never; + 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. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The return. */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Error"] | components["schemas"]["PosCashierRefusal"]; + "application/json": components["schemas"]["PosReturn"]; }; }; - 409: components["responses"]["IdempotencyConflict"]; - 413: components["responses"]["PayloadTooLarge"]; - 422: components["responses"]["UnprocessableEntity"]; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; 500: components["responses"]["InternalError"]; }; }; - posReturnGet: { + posRefundIntentResolve: { parameters: { query?: never; 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. */ "X-Branch-Id"?: components["parameters"]["XBranchId"]; + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; }; path: { id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["PosResolveIntentRequest"]; + }; + }; responses: { - /** @description The return. */ + /** @description The settled claim. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PosReturn"]; + "application/json": components["schemas"]["PosRefundIntentResolved"]; }; }; - 400: components["responses"]["BadRequest"]; + 400: components["responses"]["BadRequestEither"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["ForbiddenEither"]; - 404: components["responses"]["NotFound"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 500: components["responses"]["InternalError"]; + }; + }; + posRefundIntentPayback: { + parameters: { + query?: never; + 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. */ + "X-Branch-Id"?: components["parameters"]["XBranchId"]; + /** @description Opt in idempotent replay for POST, PUT and PATCH. The legacy name X-Idempotency-Key addresses the same claim. Claims are stored in Postgres and survive a restart; only 2xx and 3xx responses are stored. */ + "Idempotency-Key"?: components["parameters"]["IdempotencyKey"]; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PosPaybackIntentRequest"]; + }; + }; + responses: { + /** @description The credit paid back to its card. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PosRefundIntentPaidBack"]; + }; + }; + 400: components["responses"]["BadRequestEither"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ForbiddenEither"]; + 404: components["responses"]["WireNotFound"]; + 409: components["responses"]["ConflictEither"]; + 500: components["responses"]["InternalError"]; }; }; pricingCalculate: {