diff --git a/core/api/ROUTES.txt b/core/api/ROUTES.txt index efb019cf..333e44ec 100644 --- a/core/api/ROUTES.txt +++ b/core/api/ROUTES.txt @@ -93,13 +93,13 @@ GET /api/v1/admin/staff/{id} internal/staff h.GetStaff PUT /api/v1/admin/staff/{id} internal/staff h.UpdateStaff POST /api/v1/admin/staff/{id}/modules internal/staff h.GrantModule DELETE /api/v1/admin/staff/{id}/modules/{module_id} internal/staff h.RevokeModule -GET /api/v1/ap/aging internal/ap h.GetAgingSummary -GET /api/v1/ap/invoices internal/ap h.ListVendorInvoices -POST /api/v1/ap/invoices internal/ap h.CreateVendorInvoice -GET /api/v1/ap/invoices/{id} internal/ap h.GetVendorInvoice -POST /api/v1/ap/invoices/{id}/approve internal/ap h.ApproveInvoice -GET /api/v1/ap/payments internal/ap h.ListPayments -POST /api/v1/ap/payments internal/ap h.PayVendor +GET /api/v1/ap/aging internal/ap h.HandleGetAgingSummary +GET /api/v1/ap/invoices internal/ap h.HandleList +POST /api/v1/ap/invoices internal/ap h.HandleCreate +GET /api/v1/ap/invoices/{id} internal/ap h.HandleGet +POST /api/v1/ap/invoices/{id}/transitions internal/ap h.HandleTransition +GET /api/v1/ap/payments internal/ap h.HandleListPayments +POST /api/v1/ap/payments internal/ap h.HandlePayVendor GET /api/v1/apps pkg/apps h.handleList POST /api/v1/apps/{key}/disable pkg/apps h.handleDisable POST /api/v1/apps/{key}/enable pkg/apps h.handleEnable diff --git a/core/api/fragments/ap.yaml b/core/api/fragments/ap.yaml index 5ac44d69..8b1db8e1 100644 --- a/core/api/fragments/ap.yaml +++ b/core/api/fragments/ap.yaml @@ -1,19 +1,17 @@ # SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 # SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors # -# The accounts payable module, transcribed from internal/ap/handler.go, -# model.go, service.go and repository.go as they behave today. Quirks the -# transcription preserves: response money is int64 cents but the create -# requests take float dollars (tax_amount, unit_price, amount) and convert on -# the way in; the lists are bare JSON arrays (the handlers coerce nil to []), -# with no limit or offset (a ?limit= is silently ignored) and unparseable -# vendor_id filters silently dropped; service validation failures (bad dates, -# unknown invoice) surface as 500, while approve maps every failure to 422 and -# an unauthenticated approve is a 401 raised by the handler itself; create -# echoes omit vendor_name and lines, which only the reads carry. Every error -# body is the standard envelope. Every route sits behind -# RequireRole("admin","owner","finance"), registered in -# internal/app/serve/serve.go. +# The accounts payable module. The vendor invoice routes are on the wire +# contract (item C4-1b, ADR 0008 sections 7.4 and 11, on ADR 0001): Gable's +# own number (AP-, gapped sequence) as number, the vendor's as +# vendor_invoice_number, money in _cents, the scaled unit price, the cursor +# envelope, the error envelope, the revision precondition and a transitions +# route that replaced /approve. The pay request's money is _cents too (the +# rest of the payment and aging routes keeps its today shape until their own +# conversion, ADR 0008 section 11), with the payment act's new refusals. +# Every route sits behind scoped("admin","owner","finance") with the branch +# middleware, registered in internal/app/serve/serve.go; the bills carry a +# branch, so every read and write is held to the caller's wall. paths: /api/v1/ap/invoices: get: @@ -21,86 +19,115 @@ paths: tags: [ap] summary: List vendor invoices description: >- - Role guard admin, owner or finance. Optional filters vendor_id (an - unparseable value is ignored, not refused) and status (free text, - compared as given). No paging parameters exist; limit and offset are - silently ignored. Never null. Lines are not included. + Role guard admin, owner or finance, behind the branch middleware: a + bound caller sees its branch's bills only. The cursor envelope, + newest first by (created_at, id). vendor_id and po_id filter on their + document and status on the lowercase lifecycle vocabulary (a comma + separated list); a parameter the route does not declare, a status + outside the vocabulary or an uppercase one, a malformed cursor or an + out of range limit is a 400. total appears only under include=total. parameters: - name: vendor_id in: query required: false + description: Filter on the vendor. + schema: + type: string + format: uuid + - name: po_id + in: query + required: false + description: Filter on the purchase order. schema: type: string format: uuid - name: status in: query required: false - description: Invoice status filter, for example PENDING, APPROVED, PARTIAL, PAID or VOIDED. + description: Comma separated lowercase statuses (pending, approved, partial, paid, voided). schema: type: string + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The vendor invoices, ordered by due_date ascending (earliest due first). + '200': + description: The page of vendor invoice summaries. content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/ApVendorInvoice" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApInvoicePage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apInvoiceCreate tags: [ap] summary: Enter a vendor invoice description: >- - Role guard admin, owner or finance. The request carries float - dollars; subtotal, tax and line amounts are converted to int64 cents. - The invoice starts PENDING with nothing paid. Only an undecodable - body is a 400; a malformed invoice_date or due_date or any database - refusal (unknown vendor, duplicate number) is a 500. The echo omits - vendor_name and lines. + Role guard admin, owner or finance. The bill starts pending, owing + its whole total, with Gable's own number. Money is _cents, the line + unit price is _ten_thousandths and the quantity a decimal string; the + server prices each line and never accepts a status. Every line names + its gl_account_id (the approve entry debits each line's account). The + branch defaults from the body branch, the caller's context branch or + the default branch, and is held to the caller's wall (403 naming + branch_id). Validation failures are one 400 naming every field with + its full path; a duplicate vendor number for the vendor is a 409; an + unknown vendor, purchase order or account is a 400 naming the field. parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/XBranchId' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreateVendorInvoiceRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceRequest' responses: - "201": - description: The created invoice. + '201': + description: The created bill, with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string + Location: + description: The bill's own route. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApVendorInvoice' + '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' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/invoices/{id}: get: operationId: apInvoiceGet tags: [ap] summary: Get one vendor invoice with lines description: >- - Role guard admin, owner or finance. Any service failure, not only a - missing row, is a 404. lines is omitted when the invoice has none. + Role guard admin, owner or finance, held to the caller's branch wall: + another branch's bill is a 404. Carries the revision's ETag. parameters: - name: id in: path @@ -108,34 +135,57 @@ paths: schema: type: string format: uuid + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The invoice with vendor_name and lines. + '200': + description: The bill with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/ap/invoices/{id}/approve: + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/WireNotFound' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/ap/invoices/{id}/transitions: post: - operationId: apInvoiceApprove + operationId: apInvoiceTransition tags: [ap] - summary: Approve a pending vendor invoice + summary: Approve or void a vendor invoice description: >- - Role guard admin, owner or finance. No body. The approver is the - subject of the caller's JWT claims; with no claims, or a subject that - is not a UUID, the handler answers 401 itself (so in AUTH_MODE=dev, - which carries no claims, this route is always 401). Only a PENDING - invoice can be approved. Every service failure, including an unknown - id, a non pending status or a failed GL sync, is a 422. The response - carries vendor_name, approved_by and approved_at but not lines. + Role guard admin, owner or finance, held to the caller's branch wall. + Replaces the old /approve route. The body names the target status and + carries the revision (or If-Match); a write with neither is 428, a + mismatch 409 stale_revision. To approved: only from pending; the + entry posts through gl.PostEntry inside the transition's transaction + (a debit leg per line on the line's account, the tax spread across + the line accounts pro rata, the credit to Accounts Payable), and a + posting failure fails the act (a closed period is a 409 blocker + period_closed; a line with no account is a 409 blocker + line_needs_account; a required chart of accounts code that is missing + is a 409 blocker account_missing naming it). To voided: from pending + (nothing posted) or + approved (the entry is reversed whole; a bill that prices to zero + posted nothing and voids with nothing to reverse); a reason is + required, a bill with money applied is refused with the has_payments + blocker, an approved or partial bill that owes but has no linked + journal entry (a legacy bill whose posting was swallowed, or one + holding the removed sync's one leg credit) with the entry_not_linked + blocker, and a bill whose linked entry does not balance with the + entry_unbalanced blocker; nothing is written on either refusal. No + route links an entry to a bill yet: such a bill stays approved and + payable until a later repair item (a stated limit). parameters: - name: id in: path @@ -143,34 +193,57 @@ paths: schema: type: string format: uuid - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/XBranchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApInvoiceTransitionRequest' responses: - "200": - description: The approved invoice. + '200': + description: The bill after the transition, with its lines. + headers: + ETag: + description: The document's new revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/HandlerUnauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntityEither" + $ref: '#/components/schemas/ApVendorInvoice' + '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' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/payments: get: operationId: apPaymentList tags: [ap] summary: List vendor payments description: >- - Role guard admin, owner or finance. Optional vendor_id filter (an - unparseable value is ignored). No paging. Never null. + Role guard admin, owner or finance, held to the caller's branch wall + through the bills a payment applies to (a payment whose every bill is + another branch's is not the caller's to read). A bare array until the + payment routes' own conversion (ADR 0008 section 11). Optional + vendor_id filter (an unparseable value is a 400 naming it); a + parameter the route does not declare is refused. parameters: - name: vendor_id in: query @@ -179,170 +252,300 @@ paths: type: string format: uuid responses: - "200": + '200': description: The vendor payments. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApPayment" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apPaymentCreate tags: [ap] summary: Pay a vendor description: >- - Role guard admin, owner or finance. The amount is float dollars, - converted to int64 cents. The payment is created COMPLETE and applied - in order to the listed invoices up to each one's outstanding - balance, then synced to the GL. Only an undecodable body is a 400; - a malformed payment_date, an unknown invoice or any GL failure is a - 500. + Role guard admin, owner or finance. The request's money is int64 + amount_cents (a decimal amount is a 400 naming it; the old float + dollars amount is gone); the rest of the shape keeps its today form + (uppercase methods) until the payment routes' own conversion. The + payment locks the named bills in id order and applies + only to bills in approved or partial (a pending, paid or voided bill + is a 409 blocker invoice_not_approved; a bill of another vendor is a + 400 naming invoice_ids[i]); a repeated id in invoice_ids is a 400 + naming its index (appears twice); more than the named bills still owe + is a 409 blocker exceeds_open. The entry posts through gl.PostEntry inside + the act's transaction (DR Accounts Payable, CR Cash), and a posting + failure fails the act (a closed period is a 409 blocker period_closed; + a missing chart of accounts code is a 409 blocker account_missing + naming it). parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreatePaymentRequest" + $ref: '#/components/schemas/ApCreatePaymentRequest' responses: - "201": + '201': description: The created payment. content: application/json: schema: - $ref: "#/components/schemas/ApPayment" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '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' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/aging: get: operationId: apAging tags: [ap] summary: AP aging by vendor description: >- - Role guard admin, owner or finance. One row per vendor with open - balances in int64 cents buckets. Never null. + Role guard admin, owner or finance, held to the caller's branch wall + (a bound caller's buckets sum its branch's bills only). One row per + vendor with open balances in int64 cents buckets, from amount_open; a + paid or voided bill owes nothing anywhere. A bare array until the + aging route's own conversion. No query parameters; one is refused. responses: - "200": + '200': description: The aging rows. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApAgingSummary" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApAgingSummary' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' components: schemas: + ApInvoicePage: + type: object + required: [items, next_cursor, limit] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ApVendorInvoiceSummary' + next_cursor: + type: [string, 'null'] + limit: + type: integer + total: + type: integer + description: Only under include=total. ApVendorInvoice: type: object - required: [id, vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total, amount_paid, status, created_at] - description: A vendor bill (ap.VendorInvoice). Every money field is int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, + due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, + status, approved_by, approved_at, notes, revision, gl_entry_id, created_at, lines] + description: >- + A vendor bill (ap.Invoice): Gable's own number as number, the vendor's + as vendor_invoice_number, money in _cents, a lowercase status, the + revision, and the entry the approval posted. Optional fields are + present as null. properties: id: type: string format: uuid + number: + type: string + description: Gable's own number, AP- and six or more digits. vendor_id: type: string format: uuid vendor_name: type: string - description: Joined from the vendor on reads and approve; omitted on the create echo. - invoice_number: + branch_id: + type: string + format: uuid + vendor_invoice_number: + type: string + description: The vendor's own number for the bill. + currency: type: string + description: ISO 4217. invoice_date: type: string - format: date-time + format: date due_date: type: string - format: date-time + format: date po_id: - type: string + type: [string, 'null'] format: uuid - subtotal: + subtotal_cents: type: integer format: int64 - description: Cents. - tax_amount: + tax_cents: type: integer format: int64 - description: Cents. - total: + total_cents: type: integer format: int64 - description: Cents. - amount_paid: + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: type: integer format: int64 - description: Cents. status: type: string - enum: [PENDING, APPROVED, PARTIAL, PAID, VOIDED] + enum: [pending, approved, partial, paid, voided] approved_by: - type: string + type: [string, 'null'] format: uuid approved_at: - type: string + type: [string, 'null'] format: date-time notes: type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid created_at: type: string format: date-time lines: type: array - description: Present on the single read only. items: - $ref: "#/components/schemas/ApVendorInvoiceLine" - ApVendorInvoiceLine: + $ref: '#/components/schemas/ApVendorInvoiceLine' + ApVendorInvoiceSummary: type: object - required: [id, invoice_id, description, quantity, unit_price, line_total, created_at] - description: A vendor invoice line (ap.VendorInvoiceLine). unit_price and line_total are int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, + due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, + status, approved_by, approved_at, notes, revision, gl_entry_id, created_at] + description: A vendor bill header (ap.Summary), the list item and the shape the full document embeds. properties: id: type: string format: uuid - invoice_id: + number: + type: string + vendor_id: type: string format: uuid + vendor_name: + type: string + branch_id: + type: string + format: uuid + vendor_invoice_number: + type: string + currency: + type: string + invoice_date: + type: string + format: date + due_date: + type: string + format: date + po_id: + type: [string, 'null'] + format: uuid + subtotal_cents: + type: integer + format: int64 + tax_cents: + type: integer + format: int64 + total_cents: + type: integer + format: int64 + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: + type: integer + format: int64 + status: + type: string + enum: [pending, approved, partial, paid, voided] + approved_by: + type: [string, 'null'] + format: uuid + approved_at: + type: [string, 'null'] + format: date-time + notes: + type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid + created_at: + type: string + format: date-time + ApVendorInvoiceLine: + type: object + required: [id, position, description, quantity, unit_price_ten_thousandths, line_total_cents, gl_account_id, + purchase_order_line_id, product_id, po_freight_charge_id, created_at] + description: >- + A vendor invoice line (ap.InvoiceLine). The quantity is a decimal + string, the unit price is _ten_thousandths, and the extension is the + server's one rounding. The purchase order line and freight charge + links arrive with migration 106 and are written from package C's + freight rule on (ADR 0008 7.3): today's lines are not linked. + properties: + id: + type: string + format: uuid + position: + type: integer description: type: string quantity: - type: number - unit_price: + type: string + description: A plain decimal with at most 4 fraction digits. + unit_price_ten_thousandths: type: integer format: int64 - description: Cents. - line_total: + line_total_cents: type: integer format: int64 - description: Cents. gl_account_id: - type: string + type: [string, 'null'] + format: uuid + purchase_order_line_id: + type: [string, 'null'] + format: uuid + product_id: + type: [string, 'null'] + format: uuid + po_freight_charge_id: + type: [string, 'null'] format: uuid created_at: type: string @@ -350,7 +553,7 @@ components: ApPayment: type: object required: [id, vendor_id, amount, method, payment_date, status, created_at] - description: A payment to a vendor (ap.APPayment). amount is int64 cents. + description: A payment to a vendor (ap.APPayment), in its today shape. amount is int64 cents. properties: id: type: string @@ -376,7 +579,7 @@ components: type: string payment_date: type: string - format: date-time + format: date status: type: string description: PENDING, COMPLETE or VOIDED. @@ -415,13 +618,22 @@ components: description: Cents. ApCreateVendorInvoiceRequest: type: object - description: The create body. Dates are YYYY-MM-DD; tax_amount and line unit_price are float dollars today. + required: [vendor_id, vendor_invoice_number, invoice_date, due_date, lines] + description: >- + The create body. Dates are YYYY-MM-DD; money is _cents and the line + unit price is _ten_thousandths; the quantity is a decimal string; the + server prices each line and never accepts a status. properties: vendor_id: type: string format: uuid - invoice_number: + branch_id: + type: string + format: uuid + description: Defaults from the caller's context branch or the default branch. + vendor_invoice_number: type: string + description: 1 to 64 characters, unique per vendor. invoice_date: type: string format: date @@ -431,38 +643,69 @@ components: po_id: type: string format: uuid - tax_amount: - type: number - description: Float dollars today. + currency: + type: string + description: An ISO 4217 code of three capital letters; defaults from the setting. + tax_cents: + type: integer + format: int64 notes: type: string lines: type: array + minItems: 1 items: - $ref: "#/components/schemas/ApCreateVendorInvoiceLineRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceLineRequest' ApCreateVendorInvoiceLineRequest: type: object + required: [description, quantity, unit_price_ten_thousandths, gl_account_id] properties: description: type: string + description: 1 to 256 characters. quantity: - type: number - unit_price: - type: number - description: Float dollars today. + type: string + description: A plain decimal with at most 4 fraction digits, more than zero. + unit_price_ten_thousandths: + type: integer + format: int64 + description: Never negative. gl_account_id: type: string format: uuid + description: The account the approve entry debits for the line. + purchase_order_line_id: + type: string + format: uuid + product_id: + type: string + format: uuid + ApInvoiceTransitionRequest: + type: object + required: [to] + description: The transition body. The revision, or If-Match, is required (ADR 0001 section 11). + properties: + to: + type: string + enum: [approved, voided] + revision: + type: integer + format: int64 + reason: + type: string + description: Required to void; at most 500 characters. ApCreatePaymentRequest: type: object - description: The pay body. payment_date is YYYY-MM-DD; amount is float dollars today. + required: [vendor_id, amount_cents, method, payment_date] + description: The pay body. The money is int64 cents like every other money field; the rest keeps its today shape until the payment routes' own conversion. properties: vendor_id: type: string format: uuid - amount: - type: number - description: Float dollars today. + amount_cents: + type: integer + format: int64 + description: Cents; a decimal amount is a 400 naming the field. method: type: string enum: [CHECK, ACH, WIRE] @@ -475,7 +718,7 @@ components: format: date invoice_ids: type: array - description: Invoices this payment is applied to, in order. + description: Bills this payment is applied to, in the order the desk pays them; a repeated id is a 400. items: type: string format: uuid diff --git a/core/api/openapi.yaml b/core/api/openapi.yaml index 8168385d..1ba988fc 100644 --- a/core/api/openapi.yaml +++ b/core/api/openapi.yaml @@ -1188,77 +1188,99 @@ paths: tags: [ap] summary: List vendor invoices description: >- - Role guard admin, owner or finance. Optional filters vendor_id (an unparseable value is ignored, not refused) and status (free text, compared as given). No paging parameters exist; limit and offset are silently ignored. Never null. Lines are not included. + Role guard admin, owner or finance, behind the branch middleware: a bound caller sees its branch's bills only. The cursor envelope, newest first by (created_at, id). vendor_id and po_id filter on their document and status on the lowercase lifecycle vocabulary (a comma separated list); a parameter the route does not declare, a status outside the vocabulary or an uppercase one, a malformed cursor or an out of range limit is a 400. total appears only under include=total. parameters: - name: vendor_id in: query required: false + description: Filter on the vendor. + schema: + type: string + format: uuid + - name: po_id + in: query + required: false + description: Filter on the purchase order. schema: type: string format: uuid - name: status in: query required: false - description: Invoice status filter, for example PENDING, APPROVED, PARTIAL, PAID or VOIDED. + description: Comma separated lowercase statuses (pending, approved, partial, paid, voided). schema: type: string + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Include' + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The vendor invoices, ordered by due_date ascending (earliest due first). + '200': + description: The page of vendor invoice summaries. content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/ApVendorInvoice" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApInvoicePage' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apInvoiceCreate tags: [ap] summary: Enter a vendor invoice description: >- - Role guard admin, owner or finance. The request carries float dollars; subtotal, tax and line amounts are converted to int64 cents. The invoice starts PENDING with nothing paid. Only an undecodable body is a 400; a malformed invoice_date or due_date or any database refusal (unknown vendor, duplicate number) is a 500. The echo omits vendor_name and lines. + Role guard admin, owner or finance. The bill starts pending, owing its whole total, with Gable's own number. Money is _cents, the line unit price is _ten_thousandths and the quantity a decimal string; the server prices each line and never accepts a status. Every line names its gl_account_id (the approve entry debits each line's account). The branch defaults from the body branch, the caller's context branch or the default branch, and is held to the caller's wall (403 naming branch_id). Validation failures are one 400 naming every field with its full path; a duplicate vendor number for the vendor is a 409; an unknown vendor, purchase order or account is a 400 naming the field. parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/XBranchId' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreateVendorInvoiceRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceRequest' responses: - "201": - description: The created invoice. + '201': + description: The created bill, with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string + Location: + description: The bill's own route. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApVendorInvoice' + '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' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/invoices/{id}: get: operationId: apInvoiceGet tags: [ap] summary: Get one vendor invoice with lines description: >- - Role guard admin, owner or finance. Any service failure, not only a missing row, is a 404. lines is omitted when the invoice has none. + Role guard admin, owner or finance, held to the caller's branch wall: another branch's bill is a 404. Carries the revision's ETag. parameters: - name: id in: path @@ -1266,28 +1288,36 @@ paths: schema: type: string format: uuid + - $ref: '#/components/parameters/XBranchId' responses: - "200": - description: The invoice with vendor_name and lines. + '200': + description: The bill with its lines. + headers: + ETag: + description: The document's revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - /api/v1/ap/invoices/{id}/approve: + $ref: '#/components/schemas/ApVendorInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '404': + $ref: '#/components/responses/WireNotFound' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/ap/invoices/{id}/transitions: post: - operationId: apInvoiceApprove + operationId: apInvoiceTransition tags: [ap] - summary: Approve a pending vendor invoice + summary: Approve or void a vendor invoice description: >- - Role guard admin, owner or finance. No body. The approver is the subject of the caller's JWT claims; with no claims, or a subject that is not a UUID, the handler answers 401 itself (so in AUTH_MODE=dev, which carries no claims, this route is always 401). Only a PENDING invoice can be approved. Every service failure, including an unknown id, a non pending status or a failed GL sync, is a 422. The response carries vendor_name, approved_by and approved_at but not lines. + Role guard admin, owner or finance, held to the caller's branch wall. Replaces the old /approve route. The body names the target status and carries the revision (or If-Match); a write with neither is 428, a mismatch 409 stale_revision. To approved: only from pending; the entry posts through gl.PostEntry inside the transition's transaction (a debit leg per line on the line's account, the tax spread across the line accounts pro rata, the credit to Accounts Payable), and a posting failure fails the act (a closed period is a 409 blocker period_closed; a line with no account is a 409 blocker line_needs_account; a required chart of accounts code that is missing is a 409 blocker account_missing naming it). To voided: from pending (nothing posted) or approved (the entry is reversed whole; a bill that prices to zero posted nothing and voids with nothing to reverse); a reason is required, a bill with money applied is refused with the has_payments blocker, an approved or partial bill that owes but has no linked journal entry (a legacy bill whose posting was swallowed, or one holding the removed sync's one leg credit) with the entry_not_linked blocker, and a bill whose linked entry does not balance with the entry_unbalanced blocker; nothing is written on either refusal. No route links an entry to a bill yet: such a bill stays approved and payable until a later repair item (a stated limit). parameters: - name: id in: path @@ -1295,33 +1325,52 @@ paths: schema: type: string format: uuid - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/XBranchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApInvoiceTransitionRequest' responses: - "200": - description: The approved invoice. + '200': + description: The bill after the transition, with its lines. + headers: + ETag: + description: The document's new revision. + schema: + type: string content: application/json: schema: - $ref: "#/components/schemas/ApVendorInvoice" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/HandlerUnauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntityEither" + $ref: '#/components/schemas/ApVendorInvoice' + '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' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '428': + $ref: '#/components/responses/WirePreconditionRequired' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/payments: get: operationId: apPaymentList tags: [ap] summary: List vendor payments description: >- - Role guard admin, owner or finance. Optional vendor_id filter (an unparseable value is ignored). No paging. Never null. + Role guard admin, owner or finance, held to the caller's branch wall through the bills a payment applies to (a payment whose every bill is another branch's is not the caller's to read). A bare array until the payment routes' own conversion (ADR 0008 section 11). Optional vendor_id filter (an unparseable value is a 400 naming it); a parameter the route does not declare is refused. parameters: - name: vendor_id in: query @@ -1330,77 +1379,81 @@ paths: type: string format: uuid responses: - "200": + '200': description: The vendor payments. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApPayment" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' post: operationId: apPaymentCreate tags: [ap] summary: Pay a vendor description: >- - Role guard admin, owner or finance. The amount is float dollars, converted to int64 cents. The payment is created COMPLETE and applied in order to the listed invoices up to each one's outstanding balance, then synced to the GL. Only an undecodable body is a 400; a malformed payment_date, an unknown invoice or any GL failure is a 500. + Role guard admin, owner or finance. The request's money is int64 amount_cents (a decimal amount is a 400 naming it; the old float dollars amount is gone); the rest of the shape keeps its today form (uppercase methods) until the payment routes' own conversion. The payment locks the named bills in id order and applies only to bills in approved or partial (a pending, paid or voided bill is a 409 blocker invoice_not_approved; a bill of another vendor is a 400 naming invoice_ids[i]); a repeated id in invoice_ids is a 400 naming its index (appears twice); more than the named bills still owe is a 409 blocker exceeds_open. The entry posts through gl.PostEntry inside the act's transaction (DR Accounts Payable, CR Cash), and a posting failure fails the act (a closed period is a 409 blocker period_closed; a missing chart of accounts code is a 409 blocker account_missing naming it). parameters: - - $ref: "#/components/parameters/IdempotencyKey" + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ApCreatePaymentRequest" + $ref: '#/components/schemas/ApCreatePaymentRequest' responses: - "201": + '201': description: The created payment. content: application/json: schema: - $ref: "#/components/schemas/ApPayment" - "400": - $ref: "#/components/responses/BadRequestEither" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/IdempotencyConflict" - "413": - $ref: "#/components/responses/PayloadTooLarge" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApPayment' + '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' + '500': + $ref: '#/components/responses/InternalError' /api/v1/ap/aging: get: operationId: apAging tags: [ap] summary: AP aging by vendor description: >- - Role guard admin, owner or finance. One row per vendor with open balances in int64 cents buckets. Never null. + Role guard admin, owner or finance, held to the caller's branch wall (a bound caller's buckets sum its branch's bills only). One row per vendor with open balances in int64 cents buckets, from amount_open; a paid or voided bill owes nothing anywhere. A bare array until the aging route's own conversion. No query parameters; one is refused. responses: - "200": + '200': description: The aging rows. content: application/json: schema: type: array items: - $ref: "#/components/schemas/ApAgingSummary" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "500": - $ref: "#/components/responses/InternalError" + $ref: '#/components/schemas/ApAgingSummary' + '400': + $ref: '#/components/responses/BadRequestEither' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenEither' + '500': + $ref: '#/components/responses/InternalError' /api/v1/apps: get: operationId: appsList @@ -15868,91 +15921,194 @@ components: properties: ok: type: boolean + ApInvoicePage: + type: object + required: [items, next_cursor, limit] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ApVendorInvoiceSummary' + next_cursor: + type: [string, 'null'] + limit: + type: integer + total: + type: integer + description: Only under include=total. ApVendorInvoice: type: object - required: [id, vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total, amount_paid, status, created_at] - description: A vendor bill (ap.VendorInvoice). Every money field is int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, status, approved_by, approved_at, notes, revision, gl_entry_id, created_at, lines] + description: >- + A vendor bill (ap.Invoice): Gable's own number as number, the vendor's as vendor_invoice_number, money in _cents, a lowercase status, the revision, and the entry the approval posted. Optional fields are present as null. properties: id: type: string format: uuid + number: + type: string + description: Gable's own number, AP- and six or more digits. vendor_id: type: string format: uuid vendor_name: type: string - description: Joined from the vendor on reads and approve; omitted on the create echo. - invoice_number: + branch_id: + type: string + format: uuid + vendor_invoice_number: type: string + description: The vendor's own number for the bill. + currency: + type: string + description: ISO 4217. invoice_date: type: string - format: date-time + format: date due_date: type: string - format: date-time + format: date po_id: - type: string + type: [string, 'null'] format: uuid - subtotal: + subtotal_cents: type: integer format: int64 - description: Cents. - tax_amount: + tax_cents: type: integer format: int64 - description: Cents. - total: + total_cents: type: integer format: int64 - description: Cents. - amount_paid: + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: type: integer format: int64 - description: Cents. status: type: string - enum: [PENDING, APPROVED, PARTIAL, PAID, VOIDED] + enum: [pending, approved, partial, paid, voided] approved_by: - type: string + type: [string, 'null'] format: uuid approved_at: - type: string + type: [string, 'null'] format: date-time notes: type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid created_at: type: string format: date-time lines: type: array - description: Present on the single read only. items: - $ref: "#/components/schemas/ApVendorInvoiceLine" - ApVendorInvoiceLine: + $ref: '#/components/schemas/ApVendorInvoiceLine' + ApVendorInvoiceSummary: type: object - required: [id, invoice_id, description, quantity, unit_price, line_total, created_at] - description: A vendor invoice line (ap.VendorInvoiceLine). unit_price and line_total are int64 cents. + required: [id, number, vendor_id, vendor_name, branch_id, vendor_invoice_number, currency, invoice_date, due_date, po_id, subtotal_cents, tax_cents, total_cents, amount_paid_cents, amount_open_cents, status, approved_by, approved_at, notes, revision, gl_entry_id, created_at] + description: A vendor bill header (ap.Summary), the list item and the shape the full document embeds. properties: id: type: string format: uuid - invoice_id: + number: + type: string + vendor_id: + type: string + format: uuid + vendor_name: + type: string + branch_id: + type: string + format: uuid + vendor_invoice_number: + type: string + currency: + type: string + invoice_date: + type: string + format: date + due_date: + type: string + format: date + po_id: + type: [string, 'null'] + format: uuid + subtotal_cents: + type: integer + format: int64 + tax_cents: + type: integer + format: int64 + total_cents: + type: integer + format: int64 + amount_paid_cents: + type: integer + format: int64 + amount_open_cents: + type: integer + format: int64 + status: + type: string + enum: [pending, approved, partial, paid, voided] + approved_by: + type: [string, 'null'] + format: uuid + approved_at: + type: [string, 'null'] + format: date-time + notes: + type: string + revision: + type: integer + format: int64 + gl_entry_id: + type: [string, 'null'] + format: uuid + created_at: + type: string + format: date-time + ApVendorInvoiceLine: + type: object + required: [id, position, description, quantity, unit_price_ten_thousandths, line_total_cents, gl_account_id, purchase_order_line_id, product_id, po_freight_charge_id, created_at] + description: >- + A vendor invoice line (ap.InvoiceLine). The quantity is a decimal string, the unit price is _ten_thousandths, and the extension is the server's one rounding. The purchase order line and freight charge links arrive with migration 106 and are written from package C's freight rule on (ADR 0008 7.3): today's lines are not linked. + properties: + id: type: string format: uuid + position: + type: integer description: type: string quantity: - type: number - unit_price: + type: string + description: A plain decimal with at most 4 fraction digits. + unit_price_ten_thousandths: type: integer format: int64 - description: Cents. - line_total: + line_total_cents: type: integer format: int64 - description: Cents. gl_account_id: - type: string + type: [string, 'null'] + format: uuid + purchase_order_line_id: + type: [string, 'null'] + format: uuid + product_id: + type: [string, 'null'] + format: uuid + po_freight_charge_id: + type: [string, 'null'] format: uuid created_at: type: string @@ -15960,7 +16116,7 @@ components: ApPayment: type: object required: [id, vendor_id, amount, method, payment_date, status, created_at] - description: A payment to a vendor (ap.APPayment). amount is int64 cents. + description: A payment to a vendor (ap.APPayment), in its today shape. amount is int64 cents. properties: id: type: string @@ -15986,7 +16142,7 @@ components: type: string payment_date: type: string - format: date-time + format: date status: type: string description: PENDING, COMPLETE or VOIDED. @@ -16025,13 +16181,20 @@ components: description: Cents. ApCreateVendorInvoiceRequest: type: object - description: The create body. Dates are YYYY-MM-DD; tax_amount and line unit_price are float dollars today. + required: [vendor_id, vendor_invoice_number, invoice_date, due_date, lines] + description: >- + The create body. Dates are YYYY-MM-DD; money is _cents and the line unit price is _ten_thousandths; the quantity is a decimal string; the server prices each line and never accepts a status. properties: vendor_id: type: string format: uuid - invoice_number: + branch_id: + type: string + format: uuid + description: Defaults from the caller's context branch or the default branch. + vendor_invoice_number: type: string + description: 1 to 64 characters, unique per vendor. invoice_date: type: string format: date @@ -16041,38 +16204,69 @@ components: po_id: type: string format: uuid - tax_amount: - type: number - description: Float dollars today. + currency: + type: string + description: An ISO 4217 code of three capital letters; defaults from the setting. + tax_cents: + type: integer + format: int64 notes: type: string lines: type: array + minItems: 1 items: - $ref: "#/components/schemas/ApCreateVendorInvoiceLineRequest" + $ref: '#/components/schemas/ApCreateVendorInvoiceLineRequest' ApCreateVendorInvoiceLineRequest: type: object + required: [description, quantity, unit_price_ten_thousandths, gl_account_id] properties: description: type: string + description: 1 to 256 characters. quantity: - type: number - unit_price: - type: number - description: Float dollars today. + type: string + description: A plain decimal with at most 4 fraction digits, more than zero. + unit_price_ten_thousandths: + type: integer + format: int64 + description: Never negative. gl_account_id: type: string format: uuid + description: The account the approve entry debits for the line. + purchase_order_line_id: + type: string + format: uuid + product_id: + type: string + format: uuid + ApInvoiceTransitionRequest: + type: object + required: [to] + description: The transition body. The revision, or If-Match, is required (ADR 0001 section 11). + properties: + to: + type: string + enum: [approved, voided] + revision: + type: integer + format: int64 + reason: + type: string + description: Required to void; at most 500 characters. ApCreatePaymentRequest: type: object - description: The pay body. payment_date is YYYY-MM-DD; amount is float dollars today. + required: [vendor_id, amount_cents, method, payment_date] + description: The pay body. The money is int64 cents like every other money field; the rest keeps its today shape until the payment routes' own conversion. properties: vendor_id: type: string format: uuid - amount: - type: number - description: Float dollars today. + amount_cents: + type: integer + format: int64 + description: Cents; a decimal amount is a 400 naming the field. method: type: string enum: [CHECK, ACH, WIRE] @@ -16085,7 +16279,7 @@ components: format: date invoice_ids: type: array - description: Invoices this payment is applied to, in order. + description: Bills this payment is applied to, in the order the desk pays them; a repeated id is a 400. items: type: string format: uuid diff --git a/core/internal/ap/fixture_test.go b/core/internal/ap/fixture_test.go new file mode 100644 index 00000000..8fbe5348 --- /dev/null +++ b/core/internal/ap/fixture_test.go @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// The AP module end to end (C4-1b, ADR 0008 7.4 on ADR 0001): a real +// Postgres, the real service wired as serve wires it (outbox, audit log, +// database as transaction runner, branch guard), the real handler on one mux +// behind the real idempotency middleware and a branch context stand-in. +// Requests are JSON and responses are read back as JSON, so each test states +// a wire fact. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/gl" + "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" +) + +const defaultBranch = `(SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id')` + +type fixture struct { + t *testing.T + db *database.DB + srv *httptest.Server + svc *ap.Service + repo *ap.Repository + glSvc *gl.Service + vendor uuid.UUID + expense uuid.UUID // account 5020, the line account the fixture's bills use + branch uuid.UUID + prefix string +} + +// service builds a service variant with the given event recorder and audit +// sink, everything else as the fixture wires it. +func (f *fixture) service(events ap.EventRecorder, sink ap.AuditSink, tx ap.TxRunner) *ap.Service { + svc := ap.NewService(f.db, f.repo, f.glSvc, slog.Default()).WithOutbox(events).WithTxRunner(tx) + if sink != nil { + svc = svc.WithAuditLog(sink) + } + return svc +} + +// newFixture builds a vendor and serves the AP routes the way serve does. A +// request header X-Test-Sub names the acting user (the audit subject and the +// approve's approver) and X-Test-Branch puts a branch wall on the request's +// context, as the branch middleware does. +func newFixture(t *testing.T, db *database.DB) *fixture { + t.Helper() + t.Setenv("AUTH_MODE", "dev") + f := &fixture{t: t, db: db, vendor: uuid.New(), prefix: "C41B-" + uuid.NewString()[:8] + "-"} + ctx := context.Background() + if err := db.Pool.QueryRow(ctx, `SELECT `+defaultBranch).Scan(&f.branch); err != nil { + t.Fatalf("default branch: %v", err) + } + if err := db.Pool.QueryRow(ctx, `SELECT id FROM gl_accounts WHERE code = '5020'`).Scan(&f.expense); err != nil { + t.Fatalf("expense account: %v", err) + } + if _, err := db.Pool.Exec(ctx, + `INSERT INTO vendors (id, name, contact_email) VALUES ($1, $2, 'c41b@example.invalid')`, + f.vendor, "C41B Vendor "+f.vendor.String()[:8]); err != nil { + t.Fatal(err) + } + f.repo = ap.NewRepository(db) + f.glSvc = gl.NewService(gl.NewRepository(db), nil, slog.Default()) + f.svc = ap.NewService(db, f.repo, f.glSvc, slog.Default()). + WithOutbox(outbox.NewWriter(db, "")).WithTxRunner(db). + WithAuditLog(audit.NewLogger(db)).WithBranchGuard(middleware.NewBranchGuard(db)) + mux := http.NewServeMux() + ap.NewHandler(f.svc).RegisterRoutes(mux) + wallMux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := &middleware.UserClaims{Role: "finance", Roles: []string{"finance"}} + claims.Subject = r.Header.Get("X-Test-Sub") + if claims.Subject == "" { + claims.Subject = "11111111-2222-4333-8444-555555555555" + } + if role := r.Header.Get("X-Test-Role"); role != "" { + claims.Role, claims.Roles = role, []string{role} + } + ctx := context.WithValue(r.Context(), middleware.UserContextKey, claims) + // What the branch middleware settles for a real request: without a + // branch header an administrator reads every branch; X-Test-Branch + // holds the request to one branch (a branch-bound key); X-Test-User-Bound + // holds it to the branches the user's user_locations rows name. + bc := &branchctx.Context{UserSub: claims.Subject, IsAdmin: true} + if b := r.Header.Get("X-Test-Branch"); b != "" { + id := uuid.MustParse(b) + bc = &branchctx.Context{UserSub: claims.Subject, IsAdmin: r.Header.Get("X-Test-Admin") == "1", BranchID: &id} + } else if r.Header.Get("X-Test-User-Bound") == "1" { + bc = &branchctx.Context{UserSub: claims.Subject, IsAdmin: false} + } + mux.ServeHTTP(w, r.WithContext(branchctx.With(ctx, bc))) + }) + f.srv = httptest.NewServer(middleware.Idempotency(db)(wallMux)) + t.Cleanup(func() { + f.srv.Close() + f.cleanup() + }) + return f +} + +// cleanup removes everything the fixture's vendor produced, children first. +func (f *fixture) cleanup() { + ctx := context.Background() + exec := func(sql string, args ...any) { _, _ = f.db.Pool.Exec(ctx, sql, args...) } + inv := `SELECT id FROM vendor_invoices WHERE vendor_id = $1` + entries := `SELECT id FROM gl_journal_entries WHERE source_ref_id IN (` + inv + `) + OR source_ref_id IN (SELECT id FROM ap_payments WHERE vendor_id = $1)` + exec(`DELETE FROM gl_journal_lines WHERE journal_entry_id IN (SELECT id FROM gl_journal_entries WHERE reverses_entry_id IN `+entries+`)`, f.vendor) + exec(`DELETE FROM gl_journal_entries WHERE reverses_entry_id IN `+entries, f.vendor) + exec(`DELETE FROM gl_journal_lines WHERE journal_entry_id IN `+entries, f.vendor) + exec(`DELETE FROM gl_journal_entries WHERE id IN `+entries, f.vendor) + exec(`DELETE FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id IN `+inv, f.vendor) + exec(`DELETE FROM audit_log WHERE entity_type = 'vendor_invoice' AND entity_id IN `+inv, f.vendor) + exec(`DELETE FROM ap_payment_applications WHERE invoice_id IN `+inv, f.vendor) + exec(`DELETE FROM ap_payments WHERE vendor_id = $1`, f.vendor) + exec(`DELETE FROM vendor_invoice_lines WHERE invoice_id IN `+inv, f.vendor) + exec(`DELETE FROM vendor_invoices WHERE vendor_id = $1`, f.vendor) + exec(`DELETE FROM vendors WHERE id = $1`, f.vendor) +} + +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) + } +} + +type resp struct { + status int + header http.Header + body map[string]any + raw []byte +} + +func (f *fixture) do(method, path string, body any, headers ...string) resp { + f.t.Helper() + var rdr io.Reader + switch b := body.(type) { + case nil: + case string: + rdr = strings.NewReader(b) + default: + raw, err := json.Marshal(body) + if err != nil { + f.t.Fatal(err) + } + rdr = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, f.srv.URL+path, rdr) + if err != nil { + f.t.Fatal(err) + } + if rdr != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Request-ID", "req-ap-test") + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + f.t.Fatal(err) + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + out := resp{status: res.StatusCode, header: res.Header, raw: raw} + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + _ = dec.Decode(&out.body) + return out +} + +func str(t *testing.T, m map[string]any, key string) string { + t.Helper() + s, ok := m[key].(string) + if !ok { + t.Fatalf("%s is %T (%v), want a string", key, m[key], m[key]) + } + return s +} + +func num(t *testing.T, m map[string]any, key string) int64 { + t.Helper() + n, ok := m[key].(json.Number) + if !ok { + t.Fatalf("%s is %T (%v), want an integer", key, m[key], m[key]) + } + v, err := n.Int64() + if err != nil { + t.Fatalf("%s = %s, want an integer: %v", key, n, err) + } + return v +} + +func rev(t *testing.T, r resp) int64 { return num(t, r.body, "revision") } + +func errorOf(t *testing.T, r resp) (code string, blockers []string, fields []string) { + t.Helper() + e, ok := r.body["error"].(map[string]any) + if !ok { + t.Fatalf("no error envelope in %s", r.raw) + } + code, _ = e["code"].(string) + if ds, ok := e["details"].([]any); ok { + for _, d := range ds { + dm := d.(map[string]any) + if c, _ := dm["code"].(string); c != "" { + blockers = append(blockers, c) + } + if fld, _ := dm["field"].(string); fld != "" { + fields = append(fields, fld) + } + } + } + if meta, _ := r.body["meta"].(map[string]any); meta["request_id"] != "req-ap-test" { + t.Errorf("meta.request_id = %v, want the request's id", meta["request_id"]) + } + return code, blockers, fields +} + +// --------------------------------------------------------------------------- +// Making bills and payments through the routes. +// --------------------------------------------------------------------------- + +// lineBody is one bill line: quantity qty at price (dollars at scale 4 given +// as ten thousandths). +func (f *fixture) lineBody(qty string, price int64) map[string]any { + return map[string]any{"description": "2x4x8 SPF", "quantity": qty, + "unit_price_ten_thousandths": price, "gl_account_id": f.expense.String()} +} + +// createBody is a bill body of one line of qty at price (dollars at scale 4). +func (f *fixture) createBody(number string, qty string, price int64) map[string]any { + return map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": number, + "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{f.lineBody(qty, price)}, + } +} + +// enter makes a pending bill of one line of qty at price (ten thousandths). +func (f *fixture) enter(number string, qty string, price int64) (string, int64) { + f.t.Helper() + r := f.do("POST", "/api/v1/ap/invoices", f.createBody(number, qty, price)) + if r.status != http.StatusCreated { + f.t.Fatalf("enter = %d: %s", r.status, r.raw) + } + return str(f.t, r.body, "id"), rev(f.t, r) +} + +// approve runs the approve transition. +func (f *fixture) approve(id string, revision int64) resp { + f.t.Helper() + return f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "approved", "revision": revision}) +} + +// voided runs the void transition. +func (f *fixture) void(id string, revision int64, reason string) resp { + f.t.Helper() + return f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "voided", "revision": revision, "reason": reason}) +} + +// pay pays cents against the invoices. +func (f *fixture) pay(cents int64, invoiceIDs ...string) resp { + f.t.Helper() + return f.payHeaders(nil, cents, invoiceIDs...) +} + +// payHeaders pays cents against the invoices behind extra request headers. +func (f *fixture) payHeaders(headers []string, cents int64, invoiceIDs ...string) resp { + f.t.Helper() + return f.do("POST", "/api/v1/ap/payments", map[string]any{ + "vendor_id": f.vendor.String(), "amount_cents": cents, "method": "CHECK", + "payment_date": "2026-01-15", "invoice_ids": invoiceIDs, + }, headers...) +} + +// --------------------------------------------------------------------------- +// Reading the ledger, the events and the control account. +// --------------------------------------------------------------------------- + +type leg struct{ debit, credit int64 } + +// entryLegs sums, per account code, the legs of the entries whose source +// reference is the bill. +func (f *fixture) entryLegs(invoiceID string) (entries int, legs map[string]leg) { + f.t.Helper() + return f.entryLegsWhere(f.t, f.db, `e.source_ref_id = $1`, invoiceID) +} + +func (f *fixture) entryLegsWhere(t *testing.T, db *database.DB, where string, args ...any) (int, map[string]leg) { + t.Helper() + ctx := context.Background() + var entries int + if err := db.Pool.QueryRow(ctx, `SELECT count(*) FROM gl_journal_entries e WHERE `+where, args...).Scan(&entries); err != nil { + t.Fatal(err) + } + rows, err := 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.id IN (SELECT id FROM gl_journal_entries e WHERE `+where+`) GROUP BY a.code`, args...) + if err != nil { + 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 { + t.Fatal(err) + } + legs[code] = l + } + return entries, legs +} + +// controlBalance is the AP control account's balance in cents, from the +// entries this fixture's acts wrote (approves credit it, payments and +// reversals debit it), scoped to the fixture's own bills and payments. +func (f *fixture) controlBalance() int64 { + f.t.Helper() + var balance int64 + err := f.db.Pool.QueryRow(context.Background(), ` + SELECT ROUND(COALESCE(SUM(l.credit - l.debit), 0) * 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 a.code = '2010' AND e.id IN ( + SELECT id FROM gl_journal_entries + WHERE source_ref_id IN (SELECT id FROM vendor_invoices WHERE vendor_id = $1) + OR source_ref_id IN (SELECT id FROM ap_payments WHERE vendor_id = $1) + OR reverses_entry_id IN (SELECT gl_entry_id FROM vendor_invoices WHERE vendor_id = $1))`, f.vendor).Scan(&balance) + if err != nil { + f.t.Fatal(err) + } + return balance +} + +// openSum is what the vendor's open bills owe, in cents. +func (f *fixture) openSum() int64 { + f.t.Helper() + var open int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT ROUND(COALESCE(SUM(amount_open), 0) * 100)::bigint FROM vendor_invoices WHERE vendor_id = $1 AND status IN ('APPROVED', 'PARTIAL')`, + f.vendor).Scan(&open); err != nil { + f.t.Fatal(err) + } + return open +} + +// balances answers whether every journal entry this fixture's acts wrote is +// balanced. +func (f *fixture) balanced() bool { + f.t.Helper() + var bad int64 + err := f.db.Pool.QueryRow(context.Background(), ` + SELECT count(*) FROM gl_journal_entries e + JOIN (SELECT journal_entry_id, SUM(debit) d, SUM(credit) c FROM gl_journal_lines GROUP BY journal_entry_id) s + ON s.journal_entry_id = e.id + WHERE e.id IN ( + SELECT id FROM gl_journal_entries + WHERE source_ref_id IN (SELECT id FROM vendor_invoices WHERE vendor_id = $1) + OR source_ref_id IN (SELECT id FROM ap_payments WHERE vendor_id = $1) + OR reverses_entry_id IN (SELECT gl_entry_id FROM vendor_invoices WHERE vendor_id = $1)) + AND s.d <> s.c`, f.vendor).Scan(&bad) + if err != nil { + f.t.Fatal(err) + } + return bad == 0 +} + +// eventTypes lists the vendor invoice's event types in order. +func eventTypes(t *testing.T, db *database.DB, entityID string) []string { + t.Helper() + rows, err := db.Pool.Query(context.Background(), + `SELECT type FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id = $1 ORDER BY position`, 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 +} + +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 +} + +func (f *fixture) count(sql string, args ...any) int64 { + f.t.Helper() + return countOf(f.t, f.db, sql, args...) +} diff --git a/core/internal/ap/gl_integration_test.go b/core/internal/ap/gl_integration_test.go deleted file mode 100644 index 579cfa6d..00000000 --- a/core/internal/ap/gl_integration_test.go +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 -// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors - -package ap - -import ( - "context" - "log/slog" - "testing" - "time" - - "github.com/gablelbm/gable/internal/gl" - "github.com/google/uuid" -) - -// --- Mock Repositories --- - -type MockAPRepository struct { - invoices map[uuid.UUID]*VendorInvoice - payments map[uuid.UUID]*APPayment - invoiceLines map[uuid.UUID][]VendorInvoiceLine -} - -func (m *MockAPRepository) CreateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - m.invoices[inv.ID] = inv - return nil -} -func (m *MockAPRepository) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) { - return m.invoices[id], nil -} -func (m *MockAPRepository) ListVendorInvoices(ctx context.Context, vID *uuid.UUID, status string) ([]VendorInvoice, error) { - var res []VendorInvoice - for _, inv := range m.invoices { - res = append(res, *inv) - } - return res, nil -} -func (m *MockAPRepository) UpdateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - m.invoices[inv.ID] = inv - return nil -} -func (m *MockAPRepository) AddInvoiceLine(ctx context.Context, line *VendorInvoiceLine) error { - m.invoiceLines[line.InvoiceID] = append(m.invoiceLines[line.InvoiceID], *line) - return nil -} -func (m *MockAPRepository) GetInvoiceLines(ctx context.Context, invoiceID uuid.UUID) ([]VendorInvoiceLine, error) { - return m.invoiceLines[invoiceID], nil -} -func (m *MockAPRepository) CreatePayment(ctx context.Context, pmt *APPayment) error { - m.payments[pmt.ID] = pmt - return nil -} -func (m *MockAPRepository) CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error { - return nil -} -func (m *MockAPRepository) ListPayments(ctx context.Context, vID *uuid.UUID) ([]APPayment, error) { - return nil, nil -} -func (m *MockAPRepository) GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) { - return nil, nil -} - -type MockGLRepository struct { - entries []gl.JournalEntry -} - -func (m *MockGLRepository) CreateJournalEntry(ctx context.Context, entry *gl.JournalEntry) error { - m.entries = append(m.entries, *entry) - return nil -} -func (m *MockGLRepository) GetJournalEntry(ctx context.Context, id uuid.UUID) (*gl.JournalEntry, error) { - return nil, nil -} -func (m *MockGLRepository) ListJournalEntries(ctx context.Context) ([]gl.JournalEntry, error) { - return nil, nil -} -func (m *MockGLRepository) UpdateJournalEntryStatus(ctx context.Context, id uuid.UUID, status, user string) error { - return nil -} -func (m *MockGLRepository) ListAccounts(ctx context.Context) ([]gl.GLAccount, error) { - return []gl.GLAccount{ - {ID: uuid.New(), Name: "Accounts Payable", Code: "2010"}, - {ID: uuid.New(), Name: "Cash", Code: "1010"}, - }, nil -} -func (m *MockGLRepository) GetAccount(ctx context.Context, id uuid.UUID) (*gl.GLAccount, error) { - return nil, nil -} -func (m *MockGLRepository) CreateAccount(ctx context.Context, acct *gl.GLAccount) error { return nil } -func (m *MockGLRepository) UpdateAccount(ctx context.Context, acct *gl.GLAccount) error { return nil } -func (m *MockGLRepository) GetTrialBalance(ctx context.Context, asOf time.Time, currency string) ([]gl.TrialBalanceRow, error) { - return nil, nil -} - -// AP never reads financial statements; this satisfies gl.Repository only. -func (m *MockGLRepository) DefaultCurrency(ctx context.Context) (string, error) { return "USD", nil } - -func (m *MockGLRepository) GetAccountActivity(ctx context.Context, start *time.Time, end time.Time, accountTypes []string, currency string) ([]gl.AccountActivity, error) { - return nil, nil -} -func (m *MockGLRepository) ListFiscalPeriods(ctx context.Context) ([]gl.FiscalPeriod, error) { - return nil, nil -} -func (m *MockGLRepository) GetFiscalPeriodForDate(ctx context.Context, date time.Time) (*gl.FiscalPeriod, error) { - return nil, nil -} -func (m *MockGLRepository) CloseFiscalPeriod(ctx context.Context, id uuid.UUID, user string) error { - return nil -} - -func (m *MockGLRepository) ReopenFiscalPeriod(ctx context.Context, id uuid.UUID, user string) error { - return nil -} - -func (m *MockGLRepository) IsReversed(ctx context.Context, id uuid.UUID) (bool, error) { - return false, nil -} - -type MockDatabase struct{} - -func (m *MockDatabase) RunInTx(ctx context.Context, fn func(ctx context.Context) error) error { - return fn(ctx) -} - -func TestGLIntegration_ApproveInvoice(t *testing.T) { - apRepo := &MockAPRepository{ - invoices: make(map[uuid.UUID]*VendorInvoice), - invoiceLines: make(map[uuid.UUID][]VendorInvoiceLine), - } - glRepo := &MockGLRepository{} - glSvc := gl.NewService(glRepo, nil, nil) - apSvc := NewService(&MockDatabase{}, apRepo, nil, slog.Default()) - apSvc.glSvc = glSvc // Wire it up - - invID := uuid.New() - expenseAcctID := uuid.New() - inv := &VendorInvoice{ - ID: invID, - Total: 10000, // $100 - Status: InvoiceStatusPending, - Lines: []VendorInvoiceLine{ - {ID: uuid.New(), Description: "Lumber", LineTotal: 10000, GLAccountID: &expenseAcctID}, - }, - } - apRepo.invoices[invID] = inv - apRepo.invoiceLines[invID] = inv.Lines - - _, err := apSvc.ApproveInvoice(context.Background(), invID, uuid.New()) - if err != nil { - t.Fatalf("ApproveInvoice failed: %v", err) - } - - if len(glRepo.entries) != 1 { - t.Fatalf("expected 1 GL entry, got %d", len(glRepo.entries)) - } - - entry := glRepo.entries[0] - if entry.Source != gl.SourceVendorInv { - t.Errorf("expected source %s, got %s", gl.SourceVendorInv, entry.Source) - } - - // Check lines (1 AP credit, 1 Expense debit) - if len(entry.Lines) != 2 { - t.Fatalf("expected 2 GL lines, got %d", len(entry.Lines)) - } -} - -func TestGLIntegration_PayVendor(t *testing.T) { - apRepo := &MockAPRepository{ - invoices: make(map[uuid.UUID]*VendorInvoice), - payments: make(map[uuid.UUID]*APPayment), - } - glRepo := &MockGLRepository{} - glSvc := gl.NewService(glRepo, nil, nil) - apSvc := NewService(&MockDatabase{}, apRepo, nil, slog.Default()) - apSvc.glSvc = glSvc - - invID := uuid.New() - inv := &VendorInvoice{ID: invID, Total: 5000, AmountPaid: 0} - apRepo.invoices[invID] = inv - - req := CreateAPPaymentRequest{ - VendorID: uuid.New(), - Amount: 50.0, - Method: PaymentMethodCheck, - PaymentDate: "2026-03-03", - InvoiceIDs: []uuid.UUID{invID}, - } - - _, err := apSvc.PayVendor(context.Background(), req) - if err != nil { - t.Fatalf("PayVendor failed: %v", err) - } - - if len(glRepo.entries) != 1 { - t.Fatalf("expected 1 GL entry, got %d", len(glRepo.entries)) - } - - entry := glRepo.entries[0] - if entry.Source != gl.SourceVendorPmt { - t.Errorf("expected source %s, got %s", gl.SourceVendorPmt, entry.Source) - } -} diff --git a/core/internal/ap/handler.go b/core/internal/ap/handler.go index eb705bb7..1e1be4ec 100644 --- a/core/internal/ap/handler.go +++ b/core/internal/ap/handler.go @@ -6,13 +6,20 @@ package ap import ( "encoding/json" "net/http" + "strings" - "github.com/gablelbm/gable/pkg/httputil" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" ) -// Handler handles AP HTTP endpoints. +// The invoice list's ordering: created_at then id, newest first (ADR 0001 +// section 2). +const cursorScope = "ap.invoices.created_at_id_desc" + +// Handler handles the AP HTTP endpoints: the vendor invoice routes on the +// wire contract (ADR 0001, ADR 0008 7.4), and the payment and aging routes in +// their today shape until their own conversion (ADR 0008 section 11). type Handler struct { service *Service } @@ -22,8 +29,8 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// RegisterRoutes registers AP API routes. -// roleGuard protects all endpoints; pass middleware.RequireRole("admin","owner","finance") in production. +// RegisterRoutes registers the AP routes. roleGuard protects all endpoints; +// pass the branch wall's scoped("admin", "owner", "finance") in production. 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 { @@ -34,162 +41,296 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux, roleGuard ...func(http.Hand return handler } - // Vendor Invoices - mux.HandleFunc("POST /api/v1/ap/invoices", guard(h.CreateVendorInvoice)) - mux.HandleFunc("GET /api/v1/ap/invoices", guard(h.ListVendorInvoices)) - mux.HandleFunc("GET /api/v1/ap/invoices/{id}", guard(h.GetVendorInvoice)) - mux.HandleFunc("POST /api/v1/ap/invoices/{id}/approve", guard(h.ApproveInvoice)) + // Vendor invoices + mux.HandleFunc("POST /api/v1/ap/invoices", guard(h.HandleCreate)) + mux.HandleFunc("GET /api/v1/ap/invoices", guard(h.HandleList)) + mux.HandleFunc("GET /api/v1/ap/invoices/{id}", guard(h.HandleGet)) + mux.HandleFunc("POST /api/v1/ap/invoices/{id}/transitions", guard(h.HandleTransition)) // AP Payments - mux.HandleFunc("POST /api/v1/ap/payments", guard(h.PayVendor)) - mux.HandleFunc("GET /api/v1/ap/payments", guard(h.ListPayments)) + mux.HandleFunc("POST /api/v1/ap/payments", guard(h.HandlePayVendor)) + mux.HandleFunc("GET /api/v1/ap/payments", guard(h.HandleListPayments)) // Aging Report - mux.HandleFunc("GET /api/v1/ap/aging", guard(h.GetAgingSummary)) + mux.HandleFunc("GET /api/v1/ap/aging", guard(h.HandleGetAgingSummary)) } -func (h *Handler) CreateVendorInvoice(w http.ResponseWriter, r *http.Request) { - var req CreateVendorInvoiceRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) - return - } - - inv, err := h.service.CreateVendorInvoice(r.Context(), req) - if err != nil { - httputil.RespondError(w, r, "failed to create vendor invoice", http.StatusInternalServerError, err) - return - } - +func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(inv) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) } -func (h *Handler) GetVendorInvoice(w http.ResponseWriter, r *http.Request) { +func pathID(r *http.Request, what string) (uuid.UUID, error) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { - httputil.RespondError(w, r, "Invalid invoice ID", http.StatusBadRequest, err) - return + return uuid.Nil, httpx.BadRequest("invalid "+what+" id", httpx.FieldError{Field: "id", Message: "must be a UUID"}) } + return id, nil +} - inv, err := h.service.GetVendorInvoice(r.Context(), id) - if err != nil { - httputil.RespondError(w, r, "vendor invoice not found", http.StatusNotFound, err) - return +func noQuery(r *http.Request) error { + _, err := httpx.StrictQuery(r) + return err +} + +// caller reads who is acting from the auth chain: the audit subject. +func caller(r *http.Request) Caller { + if claims, ok := r.Context().Value(middleware.UserContextKey).(*middleware.UserClaims); ok && claims != nil { + return Caller{Actor: claims.Subject, Role: claims.Role} } + return Caller{} +} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(inv) +func cursorError() error { + return httpx.BadRequest("cursor keyset is malformed", httpx.FieldError{Field: "cursor", Message: "cursor keyset is malformed"}) } -func (h *Handler) ListVendorInvoices(w http.ResponseWriter, r *http.Request) { - var vendorID *uuid.UUID - if vid := r.URL.Query().Get("vendor_id"); vid != "" { - parsed, err := uuid.Parse(vid) - if err == nil { - vendorID = &parsed - } +func single(v *httpx.Validator, q map[string][]string, name string) (string, bool) { + vals := q[name] + if len(vals) == 0 { + return "", false } - status := r.URL.Query().Get("status") - - invoices, err := h.service.ListVendorInvoices(r.Context(), vendorID, status) - if err != nil { - httputil.RespondError(w, r, "failed to list vendor invoices", http.StatusInternalServerError, err) - return + if len(vals) > 1 { + v.Check(false, name, "parameter is repeated") + return "", false } + return vals[0], true +} - if invoices == nil { - invoices = []VendorInvoice{} +func uuidParam(v *httpx.Validator, q map[string][]string, name string) *uuid.UUID { + raw, ok := single(v, q, name) + if !ok { + return nil } + if id, ok := v.UUID(name, &raw, true); ok { + return &id + } + return nil +} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(invoices) +// parseListFilter reads the invoice list's filters beside the platform's +// cursor, limit and include: vendor_id, status and po_id. Unknown parameters +// and uppercase status values are refused. +func parseListFilter(r *http.Request) (f ListFilter, wantTotal bool, err error) { + q, err := httpx.StrictQuery(r, "cursor", "limit", "include", "vendor_id", "status", "po_id") + if err != nil { + return f, false, err + } + page, err := httpx.ParseListQuery(r, cursorScope) + if err != nil { + return f, false, err + } + f.Limit = page.Limit + v := &httpx.Validator{} + f.VendorID = uuidParam(v, q, "vendor_id") + f.POID = uuidParam(v, q, "po_id") + if raw, ok := single(v, q, "status"); ok { + for _, name := range strings.Split(raw, ",") { + st, ok := ParseStatus(name) + if !ok { + v.Check(false, "status", "must be a comma separated list of: pending, approved, partial, paid, voided") + break + } + f.Statuses = append(f.Statuses, st) + } + } + if raw, ok := single(v, q, "include"); ok { + set, ierr := httpx.ParseInclude(raw) + if ierr != nil { + return f, false, ierr + } + wantTotal = set.Has(httpx.IncludeTotal) + } + if err := v.Err(); err != nil { + return f, false, err + } + if page.Key != nil { + if len(page.Key) != 2 { + return f, false, cursorError() + } + t, terr := httpx.ParseKeyTime(page.Key[0]) + id, uerr := httpx.ParseKeyUUID(page.Key[1]) + if terr != nil || uerr != nil { + return f, false, cursorError() + } + f.AfterTime, f.AfterID = &t, id + } + return f, wantTotal, nil } -func (h *Handler) ApproveInvoice(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) +// HandleList answers GET /api/v1/ap/invoices: the cursor envelope. +func (h *Handler) HandleList(w http.ResponseWriter, r *http.Request) { + f, wantTotal, err := parseListFilter(r) if err != nil { - httputil.RespondError(w, r, "Invalid invoice ID", http.StatusBadRequest, err) + httpx.WriteError(w, r, err) return } - - // Extract approver ID from auth context - var approverID uuid.UUID - if claims := middleware.ClaimsFromContext(r.Context()); claims != nil { - parsed, err := uuid.Parse(claims.Subject) + items, hasMore, total, err := h.service.List(r.Context(), f, wantTotal) + if err != nil { + httpx.WriteError(w, r, err) + return + } + next := "" + if hasMore && len(items) > 0 { + last := items[len(items)-1] + next, err = httpx.MintCursor(cursorScope, httpx.FormatKeyTime(last.CreatedAt.Time), last.ID.String()) if err != nil { - httputil.RespondError(w, r, "Invalid user ID in token", http.StatusUnauthorized, err) + httpx.WriteError(w, r, err) return } - approverID = parsed - } else { - httputil.RespondError(w, r, "Authentication required", http.StatusUnauthorized, nil) - return } + var opts []httpx.ListOption + if total != nil { + opts = append(opts, httpx.WithTotal(*total)) + } + httpx.WriteList(w, items, next, f.Limit, opts...) +} - inv, err := h.service.ApproveInvoice(r.Context(), id, approverID) +// HandleGet answers GET /api/v1/ap/invoices/{id}. +func (h *Handler) HandleGet(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + id, err := pathID(r, "vendor invoice") if err != nil { - httputil.RespondError(w, r, "failed to approve invoice", http.StatusUnprocessableEntity, err) + httpx.WriteError(w, r, err) return } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(inv) + inv, err := h.service.Get(r.Context(), id) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteRevisionETag(w, inv.Revision) + writeJSON(w, http.StatusOK, inv) } -func (h *Handler) PayVendor(w http.ResponseWriter, r *http.Request) { - var req CreateAPPaymentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - httputil.RespondError(w, r, "Invalid request body", http.StatusBadRequest, err) +// HandleCreate answers POST /api/v1/ap/invoices: the bill starts pending. +func (h *Handler) HandleCreate(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) return } + var req CreateRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.Parse() + if err != nil { + httpx.WriteError(w, r, err) + return + } + inv, err := h.service.Create(r.Context(), in, caller(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteRevisionETag(w, inv.Revision) + w.Header().Set("Location", "/api/v1/ap/invoices/"+inv.ID.String()) + writeJSON(w, http.StatusCreated, inv) +} - pmt, err := h.service.PayVendor(r.Context(), req) +// HandleTransition answers POST /api/v1/ap/invoices/{id}/transitions: the +// approve and the void of ADR 0008 7.4, replacing the old /approve route. +func (h *Handler) HandleTransition(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + id, err := pathID(r, "vendor invoice") + if err != nil { + httpx.WriteError(w, r, err) + return + } + var req TransitionRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.Parse() + if err != nil { + httpx.WriteError(w, r, err) + return + } + inv, err := h.service.Transition(r.Context(), id, in, Precondition{IfMatch: r.Header.Get("If-Match"), Revision: in.Revision}, caller(r)) if err != nil { - httputil.RespondError(w, r, "failed to process vendor payment", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } + httpx.WriteRevisionETag(w, inv.Revision) + writeJSON(w, http.StatusOK, inv) +} - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(pmt) +// HandlePayVendor answers POST /api/v1/ap/payments: the payment applies only +// to approved or partial bills (ADR 0008 7.4). +func (h *Handler) HandlePayVendor(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } + var req PayRequest + if err := httpx.DecodeJSON(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + in, err := req.Parse() + if err != nil { + httpx.WriteError(w, r, err) + return + } + pmt, err := h.service.PayVendor(r.Context(), in, caller(r)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, pmt) } -func (h *Handler) ListPayments(w http.ResponseWriter, r *http.Request) { +// HandleListPayments answers GET /api/v1/ap/payments. +func (h *Handler) HandleListPayments(w http.ResponseWriter, r *http.Request) { + q, err := httpx.StrictQuery(r, "vendor_id") + if err != nil { + httpx.WriteError(w, r, err) + return + } + v := &httpx.Validator{} var vendorID *uuid.UUID - if vid := r.URL.Query().Get("vendor_id"); vid != "" { - parsed, err := uuid.Parse(vid) - if err == nil { - vendorID = &parsed + if raw, ok := single(v, q, "vendor_id"); ok { + if id, ok := v.UUID("vendor_id", &raw, true); ok { + vendorID = &id } } - + if err := v.Err(); err != nil { + httpx.WriteError(w, r, err) + return + } payments, err := h.service.ListPayments(r.Context(), vendorID) if err != nil { - httputil.RespondError(w, r, "failed to list AP payments", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - if payments == nil { payments = []APPayment{} } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(payments) + writeJSON(w, http.StatusOK, payments) } -func (h *Handler) GetAgingSummary(w http.ResponseWriter, r *http.Request) { +// HandleGetAgingSummary answers GET /api/v1/ap/aging. +func (h *Handler) HandleGetAgingSummary(w http.ResponseWriter, r *http.Request) { + if err := noQuery(r); err != nil { + httpx.WriteError(w, r, err) + return + } summary, err := h.service.GetAgingSummary(r.Context()) if err != nil { - httputil.RespondError(w, r, "failed to get AP aging summary", http.StatusInternalServerError, err) + httpx.WriteError(w, r, err) return } - if summary == nil { summary = []APAgingSummary{} } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(summary) + writeJSON(w, http.StatusOK, summary) } diff --git a/core/internal/ap/input.go b/core/internal/ap/input.go new file mode 100644 index 00000000..1499d8f5 --- /dev/null +++ b/core/internal/ap/input.go @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/google/uuid" +) + +func trim(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} + +func parseDate(v *httpx.Validator, field, raw string, required bool) (string, bool) { + if raw == "" { + if required { + v.Check(false, field, "is required as YYYY-MM-DD") + return "", false + } + return "", true + } + if _, err := time.Parse("2006-01-02", raw); err != nil { + v.Check(false, field, "must be YYYY-MM-DD") + return "", false + } + return raw, true +} + +// CreateLineRequest is one line of a bill. +type CreateLineRequest struct { + Description *string `json:"description"` + Quantity json.RawMessage `json:"quantity"` + UnitPriceTenThousand json.RawMessage `json:"unit_price_ten_thousandths"` + GLAccountID *string `json:"gl_account_id"` + PurchaseOrderLineID *string `json:"purchase_order_line_id"` + ProductID *string `json:"product_id"` +} + +// CreateRequest is the body of POST /api/v1/ap/invoices. +type CreateRequest struct { + VendorID *string `json:"vendor_id"` + BranchID *string `json:"branch_id"` + VendorInvoiceNumber *string `json:"vendor_invoice_number"` + InvoiceDate *string `json:"invoice_date"` + DueDate *string `json:"due_date"` + POID *string `json:"po_id"` + Currency *string `json:"currency"` + TaxCents json.RawMessage `json:"tax_cents"` + Notes *string `json:"notes"` + Lines []CreateLineRequest `json:"lines"` +} + +// LineInput is one validated line. The extension is priced by the service +// through httpx.Extend, never sent by the client. +type LineInput struct { + Description string + Quantity httpx.Quantity + UnitPrice httpx.Price + GLAccountID *uuid.UUID + PurchaseOrderLineID *uuid.UUID + ProductID *uuid.UUID + LineTotalCents httpx.Cents +} + +// Input is a validated bill ready for the service. +type Input struct { + VendorID uuid.UUID + BranchID *uuid.UUID + VendorInvoiceNumber string + InvoiceDate string + DueDate string + POID *uuid.UUID + Currency string + TaxCents httpx.Cents + Notes string + Lines []LineInput +} + +// Parse validates a create request in one pass: every problem is one field +// error with its full path (ADR 0001 section 4). The status is never in the +// request: a created bill starts pending. +func (req *CreateRequest) Parse() (*Input, error) { + v := &httpx.Validator{} + in := &Input{} + if id, ok := v.UUID("vendor_id", req.VendorID, true); ok { + in.VendorID = id + } + if req.BranchID != nil { + if id, ok := v.UUID("branch_id", req.BranchID, false); ok { + in.BranchID = &id + } + } + number := trim(req.VendorInvoiceNumber) + v.Check(number != "", "vendor_invoice_number", "is required") + v.Check(len(number) <= 64, "vendor_invoice_number", "must be at most 64 characters") + in.VendorInvoiceNumber = number + if d, ok := parseDate(v, "invoice_date", trim(req.InvoiceDate), true); ok { + in.InvoiceDate = d + } + if d, ok := parseDate(v, "due_date", trim(req.DueDate), true); ok { + in.DueDate = d + } + if req.POID != nil { + if id, ok := v.UUID("po_id", req.POID, false); ok { + in.POID = &id + } + } + currency := trim(req.Currency) + if currency == "" { + currency = "USD" // the service replaces this with the setting's default + } + v.Check(len(currency) == 3 && currency == strings.ToUpper(currency), "currency", "must be an ISO 4217 code of three capital letters") + in.Currency = currency + if n, ok := v.Int("tax_cents", req.TaxCents, false); ok { + v.Check(n >= 0, "tax_cents", "must not be negative") + v.Check(n <= maxBillCents, "tax_cents", "is more than a bill can hold") + in.TaxCents = httpx.Cents(n) + } + in.Notes = trim(req.Notes) + v.Check(len(in.Notes) <= 2000, "notes", "must be at most 2000 characters") + + v.Check(len(req.Lines) >= 1, "lines", "a bill carries at least one line") + var subtotal httpx.Cents + for i, l := range req.Lines { + path := fmt.Sprintf("lines[%d]", i) + line := LineInput{} + desc := trim(l.Description) + v.Check(desc != "", path+".description", "is required") + v.Check(len(desc) <= 256, path+".description", "must be at most 256 characters") + line.Description = desc + if q, ok := v.Quantity(path+".quantity", l.Quantity, true); ok { + v.Check(q > 0, path+".quantity", "must be more than zero") + line.Quantity = q + } + if p, ok := v.Int(path+".unit_price_ten_thousandths", l.UnitPriceTenThousand, true); ok { + v.Check(p >= 0, path+".unit_price_ten_thousandths", "must not be negative") + v.Check(p <= maxUnitPriceTenThousandths, path+".unit_price_ten_thousandths", "is more than a line price can hold") + line.UnitPrice = httpx.Price(p) + } + if l.GLAccountID != nil { + if id, ok := v.UUID(path+".gl_account_id", l.GLAccountID, false); ok { + line.GLAccountID = &id + } + } + v.Check(line.GLAccountID != nil, path+".gl_account_id", "is required: the approve entry debits each line's account") + if l.PurchaseOrderLineID != nil { + if id, ok := v.UUID(path+".purchase_order_line_id", l.PurchaseOrderLineID, false); ok { + line.PurchaseOrderLineID = &id + } + } + if l.ProductID != nil { + if id, ok := v.UUID(path+".product_id", l.ProductID, false); ok { + line.ProductID = &id + } + } + if line.GLAccountID != nil && line.Quantity != 0 && line.UnitPrice >= 0 && line.UnitPrice <= maxUnitPriceTenThousandths { + total, err := httpx.Extend(line.Quantity, 1, 1, line.UnitPrice) + switch { + case err != nil: + v.Check(false, path+".quantity", err.Error()) + case total > maxBillCents: + v.Check(false, path+".quantity", "the line total is more than a bill can hold") + default: + line.LineTotalCents = total + subtotal += total + } + } + in.Lines = append(in.Lines, line) + } + // The subtotal and the total land in NUMERIC(12,2) columns too. Each is + // checked only once its parts are known to fit, so the field named is the + // one that tipped it over. + if subtotal > maxBillCents { + v.Check(false, "lines", "the lines total more than a bill can hold") + } else if in.TaxCents <= maxBillCents && subtotal+in.TaxCents > maxBillCents { + v.Check(false, "tax_cents", "the lines and the tax total more than a bill can hold") + } + if err := v.Err(); err != nil { + return nil, err + } + return in, nil +} + +// TransitionRequest is the body of POST /api/v1/ap/invoices/{id}/transitions. +type TransitionRequest struct { + To *string `json:"to"` + Revision json.RawMessage `json:"revision"` + Reason *string `json:"reason"` +} + +// TransitionInput is a validated transition: the target status, the client's +// revision and the reason a void asks for. +type TransitionInput struct { + To Status + Revision *int64 + Reason string +} + +// Parse validates a transition request in one pass. +func (req *TransitionRequest) Parse() (*TransitionInput, error) { + v := &httpx.Validator{} + in := &TransitionInput{} + if req.To == nil { + v.Check(false, "to", "is required") + } else if s, ok := ParseStatus(*req.To); !ok { + v.Check(false, "to", "must be one of: pending, approved, partial, paid, voided") + } else { + in.To = s + } + if n, ok := v.Int("revision", req.Revision, false); ok { + v.Check(n >= 1, "revision", "must be a revision number, 1 or more") + in.Revision = &n + } + in.Reason = trim(req.Reason) + v.Check(len(in.Reason) <= 500, "reason", "must be at most 500 characters") + if err := v.Err(); err != nil { + return nil, err + } + return in, nil +} + +// PayRequest is the body of POST /api/v1/ap/payments. The money is integer +// cents on the wire, like every other money field (a decimal is a validation +// failure, never a silent rounding); the rest of the shape keeps its today +// form until the payment routes' own conversion (ADR 0008 section 11). +type PayRequest struct { + VendorID *string `json:"vendor_id"` + AmountCents json.RawMessage `json:"amount_cents"` + Method *string `json:"method"` + CheckNumber *string `json:"check_number"` + Reference *string `json:"reference"` + PaymentDate *string `json:"payment_date"` + InvoiceIDs []string `json:"invoice_ids"` +} + +// PayInput is a validated payment ready for the service. +type PayInput struct { + VendorID uuid.UUID + AmountCents int64 + Method PaymentMethod + CheckNumber string + Reference string + PaymentDate string + InvoiceIDs []uuid.UUID +} + +// maxPaymentCents is the most the payment columns (NUMERIC(12,2)) hold. +const maxPaymentCents = 999_999_999_999 + +// maxBillCents is the most a bill's money columns (line total, subtotal, tax +// and total, all NUMERIC(12,2)) hold; maxUnitPriceTenThousandths is the most +// the line's unit price (NUMERIC(12,4)) holds. +const ( + maxBillCents = 999_999_999_999 + maxUnitPriceTenThousandths = 999_999_999_999 +) + +// Parse validates a pay request in one pass. +func (req *PayRequest) Parse() (*PayInput, error) { + v := &httpx.Validator{} + in := &PayInput{} + if id, ok := v.UUID("vendor_id", req.VendorID, true); ok { + in.VendorID = id + } + if n, ok := v.Int("amount_cents", req.AmountCents, true); ok { + v.Check(n > 0, "amount_cents", "must be more than zero") + v.Check(n <= maxPaymentCents, "amount_cents", "is more than a payment can hold") + in.AmountCents = n + } + if req.Method == nil { + v.Check(false, "method", "is required") + } else { + switch PaymentMethod(strings.ToUpper(trim(req.Method))) { + case PaymentMethodCheck, PaymentMethodACH, PaymentMethodWire: + in.Method = PaymentMethod(strings.ToUpper(trim(req.Method))) + default: + v.Check(false, "method", "must be one of: CHECK, ACH, WIRE") + } + } + in.CheckNumber = trim(req.CheckNumber) + in.Reference = trim(req.Reference) + if d, ok := parseDate(v, "payment_date", trim(req.PaymentDate), true); ok { + in.PaymentDate = d + } + for i, raw := range req.InvoiceIDs { + id, ok := v.UUID(fmt.Sprintf("invoice_ids[%d]", i), &raw, true) + if ok { + in.InvoiceIDs = append(in.InvoiceIDs, id) + } + } + // A repeated id would apply the bill twice off one locked snapshot (only + // the amount_open CHECK would stop it, as a 500), so it is refused here. + seen := make(map[uuid.UUID]bool, len(in.InvoiceIDs)) + for i, id := range in.InvoiceIDs { + if seen[id] { + v.Check(false, fmt.Sprintf("invoice_ids[%d]", i), "appears twice") + continue + } + seen[id] = true + } + if err := v.Err(); err != nil { + return nil, err + } + return in, nil +} diff --git a/core/internal/ap/migration106_test.go b/core/internal/ap/migration106_test.go new file mode 100644 index 00000000..e6cd37a0 --- /dev/null +++ b/core/internal/ap/migration106_test.go @@ -0,0 +1,694 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// Migration 106 on rows that exist (the module recipe's step 3, ADR 0008 +// section 12): the schema up to 102 is built in a scratch database, awkward +// legacy bills are written in the shape the base left them (duplicate vendor +// numbers, an orphan po_id, a NULL created_at, odd statuses, paid and partial +// rows), then 106 is applied and every backfill is read back: the duplicate +// suffixes in (created_at, id) order, the orphan set null, the NULL created_at +// filled, the statuses normalized, the numbers minted and unique, the branch +// and currency filled, amount_open computed, the constraints in force. The +// migration applies a second time (a no-op), the down file rolls the shape +// back (refusing while a unit price is finer than cents), and the migration +// applies again to the same result. + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// scratch106 builds a scratch database and returns its connection, its URL +// (for the seed command) and the notices the connection has collected. +func scratch106(t *testing.T) (*pgx.Conn, string, *[]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 { + admin.Close(ctx) + t.Skipf("cannot create a scratch database: %v", err) + } + name := "gv1_c41bmig_" + 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 + cfg, err := pgx.ParseConfig(scratch.String()) + if err != nil { + t.Fatal(err) + } + var notices []string + cfg.OnNotice = func(_ *pgconn.PgConn, n *pgconn.Notice) { notices = append(notices, n.Message) } + conn, err := pgx.ConnectConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn, scratch.String(), ¬ices +} + +func files106(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.HasSuffix(base, "_down.sql"): + case strings.HasPrefix(base, "106_"): + target = f + case base < "106_": + before = append(before, f) + } + } + if target == "" { + t.Fatal("migration 106 not found") + } + return before, target, "../../migrations/down/106_ap_wire_contract_down.sql" +} + +func apply106(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 one106(t *testing.T, conn *pgx.Conn, sql string, args ...any) string { + t.Helper() + var v string + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&v); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return v +} + +func count106(t *testing.T, conn *pgx.Conn, sql string, args ...any) int { + t.Helper() + var n int + if err := conn.QueryRow(context.Background(), sql, args...).Scan(&n); err != nil { + t.Fatalf("%s: %v", sql, err) + } + return n +} + +// refuses asserts the named constraint refuses the statement. +func refuses106(t *testing.T, conn *pgx.Conn, constraint, sql string, args ...any) { + t.Helper() + ctx := context.Background() + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(ctx) + _, err = tx.Exec(ctx, sql, args...) + var pg *pgconn.PgError + if err == nil || !errors.As(err, &pg) || pg.ConstraintName != constraint { + t.Errorf("expected constraint %s to refuse, got %v", constraint, err) + } +} + +func TestMigration106_OnAwkwardLegacyRows(t *testing.T) { + conn, _, notices := scratch106(t) + before, target, down := files106(t) + for _, f := range before { + apply106(t, conn, f) + } + + // The awkward legacy rows: duplicates of one vendor number (three, with + // distinct created_at so the suffix order is checkable), an orphan po_id, + // a NULL created_at (the base column was NOT NULL, so the NOT NULL comes + // off first to write one), odd statuses, and paid, partial and voided rows. + ctx := context.Background() + var vendor uuid.UUID + if err := conn.QueryRow(ctx, `INSERT INTO vendors (id, name) VALUES ($1, 'Mig 106 Vendor') RETURNING id`, uuid.New()).Scan(&vendor); err != nil { + t.Fatal(err) + } + insert := func(id uuid.UUID, number, status, createdAt string, poID *uuid.UUID, total, paid string) { + t.Helper() + var po any + if poID != nil { + po = *poID + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoices (id, vendor_id, invoice_number, invoice_date, due_date, po_id, + subtotal, tax_amount, total, amount_paid, status, created_at) + VALUES ($1, $2, $3, '2026-01-10', '2026-02-10', $4, $5, 0, $5, $6, $7, $8)`, + id, vendor, number, po, total, paid, status, createdAt); err != nil { + t.Fatalf("seed %s: %v", number, err) + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoice_lines (id, invoice_id, description, quantity, unit_price, line_total, created_at) + VALUES ($1, $2, 'legacy line', 1, $3, $3, $4)`, uuid.New(), id, total, createdAt); err != nil { + t.Fatalf("seed line %s: %v", number, err) + } + } + at := func(days int) string { return time.Now().AddDate(0, 0, days).Format(time.RFC3339) } + orphan := uuid.New() + dup1, dup2, dup3 := uuid.New(), uuid.New(), uuid.New() + insert(dup1, "DUP-1", "PENDING", at(-9), nil, "100.00", "0") + insert(dup2, "DUP-1", "pending", at(-8), nil, "100.00", "0") // lowercase: normalized + insert(dup3, "DUP-1", "WEIRD", at(-7), nil, "100.00", "0") // unknown: becomes PENDING + // A genuine suffixed number the vendor already held: the suffix pass must + // step past it (#3, #4) rather than collide and roll the migration back. + genuine := uuid.New() + insert(genuine, "DUP-1 #2", "PENDING", at(-6), nil, "100.00", "0") + nailed := uuid.New() + insert(nailed, "PAID-1", "PAID", at(-6), nil, "50.00", "50.00") + partial := uuid.New() + insert(partial, "PART-1", "PARTIAL", at(-5), nil, "80.00", "30.00") + voided := uuid.New() + insert(voided, "VOID-1", "VOIDED", at(-4), &orphan, "70.00", "0") + // A hand-written cancel-like status maps to the module's voided state, not + // to pending (a cancelled bill owes nothing); a short-paid PAID bill and an + // overpaid PARTIAL one take the insert trigger's amount_open rule (a PAID + // bill owes nothing), each named by a notice. + cancelled := uuid.New() + insert(cancelled, "CANC-1", "CANCELLED", at(-4), nil, "60.00", "0") + paidShort := uuid.New() + insert(paidShort, "PAIDSHORT-1", "PAID", at(-4), nil, "50.00", "44.00") + over := uuid.New() + insert(over, "OVER-1", "PARTIAL", at(-4), nil, "80.00", "90.00") + // A negative total: the base validated no sign, so a vendor credit + // entered as a negative bill is a plausible legacy row. It owes nothing + // as a bill: it becomes VOIDED with open 0, named by a notice, and a + // negative total already VOIDED passes the amount_open range CHECK; one + // of either shape must not abort the migration (review r4 P2-2). + negative := uuid.New() + insert(negative, "NEG-1", "PENDING", at(-3), nil, "-10.00", "0") + negVoided := uuid.New() + insert(negVoided, "NEG-2", "VOIDED", at(-3), nil, "-5.00", "0") + // A negative amount paid on a positive bill (a hand edit: the base's own + // pay never wrote one) would open the bill past its total: open is + // clamped to the total and the row is named by a notice, so the range + // CHECK does not abort the migration (review r6 P3-1). An APPROVED bill + // paid past its total is counted by the overpaid notice beside the + // PARTIAL one (review r6 P3-3). + negPaid := uuid.New() + insert(negPaid, "NEGPAID-1", "APPROVED", at(-3), nil, "8.00", "-3.00") + overApproved := uuid.New() + insert(overApproved, "OVERAPP-1", "APPROVED", at(-3), nil, "9.00", "11.00") + // A NULL created_at: the base column was NOT NULL, so it comes off first + // (the migration's own fill-then-set step then has a row to fill). + if _, err := conn.Exec(ctx, `ALTER TABLE vendor_invoices ALTER COLUMN created_at DROP NOT NULL`); err != nil { + t.Fatal(err) + } + nulled := uuid.New() + insert(nulled, "NULL-CREATED", "PENDING", at(-3), nil, "10.00", "0") + if _, err := conn.Exec(ctx, `UPDATE vendor_invoices SET created_at = NULL WHERE id = $1`, nulled); err != nil { + t.Fatal(err) + } + + rowsBefore := count106(t, conn, `SELECT count(*) FROM vendor_invoices`) + + apply106(t, conn, target) + + // Counts before and after: no row lost or gained. + if rowsAfter := count106(t, conn, `SELECT count(*) FROM vendor_invoices`); rowsAfter != rowsBefore { + t.Fatalf("%d rows before, %d after", rowsBefore, rowsAfter) + } + + // The duplicates are suffixed in (created_at, id) order: the oldest keeps + // the bare number. + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup1); got != "DUP-1" { + t.Errorf("the oldest duplicate = %q, want the bare DUP-1", got) + } + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup2); got != "DUP-1 #3" { + t.Errorf("the second duplicate = %q, want DUP-1 #3, the next free suffix", got) + } + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup3); got != "DUP-1 #4" { + t.Errorf("the third duplicate = %q, want DUP-1 #4", got) + } + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, genuine); got != "DUP-1 #2" { + t.Errorf("the genuine suffixed number = %q, want it kept as DUP-1 #2", got) + } + + // The orphan po_id is set null; the others keep theirs. + if got := one106(t, conn, `SELECT COALESCE(po_id::text, 'null') FROM vendor_invoices WHERE id = $1`, voided); got != "null" { + t.Errorf("the orphan po_id = %s, want null", got) + } + + // The NULL created_at is filled. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE created_at IS NULL`); got != 0 { + t.Errorf("%d rows still hold a NULL created_at", got) + } + + // The odd statuses are normalized, with notices. + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, dup2); got != "PENDING" { + t.Errorf("the lowercase status = %s, want PENDING", got) + } + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, dup3); got != "PENDING" { + t.Errorf("the unknown status = %s, want PENDING", got) + } + joined := strings.Join(*notices, " | ") + for _, want := range []string{"duplicate vendor invoice number", "po_id is set null", "unknown status", + "cancel-like status", "paid less than their total", "2 APPROVED or PARTIAL vendor invoice(s) were paid past their total", + "negative total", "1 vendor invoice(s) carry a negative amount paid"} { + if !strings.Contains(joined, want) { + t.Errorf("the notices (%s) do not mention %q", joined, want) + } + } + + // The numbers: unique, AP-, and the sequence sits past the backfill. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE number !~ '^AP-[0-9]+$'`); got != 0 { + t.Errorf("%d rows hold no AP- number", got) + } + if got := count106(t, conn, `SELECT count(DISTINCT number) - count(*) FROM vendor_invoices`); got != 0 { + t.Errorf("%d duplicate numbers", got) + } + // The backfill order is (created_at, id): the oldest bill holds the + // smallest number. + if got := one106(t, conn, `SELECT number FROM vendor_invoices WHERE id = $1`, dup1); got != "AP-000001" { + t.Errorf("the oldest bill's number = %s, want AP-000001", got) + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoices (vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total) + VALUES ($1, 'AFTER-1', '2026-01-10', '2026-02-10', 1, 0, 1) RETURNING id`, vendor); err != nil { + t.Fatal(err) + } + if got := one106(t, conn, `SELECT number FROM vendor_invoices WHERE invoice_number = 'AFTER-1'`); got != fmt.Sprintf("AP-%06d", rowsBefore+1) { + t.Errorf("a bill created after the migration = %s, want the next number past the backfill", got) + } + + // The branch and the currency are filled. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE branch_id IS NULL OR currency IS NULL`); got != 0 { + t.Errorf("%d rows hold no branch or currency", got) + } + def := one106(t, conn, `SELECT value FROM system_settings WHERE key = 'default_branch_id'`) + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE branch_id::text <> $1`, def); got != 0 { + t.Errorf("%d rows are not at the default branch (no purchase order branch exists yet)", got) + } + + // amount_open: total less paid, zero on a voided bill and on a paid one + // (the insert trigger's rule), clamped at zero for an overpaid partial. + // A negative total bill owes nothing: it is VOIDED with open 0 (the + // range CHECK admits a voided or paid bill at any total). + for id, want := range map[uuid.UUID]string{dup1: "100.00", nailed: "0.00", partial: "50.00", voided: "0.00", + cancelled: "0.00", paidShort: "0.00", over: "0.00", negative: "0.00", negVoided: "0.00", + negPaid: "8.00", overApproved: "0.00"} { + if got := one106(t, conn, `SELECT amount_open::text FROM vendor_invoices WHERE id = $1`, id); got != want { + t.Errorf("amount_open of %s = %s, want %s", id, got, want) + } + } + // The cancel-like status becomes VOIDED, not PENDING. + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, cancelled); got != "VOIDED" { + t.Errorf("the cancel-like status = %s, want VOIDED", got) + } + // The negative total bill becomes VOIDED too. + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, negative); got != "VOIDED" { + t.Errorf("the negative total bill = %s, want VOIDED", got) + } + + // The lines: positioned in (created_at, id) order, and the price widened. + if got := one106(t, conn, `SELECT position::text FROM vendor_invoice_lines l JOIN vendor_invoices v ON v.id = l.invoice_id WHERE v.id = $1`, dup1); got != "0" { + t.Errorf("the single legacy line's position = %s, want 0", got) + } + + // The constraints refuse what the old column took. + refuses106(t, conn, "vendor_invoices_vendor_number_key", + `INSERT INTO vendor_invoices (vendor_id, invoice_number, invoice_date, due_date, subtotal, tax_amount, total) VALUES ($1, 'DUP-1', '2026-01-10', '2026-02-10', 1, 0, 1)`, vendor) + refuses106(t, conn, "vendor_invoices_status_check", + `UPDATE vendor_invoices SET status = 'WEIRD' WHERE id = $1`, dup1) + // A voided or paid bill owes nothing (review r5 P3-2): the range CHECK + // refuses one with an open amount, as the trigger's rule says. + refuses106(t, conn, "vendor_invoices_amount_open_range", + `UPDATE vendor_invoices SET status = 'PAID' WHERE id = $1`, partial) + refuses106(t, conn, "vendor_invoices_po_id_fkey", + `UPDATE vendor_invoices SET po_id = $2 WHERE id = $1`, dup1, uuid.New()) + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoice_lines WHERE purchase_order_line_id IS NOT NULL OR product_id IS NOT NULL OR po_freight_charge_id IS NOT NULL`); got != 0 { + t.Errorf("%d legacy lines were linked to something", got) + } + + // A second up is a no-op: every count and value stands. + numbersBefore := one106(t, conn, `SELECT string_agg(number, ',' ORDER BY number) FROM vendor_invoices`) + apply106(t, conn, target) + if numbersAfter := one106(t, conn, `SELECT string_agg(number, ',' ORDER BY number) FROM vendor_invoices`); numbersAfter != numbersBefore { + t.Errorf("a second apply changed the numbers: %s -> %s", numbersBefore, numbersAfter) + } + + // The down rolls the shape back. + apply106(t, conn, down) + for _, column := range []string{"number", "revision", "branch_id", "currency", "amount_open"} { + if got := count106(t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'vendor_invoices' AND column_name = $1`, column); got != 0 { + t.Errorf("the down left %s on vendor_invoices", column) + } + } + if got := count106(t, conn, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'vendor_invoice_lines' AND column_name = 'position'`); got != 0 { + t.Error("the down left position on vendor_invoice_lines") + } + if got := one106(t, conn, `SELECT data_type FROM information_schema.columns WHERE table_name = 'vendor_invoice_lines' AND column_name = 'unit_price'`); got != "numeric" { + t.Error("the down left unit_price's type changed") + } + if got := one106(t, conn, `SELECT numeric_scale::text FROM information_schema.columns WHERE table_name = 'vendor_invoice_lines' AND column_name = 'unit_price'`); got != "2" { + t.Errorf("the down left unit_price at scale %s, want 2", got) + } + // Three of the up's writes are kept, each valid old shape data. + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dup2); got != "DUP-1 #3" { + t.Errorf("the down un-suffixed a duplicate: %s", got) + } + + // The down refuses while a unit price is finer than cents. + apply106(t, conn, target) + if _, err := conn.Exec(ctx, `UPDATE vendor_invoice_lines SET unit_price = 1.0005`); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(context.Background(), func() string { + b, _ := os.ReadFile(down) + return string(b) + }()); err == nil { + t.Error("the down applied though a unit price is finer than cents") + } + // Clean that row, and the down applies; the up then applies again to the + // same result. + if _, err := conn.Exec(ctx, `UPDATE vendor_invoice_lines SET unit_price = 1.00`); err != nil { + t.Fatal(err) + } + apply106(t, conn, down) + apply106(t, conn, target) + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE number IS NULL OR amount_open IS NULL`); got != 0 { + t.Errorf("%d rows incomplete after the down-up cycle", got) + } +} + +// TestMigration106_LinksLegacyApprovedEntries proves the gl_entry_id backfill: +// a bill the base approved posted its entry through the removed sync (source +// VENDOR_INVOICE, source_ref_id the bill), and 106 links the newest posted, +// unreversed of those entries to every approved or partial bill. A bill whose +// only entry is a reversal, or that has none, stays unlinked and is counted by +// the notice, and a pending bill is never linked (nothing was approved). A +// bill whose entry is the base's real one leg shape (the Accounts Payable +// credit with no debit: the base loaded no lines) is never linked either, and +// its own notice names it, for the void would mirror the one leg entry into +// another unbalanced one. +func TestMigration106_LinksLegacyApprovedEntries(t *testing.T) { + conn, _, notices := scratch106(t) + before, target, _ := files106(t) + for _, f := range before { + apply106(t, conn, f) + } + + ctx := context.Background() + var vendor uuid.UUID + if err := conn.QueryRow(ctx, `INSERT INTO vendors (id, name) VALUES ($1, 'Mig 106 Legacy Vendor') RETURNING id`, uuid.New()).Scan(&vendor); err != nil { + t.Fatal(err) + } + var expense, payable string + if err := conn.QueryRow(ctx, `SELECT id::text FROM gl_accounts WHERE code = '5020'`).Scan(&expense); err != nil { + t.Fatalf("expense account: %v", err) + } + if err := conn.QueryRow(ctx, `SELECT id::text FROM gl_accounts WHERE code = '2010'`).Scan(&payable); err != nil { + t.Fatalf("payable account: %v", err) + } + // entry writes a posted VENDOR_INVOICE entry of a flat 10.00 for the bill, + // the shape the base's removed sync left, at the given time. + entry := func(bill uuid.UUID, at string, reverses *uuid.UUID) uuid.UUID { + t.Helper() + id := uuid.New() + var rev any + if reverses != nil { + rev = *reverses + } + if _, err := conn.Exec(ctx, `INSERT INTO gl_journal_entries (id, entry_date, memo, source, source_ref_id, status, posted_by, reverses_entry_id, created_at, updated_at, currency) + VALUES ($1, '2026-01-10', 'legacy approve', 'VENDOR_INVOICE', $2, 'POSTED', 'legacy', $3, $4, $4, 'USD')`, + id, bill, rev, at); err != nil { + t.Fatalf("seed entry: %v", err) + } + for _, l := range []struct { + account, debit, credit string + }{{expense, "10.00", "0"}, {payable, "0", "10.00"}} { + if _, err := conn.Exec(ctx, `INSERT INTO gl_journal_lines (id, journal_entry_id, account_id, description, debit, credit) + VALUES ($1, $2, $3::uuid, 'legacy leg', $4, $5)`, uuid.New(), id, l.account, l.debit, l.credit); err != nil { + t.Fatalf("seed entry line: %v", err) + } + } + return id + } + // oneLegEntry writes the base's real approve entry: one leg, the Accounts + // Payable credit for the total and no debit (the base read the bill without + // its lines, so the removed sync posted a single credit leg, and nothing + // stopped it: chk_debit_or_credit accepts one leg). This, not a balanced + // two leg entry, is the shape every base approved bill holds. + oneLegEntry := func(bill uuid.UUID, at string) uuid.UUID { + t.Helper() + id := uuid.New() + if _, err := conn.Exec(ctx, `INSERT INTO gl_journal_entries (id, entry_date, memo, source, source_ref_id, status, posted_by, created_at, updated_at, currency) + VALUES ($1, '2026-01-10', 'legacy approve', 'VENDOR_INVOICE', $2, 'POSTED', 'legacy', $3, $3, 'USD')`, + id, bill, at); err != nil { + t.Fatalf("seed one leg entry: %v", err) + } + if _, err := conn.Exec(ctx, `INSERT INTO gl_journal_lines (id, journal_entry_id, account_id, description, debit, credit) + VALUES ($1, $2, $3::uuid, 'legacy leg', 0, '10.00')`, uuid.New(), id, payable); err != nil { + t.Fatalf("seed one leg entry line: %v", err) + } + return id + } + insert := func(id uuid.UUID, number, status string) { + t.Helper() + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoices (id, vendor_id, invoice_number, invoice_date, due_date, + subtotal, tax_amount, total, amount_paid, status, created_at) + VALUES ($1, $2, $3, '2026-01-10', '2026-02-10', '10.00', 0, '10.00', '4.00', $4, NOW())`, + id, vendor, number, status); err != nil { + t.Fatalf("seed %s: %v", number, err) + } + } + oldTime := time.Now().AddDate(0, 0, -10).Format(time.RFC3339) + newTime := time.Now().AddDate(0, 0, -2).Format(time.RFC3339) + + // An approved bill with two posted entries: the newest is linked. + linked, unlinked, pending := uuid.New(), uuid.New(), uuid.New() + insert(linked, "LEG-LINKED", "APPROVED") + older := entry(linked, oldTime, nil) + newest := entry(linked, newTime, nil) + // A partial bill whose only entry is a reversal of another bill's entry: + // not linkable, so it stays unlinked and is counted. + insert(unlinked, "LEG-UNLINKED", "PARTIAL") + elsewhere := uuid.New() + stranger := entry(elsewhere, oldTime, nil) + entry(unlinked, newTime, &stranger) + // A pending bill with a posted entry: never linked, and not counted. + insert(pending, "LEG-PENDING", "PENDING") + entry(pending, newTime, nil) + // The base's real approved bill: a one leg entry, the Accounts Payable + // credit with no debit. Linking it would let the void mirror it into + // another unbalanced entry, so it stays unlinked and its own notice + // names it, apart from the bills with nothing to link. + onelegged := uuid.New() + insert(onelegged, "LEG-ONELEG", "APPROVED") + oneLegEntry(onelegged, newTime) + + // A hand cancelled bill still holding a posted entry: it becomes VOIDED + // with its credit standing, and a notice counts it. + cancelled := uuid.New() + insert(cancelled, "LEG-CANCELLED", "CANCELLED") + entry(cancelled, newTime, nil) + + apply106(t, conn, target) + + if got := one106(t, conn, `SELECT COALESCE(gl_entry_id::text, 'null') FROM vendor_invoices WHERE id = $1`, linked); got != newest.String() { + t.Errorf("the approved bill links %s, want the newest entry %s", got, newest) + } + if older == newest { + t.Fatal("the two entries share an id") + } + if got := one106(t, conn, `SELECT COALESCE(gl_entry_id::text, 'null') FROM vendor_invoices WHERE id = $1`, unlinked); got != "null" { + t.Errorf("the bill whose only entry is a reversal links %s, want none", got) + } + if got := one106(t, conn, `SELECT COALESCE(gl_entry_id::text, 'null') FROM vendor_invoices WHERE id = $1`, pending); got != "null" { + t.Errorf("the pending bill links %s, want none", got) + } + if got := one106(t, conn, `SELECT COALESCE(gl_entry_id::text, 'null') FROM vendor_invoices WHERE id = $1`, onelegged); got != "null" { + t.Errorf("the bill whose entry has one leg links %s, want none: a one leg entry must not be linked", got) + } + joined := strings.Join(*notices, " | ") + if !strings.Contains(joined, "no journal entry to link") { + t.Errorf("the notices (%s) do not name the unlinked approved bills", joined) + } + if !strings.Contains(joined, "1 voided or cancelled vendor invoice(s) still hold a posted, unreversed journal entry") { + t.Errorf("the notices (%s) do not count the cancelled bill that holds an entry", joined) + } + if !strings.Contains(joined, "1 vendor invoice(s) were posted more than once") { + t.Errorf("the notices (%s) do not count the bill posted twice", joined) + } + if !strings.Contains(joined, "one leg entry") { + t.Errorf("the notices (%s) do not name the one leg entry bills", joined) + } + // A second apply changes nothing. + apply106(t, conn, target) + if got := one106(t, conn, `SELECT COALESCE(gl_entry_id::text, 'null') FROM vendor_invoices WHERE id = $1`, linked); got != newest.String() { + t.Errorf("a second apply moved the link to %s", got) + } +} + +// TestMigration106_OnTheSeededDatabase applies 106 to a database seeded by +// the repository's own seed (the recipe's rule: a migration is proved on a +// seeded database, not only on fixtures), with awkward legacy bills written +// over the seed. Only this proof reaches the branch backfill's purchase +// order source: the seed writes purchase orders on the yards, so a bill +// tied to a real purchase order takes that order's branch, not the default +// branch every fixture row falls to. Then the down rolls the shape back, +// the up applies again to the same result, and a second up is a no-op. +func TestMigration106_OnTheSeededDatabase(t *testing.T) { + conn, scratchURL, _ := scratch106(t) + before, target, down := files106(t) + for _, f := range before { + apply106(t, conn, f) + } + // The seed, as an operator runs it: the same binary, the scratch + // database's URL on its own command. + cmd := exec.Command("go", "run", "./cmd/seed") + cmd.Dir = "../.." + cmd.Env = append(os.Environ(), "DEMO_SEED=1", "DATABASE_URL="+scratchURL) + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("the seed did not run here (%v): %s", err, out) + } + + ctx := context.Background() + // A seeded vendor and a seeded purchase order on a branch other than the + // default (the seed rotates the yards; a run that mints none makes this + // proof vacuous, so it refuses instead). + var vendor, poID, poBranch, def string + if err := conn.QueryRow(ctx, `SELECT id::text FROM vendors ORDER BY name LIMIT 1`).Scan(&vendor); err != nil { + t.Fatal(err) + } + if err := conn.QueryRow(ctx, ` + SELECT po.id::text, po.branch_id::text FROM purchase_orders po + JOIN locations l ON l.id = po.branch_id + WHERE po.branch_id::text <> (SELECT value FROM system_settings WHERE key = 'default_branch_id') + ORDER BY po.created_at, po.id LIMIT 1`).Scan(&poID, &poBranch); err != nil { + t.Fatalf("no seeded purchase order sits on a branch other than the default: %v", err) + } + def = one106(t, conn, `SELECT value FROM system_settings WHERE key = 'default_branch_id'`) + if poBranch == def { + t.Fatal("the chosen purchase order sits on the default branch") + } + + // The awkward legacy rows over the seed: a bill tied to the real + // purchase order (its branch comes from the order), a duplicate pair, + // an orphan po_id and a lowercase status. + insert := func(id uuid.UUID, number, status, poRef, createdAt string) { + t.Helper() + var po any + if poRef != "" { + po = poRef + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoices (id, vendor_id, invoice_number, invoice_date, due_date, po_id, + subtotal, tax_amount, total, amount_paid, status, created_at) + VALUES ($1, $2, $3, '2026-01-10', '2026-02-10', $4, '40.00', 0, '40.00', 0, $5, $6)`, + id, vendor, number, po, status, createdAt); err != nil { + t.Fatalf("seed %s: %v", number, err) + } + if _, err := conn.Exec(ctx, `INSERT INTO vendor_invoice_lines (id, invoice_id, description, quantity, unit_price, line_total, created_at) + VALUES ($1, $2, 'seeded legacy line', 1, '40.00', '40.00', $3)`, uuid.New(), id, createdAt); err != nil { + t.Fatalf("seed line %s: %v", number, err) + } + } + at := func(days int) string { return time.Now().AddDate(0, 0, days).Format(time.RFC3339) } + poBill, dupA, dupB, orphan, lower := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() + insert(poBill, "SEED-PO", "PENDING", poID, at(-6)) + insert(dupA, "SEED-DUP", "PENDING", "", at(-5)) + insert(dupB, "SEED-DUP", "pending", "", at(-4)) + insert(orphan, "SEED-ORPHAN", "PENDING", uuid.New().String(), at(-3)) + insert(lower, "SEED-LOWER", "partial", "", at(-2)) + rowsBefore := count106(t, conn, `SELECT count(*) FROM vendor_invoices`) + + apply106(t, conn, target) + + if rowsAfter := count106(t, conn, `SELECT count(*) FROM vendor_invoices`); rowsAfter != rowsBefore { + t.Fatalf("%d rows before, %d after", rowsBefore, rowsAfter) + } + // The bill tied to the purchase order takes the order's branch, not the + // default branch. + if got := one106(t, conn, `SELECT branch_id::text FROM vendor_invoices WHERE id = $1`, poBill); got != poBranch { + t.Errorf("the purchase order bill's branch = %s, want the order's %s", got, poBranch) + } + // Every other row still holds a branch and a currency. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE branch_id IS NULL OR currency IS NULL`); got != 0 { + t.Errorf("%d rows hold no branch or currency", got) + } + // The duplicate pair is suffixed in (created_at, id) order. + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dupA); got != "SEED-DUP" { + t.Errorf("the older duplicate = %q, want the bare SEED-DUP", got) + } + if got := one106(t, conn, `SELECT invoice_number FROM vendor_invoices WHERE id = $1`, dupB); got != "SEED-DUP #2" { + t.Errorf("the younger duplicate = %q, want SEED-DUP #2", got) + } + // The orphan po_id is set null. + if got := one106(t, conn, `SELECT COALESCE(po_id::text, 'null') FROM vendor_invoices WHERE id = $1`, orphan); got != "null" { + t.Errorf("the orphan po_id = %s, want null", got) + } + // The statuses are normalized to today's vocabulary. + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, dupB); got != "PENDING" { + t.Errorf("the lowercase status = %s, want PENDING", got) + } + if got := one106(t, conn, `SELECT status FROM vendor_invoices WHERE id = $1`, lower); got != "PARTIAL" { + t.Errorf("the lowercase partial = %s, want PARTIAL", got) + } + // The numbers and the open amounts cover every row. + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE number IS NULL OR number !~ '^AP-[0-9]+$'`); got != 0 { + t.Errorf("%d rows hold no AP- number", got) + } + if got := count106(t, conn, `SELECT count(DISTINCT number) - count(*) FROM vendor_invoices`); got != 0 { + t.Errorf("%d duplicate numbers", got) + } + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE amount_open IS NULL OR amount_open <> total - amount_paid`); got != 0 { + t.Errorf("%d rows hold a wrong amount_open", got) + } + + // A second up is a no-op. + numbersBefore := one106(t, conn, `SELECT string_agg(number, ',' ORDER BY number) FROM vendor_invoices`) + apply106(t, conn, target) + if numbersAfter := one106(t, conn, `SELECT string_agg(number, ',' ORDER BY number) FROM vendor_invoices`); numbersAfter != numbersBefore { + t.Errorf("a second apply changed the numbers: %s -> %s", numbersBefore, numbersAfter) + } + + // The down rolls the shape back, and the up applies again to the same + // result over the seeded database. + apply106(t, conn, down) + apply106(t, conn, target) + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices`); got != rowsBefore { + t.Fatalf("%d rows after the down-up cycle, want %d", got, rowsBefore) + } + if got := count106(t, conn, `SELECT count(*) FROM vendor_invoices WHERE number IS NULL OR amount_open IS NULL OR branch_id IS NULL OR currency IS NULL`); got != 0 { + t.Errorf("%d rows incomplete after the down-up cycle", got) + } + if got := one106(t, conn, `SELECT branch_id::text FROM vendor_invoices WHERE id = $1`, poBill); got != poBranch { + t.Errorf("the purchase order bill's branch after the cycle = %s, want %s", got, poBranch) + } +} diff --git a/core/internal/ap/model.go b/core/internal/ap/model.go index 859f9aa9..e8e89426 100644 --- a/core/internal/ap/model.go +++ b/core/internal/ap/model.go @@ -4,23 +4,75 @@ package ap import ( + "strings" "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/google/uuid" ) -// InvoiceStatus tracks the lifecycle of a vendor invoice. -type InvoiceStatus string +// Status is the vendor invoice lifecycle on the wire: lowercase snake_case +// (ADR 0001 section 6). The storage vocabulary stays uppercase; the mapping +// happens at this boundary. +type Status string const ( - InvoiceStatusPending InvoiceStatus = "PENDING" - InvoiceStatusApproved InvoiceStatus = "APPROVED" - InvoiceStatusPartial InvoiceStatus = "PARTIAL" - InvoiceStatusPaid InvoiceStatus = "PAID" - InvoiceStatusVoided InvoiceStatus = "VOIDED" + StatusPending Status = "pending" + StatusApproved Status = "approved" + StatusPartial Status = "partial" + StatusPaid Status = "paid" + StatusVoided Status = "voided" ) -// PaymentMethod for AP payments to vendors. +// ParseStatus accepts only the lowercase spelling; anything else is not a +// status this module knows. +func ParseStatus(raw string) (Status, bool) { + switch Status(raw) { + case StatusPending, StatusApproved, StatusPartial, StatusPaid, StatusVoided: + return Status(raw), true + } + return "", false +} + +// storageStatus maps a wire status to the column's uppercase vocabulary. +func storageStatus(s Status) string { return strings.ToUpper(string(s)) } + +// Status renders the status for a message. +func (s Status) Status() string { return string(s) } + +// wireStatus maps a stored status to the wire's lowercase one; an unknown +// stored value (a row written by hand) reads as pending. +func wireStatus(stored string) Status { + if s, ok := ParseStatus(strings.ToLower(stored)); ok { + return s + } + return StatusPending +} + +// transitions is the lifecycle: which edge each status allows. +var transitions = map[Status][]Status{ + StatusPending: {StatusApproved, StatusVoided}, + StatusApproved: {StatusVoided}, + // partial and paid carry payments; a partial bill cannot be voided while + // money has left (the transition's guard refuses it), and a paid one is + // closed. + StatusPartial: {}, + StatusPaid: {}, + StatusVoided: {}, +} + +func (s Status) allows(to Status) bool { + for _, t := range transitions[s] { + if t == to { + return true + } + } + return false +} + +// PaymentMethod for AP payments to vendors. The payment routes keep their +// uppercase wire vocabulary until their own conversion; this is the storage +// set. type PaymentMethod string const ( @@ -29,53 +81,72 @@ const ( PaymentMethodWire PaymentMethod = "WIRE" ) -// VendorInvoice represents a bill received from a vendor. -type VendorInvoice struct { - ID uuid.UUID `json:"id" db:"id"` - VendorID uuid.UUID `json:"vendor_id" db:"vendor_id"` - VendorName string `json:"vendor_name,omitempty" db:"vendor_name"` // Joined - InvoiceNumber string `json:"invoice_number" db:"invoice_number"` - InvoiceDate time.Time `json:"invoice_date" db:"invoice_date"` - DueDate time.Time `json:"due_date" db:"due_date"` - POID *uuid.UUID `json:"po_id,omitempty" db:"po_id"` - Subtotal int64 `json:"subtotal" db:"subtotal"` // Cents - TaxAmount int64 `json:"tax_amount" db:"tax_amount"` // Cents - Total int64 `json:"total" db:"total"` // Cents - AmountPaid int64 `json:"amount_paid" db:"amount_paid"` // Cents - Status InvoiceStatus `json:"status" db:"status"` - ApprovedBy *uuid.UUID `json:"approved_by,omitempty" db:"approved_by"` - ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"` - Notes string `json:"notes,omitempty" db:"notes"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - - // Populated on read - Lines []VendorInvoiceLine `json:"lines,omitempty"` +// Summary is one vendor invoice's header, the shape of both the single read +// and the list item (ADR 0001: the list item is the smaller struct the full +// document embeds, so the two cannot drift). +type Summary struct { + ID uuid.UUID `json:"id"` + Number string `json:"number"` + VendorID uuid.UUID `json:"vendor_id"` + VendorName string `json:"vendor_name"` + BranchID uuid.UUID `json:"branch_id"` + VendorInvoiceNumber string `json:"vendor_invoice_number"` + Currency string `json:"currency"` + InvoiceDate string `json:"invoice_date"` // YYYY-MM-DD + DueDate string `json:"due_date"` // YYYY-MM-DD + POID *uuid.UUID `json:"po_id"` + SubtotalCents httpx.Cents `json:"subtotal_cents"` + TaxCents httpx.Cents `json:"tax_cents"` + TotalCents httpx.Cents `json:"total_cents"` + AmountPaidCents httpx.Cents `json:"amount_paid_cents"` + AmountOpenCents httpx.Cents `json:"amount_open_cents"` + Status Status `json:"status"` + ApprovedBy *uuid.UUID `json:"approved_by"` + ApprovedAt *httpx.Timestamp `json:"approved_at"` + Notes string `json:"notes"` + Revision int64 `json:"revision"` + GLEntryID *uuid.UUID `json:"gl_entry_id"` + CreatedAt httpx.Timestamp `json:"created_at"` +} + +// Invoice is a vendor bill: the summary plus its lines. Gable's own number is +// number (AP-, gapped sequence); the vendor's own number is +// vendor_invoice_number (ADR 0008 7.4). +type Invoice struct { + Summary + Lines []InvoiceLine `json:"lines"` } -// VendorInvoiceLine represents a line item on a vendor invoice. -type VendorInvoiceLine struct { - ID uuid.UUID `json:"id" db:"id"` - InvoiceID uuid.UUID `json:"invoice_id" db:"invoice_id"` - Description string `json:"description" db:"description"` - Quantity float64 `json:"quantity" db:"quantity"` - UnitPrice int64 `json:"unit_price" db:"unit_price"` // Cents - LineTotal int64 `json:"line_total" db:"line_total"` // Cents - GLAccountID *uuid.UUID `json:"gl_account_id,omitempty" db:"gl_account_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` +// InvoiceLine is one line of a vendor bill. The extension is priced through +// httpx.Extend with the pair 1 and 1: AP lines carry no conversion pair +// until the purchase line link carries units (ADR 0008 sections 1 and 7.3). +type InvoiceLine struct { + ID uuid.UUID `json:"id"` + Position int `json:"position"` + Description string `json:"description"` + Quantity httpx.Quantity `json:"quantity"` + UnitPriceTenThousandths httpx.Price `json:"unit_price_ten_thousandths"` + LineTotalCents httpx.Cents `json:"line_total_cents"` + GLAccountID *uuid.UUID `json:"gl_account_id"` + PurchaseOrderLineID *uuid.UUID `json:"purchase_order_line_id"` + ProductID *uuid.UUID `json:"product_id"` + POFreightChargeID *uuid.UUID `json:"po_freight_charge_id"` + CreatedAt httpx.Timestamp `json:"created_at"` } -// APPayment represents a payment made to a vendor. +// APPayment is a payment made to a vendor. The payment routes keep their +// shape until their own conversion (ADR 0008 section 11); amount is cents. type APPayment struct { ID uuid.UUID `json:"id" db:"id"` VendorID uuid.UUID `json:"vendor_id" db:"vendor_id"` - VendorName string `json:"vendor_name,omitempty" db:"vendor_name"` // Joined + VendorName string `json:"vendor_name,omitempty" db:"vendor_name"` BatchID *uuid.UUID `json:"batch_id,omitempty" db:"batch_id"` Amount int64 `json:"amount" db:"amount"` // Cents Method PaymentMethod `json:"method" db:"method"` CheckNumber string `json:"check_number,omitempty" db:"check_number"` Reference string `json:"reference,omitempty" db:"reference"` - PaymentDate time.Time `json:"payment_date" db:"payment_date"` - Status string `json:"status" db:"status"` // PENDING, COMPLETE, VOIDED + PaymentDate string `json:"payment_date" db:"payment_date"` // YYYY-MM-DD + Status string `json:"status" db:"status"` CreatedAt time.Time `json:"created_at" db:"created_at"` } @@ -88,39 +159,6 @@ type APPaymentApplication struct { CreatedAt time.Time `json:"created_at" db:"created_at"` } -// --- Request Types --- - -// CreateVendorInvoiceRequest is sent when entering a new vendor bill. -type CreateVendorInvoiceRequest struct { - VendorID uuid.UUID `json:"vendor_id"` - InvoiceNumber string `json:"invoice_number"` - InvoiceDate string `json:"invoice_date"` // YYYY-MM-DD - DueDate string `json:"due_date"` // YYYY-MM-DD - POID *uuid.UUID `json:"po_id,omitempty"` - TaxAmount float64 `json:"tax_amount"` // Dollars - Notes string `json:"notes"` - Lines []CreateVendorInvoiceLineReq `json:"lines"` -} - -// CreateVendorInvoiceLineReq is a line item within a vendor invoice. -type CreateVendorInvoiceLineReq struct { - Description string `json:"description"` - Quantity float64 `json:"quantity"` - UnitPrice float64 `json:"unit_price"` // Dollars - GLAccountID *uuid.UUID `json:"gl_account_id,omitempty"` -} - -// CreateAPPaymentRequest is sent when paying vendors. -type CreateAPPaymentRequest struct { - VendorID uuid.UUID `json:"vendor_id"` - Amount float64 `json:"amount"` // Dollars - Method PaymentMethod `json:"method"` - CheckNumber string `json:"check_number,omitempty"` - Reference string `json:"reference,omitempty"` - PaymentDate string `json:"payment_date"` // YYYY-MM-DD - InvoiceIDs []uuid.UUID `json:"invoice_ids"` // Invoices this payment applies to -} - // APAgingSummary represents aging buckets for a vendor. type APAgingSummary struct { VendorID uuid.UUID `json:"vendor_id"` diff --git a/core/internal/ap/repository.go b/core/internal/ap/repository.go index c8385ac5..33645eed 100644 --- a/core/internal/ap/repository.go +++ b/core/internal/ap/repository.go @@ -5,299 +5,547 @@ package ap import ( "context" + "errors" "fmt" "time" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/pkg/database" - "github.com/gablelbm/gable/pkg/money" + "github.com/gablelbm/gable/pkg/middleware" "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) -// Repository handles AP data persistence. -type Repository interface { - CreateVendorInvoice(ctx context.Context, inv *VendorInvoice) error - GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) - ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]VendorInvoice, error) - UpdateVendorInvoice(ctx context.Context, inv *VendorInvoice) error +var errInvoiceNotFound = httpx.NotFound("vendor invoice not found") - AddInvoiceLine(ctx context.Context, line *VendorInvoiceLine) error - GetInvoiceLines(ctx context.Context, invoiceID uuid.UUID) ([]VendorInvoiceLine, error) +// Repository reads and writes the AP tables. Every statement goes through the +// executor the context resolves, so inside a transaction it is the +// transaction; the branch wall rides every read and every lock. +type Repository struct { + db *database.DB +} - CreatePayment(ctx context.Context, pmt *APPayment) error - CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error - ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) +func NewRepository(db *database.DB) *Repository { return &Repository{db: db} } - GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) +// wall is the branch predicate on an aliased table's branch_id (nil context +// values mean no wall: an in-process caller or an administrator). +func wall(alias string, branch, grants int) string { + return fmt.Sprintf(`( + ($%[2]d::uuid IS NOT NULL AND %[1]s.branch_id = $%[2]d) + OR ($%[2]d::uuid IS NULL AND $%[3]d::text IS NOT NULL AND %[1]s.branch_id IN + (SELECT branch_id FROM user_locations WHERE user_sub = $%[3]d)) + OR ($%[2]d::uuid IS NULL AND $%[3]d::text IS NULL) + )`, alias, branch, grants) } -// PostgresRepository implements Repository. -type PostgresRepository struct { - db *database.DB -} +const summaryColumns = ` + vi.id, vi.number, vi.vendor_id, COALESCE(v.name, ''), vi.branch_id, vi.invoice_number, vi.currency, + to_char(vi.invoice_date, 'YYYY-MM-DD'), to_char(vi.due_date, 'YYYY-MM-DD'), vi.po_id, + ROUND(vi.subtotal * 100)::bigint, ROUND(vi.tax_amount * 100)::bigint, ROUND(vi.total * 100)::bigint, + ROUND(vi.amount_paid * 100)::bigint, ROUND(vi.amount_open * 100)::bigint, vi.status, + vi.approved_by, vi.approved_at, COALESCE(vi.notes, ''), vi.revision, vi.gl_entry_id, vi.created_at` -// NewRepository creates a new AP repository. -func NewRepository(db *database.DB) *PostgresRepository { - return &PostgresRepository{db: db} -} +const summaryFrom = ` + FROM vendor_invoices vi + LEFT JOIN vendors v ON v.id = vi.vendor_id` + +type rowScan func(dest ...any) error -func (r *PostgresRepository) CreateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - if inv.ID == uuid.Nil { - inv.ID = uuid.New() - } - inv.CreatedAt = time.Now() - - query := ` - INSERT INTO vendor_invoices (id, vendor_id, invoice_number, invoice_date, due_date, po_id, - subtotal, tax_amount, total, amount_paid, status, notes, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - inv.ID, inv.VendorID, inv.InvoiceNumber, inv.InvoiceDate, inv.DueDate, inv.POID, - float64(inv.Subtotal)/100.0, float64(inv.TaxAmount)/100.0, float64(inv.Total)/100.0, - float64(inv.AmountPaid)/100.0, inv.Status, inv.Notes, inv.CreatedAt, +func scanSummary(scan rowScan, s *Summary) error { + var ( + subtotal, tax, total, paid, open int64 + status string + approvedAt *time.Time + created time.Time ) - if err != nil { - return fmt.Errorf("failed to create vendor invoice: %w", err) + if err := scan(&s.ID, &s.Number, &s.VendorID, &s.VendorName, &s.BranchID, &s.VendorInvoiceNumber, &s.Currency, + &s.InvoiceDate, &s.DueDate, &s.POID, &subtotal, &tax, &total, &paid, &open, &status, + &s.ApprovedBy, &approvedAt, &s.Notes, &s.Revision, &s.GLEntryID, &created); err != nil { + return err } + s.SubtotalCents, s.TaxCents, s.TotalCents, s.AmountPaidCents, s.AmountOpenCents = + httpx.Cents(subtotal), httpx.Cents(tax), httpx.Cents(total), httpx.Cents(paid), httpx.Cents(open) + s.Status, s.ApprovedAt, s.CreatedAt = wireStatus(status), httpx.PtrTimestamp(approvedAt), httpx.TimestampOf(created) return nil } -func (r *PostgresRepository) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) { - query := ` - SELECT vi.id, vi.vendor_id, COALESCE(v.name, '') as vendor_name, - vi.invoice_number, vi.invoice_date, vi.due_date, vi.po_id, - vi.subtotal, vi.tax_amount, vi.total, vi.amount_paid, vi.status, - vi.approved_by, vi.approved_at, COALESCE(vi.notes, '') as notes, vi.created_at - FROM vendor_invoices vi - LEFT JOIN vendors v ON v.id = vi.vendor_id - WHERE vi.id = $1 - ` - var inv VendorInvoice - var subtotal, taxAmount, total, amountPaid float64 - err := r.db.GetExecutor(ctx).QueryRow(ctx, query, id).Scan( - &inv.ID, &inv.VendorID, &inv.VendorName, - &inv.InvoiceNumber, &inv.InvoiceDate, &inv.DueDate, &inv.POID, - &subtotal, &taxAmount, &total, &amountPaid, &inv.Status, - &inv.ApprovedBy, &inv.ApprovedAt, &inv.Notes, &inv.CreatedAt, +const lineColumns = ` + l.id, l.position, l.description, l.quantity::text, ROUND(l.unit_price * 10000)::bigint, ROUND(l.line_total * 100)::bigint, + l.gl_account_id, l.purchase_order_line_id, l.product_id, l.po_freight_charge_id, l.created_at` + +func scanLine(scan rowScan, l *InvoiceLine) error { + var ( + qty string + price int64 + total int64 + created time.Time ) + if err := scan(&l.ID, &l.Position, &l.Description, &qty, &price, &total, + &l.GLAccountID, &l.PurchaseOrderLineID, &l.ProductID, &l.POFreightChargeID, &created); err != nil { + return err + } + q, err := httpx.ParseQuantity(qty) if err != nil { + return fmt.Errorf("vendor invoice line quantity %q: %w", qty, err) + } + l.Quantity, l.UnitPriceTenThousandths, l.LineTotalCents = q, httpx.Price(price), httpx.Cents(total) + l.CreatedAt = httpx.TimestampOf(created) + return nil +} + +// Get reads one vendor invoice, header and lines, held to the branch wall. +func (r *Repository) Get(ctx context.Context, id uuid.UUID) (*Invoice, error) { + var inv Invoice + row := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+summaryColumns+summaryFrom+` + WHERE vi.id = $1 AND `+wall("vi", 2, 3), id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanSummary(row.Scan, &inv.Summary); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errInvoiceNotFound + } return nil, fmt.Errorf("failed to get vendor invoice: %w", err) } - inv.Subtotal = money.DollarsToCents(subtotal) - inv.TaxAmount = money.DollarsToCents(taxAmount) - inv.Total = money.DollarsToCents(total) - inv.AmountPaid = money.DollarsToCents(amountPaid) + lines, err := r.Lines(ctx, id) + if err != nil { + return nil, err + } + inv.Lines = lines return &inv, nil } -func (r *PostgresRepository) ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]VendorInvoice, error) { - query := ` - SELECT vi.id, vi.vendor_id, COALESCE(v.name, '') as vendor_name, - vi.invoice_number, vi.invoice_date, vi.due_date, - vi.subtotal, vi.tax_amount, vi.total, vi.amount_paid, vi.status, vi.created_at - FROM vendor_invoices vi - LEFT JOIN vendors v ON v.id = vi.vendor_id - WHERE ($1::uuid IS NULL OR vi.vendor_id = $1) - AND ($2 = '' OR vi.status = $2) - ORDER BY vi.due_date ASC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, vendorID, status) +// Lines reads a bill's lines in position order. +func (r *Repository) Lines(ctx context.Context, invoiceID uuid.UUID) ([]InvoiceLine, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, `SELECT `+lineColumns+` + FROM vendor_invoice_lines l WHERE l.invoice_id = $1 ORDER BY l.position, l.created_at, l.id`, invoiceID) if err != nil { - return nil, fmt.Errorf("failed to list vendor invoices: %w", err) + return nil, fmt.Errorf("failed to read vendor invoice lines: %w", err) } defer rows.Close() + out := []InvoiceLine{} + for rows.Next() { + var l InvoiceLine + if err := scanLine(rows.Scan, &l); err != nil { + return nil, fmt.Errorf("failed to scan vendor invoice line: %w", err) + } + out = append(out, l) + } + return out, rows.Err() +} + +// LineWithCode is a line beside its account's stable code, what the approve +// entry's debit legs are built from. +type LineWithCode struct { + InvoiceLine + AccountCode string +} - var invoices []VendorInvoice +// LinesWithCodes reads a bill's lines joined to their account codes, in +// position order. A line with no account reads an empty code (the service +// refuses the approve naming it). +func (r *Repository) LinesWithCodes(ctx context.Context, invoiceID uuid.UUID) ([]LineWithCode, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, `SELECT `+lineColumns+`, COALESCE(a.code, '') + FROM vendor_invoice_lines l LEFT JOIN gl_accounts a ON a.id = l.gl_account_id + WHERE l.invoice_id = $1 ORDER BY l.position, l.created_at, l.id`, invoiceID) + if err != nil { + return nil, fmt.Errorf("failed to read vendor invoice lines: %w", err) + } + defer rows.Close() + out := []LineWithCode{} + for rows.Next() { + var ( + l LineWithCode + qty string + price int64 + total int64 + created time.Time + ) + if err := rows.Scan(&l.ID, &l.Position, &l.Description, &qty, &price, &total, + &l.GLAccountID, &l.PurchaseOrderLineID, &l.ProductID, &l.POFreightChargeID, &created, &l.AccountCode); err != nil { + return nil, fmt.Errorf("failed to scan vendor invoice line: %w", err) + } + q, err := httpx.ParseQuantity(qty) + if err != nil { + return nil, fmt.Errorf("vendor invoice line quantity %q: %w", qty, err) + } + l.Quantity, l.UnitPriceTenThousandths, l.LineTotalCents = q, httpx.Price(price), httpx.Cents(total) + l.CreatedAt = httpx.TimestampOf(created) + out = append(out, l) + } + return out, rows.Err() +} + +// ListFilter is the list's filters and keyset position. +type ListFilter struct { + VendorID *uuid.UUID + Statuses []Status + POID *uuid.UUID + AfterTime *time.Time + AfterID uuid.UUID + Limit int +} + +var listWhere = "\n\tWHERE " + wall("vi", 1, 2) + ` + AND ($3::uuid IS NULL OR vi.vendor_id = $3) + AND (cardinality($4::text[]) = 0 OR vi.status = ANY($4)) + AND ($5::uuid IS NULL OR vi.po_id = $5)` + +func (r *Repository) listArgs(ctx context.Context, f ListFilter) []any { + statuses := make([]string, len(f.Statuses)) + for i, s := range f.Statuses { + statuses[i] = storageStatus(s) + } + return []any{middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx), f.VendorID, statuses, f.POID} +} + +// List answers one page, newest first by (created_at, id). +func (r *Repository) List(ctx context.Context, f ListFilter) ([]Summary, error) { + args := append(r.listArgs(ctx, f), f.AfterTime, f.AfterID, f.Limit) + rows, err := r.db.GetExecutor(ctx).Query(ctx, `SELECT `+summaryColumns+summaryFrom+listWhere+` + AND ($6::timestamptz IS NULL OR (vi.created_at, vi.id) < ($6, $7::uuid)) + ORDER BY vi.created_at DESC, vi.id DESC + LIMIT $8`, args...) + if err != nil { + return nil, fmt.Errorf("failed to list vendor invoices: %w", err) + } + defer rows.Close() + out := []Summary{} for rows.Next() { - var inv VendorInvoice - var subtotal, taxAmount, total, amountPaid float64 - if err := rows.Scan( - &inv.ID, &inv.VendorID, &inv.VendorName, - &inv.InvoiceNumber, &inv.InvoiceDate, &inv.DueDate, - &subtotal, &taxAmount, &total, &amountPaid, &inv.Status, &inv.CreatedAt, - ); err != nil { + var s Summary + if err := scanSummary(rows.Scan, &s); err != nil { return nil, fmt.Errorf("failed to scan vendor invoice: %w", err) } - inv.Subtotal = money.DollarsToCents(subtotal) - inv.TaxAmount = money.DollarsToCents(taxAmount) - inv.Total = money.DollarsToCents(total) - inv.AmountPaid = money.DollarsToCents(amountPaid) - invoices = append(invoices, inv) + out = append(out, s) } - return invoices, nil + return out, rows.Err() } -func (r *PostgresRepository) UpdateVendorInvoice(ctx context.Context, inv *VendorInvoice) error { - query := ` - UPDATE vendor_invoices - SET amount_paid = $2, status = $3, approved_by = $4, approved_at = $5 - WHERE id = $1 - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - inv.ID, float64(inv.AmountPaid)/100.0, inv.Status, inv.ApprovedBy, inv.ApprovedAt, - ) +// Count is the size of the filtered set, for ?include=total. +func (r *Repository) Count(ctx context.Context, f ListFilter) (int64, error) { + var n int64 + if err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM vendor_invoices vi`+listWhere, + r.listArgs(ctx, f)...).Scan(&n); err != nil { + return 0, fmt.Errorf("failed to count vendor invoices: %w", err) + } + return n, nil +} + +// ListFull reads one page of bills with their lines in two queries: the page +// of summaries, then every listed bill's lines at once. The in-process +// callers (matching) need the whole document per bill, and a Get per row +// would be a read per bill on every match run. +func (r *Repository) ListFull(ctx context.Context, f ListFilter) ([]Invoice, error) { + summaries, err := r.List(ctx, f) if err != nil { - return fmt.Errorf("failed to update vendor invoice: %w", err) + return nil, err } - return nil + out := make([]Invoice, 0, len(summaries)) + if len(summaries) == 0 { + return out, nil + } + ids := make([]uuid.UUID, len(summaries)) + byID := make(map[uuid.UUID]int, len(summaries)) + for i, s := range summaries { + ids[i] = s.ID + byID[s.ID] = i + out = append(out, Invoice{Summary: s}) + } + rows, err := r.db.GetExecutor(ctx).Query(ctx, `SELECT l.invoice_id, `+lineColumns+` + FROM vendor_invoice_lines l WHERE l.invoice_id = ANY($1) + ORDER BY l.invoice_id, l.position, l.created_at, l.id`, ids) + if err != nil { + return nil, fmt.Errorf("failed to read vendor invoice lines: %w", err) + } + defer rows.Close() + for rows.Next() { + var ( + invoiceID uuid.UUID + l InvoiceLine + qty string + price int64 + total int64 + created time.Time + ) + if err := rows.Scan(&invoiceID, &l.ID, &l.Position, &l.Description, &qty, &price, &total, + &l.GLAccountID, &l.PurchaseOrderLineID, &l.ProductID, &l.POFreightChargeID, &created); err != nil { + return nil, fmt.Errorf("failed to scan vendor invoice line: %w", err) + } + q, err := httpx.ParseQuantity(qty) + if err != nil { + return nil, fmt.Errorf("vendor invoice line quantity %q: %w", qty, err) + } + l.Quantity, l.UnitPriceTenThousandths, l.LineTotalCents = q, httpx.Price(price), httpx.Cents(total) + l.CreatedAt = httpx.TimestampOf(created) + if i, ok := byID[invoiceID]; ok { + out[i].Lines = append(out[i].Lines, l) + } + } + return out, rows.Err() +} + +// LockedInvoice is a bill read under its row lock: what a transition or a +// payment checks before it writes. +type LockedInvoice struct { + Summary } -func (r *PostgresRepository) AddInvoiceLine(ctx context.Context, line *VendorInvoiceLine) error { - if line.ID == uuid.Nil { - line.ID = uuid.New() +// Lock takes the vendor invoice row FOR UPDATE, held to the branch wall. +func (r *Repository) Lock(ctx context.Context, id uuid.UUID) (*LockedInvoice, error) { + var locked LockedInvoice + row := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+summaryColumns+summaryFrom+` + WHERE vi.id = $1 AND `+wall("vi", 2, 3)+` FOR UPDATE OF vi`, id, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanSummary(row.Scan, &locked.Summary); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errInvoiceNotFound + } + return nil, fmt.Errorf("failed to lock vendor invoice: %w", err) } - line.CreatedAt = time.Now() + return &locked, nil +} - query := ` - INSERT INTO vendor_invoice_lines (id, invoice_id, description, quantity, unit_price, line_total, gl_account_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - line.ID, line.InvoiceID, line.Description, line.Quantity, - float64(line.UnitPrice)/100.0, float64(line.LineTotal)/100.0, - line.GLAccountID, line.CreatedAt, - ) +// LockForPayment takes one invoice of the named vendor FOR UPDATE, in the +// caller's id order (ADR 0008 section 9 step 5a). No row means no such +// invoice for this vendor. +func (r *Repository) LockForPayment(ctx context.Context, id, vendorID uuid.UUID) (*Summary, error) { + var s Summary + row := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT `+summaryColumns+summaryFrom+` + WHERE vi.id = $1 AND vi.vendor_id = $2 AND `+wall("vi", 3, 4)+` FOR UPDATE OF vi`, + id, vendorID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) + if err := scanSummary(row.Scan, &s); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to lock vendor invoice for payment: %w", err) + } + return &s, nil +} + +// Create inserts a bill and its lines. The number comes from the column's +// DEFAULT, which mints through this transaction (a rollback abandons it). +func (r *Repository) Create(ctx context.Context, in *Input, branch uuid.UUID, currency string) (*Invoice, error) { + var inv Invoice + var created time.Time + err := r.db.GetExecutor(ctx).QueryRow(ctx, ` + INSERT INTO vendor_invoices (vendor_id, invoice_number, invoice_date, due_date, po_id, branch_id, currency, + subtotal, tax_amount, total, amount_open, status, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::bigint::numeric / 100, $9::bigint::numeric / 100, + $10::bigint::numeric / 100, $10::bigint::numeric / 100, 'PENDING', NULLIF($11, '')) + RETURNING id, number, created_at`, + in.VendorID, in.VendorInvoiceNumber, in.InvoiceDate, in.DueDate, in.POID, branch, currency, + int64(subtotalCents(in)), int64(in.TaxCents), int64(totalCents(in)), in.Notes).Scan(&inv.ID, &inv.Number, &created) if err != nil { - return fmt.Errorf("failed to add invoice line: %w", err) + return nil, mapWriteError(err, "vendor_invoice_number", "vendor_id") + } + inv.CreatedAt = httpx.TimestampOf(created) + for i, l := range in.Lines { + if _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO vendor_invoice_lines (invoice_id, position, description, quantity, unit_price, line_total, + gl_account_id, purchase_order_line_id, product_id) + VALUES ($1, $2, $3, $4::numeric, $5::bigint::numeric / 10000, $6::bigint::numeric / 100, $7, $8, $9)`, + inv.ID, i, l.Description, l.Quantity.DecimalString(), int64(l.UnitPrice), int64(l.LineTotalCents), + l.GLAccountID, l.PurchaseOrderLineID, l.ProductID); err != nil { + return nil, mapWriteError(err, "", fmt.Sprintf("lines[%d]", i)) + } + } + full, err := r.Get(ctx, inv.ID) + if err != nil { + return nil, err + } + return full, nil +} + +// MarkApproved sets the bill approved under its lock, recording who approved +// it and the entry the approval posted. +func (r *Repository) MarkApproved(ctx context.Context, id uuid.UUID, approver *uuid.UUID, entryID *uuid.UUID) error { + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE vendor_invoices SET status = 'APPROVED', approved_by = $2, approved_at = NOW(), + gl_entry_id = $3, revision = revision + 1 + WHERE id = $1 AND status = 'PENDING'`, id, approver, entryID) + if err != nil { + return fmt.Errorf("failed to approve vendor invoice: %w", err) } return nil } -func (r *PostgresRepository) GetInvoiceLines(ctx context.Context, invoiceID uuid.UUID) ([]VendorInvoiceLine, error) { - query := ` - SELECT id, invoice_id, description, quantity, unit_price, line_total, gl_account_id, created_at - FROM vendor_invoice_lines - WHERE invoice_id = $1 - ORDER BY created_at ASC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, invoiceID) +// MarkVoided sets the bill voided under its lock, owing nothing more. +func (r *Repository) MarkVoided(ctx context.Context, id uuid.UUID) error { + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + UPDATE vendor_invoices SET status = 'VOIDED', amount_open = 0, revision = revision + 1 + WHERE id = $1`, id) if err != nil { - return nil, fmt.Errorf("failed to get invoice lines: %w", err) + return fmt.Errorf("failed to void vendor invoice: %w", err) } - defer rows.Close() + return nil +} - var lines []VendorInvoiceLine - for rows.Next() { - var line VendorInvoiceLine - var unitPrice, lineTotal float64 - if err := rows.Scan( - &line.ID, &line.InvoiceID, &line.Description, &line.Quantity, - &unitPrice, &lineTotal, &line.GLAccountID, &line.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("failed to scan invoice line: %w", err) - } - line.UnitPrice = money.DollarsToCents(unitPrice) - line.LineTotal = money.DollarsToCents(lineTotal) - lines = append(lines, line) +// ApplyToInvoice moves a payment's cents onto a locked bill: the paid and open +// amounts, the status the open amount settles, and the revision. +func (r *Repository) ApplyToInvoice(ctx context.Context, id uuid.UUID, apply int64) (paid bool, err error) { + err = r.db.GetExecutor(ctx).QueryRow(ctx, ` + UPDATE vendor_invoices + SET amount_paid = amount_paid + $2::bigint::numeric / 100, + amount_open = amount_open - $2::bigint::numeric / 100, + status = CASE WHEN amount_open - $2::bigint::numeric / 100 = 0 THEN 'PAID' ELSE 'PARTIAL' END, + revision = revision + 1 + WHERE id = $1 RETURNING (status = 'PAID')`, id, apply).Scan(&paid) + if err != nil { + return false, fmt.Errorf("failed to apply payment to vendor invoice: %w", err) } - return lines, nil + return paid, nil } -func (r *PostgresRepository) CreatePayment(ctx context.Context, pmt *APPayment) error { +// CreatePayment writes an AP payment row. +func (r *Repository) CreatePayment(ctx context.Context, pmt *APPayment) error { if pmt.ID == uuid.Nil { pmt.ID = uuid.New() } pmt.CreatedAt = time.Now() - - query := ` - INSERT INTO ap_payments (id, vendor_id, batch_id, amount, method, check_number, reference, payment_date, status, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - pmt.ID, pmt.VendorID, pmt.BatchID, float64(pmt.Amount)/100.0, - pmt.Method, pmt.CheckNumber, pmt.Reference, pmt.PaymentDate, pmt.Status, pmt.CreatedAt, - ) + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` + INSERT INTO ap_payments (id, vendor_id, amount, method, check_number, reference, payment_date, status, created_at) + VALUES ($1, $2, $3::bigint::numeric / 100, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8, $9)`, + pmt.ID, pmt.VendorID, pmt.Amount, pmt.Method, pmt.CheckNumber, pmt.Reference, pmt.PaymentDate, pmt.Status, pmt.CreatedAt) if err != nil { - return fmt.Errorf("failed to create AP payment: %w", err) + return mapWriteError(err, "", "vendor_id") } return nil } -func (r *PostgresRepository) CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error { +// CreatePaymentApplication links an applied amount to a bill. +func (r *Repository) CreatePaymentApplication(ctx context.Context, app *APPaymentApplication) error { if app.ID == uuid.Nil { app.ID = uuid.New() } - app.CreatedAt = time.Now() - - query := ` + _, err := r.db.GetExecutor(ctx).Exec(ctx, ` INSERT INTO ap_payment_applications (id, payment_id, invoice_id, amount, created_at) - VALUES ($1, $2, $3, $4, $5) - ` - _, err := r.db.GetExecutor(ctx).Exec(ctx, query, - app.ID, app.PaymentID, app.InvoiceID, float64(app.Amount)/100.0, app.CreatedAt, - ) + VALUES ($1, $2, $3, $4::bigint::numeric / 100, $5)`, + app.ID, app.PaymentID, app.InvoiceID, app.Amount, time.Now()) if err != nil { - return fmt.Errorf("failed to create payment application: %w", err) + return mapWriteError(err, "", "invoice_id") } return nil } -func (r *PostgresRepository) ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) { - query := ` +// ListPayments lists a vendor's payments, newest first. The branch wall rides +// it through the bills a payment applies to: a payment only the caller's wall +// cannot see (every bill it touched is another branch's) is not the caller's +// to read. A caller with no wall (an administrator without a header, an +// unbound key, a single branch install) reads every payment, one with no +// application rows included (the base wrote those for an on account payment). +func (r *Repository) ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` SELECT p.id, p.vendor_id, COALESCE(v.name, '') as vendor_name, - p.amount, p.method, COALESCE(p.check_number, '') as check_number, - COALESCE(p.reference, '') as reference, p.payment_date, p.status, p.created_at + ROUND(p.amount * 100)::bigint, p.method, COALESCE(p.check_number, ''), + COALESCE(p.reference, ''), to_char(p.payment_date, 'YYYY-MM-DD'), p.status, p.created_at FROM ap_payments p LEFT JOIN vendors v ON v.id = p.vendor_id WHERE ($1::uuid IS NULL OR p.vendor_id = $1) - ORDER BY p.payment_date DESC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query, vendorID) + AND (($2::uuid IS NULL AND $3::text IS NULL) + OR EXISTS (SELECT 1 FROM ap_payment_applications a + JOIN vendor_invoices vi ON vi.id = a.invoice_id + WHERE a.payment_id = p.id AND `+wall("vi", 2, 3)+`)) + ORDER BY p.created_at DESC, p.id DESC`, + vendorID, middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) if err != nil { return nil, fmt.Errorf("failed to list AP payments: %w", err) } defer rows.Close() - var payments []APPayment for rows.Next() { var pmt APPayment - var amount float64 - if err := rows.Scan( - &pmt.ID, &pmt.VendorID, &pmt.VendorName, - &amount, &pmt.Method, &pmt.CheckNumber, - &pmt.Reference, &pmt.PaymentDate, &pmt.Status, &pmt.CreatedAt, - ); err != nil { + var amount int64 + if err := rows.Scan(&pmt.ID, &pmt.VendorID, &pmt.VendorName, &amount, &pmt.Method, &pmt.CheckNumber, + &pmt.Reference, &pmt.PaymentDate, &pmt.Status, &pmt.CreatedAt); err != nil { return nil, fmt.Errorf("failed to scan AP payment: %w", err) } - pmt.Amount = money.DollarsToCents(amount) + pmt.Amount = amount payments = append(payments, pmt) } - return payments, nil + return payments, rows.Err() } -func (r *PostgresRepository) GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) { - query := ` +// GetAgingSummary returns the AP aging report by vendor, in cents, held to +// the caller's branch wall. +func (r *Repository) GetAgingSummary(ctx context.Context) ([]APAgingSummary, error) { + rows, err := r.db.GetExecutor(ctx).Query(ctx, ` SELECT vi.vendor_id, COALESCE(v.name, 'Unknown') as vendor_name, - COALESCE(SUM(CASE WHEN vi.due_date >= CURRENT_DATE THEN vi.total - vi.amount_paid ELSE 0 END), 0) as current_amt, - COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE AND vi.due_date >= CURRENT_DATE - 30 THEN vi.total - vi.amount_paid ELSE 0 END), 0) as past_30, - COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 30 AND vi.due_date >= CURRENT_DATE - 60 THEN vi.total - vi.amount_paid ELSE 0 END), 0) as past_60, - COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 60 THEN vi.total - vi.amount_paid ELSE 0 END), 0) as past_90, - COALESCE(SUM(vi.total - vi.amount_paid), 0) as total + ROUND(COALESCE(SUM(CASE WHEN vi.due_date >= CURRENT_DATE THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE AND vi.due_date >= CURRENT_DATE - 30 THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 30 AND vi.due_date >= CURRENT_DATE - 60 THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(CASE WHEN vi.due_date < CURRENT_DATE - 60 THEN vi.amount_open ELSE 0 END), 0) * 100)::bigint, + ROUND(COALESCE(SUM(vi.amount_open), 0) * 100)::bigint FROM vendor_invoices vi LEFT JOIN vendors v ON v.id = vi.vendor_id - WHERE vi.status NOT IN ('PAID', 'VOIDED') + WHERE `+wall("vi", 1, 2)+` AND vi.status NOT IN ('PAID', 'VOIDED') GROUP BY vi.vendor_id, v.name - ORDER BY total DESC - ` - rows, err := r.db.GetExecutor(ctx).Query(ctx, query) + ORDER BY 6 DESC`, + middleware.BranchIDForQuery(ctx), middleware.GrantsSubForQuery(ctx)) if err != nil { return nil, fmt.Errorf("failed to get AP aging: %w", err) } defer rows.Close() - var summaries []APAgingSummary for rows.Next() { var s APAgingSummary - var current, past30, past60, past90, total float64 - if err := rows.Scan(&s.VendorID, &s.VendorName, ¤t, &past30, &past60, &past90, &total); err != nil { + if err := rows.Scan(&s.VendorID, &s.VendorName, &s.Current, &s.Past30, &s.Past60, &s.Past90, &s.Total); err != nil { return nil, fmt.Errorf("failed to scan aging: %w", err) } - s.Current = money.DollarsToCents(current) - s.Past30 = money.DollarsToCents(past30) - s.Past60 = money.DollarsToCents(past60) - s.Past90 = money.DollarsToCents(past90) - s.Total = money.DollarsToCents(total) summaries = append(summaries, s) } - return summaries, nil + return summaries, rows.Err() +} + +// VendorFacts are what a bill takes from its vendor: that it exists. +func (r *Repository) VendorExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM vendors WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// POExists reports whether a purchase order exists, for the po_id reference. +func (r *Repository) POExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM purchase_orders WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// POLineExists reports whether a purchase order line exists, for a line's +// purchase_order_line_id reference. +func (r *Repository) POLineExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM purchase_order_lines WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// ProductExists reports whether a product exists, for a line's product_id +// reference. +func (r *Repository) ProductExists(ctx context.Context, id uuid.UUID) (bool, error) { + var n int + err := r.db.GetExecutor(ctx).QueryRow(ctx, `SELECT count(*) FROM products WHERE id = $1`, id).Scan(&n) + return n > 0, err +} + +// mapWriteError turns a database write refusal into the wire's error: a +// unique violation is a 409 duplicate naming the field, a foreign key +// violation is a 400 naming the field, and everything else stands as it is. +// dupField is the field a uniqueness refusal names; fks are the fields a +// foreign key refusal names, in the order the insert writes them. +func mapWriteError(err error, dupField string, fks ...string) error { + var pg interface{ SQLState() string } + if !errors.As(err, &pg) { + return err + } + field := "vendor_id" + if len(fks) > 0 { + field = fks[0] + } + switch pg.SQLState() { + case "23505": + dup := httpx.FieldError{Field: dupField, Message: "already used"} + if dupField == "" { + return httpx.Duplicate("a record this request would create already exists") + } + return httpx.Duplicate("a record this request would create already exists", dup) + case "23503": + return httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: field, Message: "no such record"}) + } + return err } diff --git a/core/internal/ap/service.go b/core/internal/ap/service.go index f1feb7fa..7392dfb5 100644 --- a/core/internal/ap/service.go +++ b/core/internal/ap/service.go @@ -5,261 +5,733 @@ package ap import ( "context" + "encoding/json" + "errors" "fmt" "log/slog" + "net/http" + "sort" + "strings" "time" "github.com/gablelbm/gable/internal/gl" - "github.com/gablelbm/gable/pkg/money" + "github.com/gablelbm/gable/internal/platform/httpx" + "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" ) -// Database interface for transaction support. -type Database interface { +// EventRecorder writes a domain event into the transactional outbox, as the +// LAST statement of the mutation's transaction (ADR 0003 section 2). +type EventRecorder interface { + Write(ctx context.Context, ev outbox.Event) error +} + +// AuditSink writes an audit row (*audit.Logger satisfies it). +type AuditSink interface { + Log(ctx context.Context, e audit.Entry) error +} + +// BranchGuard applies the payload branch rule (ADR 0008 section 11). +type BranchGuard interface { + CheckPayloadBranch(ctx context.Context, branch uuid.UUID) error +} + +// TxRunner runs fn inside one transaction, joining the caller's when ctx +// already carries one. *database.DB satisfies it. +type TxRunner interface { RunInTx(ctx context.Context, fn func(ctx context.Context) error) error } -// Service handles AP business logic. +// Event types the module writes (ADR 0008 section 10). +const ( + EventCreated = "vendor_invoice.created" + EventApproved = "vendor_invoice.approved" + EventPartial = "vendor_invoice.partial" + EventPaid = "vendor_invoice.paid" + EventVoided = "vendor_invoice.voided" +) + +// Service is the AP module: the vendor invoice acts of ADR 0008 section 7.4 +// on the wire contract of ADR 0001, and the payment act they fix. Every +// mutation is one transaction: the write, the journal entry it causes, the +// audit row, then the events last. type Service struct { - db Database - repo Repository - glSvc *gl.Service - logger *slog.Logger + db *database.DB + tx TxRunner + repo *Repository + gl *gl.Service + auditLog AuditSink + events EventRecorder + branches BranchGuard + logger *slog.Logger + now func() time.Time } -// NewService creates a new AP service. -func NewService(db Database, repo Repository, glSvc *gl.Service, logger *slog.Logger) *Service { - return &Service{ - db: db, - repo: repo, - glSvc: glSvc, - logger: logger, +func NewService(db *database.DB, repo *Repository, glSvc *gl.Service, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() } + return &Service{db: db, repo: repo, gl: glSvc, logger: logger, now: time.Now} } -// CreateVendorInvoice enters a new vendor bill with line items. -func (s *Service) CreateVendorInvoice(ctx context.Context, req CreateVendorInvoiceRequest) (*VendorInvoice, error) { - invoiceDate, err := time.Parse("2006-01-02", req.InvoiceDate) - if err != nil { - return nil, fmt.Errorf("invalid invoice_date: %w", err) +// WithOutbox sets the event writer. +func (s *Service) WithOutbox(e EventRecorder) *Service { s.events = e; return s } + +// WithTxRunner replaces the database as the transaction runner (a test gates +// transactions with it; serve leaves the database). +func (s *Service) WithTxRunner(tx TxRunner) *Service { s.tx = tx; return s } + +// WithAuditLog sets the audit logger. +func (s *Service) WithAuditLog(l AuditSink) *Service { s.auditLog = l; return s } + +// WithBranchGuard sets the payload branch rule for the write routes. +func (s *Service) WithBranchGuard(g BranchGuard) *Service { s.branches = g; return s } + +func (s *Service) inTx(ctx context.Context, fn func(ctx context.Context) error) error { + if s.tx != nil { + return s.tx.RunInTx(ctx, fn) } - dueDate, err := time.Parse("2006-01-02", req.DueDate) - if err != nil { - return nil, fmt.Errorf("invalid due_date: %w", err) - } - - // Calculate subtotal from lines - var subtotalCents int64 - for _, line := range req.Lines { - lineTotalCents := money.DollarsToCents(line.UnitPrice * line.Quantity) - subtotalCents += lineTotalCents - } - taxCents := money.DollarsToCents(req.TaxAmount) - totalCents := subtotalCents + taxCents - - var inv *VendorInvoice - err = s.db.RunInTx(ctx, func(ctx context.Context) error { - inv = &VendorInvoice{ - VendorID: req.VendorID, - InvoiceNumber: req.InvoiceNumber, - InvoiceDate: invoiceDate, - DueDate: dueDate, - POID: req.POID, - Subtotal: subtotalCents, - TaxAmount: taxCents, - Total: totalCents, - AmountPaid: 0, - Status: InvoiceStatusPending, - Notes: req.Notes, - } - - if err := s.repo.CreateVendorInvoice(ctx, inv); err != nil { + return s.db.RunInTx(ctx, fn) +} + +func (s *Service) record(ctx context.Context, ev outbox.Event) error { + if s.events == nil { + return nil + } + return s.events.Write(ctx, ev) +} + +func (s *Service) recordAll(ctx context.Context, evs []outbox.Event) error { + for _, ev := range evs { + if err := s.record(ctx, ev); err != nil { return err } + } + return nil +} + +func (s *Service) audit(ctx context.Context, e audit.Entry) error { + if s.auditLog == nil { + return nil + } + if err := s.auditLog.Log(ctx, e); err != nil { + return fmt.Errorf("failed to write the audit row: %w", err) + } + return nil +} - // Add line items - for _, lineReq := range req.Lines { - unitPriceCents := money.DollarsToCents(lineReq.UnitPrice) - lineTotalCents := money.DollarsToCents(lineReq.UnitPrice * lineReq.Quantity) +// Precondition is the client's revision for an act on the bill named in the +// path (ADR 0001 section 11). An in-process caller sets Any: the same rules +// and the same event, without a client revision to check. +type Precondition struct { + IfMatch string + Revision *int64 + Any bool +} - line := &VendorInvoiceLine{ - InvoiceID: inv.ID, - Description: lineReq.Description, - Quantity: lineReq.Quantity, - UnitPrice: unitPriceCents, - LineTotal: lineTotalCents, - GLAccountID: lineReq.GLAccountID, - } - if err := s.repo.AddInvoiceLine(ctx, line); err != nil { - return err - } - } +func (p Precondition) missing() bool { return !p.Any && p.IfMatch == "" && p.Revision == nil } - s.logger.Info("Vendor invoice created", - "id", inv.ID, - "vendor_id", inv.VendorID, - "total_cents", totalCents, - ) +func (p Precondition) check(current int64) error { + if p.Any { return nil - }) + } + return httpx.CheckRevision(current, p.IfMatch, p.Revision) +} +// Caller is who is acting: the audit subject. +type Caller struct { + Actor string + Role string +} + +func conflictBlocker(code, message string) *httpx.Error { + return &httpx.Error{Status: http.StatusConflict, Code: httpx.CodeConflict, Message: message, + Details: []httpx.FieldError{httpx.Blocker(code, message)}} +} + +// checkBranch is the record branch rule for a branch a payload names. +func (s *Service) checkBranch(ctx context.Context, branch uuid.UUID, field string) error { + if s.branches == nil { + return nil + } + err := s.branches.CheckPayloadBranch(ctx, branch) + if errors.Is(err, middleware.ErrPayloadBranchRefused) { + return &httpx.Error{Status: http.StatusForbidden, Code: httpx.CodeForbidden, + Message: "the branch is outside the branches this caller may target", + Details: []httpx.FieldError{{Field: field, Code: httpx.CodeForbidden, Message: "not a branch this caller may target"}}} + } + return err +} + +func subtotalCents(in *Input) httpx.Cents { + var sum httpx.Cents + for _, l := range in.Lines { + sum += l.LineTotalCents + } + return sum +} + +func totalCents(in *Input) httpx.Cents { return subtotalCents(in) + in.TaxCents } + +func mustJSON(v map[string]any) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte(`{}`) + } + return b +} + +// event builds one vendor_invoice.* event: a small summary, never the +// document (ADR 0008 section 10). +func (s *Service) event(typ string, from Status, inv *Summary) outbox.Event { + branch := inv.BranchID + return outbox.Event{ + Type: typ, EntityType: "vendor_invoice", EntityID: inv.ID, BranchID: &branch, + Data: mustJSON(map[string]any{ + "number": inv.Number, "vendor_invoice_number": inv.VendorInvoiceNumber, "vendor_id": inv.VendorID, + "branch_id": inv.BranchID, "status": inv.Status, "from_status": from, "revision": inv.Revision, + "total_cents": int64(inv.TotalCents), "amount_open_cents": int64(inv.AmountOpenCents), "currency": inv.Currency, + }), + } +} + +// reloaded reads the bill's summary again inside the transaction, for the +// event and the response. +func (s *Service) reloaded(ctx context.Context, id uuid.UUID) (*Summary, error) { + inv, err := s.repo.Get(ctx, id) if err != nil { return nil, err } - return inv, nil + return &inv.Summary, nil } -// ApproveInvoice approves a pending vendor invoice and posts to GL. -func (s *Service) ApproveInvoice(ctx context.Context, invoiceID uuid.UUID, approverID uuid.UUID) (*VendorInvoice, error) { - inv, err := s.repo.GetVendorInvoice(ctx, invoiceID) +// resolve holds what a create takes from the database: the vendor, the +// references, the currency default and the branch. +func (s *Service) resolve(ctx context.Context, in *Input) (branch uuid.UUID, currency string, err error) { + ok, err := s.repo.VendorExists(ctx, in.VendorID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: "vendor_id", Message: "no such vendor"}) + } + if in.POID != nil { + ok, err := s.repo.POExists(ctx, *in.POID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: "po_id", Message: "no such purchase order"}) + } + } + for i, l := range in.Lines { + if l.PurchaseOrderLineID != nil { + ok, err := s.repo.POLineExists(ctx, *l.PurchaseOrderLineID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: fmt.Sprintf("lines[%d].purchase_order_line_id", i), Message: "no such purchase order line"}) + } + } + if l.ProductID != nil { + ok, err := s.repo.ProductExists(ctx, *l.ProductID) + if err != nil { + return uuid.Nil, "", err + } + if !ok { + return uuid.Nil, "", httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: fmt.Sprintf("lines[%d].product_id", i), Message: "no such product"}) + } + } + } + currency = in.Currency + if currency == "" { + var setting string + if err := s.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT COALESCE((SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD')`).Scan(&setting); err != nil { + return uuid.Nil, "", err + } + currency = setting + } + branch = uuid.Nil + if in.BranchID != nil { + branch = *in.BranchID + } else if ctxBranch := middleware.BranchIDForQuery(ctx); ctxBranch != nil { + branch = *ctxBranch + } else { + var def uuid.UUID + if err := s.db.GetExecutor(ctx).QueryRow(ctx, + `SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'`).Scan(&def); err != nil { + return uuid.Nil, "", err + } + branch = def + } + if err := s.checkBranch(ctx, branch, "branch_id"); err != nil { + return uuid.Nil, "", err + } + return branch, currency, nil +} + +// Create enters a vendor bill: it starts pending, owing its whole total, with +// Gable's own number and the vendor's as vendor_invoice_number. +func (s *Service) Create(ctx context.Context, in *Input, who Caller) (*Invoice, error) { + branch, currency, err := s.resolve(ctx, in) + if err != nil { + return nil, err + } + var out *Invoice + err = s.inTx(ctx, func(ctx context.Context) error { + inv, err := s.repo.Create(ctx, in, branch, currency) + if err != nil { + return err + } + out = inv + if err := s.audit(ctx, audit.Entry{Action: "vendor_invoice.created", EntityType: "vendor_invoice", + EntityID: inv.ID, UserID: who.Actor, Changes: map[string]interface{}{ + "number": inv.Number, "vendor_invoice_number": inv.VendorInvoiceNumber, "vendor_id": inv.VendorID, + "total_cents": int64(inv.TotalCents), "line_count": len(in.Lines), "currency": inv.Currency}}); err != nil { + return err + } + return s.record(ctx, s.event(EventCreated, "", &inv.Summary)) + }) if err != nil { return nil, err } + s.logger.Info("vendor invoice entered", "id", out.ID, "number", out.Number, "total_cents", int64(out.TotalCents)) + return out, nil +} + +// Get reads one bill with its lines, held to the caller's branch wall. +func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Invoice, error) { + return s.repo.Get(ctx, id) +} - if inv.Status != InvoiceStatusPending { - return nil, fmt.Errorf("invoice is not pending (status: %s)", inv.Status) +// List answers one page and whether another follows. +func (s *Service) List(ctx context.Context, f ListFilter, wantTotal bool) ([]Summary, bool, *int64, error) { + asked := f.Limit + f.Limit = asked + 1 + items, err := s.repo.List(ctx, f) + if err != nil { + return nil, false, nil, err + } + more := len(items) > asked + if more { + items = items[:asked] } + var total *int64 + if wantTotal { + n, err := s.repo.Count(ctx, f) + if err != nil { + return nil, false, nil, err + } + total = &n + } + return items, more, total, nil +} - err = s.db.RunInTx(ctx, func(txCtx context.Context) error { - now := time.Now() - inv.Status = InvoiceStatusApproved - inv.ApprovedBy = &approverID - inv.ApprovedAt = &now +// Transition runs POST /ap/invoices/{id}/transitions: to approved (the +// approve of ADR 0008 7.4) or to voided (the transition that reverses the +// entry). +func (s *Service) Transition(ctx context.Context, id uuid.UUID, in *TransitionInput, pre Precondition, who Caller) (*Invoice, error) { + if pre.missing() { + return nil, httpx.PreconditionRequired("this write needs If-Match or a body revision") + } + switch in.To { + case StatusApproved: + return s.approve(ctx, id, pre, who, nil) + case StatusVoided: + return s.void(ctx, id, pre, in.Reason, who) + case StatusPending, StatusPartial, StatusPaid: + return nil, httpx.InvalidStateTransition("a bill never moves to " + in.To.Status() + " by a transition") + } + return nil, httpx.InvalidStateTransition("a bill never moves there by a transition") +} - if err := s.repo.UpdateVendorInvoice(txCtx, inv); err != nil { +// approve is the act ADR 0008 7.4 fixes: it loads the lines, posts the entry +// through gl.PostEntry inside the approve transaction, and a posting failure +// fails the act. approver is the recorded approver (the caller's UUID when it +// has one); a nil approver with an in-process caller records none. +func (s *Service) approve(ctx context.Context, id uuid.UUID, pre Precondition, who Caller, approver *uuid.UUID) (*Invoice, error) { + var out *Invoice + err := s.inTx(ctx, func(ctx context.Context) error { + locked, err := s.repo.Lock(ctx, id) + if err != nil { return err } + if err := pre.check(locked.Revision); err != nil { + return err + } + if locked.Status != StatusPending { + return httpx.InvalidStateTransition("only a pending bill can be approved", + httpx.Blocker("invoice_not_pending", "the bill is "+locked.Status.Status())) + } + lines, err := s.repo.LinesWithCodes(ctx, id) + if err != nil { + return err + } + var subtotal httpx.Cents + for _, l := range lines { + if l.AccountCode == "" { + return conflictBlocker("line_needs_account", + fmt.Sprintf("line %d has no account: the approve entry debits each line's account", l.Position)) + } + subtotal += l.LineTotalCents + } + entryID, err := s.postInvoiceEntry(ctx, &locked.Summary, lines, subtotal, who.Actor) + if err != nil { + return err + } + var approvedBy *uuid.UUID + if approver != nil { + approvedBy = approver + } else if id, err := uuid.Parse(who.Actor); err == nil { + approvedBy = &id + } + if err := s.repo.MarkApproved(ctx, id, approvedBy, entryID); err != nil { + return err + } + if err := s.audit(ctx, audit.Entry{Action: "vendor_invoice.approved", EntityType: "vendor_invoice", + EntityID: id, UserID: who.Actor, Changes: map[string]interface{}{ + "number": locked.Number, "total_cents": int64(locked.TotalCents), "gl_entry_id": entryID}}); err != nil { + return err + } + after, err := s.reloaded(ctx, id) + if err != nil { + return err + } + if err := s.record(ctx, s.event(EventApproved, StatusPending, after)); err != nil { + return err + } + full, err := s.repo.Get(ctx, id) + if err != nil { + return err + } + out = full + return nil + }) + if err != nil { + return nil, err + } + s.logger.Info("vendor invoice approved", "id", id, "total_cents", int64(out.TotalCents)) + return out, nil +} - // Post to GL: DR Expense/Inventory, CR Accounts Payable - var glLines []gl.VendorInvoiceLineDetail - for _, line := range inv.Lines { - glLines = append(glLines, gl.VendorInvoiceLineDetail{ - Description: line.Description, - AmountCents: line.LineTotal, - GLAccountID: line.GLAccountID, - }) +// postInvoiceEntry builds and posts the approve entry: a debit leg per line +// on the line's account, the tax spread across the line accounts pro rata to +// their extensions (the last line taking the remainder, the freight rule of +// ADR 0008 3.5), and the credit to Accounts Payable for the whole total. The +// entry rides the approve transaction; a posting failure fails the act. +func (s *Service) postInvoiceEntry(ctx context.Context, inv *Summary, lines []LineWithCode, subtotal httpx.Cents, actor string) (*uuid.UUID, error) { + tax := int64(inv.TaxCents) + remaining := tax + legs := make([]gl.Leg, 0, len(lines)+1) + for i, l := range lines { + debit := int64(l.LineTotalCents) + switch { + case i == len(lines)-1: + debit += remaining + case int64(subtotal) > 0: + share := tax * int64(l.LineTotalCents) / int64(subtotal) + debit += share + remaining -= share } - if err := s.glSvc.SyncVendorInvoice(txCtx, inv.ID, inv.Total, glLines); err != nil { - return fmt.Errorf("failed to sync vendor invoice to GL: %w", err) + legs = append(legs, gl.Leg{AccountCode: l.AccountCode, Description: l.Description, Debit: debit}) + } + legs = append(legs, gl.Leg{AccountCode: gl.AccountCodeAP, Description: "Accounts Payable", Credit: int64(inv.TotalCents)}) + entry, err := s.gl.PostEntry(ctx, gl.PostingInput{ + EntryDate: mustDate(inv.InvoiceDate), Memo: "Vendor invoice " + inv.Number, + Source: gl.SourceVendorInv, SourceRefID: &inv.ID, Currency: inv.Currency, PostedBy: actor, Legs: legs, + }) + if err != nil { + if errors.Is(err, gl.ErrPeriodClosed) { + return nil, conflictBlocker("period_closed", "the entry would be dated into a closed fiscal period") + } + if errors.Is(err, gl.ErrAccountMissing) { + return nil, conflictBlocker("account_missing", + "the chart of accounts holds no "+strings.TrimPrefix(err.Error(), gl.ErrAccountMissing.Error()+": ")) } + if errors.Is(err, gl.ErrUnbalanced) { + return nil, conflictBlocker("entry_unbalanced", "the bill's lines do not balance against its total") + } + return nil, fmt.Errorf("failed to post the vendor invoice entry: %w", err) + } + if entry == nil { + return nil, nil + } + return &entry.ID, nil +} +// void is the transition that reverses the entry: a pending bill has nothing +// posted and simply dies; an approved one's entry is reversed whole; a bill +// that prices to zero posted nothing and voids with nothing to reverse. A bill +// with money applied is refused: the payments have to be undone first. A bill +// that owes (approved or partial, a nonzero total) but has no linked entry is +// refused too: no route links an entry to a bill, and a legacy bill of that +// shape may hold the removed sync's one leg credit, which a plain void would +// strand. So is a linked entry that does not balance (that one leg shape, +// linked by hand): its reversal would not balance either. Both refusals are a +// stated limit: such a bill stays approved and payable until a later repair +// item. +func (s *Service) void(ctx context.Context, id uuid.UUID, pre Precondition, reason string, who Caller) (*Invoice, error) { + if reason == "" { + return nil, httpx.BadRequest("one or more fields failed validation", + httpx.FieldError{Field: "reason", Message: "is required to void a bill"}) + } + var out *Invoice + err := s.inTx(ctx, func(ctx context.Context) error { + locked, err := s.repo.Lock(ctx, id) + if err != nil { + return err + } + if err := pre.check(locked.Revision); err != nil { + return err + } + from := locked.Status + if locked.AmountPaidCents > 0 { + return httpx.InvalidStateTransition("the bill carries payments: they must be undone before it is voided", + httpx.Blocker("has_payments", fmt.Sprintf("%d cents have been applied to this bill", int64(locked.AmountPaidCents)))) + } + if !from.allows(StatusVoided) { + return httpx.InvalidStateTransition("a " + from.Status() + " bill cannot be voided") + } + // An approved or partial bill that owes but has no linked entry (a + // legacy bill: its posting was swallowed, or it holds the removed + // sync's one leg credit) must not void silently: a credit may stay in + // the control account with nothing to reverse. A zero total bill posted + // nothing and owes nothing, so it voids. + if (from == StatusApproved || from == StatusPartial) && locked.GLEntryID == nil && locked.TotalCents != 0 { + return httpx.InvalidStateTransition("the bill has no linked journal entry, so a void could leave its credit in accounts payable", + httpx.Blocker("entry_not_linked", "no route links a journal entry to a bill yet; the bill stays approved and payable until a later repair item")) + } + var reversal *uuid.UUID + if locked.GLEntryID != nil { + entry, err := s.gl.PostReversal(ctx, gl.ReversalInput{ + EntryID: *locked.GLEntryID, EntryDate: s.now(), Currency: locked.Currency, + Reason: reason, PostedBy: who.Actor, + }) + if err != nil { + if errors.Is(err, gl.ErrPeriodClosed) { + return conflictBlocker("period_closed", "the reversal would be dated into a closed fiscal period") + } + if errors.Is(err, gl.ErrUnbalanced) { + return httpx.InvalidStateTransition("the linked entry does not balance and a void would mirror it into another unbalanced entry", + httpx.Blocker("entry_unbalanced", "the bill's linked journal entry does not balance (the removed sync's one leg shape); the bill stays approved and payable until a later repair item")) + } + return fmt.Errorf("failed to reverse the vendor invoice entry: %w", err) + } + if entry != nil { + reversal = &entry.ID + } + } + if err := s.repo.MarkVoided(ctx, id); err != nil { + return err + } + if err := s.audit(ctx, audit.Entry{Action: "vendor_invoice.voided", EntityType: "vendor_invoice", + EntityID: id, UserID: who.Actor, Changes: map[string]interface{}{ + "number": locked.Number, "reason": reason, "reversal_entry_id": reversal}}); err != nil { + return err + } + after, err := s.reloaded(ctx, id) + if err != nil { + return err + } + if err := s.record(ctx, s.event(EventVoided, from, after)); err != nil { + return err + } + full, err := s.repo.Get(ctx, id) + if err != nil { + return err + } + out = full return nil }) if err != nil { return nil, err } + return out, nil +} - s.logger.Info("Vendor invoice approved and synced to GL", - "id", invoiceID, - "approved_by", approverID, - "total_cents", inv.Total, - ) +// ApproveInvoice is the in-process approve (the matcher's auto approve): the +// same transition without a client revision, attributed to the given +// approver. +func (s *Service) ApproveInvoice(ctx context.Context, invoiceID uuid.UUID, approverID uuid.UUID) (*Invoice, error) { + return s.approve(ctx, invoiceID, Precondition{Any: true}, Caller{Actor: approverID.String()}, &approverID) +} - return inv, nil +// GetVendorInvoice is the in-process read of one bill with its lines. +func (s *Service) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*Invoice, error) { + return s.repo.Get(ctx, id) } -// PayVendor creates a payment and applies it to invoices. -func (s *Service) PayVendor(ctx context.Context, req CreateAPPaymentRequest) (*APPayment, error) { - paymentDate, err := time.Parse("2006-01-02", req.PaymentDate) +// ListVendorInvoices is the in-process listing matching reads: every bill of +// the vendor (or every vendor), optionally by storage status, with each +// bill's lines, read in two queries rather than a Get per bill. +func (s *Service) ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]Invoice, error) { + invoices, err := s.repo.ListFull(ctx, ListFilter{VendorID: vendorID, Limit: 100000}) if err != nil { - return nil, fmt.Errorf("invalid payment_date: %w", err) + return nil, err + } + out := make([]Invoice, 0, len(invoices)) + for _, inv := range invoices { + if status != "" && storageStatus(inv.Status) != status && inv.Status.Status() != status { + continue + } + out = append(out, inv) } + return out, nil +} - amountCents := money.DollarsToCents(req.Amount) +// PayVendor records a payment to a vendor and applies it to the bills it +// names, in id order, under their locks: only an approved or partial bill +// takes money (ADR 0008 7.4). The entry posts through gl.PostEntry inside the +// act's transaction; a posting failure fails the act. +func (s *Service) PayVendor(ctx context.Context, in *PayInput, who Caller) (*APPayment, error) { + // The invoices are locked in id order (ADR 0008 section 9 step 5a), so + // every contender takes them in one order; the money then applies in the + // order the request named them, the order the desk pays them in. + lockedIDs := make([]uuid.UUID, len(in.InvoiceIDs)) + copy(lockedIDs, in.InvoiceIDs) + sort.Slice(lockedIDs, func(i, j int) bool { return lockedIDs[i].String() < lockedIDs[j].String() }) var pmt *APPayment - err = s.db.RunInTx(ctx, func(ctx context.Context) error { - pmt = &APPayment{ - VendorID: req.VendorID, - Amount: amountCents, - Method: req.Method, - CheckNumber: req.CheckNumber, - Reference: req.Reference, - PaymentDate: paymentDate, + err := s.inTx(ctx, func(ctx context.Context) error { + p := &APPayment{ + VendorID: in.VendorID, + Amount: in.AmountCents, + Method: in.Method, + CheckNumber: in.CheckNumber, + Reference: in.Reference, + PaymentDate: in.PaymentDate, Status: "COMPLETE", } - - if err := s.repo.CreatePayment(ctx, pmt); err != nil { + if err := s.repo.CreatePayment(ctx, p); err != nil { return err } + pmt = p - // Apply payment to invoices - remaining := amountCents - for _, invID := range req.InvoiceIDs { - if remaining <= 0 { - break - } - - inv, err := s.repo.GetVendorInvoice(ctx, invID) + locked := make(map[uuid.UUID]*Summary, len(lockedIDs)) + for _, id := range lockedIDs { + inv, err := s.repo.LockForPayment(ctx, id, in.VendorID) if err != nil { - return fmt.Errorf("invoice %s not found: %w", invID, err) + return err + } + if inv == nil { + return httpx.BadRequest("a referenced record does not exist", + httpx.FieldError{Field: fmt.Sprintf("invoice_ids[%d]", indexOfUUID(in.InvoiceIDs, id)), + Message: "no such invoice for this vendor"}) } + if inv.Status != StatusApproved && inv.Status != StatusPartial { + return httpx.InvalidStateTransition("only an approved or partial bill takes a payment", + httpx.Blocker("invoice_not_approved", inv.Number+" is "+inv.Status.Status())) + } + locked[id] = inv + } - outstanding := inv.Total - inv.AmountPaid + var events []outbox.Event + remaining := in.AmountCents + for _, id := range in.InvoiceIDs { + inv := locked[id] + open := int64(inv.AmountOpenCents) + if open <= 0 { + continue + } apply := remaining - if apply > outstanding { - apply = outstanding + if apply > open { + apply = open } - - app := &APPaymentApplication{ - PaymentID: pmt.ID, - InvoiceID: invID, - Amount: apply, + if apply <= 0 { + break } + app := &APPaymentApplication{PaymentID: p.ID, InvoiceID: id, Amount: apply} if err := s.repo.CreatePaymentApplication(ctx, app); err != nil { return err } - - // Update invoice paid amount - inv.AmountPaid += apply - if inv.AmountPaid >= inv.Total { - inv.Status = InvoiceStatusPaid - } else { - inv.Status = InvoiceStatusPartial - } - if err := s.repo.UpdateVendorInvoice(ctx, inv); err != nil { + paid, err := s.repo.ApplyToInvoice(ctx, id, apply) + if err != nil { return err } - remaining -= apply + after, err := s.reloaded(ctx, id) + if err != nil { + return err + } + from := inv.Status + typ := EventPartial + if paid { + typ = EventPaid + } + events = append(events, s.event(typ, from, after)) + if remaining <= 0 { + break + } } - - // Post to GL: DR Accounts Payable, CR Cash - if err := s.glSvc.SyncVendorPayment(ctx, pmt.ID, pmt.Amount); err != nil { - return fmt.Errorf("failed to sync vendor payment to GL: %w", err) + if remaining > 0 { + return httpx.InvalidStateTransition("the payment exceeds what the named bills still owe", + httpx.Blocker("exceeds_open", fmt.Sprintf("%d cents were asked and %d cents are open", in.AmountCents, in.AmountCents-remaining))) } - - s.logger.Info("Vendor payment created and synced to GL", - "id", pmt.ID, - "vendor_id", pmt.VendorID, - "amount_cents", amountCents, - "invoices_paid", len(req.InvoiceIDs), - ) - return nil + if len(locked) > 0 { + ref := p.ID + entry, err := s.gl.PostEntry(ctx, gl.PostingInput{ + EntryDate: mustDate(in.PaymentDate), Memo: "Vendor payment " + p.ID.String(), + Source: gl.SourceVendorPmt, SourceRefID: &ref, PostedBy: who.Actor, + Legs: []gl.Leg{ + {AccountCode: gl.AccountCodeAP, Description: "Accounts Payable", Debit: in.AmountCents - remaining}, + {AccountCode: gl.AccountCodeCash, Description: "Cash", Credit: in.AmountCents - remaining}, + }, + }) + if err != nil { + if errors.Is(err, gl.ErrPeriodClosed) { + return conflictBlocker("period_closed", "the entry would be dated into a closed fiscal period") + } + if errors.Is(err, gl.ErrAccountMissing) { + return conflictBlocker("account_missing", + "the chart of accounts holds no "+strings.TrimPrefix(err.Error(), gl.ErrAccountMissing.Error()+": ")) + } + return fmt.Errorf("failed to post the vendor payment entry: %w", err) + } + _ = entry + } + if err := s.audit(ctx, audit.Entry{Action: "vendor_payment.recorded", EntityType: "ap_payment", + EntityID: p.ID, UserID: who.Actor, Changes: map[string]interface{}{ + "vendor_id": in.VendorID, "amount_cents": in.AmountCents, "method": string(in.Method), + "invoices": len(locked)}}); err != nil { + return err + } + return s.recordAll(ctx, events) }) - if err != nil { return nil, err } + s.logger.Info("vendor payment recorded", "id", pmt.ID, "vendor_id", pmt.VendorID, "amount_cents", pmt.Amount) return pmt, nil } -// GetVendorInvoice returns a vendor invoice with lines. -func (s *Service) GetVendorInvoice(ctx context.Context, id uuid.UUID) (*VendorInvoice, error) { - inv, err := s.repo.GetVendorInvoice(ctx, id) - if err != nil { - return nil, err +func indexOfUUID(haystack []uuid.UUID, needle uuid.UUID) int { + for i, v := range haystack { + if v == needle { + return i + } } - inv.Lines, _ = s.repo.GetInvoiceLines(ctx, id) - return inv, nil + return 0 } -// ListVendorInvoices returns vendor invoices filtered by vendor and status. -func (s *Service) ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]VendorInvoice, error) { - return s.repo.ListVendorInvoices(ctx, vendorID, status) +// mustDate parses a YYYY-MM-DD wire date into the posting's business date; +// an unparsable value answers today rather than failing the posting. +func mustDate(s string) time.Time { + if t, err := time.Parse("2006-01-02", s); err == nil { + return t + } + return time.Now() } -// ListPayments returns AP payments for a vendor. +// ListPayments lists a vendor's payments. func (s *Service) ListPayments(ctx context.Context, vendorID *uuid.UUID) ([]APPayment, error) { return s.repo.ListPayments(ctx, vendorID) } diff --git a/core/internal/ap/tx_test.go b/core/internal/ap/tx_test.go new file mode 100644 index 00000000..5bf36376 --- /dev/null +++ b/core/internal/ap/tx_test.go @@ -0,0 +1,552 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// The transaction proofs of the AP module (the module recipe's set and the +// lane rule on transactions): a mutation, its journal entry, its audit row +// and its events are one fact, and every transaction runs on its own +// connection, never reaching for a second one from the pool. The concurrency +// tests run the whole service at pool size 4, the two payments of one +// invoice and the payment racing a void among them (ADR 0008 7.4). + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/platform/httpx" + "github.com/gablelbm/gable/internal/testutil" + "github.com/gablelbm/gable/pkg/audit" + "github.com/gablelbm/gable/pkg/branchctx" + "github.com/gablelbm/gable/pkg/database" + "github.com/gablelbm/gable/pkg/outbox" + "github.com/google/uuid" +) + +type failingEvents struct{} + +func (failingEvents) Write(context.Context, outbox.Event) error { + return errors.New("outbox insert failed") +} + +type failingAudit struct{} + +func (failingAudit) Log(context.Context, audit.Entry) error { + return errors.New("audit insert failed") +} + +// createInput is the parsed bill the tx tests enter. +func (f *fixture) createInput(number string) *ap.Input { + req := &ap.CreateRequest{ + VendorID: ptr(f.vendor.String()), + VendorInvoiceNumber: ptr(number), + InvoiceDate: ptr("2026-01-10"), + DueDate: ptr("2026-02-10"), + Lines: []ap.CreateLineRequest{ + {Description: ptr("2x4x8 SPF"), Quantity: raw(`"1"`), UnitPriceTenThousand: raw(`1000`), GLAccountID: ptr(f.expense.String())}, + }, + } + in, err := req.Parse() + if err != nil { + f.t.Fatal(err) + } + return in +} + +func ptr[T any](v T) *T { return &v } + +func raw(s string) []byte { return []byte(s) } + +func payInput(f *fixture, cents int64, ids ...uuid.UUID) *ap.PayInput { + req := &ap.PayRequest{ + VendorID: ptr(f.vendor.String()), AmountCents: raw(strconv.FormatInt(cents, 10)), Method: ptr("CHECK"), + PaymentDate: ptr("2026-01-15"), InvoiceIDs: nil, + } + for _, id := range ids { + req.InvoiceIDs = append(req.InvoiceIDs, id.String()) + } + in, err := req.Parse() + if err != nil { + panic(err) + } + return in +} + +func revision(n int64) ap.Precondition { return ap.Precondition{Revision: &n} } + +// RULE (ADR 0003 section 2): a mutation whose event cannot be recorded does +// not happen, for each kind of write. +func TestTx_FailedEventWriteRollsBackEachWrite(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + fail := f.service(failingEvents{}, audit.NewLogger(f.db), f.db) + good := f.svc + // An in-process caller marks itself a system caller: no branch context to + // hold a payload branch against (ADR 0007 section 2.3). + ctx := branchctx.WithSystem(context.Background()) + + if _, err := fail.Create(ctx, f.createInput("TX-EV-1"), ap.Caller{}); err == nil { + t.Error("create succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d bills survived a rolled back create", n) + } + + created, err := good.Create(ctx, f.createInput("TX-EV-2"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err == nil { + t.Error("approve succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'PENDING' AND revision = 1`, created.ID); n != 1 { + t.Error("an approve survived a rolled back event") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, created.ID); n != 0 { + t.Errorf("%d entries survived a rolled back approve", n) + } + + approved, err := good.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.Transition(ctx, approved.ID, &ap.TransitionInput{To: ap.StatusVoided, Reason: "wrong"}, revision(2), ap.Caller{}); err == nil { + t.Error("void succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'APPROVED' AND revision = 2`, approved.ID); n != 1 { + t.Error("a void survived a rolled back event") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries r JOIN gl_journal_entries o ON o.id = r.reverses_entry_id WHERE o.source_ref_id = $1`, approved.ID); n != 0 { + t.Errorf("%d reversals survived a rolled back void", n) + } + + if _, err := fail.PayVendor(ctx, payInput(f, 1000, approved.ID), ap.Caller{}); err == nil { + t.Error("payment succeeded though its event could not be written") + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments survived a rolled back payment", n) + } + if n := f.count(`SELECT count(*) FROM ap_payment_applications a JOIN vendor_invoices i ON i.id = a.invoice_id WHERE i.vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d applications survived a rolled back payment", n) + } + if got := f.count(`SELECT ROUND(amount_open * 100)::bigint FROM vendor_invoices WHERE id = $1`, approved.ID); got != 10 { + t.Errorf("amount_open = %d after a rolled back payment, want 10", got) + } +} + +// The same rule for the audit row, for each kind of write. +func TestTx_FailedAuditWriteRollsBackEachWrite(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + fail := f.service(outbox.NewWriter(f.db, ""), failingAudit{}, f.db) + good := f.svc + ctx := branchctx.WithSystem(context.Background()) + + if _, err := fail.Create(ctx, f.createInput("TX-AU-1"), ap.Caller{}); err == nil { + t.Error("create succeeded though its audit row could not be written") + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d bills survived a rolled back create", n) + } + + created, err := good.Create(ctx, f.createInput("TX-AU-2"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err == nil { + t.Error("approve succeeded though its audit row could not be written") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, created.ID); n != 0 { + t.Errorf("%d entries survived a rolled back approve", n) + } + + approved, err := good.Transition(ctx, created.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := fail.PayVendor(ctx, payInput(f, 1000, approved.ID), ap.Caller{}); err == nil { + t.Error("payment succeeded though its audit row could not be written") + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments survived a rolled back payment", n) + } +} + +// gatedTx opens as many transactions as the pool has connections and holds +// each INSIDE its transaction at a gate before fn runs. The pool is then +// empty, so any statement that goes to the pool instead of the transaction +// blocks forever. +type gatedTx struct { + db *database.DB + want int32 + entered int32 + gate sync.WaitGroup +} + +func newGatedTx(db *database.DB, want int) *gatedTx { + g := &gatedTx{db: db, want: int32(want)} + g.gate.Add(want) + return g +} + +func (g *gatedTx) RunInTx(ctx context.Context, fn func(ctx context.Context) error) error { + return g.db.RunInTx(ctx, func(txCtx context.Context) error { + if atomic.AddInt32(&g.entered, 1) <= g.want { + g.gate.Done() + g.gate.Wait() + } + return fn(txCtx) + }) +} + +// Saturation: as many contenders as the pool has connections, held inside +// their transactions at a gate, for each kind of write. This is the test that +// fails when code inside a transaction uses the pool. +func TestTx_Pool4SaturationNeedsNoSecondConnection(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + events := outbox.NewWriter(f.db, "") + good := f.service(events, audit.NewLogger(f.db), db) + ctx, cancel := context.WithTimeout(branchctx.WithSystem(context.Background()), 40*time.Second) + defer cancel() + + const contenders = 4 + var seeds []uuid.UUID + for i := 0; i < contenders; i++ { + inv, err := good.Create(ctx, f.createInput("TX-GATE-"+string(rune('A'+i))), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := good.Transition(ctx, inv.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err != nil { + t.Fatal(err) + } + seeds = append(seeds, inv.ID) + } + + phase := func(name string, run func(svc *ap.Service, i int) error) { + t.Helper() + svc := f.service(events, audit.NewLogger(f.db), newGatedTx(db, contenders)) + var wg sync.WaitGroup + errs := make(chan error, contenders) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if err := run(svc, i); err != nil { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Errorf("%s: %v", name, err) + } + } + + phase("create", func(svc *ap.Service, i int) error { + _, err := svc.Create(ctx, f.createInput("TX-GATED-"+string(rune('a'+i))), ap.Caller{}) + return err + }) + phase("approve", func(svc *ap.Service, i int) error { + inv, err := svc.Create(ctx, f.createInput("TX-GATED-AP-"+string(rune('a'+i))), ap.Caller{}) + if err != nil { + return err + } + // The create ran inside its own gated transaction; the approve is the + // write under test. + _, err = svc.Transition(ctx, inv.ID, &ap.TransitionInput{To: ap.StatusApproved}, ap.Precondition{Any: true}, ap.Caller{}) + return err + }) + phase("void", func(svc *ap.Service, i int) error { + inv, err := svc.Create(ctx, f.createInput("TX-GATED-VO-"+string(rune('a'+i))), ap.Caller{}) + if err != nil { + return err + } + _, err = svc.Transition(ctx, inv.ID, &ap.TransitionInput{To: ap.StatusVoided, Reason: "gated"}, ap.Precondition{Any: true}, ap.Caller{}) + return err + }) + phase("pay", func(svc *ap.Service, i int) error { + _, err := svc.PayVendor(ctx, payInput(f, 5, seeds[i]), ap.Caller{}) // each seed owes 10 cents + return err + }) +} + +// Three contenders at pool size 4 (the lane rule on transactions): concurrent +// creates get distinct numbers and one event each, three racers on one +// revision have exactly one winner, two payments of one invoice apply +// exactly the money once, and a payment racing a void leaves exactly one +// winner. The GL ties after every race. +func TestTx_Concurrency_Pool4ThreeContenders(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + svc := f.svc + ctx, cancel := context.WithTimeout(branchctx.WithSystem(context.Background()), 60*time.Second) + defer cancel() + + // Concurrent creates: distinct numbers, one event each. + const contenders, each = 3, 4 + var wg sync.WaitGroup + errs := make(chan error, contenders*each) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < each; j++ { + if _, err := svc.Create(ctx, f.createInput("TX-CC-"+string(rune('A'+i))+string(rune('0'+j))), ap.Caller{}); err != nil { + errs <- err + return + } + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("concurrent create: %v", err) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != contenders*each { + t.Fatalf("%d bills created, want %d", n, contenders*each) + } + if n := f.count(`SELECT count(*) FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id IN (SELECT id FROM vendor_invoices WHERE vendor_id = $1)`, f.vendor); n != contenders*each { + t.Errorf("%d events for %d bills", n, contenders*each) + } + if n := f.count(`SELECT count(DISTINCT number) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != contenders*each { + t.Errorf("%d distinct numbers for %d bills", n, contenders*each) + } + + // Three racers on one revision: exactly one winner. + made, err := svc.Create(ctx, f.createInput("TX-RACE"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + var won, stale, failed atomic.Int32 + var raceWG sync.WaitGroup + for i := 0; i < contenders; i++ { + raceWG.Add(1) + go func() { + defer raceWG.Done() + _, err := svc.Transition(ctx, made.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}) + switch { + case err == nil: + won.Add(1) + default: + if e, ok := err.(*httpx.Error); ok && e.Status == http.StatusConflict { + stale.Add(1) + } else { + t.Errorf("racer: %v", err) + failed.Add(1) + } + } + }() + } + raceWG.Wait() + if won.Load() != 1 || stale.Load() != contenders-1 || failed.Load() != 0 { + t.Errorf("%d winners, %d stale, %d failed; want 1, %d, 0", won.Load(), stale.Load(), failed.Load(), contenders-1) + } + + // Two payments of one invoice: the locks serialize them, the open amount + // is applied exactly once, and the control account still ties. + payable, err := svc.Create(ctx, f.createInput("TX-PAY2"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Transition(ctx, payable.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err != nil { + t.Fatal(err) + } + var payErrs = make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // each pays 5 of the bill's 10 cents: both succeed under the + // locks, and the 10 cents are applied exactly once between them. + if _, err := svc.PayVendor(ctx, payInput(f, 5, payable.ID), ap.Caller{}); err != nil { + payErrs <- err + } + }() + } + wg.Wait() + close(payErrs) + for err := range payErrs { + t.Fatalf("concurrent payment: %v", err) + } + if applied := f.count(`SELECT ROUND(COALESCE(SUM(amount), 0) * 100)::bigint FROM ap_payment_applications WHERE invoice_id = $1`, payable.ID); applied != 10 { + t.Errorf("%d cents applied to a 10 cent bill, want 10 exactly once", applied) + } + if open := f.count(`SELECT ROUND(amount_open * 100)::bigint FROM vendor_invoices WHERE id = $1`, payable.ID); open != 0 { + t.Errorf("amount_open = %d after the payments, want 0", open) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Errorf("after the payments: control %d, open %d", got, want) + } + if !f.balanced() { + t.Error("a payment entry does not balance") + } + + // A payment racing a void on one bill: the row lock serializes them, and + // exactly one wins. + raced, err := svc.Create(ctx, f.createInput("TX-PAYVOID"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Transition(ctx, raced.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err != nil { + t.Fatal(err) + } + var succeeded atomic.Int32 + var raceErrs = make(chan error, 2) + wg.Add(1) + go func() { + defer wg.Done() + if _, err := svc.PayVendor(ctx, payInput(f, 1000, raced.ID), ap.Caller{}); err != nil { + if e, ok := err.(*httpx.Error); !ok || e.Status != http.StatusConflict { + raceErrs <- err + } + } else { + succeeded.Add(1) + } + }() + wg.Add(1) + go func() { + defer wg.Done() + if _, err := svc.Transition(ctx, raced.ID, &ap.TransitionInput{To: ap.StatusVoided, Reason: "racing"}, revision(2), ap.Caller{}); err != nil { + if e, ok := err.(*httpx.Error); !ok || e.Status != http.StatusConflict { + raceErrs <- err + } + } else { + succeeded.Add(1) + } + }() + wg.Wait() + close(raceErrs) + for err := range raceErrs { + t.Fatalf("payment racing void: %v", err) + } + if succeeded.Load() != 1 { + t.Errorf("%d of the payment and the void won, want exactly 1", succeeded.Load()) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Errorf("after the race: control %d, open %d", got, want) + } +} + +// RULE (ADR 0008 7.4 and section 9): the payment locks the named bills in id +// order whatever order the request named them, so concurrent payments of the +// same bills serialize on the locks instead of deadlocking. Each round races +// three payments that name the same three bills in rotated orders; a lock +// order that follows the request would cancel a contender with a deadlock. +func TestTx_PaymentLocksInIDOrder(t *testing.T) { + testutil.LockOutboxTables(t) + db := testutil.RequireDBMaxConns(t, 4) + f := newFixture(t, db) + svc := f.svc + ctx, cancel := context.WithTimeout(branchctx.WithSystem(context.Background()), 60*time.Second) + defer cancel() + + const rounds = 5 + for r := 0; r < rounds; r++ { + ids := make([]uuid.UUID, 3) + for i := range ids { + inv, err := svc.Create(ctx, f.createInput("TX-ORD-"+string(rune('A'+r))+string(rune('0'+i))), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Transition(ctx, inv.ID, &ap.TransitionInput{To: ap.StatusApproved}, revision(1), ap.Caller{}); err != nil { + t.Fatal(err) + } + ids[i] = inv.ID + } + orders := [][]uuid.UUID{ + {ids[0], ids[1], ids[2]}, + {ids[2], ids[1], ids[0]}, + {ids[1], ids[2], ids[0]}, + } + var wg sync.WaitGroup + errs := make(chan error, len(orders)) + for i := range orders { + wg.Add(1) + go func(i int) { + defer wg.Done() + // each bill owes 10 cents and every payment asks for 3, so + // every contender succeeds whatever order it wins the locks in. + if _, err := svc.PayVendor(ctx, payInput(f, 3, orders[i]...), ap.Caller{}); err != nil { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("round %d: concurrent payment across the same bills: %v", r, err) + } + applied := f.count(`SELECT ROUND(COALESCE(SUM(amount), 0) * 100)::bigint FROM ap_payment_applications a + JOIN vendor_invoices i ON i.id = a.invoice_id WHERE i.vendor_id = $1 AND i.number LIKE 'AP-%'`, f.vendor) + if want := int64(9 * (r + 1)); applied != want { + t.Fatalf("round %d: %d cents applied so far, want %d", r, applied, want) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("round %d: control %d, open %d", r, got, want) + } + } +} + +// RULE (the module recipe): a rolled back create abandons its document number +// and never reuses it. The number comes from the column's sequence DEFAULT, +// which mints through the writer's transaction: the rollback gives back the +// row but not the number, so the next bill takes a later one. The behaviour +// is the sequence's own; this test pins it (review r1 P3-1 asked for the +// proof, not a change). +func TestTx_RolledBackCreateAbandonsItsNumber(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + fail := f.service(failingEvents{}, audit.NewLogger(f.db), f.db) + good := f.svc + ctx := branchctx.WithSystem(context.Background()) + + first, err := good.Create(ctx, f.createInput("TX-NUM-1"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + seq := func() int64 { + var last int64 + if err := f.db.Pool.QueryRow(context.Background(), + `SELECT last_value FROM vendor_invoice_number_seq`).Scan(&last); err != nil { + t.Fatal(err) + } + return last + } + minted := seq() + if _, err := fail.Create(ctx, f.createInput("TX-NUM-2"), ap.Caller{}); err == nil { + t.Fatal("the create whose event write fails succeeded") + } + if after := seq(); after <= minted { + t.Errorf("the sequence stands at %d after a rolled back create that minted past %d: the abandoned number would be reused", after, minted) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 1 { + t.Errorf("%d bills stand, want the one committed create", n) + } + next, err := good.Create(ctx, f.createInput("TX-NUM-3"), ap.Caller{}) + if err != nil { + t.Fatal(err) + } + numbered := func(s string) int64 { + n, err := strconv.ParseInt(strings.TrimPrefix(s, "AP-"), 10, 64) + if err != nil { + t.Fatalf("number %q: %v", s, err) + } + return n + } + if numbered(next.Number) <= numbered(first.Number) { + t.Errorf("the bill after a rolled back create takes number %s, not past %s: the abandoned number was reused", next.Number, first.Number) + } +} diff --git a/core/internal/ap/wire_test.go b/core/internal/ap/wire_test.go new file mode 100644 index 00000000..f195356e --- /dev/null +++ b/core/internal/ap/wire_test.go @@ -0,0 +1,1206 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +package ap_test + +// The vendor invoice wire tests (the module recipe's set) and the three live +// failures of ADR 0008 7.4 in their converted form: the approve entry carries +// a debit leg per line, a posting failure fails the act with its cause, and a +// payment applies only to an approved or partial bill. The GL ties after +// every act: each entry balances, and the AP control account (2010) equals +// what the open bills owe. + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/gablelbm/gable/internal/testutil" + "github.com/google/uuid" +) + +var numberPattern = regexp.MustCompile(`^AP-\d{6,}$`) + +// RULE (ADR 0008 7.4, ADR 0001 sections 7 and 8): a bill read carries +// Gable's own number, the vendor's as vendor_invoice_number, a lowercase +// status, money in cents, the scaled unit price, the revision with its ETag, +// and every line in the shared shape; the optional fields are present as +// null, and no legacy field survives. +func TestWire_InvoiceShape(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-2026-001", + "invoice_date": "2026-01-10", "due_date": "2026-02-10", "tax_cents": 488, + "notes": "fixture bill", "lines": []map[string]any{ + {"description": "2x4x8 SPF", "quantity": "2", "unit_price_ten_thousandths": 550000, "gl_account_id": f.expense.String()}, + {"description": "delivery", "quantity": "1", "unit_price_ten_thousandths": 10000, "gl_account_id": f.expense.String()}, + }, + }) + if r.status != http.StatusCreated { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + b := r.body + if !numberPattern.MatchString(str(t, b, "number")) { + t.Errorf("number = %q, want AP-nnnnnn", b["number"]) + } + for key, want := range map[string]string{"vendor_invoice_number": "V-2026-001", "status": "pending", "currency": "USD", + "invoice_date": "2026-01-10", "due_date": "2026-02-10", "notes": "fixture bill", "vendor_name": "C41B Vendor " + f.vendor.String()[:8]} { + 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": 11100, "tax_cents": 488, "total_cents": 11588, + "amount_paid_cents": 0, "amount_open_cents": 11588, "revision": 1} { + if got := num(t, b, key); got != want { + t.Errorf("%s = %d, want %d", key, got, want) + } + } + if etag := r.header.Get("ETag"); etag != `"1"` { + t.Errorf("ETag = %q, want \"1\"", etag) + } + if loc := r.header.Get("Location"); loc != "/api/v1/ap/invoices/"+str(t, b, "id") { + t.Errorf("Location = %q", loc) + } + if b["branch_id"] != f.branch.String() { + t.Errorf("branch_id = %v, want the default branch (no branch in the body, no context branch)", b["branch_id"]) + } + for _, key := range []string{"po_id", "approved_by", "approved_at", "gl_entry_id"} { + if v, ok := b[key]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want present as null", key, v, ok) + } + } + for _, key := range []string{"invoice_number", "subtotal", "tax_amount", "total", "amount_paid", "unit_price", "line_total"} { + if _, ok := b[key]; ok { + t.Errorf("legacy field %q is still on the wire", key) + } + } + lines := b["lines"].([]any) + if len(lines) != 2 { + t.Fatalf("%d lines, want 2: %s", len(lines), r.raw) + } + l := lines[0].(map[string]any) + for key, want := range map[string]string{"quantity": "2", "description": "2x4x8 SPF"} { + if got := str(t, l, key); got != want { + t.Errorf("line %s = %q, want %q", key, got, want) + } + } + for key, want := range map[string]int64{"unit_price_ten_thousandths": 550000, "line_total_cents": 11000, "position": 0} { + if got := num(t, l, key); got != want { + t.Errorf("line %s = %d, want %d", key, got, want) + } + } + if l["gl_account_id"] != f.expense.String() { + t.Errorf("line gl_account_id = %v", l["gl_account_id"]) + } + for _, key := range []string{"purchase_order_line_id", "product_id", "po_freight_charge_id"} { + if v, ok := l[key]; !ok || v != nil { + t.Errorf("line %s = %v (present %v), want present as null", key, v, ok) + } + } + if ev := eventTypes(t, f.db, str(t, b, "id")); fmt.Sprint(ev) != "[vendor_invoice.created]" { + t.Errorf("events = %v, want [vendor_invoice.created]", ev) + } + if n := f.count(`SELECT count(*) FROM audit_log WHERE entity_type = 'vendor_invoice' AND entity_id = $1 AND action = 'vendor_invoice.created'`, b["id"]); n != 1 { + t.Errorf("%d create audit rows, want 1", n) + } + // The number is Gable's own and unique: a second bill may carry the same + // vendor number? No: the vendor's number is unique per vendor. + if bad := f.do("POST", "/api/v1/ap/invoices", f.createBody("V-2026-001", "1", 1000)); bad.status != http.StatusConflict { + t.Errorf("duplicate vendor number = %d, want 409", bad.status) + } +} + +// RULE (ADR 0001 section 4): one 400 with every offending field in details, +// the full path included; a body the route cannot consume is bad_request; an +// unknown field is refused; a create has no status. +func TestWire_CreateValidation(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), + "lines": []map[string]any{ + {"description": "", "quantity": "0", "unit_price_ten_thousandths": -5}, + {"description": "ok", "quantity": "1", "unit_price_ten_thousandths": 100}, + }, + }) + if r.status != http.StatusBadRequest { + t.Fatalf("validation = %d: %s", r.status, r.raw) + } + code, _, fields := errorOf(t, r) + if code != "validation_failed" { + t.Errorf("code = %q, want validation_failed", code) + } + want := []string{"vendor_invoice_number", "invoice_date", "due_date", + "lines[0].description", "lines[0].quantity", "lines[0].unit_price_ten_thousandths", "lines[0].gl_account_id", + "lines[1].gl_account_id"} + if fmt.Sprint(fields) != fmt.Sprint(want) { + t.Errorf("fields = %v, want %v", fields, want) + } + bad := f.do("POST", "/api/v1/ap/invoices", "not-an-object") + if bad.status != http.StatusBadRequest { + t.Errorf("bad body = %d, want 400", bad.status) + } + if code, _, _ := errorOf(t, bad); code != "bad_request" { + t.Errorf("bad body code = %q, want bad_request", code) + } + if unknown := f.do("POST", "/api/v1/ap/invoices", map[string]any{"vendor_id": f.vendor.String(), "nope": 1}); unknown.status != http.StatusBadRequest { + t.Errorf("unknown field = %d, want 400", unknown.status) + } + nv := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": uuid.NewString(), "vendor_invoice_number": "V-X", "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{{"description": "x", "quantity": "1", "unit_price_ten_thousandths": 1, "gl_account_id": f.expense.String()}}}) + if nv.status != http.StatusBadRequest { + t.Errorf("unknown vendor = %d, want 400 naming vendor_id", nv.status) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d bills survived a refused create", n) + } +} + +// A bill's money is bounded by its columns: NUMERIC(12,2) for the line +// total, the tax and the total, NUMERIC(12,4) for the unit price. Past a +// bound the create is a 400 naming the field, never a 500 numeric overflow, +// and nothing is written; at the bound it is created (reviews r5 P3-1 and r6 +// P3-2). +func TestWire_CreateMoneyCeilings(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + const maxCents, maxPrice = int64(999_999_999_999), int64(999_999_999_999) + body := func(number string, tax int64, lines ...map[string]any) map[string]any { + b := map[string]any{"vendor_id": f.vendor.String(), "vendor_invoice_number": number, + "invoice_date": "2026-01-10", "due_date": "2026-02-10", "tax_cents": tax, "lines": lines} + return b + } + for _, c := range []struct { + name string + body map[string]any + field string + }{ + {"tax past the column", body("V-CAP-1", maxCents+1, f.lineBody("1", 100)), "tax_cents"}, + {"tax at int64 max", body("V-CAP-2", 9223372036854775807, f.lineBody("1", 100)), "tax_cents"}, + {"unit price past the column", body("V-CAP-3", 0, f.lineBody("1", maxPrice+1)), "lines[0].unit_price_ten_thousandths"}, + {"line total past the column", body("V-CAP-4", 0, f.lineBody("100000", 100_000_000_000)), "lines[0].quantity"}, + // 99 units at the largest price is 990000000000 cents: one line fits, + // two do not, and nor does one with 100000000000 cents of tax. + {"subtotal past the column", body("V-CAP-5", 0, f.lineBody("99", maxPrice), f.lineBody("99", maxPrice)), "lines"}, + {"total past the column", body("V-CAP-6", 100_000_000_000, f.lineBody("99", maxPrice)), "tax_cents"}, + } { + r := f.do("POST", "/api/v1/ap/invoices", c.body) + if r.status != http.StatusBadRequest { + t.Errorf("%s: create = %d, want 400: %s", c.name, r.status, r.raw) + continue + } + if _, _, fields := errorOf(t, r); fmt.Sprint(fields) != fmt.Sprint([]string{c.field}) { + t.Errorf("%s: fields = %v, want [%s]", c.name, fields, c.field) + } + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d bills survived a refused create", n) + } + // Under the bound: the largest price, and a line, tax and total close to + // the column's limit, are created. + r := f.do("POST", "/api/v1/ap/invoices", body("V-CAP-OK", 9_999_999_999, f.lineBody("99", maxPrice))) + if r.status != http.StatusCreated { + t.Fatalf("create at the bound = %d, want 201: %s", r.status, r.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Errorf("after the create: control %d, open %d", got, want) + } +} + +// RULE (ADR 0001 sections 1, 2 and 5): the list is the cursor envelope, +// newest first, every row once, with filters that filter and unknown or +// uppercase values refused; an empty page is [] in the bytes. +func TestWire_List(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + var ids []string + for i := 0; i < 3; i++ { + id, _ := f.enter(fmt.Sprintf("V-L%d", i), "1", 1000) + ids = append(ids, id) + } + var seen []string + cursor := "" + for page := 0; page < 5; page++ { + path := "/api/v1/ap/invoices?limit=2&vendor_id=" + f.vendor.String() + if cursor != "" { + path += "&cursor=" + cursor + } + r := f.do("GET", path, nil) + if r.status != 200 { + t.Fatalf("list = %d: %s", r.status, r.raw) + } + for _, it := range r.body["items"].([]any) { + seen = append(seen, it.(map[string]any)["id"].(string)) + } + if num(t, r.body, "limit") != 2 { + t.Errorf("limit echo = %v, want 2", r.body["limit"]) + } + if r.body["next_cursor"] == nil { + break + } + cursor = r.body["next_cursor"].(string) + } + want := []string{ids[2], ids[1], ids[0]} + if fmt.Sprint(seen) != fmt.Sprint(want) { + t.Errorf("the cursor walk saw %v, want %v (newest first, each once)", seen, want) + } + if r := f.do("GET", "/api/v1/ap/invoices?vendor_id="+f.vendor.String()+"&include=total", nil); r.status != 200 || num(t, r.body, "total") != 3 { + t.Errorf("include=total = %d %v, want 3", r.status, r.body["total"]) + } + if r := f.do("GET", "/api/v1/ap/invoices?vendor_id="+f.vendor.String()+"&status=pending", nil); r.status != 200 || len(r.body["items"].([]any)) != 3 { + t.Errorf("status=pending = %d, want the 3 pending", r.status) + } + if r := f.do("GET", "/api/v1/ap/invoices?vendor_id="+f.vendor.String()+"&status=approved,partial", nil); r.status != 200 || len(r.body["items"].([]any)) != 0 { + t.Errorf("status=approved,partial = %d %s, want none", r.status, r.raw) + } + if r := f.do("GET", "/api/v1/ap/invoices?vendor_id="+f.vendor.String()+"&po_id="+uuid.NewString(), nil); r.status != 200 || len(r.body["items"].([]any)) != 0 { + t.Errorf("a po_id filter that matches nothing: %d %s", r.status, r.raw) + } + empty := f.do("GET", "/api/v1/ap/invoices?vendor_id="+uuid.NewString(), nil) + if !strings.Contains(string(empty.raw), `"items":[]`) { + t.Errorf("an empty page = %s, want items [] in the bytes", empty.raw) + } + for _, bad := range []string{"offset=1", "status=PENDING", "status=unknown", "vendor_id=zzz", "limit=0", "limit=201", "cursor=junk", "nope=1"} { + if r := f.do("GET", "/api/v1/ap/invoices?"+bad, nil); r.status != 400 { + t.Errorf("?%s = %d, want 400", bad, r.status) + } + } +} + +// RULE (ADR 0001 section 3): not found and a malformed id are the wire's +// envelope. +func TestWire_GetRefusals(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("GET", "/api/v1/ap/invoices/"+uuid.NewString(), nil) + if r.status != 404 { + t.Fatalf("unknown bill = %d, want 404", r.status) + } + if code, _, _ := errorOf(t, r); code != "not_found" { + t.Errorf("code = %q, want not_found", code) + } + r = f.do("GET", "/api/v1/ap/invoices/not-a-uuid", nil) + if code, _, fields := errorOf(t, r); r.status != 400 || code != "bad_request" || len(fields) != 1 || fields[0] != "id" { + t.Errorf("malformed id = %d %q %v, want 400 bad_request naming id", r.status, code, fields) + } +} + +// RULE (ADR 0001 section 11): a write needs the revision, through If-Match or +// the body; a mismatch is 409 stale_revision; the new revision and its ETag +// come back on success. +func TestWire_Revision(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, _ := f.enter("V-R", "1", 1000) + body := func() map[string]any { + return map[string]any{"to": "approved"} + } + if r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", body()); r.status != http.StatusPreconditionRequired { + t.Errorf("no precondition = %d, want 428", r.status) + } + r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", map[string]any{"to": "approved", "revision": 5}) + if r.status != http.StatusConflict { + t.Errorf("stale revision = %d, want 409", r.status) + } + if code, _, _ := errorOf(t, r); code != "stale_revision" { + t.Errorf("stale code = %q", code) + } + if r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", map[string]any{"to": "approved", "revision": 1}, "If-Match", `W/"1"`); r.status != http.StatusOK { + t.Errorf("weak If-Match = %d, want 200", r.status) + } + if r := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", map[string]any{"to": "voided", "revision": 2, "reason": "wrong bill"}, "If-Match", `"3"`); r.status != http.StatusBadRequest { + t.Errorf("header and body disagree = %d, want 400", r.status) + } +} + +// RULE (ADR 0008 7.4 and section 10): the lifecycle. Approve posts the entry; +// void reverses it; the events land in order; the edges the lifecycle refuses +// answer 409 invalid_state_transition. +func TestWire_Lifecycle(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-LC", "2", 500000) + + r := f.approve(id, r1) + if r.status != http.StatusOK || str(t, r.body, "status") != "approved" { + t.Fatalf("approve = %d %v: %s", r.status, r.body["status"], r.raw) + } + if rev(t, r) != 2 || r.header.Get("ETag") != `"2"` { + t.Errorf("approve revision = %d %q, want 2 \"2\"", rev(t, r), r.header.Get("ETag")) + } + if str(t, r.body, "approved_by") == "" || r.body["approved_at"] == nil { + t.Errorf("approved_by %v approved_at %v, want set", r.body["approved_by"], r.body["approved_at"]) + } + // A second approve is refused. + again := f.approve(id, 2) + if again.status != http.StatusConflict { + t.Errorf("second approve = %d, want 409", again.status) + } + if code, blockers, _ := errorOf(t, again); code != "invalid_state_transition" || len(blockers) == 0 { + t.Errorf("second approve = %q %v", code, blockers) + } + // A bill with money applied cannot be voided. + if p := f.pay(10000, id); p.status != http.StatusCreated { + t.Fatalf("pay = %d: %s", p.status, p.raw) + } + v := f.void(id, 3, "wrong bill") + if v.status != http.StatusConflict { + t.Fatalf("void with payments = %d, want 409", v.status) + } + if code, blockers, _ := errorOf(t, v); code != "invalid_state_transition" || blockers[0] != "has_payments" { + t.Errorf("void with payments = %q %v, want has_payments", code, blockers) + } + // A pending bill voids with no entry to reverse. + id2, r2 := f.enter("V-LC2", "1", 1000) + vp := f.void(id2, r2, "entered twice") + if vp.status != http.StatusOK || str(t, vp.body, "status") != "voided" { + t.Fatalf("void pending = %d: %s", vp.status, vp.raw) + } + if num(t, vp.body, "amount_open_cents") != 0 { + t.Errorf("a voided bill owes %d, want 0", num(t, vp.body, "amount_open_cents")) + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, id2); n != 0 { + t.Errorf("a pending bill voided with %d entries, want none", n) + } + // An approved bill voids by reversing its entry. + id3, r3 := f.enter("V-LC3", "1", 1000) + if a := f.approve(id3, r3); a.status != http.StatusOK { + t.Fatalf("approve = %s", a.raw) + } + va := f.void(id3, 2, "wrong vendor") + if va.status != http.StatusOK || str(t, va.body, "status") != "voided" { + t.Fatalf("void approved = %d: %s", va.status, va.raw) + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries r JOIN gl_journal_entries o ON o.id = r.reverses_entry_id WHERE o.source_ref_id = $1`, id3); n != 1 { + t.Errorf("the void wrote %d reversals, want 1", n) + } + if ev := eventTypes(t, f.db, id3); fmt.Sprint(ev) != "[vendor_invoice.created vendor_invoice.approved vendor_invoice.voided]" { + t.Errorf("events = %v", ev) + } + // The targets a bill never reaches by a transition. + for _, to := range []string{"pending", "partial", "paid"} { + if tr := f.do("POST", "/api/v1/ap/invoices/"+id2+"/transitions", map[string]any{"to": to, "revision": 2, "reason": "x"}); tr.status != http.StatusConflict { + t.Errorf("transition to %s = %d, want 409", to, tr.status) + } + } + if nr := f.do("POST", "/api/v1/ap/invoices/"+id2+"/transitions", map[string]any{"to": "voided", "revision": 2}); nr.status != http.StatusBadRequest { + t.Errorf("void without a reason = %d, want 400", nr.status) + } +} + +// LIVE FAILURE (ADR 0008 7.4, section 14), converted form: approve loads the +// bill's lines, so the entry carries a debit leg per line beside the Accounts +// Payable credit, and it balances. +func TestWire_ApproveEntryCarriesLineDebits(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-GL1", "2", 500000) // 2 at 50.00 = 100.00 + if a := f.approve(id, r1); a.status != http.StatusOK { + t.Fatalf("approve = %s", a.raw) + } + entries, legs := f.entryLegs(id) + if entries != 1 { + t.Fatalf("%d entries, want 1", entries) + } + if len(legs) != 2 { + t.Fatalf("the entry's accounts = %v, want 5020 (the line) and 2010 (the payable)", legs) + } + if legs["5020"].debit != 10000 || legs["5020"].credit != 0 { + t.Errorf("5020 = %+v, want debit 10000", legs["5020"]) + } + if legs["2010"].debit != 0 || legs["2010"].credit != 10000 { + t.Errorf("2010 = %+v, want credit 10000", legs["2010"]) + } + if !f.balanced() { + t.Error("the approve entry does not balance") + } +} + +// A bill with tax approves balanced: the tax spreads across the line accounts +// pro rata to their extensions, the last line taking the remainder. +func TestWire_ApproveSpreadsTax(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-TAX", + "invoice_date": "2026-01-10", "due_date": "2026-02-10", "tax_cents": 101, + "lines": []map[string]any{ + {"description": "two thirds", "quantity": "2", "unit_price_ten_thousandths": 500000, "gl_account_id": f.expense.String()}, + {"description": "one third", "quantity": "1", "unit_price_ten_thousandths": 500000, "gl_account_id": f.expense.String()}, + }, + }) + if r.status != http.StatusCreated { + t.Fatalf("create = %d: %s", r.status, r.raw) + } + id := str(t, r.body, "id") + if num(t, r.body, "subtotal_cents") != 15000 || num(t, r.body, "total_cents") != 15101 { + t.Fatalf("subtotal %v total %v", r.body["subtotal_cents"], r.body["total_cents"]) + } + if a := f.approve(id, 1); a.status != http.StatusOK { + t.Fatalf("approve = %s", a.raw) + } + _, legs := f.entryLegs(id) + if legs["5020"].debit != 15101 { + t.Errorf("5020 debit = %d, want 15101 (15000 lines plus the 101 tax)", legs["5020"].debit) + } + if legs["2010"].credit != 15101 { + t.Errorf("2010 credit = %d, want 15101", legs["2010"].credit) + } + if !f.balanced() { + t.Error("the taxed approve entry does not balance") + } +} + +// A line with no account cannot be approved (a legacy bill): the approve +// refuses naming the line, and nothing posts. +func TestWire_ApproveRefusesLineWithoutAccount(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, _ := f.enter("V-NOACC", "1", 1000) + mustExec(t, f.db, `UPDATE vendor_invoice_lines SET gl_account_id = NULL WHERE invoice_id = $1`, id) + r := f.approve(id, 1) + if r.status != http.StatusConflict { + t.Fatalf("approve = %d, want 409", r.status) + } + if code, blockers, _ := errorOf(t, r); code != "conflict" || blockers[0] != "line_needs_account" { + t.Errorf("approve = %q %v, want line_needs_account", code, blockers) + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, id); n != 0 { + t.Errorf("%d entries posted for a refused approve", n) + } + if s := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'PENDING'`, id); s != 1 { + t.Error("the bill did not stay pending") + } +} + +// LIVE FAILURE (ADR 0008 7.4, section 14), converted form: a posting failure +// fails the act with its cause. The period holding the bill's date is closed, +// so the journal insert refuses and the approve answers 409 period_closed, +// leaving the bill pending and the ledger untouched. +func TestWire_ApprovePostingFailureFailsTheAct(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-CLOSED", "1", 1000) + mustExec(t, f.db, `UPDATE gl_fiscal_periods SET status = 'CLOSED' + WHERE start_date <= '2026-01-10' AND end_date >= '2026-01-10'`) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE gl_fiscal_periods SET status = 'OPEN' + WHERE start_date <= '2026-01-10' AND end_date >= '2026-01-10'`) + }) + closed := f.approve(id, r1) + if closed.status != http.StatusConflict { + t.Fatalf("approve into a closed period = %d, want 409: %s", closed.status, closed.raw) + } + if code, blockers, _ := errorOf(t, closed); code != "conflict" || blockers[0] != "period_closed" { + t.Errorf("approve = %q %v, want period_closed", code, blockers) + } + if s := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'PENDING' AND revision = 1`, id); s != 1 { + t.Error("the bill did not stay pending at revision 1") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, id); n != 0 { + t.Errorf("%d entries posted into a closed period", n) + } +} + +// LIVE FAILURE (ADR 0008 7.4, section 14), converted form: a payment applies +// only to an approved or partial bill. A pending bill is refused with the +// invoice_not_approved blocker and nothing is written. +func TestWire_PaymentRefusesUnapprovedInvoice(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, _ := f.enter("V-PENDING", "1", 1000) + pr := f.pay(1000, id) + if pr.status != http.StatusConflict { + t.Fatalf("pay pending = %d, want 409: %s", pr.status, pr.raw) + } + if code, blockers, _ := errorOf(t, pr); code != "invalid_state_transition" || blockers[0] != "invoice_not_approved" { + t.Errorf("pay pending = %q %v, want invoice_not_approved", code, blockers) + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments survived the refusal", n) + } + if n := f.count(`SELECT count(*) FROM ap_payment_applications a JOIN vendor_invoices i ON i.id = a.invoice_id WHERE i.vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d applications were written against the pending bill", n) + } +} + +// RULE (ADR 0008 7.4): a payment applies to approved and partial bills, in id +// order, and the GL ties after every act: each entry balances and the AP +// control account equals what the open bills owe. +func TestWire_PaymentAppliesAndTies(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + a, ra := f.enter("V-PAY-A", "1", 500000) // 50.00 + b, rb := f.enter("V-PAY-B", "1", 300000) // 30.00 + if aa := f.approve(a, ra); aa.status != http.StatusOK { + t.Fatal(aa.raw) + } + if ab := f.approve(b, rb); ab.status != http.StatusOK { + t.Fatal(ab.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the approves: control %d, open %d", got, want) + } + // A partial payment of the first bill. + if pp := f.pay(2000, a); pp.status != http.StatusCreated { + t.Fatalf("partial pay = %d: %s", pp.status, pp.raw) + } + if g := f.do("GET", "/api/v1/ap/invoices/"+a, nil); g.status != 200 || str(t, g.body, "status") != "partial" || + num(t, g.body, "amount_paid_cents") != 2000 || num(t, g.body, "amount_open_cents") != 3000 { + t.Fatalf("after the partial: %s", g.raw) + } + if ev := eventTypes(t, f.db, a); fmt.Sprint(ev) != "[vendor_invoice.created vendor_invoice.approved vendor_invoice.partial]" { + t.Errorf("events = %v", ev) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the partial payment: control %d, open %d", got, want) + } + // A payment naming both bills, more than the first still owes: the rest + // lands on the second (3000 closes A, 2000 of B's 3000). + if pb := f.pay(5000, a, b); pb.status != http.StatusCreated { + t.Fatalf("pay both = %d: %s", pb.status, pb.raw) + } + if g := f.do("GET", "/api/v1/ap/invoices/"+a, nil); str(t, g.body, "status") != "paid" { + t.Errorf("bill A = %s, want paid", g.raw) + } + if g := f.do("GET", "/api/v1/ap/invoices/"+b, nil); str(t, g.body, "status") != "partial" || num(t, g.body, "amount_open_cents") != 1000 { + t.Errorf("bill B = %s, want partial owing 1000", g.raw) + } + if ev := eventTypes(t, f.db, a); fmt.Sprint(ev) != "[vendor_invoice.created vendor_invoice.approved vendor_invoice.partial vendor_invoice.paid]" { + t.Errorf("events = %v", ev) + } + if !f.balanced() { + t.Error("a payment entry does not balance") + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after both payments: control %d, open %d", got, want) + } + // More than the bills owe is refused, and nothing is written. + before := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor) + over := f.pay(100000, b) // b is partial, owing 1000 + if over.status != http.StatusConflict { + t.Fatalf("overpay = %d, want 409: %s", over.status, over.raw) + } + if _, blockers, _ := errorOf(t, over); blockers[0] != "exceeds_open" { + t.Errorf("overpay = %v, want exceeds_open", blockers) + } + if after := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); after != before { + t.Errorf("%d payments were written for a refused overpay", after-before) + } + // A bill of another vendor is not this payment's to pay. + if ui := f.pay(700, uuid.NewString()); ui.status != http.StatusBadRequest { + t.Errorf("unknown invoice = %d, want 400 naming invoice_ids[0]", ui.status) + } + // An old-wire body is a validation failure, not a 500. + if eb := f.do("POST", "/api/v1/ap/payments", map[string]any{"vendor_id": f.vendor.String()}); eb.status != http.StatusBadRequest { + t.Errorf("an empty pay body = %d, want 400", eb.status) + } + // The payment list stays a bare array until its own conversion. + if pl := f.do("GET", "/api/v1/ap/payments?vendor_id="+f.vendor.String(), nil); pl.status != 200 || !strings.HasPrefix(string(pl.raw), "[") || strings.Count(string(pl.raw), `"id"`) != 2 { + t.Errorf("list payments = %d %s, want a bare array of the 2 payments", pl.status, pl.raw) + } +} + +// RULE: an operator's gap in the chart of accounts (no 2010) is a 409 blocker +// naming the account, not a 500: nothing is written either way, but the wire +// answer must tell the operator what to mend (review r2 P3-3). +func TestWire_ApproveAccountMissing(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + mustExec(t, f.db, `UPDATE gl_accounts SET code = 'X2010' WHERE code = '2010'`) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE gl_accounts SET code = '2010' WHERE code = 'X2010'`) + }) + id, r1 := f.enter("V-NOAP", "1", 1000) + a := f.approve(id, r1) + if a.status != http.StatusConflict { + t.Fatalf("approve with no 2010 = %d, want 409: %s", a.status, a.raw) + } + if code, blockers, _ := errorOf(t, a); code != "conflict" || blockers[0] != "account_missing" { + t.Errorf("approve with no 2010 = %q %v, want account_missing", code, blockers) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'PENDING' AND revision = 1`, id); n != 1 { + t.Error("the bill did not stay pending at revision 1") + } + if n := f.count(`SELECT count(*) FROM gl_journal_entries WHERE source_ref_id = $1`, id); n != 0 { + t.Errorf("%d entries posted without the control account", n) + } + // A payment already made on an approved bill leaves the same gap on its + // entry (DR 2010), so the payment act refuses the same way. + mustExec(t, f.db, `UPDATE gl_accounts SET code = '2010' WHERE code = 'X2010'`) + aa := f.approve(id, r1) + if aa.status != http.StatusOK { + t.Fatal(aa.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the approve: control %d, open %d", got, want) + } + mustExec(t, f.db, `UPDATE gl_accounts SET code = 'X2010' WHERE code = '2010'`) + p := f.pay(10, id) + if p.status != http.StatusConflict { + t.Fatalf("pay with no 2010 = %d, want 409: %s", p.status, p.raw) + } + if _, blockers, _ := errorOf(t, p); blockers[0] != "account_missing" { + t.Errorf("pay with no 2010 = %v, want account_missing", blockers) + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments survived the refusal", n) + } + if !f.balanced() { + t.Error("an entry does not balance") + } +} + +// RULE (ADR 0001 section 4): a client mistake is a 400 naming the field, never +// a 500. A payment naming one bill twice would apply it twice (the second pass +// reuses the stale locked summary) and only the amount_open CHECK stops it, so +// Parse refuses the repeated id up front. +func TestWire_PaymentRefusesDuplicateInvoiceIDs(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-DUP-ID", "1", 1000) + if a := f.approve(id, r1); a.status != http.StatusOK { + t.Fatal(a.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the approve: control %d, open %d", got, want) + } + dup := f.pay(12, id, id) + if dup.status != http.StatusBadRequest { + t.Fatalf("pay a repeated invoice id = %d, want 400: %s", dup.status, dup.raw) + } + if _, _, fields := errorOf(t, dup); len(fields) == 0 || fields[0] != "invoice_ids[1]" { + t.Errorf("pay a repeated invoice id names %v, want invoice_ids[1]", fields) + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d payments written for a refused duplicate", n) + } + if n := f.count(`SELECT count(*) FROM ap_payment_applications a JOIN vendor_invoices i ON i.id = a.invoice_id WHERE i.vendor_id = $1`, f.vendor); n != 0 { + t.Errorf("%d applications written for a refused duplicate", n) + } + if !f.balanced() { + t.Error("an entry does not balance") + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Errorf("after the refused duplicate: control %d, open %d", got, want) + } + // A payment naming the bill once still applies. + if p := f.pay(10, id); p.status != http.StatusCreated { + t.Errorf("pay after the refusal = %d, want 201: %s", p.status, p.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want || want != 0 { + t.Errorf("after the payment: control %d, open %d, want both 0", got, want) + } +} + +// RULE (ADR 0008 section 11, converted here by the lead's promotion of review +// r2 P3-4): the payment request takes integer cents on the wire, like every +// other money field. A decimal amount is a validation failure, never a silent +// rounding, and the old float dollars field is gone. +func TestWire_PayAmountIsCents(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-CENTS", "1", 1000) + if a := f.approve(id, r1); a.status != http.StatusOK { + t.Fatal(a.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the approve: control %d, open %d", got, want) + } + body := func(amount any) map[string]any { + return map[string]any{"vendor_id": f.vendor.String(), "amount_cents": amount, "method": "CHECK", + "payment_date": "2026-01-15", "invoice_ids": []any{id}} + } + if p := f.do("POST", "/api/v1/ap/payments", body(10)); p.status != http.StatusCreated { + t.Fatalf("pay integer cents = %d, want 201: %s", p.status, p.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want || want != 0 { + t.Errorf("after the payment: control %d, open %d, want both 0", got, want) + } + if !f.balanced() { + t.Error("the payment entry does not balance") + } + // A decimal is refused, never silently rounded (10.5 is not a cent amount). + id2, r1b := f.enter("V-CENTS-2", "1", 1000) + if a := f.approve(id2, r1b); a.status != http.StatusOK { + t.Fatal(a.raw) + } + body2 := func(amount any) map[string]any { + return map[string]any{"vendor_id": f.vendor.String(), "amount_cents": amount, "method": "CHECK", + "payment_date": "2026-01-15", "invoice_ids": []any{id2}} + } + d := f.do("POST", "/api/v1/ap/payments", body2(10.5)) + if d.status != http.StatusBadRequest { + t.Fatalf("pay a decimal = %d, want 400: %s", d.status, d.raw) + } + if _, _, fields := errorOf(t, d); len(fields) == 0 || fields[0] != "amount_cents" { + t.Errorf("pay a decimal names %v, want amount_cents", fields) + } + // An amount past the payment column's NUMERIC(12,2) is a 400 naming the + // field, never a numeric overflow answered as a 500. + for _, huge := range []int64{math.MaxInt64, 1_000_000_000_000} { + h := f.do("POST", "/api/v1/ap/payments", body2(huge)) + if h.status != http.StatusBadRequest { + t.Fatalf("pay %d cents = %d, want 400: %s", huge, h.status, h.raw) + } + if _, _, fields := errorOf(t, h); len(fields) == 0 || fields[0] != "amount_cents" { + t.Errorf("pay %d cents names %v, want amount_cents", huge, fields) + } + } + if got, want := f.controlBalance(), f.openSum(); got != want || !f.balanced() { + t.Errorf("after the refused huge amounts: control %d, open %d, balanced %v", got, want, f.balanced()) + } + // The old float dollars field is not read. + old := f.do("POST", "/api/v1/ap/payments", map[string]any{ + "vendor_id": f.vendor.String(), "amount": 0.10, "method": "CHECK", + "payment_date": "2026-01-15", "invoice_ids": []any{id2}}) + if old.status != http.StatusBadRequest { + t.Errorf("pay the old float field = %d, want 400", old.status) + } + if n := f.count(`SELECT count(*) FROM ap_payments WHERE vendor_id = $1`, f.vendor); n != 1 { + t.Errorf("%d payments stand, want the one accepted", n) + } +} + +// RULE (ADR 0008 7.4): a bill approved before migration 106 carries its entry +// but no pointer to it; the migration links the newest posted, unreversed, +// balanced VENDOR_INVOICE entry, and the void reverses exactly that entry, so +// the tie (every entry balanced, 2010 equal to what the open bills owe) holds +// for a legacy bill as for a new one. A legacy approved bill whose entry is +// missing is refused rather than voided silently with the credit stranded in +// 2010, and so is one whose linked entry is the base's one leg shape: a void +// would mirror it into another unbalanced entry. +// A bill that prices to zero (a zero price, or a positive price that rounds +// to 0 cents at the line) approves with no entry, because there is nothing to +// post. It owes nothing, so its void has nothing to reverse and goes through: +// it must not be refused as a bill with no linked entry (review r6 P2-1). +func TestWire_VoidZeroBill(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + + for _, c := range []struct { + number string + price int64 + }{ + {"V-ZERO-1", 0}, // a zero price + {"V-ZERO-2", 5}, // 0.0005 a unit, 0 cents at the line + } { + id, r1 := f.enter(c.number, "1", c.price) + a := f.approve(id, r1) + if a.status != http.StatusOK { + t.Fatalf("%s: approve = %d: %s", c.number, a.status, a.raw) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND total = 0 AND gl_entry_id IS NULL`, id); n != 1 { + t.Fatalf("%s: the approved bill is not a zero total with no entry", c.number) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("%s: after the approve: control %d, open %d", c.number, got, want) + } + before := f.count(`SELECT count(*) FROM gl_journal_entries`) + v := f.void(id, 2, "entered in error") + if v.status != http.StatusOK { + t.Fatalf("%s: void the zero bill = %d, want 200: %s", c.number, v.status, v.raw) + } + if str(t, v.body, "status") != "voided" { + t.Errorf("%s: the zero bill = %s, want voided", c.number, v.raw) + } + if after := f.count(`SELECT count(*) FROM gl_journal_entries`); after != before { + t.Errorf("%s: %d entries were written for a void with nothing to reverse", c.number, after-before) + } + if !f.balanced() { + t.Errorf("%s: an entry does not balance", c.number) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Errorf("%s: after the void: control %d, open %d", c.number, got, want) + } + } +} + +func TestWire_VoidLegacyApprovedBill(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + + // The linked legacy bill: approved with its entry, the pointer absent (the + // state every pre-106 approved bill holds after the migration). + id, r1 := f.enter("V-LEG-1", "1", 1000) // 10.00 + if a := f.approve(id, r1); a.status != http.StatusOK { + t.Fatal(a.raw) + } + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the approve: control %d, open %d", got, want) + } + mustExec(t, f.db, `UPDATE vendor_invoices SET gl_entry_id = NULL WHERE id = $1`, id) + // The migration's link: the entry the old sync posted is the bill's + // newest posted, unreversed VENDOR_INVOICE entry. + mustExec(t, f.db, `UPDATE vendor_invoices vi SET gl_entry_id = e.id + FROM gl_journal_entries e + WHERE e.source = 'VENDOR_INVOICE' AND e.source_ref_id = vi.id + AND e.status = 'POSTED' AND e.reverses_entry_id IS NULL AND vi.id = $1`, id) + v := f.void(id, 2, "legacy bill voided") + if v.status != http.StatusOK { + t.Fatalf("void the linked legacy bill = %d, want 200: %s", v.status, v.raw) + } + if str(t, v.body, "status") != "voided" { + t.Errorf("the legacy bill = %s, want voided", v.raw) + } + reversals := f.count(`SELECT count(*) FROM gl_journal_entries r JOIN gl_journal_entries o ON o.id = r.reverses_entry_id WHERE o.source_ref_id = $1`, id) + if reversals != 1 { + t.Errorf("%d reversals of the legacy entry, want exactly 1", reversals) + } + _, legs := f.entryLegsWhere(t, f.db, `e.source_ref_id = $1 + OR e.reverses_entry_id IN (SELECT id FROM gl_journal_entries WHERE source_ref_id = $1)`, id) + if legs["5020"] != (leg{debit: 10, credit: 10}) || legs["2010"] != (leg{debit: 10, credit: 10}) { + t.Errorf("the reversal is not the exact mirror: %v", legs) + } + if !f.balanced() { + t.Error("an entry of the legacy void does not balance") + } + if got, want := f.controlBalance(), f.openSum(); got != want || want != 0 { + t.Errorf("after the legacy void: control %d, open %d, want both 0", got, want) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND gl_entry_id IS NOT NULL`, id); n != 1 { + t.Error("the void lost the legacy entry link") + } + + // The unlinked legacy bill: its entry never posted (the base swallowed the + // journal error). The void is refused with a blocker, and nothing is + // written: no reversal, no status change, the tie untouched. + id2, r1b := f.enter("V-LEG-2", "1", 2000) // 20.00 + if a := f.approve(id2, r1b); a.status != http.StatusOK { + t.Fatal(a.raw) + } + mustExec(t, f.db, `UPDATE vendor_invoices SET gl_entry_id = NULL WHERE id = $1`, id2) + mustExec(t, f.db, `DELETE FROM gl_journal_lines WHERE journal_entry_id IN + (SELECT id FROM gl_journal_entries WHERE source_ref_id = $1)`, id2) + mustExec(t, f.db, `DELETE FROM gl_journal_entries WHERE source_ref_id = $1`, id2) + before := f.count(`SELECT count(*) FROM gl_journal_entries`) + rv := f.void(id2, 2, "legacy bill voided") + if rv.status != http.StatusConflict { + t.Fatalf("void the unlinked legacy bill = %d, want 409: %s", rv.status, rv.raw) + } + if code, blockers, _ := errorOf(t, rv); code != "invalid_state_transition" || blockers[0] != "entry_not_linked" { + t.Errorf("void the unlinked legacy bill = %q %v, want entry_not_linked", code, blockers) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'APPROVED' AND revision = 2`, id2); n != 1 { + t.Error("a refused legacy void changed the bill") + } + if after := f.count(`SELECT count(*) FROM gl_journal_entries`); after != before { + t.Errorf("%d entries were written for a refused void", after-before) + } + + // The base's real legacy shape: the entry the base's approve posted has + // one leg, the Accounts Payable credit for the whole total with no debit + // (the base loaded no lines). An operator may link such an entry by hand, + // or an older link may already point at it; the void must not mirror it + // into a second unbalanced entry: it is refused with the + // entry_unbalanced blocker and nothing is written (review r4 P2-1). + id3, r1c := f.enter("V-LEG-3", "1", 3000) // 30.00 + if a := f.approve(id3, r1c); a.status != http.StatusOK { + t.Fatal(a.raw) + } + // Rewrite the approve entry into the base's one leg shape: only the + // Accounts Payable credit stays. + mustExec(t, f.db, `DELETE FROM gl_journal_lines WHERE journal_entry_id IN + (SELECT id FROM gl_journal_entries WHERE source_ref_id = $1) + AND account_id <> (SELECT id FROM gl_accounts WHERE code = '2010')`, id3) + if f.balanced() { + t.Fatal("the seeded one leg entry balances; the seed did not take") + } + controlBefore, openBefore := f.controlBalance(), f.openSum() + before3 := f.count(`SELECT count(*) FROM gl_journal_entries`) + bv := f.void(id3, 2, "legacy bill voided") + if bv.status != http.StatusConflict { + t.Fatalf("void a bill whose linked entry has one leg = %d, want 409: %s", bv.status, bv.raw) + } + if code, blockers, _ := errorOf(t, bv); code != "invalid_state_transition" || blockers[0] != "entry_unbalanced" { + t.Errorf("void a bill whose linked entry has one leg = %q %v, want entry_unbalanced", code, blockers) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE id = $1 AND status = 'APPROVED' AND revision = 2`, id3); n != 1 { + t.Error("a refused one leg void changed the bill") + } + if after := f.count(`SELECT count(*) FROM gl_journal_entries`); after != before3 { + t.Errorf("%d entries were written for a refused one leg void", after-before3) + } + // The tie after the refused act: the only unbalanced entry in the fixture + // is the seeded legacy one (no act of the fixture wrote an unbalanced + // entry), and the refusal moved no money: the control account and the + // open sum stand where the seed left them (the earlier unlinked bill's + // deleted entry is the legacy gap, its credit absent from 2010). + if n := f.count(`SELECT count(*) FROM gl_journal_entries e + JOIN (SELECT journal_entry_id, SUM(debit) d, SUM(credit) c FROM gl_journal_lines GROUP BY journal_entry_id) s + ON s.journal_entry_id = e.id + WHERE e.id IN (SELECT id FROM gl_journal_entries + WHERE source_ref_id IN (SELECT id FROM vendor_invoices WHERE vendor_id = $1) + OR source_ref_id IN (SELECT id FROM ap_payments WHERE vendor_id = $1) + OR reverses_entry_id IN (SELECT gl_entry_id FROM vendor_invoices WHERE vendor_id = $1)) + AND s.d <> s.c`, f.vendor); n != 1 { + t.Errorf("%d unbalanced entries stand, want only the seeded legacy one", n) + } + if got, want := f.controlBalance(), controlBefore; got != want { + t.Errorf("after the refused one leg void: control %d, want the untouched %d", got, want) + } + if got, want := f.openSum(), openBefore; got != want { + t.Errorf("after the refused one leg void: open %d, want the untouched %d", got, want) + } +} + +// RULE (ADR 0008 section 11): the branch wall on every read and write. A +// caller held to one branch finds another branch's bill a 404 on the by-id +// routes and never in the list; a create naming a branch it may not target is +// a 403 naming branch_id. +func TestWire_BranchWall(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + other := uuid.New() + mustExec(t, f.db, `INSERT INTO locations (id, type, code) VALUES ($1, 'BRANCH', $2)`, other, "C41B-"+other.String()[:8]) + t.Cleanup(func() { + mustExec(t, f.db, `UPDATE vendor_invoices SET branch_id = `+defaultBranch+` WHERE branch_id = $1`, other) + mustExec(t, f.db, `DELETE FROM locations WHERE id = $1`, other) + }) + id, _ := f.enter("V-WALL", "1", 100000) + mustExec(t, f.db, `UPDATE vendor_invoices SET branch_id = $2 WHERE id = $1`, id, other) + + wall := []string{"X-Test-Branch", f.branch.String()} + if g := f.do("GET", "/api/v1/ap/invoices/"+id, nil, wall...); g.status != http.StatusNotFound { + t.Errorf("a held caller reads another branch's bill: %d, want 404", g.status) + } + if ta := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "approved", "revision": 1}, wall...); ta.status != http.StatusNotFound { + t.Errorf("a held caller approves another branch's bill: %d, want 404", ta.status) + } + if tv := f.do("POST", "/api/v1/ap/invoices/"+id+"/transitions", + map[string]any{"to": "voided", "revision": 1, "reason": "x"}, wall...); tv.status != http.StatusNotFound { + t.Errorf("a held caller voids another branch's bill: %d, want 404", tv.status) + } + list := f.do("GET", "/api/v1/ap/invoices?limit=50&vendor_id="+f.vendor.String(), nil, wall...) + if list.status != 200 { + t.Fatalf("list = %d", list.status) + } + for _, it := range list.body["items"].([]any) { + if it.(map[string]any)["id"].(string) == id { + t.Error("the walled list shows the other branch's bill") + } + } + foreign := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-FOREIGN", "branch_id": other.String(), + "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{f.lineBody("1", 1000)}}, wall...) + if foreign.status != http.StatusForbidden { + t.Errorf("create at a foreign branch = %d, want 403", foreign.status) + } + if code, _, fields := errorOf(t, foreign); code != "forbidden" || len(fields) == 0 || fields[0] != "branch_id" { + t.Errorf("create at a foreign branch = %q %v", code, fields) + } + // The same caller creates at its own branch. + if r := f.do("POST", "/api/v1/ap/invoices", map[string]any{ + "vendor_id": f.vendor.String(), "vendor_invoice_number": "V-OWN", "branch_id": f.branch.String(), + "invoice_date": "2026-01-10", "due_date": "2026-02-10", + "lines": []map[string]any{f.lineBody("1", 1000)}}, wall...); r.status != http.StatusCreated { + t.Errorf("create at the caller's branch = %d, want 201: %s", r.status, r.raw) + } + + // RULE (ADR 0008 section 11): the wall holds on the aging report, the + // payments list and the payment act itself, for a caller bound by + // user_locations and for a key bound to one branch. The foreign bill stays + // approved and owing (moved above); a payment lands on it through the + // unwalled administrator so the two reads have something to leak. + home, rh := f.enter("V-WALL-HOME", "1", 100000) + if ha := f.approve(home, rh); ha.status != http.StatusOK { + t.Fatal(ha.raw) + } + if fa := f.approve(id, 1); fa.status != http.StatusOK { + t.Fatalf("the administrator approves the foreign bill = %d: %s", fa.status, fa.raw) + } + if pa := f.pay(400, id); pa.status != http.StatusCreated { + t.Fatalf("the administrator's payment on the foreign bill = %d: %s", pa.status, pa.raw) + } + // A legacy payment with no application rows (the base wrote one whenever + // invoice_ids was empty, and left the rest of an amount unapplied): the + // wall filters a payment only through the bills it applied to, so a walled + // caller cannot see it, but an unwalled caller must (an administrator with + // no header, an unbound key, and every caller while the single branch + // switch is off). + mustExec(t, f.db, `INSERT INTO ap_payments (id, vendor_id, amount, method, payment_date, status, created_at) + VALUES ($1, $2, 1.23, 'CHECK', '2026-01-14', 'COMPLETE', NOW())`, uuid.New(), f.vendor) + if got, want := f.controlBalance(), f.openSum(); got != want { + t.Fatalf("after the payment on the foreign bill: control %d, open %d", got, want) + } + if !f.balanced() { + t.Error("the payment entry on the foreign bill does not balance") + } + // A user bound to the home branch through user_locations. + bound := "c41b-bound-" + uuid.NewString()[:8] + mustExec(t, f.db, `INSERT INTO user_locations (user_sub, branch_id) VALUES ($1, $2)`, bound, f.branch) + agingTotal := func(headers ...string) int64 { + t.Helper() + r := f.do("GET", "/api/v1/ap/aging", nil, headers...) + if r.status != 200 { + t.Fatalf("aging = %d: %s", r.status, r.raw) + } + var rows []map[string]any + if err := json.Unmarshal(r.raw, &rows); err != nil { + t.Fatalf("aging body: %v", err) + } + for _, row := range rows { + if row["vendor_id"] == f.vendor.String() { + return int64(row["total"].(float64)) + } + } + return -1 + } + for name, headers := range map[string][]string{ + "a user bound by user_locations": {"X-Test-Sub", bound, "X-Test-User-Bound", "1"}, + "a key bound to one branch": wall, + } { + if got := agingTotal(headers...); got != 1010 { + t.Errorf("the aging total behind the wall for %s = %d, want the caller's branch bills only (1010 cents)", name, got) + } + pl := f.do("GET", "/api/v1/ap/payments?vendor_id="+f.vendor.String(), nil, headers...) + if pl.status != 200 { + t.Fatalf("payments list = %d: %s", pl.status, pl.raw) + } + if n := strings.Count(string(pl.raw), `"id"`); n != 0 { + t.Errorf("the payments list behind the wall for %s shows %d payment(s) applied to another branch's bill", name, n) + } + if pr := f.payHeaders(headers, 400, id); pr.status != http.StatusBadRequest { + t.Errorf("a payment on another branch's bill for %s = %d, want 400", name, pr.status) + } + } + // Unwalled, both reads carry the other branch's bill and its payment, and + // the payments list carries the applicationless legacy payment too: an + // open wall lists every payment. + if got := agingTotal(); got != 1610 { + t.Errorf("the unwalled aging total = %d, want every branch's 1610 cents", got) + } + if pl := f.do("GET", "/api/v1/ap/payments?vendor_id="+f.vendor.String(), nil); strings.Count(string(pl.raw), `"id"`) != 2 { + t.Errorf("the unwalled payments list = %s, want the applied payment and the applicationless legacy one", pl.raw) + } +} + +// RULE (ADR 0001 section 9): idempotency through the existing middleware: the +// same create twice with one key returns the first response and makes one row +// and one event; the same key with another body is 422. +func TestWire_Idempotency(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + key := "c41b-" + uuid.NewString() + body := f.createBody("V-IDEM", "1", 1000) + first := f.do("POST", "/api/v1/ap/invoices", body, "Idempotency-Key", key) + if first.status != http.StatusCreated { + t.Fatalf("first = %d: %s", first.status, first.raw) + } + again := f.do("POST", "/api/v1/ap/invoices", body, "Idempotency-Key", key) + if again.status != http.StatusCreated || again.header.Get("Idempotency-Replayed") != "true" { + t.Fatalf("replay = %d replayed=%q, want 201 replayed", again.status, again.header.Get("Idempotency-Replayed")) + } + if again.body["id"] != first.body["id"] { + t.Errorf("replay answered %v, want the first bill %v", again.body["id"], first.body["id"]) + } + if n := f.count(`SELECT count(*) FROM vendor_invoices WHERE vendor_id = $1`, f.vendor); n != 1 { + t.Errorf("%d bills for one keyed create", n) + } + if n := f.count(`SELECT count(*) FROM events_outbox WHERE entity_type = 'vendor_invoice' AND entity_id = $1`, first.body["id"]); n != 1 { + t.Errorf("%d events for one keyed create", n) + } + other := f.createBody("V-IDEM-2", "1", 1000) + if r := f.do("POST", "/api/v1/ap/invoices", other, "Idempotency-Key", key); r.status != http.StatusUnprocessableEntity { + t.Errorf("key reuse with another body = %d, want 422", r.status) + } +} + +// The aging read: the buckets come from amount_open, and a voided bill owes +// nothing anywhere. +func TestWire_Aging(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + id, r1 := f.enter("V-AGE", "1", 1000) // 10 cents open + if a := f.approve(id, r1); a.status != http.StatusOK { + t.Fatal(a.raw) + } + r := f.do("GET", "/api/v1/ap/aging", nil) + if r.status != 200 { + t.Fatalf("aging = %d: %s", r.status, r.raw) + } + var rows []map[string]any + if err := json.Unmarshal(r.raw, &rows); err != nil { + t.Fatalf("aging body: %v", err) + } + found := false + for _, row := range rows { + if row["vendor_id"] == f.vendor.String() { + found = true + if row["total"].(float64) != 10 { + t.Errorf("aging total = %v, want 10 cents", row["total"]) + } + } + } + if !found { + t.Errorf("the aging report does not list the fixture's vendor: %s", r.raw) + } +} + +// RULE (review r3 P2-2): the in-process listing matching reads carries each +// bill's own lines in position order, with and without the status filter. The +// lines come from one second query keyed by the page's ids, so a mutant that +// matches no line, or crosses lines between bills, fails here. +func TestListVendorInvoices_EachBillCarriesItsOwnLines(t *testing.T) { + testutil.LockOutboxTables(t) + f := newFixture(t, testutil.RequireDB(t)) + enterLines := func(number string, descriptions ...string) (string, int64) { + t.Helper() + body := f.createBody(number, "1", 1000) + var lines []map[string]any + for _, d := range descriptions { + l := f.lineBody("1", 1000) + l["description"] = d + lines = append(lines, l) + } + body["lines"] = lines + r := f.do("POST", "/api/v1/ap/invoices", body) + if r.status != http.StatusCreated { + t.Fatalf("enter %s = %d: %s", number, r.status, r.raw) + } + return str(t, r.body, "id"), rev(t, r) + } + oneID, oneRev := enterLines("V-LF1", "one-a") + threeID, _ := enterLines("V-LF3", "three-a", "three-b", "three-c") + if r := f.approve(oneID, oneRev); r.status != http.StatusOK { + t.Fatalf("approve = %d: %s", r.status, r.raw) + } + if !f.balanced() || f.controlBalance() != f.openSum() { + t.Fatalf("after the approve: balanced %v, control %d, open %d", f.balanced(), f.controlBalance(), f.openSum()) + } + want := map[string][]string{oneID: {"one-a"}, threeID: {"three-a", "three-b", "three-c"}} + check := func(label, status string, wantIDs ...string) { + t.Helper() + got, err := f.svc.ListVendorInvoices(t.Context(), &f.vendor, status) + if err != nil { + t.Fatalf("%s: %v", label, err) + } + if len(got) != len(wantIDs) { + t.Fatalf("%s: %d bills, want %d", label, len(got), len(wantIDs)) + } + for _, inv := range got { + wantLines, ok := want[inv.ID.String()] + if !ok { + t.Fatalf("%s: unexpected bill %s", label, inv.ID) + } + var descs []string + for _, l := range inv.Lines { + descs = append(descs, l.Description) + } + if fmt.Sprint(descs) != fmt.Sprint(wantLines) { + t.Errorf("%s: bill %s carries lines %v, want %v", label, inv.ID, descs, wantLines) + } + } + } + check("every status", "", oneID, threeID) + check("approved only", "APPROVED", oneID) + check("pending only", "PENDING", threeID) +} diff --git a/core/internal/apicontract/model_shape_test.go b/core/internal/apicontract/model_shape_test.go index b88c36da..8f7b026a 100644 --- a/core/internal/apicontract/model_shape_test.go +++ b/core/internal/apicontract/model_shape_test.go @@ -297,8 +297,9 @@ var modelBoundSchemas = []struct { {"GlAccountLineItem", gl.AccountLineItem{}}, {"GlProfitAndLossReport", gl.ProfitAndLossReport{}}, {"GlBalanceSheetReport", gl.BalanceSheetReport{}}, - {"ApVendorInvoice", ap.VendorInvoice{}}, - {"ApVendorInvoiceLine", ap.VendorInvoiceLine{}}, + {"ApVendorInvoice", ap.Invoice{}}, + {"ApVendorInvoiceSummary", ap.Summary{}}, + {"ApVendorInvoiceLine", ap.InvoiceLine{}}, {"ApPayment", ap.APPayment{}}, {"ApAgingSummary", ap.APAgingSummary{}}, {"BankreconBankAccount", bankrecon.BankAccount{}}, diff --git a/core/internal/app/migrate/units_report_test.go b/core/internal/app/migrate/units_report_test.go index 002802d1..0ceced51 100644 --- a/core/internal/app/migrate/units_report_test.go +++ b/core/internal/app/migrate/units_report_test.go @@ -5,8 +5,8 @@ package migrate import ( "context" - "database/sql" "crypto/rand" + "database/sql" "encoding/hex" "fmt" "io" diff --git a/core/internal/app/serve/chain_c5_2a_test.go b/core/internal/app/serve/chain_c5_2a_test.go index 77cc0839..e142480d 100644 --- a/core/internal/app/serve/chain_c5_2a_test.go +++ b/core/internal/app/serve/chain_c5_2a_test.go @@ -101,8 +101,8 @@ func (r *chainRig) Times(path string) int { return r.reached[path] } // markerInner captures whether the marker was on, the authenticated // principal (if any), and the actor kind the chain resolved. type markerInner struct { - gotKey bool - actorKind string + gotKey bool + actorKind string } func (m *markerInner) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/core/internal/app/serve/serve.go b/core/internal/app/serve/serve.go index a9ff18d5..e677a5c4 100644 --- a/core/internal/app/serve/serve.go +++ b/core/internal/app/serve/serve.go @@ -64,8 +64,8 @@ import ( "github.com/gablelbm/gable/internal/reporting" "github.com/gablelbm/gable/internal/salesteam" "github.com/gablelbm/gable/internal/tax" - "github.com/gablelbm/gable/internal/unit" "github.com/gablelbm/gable/internal/techadmin" + "github.com/gablelbm/gable/internal/unit" "github.com/gablelbm/gable/internal/vendor" "github.com/gablelbm/gable/internal/vision" "github.com/gablelbm/gable/pkg/apps" @@ -666,11 +666,17 @@ func Run() { posHandler := pos.NewHandler(posSvc) posHandler.RegisterRoutes(mux, scoped("admin", "owner", "cashier")) - // Accounts Payable Module + // Accounts Payable Module: the vendor invoice routes on the wire contract + // (C4-1b, ADR 0008 7.4). The bills carry a branch, so every route runs + // behind the branch middleware and the create holds its payload branch to + // the caller's context (ADR 0008 section 11). apRepo := ap.NewRepository(db) - apSvc := ap.NewService(db, apRepo, glSvc, logger) - apHandler := ap.NewHandler(apSvc) - apHandler.RegisterRoutes(mux, middleware.RequireRole("admin", "owner", "finance")) + apSvc := ap.NewService(db, apRepo, glSvc, logger). + WithOutbox(outbox.NewWriter(db, cfg.EventsOrg)). + WithTxRunner(db). + WithAuditLog(auditLog). + WithBranchGuard(wall.guard) + ap.NewHandler(apSvc).RegisterRoutes(mux, scoped("admin", "owner", "finance")) // 3-Way PO Matching Module matchingRepo := matching.NewRepository(db) diff --git a/core/internal/app/serve/wire_branch_wall_test.go b/core/internal/app/serve/wire_branch_wall_test.go index 756563c1..89b7081b 100644 --- a/core/internal/app/serve/wire_branch_wall_test.go +++ b/core/internal/app/serve/wire_branch_wall_test.go @@ -301,15 +301,15 @@ func (s fixturePOSource) GetPOBranch(_ context.Context, id uuid.UUID) (*uuid.UUI // answer before any invoice is needed. type fixtureAPSource struct{} -func (fixtureAPSource) ListVendorInvoices(_ context.Context, _ *uuid.UUID, _ string) ([]ap.VendorInvoice, error) { +func (fixtureAPSource) ListVendorInvoices(_ context.Context, _ *uuid.UUID, _ string) ([]ap.Invoice, error) { return nil, nil } -func (fixtureAPSource) GetVendorInvoice(_ context.Context, _ uuid.UUID) (*ap.VendorInvoice, error) { +func (fixtureAPSource) GetVendorInvoice(_ context.Context, _ uuid.UUID) (*ap.Invoice, error) { return nil, nil } -func (fixtureAPSource) ApproveInvoice(_ context.Context, _ uuid.UUID, _ uuid.UUID) (*ap.VendorInvoice, error) { +func (fixtureAPSource) ApproveInvoice(_ context.Context, _ uuid.UUID, _ uuid.UUID) (*ap.Invoice, error) { return nil, nil } diff --git a/core/internal/characterization/harness_test.go b/core/internal/characterization/harness_test.go index 82c4f6c8..944f674b 100644 --- a/core/internal/characterization/harness_test.go +++ b/core/internal/characterization/harness_test.go @@ -475,6 +475,7 @@ func seedVars(t *testing.T, dbURL string) map[string]string { if vars["glAccount2"] == "" { one("glAccount2", `SELECT id FROM gl_accounts ORDER BY code OFFSET 1 LIMIT 1`) } + one("expenseAccount", `SELECT id FROM gl_accounts WHERE code = '5020'`) one("vehicle", `SELECT id FROM vehicles WHERE deleted_at IS NULL ORDER BY name LIMIT 1`) one("driver", `SELECT id FROM drivers WHERE deleted_at IS NULL ORDER BY name LIMIT 1`) one("marketIndex", `SELECT id FROM market_indices WHERE index_code = 'RL_SPF_2X4'`) diff --git a/core/internal/characterization/scenarios_r1b_a_test.go b/core/internal/characterization/scenarios_r1b_a_test.go index 009a19c8..dab8e4ac 100644 --- a/core/internal/characterization/scenarios_r1b_a_test.go +++ b/core/internal/characterization/scenarios_r1b_a_test.go @@ -592,25 +592,35 @@ func r1bAAPGroups() []groupDef { method: "POST", path: "/api/v1/ap/invoices", body: map[string]any{ - "vendor_id": "{vendor}", "invoice_number": "GOLD-AP-A01", - "invoice_date": "{today}", "due_date": "{today+30}", "tax_amount": 0, + "vendor_id": "{vendor}", "vendor_invoice_number": "GOLD-AP-A01", + "invoice_date": "{today}", "due_date": "{today+30}", "tax_cents": 0, "lines": []map[string]any{ - {"description": "golden ap payable line", "quantity": 2, "unit_price": 50.0}, + {"description": "golden ap payable line", "quantity": "2", + "unit_price_ten_thousandths": 500000, "gl_account_id": "{expenseAccount}"}, }, }, extract: map[string]string{"a_apinvoice": "/id"}, }, - // AUTH_MODE=dev attaches no claims, and the approve handler - // requires an authenticated approver: 401 on every dev call. - {name: "ap.invoice.approve.no_claims", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/approve"}, - {name: "ap.invoice.approve.bad_id", method: "POST", path: "/api/v1/ap/invoices/not-a-uuid/approve"}, + {name: "ap.invoice.approve.no_precondition", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "approved"}}, + {name: "ap.invoice.approve", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "approved", "revision": 1}}, + {name: "ap.invoice.approve.again", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "approved", "revision": 2}}, + {name: "ap.invoice.transition.bad_id", method: "POST", path: "/api/v1/ap/invoices/not-a-uuid/transitions", + body: map[string]any{"to": "approved", "revision": 1}}, + // The live failure the C4-1b fixes pin in the golden: a paid bill + // takes no further payment, and one with money applied does not + // void. + {name: "ap.invoice.events", method: "GET", + path: "/api/v1/events?types=vendor_invoice.created,vendor_invoice.approved&limit=10"}, {name: "ap.payments.list_empty_for_vendor", method: "GET", path: "/api/v1/ap/payments?vendor_id={vendor}"}, { name: "ap.payment.create", method: "POST", path: "/api/v1/ap/payments", body: map[string]any{ - "vendor_id": "{vendor}", "amount": 40.0, "method": "CHECK", "check_number": "GOLD-CHK-1", + "vendor_id": "{vendor}", "amount_cents": 4000, "method": "CHECK", "check_number": "GOLD-CHK-1", "reference": "golden partial", "payment_date": "{today}", "invoice_ids": []any{"{a_apinvoice}"}, }, @@ -622,20 +632,27 @@ func r1bAAPGroups() []groupDef { method: "POST", path: "/api/v1/ap/payments", body: map[string]any{ - "vendor_id": "{vendor}", "amount": 60.0, "method": "ACH", + "vendor_id": "{vendor}", "amount_cents": 6000, "method": "ACH", "reference": "golden settle", "payment_date": "{today}", "invoice_ids": []any{"{a_apinvoice}"}, }, }, {name: "ap.invoice.get.after_settle", method: "GET", path: "/api/v1/ap/invoices/{a_apinvoice}"}, + {name: "ap.payment.create.paid_invoice", method: "POST", path: "/api/v1/ap/payments", + body: map[string]any{ + "vendor_id": "{vendor}", "amount_cents": 100, "method": "CHECK", "payment_date": "{today}", + "invoice_ids": []any{"{a_apinvoice}"}, + }}, + {name: "ap.invoice.void.with_payments", method: "POST", path: "/api/v1/ap/invoices/{a_apinvoice}/transitions", + body: map[string]any{"to": "voided", "revision": 4, "reason": "golden void refused"}}, {name: "ap.payments.list_vendor", method: "GET", path: "/api/v1/ap/payments?vendor_id={vendor}", sortPrimaryArray: true}, {name: "ap.payment.create.bad_date", method: "POST", path: "/api/v1/ap/payments", body: map[string]any{ - "vendor_id": "{vendor}", "amount": 1.0, "method": "CHECK", "payment_date": "31/12/2000", + "vendor_id": "{vendor}", "amount_cents": 100, "method": "CHECK", "payment_date": "31/12/2000", }}, {name: "ap.payment.create.unknown_invoice", method: "POST", path: "/api/v1/ap/payments", body: map[string]any{ - "vendor_id": "{vendor}", "amount": 1.0, "method": "CHECK", "payment_date": "{today}", + "vendor_id": "{vendor}", "amount_cents": 100, "method": "CHECK", "payment_date": "{today}", "invoice_ids": []any{r1bAMissingID}, }}, {name: "ap.payment.create.bad_body", method: "POST", path: "/api/v1/ap/payments", body: "not-an-object"}, diff --git a/core/internal/characterization/scenarios_sales_test.go b/core/internal/characterization/scenarios_sales_test.go index 49006df9..148c1451 100644 --- a/core/internal/characterization/scenarios_sales_test.go +++ b/core/internal/characterization/scenarios_sales_test.go @@ -891,16 +891,30 @@ func apGroups() []groupDef { method: "POST", path: "/api/v1/ap/invoices", body: map[string]any{ - "vendor_id": "{vendor}", "invoice_number": "GOLD-AP-001", - "invoice_date": "{today}", "due_date": "{today}", "tax_amount": 0, + "vendor_id": "{vendor}", "vendor_invoice_number": "GOLD-AP-001", + "invoice_date": "{today}", "due_date": "{today}", "tax_cents": 0, "lines": []map[string]any{ - {"description": "golden ap line", "quantity": 1, "unit_price": 100.0}, + {"description": "golden ap line", "quantity": "1", + "unit_price_ten_thousandths": 1000000, "gl_account_id": "{expenseAccount}"}, }, }, extract: map[string]string{"myAPInvoice": "/id"}, }, + {name: "ap.invoice.create.validation", method: "POST", path: "/api/v1/ap/invoices", + body: map[string]any{ + "vendor_id": "{vendor}", "vendor_invoice_number": "GOLD-AP-002", + "invoice_date": "{today}", "due_date": "{today}", + "lines": []map[string]any{ + {"description": "no account", "quantity": "1", "unit_price_ten_thousandths": 100}, + }, + }}, {name: "ap.invoice.get", method: "GET", path: "/api/v1/ap/invoices/{myAPInvoice}"}, {name: "ap.invoice.list", method: "GET", path: "/api/v1/ap/invoices?limit=3"}, + {name: "ap.invoice.list.status", method: "GET", path: "/api/v1/ap/invoices?limit=3&status=pending"}, + {name: "ap.invoice.list.bad_status", method: "GET", path: "/api/v1/ap/invoices?status=PENDING"}, + {name: "ap.invoice.void.pending", method: "POST", path: "/api/v1/ap/invoices/{myAPInvoice}/transitions", + body: map[string]any{"to": "voided", "revision": 1, "reason": "golden void"}}, + {name: "ap.invoice.voided.get", method: "GET", path: "/api/v1/ap/invoices/{myAPInvoice}"}, }, }, { name: "edi", diff --git a/core/internal/characterization/testdata/goldens/ap.json b/core/internal/characterization/testdata/goldens/ap.json index 7360087d..b8bd6d14 100644 --- a/core/internal/characterization/testdata/goldens/ap.json +++ b/core/internal/characterization/testdata/goldens/ap.json @@ -7,33 +7,99 @@ "body": { "due_date": "", "invoice_date": "", - "invoice_number": "GOLD-AP-001", "lines": [ { "description": "golden ap line", - "quantity": 1, - "unit_price": 100 + "gl_account_id": "", + "quantity": "1", + "unit_price_ten_thousandths": 1000000 } ], - "tax_amount": 0, - "vendor_id": "" + "tax_cents": 0, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001" } }, "response": { "status": 201, "content_type": "application/json", "body": { - "amount_paid": 0, + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-001", - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "" + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 + } + ], + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + } + } + }, + { + "name": "ap.invoice.create.validation", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices", + "body": { + "due_date": "", + "invoice_date": "", + "lines": [ + { + "description": "no account", + "quantity": "1", + "unit_price_ten_thousandths": 100 + } + ], + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-002" + } + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "validation_failed", + "details": [ + { + "field": "lines[0].gl_account_id", + "message": "is required: the approve entry debits each line's account" + } + ], + "message": "one or more fields failed validation" + }, + "meta": { + "request_id": "" + } } } }, @@ -41,34 +107,48 @@ "name": "ap.invoice.get", "request": { "method": "GET", - "path": "/api/v1/ap/invoices/" + "path": "/api/v1/ap/invoices/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "amount_paid": 0, + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-001", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", "lines": [ { "created_at": "", "description": "golden ap line", - "id": "", - "invoice_id": "", - "line_total": 10000, - "quantity": 1, - "unit_price": 10000 + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 } ], - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", "vendor_name": "BC Roofing Wholesale" } } @@ -82,78 +162,304 @@ "response": { "status": 200, "content_type": "application/json", - "body": [ - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B91", - "status": "PENDING", - "subtotal": 91091, - "tax_amount": 0, - "total": 91091, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" - }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B61", - "status": "PENDING", - "subtotal": 61061, - "tax_amount": 0, - "total": 61061, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" - }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B31", - "status": "PENDING", - "subtotal": 31031, - "tax_amount": 0, - "total": 31031, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" - }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-WIN-B29", - "status": "PENDING", - "subtotal": 29029, - "tax_amount": 0, - "total": 29029, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" + "body": { + "items": [ + { + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 29029, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000001", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 29029, + "tax_cents": 0, + "total_cents": 29029, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B29", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 31031, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000002", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 31031, + "tax_cents": 0, + "total_cents": 31031, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B31", + "vendor_name": "BC Roofing Wholesale" + } + ], + "limit": 3, + "next_cursor": "" + } + } + }, + { + "name": "ap.invoice.list.status", + "request": { + "method": "GET", + "path": "/api/v1/ap/invoices?limit=3&status=pending" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 29029, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000001", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 29029, + "tax_cents": 0, + "total_cents": 29029, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B29", + "vendor_name": "BC Roofing Wholesale" + }, + { + "amount_open_cents": 31031, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "notes": "golden clock window fixture", + "number": "AP-000002", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 31031, + "tax_cents": 0, + "total_cents": 31031, + "vendor_id": "", + "vendor_invoice_number": "GOLD-WIN-B31", + "vendor_name": "BC Roofing Wholesale" + } + ], + "limit": 3, + "next_cursor": "" + } + } + }, + { + "name": "ap.invoice.list.bad_status", + "request": { + "method": "GET", + "path": "/api/v1/ap/invoices?status=PENDING" + }, + "response": { + "status": 400, + "content_type": "application/json", + "body": { + "error": { + "code": "validation_failed", + "details": [ + { + "field": "status", + "message": "must be a comma separated list of: pending, approved, partial, paid, voided" + } + ], + "message": "one or more fields failed validation" }, - { - "amount_paid": 0, - "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-001", - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", - "vendor_name": "BC Roofing Wholesale" + "meta": { + "request_id": "" } - ] + } + } + }, + { + "name": "ap.invoice.void.pending", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "reason": "golden void", + "revision": 1, + "to": "voided" + } + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "amount_open_cents": 0, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 + } + ], + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 2, + "status": "voided", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + } + } + }, + { + "name": "ap.invoice.voided.get", + "request": { + "method": "GET", + "path": "/api/v1/ap/invoices/" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "amount_open_cents": 0, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "1", + "unit_price_ten_thousandths": 1000000 + } + ], + "notes": "", + "number": "AP-000005", + "po_id": null, + "revision": 2, + "status": "voided", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001", + "vendor_name": "BC Roofing Wholesale" + } } } ] diff --git a/core/internal/characterization/testdata/goldens/ap_payments.json b/core/internal/characterization/testdata/goldens/ap_payments.json index f313665c..1e162b7d 100644 --- a/core/internal/characterization/testdata/goldens/ap_payments.json +++ b/core/internal/characterization/testdata/goldens/ap_payments.json @@ -7,81 +7,291 @@ "body": { "due_date": "", "invoice_date": "", - "invoice_number": "GOLD-AP-A01", "lines": [ { "description": "golden ap payable line", - "quantity": 2, - "unit_price": 50 + "gl_account_id": "", + "quantity": "2", + "unit_price_ten_thousandths": 500000 } ], - "tax_amount": 0, - "vendor_id": "" + "tax_cents": 0, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01" } }, "response": { "status": 201, "content_type": "application/json", "body": { - "amount_paid": 0, + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": null, + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-A01", - "status": "PENDING", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "" + "currency": "USD", + "due_date": "", + "gl_entry_id": null, + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap payable line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 + } + ], + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 1, + "status": "pending", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", + "vendor_name": "BC Roofing Wholesale" + } + } + }, + { + "name": "ap.invoice.approve.no_precondition", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "to": "approved" + } + }, + "response": { + "status": 428, + "content_type": "application/json", + "body": { + "error": { + "code": "precondition_required", + "message": "this write needs If-Match or a body revision" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "ap.invoice.approve", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "revision": 1, + "to": "approved" + } + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "amount_open_cents": 10000, + "amount_paid_cents": 0, + "approved_at": "", + "approved_by": null, + "branch_id": "", + "created_at": "", + "currency": "USD", + "due_date": "", + "gl_entry_id": "", + "id": "", + "invoice_date": "", + "lines": [ + { + "created_at": "", + "description": "golden ap payable line", + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 + } + ], + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 2, + "status": "approved", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", + "vendor_name": "BC Roofing Wholesale" } } }, { - "name": "ap.invoice.approve.no_claims", + "name": "ap.invoice.approve.again", "request": { "method": "POST", - "path": "/api/v1/ap/invoices//approve" + "path": "/api/v1/ap/invoices//transitions", + "body": { + "revision": 2, + "to": "approved" + } }, "response": { - "status": 401, + "status": 409, "content_type": "application/json", "body": { "error": { - "code": "UNAUTHORIZED", - "message": "Unauthorized" + "code": "invalid_state_transition", + "details": [ + { + "code": "invoice_not_pending", + "message": "the bill is approved" + } + ], + "message": "only a pending bill can be approved" }, "meta": { - "request_id": "" + "request_id": "" } } } }, { - "name": "ap.invoice.approve.bad_id", + "name": "ap.invoice.transition.bad_id", "request": { "method": "POST", - "path": "/api/v1/ap/invoices/not-a-uuid/approve" + "path": "/api/v1/ap/invoices/not-a-uuid/transitions", + "body": { + "revision": 1, + "to": "approved" + } }, "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": "invalid vendor invoice id" }, "meta": { - "request_id": "" + "request_id": "" } } } }, + { + "name": "ap.invoice.events", + "request": { + "method": "GET", + "path": "/api/v1/events?types=vendor_invoice.created,vendor_invoice.approved&limit=10" + }, + "response": { + "status": 200, + "content_type": "application/json", + "body": { + "items": [ + { + "at": "", + "branch_id": "", + "data": { + "amount_open_cents": 10000, + "branch_id": "", + "currency": "USD", + "from_status": "", + "number": "AP-000005", + "revision": 1, + "status": "pending", + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-001" + }, + "entity": { + "id": "", + "kind": "vendor_invoice" + }, + "event_id": "", + "org": "default", + "type": "vendor_invoice.created" + }, + { + "at": "", + "branch_id": "", + "data": { + "amount_open_cents": 10000, + "branch_id": "", + "currency": "USD", + "from_status": "", + "number": "AP-000006", + "revision": 1, + "status": "pending", + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01" + }, + "entity": { + "id": "", + "kind": "vendor_invoice" + }, + "event_id": "", + "org": "default", + "type": "vendor_invoice.created" + }, + { + "at": "", + "branch_id": "", + "data": { + "amount_open_cents": 10000, + "branch_id": "", + "currency": "USD", + "from_status": "pending", + "number": "AP-000006", + "revision": 2, + "status": "approved", + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01" + }, + "entity": { + "id": "", + "kind": "vendor_invoice" + }, + "event_id": "", + "org": "default", + "type": "vendor_invoice.approved" + } + ], + "limit": 10, + "next_cursor": "" + } + } + }, { "name": "ap.payments.list_empty_for_vendor", "request": { "method": "GET", - "path": "/api/v1/ap/payments?vendor_id=" + "path": "/api/v1/ap/payments?vendor_id=" }, "response": { "status": 200, @@ -95,15 +305,15 @@ "method": "POST", "path": "/api/v1/ap/payments", "body": { - "amount": 40, + "amount_cents": 4000, "check_number": "GOLD-CHK-1", "invoice_ids": [ - "" + "" ], "method": "CHECK", "payment_date": "", "reference": "golden partial", - "vendor_id": "" + "vendor_id": "" } }, "response": { @@ -113,12 +323,12 @@ "amount": 4000, "check_number": "GOLD-CHK-1", "created_at": "", - "id": "", + "id": "", "method": "CHECK", - "payment_date": "", + "payment_date": "", "reference": "golden partial", "status": "COMPLETE", - "vendor_id": "" + "vendor_id": "" } } }, @@ -126,34 +336,48 @@ "name": "ap.invoice.get.after_partial", "request": { "method": "GET", - "path": "/api/v1/ap/invoices/" + "path": "/api/v1/ap/invoices/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "amount_paid": 4000, + "amount_open_cents": 6000, + "amount_paid_cents": 4000, + "approved_at": "", + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-A01", + "currency": "USD", + "due_date": "", + "gl_entry_id": "", + "id": "", + "invoice_date": "", "lines": [ { "created_at": "", "description": "golden ap payable line", - "id": "", - "invoice_id": "", - "line_total": 10000, - "quantity": 2, - "unit_price": 5000 + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 } ], - "status": "PARTIAL", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 3, + "status": "partial", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", "vendor_name": "BC Roofing Wholesale" } } @@ -164,14 +388,14 @@ "method": "POST", "path": "/api/v1/ap/payments", "body": { - "amount": 60, + "amount_cents": 6000, "invoice_ids": [ - "" + "" ], "method": "ACH", "payment_date": "", "reference": "golden settle", - "vendor_id": "" + "vendor_id": "" } }, "response": { @@ -180,12 +404,12 @@ "body": { "amount": 6000, "created_at": "", - "id": "", + "id": "", "method": "ACH", - "payment_date": "", + "payment_date": "", "reference": "golden settle", "status": "COMPLETE", - "vendor_id": "" + "vendor_id": "" } } }, @@ -193,44 +417,124 @@ "name": "ap.invoice.get.after_settle", "request": { "method": "GET", - "path": "/api/v1/ap/invoices/" + "path": "/api/v1/ap/invoices/" }, "response": { "status": 200, "content_type": "application/json", "body": { - "amount_paid": 10000, + "amount_open_cents": 0, + "amount_paid_cents": 10000, + "approved_at": "", + "approved_by": null, + "branch_id": "", "created_at": "", - "due_date": "", - "id": "", - "invoice_date": "", - "invoice_number": "GOLD-AP-A01", + "currency": "USD", + "due_date": "", + "gl_entry_id": "", + "id": "", + "invoice_date": "", "lines": [ { "created_at": "", "description": "golden ap payable line", - "id": "", - "invoice_id": "", - "line_total": 10000, - "quantity": 2, - "unit_price": 5000 + "gl_account_id": "", + "id": "", + "line_total_cents": 10000, + "po_freight_charge_id": null, + "position": 0, + "product_id": null, + "purchase_order_line_id": null, + "quantity": "2", + "unit_price_ten_thousandths": 500000 } ], - "status": "PAID", - "subtotal": 10000, - "tax_amount": 0, - "total": 10000, - "vendor_id": "", + "notes": "", + "number": "AP-000006", + "po_id": null, + "revision": 4, + "status": "paid", + "subtotal_cents": 10000, + "tax_cents": 0, + "total_cents": 10000, + "vendor_id": "", + "vendor_invoice_number": "GOLD-AP-A01", "vendor_name": "BC Roofing Wholesale" } } }, + { + "name": "ap.payment.create.paid_invoice", + "request": { + "method": "POST", + "path": "/api/v1/ap/payments", + "body": { + "amount_cents": 100, + "invoice_ids": [ + "" + ], + "method": "CHECK", + "payment_date": "", + "vendor_id": "" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "invalid_state_transition", + "details": [ + { + "code": "invoice_not_approved", + "message": "AP-000006 is paid" + } + ], + "message": "only an approved or partial bill takes a payment" + }, + "meta": { + "request_id": "" + } + } + } + }, + { + "name": "ap.invoice.void.with_payments", + "request": { + "method": "POST", + "path": "/api/v1/ap/invoices//transitions", + "body": { + "reason": "golden void refused", + "revision": 4, + "to": "voided" + } + }, + "response": { + "status": 409, + "content_type": "application/json", + "body": { + "error": { + "code": "invalid_state_transition", + "details": [ + { + "code": "has_payments", + "message": "10000 cents have been applied to this bill" + } + ], + "message": "the bill carries payments: they must be undone before it is voided" + }, + "meta": { + "request_id": "" + } + } + } + }, { "name": "ap.payments.list_vendor", "sort_primary_array": true, "request": { "method": "GET", - "path": "/api/v1/ap/payments?vendor_id=" + "path": "/api/v1/ap/payments?vendor_id=" }, "response": { "status": 200, @@ -240,23 +544,23 @@ "amount": 4000, "check_number": "GOLD-CHK-1", "created_at": "", - "id": "", + "id": "", "method": "CHECK", - "payment_date": "", + "payment_date": "", "reference": "golden partial", "status": "COMPLETE", - "vendor_id": "", + "vendor_id": "", "vendor_name": "BC Roofing Wholesale" }, { "amount": 6000, "created_at": "", - "id": "", + "id": "", "method": "ACH", - "payment_date": "", + "payment_date": "", "reference": "golden settle", "status": "COMPLETE", - "vendor_id": "", + "vendor_id": "", "vendor_name": "BC Roofing Wholesale" } ] @@ -268,22 +572,28 @@ "method": "POST", "path": "/api/v1/ap/payments", "body": { - "amount": 1, + "amount_cents": 100, "method": "CHECK", "payment_date": "31/12/2000", - "vendor_id": "" + "vendor_id": "" } }, "response": { - "status": 500, + "status": 400, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "validation_failed", + "details": [ + { + "field": "payment_date", + "message": "must be YYYY-MM-DD" + } + ], + "message": "one or more fields failed validation" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -294,25 +604,31 @@ "method": "POST", "path": "/api/v1/ap/payments", "body": { - "amount": 1, + "amount_cents": 100, "invoice_ids": [ - "" + "" ], "method": "CHECK", "payment_date": "", - "vendor_id": "" + "vendor_id": "" } }, "response": { - "status": 500, + "status": 400, "content_type": "application/json", "body": { "error": { - "code": "INTERNAL_ERROR", - "message": "Internal Server Error" + "code": "bad_request", + "details": [ + { + "field": "invoice_ids[0]", + "message": "no such invoice for this vendor" + } + ], + "message": "a referenced record does not exist" }, "meta": { - "request_id": "" + "request_id": "" } } } @@ -329,11 +645,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": "" } } } diff --git a/core/internal/characterization/testdata/goldens/clockwindow.json b/core/internal/characterization/testdata/goldens/clockwindow.json index 95b5a5a5..e1c6209f 100644 --- a/core/internal/characterization/testdata/goldens/clockwindow.json +++ b/core/internal/characterization/testdata/goldens/clockwindow.json @@ -11,11 +11,11 @@ "content_type": "application/json", "body": [ { - "current": 10000, + "current": 0, "past_30": 29029, "past_60": 31031, "past_90": 152152, - "total": 222212, + "total": 212212, "vendor_id": "", "vendor_name": "BC Roofing Wholesale" } diff --git a/core/internal/characterization/testdata/goldens/gl_accounts_entries.json b/core/internal/characterization/testdata/goldens/gl_accounts_entries.json index a0a02dd3..1dcbd038 100644 --- a/core/internal/characterization/testdata/goldens/gl_accounts_entries.json +++ b/core/internal/characterization/testdata/goldens/gl_accounts_entries.json @@ -263,7 +263,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 143, + "entry_number": 144, "id": "", "lines": [ { @@ -337,7 +337,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 144, + "entry_number": 145, "id": "", "lines": [ { @@ -436,7 +436,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 143, + "entry_number": 144, "id": "", "lines": [ { @@ -562,7 +562,7 @@ "body": { "created_at": "", "entry_date": "", - "entry_number": 145, + "entry_number": 146, "id": "", "lines": [ { @@ -586,7 +586,7 @@ "journal_entry_id": "" } ], - "memo": "Reversal of #143: golden reversal", + "memo": "Reversal of #144: golden reversal", "posted_by": "system", "source": "REVERSAL", "source_ref_id": "", @@ -752,9 +752,9 @@ { "created_at": "", "entry_date": "", - "entry_number": 145, + "entry_number": 146, "id": "", - "memo": "Reversal of #143: golden reversal", + "memo": "Reversal of #144: golden reversal", "posted_by": "system", "source": "REVERSAL", "source_ref_id": "", @@ -766,7 +766,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 144, + "entry_number": 145, "id": "", "memo": "golden void source", "posted_by": "", @@ -779,7 +779,7 @@ { "created_at": "", "entry_date": "", - "entry_number": 143, + "entry_number": 144, "id": "", "memo": "golden reverse source", "posted_by": "system", @@ -792,9 +792,9 @@ { "created_at": "", "entry_date": "", - "entry_number": 142, + "entry_number": 143, "id": "", - "memo": "Vendor Payment ", + "memo": "Vendor payment ", "posted_by": "system", "source": "VENDOR_PAYMENT", "source_ref_id": "", @@ -806,9 +806,9 @@ { "created_at": "", "entry_date": "", - "entry_number": 141, + "entry_number": 142, "id": "", - "memo": "Vendor Payment ", + "memo": "Vendor payment ", "posted_by": "system", "source": "VENDOR_PAYMENT", "source_ref_id": "", @@ -820,12 +820,26 @@ { "created_at": "", "entry_date": "", - "entry_number": 140, + "entry_number": 141, "id": "", + "memo": "Vendor invoice AP-000006", + "posted_by": "system", + "source": "VENDOR_INVOICE", + "source_ref_id": "", + "status": "POSTED", + "total_credit": 10000, + "total_debit": 10000, + "updated_at": "" + }, + { + "created_at": "", + "entry_date": "", + "entry_number": 140, + "id": "", "memo": "Void of payment PAY-000047", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 10000, "total_debit": 10000, @@ -835,11 +849,11 @@ "created_at": "", "entry_date": "", "entry_number": 139, - "id": "", + "id": "", "memo": "Payment PAY-000047", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 10000, "total_debit": 10000, @@ -849,11 +863,11 @@ "created_at": "", "entry_date": "", "entry_number": 138, - "id": "", + "id": "", "memo": "Payment PAY-000046", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2500, "total_debit": 2500, @@ -863,11 +877,11 @@ "created_at": "", "entry_date": "", "entry_number": 137, - "id": "", + "id": "", "memo": "Void of payment PAY-000045", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 4500, "total_debit": 4500, @@ -877,11 +891,11 @@ "created_at": "", "entry_date": "", "entry_number": 136, - "id": "", + "id": "", "memo": "Reversal of #133: golden void", "posted_by": "system", "source": "REVERSAL", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1142, "total_debit": 1142, @@ -891,11 +905,11 @@ "created_at": "", "entry_date": "", "entry_number": 135, - "id": "", + "id": "", "memo": "Reversal of #132: golden void", "posted_by": "system", "source": "REVERSAL", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2000, "total_debit": 2000, @@ -905,11 +919,11 @@ "created_at": "", "entry_date": "", "entry_number": 134, - "id": "", + "id": "", "memo": "Refund of payment PAY-000045", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 500, "total_debit": 500, @@ -919,11 +933,11 @@ "created_at": "", "entry_date": "", "entry_number": 133, - "id": "", + "id": "", "memo": "Payment PAY-000045 applied to IN-000051", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1142, "total_debit": 1142, @@ -933,11 +947,11 @@ "created_at": "", "entry_date": "", "entry_number": 132, - "id": "", + "id": "", "memo": "Payment PAY-000045 applied to IN-000051", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2000, "total_debit": 2000, @@ -947,11 +961,11 @@ "created_at": "", "entry_date": "", "entry_number": 131, - "id": "", + "id": "", "memo": "Payment PAY-000045", "posted_by": "system", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 5000, "total_debit": 5000, @@ -961,11 +975,11 @@ "created_at": "", "entry_date": "", "entry_number": 130, - "id": "", + "id": "", "memo": "Reversal of #129: credit memo CM-000003 voided", "posted_by": "system", "source": "REVERSAL", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 75, "total_debit": 75, @@ -975,11 +989,11 @@ "created_at": "", "entry_date": "", "entry_number": 129, - "id": "", + "id": "", "memo": "Credit memo CM-000003", "posted_by": "system", "source": "CREDIT_MEMO", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 75, "total_debit": 75, @@ -989,11 +1003,11 @@ "created_at": "", "entry_date": "", "entry_number": 128, - "id": "", + "id": "", "memo": "Invoice IN-000051", "posted_by": "system", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 5242, "total_debit": 5242, @@ -1003,7 +1017,7 @@ "created_at": "", "entry_date": "", "entry_number": 127, - "id": "", + "id": "", "memo": "golden characterisation entry", "posted_by": "system", "source": "MANUAL", @@ -1016,11 +1030,11 @@ "created_at": "", "entry_date": "", "entry_number": 38, - "id": "", + "id": "", "memo": "Invoice IN-000014", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2039305, "total_debit": 2039305, @@ -1030,11 +1044,11 @@ "created_at": "", "entry_date": "", "entry_number": 36, - "id": "", + "id": "", "memo": "Payment PAY-000012 applied to IN-000012", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 443905, "total_debit": 443905, @@ -1044,11 +1058,11 @@ "created_at": "", "entry_date": "", "entry_number": 35, - "id": "", + "id": "", "memo": "Payment PAY-000012", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 443905, "total_debit": 443905, @@ -1058,11 +1072,11 @@ "created_at": "", "entry_date": "", "entry_number": 63, - "id": "", + "id": "", "memo": "Payment PAY-000020 applied to IN-000023", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1239134, "total_debit": 1239134, @@ -1072,11 +1086,11 @@ "created_at": "", "entry_date": "", "entry_number": 62, - "id": "", + "id": "", "memo": "Payment PAY-000020", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1239134, "total_debit": 1239134, @@ -1086,11 +1100,11 @@ "created_at": "", "entry_date": "", "entry_number": 39, - "id": "", + "id": "", "memo": "Invoice IN-000015", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 196103, "total_debit": 196103, @@ -1100,11 +1114,11 @@ "created_at": "", "entry_date": "", "entry_number": 34, - "id": "", + "id": "", "memo": "Invoice IN-000012", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 443905, "total_debit": 443905, @@ -1114,11 +1128,11 @@ "created_at": "", "entry_date": "", "entry_number": 61, - "id": "", + "id": "", "memo": "Invoice IN-000023", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1239134, "total_debit": 1239134, @@ -1128,11 +1142,11 @@ "created_at": "", "entry_date": "", "entry_number": 54, - "id": "", + "id": "", "memo": "Payment PAY-000017 applied to IN-000020", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 204456, "total_debit": 204456, @@ -1142,11 +1156,11 @@ "created_at": "", "entry_date": "", "entry_number": 53, - "id": "", + "id": "", "memo": "Payment PAY-000017", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 204456, "total_debit": 204456, @@ -1156,11 +1170,11 @@ "created_at": "", "entry_date": "", "entry_number": 80, - "id": "", + "id": "", "memo": "Payment PAY-000025 applied to IN-000030", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 906156, "total_debit": 906156, @@ -1170,11 +1184,11 @@ "created_at": "", "entry_date": "", "entry_number": 79, - "id": "", + "id": "", "memo": "Payment PAY-000025", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 906156, "total_debit": 906156, @@ -1184,11 +1198,11 @@ "created_at": "", "entry_date": "", "entry_number": 52, - "id": "", + "id": "", "memo": "Invoice IN-000020", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 204456, "total_debit": 204456, @@ -1198,11 +1212,11 @@ "created_at": "", "entry_date": "", "entry_number": 96, - "id": "", + "id": "", "memo": "Payment PAY-000030 applied to IN-000036", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 476177, "total_debit": 476177, @@ -1212,11 +1226,11 @@ "created_at": "", "entry_date": "", "entry_number": 95, - "id": "", + "id": "", "memo": "Payment PAY-000030", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 476177, "total_debit": 476177, @@ -1226,11 +1240,11 @@ "created_at": "", "entry_date": "", "entry_number": 121, - "id": "", + "id": "", "memo": "Payment PAY-000038 applied to IN-000045", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1086982, "total_debit": 1086982, @@ -1240,11 +1254,11 @@ "created_at": "", "entry_date": "", "entry_number": 120, - "id": "", + "id": "", "memo": "Payment PAY-000038", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1086982, "total_debit": 1086982, @@ -1254,11 +1268,11 @@ "created_at": "", "entry_date": "", "entry_number": 78, - "id": "", + "id": "", "memo": "Invoice IN-000030", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 906156, "total_debit": 906156, @@ -1268,11 +1282,11 @@ "created_at": "", "entry_date": "", "entry_number": 47, - "id": "", + "id": "", "memo": "Payment PAY-000015 applied to IN-000017", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 753725, "total_debit": 753725, @@ -1282,11 +1296,11 @@ "created_at": "", "entry_date": "", "entry_number": 46, - "id": "", + "id": "", "memo": "Payment PAY-000015", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 753725, "total_debit": 753725, @@ -1296,11 +1310,11 @@ "created_at": "", "entry_date": "", "entry_number": 94, - "id": "", + "id": "", "memo": "Invoice IN-000036", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 476177, "total_debit": 476177, @@ -1310,11 +1324,11 @@ "created_at": "", "entry_date": "", "entry_number": 57, - "id": "", + "id": "", "memo": "Payment PAY-000018 applied to IN-000021", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 135367, "total_debit": 135367, @@ -1324,11 +1338,11 @@ "created_at": "", "entry_date": "", "entry_number": 56, - "id": "", + "id": "", "memo": "Payment PAY-000018", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 135367, "total_debit": 135367, @@ -1338,11 +1352,11 @@ "created_at": "", "entry_date": "", "entry_number": 119, - "id": "", + "id": "", "memo": "Invoice IN-000045", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1086982, "total_debit": 1086982, @@ -1352,11 +1366,11 @@ "created_at": "", "entry_date": "", "entry_number": 9, - "id": "", + "id": "", "memo": "Payment PAY-000003 applied to IN-000003", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1726343, "total_debit": 1726343, @@ -1366,11 +1380,11 @@ "created_at": "", "entry_date": "", "entry_number": 8, - "id": "", + "id": "", "memo": "Payment PAY-000003", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1726343, "total_debit": 1726343, @@ -1380,11 +1394,11 @@ "created_at": "", "entry_date": "", "entry_number": 12, - "id": "", + "id": "", "memo": "Payment PAY-000004 applied to IN-000004", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 317253, "total_debit": 317253, @@ -1394,11 +1408,11 @@ "created_at": "", "entry_date": "", "entry_number": 11, - "id": "", + "id": "", "memo": "Payment PAY-000004", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 317253, "total_debit": 317253, @@ -1408,11 +1422,11 @@ "created_at": "", "entry_date": "", "entry_number": 45, - "id": "", + "id": "", "memo": "Invoice IN-000017", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 753725, "total_debit": 753725, @@ -1422,11 +1436,11 @@ "created_at": "", "entry_date": "", "entry_number": 30, - "id": "", + "id": "", "memo": "Payment PAY-000010 applied to IN-000010", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 131286, "total_debit": 131286, @@ -1436,11 +1450,11 @@ "created_at": "", "entry_date": "", "entry_number": 29, - "id": "", + "id": "", "memo": "Payment PAY-000010", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 131286, "total_debit": 131286, @@ -1450,11 +1464,11 @@ "created_at": "", "entry_date": "", "entry_number": 55, - "id": "", + "id": "", "memo": "Invoice IN-000021", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 135367, "total_debit": 135367, @@ -1464,11 +1478,11 @@ "created_at": "", "entry_date": "", "entry_number": 84, - "id": "", + "id": "", "memo": "Invoice IN-000032", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 879959, "total_debit": 879959, @@ -1478,11 +1492,11 @@ "created_at": "", "entry_date": "", "entry_number": 7, - "id": "", + "id": "", "memo": "Invoice IN-000003", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1726343, "total_debit": 1726343, @@ -1492,11 +1506,11 @@ "created_at": "", "entry_date": "", "entry_number": 108, - "id": "", + "id": "", "memo": "Payment PAY-000034 applied to IN-000040", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 114044, "total_debit": 114044, @@ -1506,11 +1520,11 @@ "created_at": "", "entry_date": "", "entry_number": 107, - "id": "", + "id": "", "memo": "Payment PAY-000034", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 114044, "total_debit": 114044, @@ -1520,11 +1534,11 @@ "created_at": "", "entry_date": "", "entry_number": 10, - "id": "", + "id": "", "memo": "Invoice IN-000004", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 793132, "total_debit": 793132, @@ -1534,11 +1548,11 @@ "created_at": "", "entry_date": "", "entry_number": 28, - "id": "", + "id": "", "memo": "Invoice IN-000010", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 131286, "total_debit": 131286, @@ -1548,11 +1562,11 @@ "created_at": "", "entry_date": "", "entry_number": 76, - "id": "", + "id": "", "memo": "Payment PAY-000024 applied to IN-000028", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1701123, "total_debit": 1701123, @@ -1562,11 +1576,11 @@ "created_at": "", "entry_date": "", "entry_number": 75, - "id": "", + "id": "", "memo": "Payment PAY-000024", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1701123, "total_debit": 1701123, @@ -1576,11 +1590,11 @@ "created_at": "", "entry_date": "", "entry_number": 106, - "id": "", + "id": "", "memo": "Invoice IN-000040", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 114044, "total_debit": 114044, @@ -1590,11 +1604,11 @@ "created_at": "", "entry_date": "", "entry_number": 77, - "id": "", + "id": "", "memo": "Invoice IN-000029", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 471765, "total_debit": 471765, @@ -1604,11 +1618,11 @@ "created_at": "", "entry_date": "", "entry_number": 105, - "id": "", + "id": "", "memo": "Payment PAY-000033 applied to IN-000039", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 778130, "total_debit": 778130, @@ -1618,11 +1632,11 @@ "created_at": "", "entry_date": "", "entry_number": 104, - "id": "", + "id": "", "memo": "Payment PAY-000033", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 778130, "total_debit": 778130, @@ -1632,11 +1646,11 @@ "created_at": "", "entry_date": "", "entry_number": 60, - "id": "", + "id": "", "memo": "Payment PAY-000019 applied to IN-000022", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 968303, "total_debit": 968303, @@ -1646,11 +1660,11 @@ "created_at": "", "entry_date": "", "entry_number": 59, - "id": "", + "id": "", "memo": "Payment PAY-000019", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 968303, "total_debit": 968303, @@ -1660,11 +1674,11 @@ "created_at": "", "entry_date": "", "entry_number": 74, - "id": "", + "id": "", "memo": "Invoice IN-000028", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1701123, "total_debit": 1701123, @@ -1674,11 +1688,11 @@ "created_at": "", "entry_date": "", "entry_number": 6, - "id": "", + "id": "", "memo": "Payment PAY-000002 applied to IN-000002", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1706734, "total_debit": 1706734, @@ -1688,11 +1702,11 @@ "created_at": "", "entry_date": "", "entry_number": 5, - "id": "", + "id": "", "memo": "Payment PAY-000002", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1706734, "total_debit": 1706734, @@ -1702,11 +1716,11 @@ "created_at": "", "entry_date": "", "entry_number": 103, - "id": "", + "id": "", "memo": "Invoice IN-000039", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 778130, "total_debit": 778130, @@ -1716,11 +1730,11 @@ "created_at": "", "entry_date": "", "entry_number": 102, - "id": "", + "id": "", "memo": "Payment PAY-000032 applied to IN-000038", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1528233, "total_debit": 1528233, @@ -1730,11 +1744,11 @@ "created_at": "", "entry_date": "", "entry_number": 101, - "id": "", + "id": "", "memo": "Payment PAY-000032", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1528233, "total_debit": 1528233, @@ -1744,11 +1758,11 @@ "created_at": "", "entry_date": "", "entry_number": 58, - "id": "", + "id": "", "memo": "Invoice IN-000022", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 968303, "total_debit": 968303, @@ -1758,11 +1772,11 @@ "created_at": "", "entry_date": "", "entry_number": 3, - "id": "", + "id": "", "memo": "Payment PAY-000001 applied to IN-000001", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 813092, "total_debit": 813092, @@ -1772,11 +1786,11 @@ "created_at": "", "entry_date": "", "entry_number": 2, - "id": "", + "id": "", "memo": "Payment PAY-000001", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 813092, "total_debit": 813092, @@ -1786,11 +1800,11 @@ "created_at": "", "entry_date": "", "entry_number": 51, - "id": "", + "id": "", "memo": "Payment PAY-000016 applied to IN-000019", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 594508, "total_debit": 594508, @@ -1800,11 +1814,11 @@ "created_at": "", "entry_date": "", "entry_number": 50, - "id": "", + "id": "", "memo": "Payment PAY-000016", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 594508, "total_debit": 594508, @@ -1814,11 +1828,11 @@ "created_at": "", "entry_date": "", "entry_number": 126, - "id": "", + "id": "", "memo": "Credit memo CM-000002", "posted_by": "seed", "source": "CREDIT_MEMO", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 28500, "total_debit": 28500, @@ -1828,11 +1842,11 @@ "created_at": "", "entry_date": "", "entry_number": 37, - "id": "", + "id": "", "memo": "Invoice IN-000013", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 198455, "total_debit": 198455, @@ -1842,11 +1856,11 @@ "created_at": "", "entry_date": "", "entry_number": 4, - "id": "", + "id": "", "memo": "Invoice IN-000002", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1706734, "total_debit": 1706734, @@ -1856,11 +1870,11 @@ "created_at": "", "entry_date": "", "entry_number": 100, - "id": "", + "id": "", "memo": "Invoice IN-000038", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1528233, "total_debit": 1528233, @@ -1870,11 +1884,11 @@ "created_at": "", "entry_date": "", "entry_number": 125, - "id": "", + "id": "", "memo": "Credit memo CM-000001", "posted_by": "seed", "source": "CREDIT_MEMO", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 12500, "total_debit": 12500, @@ -1884,11 +1898,11 @@ "created_at": "", "entry_date": "", "entry_number": 48, - "id": "", + "id": "", "memo": "Invoice IN-000018", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1210905, "total_debit": 1210905, @@ -1898,11 +1912,11 @@ "created_at": "", "entry_date": "", "entry_number": 1, - "id": "", + "id": "", "memo": "Invoice IN-000001", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 813092, "total_debit": 813092, @@ -1912,11 +1926,11 @@ "created_at": "", "entry_date": "", "entry_number": 24, - "id": "", + "id": "", "memo": "Payment PAY-000008 applied to IN-000008", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 565989, "total_debit": 565989, @@ -1926,11 +1940,11 @@ "created_at": "", "entry_date": "", "entry_number": 23, - "id": "", + "id": "", "memo": "Payment PAY-000008", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 565989, "total_debit": 565989, @@ -1940,11 +1954,11 @@ "created_at": "", "entry_date": "", "entry_number": 49, - "id": "", + "id": "", "memo": "Invoice IN-000019", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 594508, "total_debit": 594508, @@ -1954,11 +1968,11 @@ "created_at": "", "entry_date": "", "entry_number": 115, - "id": "", + "id": "", "memo": "Invoice IN-000043", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 589412, "total_debit": 589412, @@ -1968,11 +1982,11 @@ "created_at": "", "entry_date": "", "entry_number": 22, - "id": "", + "id": "", "memo": "Invoice IN-000008", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 565989, "total_debit": 565989, @@ -1982,11 +1996,11 @@ "created_at": "", "entry_date": "", "entry_number": 72, - "id": "", + "id": "", "memo": "Payment PAY-000023 applied to IN-000026", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 729297, "total_debit": 729297, @@ -1996,11 +2010,11 @@ "created_at": "", "entry_date": "", "entry_number": 71, - "id": "", + "id": "", "memo": "Payment PAY-000023", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 729297, "total_debit": 729297, @@ -2010,11 +2024,11 @@ "created_at": "", "entry_date": "", "entry_number": 70, - "id": "", + "id": "", "memo": "Invoice IN-000026", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 729297, "total_debit": 729297, @@ -2024,11 +2038,11 @@ "created_at": "", "entry_date": "", "entry_number": 90, - "id": "", + "id": "", "memo": "Payment PAY-000028 applied to IN-000034", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 896506, "total_debit": 896506, @@ -2038,11 +2052,11 @@ "created_at": "", "entry_date": "", "entry_number": 89, - "id": "", + "id": "", "memo": "Payment PAY-000028", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 896506, "total_debit": 896506, @@ -2052,11 +2066,11 @@ "created_at": "", "entry_date": "", "entry_number": 44, - "id": "", + "id": "", "memo": "Payment PAY-000014 applied to IN-000016", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 610007, "total_debit": 610007, @@ -2066,11 +2080,11 @@ "created_at": "", "entry_date": "", "entry_number": 43, - "id": "", + "id": "", "memo": "Payment PAY-000014", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 610007, "total_debit": 610007, @@ -2080,11 +2094,11 @@ "created_at": "", "entry_date": "", "entry_number": 83, - "id": "", + "id": "", "memo": "Payment PAY-000026 applied to IN-000031", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1153309, "total_debit": 1153309, @@ -2094,11 +2108,11 @@ "created_at": "", "entry_date": "", "entry_number": 82, - "id": "", + "id": "", "memo": "Payment PAY-000026", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1153309, "total_debit": 1153309, @@ -2108,11 +2122,11 @@ "created_at": "", "entry_date": "", "entry_number": 114, - "id": "", + "id": "", "memo": "Payment PAY-000036 applied to IN-000042", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 312490, "total_debit": 312490, @@ -2122,11 +2136,11 @@ "created_at": "", "entry_date": "", "entry_number": 113, - "id": "", + "id": "", "memo": "Payment PAY-000036", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 312490, "total_debit": 312490, @@ -2136,11 +2150,11 @@ "created_at": "", "entry_date": "", "entry_number": 88, - "id": "", + "id": "", "memo": "Invoice IN-000034", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 896506, "total_debit": 896506, @@ -2150,11 +2164,11 @@ "created_at": "", "entry_date": "", "entry_number": 42, - "id": "", + "id": "", "memo": "Invoice IN-000016", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 610007, "total_debit": 610007, @@ -2164,11 +2178,11 @@ "created_at": "", "entry_date": "", "entry_number": 118, - "id": "", + "id": "", "memo": "Payment PAY-000037 applied to IN-000044", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1512009, "total_debit": 1512009, @@ -2178,11 +2192,11 @@ "created_at": "", "entry_date": "", "entry_number": 117, - "id": "", + "id": "", "memo": "Payment PAY-000037", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1512009, "total_debit": 1512009, @@ -2192,11 +2206,11 @@ "created_at": "", "entry_date": "", "entry_number": 81, - "id": "", + "id": "", "memo": "Invoice IN-000031", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1153309, "total_debit": 1153309, @@ -2206,11 +2220,11 @@ "created_at": "", "entry_date": "", "entry_number": 112, - "id": "", + "id": "", "memo": "Invoice IN-000042", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 312490, "total_debit": 312490, @@ -2220,11 +2234,11 @@ "created_at": "", "entry_date": "", "entry_number": 116, - "id": "", + "id": "", "memo": "Invoice IN-000044", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1512009, "total_debit": 1512009, @@ -2234,11 +2248,11 @@ "created_at": "", "entry_date": "", "entry_number": 66, - "id": "", + "id": "", "memo": "Payment PAY-000021 applied to IN-000024", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 250918, "total_debit": 250918, @@ -2248,11 +2262,11 @@ "created_at": "", "entry_date": "", "entry_number": 65, - "id": "", + "id": "", "memo": "Payment PAY-000021", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 250918, "total_debit": 250918, @@ -2262,11 +2276,11 @@ "created_at": "", "entry_date": "", "entry_number": 64, - "id": "", + "id": "", "memo": "Invoice IN-000024", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 627295, "total_debit": 627295, @@ -2276,11 +2290,11 @@ "created_at": "", "entry_date": "", "entry_number": 87, - "id": "", + "id": "", "memo": "Payment PAY-000027 applied to IN-000033", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 881989, "total_debit": 881989, @@ -2290,11 +2304,11 @@ "created_at": "", "entry_date": "", "entry_number": 86, - "id": "", + "id": "", "memo": "Payment PAY-000027", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 881989, "total_debit": 881989, @@ -2304,11 +2318,11 @@ "created_at": "", "entry_date": "", "entry_number": 27, - "id": "", + "id": "", "memo": "Payment PAY-000009 applied to IN-000009", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 411909, "total_debit": 411909, @@ -2318,11 +2332,11 @@ "created_at": "", "entry_date": "", "entry_number": 26, - "id": "", + "id": "", "memo": "Payment PAY-000009", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 411909, "total_debit": 411909, @@ -2332,11 +2346,11 @@ "created_at": "", "entry_date": "", "entry_number": 15, - "id": "", + "id": "", "memo": "Payment PAY-000005 applied to IN-000005", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 151538, "total_debit": 151538, @@ -2346,11 +2360,11 @@ "created_at": "", "entry_date": "", "entry_number": 14, - "id": "", + "id": "", "memo": "Payment PAY-000005", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 151538, "total_debit": 151538, @@ -2360,11 +2374,11 @@ "created_at": "", "entry_date": "", "entry_number": 99, - "id": "", + "id": "", "memo": "Payment PAY-000031 applied to IN-000037", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1002771, "total_debit": 1002771, @@ -2374,11 +2388,11 @@ "created_at": "", "entry_date": "", "entry_number": 98, - "id": "", + "id": "", "memo": "Payment PAY-000031", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1002771, "total_debit": 1002771, @@ -2388,11 +2402,11 @@ "created_at": "", "entry_date": "", "entry_number": 85, - "id": "", + "id": "", "memo": "Invoice IN-000033", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 881989, "total_debit": 881989, @@ -2402,11 +2416,11 @@ "created_at": "", "entry_date": "", "entry_number": 25, - "id": "", + "id": "", "memo": "Invoice IN-000009", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 411909, "total_debit": 411909, @@ -2416,11 +2430,11 @@ "created_at": "", "entry_date": "", "entry_number": 13, - "id": "", + "id": "", "memo": "Invoice IN-000005", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 378846, "total_debit": 378846, @@ -2430,11 +2444,11 @@ "created_at": "", "entry_date": "", "entry_number": 111, - "id": "", + "id": "", "memo": "Payment PAY-000035 applied to IN-000041", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 341856, "total_debit": 341856, @@ -2444,11 +2458,11 @@ "created_at": "", "entry_date": "", "entry_number": 110, - "id": "", + "id": "", "memo": "Payment PAY-000035", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 341856, "total_debit": 341856, @@ -2458,11 +2472,11 @@ "created_at": "", "entry_date": "", "entry_number": 97, - "id": "", + "id": "", "memo": "Invoice IN-000037", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1002771, "total_debit": 1002771, @@ -2472,11 +2486,11 @@ "created_at": "", "entry_date": "", "entry_number": 124, - "id": "", + "id": "", "memo": "Payment PAY-000039 applied to IN-000046", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 627004, "total_debit": 627004, @@ -2486,11 +2500,11 @@ "created_at": "", "entry_date": "", "entry_number": 123, - "id": "", + "id": "", "memo": "Payment PAY-000039", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 627004, "total_debit": 627004, @@ -2500,11 +2514,11 @@ "created_at": "", "entry_date": "", "entry_number": 109, - "id": "", + "id": "", "memo": "Invoice IN-000041", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 341856, "total_debit": 341856, @@ -2514,11 +2528,11 @@ "created_at": "", "entry_date": "", "entry_number": 93, - "id": "", + "id": "", "memo": "Payment PAY-000029 applied to IN-000035", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1361223, "total_debit": 1361223, @@ -2528,11 +2542,11 @@ "created_at": "", "entry_date": "", "entry_number": 92, - "id": "", + "id": "", "memo": "Payment PAY-000029", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1361223, "total_debit": 1361223, @@ -2542,11 +2556,11 @@ "created_at": "", "entry_date": "", "entry_number": 122, - "id": "", + "id": "", "memo": "Invoice IN-000046", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 627004, "total_debit": 627004, @@ -2556,11 +2570,11 @@ "created_at": "", "entry_date": "", "entry_number": 91, - "id": "", + "id": "", "memo": "Invoice IN-000035", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1361223, "total_debit": 1361223, @@ -2570,11 +2584,11 @@ "created_at": "", "entry_date": "", "entry_number": 73, - "id": "", + "id": "", "memo": "Invoice IN-000027", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 671849, "total_debit": 671849, @@ -2584,11 +2598,11 @@ "created_at": "", "entry_date": "", "entry_number": 69, - "id": "", + "id": "", "memo": "Payment PAY-000022 applied to IN-000025", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 844652, "total_debit": 844652, @@ -2598,11 +2612,11 @@ "created_at": "", "entry_date": "", "entry_number": 68, - "id": "", + "id": "", "memo": "Payment PAY-000022", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 844652, "total_debit": 844652, @@ -2612,11 +2626,11 @@ "created_at": "", "entry_date": "", "entry_number": 21, - "id": "", + "id": "", "memo": "Payment PAY-000007 applied to IN-000007", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1166200, "total_debit": 1166200, @@ -2626,11 +2640,11 @@ "created_at": "", "entry_date": "", "entry_number": 20, - "id": "", + "id": "", "memo": "Payment PAY-000007", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1166200, "total_debit": 1166200, @@ -2640,11 +2654,11 @@ "created_at": "", "entry_date": "", "entry_number": 33, - "id": "", + "id": "", "memo": "Payment PAY-000011 applied to IN-000011", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 591608, "total_debit": 591608, @@ -2654,11 +2668,11 @@ "created_at": "", "entry_date": "", "entry_number": 32, - "id": "", + "id": "", "memo": "Payment PAY-000011", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 591608, "total_debit": 591608, @@ -2668,11 +2682,11 @@ "created_at": "", "entry_date": "", "entry_number": 67, - "id": "", + "id": "", "memo": "Invoice IN-000025", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 844652, "total_debit": 844652, @@ -2682,11 +2696,11 @@ "created_at": "", "entry_date": "", "entry_number": 18, - "id": "", + "id": "", "memo": "Payment PAY-000006 applied to IN-000006", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2109656, "total_debit": 2109656, @@ -2696,11 +2710,11 @@ "created_at": "", "entry_date": "", "entry_number": 17, - "id": "", + "id": "", "memo": "Payment PAY-000006", "posted_by": "seed", "source": "PAYMENT", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2109656, "total_debit": 2109656, @@ -2710,11 +2724,11 @@ "created_at": "", "entry_date": "", "entry_number": 19, - "id": "", + "id": "", "memo": "Invoice IN-000007", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 1166200, "total_debit": 1166200, @@ -2724,11 +2738,11 @@ "created_at": "", "entry_date": "", "entry_number": 31, - "id": "", + "id": "", "memo": "Invoice IN-000011", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 591608, "total_debit": 591608, @@ -2738,11 +2752,11 @@ "created_at": "", "entry_date": "", "entry_number": 16, - "id": "", + "id": "", "memo": "Invoice IN-000006", "posted_by": "seed", "source": "INVOICE", - "source_ref_id": "", + "source_ref_id": "", "status": "POSTED", "total_credit": 2109656, "total_debit": 2109656, diff --git a/core/internal/drafts/feed_test.go b/core/internal/drafts/feed_test.go index 53fa8d2d..456741c3 100644 --- a/core/internal/drafts/feed_test.go +++ b/core/internal/drafts/feed_test.go @@ -271,7 +271,7 @@ func TestFeedCommitOrderServesBoth(t *testing.T) { t.Cleanup(func() { _ = txA.Rollback(context.Background()) }) if _, err := txA.Exec(ctx, "INSERT INTO draft_events (draft_id, module, branch_id, op, revision, status, actor_kind) "+ - "VALUES ($1, 'quotes', (SELECT value::uuid FROM system_settings WHERE key='default_branch_id'), 'created', 1, 'OPEN', 'anonymous')", + "VALUES ($1, 'quotes', (SELECT value::uuid FROM system_settings WHERE key='default_branch_id'), 'created', 1, 'OPEN', 'anonymous')", uuid.New()); err != nil { t.Fatalf("A insert: %v", err) } @@ -289,7 +289,7 @@ func TestFeedCommitOrderServesBoth(t *testing.T) { go func() { _, err := txB.Exec(ctx, "INSERT INTO draft_events (draft_id, module, branch_id, op, revision, status, actor_kind) "+ - "VALUES ($1, 'quotes', (SELECT value::uuid FROM system_settings WHERE key='default_branch_id'), 'created', 1, 'OPEN', 'anonymous')", + "VALUES ($1, 'quotes', (SELECT value::uuid FROM system_settings WHERE key='default_branch_id'), 'created', 1, 'OPEN', 'anonymous')", uuid.New()) insertBErr <- err }() diff --git a/core/internal/gl/postentry.go b/core/internal/gl/postentry.go index 85d4f47b..46a5c852 100644 --- a/core/internal/gl/postentry.go +++ b/core/internal/gl/postentry.go @@ -19,6 +19,7 @@ import ( const ( AccountCodeInventory = "1030" AccountCodeSalesTax = "2020" + AccountCodeAP = "2010" // Accounts Payable: what vendors are owed (C4-1b, ADR 0008 7.4) AccountCodeCOGS = "5010" AccountCodeDeliveryRev = "4020" AccountCodeSalesDiscounts = "4050" @@ -38,6 +39,9 @@ var ( // ErrPeriodClosed is the 077 trigger's refusal, mapped: the act answers 409 // with the blocker period_closed. ErrPeriodClosed = errors.New("gl: the entry is dated into a closed fiscal period") + // ErrAccountMissing names a code the chart of accounts does not hold, so + // the act can answer a 409 telling the operator what to mend. + ErrAccountMissing = errors.New("gl: a required account is missing from the chart of accounts") ) // Leg is one leg of a posting, by stable account code. A leg with a zero diff --git a/core/internal/gl/service.go b/core/internal/gl/service.go index bf0cf379..554e0341 100644 --- a/core/internal/gl/service.go +++ b/core/internal/gl/service.go @@ -63,7 +63,7 @@ func (s *Service) resolveAccountIDs(ctx context.Context, codes ...string) (map[s for _, c := range codes { id, ok := byCode[c] if !ok { - return nil, fmt.Errorf("required GL account %q not found in chart of accounts", c) + return nil, fmt.Errorf("%w: %q", ErrAccountMissing, c) } out[c] = id } @@ -690,7 +690,6 @@ func (s *Service) ApplyCustomerDeposit(ctx context.Context, depositID uuid.UUID, return entry.ID, nil } -// SyncVendorInvoice creates a journal entry: DR Expense/Inventory / CR Accounts Payable. // PostTillOverShort books a balanced journal entry for a drawer variance at // till close and returns the entry ID. over_short = counted − expected: // - short (negative): DR Cash Over/Short, CR Cash (book cash drops to the @@ -742,93 +741,6 @@ func (s *Service) PostTillOverShort(ctx context.Context, sessionID uuid.UUID, ov return entry.ID, nil } -func (s *Service) SyncVendorInvoice(ctx context.Context, invoiceID uuid.UUID, totalCents int64, lineDetails []VendorInvoiceLineDetail) error { - entry := &JournalEntry{ - EntryDate: time.Now(), - Memo: fmt.Sprintf("Vendor Invoice %s", invoiceID), - Source: SourceVendorInv, - SourceRefID: &invoiceID, - Status: StatusPosted, - PostedBy: "system", - Lines: []JournalLine{ - {AccountID: uuid.Nil, Description: "Accounts Payable", Debit: 0, Credit: totalCents}, - }, - } - - for _, line := range lineDetails { - acctID := uuid.Nil - if line.GLAccountID != nil { - acctID = *line.GLAccountID - } - entry.Lines = append(entry.Lines, JournalLine{ - AccountID: acctID, - Description: line.Description, - Debit: line.AmountCents, - Credit: 0, - }) - } - - // If line GL IDs were missing, try to resolve AP by name - if entry.Lines[0].AccountID == uuid.Nil { - accounts, err := s.repo.ListAccounts(ctx) - if err == nil { - for _, a := range accounts { - if a.Name == "Accounts Payable" { - entry.Lines[0].AccountID = a.ID - break - } - } - } - } - - if err := s.repo.CreateJournalEntry(ctx, entry); err != nil { - s.logger.Warn("Failed to post vendor invoice to internal GL", "error", err, "invoice_id", invoiceID) - } - - return nil -} - -// SyncVendorPayment creates a journal entry: DR Accounts Payable / CR Cash. -func (s *Service) SyncVendorPayment(ctx context.Context, paymentID uuid.UUID, amount int64) error { - entry := &JournalEntry{ - EntryDate: time.Now(), - Memo: fmt.Sprintf("Vendor Payment %s", paymentID), - Source: SourceVendorPmt, - SourceRefID: &paymentID, - Status: StatusPosted, - PostedBy: "system", - Lines: []JournalLine{ - {AccountID: uuid.Nil, Description: "Accounts Payable", Debit: amount, Credit: 0}, - {AccountID: uuid.Nil, Description: "Cash", Debit: 0, Credit: amount}, - }, - } - - accounts, err := s.repo.ListAccounts(ctx) - if err == nil { - acctMap := make(map[string]uuid.UUID) - for _, a := range accounts { - acctMap[a.Name] = a.ID - } - for i := range entry.Lines { - if id, ok := acctMap[entry.Lines[i].Description]; ok { - entry.Lines[i].AccountID = id - } - } - } - - if err := s.repo.CreateJournalEntry(ctx, entry); err != nil { - s.logger.Warn("Failed to post vendor payment to internal GL", "error", err, "payment_id", paymentID) - } - - return nil -} - -type VendorInvoiceLineDetail struct { - Description string - AmountCents int64 - GLAccountID *uuid.UUID -} - // --- Helpers --- func isValidAccountType(t string) bool { diff --git a/core/internal/inventory/levels_embed_test.go b/core/internal/inventory/levels_embed_test.go index 18688497..c3ae3795 100644 --- a/core/internal/inventory/levels_embed_test.go +++ b/core/internal/inventory/levels_embed_test.go @@ -90,4 +90,4 @@ func jsonTagsOrdered(t reflect.Type) []string { out = append(out, name) } return out -} \ No newline at end of file +} diff --git a/core/internal/matching/service.go b/core/internal/matching/service.go index 52a3cce1..39d6b454 100644 --- a/core/internal/matching/service.go +++ b/core/internal/matching/service.go @@ -8,9 +8,11 @@ import ( "fmt" "log/slog" "math" + "strconv" "time" "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/internal/purchase_order" "github.com/gablelbm/gable/pkg/database" "github.com/gablelbm/gable/pkg/money" @@ -37,9 +39,9 @@ type POSource interface { // APSource is the slice of the accounts-payable service the matcher needs, // declared by the consumer for the same reason. *ap.Service satisfies it as-is. type APSource interface { - ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]ap.VendorInvoice, error) - GetVendorInvoice(ctx context.Context, id uuid.UUID) (*ap.VendorInvoice, error) - ApproveInvoice(ctx context.Context, invoiceID uuid.UUID, approverID uuid.UUID) (*ap.VendorInvoice, error) + ListVendorInvoices(ctx context.Context, vendorID *uuid.UUID, status string) ([]ap.Invoice, error) + GetVendorInvoice(ctx context.Context, id uuid.UUID) (*ap.Invoice, error) + ApproveInvoice(ctx context.Context, invoiceID uuid.UUID, approverID uuid.UUID) (*ap.Invoice, error) } // Service handles 3-way PO matching business logic. @@ -87,15 +89,14 @@ func (s *Service) RunMatch(ctx context.Context, poID uuid.UUID) (*MatchResult, e return nil, fmt.Errorf("failed to list vendor invoices: %w", err) } - var vendorInvoice *ap.VendorInvoice - for i, inv := range invoices { + var vendorInvoice *ap.Invoice + for _, inv := range invoices { if inv.POID != nil && *inv.POID == poID { fullInv, err := s.apSvc.GetVendorInvoice(ctx, inv.ID) if err != nil { continue } vendorInvoice = fullInv - _ = i break } } @@ -134,7 +135,7 @@ func (s *Service) RunMatch(ctx context.Context, poID uuid.UUID) (*MatchResult, e } // 5. Build invoice line lookup (by index position since we can't match by product) - invoiceLines := make(map[int]ap.VendorInvoiceLine) + invoiceLines := make(map[int]ap.InvoiceLine) if vendorInvoice != nil { for i, line := range vendorInvoice.Lines { invoiceLines[i] = line @@ -160,8 +161,12 @@ func (s *Service) RunMatch(ctx context.Context, poID uuid.UUID) (*MatchResult, e // Get corresponding invoice line (matched by position) if invLine, ok := invoiceLines[i]; ok { - detail.InvoicedQty = invLine.Quantity - detail.InvoiceUnitPrice = invLine.UnitPrice + // The AP line's quantity and unit price are the wire's scale 4 + // types since C4-1b; the comparison here stays in the floats and + // cents this unconverted module already uses until its own + // conversion (C4-2 E). + detail.InvoicedQty = quantityFloat(invLine.Quantity) + detail.InvoiceUnitPrice = int64(math.Round(float64(invLine.UnitPriceTenThousandths) / 100)) } // If no invoice line exists, it is an exception: there is nothing to @@ -346,6 +351,13 @@ func lineAmountDiffCents(detail *MatchLineDetail) int64 { return money.RoundToCents(float64(detail.InvoiceUnitPrice-detail.POUnitCost) * qty) } +// quantityFloat reads a wire quantity as the float this module's unconverted +// tolerance arithmetic still works in. +func quantityFloat(q httpx.Quantity) float64 { + f, _ := strconv.ParseFloat(q.DecimalString(), 64) + return f +} + func calcVariancePct(expected, actual float64) float64 { if expected == 0 { if actual == 0 { diff --git a/core/internal/matching/service_test.go b/core/internal/matching/service_test.go index 3638f980..9ab7b742 100644 --- a/core/internal/matching/service_test.go +++ b/core/internal/matching/service_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/gablelbm/gable/internal/ap" + "github.com/gablelbm/gable/internal/platform/httpx" "github.com/gablelbm/gable/internal/purchase_order" "github.com/google/uuid" ) @@ -143,19 +144,19 @@ type approval struct { } type fakeAP struct { - headers []ap.VendorInvoice // what ListVendorInvoices returns - full map[uuid.UUID]ap.VendorInvoice // what GetVendorInvoice returns (with lines) + headers []ap.Invoice // what ListVendorInvoices returns + full map[uuid.UUID]ap.Invoice // what GetVendorInvoice returns (with lines) listErr error getErr error approveErr error approvals []approval } -func (f *fakeAP) ListVendorInvoices(context.Context, *uuid.UUID, string) ([]ap.VendorInvoice, error) { +func (f *fakeAP) ListVendorInvoices(context.Context, *uuid.UUID, string) ([]ap.Invoice, error) { return f.headers, f.listErr } -func (f *fakeAP) GetVendorInvoice(_ context.Context, id uuid.UUID) (*ap.VendorInvoice, error) { +func (f *fakeAP) GetVendorInvoice(_ context.Context, id uuid.UUID) (*ap.Invoice, error) { if f.getErr != nil { return nil, f.getErr } @@ -166,7 +167,7 @@ func (f *fakeAP) GetVendorInvoice(_ context.Context, id uuid.UUID) (*ap.VendorIn return &inv, nil } -func (f *fakeAP) ApproveInvoice(_ context.Context, invoiceID, approverID uuid.UUID) (*ap.VendorInvoice, error) { +func (f *fakeAP) ApproveInvoice(_ context.Context, invoiceID, approverID uuid.UUID) (*ap.Invoice, error) { f.approvals = append(f.approvals, approval{invoiceID: invoiceID, approverID: approverID}) if f.approveErr != nil { return nil, f.approveErr @@ -208,28 +209,29 @@ func poWithLines(specs ...poLineSpec) *purchase_order.PurchaseOrder { } // invoiceFor builds a vendor invoice linked to poID, with (qty, unit price in -// cents) line pairs. +// cents) line pairs. The quantities and prices are the wire's scale 4 types +// since C4-1b. type invLineSpec struct { qty float64 - unitPrice int64 // cents, as ap stores them + unitPrice int64 // cents, as the matcher's arithmetic uses them } func invoiceFor(poID uuid.UUID, specs ...invLineSpec) *fakeAP { - inv := ap.VendorInvoice{ID: uuid.New(), POID: &poID, InvoiceNumber: "V-1001"} + inv := ap.Invoice{Summary: ap.Summary{ID: uuid.New(), POID: &poID, VendorInvoiceNumber: "V-1001"}} for _, s := range specs { - inv.Lines = append(inv.Lines, ap.VendorInvoiceLine{ - ID: uuid.New(), - InvoiceID: inv.ID, - Quantity: s.qty, - UnitPrice: s.unitPrice, - LineTotal: int64(s.qty * float64(s.unitPrice)), + inv.Lines = append(inv.Lines, ap.InvoiceLine{ + ID: uuid.New(), + Position: len(inv.Lines), + Quantity: httpx.Quantity(int64(math.Round(s.qty * 10000))), + UnitPriceTenThousandths: httpx.Price(s.unitPrice * 100), + LineTotalCents: httpx.Cents(int64(s.qty * float64(s.unitPrice))), }) } header := inv header.Lines = nil // ListVendorInvoices returns headers without lines return &fakeAP{ - headers: []ap.VendorInvoice{header}, - full: map[uuid.UUID]ap.VendorInvoice{inv.ID: inv}, + headers: []ap.Invoice{header}, + full: map[uuid.UUID]ap.Invoice{inv.ID: inv}, } } @@ -669,7 +671,7 @@ func TestRunMatch_AutoApproveCanBeDisabled(t *testing.T) { // no third document — and nothing may be approved. func TestRunMatch_NoVendorInvoiceIsAnException(t *testing.T) { po := poWithLines(poLineSpec{ordered: 10, received: 10, cost: 10.00}) - apSvc := &fakeAP{full: map[uuid.UUID]ap.VendorInvoice{}} + apSvc := &fakeAP{full: map[uuid.UUID]ap.Invoice{}} repo := &fakeRepo{cfg: &MatchConfig{PriceTolerancePct: 2.0, AutoApproveOnMatch: true}} result, err := newMatchService(repo, &fakePO{po: po}, apSvc).RunMatch(context.Background(), po.ID) diff --git a/core/internal/unit/model.go b/core/internal/unit/model.go index 3e25442b..ddbf5b9c 100644 --- a/core/internal/unit/model.go +++ b/core/internal/unit/model.go @@ -49,14 +49,14 @@ func ParseDimension(name string) (Dimension, bool) { // active flag. No unit is ever deleted; the write routes offer // deactivation only. type Unit struct { - Code string `json:"code"` - Name string `json:"name"` - Dimension Dimension `json:"dimension"` + Code string `json:"code"` + Name string `json:"name"` + Dimension Dimension `json:"dimension"` StdUnitQty *httpx.Quantity `json:"std_unit_qty"` StdRefQty *httpx.Quantity `json:"std_ref_qty"` - IsSystem bool `json:"is_system"` - IsActive bool `json:"is_active"` - Revision int64 `json:"revision"` + IsSystem bool `json:"is_system"` + IsActive bool `json:"is_active"` + Revision int64 `json:"revision"` CreatedAt httpx.Timestamp `json:"created_at"` UpdatedAt httpx.Timestamp `json:"updated_at"` } diff --git a/core/migrations/106_ap_wire_contract.sql b/core/migrations/106_ap_wire_contract.sql new file mode 100644 index 00000000..88a9db8e --- /dev/null +++ b/core/migrations/106_ap_wire_contract.sql @@ -0,0 +1,364 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- 106: the AP fixes onto the wire contract (item C4-1b, ADR 0008 sections +-- 7.4 and 12, on ADR 0001). The steps are the record's C4-1b list, in order: +-- +-- 1 vendor_invoices: created_at NOT NULL, revision, the number (AP-), the +-- branch (from the purchase order's branch when that column exists, +-- else the default branch), the currency, a status CHECK on today's +-- values (lowercase spellings uppercased, cancel-like values and +-- negative totals become VOIDED, other unknown values become PENDING, +-- each with a notice), +-- po_id as a real foreign key (orphans are set null, with a notice), +-- UNIQUE (vendor_id, invoice_number) after duplicates are suffixed #2, +-- #3 in (created_at, id) order (with a notice), amount_open (the insert +-- trigger's rule: zero on a voided or paid bill, the total less paid +-- otherwise, with notices for short-paid PAID and overpaid PARTIAL +-- rows), and gl_entry_id linked to the newest posted, unreversed, +-- balanced VENDOR_INVOICE entry of every approved or partial bill (a one +-- leg entry, the base's approve shape, is never linked and is counted by +-- its own notice; so are the bills left without one). +-- 2 vendor_invoice_lines: position, purchase_order_line_id, product_id, +-- unit_price widened to NUMERIC(12,4), po_freight_charge_id (the +-- carrier freight line link of 7.3, written from package C's freight +-- rule on). Existing lines are not linked: today's lines have no +-- reliable key to their purchase line, and the old positional pairing +-- is exactly the defect. +-- 3 Keyset indexes on vendor invoices. +-- +-- Every statement is guarded so a second apply is a no-op. + +-- 1. vendor_invoices -------------------------------------------------------- + +UPDATE vendor_invoices SET created_at = NOW() WHERE created_at IS NULL; +ALTER TABLE vendor_invoices ALTER COLUMN created_at SET NOT NULL; +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1; + +-- The branch: the purchase order's when C4-1a's column is already there, +-- else the default branch for every row. +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS branch_id UUID NULL REFERENCES locations (id); +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'purchase_orders' AND column_name = 'branch_id') THEN + EXECUTE $sql$UPDATE vendor_invoices vi SET branch_id = po.branch_id + FROM purchase_orders po + WHERE po.id = vi.po_id AND vi.branch_id IS NULL AND po.branch_id IS NOT NULL$sql$; + END IF; +END $$; +DO $$ +DECLARE missing BIGINT; +BEGIN + SELECT count(*) INTO missing FROM vendor_invoices WHERE branch_id IS NULL; + UPDATE vendor_invoices + SET branch_id = (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id') + WHERE branch_id IS NULL; + IF missing > 0 THEN + RAISE NOTICE 'migration 106: % vendor invoice(s) had no branch and take the default branch', missing; + END IF; +END $$; +ALTER TABLE vendor_invoices ALTER COLUMN branch_id SET NOT NULL; + +-- A raw insert (the seed, the characterization fixtures) carries no branch, +-- currency or open amount: it opens at the default branch, in the default +-- currency, owing its whole total, as the service would set it (the invoices +-- migration's amount_open trigger is the shape). +CREATE OR REPLACE FUNCTION vendor_invoices_defaults() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.branch_id IS NULL THEN + NEW.branch_id := (SELECT value::uuid FROM system_settings WHERE key = 'default_branch_id'); + END IF; + IF NEW.currency IS NULL THEN + NEW.currency := COALESCE((SELECT value FROM system_settings WHERE key = 'currency.default'), 'USD'); + END IF; + IF NEW.amount_open IS NULL THEN + NEW.amount_open := CASE WHEN NEW.status IN ('VOIDED', 'PAID') THEN 0 ELSE NEW.total END; + END IF; + RETURN NEW; +END $$; +DROP TRIGGER IF EXISTS trg_vendor_invoices_default_branch ON vendor_invoices; +DROP TRIGGER IF EXISTS trg_vendor_invoices_defaults ON vendor_invoices; +CREATE TRIGGER trg_vendor_invoices_defaults BEFORE INSERT ON vendor_invoices + FOR EACH ROW EXECUTE FUNCTION vendor_invoices_defaults(); + +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS currency CHAR(3) NULL; +UPDATE vendor_invoices SET currency = COALESCE((SELECT s.value FROM system_settings s WHERE s.key = 'currency.default'), 'USD') +WHERE currency IS NULL; +ALTER TABLE vendor_invoices ALTER COLUMN currency SET NOT NULL; +DO $$ BEGIN + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_currency_format CHECK (currency ~ '^[A-Z]{3}$'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- The status CHECK on today's values: lowercase spellings are uppercased and +-- anything unknown becomes PENDING, each with a notice. A hand-written +-- cancel-like value (CANCELLED, CANCELED) maps to the module's voided state, +-- never to pending: a cancelled bill owes nothing. So does a negative total +-- (the base validated no sign, so a vendor credit entered as a negative bill +-- is a plausible legacy row): it owes nothing as a bill, and the amount_open +-- range CHECK below would otherwise abort the whole migration on it. +DO $$ +DECLARE lower BIGINT; unknown BIGINT; cancels BIGINT; negatives BIGINT; +BEGIN + SELECT count(*) INTO lower FROM vendor_invoices WHERE status <> UPPER(btrim(status)); + IF lower > 0 THEN + UPDATE vendor_invoices SET status = UPPER(btrim(status)) WHERE status <> UPPER(btrim(status)); + RAISE NOTICE 'migration 106: % vendor invoice status value(s) were not uppercase and are uppercased', lower; + END IF; + SELECT count(*) INTO cancels FROM vendor_invoices WHERE status IN ('CANCELLED', 'CANCELED'); + IF cancels > 0 THEN + UPDATE vendor_invoices SET status = 'VOIDED' WHERE status IN ('CANCELLED', 'CANCELED'); + RAISE NOTICE 'migration 106: % vendor invoice(s) held a cancel-like status (CANCELLED, CANCELED) and become VOIDED owing nothing', cancels; + END IF; + SELECT count(*) INTO negatives FROM vendor_invoices WHERE total < 0 AND status <> 'VOIDED'; + IF negatives > 0 THEN + UPDATE vendor_invoices SET status = 'VOIDED' WHERE total < 0 AND status <> 'VOIDED'; + RAISE NOTICE 'migration 106: % vendor invoice(s) hold a negative total (a vendor credit entered as a negative bill; re-enter it as a credit) and become VOIDED owing nothing', negatives; + END IF; + SELECT count(*) INTO unknown FROM vendor_invoices + WHERE status NOT IN ('PENDING', 'APPROVED', 'PARTIAL', 'PAID', 'VOIDED'); + IF unknown > 0 THEN + UPDATE vendor_invoices SET status = 'PENDING' + WHERE status NOT IN ('PENDING', 'APPROVED', 'PARTIAL', 'PAID', 'VOIDED'); + RAISE NOTICE 'migration 106: % vendor invoice(s) held an unknown status and become PENDING', unknown; + END IF; +END $$; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_status_check; +ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_status_check + CHECK (status IN ('PENDING', 'APPROVED', 'PARTIAL', 'PAID', 'VOIDED')); + +-- po_id becomes a real foreign key; an orphan is set null, with a notice. +DO $$ +DECLARE orphans BIGINT; +BEGIN + SELECT count(*) INTO orphans FROM vendor_invoices vi + WHERE vi.po_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM purchase_orders po WHERE po.id = vi.po_id); + IF orphans > 0 THEN + UPDATE vendor_invoices vi SET po_id = NULL + WHERE vi.po_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM purchase_orders po WHERE po.id = vi.po_id); + RAISE NOTICE 'migration 106: % vendor invoice(s) named no purchase order and their po_id is set null', orphans; + END IF; +END $$; +DO $$ BEGIN + ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_po_id_fkey; + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_po_id_fkey + FOREIGN KEY (po_id) REFERENCES purchase_orders (id) ON DELETE SET NULL; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Gable's own number, AP-, from a sequence (gapped: a create that rolls back +-- abandons its number), the payment migration's shape. +CREATE SEQUENCE IF NOT EXISTS vendor_invoice_number_seq; +CREATE OR REPLACE FUNCTION vendor_invoice_next_number() RETURNS TEXT +LANGUAGE sql AS $$ + SELECT 'AP-' || lpad(s.n::text, GREATEST(6, length(s.n::text)), '0') + FROM (SELECT nextval('vendor_invoice_number_seq') AS n) s +$$; +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS number TEXT; +WITH ordered AS ( + SELECT id, row_number() OVER (ORDER BY created_at, id) AS n FROM vendor_invoices WHERE number IS NULL +) +UPDATE vendor_invoices p +SET number = 'AP-' || lpad((o.n + COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM vendor_invoices WHERE number ~ '^AP-[0-9]+$'), 0))::text, + GREATEST(6, length((o.n + COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM vendor_invoices WHERE number ~ '^AP-[0-9]+$'), 0))::text)), '0') +FROM ordered o WHERE p.id = o.id; +SELECT setval('vendor_invoice_number_seq', + GREATEST(COALESCE((SELECT MAX(substring(number FROM 4)::bigint) FROM vendor_invoices WHERE number ~ '^AP-[0-9]+$'), 0) + 1, + (SELECT CASE WHEN is_called THEN last_value + 1 ELSE last_value END FROM vendor_invoice_number_seq)), + false); +ALTER TABLE vendor_invoices ALTER COLUMN number SET DEFAULT vendor_invoice_next_number(); +ALTER TABLE vendor_invoices ALTER COLUMN number SET NOT NULL; +DO $$ BEGIN + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_number_key UNIQUE (number); +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; +END $$; + +-- The vendor's own number is unique per vendor once duplicates are suffixed +-- #2, #3 in (created_at, id) order, the oldest keeping the bare number. A +-- vendor may already hold a genuine "X #2" beside its duplicate "X" rows, so +-- each suffixed row takes the next suffix no row of that vendor holds. +DO $$ +DECLARE dups BIGINT; r RECORD; n BIGINT; candidate TEXT; +BEGIN + SELECT count(*) - count(DISTINCT (vendor_id, invoice_number)) INTO dups FROM vendor_invoices; + IF dups > 0 THEN + FOR r IN ( + SELECT id, vendor_id, invoice_number, + row_number() OVER (PARTITION BY vendor_id, invoice_number ORDER BY created_at, id) AS rn + FROM vendor_invoices + ) LOOP + IF r.rn = 1 THEN + CONTINUE; + END IF; + n := r.rn; + LOOP + candidate := left(r.invoice_number, 64 - length(' #' || n)) || ' #' || n; + EXIT WHEN NOT EXISTS (SELECT 1 FROM vendor_invoices vi + WHERE vi.vendor_id = r.vendor_id AND vi.invoice_number = candidate AND vi.id <> r.id); + n := n + 1; + END LOOP; + UPDATE vendor_invoices SET invoice_number = candidate WHERE id = r.id; + END LOOP; + RAISE NOTICE 'migration 106: % duplicate vendor invoice number(s) suffixed #2, #3 in (created_at, id) order, the next free suffix where a genuine one is taken', dups; + END IF; +END $$; +DO $$ BEGIN + ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_vendor_number_key UNIQUE (vendor_id, invoice_number); +EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; +END $$; + +-- The entry the approval posts, which the void transition reverses. +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS gl_entry_id UUID NULL REFERENCES gl_journal_entries (id); + +-- A bill the base approved holds its entry (the removed sync posted it with +-- source VENDOR_INVOICE and source_ref_id the bill) but no pointer to it. +-- Every approved or partial bill is linked to its newest posted, unreversed, +-- balanced VENDOR_INVOICE entry (one already reversed has been undone by hand +-- and is not the void's to reverse again; one whose legs do not balance, the +-- base's one leg approve shape, would let the void mirror it into another +-- unbalanced entry, so it is never linked and its own notice names it); the +-- bills left without one are counted by a notice, and the void transition +-- refuses them until an entry is linked. +DO $$ +DECLARE oneleg BIGINT; missing BIGINT; +BEGIN + UPDATE vendor_invoices vi + SET gl_entry_id = e.id + FROM gl_journal_entries e + WHERE e.source = 'VENDOR_INVOICE' AND e.source_ref_id = vi.id + AND e.status = 'POSTED' AND e.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM gl_journal_entries r WHERE r.reverses_entry_id = e.id) + AND (SELECT SUM(l.debit) FROM gl_journal_lines l WHERE l.journal_entry_id = e.id) + = (SELECT SUM(l.credit) FROM gl_journal_lines l WHERE l.journal_entry_id = e.id) + AND vi.status IN ('APPROVED', 'PARTIAL') AND vi.gl_entry_id IS NULL + AND e.id = (SELECT e2.id FROM gl_journal_entries e2 + WHERE e2.source = 'VENDOR_INVOICE' AND e2.source_ref_id = vi.id + AND e2.status = 'POSTED' AND e2.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM gl_journal_entries r2 WHERE r2.reverses_entry_id = e2.id) + AND (SELECT SUM(l2.debit) FROM gl_journal_lines l2 WHERE l2.journal_entry_id = e2.id) + = (SELECT SUM(l2.credit) FROM gl_journal_lines l2 WHERE l2.journal_entry_id = e2.id) + ORDER BY e2.created_at DESC, e2.id DESC LIMIT 1); + SELECT count(*) INTO oneleg FROM vendor_invoices vi + WHERE vi.status IN ('APPROVED', 'PARTIAL') AND vi.gl_entry_id IS NULL + AND EXISTS (SELECT 1 FROM gl_journal_entries e + WHERE e.source = 'VENDOR_INVOICE' AND e.source_ref_id = vi.id + AND e.status = 'POSTED' AND e.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM gl_journal_entries r WHERE r.reverses_entry_id = e.id) + AND (SELECT SUM(l.debit) FROM gl_journal_lines l WHERE l.journal_entry_id = e.id) + <> (SELECT SUM(l.credit) FROM gl_journal_lines l WHERE l.journal_entry_id = e.id)); + IF oneleg > 0 THEN + RAISE NOTICE 'migration 106: % approved or partial vendor invoice(s) hold a one leg entry from the removed sync; post the missing debit legs, then link', oneleg; + END IF; + SELECT count(*) INTO missing FROM vendor_invoices vi + WHERE vi.status IN ('APPROVED', 'PARTIAL') AND vi.gl_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM gl_journal_entries e + WHERE e.source = 'VENDOR_INVOICE' AND e.source_ref_id = vi.id + AND e.status = 'POSTED' AND e.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM gl_journal_entries r WHERE r.reverses_entry_id = e.id) + AND (SELECT SUM(l.debit) FROM gl_journal_lines l WHERE l.journal_entry_id = e.id) + <> (SELECT SUM(l.credit) FROM gl_journal_lines l WHERE l.journal_entry_id = e.id)); + IF missing > 0 THEN + RAISE NOTICE 'migration 106: % approved or partial vendor invoice(s) have no journal entry to link; their void is refused until one is linked', missing; + END IF; +END $$; + +-- Two more legacy shapes the link cannot mend, each named by a notice (ADR +-- 0008 7.4 says nothing of reversing a cancelled bill's credit, so the +-- migration only counts them): a bill that is VOIDED here (hand cancelled, or +-- a negative total) still holding a posted, unreversed entry keeps its credit +-- in Accounts Payable, and a bill posted twice (the base read its status +-- outside the transaction, so a double click could post it twice) links only +-- its newest entry, leaving the other's credit standing after a void. +DO $$ +DECLARE stranded BIGINT; doubled BIGINT; +BEGIN + SELECT count(*) INTO stranded FROM vendor_invoices vi + WHERE vi.status = 'VOIDED' + AND EXISTS (SELECT 1 FROM gl_journal_entries e + WHERE e.source = 'VENDOR_INVOICE' AND e.source_ref_id = vi.id + AND e.status = 'POSTED' AND e.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM gl_journal_entries r WHERE r.reverses_entry_id = e.id)); + IF stranded > 0 THEN + RAISE NOTICE 'migration 106: % voided or cancelled vendor invoice(s) still hold a posted, unreversed journal entry; their credit stays in Accounts Payable until it is reversed by hand', stranded; + END IF; + SELECT count(*) INTO doubled FROM vendor_invoices vi + WHERE (SELECT count(*) FROM gl_journal_entries e + WHERE e.source = 'VENDOR_INVOICE' AND e.source_ref_id = vi.id + AND e.status = 'POSTED' AND e.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM gl_journal_entries r WHERE r.reverses_entry_id = e.id)) > 1; + IF doubled > 0 THEN + RAISE NOTICE 'migration 106: % vendor invoice(s) were posted more than once (more than one posted, unreversed journal entry); only the newest is linked, reverse the others by hand', doubled; + END IF; +END $$; + +-- amount_open: what the vendor is still owed, by the insert trigger's rule: +-- zero on a voided or paid bill, the total less what was paid (clamped +-- between zero and the total) otherwise. A short-paid PAID bill, an overpaid +-- APPROVED or PARTIAL one and a negative amount paid (a hand edit: the base's +-- own pay never wrote one on a positive bill) are each named by a notice, so +-- an operator can mend the row. The range CHECK says the rule exactly: a +-- voided or paid bill owes zero at any total (a negative one included, the +-- base accepted any sign); any other owes between zero and its total. +DO $$ +DECLARE shortPaid BIGINT; overpaid BIGINT; negPaid BIGINT; +BEGIN + SELECT count(*) INTO shortPaid FROM vendor_invoices WHERE status = 'PAID' AND amount_paid < total; + IF shortPaid > 0 THEN + RAISE NOTICE 'migration 106: % PAID vendor invoice(s) were paid less than their total and owe nothing', shortPaid; + END IF; + SELECT count(*) INTO overpaid FROM vendor_invoices WHERE status IN ('APPROVED', 'PARTIAL') AND amount_paid > total; + IF overpaid > 0 THEN + RAISE NOTICE 'migration 106: % APPROVED or PARTIAL vendor invoice(s) were paid past their total and owe nothing', overpaid; + END IF; + SELECT count(*) INTO negPaid FROM vendor_invoices WHERE amount_paid < 0; + IF negPaid > 0 THEN + RAISE NOTICE 'migration 106: % vendor invoice(s) carry a negative amount paid; each owes at most its total', negPaid; + END IF; +END $$; +ALTER TABLE vendor_invoices ADD COLUMN IF NOT EXISTS amount_open NUMERIC(12, 2) NULL; +UPDATE vendor_invoices SET amount_open = CASE WHEN status IN ('VOIDED', 'PAID') THEN 0 + ELSE LEAST(total, GREATEST(0, total - amount_paid)) END +WHERE amount_open IS NULL; +ALTER TABLE vendor_invoices ALTER COLUMN amount_open SET NOT NULL; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_amount_open_range; +ALTER TABLE vendor_invoices ADD CONSTRAINT vendor_invoices_amount_open_range + CHECK (amount_open >= 0 AND CASE WHEN status IN ('VOIDED', 'PAID') THEN amount_open = 0 ELSE amount_open <= total END); + +-- 2. vendor_invoice_lines --------------------------------------------------- + +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS position INTEGER NULL; + +-- A raw line insert (the characterization fixtures) carries no position: it +-- takes the next position of its bill. +CREATE OR REPLACE FUNCTION vendor_invoice_lines_default_position() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.position IS NULL THEN + NEW.position := (SELECT COALESCE(MAX(position) + 1, 0) FROM vendor_invoice_lines WHERE invoice_id = NEW.invoice_id); + END IF; + RETURN NEW; +END $$; +DROP TRIGGER IF EXISTS trg_vendor_invoice_lines_default_position ON vendor_invoice_lines; +CREATE TRIGGER trg_vendor_invoice_lines_default_position BEFORE INSERT ON vendor_invoice_lines + FOR EACH ROW EXECUTE FUNCTION vendor_invoice_lines_default_position(); +WITH ordered AS ( + SELECT id, row_number() OVER (PARTITION BY invoice_id ORDER BY created_at, id) - 1 AS pos + FROM vendor_invoice_lines +) +UPDATE vendor_invoice_lines l SET position = o.pos FROM ordered o WHERE l.id = o.id AND l.position IS NULL; +ALTER TABLE vendor_invoice_lines ALTER COLUMN position SET NOT NULL; +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS purchase_order_line_id UUID NULL + REFERENCES purchase_order_lines (id) ON DELETE SET NULL; +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS product_id UUID NULL + REFERENCES products (id) ON DELETE SET NULL; +ALTER TABLE vendor_invoice_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 4); +ALTER TABLE vendor_invoice_lines ADD COLUMN IF NOT EXISTS po_freight_charge_id UUID NULL + REFERENCES po_freight_charges (id) ON DELETE SET NULL; + +-- 3. Keyset indexes --------------------------------------------------------- + +CREATE INDEX IF NOT EXISTS idx_vendor_invoices_created_id ON vendor_invoices (created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_vendor_invoices_vendor_created ON vendor_invoices (vendor_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_vendor_invoices_po ON vendor_invoices (po_id) WHERE po_id IS NOT NULL; diff --git a/core/migrations/down/106_ap_wire_contract_down.sql b/core/migrations/down/106_ap_wire_contract_down.sql new file mode 100644 index 00000000..f3686d74 --- /dev/null +++ b/core/migrations/down/106_ap_wire_contract_down.sql @@ -0,0 +1,65 @@ +-- SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0 +-- SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +-- Down of 106_ap_wire_contract (item C4-1b). Each step reverses its own, and +-- refuses, naming the rows it cannot map back, wherever data written in the +-- new shape has no place in the old one (a down never discards a dealer's +-- data): +-- +-- 3 drops the keyset indexes; +-- 2 drops the line link columns and the position, and narrows unit_price +-- back to scale 2 only when every stored value is a whole cent, raising +-- an exception naming the count otherwise; +-- 1 drops amount_open, the number and its sequence, the currency, the +-- revision and the branch, the two uniqueness constraints and the +-- status CHECK. Three of the up's writes are kept, each valid old shape +-- data: the suffixed duplicate vendor numbers stay suffixed (a dealer +-- may have had a genuine "X #2" before, so un-suffixing cannot be told +-- apart from it), the normalized statuses stay normalized (a cancel-like +-- or negative total bill's VOIDED mapping with them), and a po_id +-- the up orphaned stays null (the purchase order it named is gone). + +-- 3 ------------------------------------------------------------------------- + +DROP INDEX IF EXISTS idx_vendor_invoices_po; +DROP INDEX IF EXISTS idx_vendor_invoices_vendor_created; +DROP INDEX IF EXISTS idx_vendor_invoices_created_id; + +-- 2 ------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_vendor_invoice_lines_default_position ON vendor_invoice_lines; +DROP FUNCTION IF EXISTS vendor_invoice_lines_default_position(); +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS po_freight_charge_id; +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS product_id; +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS purchase_order_line_id; +ALTER TABLE vendor_invoice_lines DROP COLUMN IF EXISTS position; +DO $$ +DECLARE fine BIGINT; +BEGIN + SELECT count(*) INTO fine FROM vendor_invoice_lines WHERE unit_price <> ROUND(unit_price, 2); + IF fine > 0 THEN + RAISE EXCEPTION 'vendor_invoice_lines holds % row(s) with a unit price finer than cents; the scale 2 column cannot carry them', fine; + END IF; + ALTER TABLE vendor_invoice_lines ALTER COLUMN unit_price TYPE NUMERIC(12, 2); +END $$; + +-- 1 ------------------------------------------------------------------------- + +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_amount_open_range; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS amount_open; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS gl_entry_id; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_number_key; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_vendor_number_key; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_status_check; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_currency_format; +ALTER TABLE vendor_invoices DROP CONSTRAINT IF EXISTS vendor_invoices_po_id_fkey; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS currency; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS number; +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS revision; +DROP TRIGGER IF EXISTS trg_vendor_invoices_default_branch ON vendor_invoices; +DROP TRIGGER IF EXISTS trg_vendor_invoices_defaults ON vendor_invoices; +DROP FUNCTION IF EXISTS vendor_invoices_defaults(); +DROP FUNCTION IF EXISTS vendor_invoices_default_branch(); +ALTER TABLE vendor_invoices DROP COLUMN IF EXISTS branch_id; +DROP FUNCTION IF EXISTS vendor_invoice_next_number(); +DROP SEQUENCE IF EXISTS vendor_invoice_number_seq; diff --git a/docs/refactor/CONTRACT-CHANGES.md b/docs/refactor/CONTRACT-CHANGES.md index 8970bb14..b6471441 100644 --- a/docs/refactor/CONTRACT-CHANGES.md +++ b/docs/refactor/CONTRACT-CHANGES.md @@ -274,3 +274,20 @@ get an entry. | C5-2a | `POST /api/v1/drafts/quotes`, `PUT /api/v1/drafts/quotes/{id}` (and the orders kind's) | No draft routes existed (the payload branch rule was the quote create's alone, PR 37) | The payload branch rule of ADR 0007 section 2.3 applies to every draft route through the same guard: with a branch in the request's context (always, for a bound key) a payload `branch_id` must equal it, else 403 naming `payload.branch_id`; with no context branch an administrator or owner may name any branch, and so may every caller while the single branch kill switch is off, an unbound key, and `AUTH_MODE=dev`; a user with no context branch may name only a branch in their grants; the branch is fixed at create | | C5-2a | `PUT /api/v1/quotes/{id}/file` | No such route: the AI parse's original file could only enter through the quote create, which a draft promotion replaces | The quote file route of ADR 0007 section 10: the raw file bytes with their content type, the same 5 MiB bound (413 above it), `quotes:write`, the revision precondition (428, 409), accepted only while the quote is in status `draft` (else 409 `invalid_state_transition` with the `quote_not_draft` blocker); it replaces any stored file, moves the revision by one, returns the quote with its new `ETag`, applies the branch wall like the read, writes the `quote.file_attached` audit row and no outbox event. Being an entity write on a gated module it is refused to an agent marked session | | C5-2a | `GET /api/v1/links/quotes/{id}`, `GET /api/v1/links/orders/{id}`, `GET /api/v1/links/invoices/{id}`, `GET /api/v1/links/customers/{id}`, `GET /api/v1/links/products/{id}`, `GET /api/v1/links/drafts/quotes/{id}`, `GET /api/v1/links/drafts/orders/{id}` | No link resolver existed: every frontend and agent carried its own route table | The link resolver of ADR 0007 section 8: `{id}` is a UUID or the entity's document number, the record must exist and be visible to the caller (the module's own read, so an invisible record is the same 404 a missing one gets), and the answer carries the desk link (absolute when `GABLE_PUBLIC_URL` is set, origin relative otherwise), the front door's `/?open=` form, `gable://` for the shell, a null portal until it has record screens, and the agent slot from `GABLE_AGENT_URL_TEMPLATE` (null when unset). The scope is read off the URL: the module `read` for an entity link, `propose` or `commit` for a draft link. The declarative table is the only copy and generates `core/api/links.json`; `core/api/link-paths.json` carries the plain segment vectors the shell, the door and their tests share | +| C4-1b | ap: `GET /api/v1/ap/invoices` | A bare JSON array ordered by `due_date` ascending, no paging (a `?limit=` silently ignored), `vendor_id` and free text `status` filters (an unparseable vendor_id silently dropped), money as int64 cents but no revision, branch or Gable number | The cursor envelope `{items, next_cursor, limit}` newest first by `(created_at, id)`, the strict query guard (`vendor_id`, `po_id`, `status` as a comma separated list of the lowercase statuses `pending, approved, partial, paid, voided`, beside cursor, limit and include; unknown parameters, uppercase statuses, a malformed cursor or an out of range limit are 400s), `total` only under `include=total`, and the branch wall (a bound caller sees its branch's bills only). Every item carries `number` (Gable's own, `AP-`), `vendor_invoice_number` (the vendor's), `branch_id`, `currency`, `_cents` money, `amount_open_cents`, `revision`, `gl_entry_id` and the lowercase status | +| C4-1b | ap: `POST /api/v1/ap/invoices` | The body took float dollars (`tax_amount`, line `unit_price`, `quantity` as a float number); the bill's number was the vendor's own `invoice_number`; every service failure (a malformed date, an unknown vendor, a duplicate number) answered 500 | The wire contract's create: `tax_cents` int64, the line `unit_price_ten_thousandths` int64 and `quantity` a decimal string, the extension priced by the server with its one rounding; `vendor_invoice_number` (1 to 64 characters, unique per vendor, a duplicate a 409 naming it), an optional `branch_id` (defaults from the body, the caller's context branch or the default branch; a branch the caller may not target is a 403 naming `branch_id`), `currency` (defaults from the `currency.default` setting), an optional `po_id` (a real foreign key since migration 106), and per line an optional `purchase_order_line_id` and `product_id`. A line now requires its `gl_account_id` (the approve entry debits each line's account): a create without one is a 400 naming `lines[i].gl_account_id`. Validation failures are one 400 naming every field with its full path; the answer carries the revision and its `ETag`, `Location`, and `vendor_invoice.created` is written to the outbox with an audit row in the same transaction | +| C4-1b | ap: `GET /api/v1/ap/invoices/{id}` | Any service failure was a 404; the body carried the vendor's `invoice_number`, no `number`, `revision` or branch; lines were omitted when absent and priced in cents floats | The wire contract's read: a malformed id a 400 naming `id`, a missing or walled bill a 404, the revision's `ETag`, every optional field present as null, and lines in the shared shape (`position`, `quantity` as a decimal string, `unit_price_ten_thousandths`, `line_total_cents`, `gl_account_id`, `purchase_order_line_id`, `product_id`, `po_freight_charge_id`). The read is held to the caller's branch wall: another branch's bill is a 404 | +| C4-1b | ap: `POST /api/v1/ap/invoices/{id}/approve` replaced by `POST /api/v1/ap/invoices/{id}/transitions` | The approve route took no body, required the caller's JWT subject to be a UUID (401 otherwise, so in `AUTH_MODE=dev` it was always 401), mapped every service failure to 422, loaded no lines (so the entry it posted carried only the Accounts Payable credit leg), posted through `gl.SyncVendorInvoice` which swallowed the journal insert's error, and could never void | The transitions route of ADR 0008 7.4 with body `{to, revision, reason?}` (or `If-Match`; neither is 428, a stale one 409 `stale_revision`). `to: "approved"` (only from pending, else 409 `invalid_state_transition` with the `invoice_not_pending` blocker) loads the lines inside the transition's transaction, posts the entry through `gl.PostEntry` in that transaction (a debit leg per line on the line's account, the tax spread across the line accounts pro rata to their extensions with the last line taking the remainder, the credit to `2010` for the whole total), and a posting failure fails the act: a closed fiscal period is a 409 with the `period_closed` blocker, and a legacy line with no account is a 409 with `line_needs_account` naming the line (the base swallowed the posting error and answered 200 with the bill marked approved). `to: "voided"` (from pending, nothing posted; from approved, the entry reversed whole) requires a `reason` and is refused with the `has_payments` blocker while money is applied; `amount_open` goes to zero. The acts write `vendor_invoice.approved` and `vendor_invoice.voided` with audit rows in the same transaction. The targets `pending`, `partial` and `paid` are refused as transitions | +| C4-1b | ap: `POST /api/v1/ap/payments` | The payment applied to any invoice whatever its status (a PENDING bill was paid), read each invoice unlocked in the request's order, and posted through `gl.SyncVendorPayment`, which resolved its account ids by name and swallowed the journal insert's error | The payment act of ADR 0008 7.4, its request shape unchanged until the payment routes' own conversion (uppercase methods, `payment_date`; the float `amount` is superseded by `amount_cents`, see the request money row below): the named bills are locked in id order (section 9 step 5a) and the money applies in the order the request named them; only an `approved` or `partial` bill takes money (a pending, paid or voided bill is a 409 `invalid_state_transition` with the `invoice_not_approved` blocker, and a bill of another vendor a 400 naming `invoice_ids[i]`); more than the named bills still owe is a 409 with the `exceeds_open` blocker and writes nothing. The entry posts through `gl.PostEntry` inside the act's transaction (DR `2010` / CR `1010` for the applied sum) and a posting failure fails the act (a closed period a 409 `period_closed`). Each settled bill writes `vendor_invoice.partial` or `vendor_invoice.paid` and the payment an audit row, in the same transaction | +| C4-1b | ap: `POST /api/v1/ap/payments` request money (review fix, ADR 0008 section 11's payments conversion for the amount) | The request's `amount` was float dollars, converted with one silent rounding (10.005 became 1001 cents; 0.1 + 0.2 became 30), against the record's cents-everywhere rule | The request takes `amount_cents`, int64 cents like every other money field: a decimal or otherwise non-integer amount is a 400 naming `amount_cents`, and so is one past what the payment columns hold (999999999999 cents; the int64 maximum used to answer a 500 numeric overflow), and the old float `amount` field is refused as unknown. The response's `amount` stays cents as it already was; the rest of the payment request shape (uppercase methods) keeps its today form until the payment routes' own conversion. The desk AP page sends cents (its form still speaks dollars to the clerk, converted once and exactly on submit) | +| C4-1b | ap: `POST /api/v1/ap/invoices` money ceilings (review fix) | A bill whose tax, unit price, line total, subtotal or total did not fit its column (NUMERIC(12,2) for the money, NUMERIC(12,4) for the unit price) answered 500 `internal_error` on a numeric overflow | Each is a 400 `validation_failed` naming its field and nothing is written: `tax_cents` past 999999999999, `lines[i].unit_price_ten_thousandths` past 999999999999, `lines[i].quantity` when the line total passes 999999999999 cents, `lines` when the subtotal does, and `tax_cents` when the lines fit but the lines and the tax together do not | +| C4-1b | ap: `GET /api/v1/ap/payments`, `GET /api/v1/ap/aging` | The payments list silently dropped an unparseable `vendor_id`; the aging buckets were computed from `total - amount_paid` with no parameter guard | Their shapes stay as they are until the payment and aging routes' own conversion (ADR 0008 section 11): the strict query guard now applies (an unparseable `vendor_id` is a 400 naming it on the payments list; an unknown parameter is a 400 on both), `payment_date` reads `YYYY-MM-DD`, and the aging buckets come from `amount_open` (a paid or voided bill owes nothing anywhere) | +| C4-1b | ap: `POST /api/v1/ap/invoices/{id}/transitions` to voided, on a bill with no linked entry, or with an unbalanced one (review fix) | An approved bill whose entry was never linked (a legacy bill whose posting the base swallowed) voided silently: the status went VOIDED, `amount_open` to zero, and the credit stayed in `2010` with nothing reversing it; a bill linked to the base's one leg entry (the Accounts Payable credit with no debit) answered 500 `internal_error` | The unlinked void of a bill that owes is refused with a 409 `invalid_state_transition` and the `entry_not_linked` blocker, and the one leg one with the same 409 and the `entry_unbalanced` blocker: nothing is written (migration 106 links every approved or partial bill to its newest posted, unreversed, balanced `VENDOR_INVOICE` entry, counts the bills whose only entry has one leg in a notice of its own, and counts the bills left without one in another notice). A bill that prices to zero posted no entry and owes nothing: its void goes through with nothing to reverse. Stated limit: no route links an entry to a bill yet, so a refused bill stays approved and payable until a later repair item | +| C4-1b | ap: `GET /api/v1/ap/aging`, `GET /api/v1/ap/payments` (review fix) | Both reads crossed the branch wall: the aging buckets summed `amount_open` over every branch's bills, and the payments list showed payments applied to other branches' bills, while every other AP read and write held the wall | Both hold the caller's branch wall: the aging query carries the same predicate the invoice list uses, and the payments list keeps only payments with an application on a bill inside the wall for a walled caller (a payment with no application rows, the base's on account payment, is hidden from a walled caller and listed for a caller with no wall: an administrator without a header, an unbound key, a single branch install). A payment that spans branches shows at its whole amount to a caller who sees any one of its bills (the wall filters which payments, not their amounts) | +| C4-1b | ap: `POST /api/v1/ap/payments` with a repeated id in `invoice_ids` (review fix) | A payment naming one bill twice applied it twice off the stale locked snapshot, and only the `amount_open` CHECK stopped it, as a bare 500 `internal_error` | A repeated id is a 400 naming `invoice_ids[i]` (appears twice), refused before any money moves | +| C4-1b | ap: `POST /api/v1/ap/invoices/{id}/transitions` to approved, `POST /api/v1/ap/payments`, when a required chart of accounts code is missing (review fix) | A missing control account (2010) answered 500 `internal_error`; nothing was written, but the answer told the operator nothing | A 409 `conflict` with the `account_missing` blocker naming the missing code | +| C4-1b | migration 106 `ap_wire_contract`, review fixes (operational, no route) | Unknown statuses all became PENDING (a hand-written CANCELLED bill came back owed and payable); `amount_open` zeroed only VOIDED (a short-paid PAID bill migrated still owing); the duplicate suffix could collide with a genuine `X #2` and roll the migration back; `gl_entry_id` was never backfilled; a negative total bill (a vendor credit entered as a bill, which the base accepted at any sign) aborted the whole migration on the `amount_open` range CHECK, and so did a hand edited negative amount paid on a positive bill | Cancel-like values (CANCELLED, CANCELED) become VOIDED owing nothing, and so does a negative total bill, named by a notice telling the operator to re-enter it as a credit (the range CHECK says the rule exactly: a voided or paid bill owes zero at any total, any other owes between zero and its total); `amount_open` follows the insert trigger's rule (zero on a voided or paid bill, the total less paid clamped between zero and the total otherwise) with notices counting short-paid PAID rows, APPROVED or PARTIAL rows paid past their total, and rows carrying a negative amount paid; each suffixed duplicate takes the next suffix no row of that vendor holds; every approved or partial bill is linked to its newest posted, unreversed, balanced `VENDOR_INVOICE` entry, with a notice counting the bills whose only entry has one leg (the base's approve shape) and another counting the bills left without one; two further notices count the VOIDED bills (hand cancelled, or negative total) that still hold a posted, unreversed entry, whose credit stays in `2010` until an operator reverses it (ADR 0008 7.4 does not ask the migration to), and the bills posted more than once (only the newest entry is linked) | +| C4-1b | ap: a legacy pending bill with no lines, or with a line that has no account (operational note) | Such a bill could sit pending for ever: the old approve posted an entry with only the payable leg and never read the lines | The approve refuses it (409 `line_needs_account`, or `entry_unbalanced` when no lines are left) and no route edits lines, so voiding is its only way out; recorded here as the documented boundary until line editing arrives | +| C4-1b | migration 106 `ap_wire_contract` (operational, no route) | `vendor_invoices` had no revision, number, branch, currency or `amount_open`; `po_id` was a bare UUID column with no foreign key; `vendor_invoice_lines` had no `position`, purchase line, product or freight charge links and a scale 2 unit price; nothing was unique | The C4-1b migration of ADR 0008 section 12: `created_at` NOT NULL, `revision`, `number` (`AP-`, a sequence, backfilled in `(created_at, id)` order, the DEFAULT minting through the writer's transaction), `branch_id` (backfilled from the purchase order's branch when that column exists, else the default branch; a defaults trigger serves raw inserts), `currency` (from the `currency.default` setting), a status CHECK on today's values (lowercase spellings uppercased and unknown values become PENDING, each with a notice; cancel-like values and negative totals become VOIDED, see the review fix row above), `po_id` a real foreign key with orphans set null (notice), `UNIQUE (vendor_id, invoice_number)` after duplicates are suffixed `#2`, `#3` in `(created_at, id)` order (notice), `amount_open` (the total less paid, zero on a voided bill; the review fix row above gives the rule now in force), `gl_entry_id`; the lines gain `position` (backfilled in `(created_at, id)` order), `purchase_order_line_id`, `product_id`, a `unit_price` widened to scale 4 and `po_freight_charge_id` (written from package C's freight rule on; today's lines are not linked, the positional pairing being the defect); the keyset indexes. The down reverses each step, keeps the suffixed duplicates, normalized statuses and nulled orphans (each valid old shape data), and refuses while any unit price is finer than cents | +| C4-1b | ap module scope and wiring (ADR 0002) | The AP routes sat behind `RequireRole` only, with no branch middleware, no outbox and no audit log; `SyncVendorInvoice` and `SyncVendorPayment` lived in `internal/gl` | The routes sit behind `scoped("admin", "owner", "finance")` with the branch middleware and the branch guard on the create's payload branch; the service carries the outbox writer, the audit logger and the database as transaction runner, wired in serve as every converted module is; the two broken sync functions are deleted from `internal/gl` (their only caller was AP), and `gl` gains the `2010` account code constant beside the sales postings' | +| C4-1b | goldens that follow | `ap` created a bill in float dollars and read it back; `ap_payments` scripted the 401 approve, the float create and payments that paid a pending bill | `ap` and `ap_payments` re-recorded on the converted routes: the create in cents, ten thousandths and a decimal string quantity, the 428 transition without a precondition, the approve through `/transitions` and its 409 on a second attempt, the events feed step, the partial and settling payments, the paid bill's further payment refused `invoice_not_approved`, the void with payments refused `has_payments`, the validation 400, the status filters and the pending void | diff --git a/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts b/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts index d61654dd..733a7322 100644 --- a/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts +++ b/web/apps/desk/src/pages/accounting/AccountsPayable.test.ts @@ -4,18 +4,21 @@ /** * Accounts Payable UI. * - * This page straddles the ERP's money split, so the tests below pin both - * sides of it: + * The vendor invoice routes are on the wire contract (C4-1b, ADR 0008 7.4), + * so the tests below pin both sides of the money split: * - * read — every amount from /api/v1/ap/* is int64 CENTS - * (core/internal/ap/model.go), rendered with the shared - * `formatCents()`. - * write — POST /api/v1/ap/invoices and /api/v1/ap/payments take float64 - * DOLLARS (ap/model.go:100,109,116), which the service multiplies - * by 100 on the way in (ap/service.go:53,56,81,167). + * read: every invoice amount from /api/v1/ap/invoices is int64 CENTS and + * every line unit price int64 TEN THOUSANDTHS + * (core/internal/ap/model.go), rendered with `formatCents()` and + * `formatPrice4()`; the list arrives in the cursor envelope, which + * the service walks. + * write: POST /api/v1/ap/invoices takes cents, a decimal string quantity + * and ten thousandths; the approve goes through + * /ap/invoices/{id}/transitions with the revision; POST + * /api/v1/ap/payments takes int64 amount_cents. * - * Getting that backwards in either direction is a 100x error on a vendor - * payment, so the request bodies are asserted, not just the rendering. + * Getting the scales backwards in either direction is a 100x error on a + * vendor payment, so the request bodies are asserted, not just the rendering. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import './AccountsPayable' @@ -25,20 +28,31 @@ import type { Vendor } from '../../types/vendor' import { mountAsync, text, jsonResponse, clickByText, update, flush, q } from '../../test/dom' const VENDOR_ID = '11111111-1111-1111-1111-111111111111' +const BRANCH_ID = '22222222-2222-4222-8222-222222222222' function invoice(overrides: Partial = {}): VendorInvoice { return { id: 'inv-1', + number: 'AP-000001', vendor_id: VENDOR_ID, vendor_name: 'Cascade Lumber Co', - invoice_number: 'CL-4471', + branch_id: BRANCH_ID, + vendor_invoice_number: 'CL-4471', + currency: 'USD', invoice_date: '2026-08-01', due_date: '2026-08-31', - subtotal: 1_200_000, - tax_amount: 34_567, - total: 1_234_567, // $12,345.67 - amount_paid: 0, - status: 'APPROVED', + po_id: null, + subtotal_cents: 1_200_000, + tax_cents: 34_567, + total_cents: 1_234_567, // $12,345.67 + amount_paid_cents: 0, + amount_open_cents: 1_234_567, + status: 'approved', + approved_by: null, + approved_at: null, + notes: '', + revision: 1, + gl_entry_id: null, created_at: '2026-08-01T10:00:00Z', ...overrides, } @@ -92,7 +106,11 @@ interface Fixture { /** Serve the five endpoints the page loads on connect. Returns the fetch spy. */ function serve(f: Fixture = {}) { const spy = vi.fn((url: string, _init?: RequestInit) => { - if (url.includes('/ap/invoices')) return Promise.resolve(jsonResponse(f.invoices ?? [])) + // The invoice list is the envelope; one invoice's own route serves the + // document itself. + const byId = f.invoices?.find((inv) => url.endsWith(`/ap/invoices/${inv.id}`)) + if (byId) return Promise.resolve(jsonResponse(byId)) + if (url.includes('/ap/invoices')) return Promise.resolve(jsonResponse({ items: f.invoices ?? [], next_cursor: null, limit: 100 })) if (url.includes('/ap/payments')) return Promise.resolve(jsonResponse(f.payments ?? [])) if (url.includes('/ap/aging')) return Promise.resolve(jsonResponse(f.agingSummary ?? [])) if (url.includes('/vendors')) return Promise.resolve(jsonResponse([vendor])) @@ -134,7 +152,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('accounts payable — reading cents', () => { +describe('accounts payable: reading cents', () => { it('renders an invoice total in dollars, not cents', async () => { serve({ invoices: [invoice()] }) const el = await mountAsync('gable-accounts-payable') @@ -143,32 +161,39 @@ describe('accounts payable — reading cents', () => { expect(text(el)).not.toContain('$1,234,567.00') }) + it('shows the vendor own number and Gable number beside each other', async () => { + serve({ invoices: [invoice()] }) + const el = await mountAsync('gable-accounts-payable') + + expect(text(el)).toContain('CL-4471') + }) + it('groups thousands on a large outstanding balance', async () => { - serve({ invoices: [invoice({ total: 987_654_321_00, subtotal: 987_654_321_00, tax_amount: 0 })] }) + serve({ invoices: [invoice({ total_cents: 987_654_321_00, subtotal_cents: 987_654_321_00, tax_cents: 0, amount_open_cents: 987_654_321_00 })] }) const el = await mountAsync('gable-accounts-payable') expect(text(el)).toContain('$987,654,321.00') }) it('renders a zero amount paid as $0.00', async () => { - serve({ invoices: [invoice({ amount_paid: 0 })] }) + serve({ invoices: [invoice({ amount_paid_cents: 0 })] }) const el = await mountAsync('gable-accounts-payable') expect(text(el)).toContain('$0.00') }) - it('totals outstanding AP as invoice total less amount already paid', async () => { + it('totals outstanding AP from the open amounts', async () => { serve({ invoices: [ - invoice({ id: 'a', total: 1_000_000, amount_paid: 250_000, status: 'PARTIAL' }), - invoice({ id: 'b', total: 500_000, amount_paid: 0, status: 'APPROVED' }), - // PENDING is not yet an obligation; it must not count as outstanding. - invoice({ id: 'c', total: 900_000, amount_paid: 0, status: 'PENDING' }), + invoice({ id: 'a', total_cents: 1_000_000, amount_paid_cents: 250_000, amount_open_cents: 750_000, status: 'partial' }), + invoice({ id: 'b', total_cents: 500_000, amount_paid_cents: 0, amount_open_cents: 500_000, status: 'approved' }), + // pending is not yet an obligation; it must not count as outstanding. + invoice({ id: 'c', total_cents: 900_000, amount_paid_cents: 0, amount_open_cents: 900_000, status: 'pending' }), ], }) const el = await mountAsync('gable-accounts-payable') - // (1,000,000 - 250,000) + 500,000 = 1,250,000 cents. + // 750,000 + 500,000 = 1,250,000 cents. expect(text(el)).toContain('$12,500.00') // ...and the pending bill is reported separately, at its full value. expect(text(el)).toContain('$9,000.00') @@ -226,9 +251,24 @@ describe('accounts payable — reading cents', () => { expect(text(el)).toContain('$7,388.07') expect(text(el)).toContain('10432') }) + + it('renders a line unit price at four decimals in the bill details', async () => { + serve({ invoices: [invoice({ lines: [ + // 10 at $73.8813: the server's one rounding makes the line $7.39 + { id: 'l1', position: 0, description: '2x4 SPF', quantity: '10', unit_price_ten_thousandths: 738813, line_total_cents: 739, + gl_account_id: null, purchase_order_line_id: null, product_id: null, po_freight_charge_id: null, created_at: '2026-08-01T10:00:00Z' }, + ] })] }) + const el = await mountAsync('gable-accounts-payable') + + await (el as unknown as { _viewInvoiceDetails: (id: string) => Promise })._viewInvoiceDetails('inv-1') + await update(el, {}) + + expect(text(el)).toContain('$73.8813') + expect(text(el)).toContain('$7.39') + }) }) -describe('accounts payable — writing dollars', () => { +describe('accounts payable: writing the wire scales', () => { /** Open the bill drawer and fill one line plus tax. */ async function draftBill( el: LitElement, @@ -247,9 +287,10 @@ describe('accounts payable — writing dollars', () => { input.dispatchEvent(new Event('change', { bubbles: true })) } - // Two text inputs in this drawer: the invoice number, then the single - // line item's description. Both are `required` and the submit handler - // rejects a blank description, so the draft is not submittable without it. + // Two text inputs in this drawer: the vendor invoice number, then the + // single line item's description. Both are `required` and the submit + // handler rejects a blank description, so the draft is not submittable + // without it. const textInputs = Array.from(el.querySelectorAll('input[type="text"]')) setText(textInputs[0], 'CL-9001') setText(textInputs[1], opts.description ?? '2x4 SPF #2') @@ -275,41 +316,45 @@ describe('accounts payable — writing dollars', () => { it('keeps the preview to two decimal places when the arithmetic does not divide evenly', async () => { serve() const el = await mountAsync('gable-accounts-payable') - // 3 x $1.005. Before the preview was computed in cents it rendered - // "$3.015" — three decimal places on a money field, because - // `toLocaleString` defaults `maximumFractionDigits` to 3. + // 3 x $1.005 = $3.015, which rounds once, half away from zero, to $3.02: + // the preview and the server's exact decimal arithmetic agree, where a + // float preview (3 * 1.005 = 3.014999...) would show $3.01 and drift a + // cent from the bill that is filed. await draftBill(el, { qty: '3', unitPrice: '1.005', tax: '0' }) const body = text(el) expect(body).not.toMatch(/\$\d+\.\d{3}/) - - // $3.01, not the $3.02 exact decimal arithmetic would give: 1.005 is not - // representable in binary floating point and lands just below, so - // 3 * 1.005 * 100 = 301.49999999999994 and rounds down. The backend - // computes the stored figure the same way — `int64(UnitPrice*Quantity*100 - // + 0.5)` at ap/service.go:53 gives int64(301.99999999999994) = 301 — so - // the preview and the saved invoice agree. Pinned because they must keep - // agreeing: whichever side is "fixed" alone would introduce a drift - // between what the user is shown and what is filed. - expect(body).toContain('$3.01') + expect(body).toContain('$3.02') }) - it('sends unit price and tax as dollars, the units the endpoint documents', async () => { + it('sends cents, a decimal string quantity and ten thousandths, the units the endpoint documents', async () => { const spy = serve() const el = await mountAsync('gable-accounts-payable') await draftBill(el, { qty: '10', unitPrice: '73.88', tax: '12.34' }) await submitOpenForm(el) - const bill = postedBodies(spy).find((b) => 'invoice_number' in b) + const bill = postedBodies(spy).find((b) => 'vendor_invoice_number' in b) expect(bill).toBeDefined() - // Dollars, NOT 7388 / 1234 cents — ap/service.go multiplies by 100 itself, - // so sending cents here would store a bill 100x too large. - expect(bill!.tax_amount).toBe(12.34) - expect((bill!.lines as { unit_price: number }[])[0].unit_price).toBe(73.88) + // Cents and ten thousandths, NOT dollars: sending dollars here would + // store a bill 100x (and a price 10000x) too small. + expect(bill!.tax_cents).toBe(1234) + expect((bill!.lines as { quantity: string; unit_price_ten_thousandths: number }[])[0].quantity).toBe('10') + expect((bill!.lines as { quantity: string; unit_price_ten_thousandths: number }[])[0].unit_price_ten_thousandths).toBe(738800) + expect(bill!.vendor_invoice_number).toBe('CL-9001') }) - it('converts the selected invoices to dollars when prefilling a payment', async () => { - const spy = serve({ invoices: [invoice({ total: 1_234_567, amount_paid: 0 })] }) + it('refuses a price finer than four decimals instead of filing it truncated', async () => { + const spy = serve() + const el = await mountAsync('gable-accounts-payable') + await draftBill(el, { qty: '3', unitPrice: '1.00005', tax: '0' }) + await submitOpenForm(el) + + const bill = postedBodies(spy).find((b) => 'vendor_invoice_number' in b) + expect(bill, 'a price finer than four decimals must not be filed as a silently truncated ten thousandths value').toBeUndefined() + }) + + it('prefills a payment from the selected invoices and sends cents', async () => { + const spy = serve({ invoices: [invoice({ amount_open_cents: 1_234_567 })] }) const el = await mountAsync('gable-accounts-payable') await clickByText(el, 'button', 'Record Payment') @@ -323,16 +368,20 @@ describe('accounts payable — writing dollars', () => { checkbox!.click() await update(el, {}) + // The form still speaks dollars to the clerk: 1,234,567 cents = $12,345.67. + const amountInput = Array.from(el.querySelectorAll('input[type="number"]')).pop()! + expect(Number(amountInput.value)).toBe(12345.67) + await submitOpenForm(el) const pmt = postedBodies(spy).find((b) => 'invoice_ids' in b) expect(pmt).toBeDefined() - // 1,234,567 cents = $12,345.67 — dollars on the wire. - expect(pmt!.amount).toBe(12345.67) + // The payment request takes int64 cents on the wire. + expect(pmt!.amount_cents).toBe(1234567) }) }) -describe('accounts payable — endpoints', () => { +describe('accounts payable: endpoints', () => { it('calls exactly the AP endpoints the public backend serves', async () => { const spy = serve({ invoices: [invoice()] }) await mountAsync('gable-accounts-payable') @@ -343,15 +392,41 @@ describe('accounts payable — endpoints', () => { expect(urls.some((u) => u.includes('/api/v1/ap/aging'))).toBe(true) }) - it('approves an invoice through the approve endpoint', async () => { - const spy = serve({ invoices: [invoice({ status: 'PENDING' })] }) + it('walks the invoice list cursor to the last page', async () => { + const page1 = { items: [invoice({ id: 'inv-1' })], next_cursor: 'next', limit: 100 } + const page2 = { items: [invoice({ id: 'inv-2', number: 'AP-000002' })], next_cursor: null, limit: 100 } + let calls = 0 + const spy = vi.fn((url: string) => { + if (url.includes('/ap/invoices')) { + calls += 1 + return Promise.resolve(jsonResponse(calls === 1 ? page1 : page2)) + } + if (url.includes('/ap/payments')) return Promise.resolve(jsonResponse([])) + if (url.includes('/ap/aging')) return Promise.resolve(jsonResponse([])) + if (url.includes('/vendors')) return Promise.resolve(jsonResponse([vendor])) + if (url.includes('/gl/accounts')) return Promise.resolve(jsonResponse([])) + return Promise.resolve(jsonResponse([])) + }) + vi.stubGlobal('fetch', spy) + + const el = await mountAsync('gable-accounts-payable') + + const listUrl = spy.mock.calls.map((c) => String(c[0])).filter((u) => u.includes('/ap/invoices') && u.includes('cursor=next')) + expect(listUrl, 'the second page is fetched with the first cursor').toHaveLength(1) + expect(text(el)).toContain('CL-4471') // both pages' vendor numbers render + vi.unstubAllGlobals() + }) + + it('approves an invoice through the transitions route with its revision', async () => { + const spy = serve({ invoices: [invoice({ status: 'pending' })] }) const el = await mountAsync('gable-accounts-payable') await clickByText(el, 'button', 'Approve') - const approved = spy.mock.calls.find((c) => String(c[0]).includes('/approve')) - expect(approved, 'expected a POST to /api/v1/ap/invoices/{id}/approve').toBeDefined() - expect(String(approved![0])).toContain('/api/v1/ap/invoices/inv-1/approve') - expect(approved![1]?.method).toBe('POST') + const approve = spy.mock.calls.find((c) => String(c[0]).includes('/transitions')) + expect(approve, 'expected a POST to /api/v1/ap/invoices/{id}/transitions').toBeDefined() + expect(String(approve![0])).toContain('/api/v1/ap/invoices/inv-1/transitions') + expect(approve![1]?.method).toBe('POST') + expect(JSON.parse(approve![1]!.body as string)).toEqual({ to: 'approved', revision: 1 }) }) }) diff --git a/web/apps/desk/src/pages/accounting/AccountsPayable.ts b/web/apps/desk/src/pages/accounting/AccountsPayable.ts index cf27ee33..3a1b06b9 100644 --- a/web/apps/desk/src/pages/accounting/AccountsPayable.ts +++ b/web/apps/desk/src/pages/accounting/AccountsPayable.ts @@ -4,7 +4,7 @@ import { LitElement, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { icon } from '../../lib/icons.ts'; -import { formatCents } from '../../lib/utils'; +import { formatCents, formatPrice4 } from '../../lib/utils'; import { ToastService } from '../../lib/toast-service.ts'; import { APService } from '../../services/APService.ts'; import { VendorService } from '../../services/VendorService.ts'; @@ -16,6 +16,40 @@ import { CheckCircle2, PlusCircle, Trash2, Receipt, Info } from 'lucide'; +/** + * One line's extension in cents, exact: the decimal strings are read as + * scaled integers (quantity and price at scale 4 each), multiplied, and + * rounded once to cents, half away from zero, the way the server's + * httpx.Extend prices the line. Never parseFloat: 1.005 is not representable + * and a float preview can disagree with the exact bill that is filed. + */ +function exactLineCents(qty: string, price: string): number { + const product = scaledTenThousandths(qty) * scaledTenThousandths(price); // scale 8 + const neg = product < 0n; + const abs = neg ? -product : product; + const cents = (abs + 500_000n) / 1_000_000n; // one rounding, half away from zero + return Number(neg ? -cents : cents); +} + +/** The decimal string with trailing fraction zeros dropped, the wire's form. */ +function trimDecimal(raw: string): string { + const t = raw.trim(); + if (!t.includes('.')) return t; + return t.replace(/0+$/, '').replace(/\.$/, ''); +} + +/** A decimal string read as the scaled integer the wire carries (scale 4). */ +function scaledTenThousandths(raw: string): bigint { + const t = raw.trim(); + if (t === '') return 0n; + const neg = t.startsWith('-'); + const body = neg ? t.slice(1) : t; + const [int = '0', frac = ''] = body.split('.'); + const frac4 = (frac + '0000').slice(0, 4); + const v = BigInt(int || '0') * 10000n + BigInt(frac4 || '0'); + return neg ? -v : v; +} + @customElement('gable-accounts-payable') export class AccountsPayable extends LitElement { createRenderRoot() { return this; } @@ -89,35 +123,33 @@ export class AccountsPayable extends LitElement { } /** - * Draft-invoice total in cents, for the "Estimated Total" preview. + * Draft-bill total in cents, for the "Estimated Total" preview. * - * The bill form is denominated in DOLLARS because that is what - * POST /api/v1/ap/invoices takes (ap/model.go:100,109 — `TaxAmount` and - * `UnitPrice` are float64 dollars). The preview is computed here the same - * way the server computes the stored figure — rounding each line to a cent - * individually and adding tax separately (ap/service.go:53,56) — rather - * than summing floats and formatting at the end. Summing first can leave - * the preview a cent away from the invoice that is actually saved. + * The form is denominated in dollars for the clerk; the route takes a + * decimal string quantity and ten thousandths (C4-1b, ADR 0008 7.4) and + * the server prices each line exactly with one rounding to cents, half + * away from zero (httpx.Extend). The preview does the same arithmetic in + * integers, so it never lands a cent away from the bill that is saved. */ private get _estimatedTotalCents(): number { const lines = this.billLines.reduce( - (sum, l) => sum + Math.round(Number(l.quantity) * Number(l.unit_price) * 100), + (sum, l) => sum + exactLineCents(String(l.quantity), String(l.unit_price)), 0, ); - return lines + Math.round(Number(this.billTaxAmount) * 100); + return lines + exactLineCents('1', String(this.billTaxAmount)); } private _statusBadgeClass(status: string): string { switch (status) { - case 'PENDING': + case 'pending': return 'bg-amber-500/10 text-amber-400 border border-amber-500/20'; - case 'APPROVED': + case 'approved': return 'bg-blue-500/10 text-blue-400 border border-blue-500/20'; - case 'PARTIAL': + case 'partial': return 'bg-purple-500/10 text-purple-400 border border-purple-500/20'; - case 'PAID': + case 'paid': return 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'; - case 'VOIDED': + case 'voided': return 'bg-zinc-800 text-zinc-500 border border-zinc-700'; default: return 'bg-zinc-800 text-zinc-400'; @@ -168,21 +200,28 @@ export class AccountsPayable extends LitElement { ToastService.show('Quantity and unit price must be positive values', 'error'); return; } + // The wire carries each at scale 4; a finer value would be filed + // silently truncated, so the form refuses it instead. + if (Math.round(line.quantity * 10_000) / 10_000 !== line.quantity || + Math.round(line.unit_price * 10_000) / 10_000 !== line.unit_price) { + ToastService.show('Quantity and unit price carry at most four decimals', 'error'); + return; + } } try { const req = { vendor_id: this.billVendorId, - invoice_number: this.billInvoiceNumber.trim(), + vendor_invoice_number: this.billInvoiceNumber.trim(), invoice_date: this.billInvoiceDate, due_date: this.billDueDate, - tax_amount: Number(this.billTaxAmount), + tax_cents: exactLineCents('1', String(this.billTaxAmount)), notes: this.billNotes.trim(), lines: this.billLines.map(l => ({ description: l.description.trim(), - quantity: Number(l.quantity), - unit_price: Number(l.unit_price), - gl_account_id: l.gl_account_id || undefined + quantity: trimDecimal(String(l.quantity)), + unit_price_ten_thousandths: Number(scaledTenThousandths(String(l.unit_price))), + gl_account_id: l.gl_account_id })) }; @@ -213,9 +252,9 @@ export class AccountsPayable extends LitElement { this.vendorOutstandingInvoices = []; return; } - // Invoices that are APPROVED or PARTIAL are outstanding + // Invoices that are approved or partial are outstanding this.vendorOutstandingInvoices = this.invoices.filter( - inv => inv.vendor_id === this.payVendorId && (inv.status === 'APPROVED' || inv.status === 'PARTIAL') + inv => inv.vendor_id === this.payVendorId && (inv.status === 'approved' || inv.status === 'partial') ); } @@ -231,7 +270,7 @@ export class AccountsPayable extends LitElement { for (const invId of this.paySelectedInvoiceIds) { const inv = this.invoices.find(i => i.id === invId); if (inv) { - sum += (inv.total - inv.amount_paid); + sum += inv.amount_open_cents; } } this.payAmount = Number((sum / 100).toFixed(2)); @@ -239,7 +278,10 @@ export class AccountsPayable extends LitElement { private async _handlePayVendorSubmit(e: Event) { e.preventDefault(); - if (!this.payVendorId || this.payAmount <= 0) { + // Dollars in the form, cents on the wire: the one exact conversion of + // a two decimal input (never a float the server would have to round). + const amountCents = Math.round(this.payAmount * 100); + if (!this.payVendorId || amountCents <= 0) { ToastService.show('Please select a vendor and enter a positive payment amount', 'error'); return; } @@ -252,7 +294,7 @@ export class AccountsPayable extends LitElement { try { const req = { vendor_id: this.payVendorId, - amount: Number(this.payAmount), + amount_cents: amountCents, method: this.payMethod, check_number: this.payMethod === 'CHECK' ? this.payCheckNumber.trim() : undefined, reference: this.payReference.trim() || undefined, @@ -270,12 +312,12 @@ export class AccountsPayable extends LitElement { } } - private async _handleApproveInvoice(invoiceId: string) { + private async _handleApproveInvoice(invoice: VendorInvoice) { try { - await APService.approveVendorInvoice(invoiceId); + await APService.transitionVendorInvoice(invoice.id, { to: 'approved', revision: invoice.revision }); ToastService.show('Vendor invoice approved and posted to General Ledger', 'success'); - if (this.selectedInvoice && this.selectedInvoice.id === invoiceId) { - this.selectedInvoice = await APService.getVendorInvoice(invoiceId); + if (this.selectedInvoice && this.selectedInvoice.id === invoice.id) { + this.selectedInvoice = await APService.getVendorInvoice(invoice.id); } await this._loadAllData(); } catch (err) { @@ -307,14 +349,14 @@ export class AccountsPayable extends LitElement { // Summary Statistics const totalOutstanding = this.invoices - .filter(i => i.status === 'APPROVED' || i.status === 'PARTIAL') - .reduce((sum, i) => sum + (i.total - i.amount_paid), 0); + .filter(i => i.status === 'approved' || i.status === 'partial') + .reduce((sum, i) => sum + i.amount_open_cents, 0); const pendingApproval = this.invoices - .filter(i => i.status === 'PENDING') - .reduce((sum, i) => sum + i.total, 0); + .filter(i => i.status === 'pending') + .reduce((sum, i) => sum + i.total_cents, 0); - const pendingApprovalCount = this.invoices.filter(i => i.status === 'PENDING').length; + const pendingApprovalCount = this.invoices.filter(i => i.status === 'pending').length; // Paid Month-To-Date const currentMonth = new Date().getMonth(); @@ -477,23 +519,23 @@ export class AccountsPayable extends LitElement { ${inv.vendor_name || 'Seeded Vendor'} ${new Date(inv.invoice_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} ${new Date(inv.due_date).toLocaleDateString('en-US', { timeZone: 'UTC' })} - ${formatCents(inv.total)} - ${formatCents(inv.amount_paid)} + ${formatCents(inv.total_cents)} + ${formatCents(inv.amount_paid_cents)} ${inv.status} - ${inv.status === 'PENDING' ? html` + ${inv.status === 'pending' ? html`