From cbc1a90595802cb309c5f09db82c30a759328abc Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:16:43 -0700 Subject: [PATCH 1/2] docs: accounts payable module page Adds docs/modules/ap.md for the AP module: the vendor bill (ApInvoice), the approve and void transitions, the vendor payment (ApPayment), the aging by vendor and the GL postings (2010 Accounts Payable, 1010 Cash). Lists the seven AP routes under /api/v1/ap, the bill and line fields, the lifecycle with its blockers (has_payments, entry_not_linked, entry_unbalanced, period_closed, account_missing, line_needs_account, invoice_not_approved, exceeds_open), the events the module writes (vendor_invoice.created, .approved, .partial, .paid, .voided), the admin/owner/finance role guard behind the branch wall and the stated limits from C4-1b (unlinked legacy bills stay approved and payable; across-branch payments show whole). Adds the page to docs/modules/README.md's module table and the ap segment to the convention block, and removes AP from the not-in-brief sentence. Backed by docs/gable-v1/mm/docs-ap-claims.md: every sentence on the page traces to a row in that table (routes, bill fields, payment fields, statuses, blockers, role and scope checks, events, postings, branch wall behaviour, money ceilings and the ADR rule sentences). Migration 106 brought the AP tables onto the wire contract; C4-1b (PR 78) owns the conversion. ADR 0008 sections 7.4 and 12-13 hold the design; ADR 0001 sections 7, 7a, 9 and 11 hold the wire contract; ADR 0007 section 5.5 holds the branch bound key rule the payload branch_id applies. Signed-off-by: colton --- docs/modules/README.md | 5 +- docs/modules/ap.md | 556 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 docs/modules/ap.md diff --git a/docs/modules/README.md b/docs/modules/README.md index 88e4d141..edc6fc2e 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -52,6 +52,7 @@ The pages: | Payments, deposits and AR | [payments.md](payments.md) | The payment, the application, the AR subledger, the aging, the statement, the reconciliation, the credit memo applications and refunds. | C2-4. | | Units | [units.md](units.md) | The unit catalogue, the conversion pair, the product unit set, the inexact refusal, the quote tally, the migration 099 report. | C3-2A-units. | | Charge codes | [charge-codes.md](charge-codes.md) | The fee master, the revenue account snapshot on a line, the seeded `FREIGHT`, `FUEL`, `RESTOCK`, `ADJUST`. | C2-2a. | +| Accounts payable | [ap.md](ap.md) | The vendor bill, the approve and void transitions, the vendor payment, the AP aging by vendor, the GL postings (`2010` Accounts Payable, `1010` Cash) and the stated limits. | C4-1b. | | Locations and branches | [locations.md](locations.md) | The location and branch master, the branch tree, the user branch grants, and the branch wall on the by id reads and the location list. | C3-1. | | Drafts, links and confirm gated scopes | [drafts.md](drafts.md) | The proposed document, the SSE change feed, the promotion, the link resolver, the machine key scope grammar (read, write, propose, commit), the branch bound key, the confirm gate, and the audit rows. | C5-2a. | | Events feed | [events.md](events.md) | The `events_outbox` cursor read API (item R1-12), the `events.position` ordering guarantee, the `next_cursor` exception, the type filter, the event vocabulary by module on `refactor/v1`. | R1-12, R1-12b. | @@ -79,7 +80,7 @@ The pages: pages here: `invoices`, `quotes`, `orders`, `customers`, `products`, `inventory`, `activities`, `ship-tos`, `contacts`, `payment-terms`, `price_levels`, `credit-memos`, `configurator`, - `millwork`, `payments`, `governance`, `apps`). The admin module + `millwork`, `payments`, `ap`, `governance`, `apps`). The admin module declares finer area scopes in `core/pkg/middleware/machinekey.go` (ADR 0009 section 1): `admin:settings` on `/api/v1/admin/settings/...`, `admin:staff` on @@ -126,6 +127,6 @@ the page describes the wire rather than restating the SQL. dashboard, deliveries, invites, invoices, orders, quotes, users and the rest) sit under the `internal/portal` package in the route census. -- The accounts, GL, AP, bank reconciliation, matching, purchase +- The accounts, GL, bank reconciliation, matching, purchase order, deposit, document print, EDI, governance, accounting and AI surfaces, which are not in the brief's list. diff --git a/docs/modules/ap.md b/docs/modules/ap.md new file mode 100644 index 00000000..f504b110 --- /dev/null +++ b/docs/modules/ap.md @@ -0,0 +1,556 @@ + + +# Accounts payable + +The accounts payable module records vendor bills (the money a yard owes +its vendors) and the payments the yard makes against them. A bill is the +header the desk enters (the vendor, the vendor's own number, the date, +the due date, the tax) and the priced lines (the goods or services, the +quantity, the unit price, the GL account the line's money lands on). A +payment names the bills it applies to (in the order the desk pays them) +and locks that order on the database so two callers can never apply the +same payment twice. Both acts post one journal entry inside one +transaction: a payment that posts `DR 2010 / CR 1010`, and an approve +that builds the entry from the lines and credits the whole total to +`2010`. + +This module is the work of C4-1b (item C4-1b: the AP fixes of ADR 0008 +section 7.4 on the wire contract of ADR 0001, plus migration 106). The +Go code is in `core/internal/ap/`. The table migrations are in +`core/migrations/106_ap_wire_contract.sql` (the `ap_wire_contract` item +the ADR planned as 098 in the C4-1b section, renumbered to 106 at merge). + +## What it does in a yard + +A yard buys lumber, fasteners and concrete from vendors on terms. The +bill lands on the desk from the carrier or by email. The clerk enters +the bill through `POST /ap/invoices`: the line money and the tax are +integers in cents and the unit price is an integer at scale four (the +desk still types dollars, the form does the one exact conversion). The +bill starts pending, owing its whole total, with Gable's own number +(prefix `AP-`); the vendor's own number is `vendor_invoice_number` +(1 to 64 characters, unique per vendor). + +When the bill is right, the desk approves it through +`POST /ap/invoices/{id}/transitions` with `to: "approved"`. The +approve loads the lines, builds the journal entry in the act's +transaction (a debit leg per line on the line's account, the tax spread +across the line accounts pro rata 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 the `period_closed` +blocker; a chart of accounts code that is missing is the +`account_missing` blocker; a line with no account is the +`line_needs_account` blocker; an entry that does not balance is the +`entry_unbalanced` blocker. + +When the bill is wrong, the desk voids it through the same route with +`to: "voided"` and a reason. A pending bill voids with nothing posted; +an approved bill's entry is reversed whole through `gl.PostReversal`; +the bill's `amount_open` drops to zero; a bill that prices to zero +posted nothing and voids with nothing to reverse. A bill with money +applied is refused with `has_payments` (the payments have to be undone +first). An approved or partial bill with a nonzero total and no linked +entry is refused with `entry_not_linked`, and one whose linked entry +does not balance with `entry_unbalanced`: both stay approved and +payable until a later repair item (no route today links an entry to a +bill). Both refusals write nothing. + +Payments land as the desk cuts the checks. `POST /ap/payments` takes +the vendor, the amount in cents, the method (`CHECK`, `ACH` or +`WIRE`), the date and the bills in the order the desk pays them. The +act locks the named bills in id order (so two callers can never race +on the same bill) and applies only to bills in `approved` or `partial` +(a `pending`, `paid` or `voided` bill is the `invoice_not_approved` +blocker). A bill of another vendor is a 400 naming `invoice_ids[i]`, a +repeated id a 400 naming its index. More than the named bills still +owe is the `exceeds_open` blocker and writes nothing. The applied sum +posts `DR 2010 / CR 1010` inside the act's transaction. Each settled +bill writes `vendor_invoice.partial` (when more is still owed) or +`vendor_invoice.paid` (when this payment closes it). + +The aging (`GET /ap/aging`) is the desk's open balances by vendor, in +int64 cents buckets (`current`, `past_30`, `past_60`, `past_90`, +`total`). A bill that is paid or voided owes nothing anywhere. The +payments list (`GET /ap/payments`) carries the wall through the bills +its payments apply to. 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). + +## Routes + +Every route below is in `core/api/fragments/ap.yaml`; the registered +handles are in `core/internal/ap/handler.go`; the route census +(`core/api/ROUTES.txt`) lists each one under the `internal/ap` +package. + +| Method | Path | One line | +|---|---|---| +| GET | `/api/v1/ap/invoices` | Cursor list of vendor invoices, newest first by `(created_at, id)`; `vendor_id`, `po_id`, `status` filter; `total` under `include=total`; revision as ETag. | +| POST | `/api/v1/ap/invoices` | Enter a vendor invoice (the bill starts pending, owing its whole total); `Idempotency-Key` rides the standard header; `X-Branch-Id` header; money in `*_cents` and the line unit price in `*_ten_thousandths`. | +| GET | `/api/v1/ap/invoices/{id}` | One bill with its lines and revision as ETag; held to the caller's branch wall (another branch's bill is a 404). | +| POST | `/api/v1/ap/invoices/{id}/transitions` | Approve (`to: "approved"`, only from pending) or void (`to: "voided"`, reason required) a bill; on the revision (`If-Match` or body `revision`); `Idempotency-Key` rides the standard header. | +| GET | `/api/v1/ap/payments` | List vendor payments, filtered by `vendor_id`; held to the caller's branch wall through the bills each payment applies to. | +| POST | `/api/v1/ap/payments` | Record a vendor payment; `amount_cents` int64; `method` enum `CHECK`, `ACH` or `WIRE`; `invoice_ids` in the order the desk pays them; `Idempotency-Key`. | +| GET | `/api/v1/ap/aging` | Aging buckets in int64 cents by vendor (`current`, `past_30`, `past_60`, `past_90`, `total`); held to the caller's branch wall. | + +Every write above sits behind `scoped("admin", "owner", "finance")` +(`core/internal/app/serve/serve.go:679`), the branch wall +(`BranchGuard.CheckPayloadBranch` at `core/internal/ap/service.go:156-167`) +and the user's branch context. A bound machine key without the right +scope is `403 forbidden` (ADR 0002); the audit row carries the refused +scope. + +## The main resources + +### The bill + +`ApVendorInvoice` (see `core/api/fragments/ap.yaml` +`components.schemas.ApVendorInvoice`, +`core/internal/ap/model.go` `Invoice`). + +| Field | Wire form | Note | +|---|---|---| +| `id` | UUID | The bill id. | +| `number` | text | Gable's own number, prefix `AP-`, gapped sequence. | +| `vendor_id` | UUID | The vendor this bill is from. | +| `vendor_name` | text | Snapshot of the vendor name. | +| `branch_id` | UUID | The branch the bill belongs to. | +| `vendor_invoice_number` | text | The vendor's own number, 1 to 64 characters, unique per vendor. | +| `currency` | ISO 4217 | The bill's currency; defaults from the `currency.default` setting. | +| `invoice_date` | date | YYYY-MM-DD. | +| `due_date` | date | YYYY-MM-DD. | +| `po_id` | UUID, nullable | The purchase order this bill was matched to (FK since migration 106). | +| `subtotal_cents` | integer | Sum of `line_total_cents`. | +| `tax_cents` | integer | The bill's tax. | +| `total_cents` | integer | `subtotal_cents + tax_cents`. | +| `amount_paid_cents` | integer | The total the vendor has been paid against this bill. | +| `amount_open_cents` | integer | The total the bill still owes, read only. | +| `status` | lowercase enum | `pending`, `approved`, `partial`, `paid`, `voided`. | +| `approved_by` | UUID, nullable | The approver. | +| `approved_at` | timestamp, nullable | When it was approved. | +| `notes` | text | At most 2000 characters. | +| `revision` | integer | Starts at 1; returned as ETag. | +| `gl_entry_id` | UUID, nullable | The entry the approve posted (set on approve, cleared on the void's reversal). | +| `created_at` | timestamp | RFC 3339 UTC. | +| `lines` | array of `ApVendorInvoiceLine` | The priced lines, in position order. | + +`ApVendorInvoiceSummary` (`fragments/ap.yaml`) is the same head, the +list item and the shape the full document embeds, without `lines`. + +### The line + +`ApVendorInvoiceLine` (`fragments/ap.yaml`, +`core/internal/ap/model.go` `InvoiceLine`). + +| Field | Wire form | Note | +|---|---|---| +| `id` | UUID | The line id. | +| `position` | integer | The line's place on the bill. | +| `description` | text | 1 to 256 characters. | +| `quantity` | decimal string | A plain decimal with at most 4 fraction digits, more than zero. | +| `unit_price_ten_thousandths` | integer | Never negative; the value `httpx.Price` on the Go side. | +| `line_total_cents` | integer | The extension priced by the server 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). | +| `gl_account_id` | UUID | The GL account the approve entry debits for this line; required on create. | +| `purchase_order_line_id` | UUID, nullable | The purchase order line this was matched to (since migration 106; today's lines are not linked). | +| `product_id` | UUID, nullable | The product this line refers to. | +| `po_freight_charge_id` | UUID, nullable | The carrier freight line this line links to (written from package C's freight rule on, ADR 0008 7.3). | +| `created_at` | timestamp | RFC 3339 UTC. | + +### The payment + +`ApPayment` (`fragments/ap.yaml`, `core/internal/ap/model.go` +`APPayment`). The payment routes keep their today shape until their own +conversion (ADR 0008 section 11); the field set below is that shape. + +| Field | Wire form | Note | +|---|---|---| +| `id` | UUID | The payment id. | +| `vendor_id` | UUID | The vendor the payment is to. | +| `vendor_name` | text | Snapshot of the vendor name, filled by the list join. | +| `batch_id` | UUID, nullable | The batch this payment is in (today's shape). | +| `amount` | integer | Cents (the response); the request uses `amount_cents`. | +| `method` | enum | `CHECK`, `ACH`, `WIRE` (UPPERCASE storage, UPPERCASE on the wire today). | +| `check_number` | text, nullable | For a check. | +| `reference` | text, nullable | Free text. | +| `payment_date` | date | YYYY-MM-DD. | +| `status` | enum | `PENDING`, `COMPLETE`, `VOIDED` (UPPERCASE storage, today's wire). | +| `created_at` | timestamp | RFC 3339 UTC. | + +`ApPaymentApplication` (the join row, `model.go` `APPaymentApplication`) +links a payment to the bill it applies to with `id`, `payment_id`, +`invoice_id`, `amount` (cents) and `created_at`. The same row is the AP +side of the application. + +`ApAgingSummary` (the aging row, `fragments/ap.yaml`) carries +`vendor_id`, `vendor_name`, `current`, `past_30`, `past_60`, `past_90`, +`total`, every bucket int64 cents. + +### Money and quantity conventions + +Money is integer cents under `_cents`. The unit price is integer at +scale four under `_ten_thousandths`. The line quantity is a decimal +string with at most four fraction digits. The extension is the +quantity multiplied by the unit price and by the pair 1 and 1 (AP +lines carry no conversion pair until the purchase line link carries +units), rounded once to cents half away from zero through +`httpx.Extend`. The create body takes `tax_cents` as int64 and the line +`unit_price_ten_thousandths` as int64; a non-integer `amount_cents` on +a payment is a 400 naming `amount_cents` (never silent rounding). + +### Create body fields + +`ApCreateVendorInvoiceRequest` (`fragments/ap.yaml`): `vendor_id` +required, optional `branch_id` (defaults from the caller's context +branch or the `default_branch_id` setting; a branch the caller may not +target is a 403 naming `branch_id`), `vendor_invoice_number` (1 to 64 +characters, unique per vendor; a duplicate is a 409 naming the field), +`invoice_date`, `due_date` (both YYYY-MM-DD), optional `po_id` +(an unknown PO is a 400 naming `po_id`), optional `currency` +(defaults from the `currency.default` setting; a request's currency +must be an ISO 4217 code of three capital letters), `tax_cents`, `notes` +(at most 2000 characters) and `lines` (at least one line, the same +set of fields as the line schema minus `id`, `position` and +`created_at`, with the addition of the optional `purchase_order_line_id` +and `product_id`). The server prices each line and never accepts a +status: a created bill starts `pending`. + +`ApCreatePaymentRequest` (`fragments/ap.yaml`): `vendor_id` required, +`amount_cents` int64 (> 0 and `<= 999999999999`), `method` enum +(`CHECK`, `ACH`, `WIRE`), `payment_date` (YYYY-MM-DD), optional +`check_number`, `reference` and `invoice_ids` (the bills in the order +the desk pays them; repeated ids are 400 naming the index). + +## Lifecycle and transitions + +The wire vocabulary is lowercase; the storage column is UPPERCASE, the +mapping at `model.go` `storageStatus` and `wireStatus`. + +The bill's `transitions` map (model.go lines 53 to 62) is the edges the +module allows: + +| From | To | Reason | Event | +|---|---|---|---| +| `pending` | `approved` | Loader has reviewed, the GL entry can post. | `vendor_invoice.approved` | +| `pending` | `voided` | The bill was wrong; nothing posted yet. | `vendor_invoice.voided` | +| `approved` | `voided` | The bill is wrong; the entry is reversed whole. | `vendor_invoice.voided` | +| `partial` | none | Partial carries payments; the bill cannot be voided while money has left. | | +| `paid` | none | Closed. | | +| `voided` | none | Terminal. | | + +A payment sets a bill's `status` to `partial` (when more is still owed) +or `paid` (when this payment closes it) at the moment of the apply. +Transitions `to: "pending"`, `to: "partial"` and `to: "paid"` from +`POST /ap/invoices/{id}/transitions` are refused with +`invalid_state_transition`; a payment is the only writer for those two. + +### Approve + +`POST /ap/invoices/{id}/transitions` with `to: "approved"` and the +revision (in the body or in `If-Match`). The act runs in one +transaction: + +1. Lock the bill (`repo.Lock`); check the revision under the lock. +2. Refuse `409 invoice_not_pending` if the bill is not `pending`. +3. Load the lines with their account codes (`repo.LinesWithCodes`). +4. Refuse `409 line_needs_account` if any line's code is empty, naming + the line's position. +5. Build the entry (`postInvoiceEntry`): 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 + (`tax * line_total_cents / subtotal_cents`, the remainder accumulated + into the last line), the credit to `gl.AccountCodeAP = "2010"` for + the whole total. +6. Post the entry through `gl.PostEntry` in the same transaction. The + posting can raise `period_closed` (closed fiscal period), + `account_missing` (chart of accounts code absent) or + `entry_unbalanced` (debits and credits do not balance); each is + mapped to a 409 by the module and fails the act (ADR 0008 7.4). +7. Mark the bill approved with the linked `gl_entry_id` and the + approver (the caller's UUID when the actor is a parseable UUID, else + nil for an in-process caller; `s.ApproveInvoice` passes a fixed + approver). +8. Write the audit row, then the event + (`vendor_invoice.approved`) in the same transaction (ADR 0003 + section 2). + +The `Idempotency-Key` rides the standard header (ADR 0001 section 9). +The transaction commits or rolls back whole: a posting refusal writes +nothing. + +### Void + +`POST /ap/invoices/{id}/transitions` with `to: "voided"`, the revision, +and a `reason` (required, at most 500 characters). The act runs in one +transaction: + +1. Refuse `400 reason required` if `reason` is empty. +2. Lock the bill; check the revision under the lock. +3. Refuse `409 has_payments` if `amount_paid_cents > 0`, naming the + cents applied; the payments have to be undone first. +4. Refuse `409 invalid_state_transition` if the bill's `from` does not + allow `voided` (the `partial` and `paid` rows in the map). +5. Refuse `409 entry_not_linked` if the bill is `approved` or `partial`, + has a nonzero `total_cents` and no `gl_entry_id`: a credit may stay + in `2010` with nothing to reverse, so the bill stays approved and + payable (a stated limit). +6. If the bill has a `gl_entry_id`, post the reversal through + `gl.PostReversal` with the void time as `EntryDate` and the reason. + `PostReversal` can raise `period_closed` (mapped to 409) or + `entry_unbalanced` (the removed sync's one leg shape, mapped to 409 + `entry_unbalanced`); the latter also stays approved and payable (a + stated limit). +7. Mark the bill voided; write the audit row, then the event + (`vendor_invoice.voided`) in the same transaction. + +A bill that prices to zero posted nothing and owes nothing: there is +no `gl_entry_id`, the reversal block is skipped, and the void goes +through with nothing to reverse. + +### Pay + +`POST /ap/payments`. The act runs in one transaction: + +1. Create the payment row with `status: "COMPLETE"` (`repo.CreatePayment`). +2. Lock the named bills in `id` order (`LockForPayment` with the + payment's vendor); id order makes every contender take the locks in + one order. A bill of another vendor returns no row and the act + refuses `400 invoice_ids[i]` naming the index. A repeated id in + `invoice_ids` is `400 invoice_ids[i] (appears twice)`. +3. Refuse `409 invoice_not_approved` if any locked bill is `pending`, + `paid` or `voided`, naming the bill's `number` and `status`. +4. Apply the money in `in.InvoiceIDs` order (the desk's order). Each + application writes an `APPaymentApplication` row and updates the + bill's `amount_paid_cents`. A bill that closes on this payment + triggers `EventPaid`; one that still owes triggers `EventPartial`. +5. Refuse `409 exceeds_open` if the applied sum was less than the + request's amount, naming the cents asked and the cents open. +6. When at least one bill was settled, post the entry + `DR 2010 / CR 1010` for the applied sum through `gl.PostEntry`, + `Source: VENDOR_PAYMENT`, `SourceRefID: &payment.ID`. The posting + can raise `period_closed` (mapped to 409) or `account_missing` + (mapped to 409); each fails the act and writes nothing. +7. Write the audit row, then the events + (`vendor_invoice.partial` or `vendor_invoice.paid` for each settled + bill) in the same transaction. + +The `Idempotency-Key` rides the standard header. + +The payment's `VendorID` is the one the body names; the bills in +`invoice_ids` must match (a different vendor's bill is a 400). The +`method` enum is `CHECK`, `ACH` or `WIRE` (UPPERCASE); the date is +YYYY-MM-DD; the money is int64 cents in `amount_cents`. The payment +row's `amount` field on the response stays cents, the response's +`status` stays the UPPERCASE storage enum, and the rest of the request +shape (uppercase methods, `payment_date`) keeps its today form until +the payment routes' own conversion. + +## Postings + +The approve builds the entry from the lines. Every line contributes +one debit leg on its own account (`gl.Leg` with `AccountCode` set to +the line's account, `Description` set to the line's description, and +`Debit` set to the line's extension); for the tax, each line's debit +is increased by its pro rata share +(`tax_cents * line.LineTotalCents / subtotal`), and the last line's +debit absorbs any remainder. The credit leg is one +`gl.Leg` with `AccountCode: gl.AccountCodeAP = "2010"`, +`Description: "Accounts Payable"` and `Credit: TotalCents`. The entry +posts with `Source: VENDOR_INVOICE`, `SourceRefID: &bill.ID`, the bill's +currency and the bill's `invoice_date` as `EntryDate`, the +`Memo: "Vendor invoice " + bill.Number`. The returned `entry.ID` is +stored on the bill as `gl_entry_id`. + +The pay entry is one entry: `DR 2010 / CR 1010` for the applied sum +(`AmountCents - remaining`), `Source: VENDOR_PAYMENT`, +`SourceRefID: &payment.ID`, `Memo: "Vendor payment " + payment.ID`. It +posts only when `len(locked) > 0`. + +The void posts a reversal of the bill's `gl_entry_id` through +`gl.PostReversal`, with `EntryDate: time.Now()` (the void time) and the +call's `reason`. The bill's `gl_entry_id` stays set (the link is kept +for the audit trail). + +`AccountCodeAP = "2010"` (Accounts Payable) is declared at +`core/internal/gl/postentry.go:22`. `AccountCodeCash = "1010"` (Cash) +is declared at `core/internal/gl/service.go:35`. + +## Events the module writes + +Every mutation writes the event as the last statement of its +transaction ([ADR 0003](../adr/0003-events-outbox.md) section 2). The +event constants and the strings they hold: + +| Event | Constant (source) | Written at | +|---|---|---| +| `vendor_invoice.created` | `EventCreated` (`core/internal/ap/service.go:50`) | `core/internal/ap/service.go:302` (`s.event(EventCreated, "", &inv.Summary)` in the create transaction) | +| `vendor_invoice.approved` | `EventApproved` (`service.go:51`) | `service.go:409` (`s.event(EventApproved, StatusPending, after)` in the approve transaction) | +| `vendor_invoice.partial` | `EventPartial` (`service.go:52`) | `service.go:670` (`s.event(typ, from, after)`, `typ` is `EventPartial` when `paid` is false) | +| `vendor_invoice.paid` | `EventPaid` (`service.go:53`) | `service.go:670` (`typ` is `EventPaid` when the apply closed the bill) | +| `vendor_invoice.voided` | `EventVoided` (`service.go:54`) | `service.go:545` (`s.event(EventVoided, from, after)` in the void transaction) | + +The events carry a small summary (the body's `data`), never the +document: `number`, `vendor_invoice_number`, `vendor_id`, `branch_id`, +`status`, `from_status`, `revision`, `total_cents`, `amount_open_cents`, +`currency` (the body of `event()` at `service.go:189-198`). They ride +the transactional outbox with `EntityType: "vendor_invoice"`, the bill id +and the bill's `branch_id`. + +A `POST /ap/payments` writes one event per settled bill: a bill that +still owes after the payment writes `vendor_invoice.partial`; one that +the payment closed writes `vendor_invoice.paid`. The payment row itself +does not have its own event today. + +## Scopes, roles and keys + +A machine key reaching the AP routes needs `ap:read` for `GET` and +`HEAD`, and `ap:write` for every other method (ADR 0002; the segment +is the first path segment under `/api/v1/`, here `ap`; the segment +table is in `pkg/middleware/machinekey.go`). The user guard at the serve +layer is `admin`, `owner`, `finance` (registered at +`core/internal/app/serve/serve.go:679`: +`ap.NewHandler(apSvc).RegisterRoutes(mux, scoped("admin", "owner", "finance"))`). +No other module's guard reaches an AP route. A key without the right +scope is `403 forbidden`; the audit row carries the refused scope (ADR +0002 section 5). + +A branch bound machine key (ADR 0007 section 5.5) is pinned to its one +branch: a `GET` and a `POST` with `X-Branch-Id` set to another branch +are `403 forbidden`; a payload `branch_id` outside the key's bound is +`403 forbidden` naming `branch_id`. The payload rule +(`BranchGuard.CheckPayloadBranch`, `service.go:156-167`) is what +`POST /ap/invoices` and `POST /ap/payments` apply. The branch resolves +in three steps (`service.go:263-278`): the payload `branch_id`, the +caller's context branch, or the `default_branch_id` setting; the +`currency.default` setting is the same shape for the currency. + +## Stated limits + +These are limits the module lives with until a later repair item. + +- An `approved` or `partial` bill with a nonzero `total_cents` and no + linked `gl_entry_id` is refused a `to: "voided"` with `409 + entry_not_linked`: the credit may stay in `2010` with nothing to + reverse, so the bill stays approved and payable. This is the legacy + unlinked bill (its posting was swallowed, or it holds the removed + sync's one leg credit). +- A bill linked to an entry that does not balance (the removed sync's + one leg shape, debit absent) is refused the same void with `409 + entry_unbalanced`. Its reversal would mirror the imbalance into + another entry, so the bill stays approved and payable. +- No route links an entry to a bill; the link `gl_entry_id` is + populated only by the module's own approve today. +- 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 are + visible, not the amounts of the ones shown. + +## Money ceilings (the create-time limits) + +Each is a `400 validation_failed` naming the field that tipped it over. +Nothing is written. + +- `tax_cents` past `999999999999`. +- `lines[i].unit_price_ten_thousandths` past `999999999999`. +- `lines[i].quantity` when its line extension passes `999999999999` + cents. +- `lines` when their subtotal passes `999999999999` cents. +- `tax_cents` when the lines fit but the lines and the tax together + pass `999999999999` cents. +- `amount_cents` on a payment past `999999999999`, and one that is not + more than zero. + +The constants live at `core/internal/ap/input.go:261-265`: +`maxBillCents = 999_999_999_999` and +`maxUnitPriceTenThousandths = 999_999_999_999` (and `maxPaymentCents = +999_999_999_999` at line 257). The columns are NUMERIC(12,2) for money +and NUMERIC(12,4) for unit price, so the ceiling is the column's. + +## ADRs that govern this module + +- [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md): + the list envelope, cursors, the error envelope (4), the lowercase + status vocabulary (6), money in `_cents` (7), unit prices in + `_ten_thousandths` (7a), the `Idempotency-Key` header (9), the + revision concurrency rule and the 428 / 409 `stale_revision` + preconditions (11), the field name casing (12). +- [`docs/adr/0002-machine-keys.md`](../adr/0002-machine-keys.md): the + segment rule for `ap:read` and `ap:write` (section 2). +- [`docs/adr/0003-events-outbox.md`](../adr/0003-events-outbox.md): + the events table, the ordering guarantee and the writer that runs as + the last statement of the mutation's transaction (sections 1, 2, 3, + 5). +- [`docs/adr/0007-drafts-links-and-confirm-gated-scopes.md`](../adr/0007-drafts-links-and-confirm-gated-scopes.md): + section 5.5, the branch bound key and the payload `branch_id` + refusal (the rule the create's `branch_id` applies). +- [`docs/adr/0008-inventory-identity-and-vendor-intake.md`](../adr/0008-inventory-identity-and-vendor-intake.md): + section 7.4 (the AP fixes C4-1b makes: approve loads the lines, the + entry posts through `gl.PostEntry` in the approve transaction and a + failure fails the act, `VOIDED` is reachable by a transition that + reverses the entry, a payment applies only to `approved` or + `partial` invoices locked in id order); section 12 (the + `ap_wire_contract` migration, numbered 098 in the ADR and 106 at + merge); section 13.1 (the C4-1b item, its builds and its tests). + +## How to try it locally + +The repository's own seed and the local make targets are the only way +to exercise the module end to end. `make up` builds and starts the +local stack (Postgres, migrate and seed, `core serve`, `core worker`, +the web front door) on http://127.0.0.1:8080 with `AUTH_MODE=dev`; +`make down` removes it. To run the core from source instead: +`make db`, `make migrate`, `DEMO_SEED=1 make seed`, then +`cd core && AUTH_MODE=dev go run ./cmd/server`. The `make up` and +`make db` workflows use different compose projects and volumes, so +the `make db` data is never truncated or removed by `make up` or +`make down` (`AUTH_MODE=dev` needs no `Authorization` header; the +examples below show the production header shape). + +Migration 106 brings the AP tables onto the wire contract: a +`vendor_invoices.number`, a `branch_id`, a `currency`, a status CHECK, +a `po_id` FK, `UNIQUE (vendor_id, invoice_number)`, an `amount_open`, +and `vendor_invoice_lines.position`, `purchase_order_line_id NULL`, +`product_id NULL`, `unit_price` widened to NUMERIC(12,4) and +`po_freight_charge_id NULL`. The demo seed keeps no vendor bill on +`AP`. Then, with a finance role bearer and the seeded branch: + +``` +curl -X POST http://localhost:8080/api/v1/ap/invoices \ + -H 'Authorization: Bearer ' \ + -H 'X-Branch-Id: ' \ + -H 'Idempotency-Key: 1' \ + -H 'Content-Type: application/json' \ + -d @ap-invoice-create.json +``` + +with a body that names the seeded vendor, the seeded branch, a +1 to 64 character `vendor_invoice_number`, the two dates as +YYYY-MM-DD, an ISO 4217 currency, `tax_cents`, optional `notes` and at +least one line (`description`, `quantity` as a decimal string, +`unit_price_ten_thousandths` int64 and the line's `gl_account_id`): + +``` +curl -X POST http://localhost:8080/api/v1/ap/invoices//transitions \ + -H 'Authorization: Bearer ' \ + -H 'X-Branch-Id: ' \ + -H 'Idempotency-Key: 2' \ + -H 'If-Match: "1"' \ + -H 'Content-Type: application/json' \ + -d '{"to":"approved"}' +``` + +The transition's ETag response is the new revision; a `to: "voided"` +follows the same shape and adds a `reason`. The payment needs the +same scope, an `Idempotency-Key`, `amount_cents` int64 (> 0 and +`<= 999999999999`), a `method` of `CHECK`, `ACH` or `WIRE`, the date +as YYYY-MM-DD, and `invoice_ids` in the order the desk pays them. + +The posting and rejection proofs in +`core/internal/ap/service.go` `postInvoiceEntry`, the wiring tests in +`core/internal/ap/wire_test.go` and the transaction proofs in +`core/internal/ap/tx_test.go` (the revision race, the wallet +transitions, the linked and unlinked voids, the money ceilings, the +across-branches payment) pin the behaviour. The migration proofs in +`migration106_test.go` pin the column types, the backfill and the +indexes. From 93ffc93ba19d620be17e1c62255f0933afbf9ca7 Mon Sep 17 00:00:00 2001 From: colton Date: Sat, 10 Oct 2026 13:32:38 -0700 Subject: [PATCH 2/2] docs(ap): address round 1 review findings on the AP module page Address the Sonnet 5.5 review for PR 89 (round 1): P1-1 clarify that the payload branch rule applies to POST /ap/invoices only; POST /ap/payments carries no branch_id and is held to the wall through the bills it names via LockForPayment. P2-1 an omitted currency is USD (Parse fills it in input.go); the currency.default setting is not read on the create path. P2-2 the id-order lock prevents deadlock and serialises payments on a bill; repeats are caught by Idempotency-Key and the open amount check. P2-3 remove the invented UI clause about the desk typing dollars. P2-4 MarkVoided leaves gl_entry_id set; the bill rows agree. P2-5 ApPaymentApplication is a storage row, not on the wire. P3 notes: entry_not_linked reachable from approved only (a partial bill is refused has_payments first); every route reads included is behind scoped; name the field the AP code returns (branch_id); the approver is the caller-given id; error envelope is ADR 0001 section 3; dev mode needs no bearer; method is accepted case insensitively; remove 'Loader has reviewed'; add the machine key role guard sentence from ADR 0002 section 4. Claims table rows in mm/docs-ap-claims.md bind each change. Signed-off-by: colton --- docs/modules/ap.md | 77 ++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/docs/modules/ap.md b/docs/modules/ap.md index f504b110..0e755df5 100644 --- a/docs/modules/ap.md +++ b/docs/modules/ap.md @@ -11,11 +11,13 @@ header the desk enters (the vendor, the vendor's own number, the date, the due date, the tax) and the priced lines (the goods or services, the quantity, the unit price, the GL account the line's money lands on). A payment names the bills it applies to (in the order the desk pays them) -and locks that order on the database so two callers can never apply the -same payment twice. Both acts post one journal entry inside one -transaction: a payment that posts `DR 2010 / CR 1010`, and an approve -that builds the entry from the lines and credits the whole total to -`2010`. +and locks that order on the database so concurrent payments cannot +deadlock or both spend the same open amount (repeats are refused as +`invoice_ids[i] (appears twice)` by `PayRequest.Parse`, and over-payment +as `exceeds_open` by `PayVendor`). Both acts post one journal entry +inside one transaction: a payment that posts `DR 2010 / CR 1010`, and an +approve that builds the entry from the lines and credits the whole +total to `2010`. This module is the work of C4-1b (item C4-1b: the AP fixes of ADR 0008 section 7.4 on the wire contract of ADR 0001, plus migration 106). The @@ -28,8 +30,7 @@ the ADR planned as 098 in the C4-1b section, renumbered to 106 at merge). A yard buys lumber, fasteners and concrete from vendors on terms. The bill lands on the desk from the carrier or by email. The clerk enters the bill through `POST /ap/invoices`: the line money and the tax are -integers in cents and the unit price is an integer at scale four (the -desk still types dollars, the form does the one exact conversion). The +integers in cents and the unit price is an integer at scale four. The bill starts pending, owing its whole total, with Gable's own number (prefix `AP-`); the vendor's own number is `vendor_invoice_number` (1 to 64 characters, unique per vendor). @@ -52,9 +53,10 @@ an approved bill's entry is reversed whole through `gl.PostReversal`; the bill's `amount_open` drops to zero; a bill that prices to zero posted nothing and voids with nothing to reverse. A bill with money applied is refused with `has_payments` (the payments have to be undone -first). An approved or partial bill with a nonzero total and no linked -entry is refused with `entry_not_linked`, and one whose linked entry -does not balance with `entry_unbalanced`: both stay approved and +first); a partial bill is refused there before the void reaches the +linked-entry checks. An approved bill with a nonzero total and no +linked entry is refused with `entry_not_linked`, and one whose linked +entry does not balance with `entry_unbalanced`: both stay approved and payable until a later repair item (no route today links an entry to a bill). Both refusals write nothing. @@ -96,7 +98,7 @@ package. | POST | `/api/v1/ap/payments` | Record a vendor payment; `amount_cents` int64; `method` enum `CHECK`, `ACH` or `WIRE`; `invoice_ids` in the order the desk pays them; `Idempotency-Key`. | | GET | `/api/v1/ap/aging` | Aging buckets in int64 cents by vendor (`current`, `past_30`, `past_60`, `past_90`, `total`); held to the caller's branch wall. | -Every write above sits behind `scoped("admin", "owner", "finance")` +Every route above (reads included) sits behind `scoped("admin", "owner", "finance")` (`core/internal/app/serve/serve.go:679`), the branch wall (`BranchGuard.CheckPayloadBranch` at `core/internal/ap/service.go:156-167`) and the user's branch context. A bound machine key without the right @@ -119,7 +121,7 @@ scope. | `vendor_name` | text | Snapshot of the vendor name. | | `branch_id` | UUID | The branch the bill belongs to. | | `vendor_invoice_number` | text | The vendor's own number, 1 to 64 characters, unique per vendor. | -| `currency` | ISO 4217 | The bill's currency; defaults from the `currency.default` setting. | +| `currency` | ISO 4217 | The bill's currency; an omitted currency is `USD`. | | `invoice_date` | date | YYYY-MM-DD. | | `due_date` | date | YYYY-MM-DD. | | `po_id` | UUID, nullable | The purchase order this bill was matched to (FK since migration 106). | @@ -133,7 +135,7 @@ scope. | `approved_at` | timestamp, nullable | When it was approved. | | `notes` | text | At most 2000 characters. | | `revision` | integer | Starts at 1; returned as ETag. | -| `gl_entry_id` | UUID, nullable | The entry the approve posted (set on approve, cleared on the void's reversal). | +| `gl_entry_id` | UUID, nullable | The entry the approve posted (set on approve; kept after a void). | | `created_at` | timestamp | RFC 3339 UTC. | | `lines` | array of `ApVendorInvoiceLine` | The priced lines, in position order. | @@ -172,7 +174,7 @@ conversion (ADR 0008 section 11); the field set below is that shape. | `vendor_name` | text | Snapshot of the vendor name, filled by the list join. | | `batch_id` | UUID, nullable | The batch this payment is in (today's shape). | | `amount` | integer | Cents (the response); the request uses `amount_cents`. | -| `method` | enum | `CHECK`, `ACH`, `WIRE` (UPPERCASE storage, UPPERCASE on the wire today). | +| `method` | enum | `CHECK`, `ACH`, `WIRE` (UPPERCASE storage; the wire is accepted case insensitively). | | `check_number` | text, nullable | For a check. | | `reference` | text, nullable | Free text. | | `payment_date` | date | YYYY-MM-DD. | @@ -181,8 +183,10 @@ conversion (ADR 0008 section 11); the field set below is that shape. `ApPaymentApplication` (the join row, `model.go` `APPaymentApplication`) links a payment to the bill it applies to with `id`, `payment_id`, -`invoice_id`, `amount` (cents) and `created_at`. The same row is the AP -side of the application. +`invoice_id`, `amount` (cents) and `created_at`. It is a storage row, +not on the wire: no AP route returns it; `PayVendor` writes it inside +the payment's transaction and the response only returns the +`ApPayment`. `ApAgingSummary` (the aging row, `fragments/ap.yaml`) carries `vendor_id`, `vendor_name`, `current`, `past_30`, `past_60`, `past_90`, @@ -209,7 +213,7 @@ target is a 403 naming `branch_id`), `vendor_invoice_number` (1 to 64 characters, unique per vendor; a duplicate is a 409 naming the field), `invoice_date`, `due_date` (both YYYY-MM-DD), optional `po_id` (an unknown PO is a 400 naming `po_id`), optional `currency` -(defaults from the `currency.default` setting; a request's currency +(an omitted currency is `USD`; a request's currency must be an ISO 4217 code of three capital letters), `tax_cents`, `notes` (at most 2000 characters) and `lines` (at least one line, the same set of fields as the line schema minus `id`, `position` and @@ -233,7 +237,7 @@ module allows: | From | To | Reason | Event | |---|---|---|---| -| `pending` | `approved` | Loader has reviewed, the GL entry can post. | `vendor_invoice.approved` | +| `pending` | `approved` | The bill is right; the GL entry can post. | `vendor_invoice.approved` | | `pending` | `voided` | The bill was wrong; nothing posted yet. | `vendor_invoice.voided` | | `approved` | `voided` | The bill is wrong; the entry is reversed whole. | `vendor_invoice.voided` | | `partial` | none | Partial carries payments; the bill cannot be voided while money has left. | | @@ -244,7 +248,7 @@ A payment sets a bill's `status` to `partial` (when more is still owed) or `paid` (when this payment closes it) at the moment of the apply. Transitions `to: "pending"`, `to: "partial"` and `to: "paid"` from `POST /ap/invoices/{id}/transitions` are refused with -`invalid_state_transition`; a payment is the only writer for those two. +`invalid_state_transition`; a payment is the only writer for those. ### Approve @@ -269,9 +273,9 @@ transaction: `entry_unbalanced` (debits and credits do not balance); each is mapped to a 409 by the module and fails the act (ADR 0008 7.4). 7. Mark the bill approved with the linked `gl_entry_id` and the - approver (the caller's UUID when the actor is a parseable UUID, else - nil for an in-process caller; `s.ApproveInvoice` passes a fixed - approver). + approver (the caller's UUID when the actor is a parseable UUID, + else nil for an in-process caller; `s.ApproveInvoice` passes the + caller-given approver id). 8. Write the audit row, then the event (`vendor_invoice.approved`) in the same transaction (ADR 0003 section 2). @@ -414,17 +418,25 @@ layer is `admin`, `owner`, `finance` (registered at `ap.NewHandler(apSvc).RegisterRoutes(mux, scoped("admin", "owner", "finance"))`). No other module's guard reaches an AP route. A key without the right scope is `403 forbidden`; the audit row carries the refused scope (ADR -0002 section 5). +0002 section 5). A machine key skips the role guard entirely: ADR 0002 +section 4 holds that a key has no roles and `RequireRole` skips its +check when the request authenticated with a key, so a key is admitted +on the scope check and the role guard never runs. A branch bound machine key (ADR 0007 section 5.5) is pinned to its one branch: a `GET` and a `POST` with `X-Branch-Id` set to another branch are `403 forbidden`; a payload `branch_id` outside the key's bound is -`403 forbidden` naming `branch_id`. The payload rule -(`BranchGuard.CheckPayloadBranch`, `service.go:156-167`) is what -`POST /ap/invoices` and `POST /ap/payments` apply. The branch resolves -in three steps (`service.go:263-278`): the payload `branch_id`, the -caller's context branch, or the `default_branch_id` setting; the -`currency.default` setting is the same shape for the currency. +`403 forbidden` naming `branch_id` (the AP code names the field +`branch_id`). The payload rule (`BranchGuard.CheckPayloadBranch`, +`service.go:156-167`) is what `POST /ap/invoices` applies; it is the +only AP route with a payload branch. `POST /ap/payments` carries no +`branch_id` and is held to the wall through the bills it names (a bill +outside the caller's wall is a 400 naming `invoice_ids[i]`, +`LockForPayment` at `repository.go:307-319`). The branch resolves in +three steps (`service.go:263-278`): the payload `branch_id`, the +caller's context branch, or the `default_branch_id` setting. The +`currency` is `Parse`'s `USD` on the route (input.go:117-122); the +`currency.default` setting is not read on the create path. ## Stated limits @@ -470,7 +482,7 @@ and NUMERIC(12,4) for unit price, so the ceiling is the column's. ## ADRs that govern this module - [`docs/adr/0001-wire-contract.md`](../adr/0001-wire-contract.md): - the list envelope, cursors, the error envelope (4), the lowercase + the list envelope, cursors, the error envelope (3), the lowercase status vocabulary (6), money in `_cents` (7), unit prices in `_ten_thousandths` (7a), the `Idempotency-Key` header (9), the revision concurrency rule and the 428 / 409 `stale_revision` @@ -513,11 +525,11 @@ a `po_id` FK, `UNIQUE (vendor_id, invoice_number)`, an `amount_open`, and `vendor_invoice_lines.position`, `purchase_order_line_id NULL`, `product_id NULL`, `unit_price` widened to NUMERIC(12,4) and `po_freight_charge_id NULL`. The demo seed keeps no vendor bill on -`AP`. Then, with a finance role bearer and the seeded branch: +`AP`. Then, with the seeded branch (dev mode needs no +`Authorization` header, ADR 0002 section 3): ``` curl -X POST http://localhost:8080/api/v1/ap/invoices \ - -H 'Authorization: Bearer ' \ -H 'X-Branch-Id: ' \ -H 'Idempotency-Key: 1' \ -H 'Content-Type: application/json' \ @@ -532,7 +544,6 @@ least one line (`description`, `quantity` as a decimal string, ``` curl -X POST http://localhost:8080/api/v1/ap/invoices//transitions \ - -H 'Authorization: Bearer ' \ -H 'X-Branch-Id: ' \ -H 'Idempotency-Key: 2' \ -H 'If-Match: "1"' \