From cb0113f9f4442ac8c9f161ec1d9d928c2dfa0342 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 3 Nov 2026 01:27:07 +0200 Subject: [PATCH 1/2] feat: add item supplier payments and approvals queue Keep payment holds independent of workflow and margin approval. Require proof before collection readiness; deduplicate uploads transactionally in PocketBase and preserve payment history. Includes schema migration, deployment runbook, browser checks and isolated PocketBase regression coverage. Full suite has 5156 passing tests and retains two pre-existing script-import errors. --- docs/order-payments.md | 42 ++ .../plans/2026-11-02-order-item-payments.md | 79 +++ ...rder-item-payments-and-approvals-design.md | 98 ++++ e2e/order-payments.spec.ts | 34 ++ package.json | 3 +- pocketbase/pb_hooks/DEPLOY | 2 + pocketbase/pb_hooks/audit_log.pb.js | 6 +- pocketbase/pb_hooks/audit_log_utils.js | 13 + pocketbase/pb_hooks/order_payment_utils.js | 457 ++++++++++++++++++ pocketbase/pb_hooks/order_payments.pb.js | 57 +++ .../migrations/2026-11-02-order-payments.ts | 58 +++ scripts/smoke/order-payments-hooks.mjs | 198 ++++++++ src/app/(app)/orders/[id]/process/page.tsx | 47 +- .../(app)/orders/payments-approvals/page.tsx | 5 + .../v1/order-payments/[id]/documents/route.ts | 10 + .../api/v1/order-payments/[id]/paid/route.ts | 11 + src/app/api/v1/order-payments/[id]/route.ts | 15 + .../api/v1/order-payments/combine/route.ts | 13 + src/app/api/v1/order-payments/route.test.ts | 22 + src/app/api/v1/order-payments/route.ts | 14 + .../api/v1/orders/[id]/process-data/route.ts | 8 + .../orders/order-item-payment.test.tsx | 45 ++ src/components/orders/order-item-payment.tsx | 57 +++ .../orders/payments-approvals-page.test.tsx | 48 ++ .../orders/payments-approvals-page.tsx | 131 +++++ src/components/sidebar.tsx | 3 +- src/lib/currency.ts | 4 + src/lib/order-payment-client.ts | 12 + src/lib/order-payments.ts | 58 +++ src/lib/pdf/quote-document.test.tsx | 1 + src/lib/pdf/render.test.ts | 1 + .../collection-ready-dates-service.test.ts | 13 + .../server/collection-ready-dates-service.ts | 3 + src/lib/server/order-payment-hooks.test.ts | 276 +++++++++++ src/lib/server/order-payment-route.ts | 32 ++ src/lib/server/order-payment-service.test.ts | 74 +++ src/lib/server/order-payment-service.ts | 136 ++++++ src/lib/server/order-process-service.ts | 20 + src/lib/types.ts | 3 + 39 files changed, 2071 insertions(+), 38 deletions(-) create mode 100644 docs/order-payments.md create mode 100644 docs/superpowers/plans/2026-11-02-order-item-payments.md create mode 100644 docs/superpowers/specs/2026-11-02-order-item-payments-and-approvals-design.md create mode 100644 e2e/order-payments.spec.ts create mode 100644 pocketbase/pb_hooks/order_payment_utils.js create mode 100644 pocketbase/pb_hooks/order_payments.pb.js create mode 100644 scripts/migrations/2026-11-02-order-payments.ts create mode 100644 scripts/smoke/order-payments-hooks.mjs create mode 100644 src/app/(app)/orders/payments-approvals/page.tsx create mode 100644 src/app/api/v1/order-payments/[id]/documents/route.ts create mode 100644 src/app/api/v1/order-payments/[id]/paid/route.ts create mode 100644 src/app/api/v1/order-payments/[id]/route.ts create mode 100644 src/app/api/v1/order-payments/combine/route.ts create mode 100644 src/app/api/v1/order-payments/route.test.ts create mode 100644 src/app/api/v1/order-payments/route.ts create mode 100644 src/app/api/v1/orders/[id]/process-data/route.ts create mode 100644 src/components/orders/order-item-payment.test.tsx create mode 100644 src/components/orders/order-item-payment.tsx create mode 100644 src/components/orders/payments-approvals-page.test.tsx create mode 100644 src/components/orders/payments-approvals-page.tsx create mode 100644 src/lib/currency.ts create mode 100644 src/lib/order-payment-client.ts create mode 100644 src/lib/order-payments.ts create mode 100644 src/lib/server/order-payment-hooks.test.ts create mode 100644 src/lib/server/order-payment-route.ts create mode 100644 src/lib/server/order-payment-service.test.ts create mode 100644 src/lib/server/order-payment-service.ts create mode 100644 src/lib/server/order-process-service.ts diff --git a/docs/order-payments.md b/docs/order-payments.md new file mode 100644 index 0000000..9917c24 --- /dev/null +++ b/docs/order-payments.md @@ -0,0 +1,42 @@ +# Supplier payments and order-item approvals + +The **Payments and Approvals** entry under Orders opens `/orders/payments-approvals`. + +On an order's process page, switch on **Payment required** for a line when its supplier needs payment. The hold starts immediately and the queue shows **Needs details** until someone confirms the supplier, amount and supporting evidence. The buyer may still price items and create/send supplier POs. The payment hold prevents **Ready for collection** and dispatch, including scheduled readiness. Payment is separate from `item_status`, so margin approval remains in force independently. + +Open Payment details, confirm the total amount in rand (including any charges), supplier reference and notes. Upload the invoice, pro forma, PO or photos, or select an existing generated supplier PO as evidence. Files are added to every covered item; existing attachments stay intact. An item with ten attachments needs its attachment capacity resolved before more can be uploaded. This feature never removes unrelated files to make space. + +Select requests for the same order and supplier and choose Combine selected if one supplier payment covers them. The combined request needs its total explicitly confirmed; requests are never merged merely because they share a PO or supplier. Removing only one item's requirement keeps the others held and requires their amount to be reconfirmed. + +When payment has been made, enter its reference, upload proof and choose **Mark paid**. This records full settlement of the confirmed request, not a bank transaction. It does not mark the item ready or perform another workflow action. The buyer or existing date automation can subsequently mark it ready. Partial/deposit instalments do not release this v1 hold. + +Paid/cancelled filters retain history and evidence. Correcting a pending item supplier, supplier quantity or cost invalidates the confirmed amount; refresh and reconcile the request before payment. A changed paid supplier/amount cannot reuse old proof to release collection. Start a fresh requirement before making a pre-collection change, or explicitly cancel that fresh requirement with a reason if no extra payment is needed. Paid history stays attached. A split of an unpaid held line creates a separate incomplete hold for the remainder without duplicating the original confirmed amount. + +Approvals lists existing `approval_needed` order items with the actual calculated markup, including losses. Its Open item link leads to the existing workflow; WhatsApp/n8n approval decisions continue as before. Quote approvals remain on their existing page. + +## Live rollout (manual) + +No live migration, hook publish or application deploy is performed by implementing this feature. + +1. Back up the live PB database using the normal deployment procedure. +2. Run `npm run pb:migrate:order-payments` with the intended PB admin environment. The migration is idempotent. It creates the metadata-only `supplier_payment_requests` collection and `order_items.payment_status` / `payment_request`; it does **not** alter the existing `item_status` select values. Request collection list/view/write rules are admin-only; application routes validate the office browser session. +3. Commit/publish `pocketbase/pb_hooks/` using `npm run pb:hooks:publish` after its changes have reached the deployment branch. `DEPLOY` includes both `order_payments.pb.js` and `order_payment_utils.js` plus the audit helper. The VPS sync process reloads hooks; see `scripts/pb-hooks/README.md`. +4. Verify the superuser-authenticated `GET /api/wfdash/order-payments-capability` returns `{ "version": 2 }`. It verifies required schema fields and identifies the guard with transactionally deduplicated document uploads. Do not enable requests with an older guard. The Next service checks this endpoint before every mutation and displays an unavailable state until schema and hooks are ready. +5. Deploy the application. Document uploads use a PocketBase transaction for capacity checks, content-identity lookup and additive writes, so concurrent retries cannot append duplicate proof. All paid proof files remain protected across later payment requests. New APIs accept a validated user PB token in `Authorization: Bearer …`; the browser helper sends it. No agent secret is exposed to the browser. Existing office access is used; no new accountant role is introduced. +6. On a designated test order, require payment and verify supplier PO processing remains possible; direct and manual ready-for-collection must fail before proof/paid. Complete the test request with real designated test evidence, confirm that Paid alone does not set ready, then perform the normal readiness step. Also verify the readiness date tick skips an unpaid test item. + +Deploy enforcement before making any payment requirement available. A missing capability/schema produces a 503 on mutations; legacy items without payment fields/requirements continue normally. Do not run test writes against real customer items accidentally. + +## Verification + +- `npm run test:run` for the full suite. Existing baseline script-import errors, if any, must be reported separately rather than ignored. +- `npx tsc --noEmit` and `npm run build`. +- `npx vitest run src/lib/server/order-payment-hooks.test.ts src/lib/server/order-payment-service.test.ts src/app/api/v1/order-payments/route.test.ts src/components/orders/order-item-payment.test.tsx src/components/orders/payments-approvals-page.test.tsx src/lib/server/collection-ready-dates-service.test.ts`. +- `E2E_BASE_URL=http://localhost:3194 npx playwright test e2e/order-payments.spec.ts --workers=1` checks desktop/mobile with fixture API responses, proof-required interaction and actual form submission. +- `POCKETBASE_TEST_BINARY=/absolute/path/to/pocketbase node scripts/smoke/order-payments-hooks.mjs` starts an ephemeral isolated local PB 0.39.8 instance, runs the migration twice and exercises actual hooks/transactions. It never connects to the live PB. Obtain the platform binary from the official PocketBase release for that pinned version. + +The model/request guards use PocketBase's transaction and event APIs; see [record operations](https://pocketbase.io/docs/js-records/) and [event hooks](https://pocketbase.io/docs/js-event-hooks/). + +## Rollback + +Do not remove payment guards or fields while there are outstanding holds. First stop new requests by reverting the app feature, retain the hooks and metadata, and settle or explicitly cancel outstanding requests through the payment actions. Removing guards alone permits unpaid collection; removing metadata loses evidence/history. Restore a tested database backup only through the established rollback process, never by deleting request records. diff --git a/docs/superpowers/plans/2026-11-02-order-item-payments.md b/docs/superpowers/plans/2026-11-02-order-item-payments.md new file mode 100644 index 0000000..a64eca0 --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-order-item-payments.md @@ -0,0 +1,79 @@ +# Order-item payments Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans for the controller and a bounded implementation worker for Task 1, then a fresh whole-change reviewer. Steps use checkbox syntax for tracking. + +**Goal:** Add supplier payment requests and a combined Payments and Approvals queue; unpaid requirements block collection readiness, never PO processing. +**Architecture:** Metadata-only `supplier_payment_requests`, item payment projections, a transactional superuser-only PocketBase command route, authenticated Next service endpoints, and existing themed React components. PocketBase enforces direct-write invariants; source/proof files stay on item attachments. +**Tech Stack:** Next 16.2.6, React 19, PocketBase 0.39.8 JS hooks, TypeScript, Vitest. +**Spec:** `docs/superpowers/specs/2026-11-02-order-item-payments-and-approvals-design.md` + +## Global Constraints +- Payment blocks ready-for-collection and dispatch only. PO creation/sending and ordinary edits continue. +- Preserve `item_status` including approval state. No new item_status select values. +- Use `addAttachmentForm`, never replace item attachment arrays; browser links use `/api/v1/files/`. +- New browser endpoints validate sessions. PB commands are superuser-only and transaction guarded. +- Existing schema/hook live rollout is manual; do not migrate or publish live without deployment authorization. +- Leave unrelated changes in the original checkout untouched; implement in the managed worktree. + +## Review Focus +- A stale payer tab after supplier/quantity/cost changes must not settle obsolete evidence. +- A ready-date tick must skip unpaid stops and allow normal non-payment stops. +- Failed/retried uploads must not release a hold or overwrite unrelated files. +- Combining/cancelling one grouped item must preserve other holds and payment history. +- Direct PB writes and splitting must not bypass the hold or fake paid. + +## Shared interfaces + +`src/lib/order-payments.ts` owns client-safe interfaces: +- `PaymentDocument { collection: string; recordId: string; filename: string; role: "support" | "proof" }` +- `SupplierPaymentRequest`: PB record fields `id`, `created`, `updated`, `order`, `supplier`, `items: string[]`, `status: "draft" | "pending" | "paid" | "cancelled"`, `amount`, `paid_amount`, `reference`, `payment_reference`, `note`, `requested_by`, `paid_by`, `paid_at`, `cancellation_reason`, `documents: PaymentDocument[]`, `item_snapshot: Record`, `revision: number`, `history: unknown[]`. +- Item fields `payment_status?: "required" | "paid" | ""`, `payment_request?: string`. + +PocketBase command: `POST /api/wfdash/order-payments`, body `{ op, actor: { id, name }, ... }`, returns `{ request: SupplierPaymentRequest }`. +- `require`: `{ item_id }`, immediately creates draft hold. Idempotently returns active request. +- `save`: `{ request_id, revision, amount, reference, note, documents?: PaymentDocument[] }`; retain previous docs; transition pending only with positive amount, supplier and support. Snapshot current supplier quantities/costs. Explicitly reconfirms total after edits. +- `paid`: `{ request_id, revision, paid_amount, payment_reference, documents: PaymentDocument[] }`; verify positive full amount, pending snapshot, support and proof on every covered item, then settle transactionally. +- `cancel`: `{ request_id, revision, item_id?: string, reason }`; optional item only, leave other members held and draft with amount requiring reconfirmation. +- `combine`: `{ request_ids: string[], revisions: Record }`; same order/supplier, pending/draft only; cancel old requests with merge history, create new draft with union of members/docs and no silently confirmed total. +`POST /api/wfdash/order-payments-document` atomically checks request revision, deduplicates a content-derived filename and adds the multipart file to every item; returns `{ documents: PaymentDocument[] }`. +`GET /api/wfdash/order-payments-capability` returns `{ version: 2 }` only when schema is available. + +## Task 1: PocketBase transactional workflow, schema and enforcement + +**Files:** `pocketbase/pb_hooks/order_payments.pb.js`, `order_payment_utils.js`, `DEPLOY`, audit hook/helpers, `scripts/migrations/2026-11-02-order-payments.ts`, hook behavioural tests under `src/lib/server/order-payment-hooks.test.ts`, package migration script. +**Produces:** command and capability endpoints above; protected item projections; readiness invariant; draft hold on unpaid split remainders. Hook audit writes use existing audit helper with actor context. +- [x] Write failing hook behavioural tests using actual loaded JS module/registered hooks with a transaction-aware record harness; prove require/payment/readiness/direct-write/split/cancellation/concurrency behaviours. +- [x] Run focused tests and verify failures because implementation is absent. +- [x] Implement metadata schema, commands, request/model hooks, audit extension and deployment manifest. +- [x] Run focused tests; test against an isolated local PocketBase if a suitable binary is available. No live writes. +- [x] Commit only this task's files. + +## Task 2: Authenticated services, upload preservation and page data migration + +**Files:** `src/lib/order-payments.ts`, `src/lib/server/order-payment-service.ts`, `src/app/api/v1/order-payments/**`, `src/lib/server/order-process-service.ts`, `src/app/api/v1/orders/[id]/process-data/route.ts`, focused tests. +**Consumes:** Task 1 contract. **Produces:** list DTO `{ enabled, requests, approvals }`; POST require `{ item_id }`; PATCH request save/cancel/combine JSON; POST request `/paid` multipart with revision/paid_amount/payment_reference and proof; POST request `/documents` multipart support. Commands use actor from validated browser session, never browser supplied identity. +- [x] Write service/route tests that fail for session rejection, missing hook/schema, failed uploads, grouped proof and wrong amount, retries and capacity preservation. +- [x] Run and observe RED, implement service and thin routes, verify GREEN. +- [x] Add browser-session process-data endpoint returning order, items, suppliers, active stock and matched quote items via current services/admin client; migrate all legacy process page reads and currency import without broad unrelated changes. +- [x] Run focused tests and TypeScript. Commit scoped files. + +## Task 3: Payment toggle and combined queue + +**Files:** `src/components/orders/order-item-payment.tsx`, `src/components/orders/payments-approvals-page.tsx`, `src/app/(app)/orders/payments-approvals/page.tsx`, process page, sidebar, component tests. +**Consumes:** Task 2 endpoints and DTOs. +- [x] Write failing interaction tests for immediate toggle hold, incomplete requests, supporting upload, paid proof requirement, grouped selection, cancellation reason and visible API errors. +- [x] Implement item toggle/badge with queue link; two themed accessible tables, search/history filters, explicit selection/combine, inline request editor with supplier evidence, upload and paid form. Mobile scrollable tables, labelled controls, loading/errors and refresh states. +- [x] Run interaction tests, TypeScript and relevant ESLint. Commit scoped files. + +## Task 4: Readiness integration, rollout runbook and final verification + +**Files:** ready-date service/tests, Buyer Tasks and map errors as needed, `docs/order-payments.md`, plan/spec status. +- [x] Add RED test proving unpaid date automation skips without an error and paid/legacy lines proceed. Implement shared guard check while PB remains enforcement authority. +- [x] Verify all manual/API paths rely on PB invariant and render actionable errors; direct readiness and dispatch rejection are tested by Task 1. +- [x] Document explicit schema and hook rollout, capability gate, verification, rollback and full-request-only v1 scope. +- [x] Run `npm run test:run`, `npx tsc --noEmit`, changed-file ESLint and production build. Report any baseline failures separately. +- [x] Have a fresh reviewer inspect the full change; fix material findings with regression tests, verify again, and commit. + +## Completion evidence + +Implemented in `codex/order-item-payments`. Final fresh review and scoped re-review cleared all material findings. Focused tests: 42 passed. Real PocketBase 0.39.8 smoke: passed (migration twice, readiness, split conservation, historical proof and concurrent upload deduplication). Browser desktop/mobile native payment submission: passed. Production build and TypeScript: passed. Full suite: 376 files / 5156 passing tests; command retains the two pre-existing unhandled script-import process.exit errors in apply-default-markup.test.ts and backfill-stock-prices.test.ts. Scoped ESLint: no errors, one pre-existing unused Zap import warning. Live migration, hook publication and deployment remain manual. diff --git a/docs/superpowers/specs/2026-11-02-order-item-payments-and-approvals-design.md b/docs/superpowers/specs/2026-11-02-order-item-payments-and-approvals-design.md new file mode 100644 index 0000000..16a5814 --- /dev/null +++ b/docs/superpowers/specs/2026-11-02-order-item-payments-and-approvals-design.md @@ -0,0 +1,98 @@ +# Order-item supplier payments and approvals + +Status: approved by the operator on 2026-11-02; implementation authorised after confirming that only ready for collection is blocked. + +## Intent and confirmed behaviour + +Give the office a shared queue of supplier payments needed for customer-order items, with source documents and proof of payment. Put existing order-item markup approvals beside it. + +The operator requested an item-level payment toggle, a sidebar entry under Orders called **Payments and Approvals**, two tables (Payments and Approvals), supporting-document uploads, and a Mark paid action with proof of payment. + +The operator clarified the payment boundary: **everything continues normally, but an item requiring payment can only become ready for collection once payment has been made and marked paid.** Creating and sending supplier POs must remain possible while payment is pending. Payment here means payment to the supplier. + +## Payment state alongside the existing workflow + +Keep `order_items.item_status` for the existing buying, approval, collection and invoicing lifecycle. Add a distinct payment state with visible labels Not required, Payment needed, and Paid. The per-item toggle creates/removes a payment requirement, not a replacement workflow stage. + +This is the proposed implementation choice, not a separate operator decision: payment and markup approval can coexist. Paying must never overwrite `approval_needed`, `approval_rejected`, or any other item workflow status. No new values of `item_status` are needed. + +`ready_for_collection` is the actual readiness flag. Entry to `collection_pool` alone is not readiness: pending stops may already be in that pool. Unpaid items can remain visible there, with a Payment needed badge, but cannot be marked ready or dispatched. + +Mark paid records settlement of the payment request and removes that hold. It does not mark ready, create/send a PO, assign a driver, collect, invoice, or otherwise move the item. Existing manual readiness and expected-ready-date automation may proceed after payment, subject to their existing rules. + +## Item interaction + +On the order process item card, add **Payment required** and the current payment badge. The request editor captures supplier, an explicit amount to pay in rand, supplier document/reference, optional note, and supporting files. Customer company and order come from the item. Existing generated PO documents can be opened/selected as evidence rather than requiring the user to download and reupload them. + +Proposed defaults: + +- A supplier and positive, explicitly confirmed payment amount are required to submit a complete payment request. At least one supporting document is required. Turning the toggle on immediately establishes the hold, even while the request is incomplete; show incomplete requests in Payments with a Needs details indicator. +- Suggest supplier cost using supplier `order_qty` and `cost_price`, never customer quantity or customer selling price. Label it an estimate; the payer uses the confirmed requested amount and supplier document. The amount is the total requested including any applicable charges/tax, entered explicitly; no inferred VAT or discounts. +- Payment requirement can be set on a not-yet-ready, unassigned customer-order item. If already ready, tell the operator to return it to not-ready first. If assigned or already collected/settled, reject creation of this pre-collection hold with a clear message. Do not silently remove a driver's assignment. +- Switching an unpaid requirement off cancels it with a required reason and preserves its history/files. It does not mark paid. Cancelling a requirement removes this hold, as an intentional correction to the requirement. +- For a grouped request, switching one item off removes only that item's requirement. All other items stay held. Preserve the original membership/history and evidence, mark the remaining request Needs details, and require its total to be explicitly reconfirmed before payment; cancel the entire request only when its last item is removed. Never silently redistribute a group amount. +- Once paid, keep the paid indicator and proof in history. A new pre-collection payment requirement creates a fresh request; it never erases the earlier paid record. + +## Payments and Approvals page + +Add one navigation entry under Orders, proposed route `/orders/payments-approvals`. Show two separately titled tables on the same page, with counts, search, and links back to the relevant order/process item. + +### Payments table + +Show customer company, order number, supplier, PO/reference, affected items and supplier quantities, confirmed amount, request state, age/requester, supporting documents, and actions. Payment needed is the default view, including incomplete requests; a Paid/cancelled history filter keeps previous requests accessible. + +A payment request may cover selected items on the **same customer order and supplier**. Do not merge all items for one supplier or PO automatically: two invoices may represent separate payments. Default to one request per item; allow explicitly combining selected unpaid items into one request, with one confirmed total and shared documents. An item belongs to at most one active unpaid request. Do not combine paid requests, cross-order requests, or cross-supplier requests. + +**Mark paid** opens an upload form requiring proof of payment and actual amount/reference. For v1, the actual amount must settle the full request; a different amount requires correcting the pending request first. Partial/deposit instalments are outside v1. The UI must state this clearly rather than treating a deposit as full settlement. One proof may settle all selected items in that request, never every item on the order or supplier PO by implication. + +Save and verify the proof before marking the request paid. Record payer identity and server time. Repeated submissions must not duplicate payments or attachments; conflicting edits/paid-again requests return an explanatory conflict. A failed or partially completed file upload leaves the request pending and blocked, with a safe retry path. + +### Approvals table + +List the existing `item_status = "approval_needed"` customer-order items, showing company, order, item, supplier, cost, selling price, actual calculated markup, and the existing order/process link. A negative actual markup must remain visible even though stored mirrored markup fields can be clamped to zero. + +Proposed v1 scope: centralise visibility and link to the existing approval workflow; retain the WhatsApp/n8n decision mechanism. New in-app approval/rejection buttons are not assumed. These are order-item margin approvals, separate from `/quotes/approvals`. + +An item needing both approval and payment appears in both tables. Approval or rejection changes only the existing approval/workflow state; payment changes only the payment state. Neither action clears the other requirement. + +## Storage, documents and audit + +Proposed data model: a metadata-only supplier payment request collection, plus an active request relation/payment-state projection on `order_items`. Requests record the order, supplier, selected items, state (draft/pending/paid/cancelled), requested/paid amounts, references, actors/timestamps, cancellation reason, and exact document references. In this design, draft means an incomplete request with the payment hold already active. Existing items with no request are Not required. + +New request metadata is justified for one payment covering several items and immutable history; it is **not a new attachment collection**. Upload new supporting files and proof to each covered item's existing `order_item_attachements` using `addAttachmentForm`. Retain exact stored filenames/document roles in request metadata, rather than assuming file position. Existing PO-document references identify their collection/record/filename. Render all browser file links via `/api/v1/files/…`. + +Respect the existing ten-file limit. Check capacity, preserve all unrelated files, and show an actionable error if capacity is exhausted. Never delete driver photos, older payments, supplier invoices, or unrelated attachments to make room. Use stable per-request document names and retry deduplication; older copies may only be replaced within that same document identity. Do not physically erase paid proof through this feature. + +Use the existing audit context for actor attribution and the PocketBase audit hook for per-item changes. Extend the hook to capture request records too; do not write manual audit notes for field changes. Normal uploads, metadata edits, and payment actions must not overwrite other keys in shared item JSON. + +## Enforcement and integration + +Business logic belongs in `src/lib/server/order-payment-service.ts`, with thin authenticated `/api/v1/…` routes. New browser calls require a validated browser session; use the app's existing order-writing access model for request/paid actions, without inventing a new accountant role. This permission choice is a proposed default to review before implementation. No anonymous mutation or exposed agent key. + +Use a shared server eligibility rule for readiness and dispatch, plus a PocketBase write hook enforcing the invariant for direct PB writes and automation. Check current stored payment/request evidence; a client setting a paid flag alone is not proof of settlement. Settlement must verify required proof and use a transaction/concurrency guard so stale request edits cannot release an unpaid item. Requests/amounts/items cannot be altered after settlement; corrections create a fresh request/history entry. + +Apply checks to: + +- Buyer Tasks `markGroupReady`. +- Collection Pool/Map mark-ready and driver-assignment routes. +- `markDueLinesReady` date automation: skip pending payment holds without treating them as system failures, retry on later ticks. +- Generic order-item status and direct browser PB writes; prevent dispatch/collection bypass of the readiness hold. +- Back-order and other paths that set collection eligibility. Splitting an unpaid, held item creates the remainder with its own draft payment hold (even if its supplier is not chosen yet), and flags the original pending request for explicit amount/membership reconciliation before payment. Do not duplicate a confirmed amount across the two requests or let the remainder silently escape the hold. New remainder/split lines must not inherit a settled payment indiscriminately; preserve original paid history and require an explicit new requirement when extra supplier payment is needed. These split defaults are implementation proposals to review, not separately confirmed operator rules. + +Manual endpoints return a clear conflict identifying the unpaid items. Batch actions keep the existing per-item result pattern: eligible rows may succeed, blocked rows stay unchanged and are reported. UI bulk results must make partial success visible. Do not block creating/sending POs, supplier correspondence, ordinary item edits, or setting forecast collection dates. Editing supplier, supplier quantity/cost, or membership of an active request requires explicit reconciliation of that request before it can be paid; do not silently change the supplier or amount under a payer's open form. + +Touching the process page requires migration of its legacy `pb-helpers.ts` data calls into the current service/API layer, per AGENTS.md. Read the installed Next.js guides before product code. New read-only MCP tools, if added later, must join Giellie's `READ_TOOLS`; v1 needs no new MCP capability. + +## Deployment and verification + +Prepare a deliberate schema migration for payment metadata/projections and the metadata-only request collection, with browser writes controlled through the server. Missing schema fails the new feature closed; legacy non-payment items retain their normal flow. No live schema changes are made during design work. Do not change the existing `item_status` select list or rely on the schema export as live truth. + +Publish the enforcement/audit hooks through the existing PB hooks git deployment process. Enable payment requests only after schema, service and hook capability checks pass, so no active payment hold exists without readiness enforcement. Verify against the running PB instance, including a direct-write rejection, before enabling the feature for operators. + +Required behavioural checks: unpaid item cannot become ready manually, automatically or through direct/API writes; pending-but-not-ready pool entry remains possible; supplier PO creation/sending continues; proof failure never releases a hold; paid item may subsequently be marked ready; no automatic workflow jump on payment; approval/payment overlap preserves both requirements; explicit batch selections and totals; retries/concurrent edits; attachment preservation/capacity; request cancellation/history; old items unaffected. Extend existing relevant tests rather than testing display text alone. + +## Scope and remaining review + +This is a customer-order-item payment queue, not a bank integration, accounting ledger, stock-purchase payment system, partial-payment scheduler, or automatic payment sender. Product UI should use ordinary payment language, without implementation details. + +The confirmed boundary is ready for collection. The separate payment state, explicit grouping, full-payment-only v1, cancellation rules, shared office permissions, and approval links rather than new decision buttons are proposed defaults, not additional claims of operator agreement. Review these with the operator before implementation. Exact schema/route names and transactional implementation are left to the builder as long as these behaviours and repository rules hold. diff --git a/e2e/order-payments.spec.ts b/e2e/order-payments.spec.ts new file mode 100644 index 0000000..269aea8 --- /dev/null +++ b/e2e/order-payments.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; + +const requests = [ + { id: "pay1", revision: 1, created: "2026-11-02 10:00:00.000Z", status: "pending", amount: 1250, order: "order1", supplier: "supplier1", companyName: "Acme Engineering", orderNumber: "ORD26110201", supplierName: "Fastener Supply", items: ["line1"], lines: [{ id: "line1", description: "M16 × 50 hex bolts", order_qty: 100, cost_price: 12.5 }], documents: [], purchaseOrders: [], reference: "PF-1042", note: "Supplier needs payment before collection", requested_by: "Buyer" }, + { id: "pay2", revision: 1, created: "2026-11-02 11:00:00.000Z", status: "draft", amount: 0, order: "order1", supplier: "supplier1", companyName: "Acme Engineering", orderNumber: "ORD26110201", supplierName: "Fastener Supply", items: ["line2"], lines: [{ id: "line2", description: "M16 washers", order_qty: 100, cost_price: 1 }], documents: [], purchaseOrders: [], reference: "", note: "", requested_by: "Buyer" }, +]; + +test("payment queue works on desktop and phone and requires proof", async ({ page }) => { + await page.addInitScript(() => { + const payload = btoa(JSON.stringify({ exp: 4102444800 })); + localStorage.setItem("pocketbase_auth", JSON.stringify({ token: `e30.${payload}.test`, record: { id: "test-user", collectionName: "users", name: "Test Buyer", email: "buyer@example.test" } })); + }); + await page.route("**/api/v1/order-payments", (route) => route.fulfill({ json: { enabled: true, requests, approvals: [{ id: "approval1", order: "order2", companyName: "Mining Workshop", orderNumber: "ORD26110202", supplierName: "Supply House", description: "M20 studs", cost: 100, selling: 90, markup: -10 }] } })); + await page.route("**/api/v1/order-payments/pay1/paid", (route) => route.fulfill({ json: { request: { ...requests[0], status: "paid" } } })); + await page.goto("/orders/payments-approvals?request=pay1"); + await expect(page.getByRole("heading", { name: "Payments and Approvals" })).toBeVisible(); + await expect(page.getByRole("table")).toHaveCount(2); + await expect(page.getByText("-10.0%")).toBeVisible(); + const markPaid = page.getByRole("button", { name: "Mark paid", exact: true }); + await expect(markPaid).toBeDisabled(); + await page.getByLabel("Payment reference", { exact: true }).fill("EFT-555"); + await page.getByLabel("Proof of payment", { exact: true }).setInputFiles({ name: "proof.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-test") }); + await expect(markPaid).toBeEnabled(); + await page.setViewportSize({ width: 1440, height: 1100 }); + await page.screenshot({ path: "test-results/order-payments-desktop.png", fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.screenshot({ path: "test-results/order-payments-mobile.png", fullPage: true }); + const sent = page.waitForRequest((r) => r.url().endsWith("/order-payments/pay1/paid") && r.method() === "POST"); + await markPaid.click(); + const request = await sent; + expect(request.postData()).toContain("EFT-555"); + expect(request.postData()).toContain("1250"); +}); diff --git a/package.json b/package.json index aaa6a19..a757b70 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,8 @@ "e2e": "playwright test", "e2e:ui": "playwright test --ui", "e2e:install": "playwright install --with-deps chromium", - "pb:hooks:publish": "git subtree push --prefix=pocketbase/pb_hooks http://forgejo-b82b4lpyyzyg40thdsjdd38i.163.245.214.68.sslip.io/David/WFDASH-PBHOOKS.git main" + "pb:hooks:publish": "git subtree push --prefix=pocketbase/pb_hooks http://forgejo-b82b4lpyyzyg40thdsjdd38i.163.245.214.68.sslip.io/David/WFDASH-PBHOOKS.git main", + "pb:migrate:order-payments": "npx tsx scripts/migrations/2026-11-02-order-payments.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.116.0", diff --git a/pocketbase/pb_hooks/DEPLOY b/pocketbase/pb_hooks/DEPLOY index eccd6f3..bb3a7a1 100644 --- a/pocketbase/pb_hooks/DEPLOY +++ b/pocketbase/pb_hooks/DEPLOY @@ -9,3 +9,5 @@ quotes.pb.js audit_log_utils.js audit_log.pb.js collection_return.pb.js +order_payment_utils.js +order_payments.pb.js diff --git a/pocketbase/pb_hooks/audit_log.pb.js b/pocketbase/pb_hooks/audit_log.pb.js index 82edd18..e71a411 100644 --- a/pocketbase/pb_hooks/audit_log.pb.js +++ b/pocketbase/pb_hooks/audit_log.pb.js @@ -19,7 +19,7 @@ onRecordCreateRequest((e) => { const audit = require(`${__hooks}/audit_log_utils.js`); e.next(); audit.log(e, "create", null, audit.toPlain(e.record)); -}, "quotes", "quote_items", "quote_attachments", "orders", "order_items", "delivery_notes"); +}, "quotes", "quote_items", "quote_attachments", "orders", "order_items", "delivery_notes", "supplier_payment_requests"); onRecordUpdateRequest((e) => { const audit = require(`${__hooks}/audit_log_utils.js`); @@ -32,11 +32,11 @@ onRecordUpdateRequest((e) => { } e.next(); if (before) audit.log(e, "update", before, audit.toPlain(e.record)); -}, "quotes", "quote_items", "orders", "order_items", "delivery_notes"); +}, "quotes", "quote_items", "orders", "order_items", "delivery_notes", "supplier_payment_requests"); onRecordDeleteRequest((e) => { const audit = require(`${__hooks}/audit_log_utils.js`); const before = audit.toPlain(e.record); e.next(); audit.log(e, "delete", before, null); -}, "quotes", "quote_items", "quote_attachments", "orders", "order_items", "delivery_notes"); +}, "quotes", "quote_items", "quote_attachments", "orders", "order_items", "delivery_notes", "supplier_payment_requests"); diff --git a/pocketbase/pb_hooks/audit_log_utils.js b/pocketbase/pb_hooks/audit_log_utils.js index 899e33d..810370e 100644 --- a/pocketbase/pb_hooks/audit_log_utils.js +++ b/pocketbase/pb_hooks/audit_log_utils.js @@ -29,6 +29,7 @@ const SNAPSHOT_FIELDS = { quote_items: ["description", "quantity", "cost_price", "selling_price", "markup", "supplier"], order_items: ["description", "quantity", "cost_price", "selling_price", "markup", "supplier", "item_status"], quote_attachments: ["filename", "kind"], + supplier_payment_requests: ["items", "status", "amount", "reference", "requested_by"], }; const MAX_VALUE_CHARS = 300; @@ -146,6 +147,16 @@ function resolveActor(e) { const auth = e.auth; const isSuper = !!(auth && auth.isSuperuser && auth.isSuperuser()); + // Model hooks have no request auth. A split inherits a system-authored hold. + if (!auth && e.paymentActor && !e.paymentActor.id) { + return { id: "", name: e.paymentActor.name, email: "", source: "system" }; + } + + // Custom payment commands are superuser-only and supply the verified server actor. + if (isSuper && e.paymentActor) { + return { id: e.paymentActor.id || "", name: e.paymentActor.name || "Unknown", email: "", source: "web" }; + } + if (auth && !isSuper) { return { id: auth.id, @@ -204,6 +215,8 @@ function resolveParent(app, collection, data) { case "orders": if (d.quote) refs.push("quote:" + d.quote); return { parentType: "order", parentId: d.id, label: d.order_number || "", refs: refs }; + case "supplier_payment_requests": + return { parentType: "order", parentId: d.order, label: d.reference || "Supplier payment", refs: refs }; case "order_items": if (d.order) { return { parentType: "order", parentId: d.order, label: d.description || "", refs: refs }; diff --git a/pocketbase/pb_hooks/order_payment_utils.js b/pocketbase/pb_hooks/order_payment_utils.js new file mode 100644 index 0000000..abff297 --- /dev/null +++ b/pocketbase/pb_hooks/order_payment_utils.js @@ -0,0 +1,457 @@ +/** Supplier-payment commands. All mutations run in one PB transaction. */ +const REQUESTS = "supplier_payment_requests"; +const ITEMS = "order_items"; +const REQUEST_FIELDS = ["order", "supplier", "items", "status", "amount", "paid_amount", "reference", "payment_reference", "note", "requested_by", "paid_by", "paid_at", "cancellation_reason", "documents", "item_snapshot", "revision", "history"]; +const BLOCKED_STAGES = ["ready_delivery", "assigned", "collected", "stock_verified", "picking", "ready_invoice", "invoiced", "stock_booked", "with_driver", "checked_in", "completed", "delivered", "delivery_note"]; +const NEW_HOLD_STAGES = ["pending", "processed", "po_generated", "collection_pool", "approval_needed", "approval_rejected"]; + +function fail(message, status) { + throw new ApiError(status || 409, message); +} +function plain(record) { return JSON.parse(JSON.stringify(record)); } +function array(value) { return Array.isArray(value) ? value : (value ? [value] : []); } +function text(value, max) { return String(value || "").trim().slice(0, max || 2000); } +function active(record) { return ["draft", "pending"].indexOf(record.getString("status")) !== -1; } +function money(value) { + const n = Number(value); + if (!isFinite(n) || n <= 0 || Math.round(n * 100) > 100000000000) fail("Enter a positive payment amount with at most two decimal places.", 422); + if (Math.abs(n * 100 - Math.round(n * 100)) > 0.00001) fail("Payment amounts must have at most two decimal places.", 422); + return Math.round(n * 100) / 100; +} +function lookup(app, name, id) { + try { return app.findRecordById(name, text(id, 64)); } + catch (_) { fail("Payment request or order item was not found. Refresh and try again.", 404); } +} +function assertSchema(app) { + const required = { order_items: ["payment_status", "payment_request", "ready_for_collection", "order_item_attachements"], supplier_payment_requests: REQUEST_FIELDS }; + Object.keys(required).forEach((name) => { + let collection; + try { collection = app.findCollectionByNameOrId(name); } + catch (_) { fail("Supplier payments need the PocketBase schema migration before use.", 503); } + required[name].forEach((field) => { + let found; + try { found = collection.fields.getByName(field); } catch (_) { found = null; } + if (!found) fail("Supplier payments need the PocketBase schema migration (" + name + "." + field + ").", 503); + }); + }); +} +function actor(body) { + if (!body.actor || !text(body.actor.id, 64) || !text(body.actor.name, 200)) fail("A verified payment actor is required.", 422); + return { id: text(body.actor.id, 64), name: text(body.actor.name, 200) }; +} +function auditEvent(e, app, person) { + return { app: app, auth: e.auth, requestInfo: () => e.requestInfo(), paymentActor: person || null }; +} +function save(e, app, record, audit, person) { + let before = null; + try { before = plain(app.findRecordById(record.collection().name, record.id)); } catch (_) { /* new record */ } + app.save(record); + if (audit) audit(auditEvent(e, app, person), before ? "update" : "create", before, plain(record)); + return record; +} +function eventHistory(request, kind, person, details) { + const history = array(plain(request).history); + history.push({ at: new Date().toISOString(), action: kind, actor: person || null, details: details || {} }); + request.set("history", history); +} +function bump(request) { request.set("revision", Number(request.get("revision") || 0) + 1); } +function requestItems(app, request) { + return array(plain(request).items).map((id) => lookup(app, ITEMS, id)); +} +function snapshot(items) { + const result = {}; + items.forEach((item) => { + result[item.id] = { + order: item.getString("order"), supplier: item.getString("supplier"), + quantity: item.get("quantity"), order_qty: item.get("order_qty"), cost_price: item.get("cost_price"), + }; + }); + return result; +} +function ensureMembership(request, items) { + items.forEach((item) => { + if (item.getString("payment_request") !== request.id || item.getString("payment_status") !== "required") { + fail("This payment's items changed. Refresh and reconcile the request."); + } + }); +} +function consistent(items, needSupplier) { + if (!items.length) fail("A payment request must cover at least one item.", 422); + const order = items[0].getString("order"); + const supplier = items[0].getString("supplier"); + if (!order) fail("Only customer-order items support this payment flow.", 422); + if (needSupplier && !supplier) fail("Choose a supplier on the item before confirming this payment request.", 422); + items.forEach((item) => { + if (item.getString("order") !== order || item.getString("supplier") !== supplier) fail("Payment items must belong to the same order and supplier. Remove changed items and reconcile the request."); + }); + return { order: order, supplier: supplier }; +} +function mutable(app, body) { + const request = lookup(app, REQUESTS, body.request_id); + if (!active(request)) fail("This request is already paid or cancelled. Refresh before continuing."); + if (Number(body.revision) !== Number(request.get("revision"))) fail("This payment request changed. Refresh and try again."); + return request; +} +function documentKey(doc) { return [doc.collection, doc.recordId, doc.filename, doc.role].join("|"); } +function mergeDocuments(existing, additions) { + const result = []; + const seen = {}; + array(existing).concat(array(additions)).forEach((doc) => { + if (!doc || ["support", "proof"].indexOf(doc.role) === -1 || !doc.collection || !doc.recordId || !doc.filename || /[\\/]/.test(doc.filename)) fail("Invalid payment document reference.", 422); + const normalized = { collection: text(doc.collection, 100), recordId: text(doc.recordId, 64), filename: text(doc.filename, 255), role: doc.role }; + const key = documentKey(normalized); + if (!seen[key]) { seen[key] = true; result.push(normalized); } + }); + return result; +} +function docExists(app, doc, item, candidate) { + if (doc.collection === ITEMS && doc.recordId === item.id) { + const source = candidate && candidate.id === item.id ? candidate : plain(item); + return array(source.order_item_attachements).indexOf(doc.filename) !== -1; + } + if (doc.role === "support" && doc.collection === "purchase_orders" && doc.recordId === item.getString("purchase_order")) { + let po; + try { po = plain(app.findRecordById("purchase_orders", doc.recordId)); } catch (_) { return false; } + return array(po.documents).concat(array(po.documents_history)).indexOf(doc.filename) !== -1; + } + return false; +} +function validateAdditions(app, docs, items) { + array(docs).forEach((doc) => { + if (!items.some((item) => docExists(app, doc, item))) fail("A payment document is missing or does not belong to the selected items.", 422); + }); +} +function evidence(app, docs, items, role, candidate) { + return items.every((item) => docs.some((doc) => doc.role === role && docExists(app, doc, item, candidate))); +} +function createDraft(e, app, item, person, audit, extra) { + const request = new Record(app.findCollectionByNameOrId(REQUESTS)); + request.set("order", item.getString("order")); + request.set("supplier", item.getString("supplier")); + request.set("items", [item.id]); + request.set("status", "draft"); + request.set("amount", 0); + request.set("revision", 1); + request.set("requested_by", person.name); + request.set("documents", []); + request.set("item_snapshot", snapshot([item])); + eventHistory(request, "required", person, extra); + save(e, app, request, audit, person); + item.set("payment_status", "required"); + item.set("payment_request", request.id); + save(e, app, item, audit, person); + return request; +} +function sameSnapshot(left, right) { + const ids = Object.keys(left).sort(); + if (ids.join("|") !== Object.keys(right || {}).sort().join("|")) return false; + return ids.every((id) => Object.keys(left[id]).every((key) => JSON.stringify(left[id][key]) === JSON.stringify(right[id][key]))); +} +function requirePayment(e, app, body, person, audit) { + const item = lookup(app, ITEMS, body.item_id); + if (item.getString("payment_status") === "required" && item.getString("payment_request")) { + const existing = lookup(app, REQUESTS, item.getString("payment_request")); + if (!active(existing) || array(plain(existing).items).indexOf(item.id) === -1) fail("Payment membership needs reconciliation."); + return existing; + } + if (!item.getString("order")) fail("This item has no customer order.", 422); + if (item.getBool("ready_for_collection")) fail("Return this item to not ready for collection before requiring payment."); + if (NEW_HOLD_STAGES.indexOf(item.getString("item_status")) === -1 || item.getString("drv_id") || item.getString("drv_status") === "assigned") fail("This item is already assigned, collected or closed; a new pre-collection payment hold cannot be added."); + return createDraft(e, app, item, person, audit); +} +function saveRequest(e, app, body, person, audit) { + const request = mutable(app, body); + const items = requestItems(app, request); + ensureMembership(request, items); + const group = consistent(items, false); + const amount = body.amount === undefined ? Number(request.get("amount") || 0) : Number(body.amount); + if (!isFinite(amount) || amount < 0) fail("Enter a valid payment amount.", 422); + if (amount > 0) money(amount); + const docs = mergeDocuments(plain(request).documents, body.documents); + validateAdditions(app, array(body.documents), items); + request.set("supplier", group.supplier); + request.set("amount", amount); + if (body.reference !== undefined) request.set("reference", text(body.reference, 200)); + if (body.note !== undefined) request.set("note", text(body.note, 4000)); + request.set("documents", docs); + request.set("item_snapshot", snapshot(items)); + request.set("status", amount > 0 && group.supplier && evidence(app, docs, items, "support") ? "pending" : "draft"); + eventHistory(request, "saved", person); + bump(request); + return save(e, app, request, audit, person); +} +function payRequest(e, app, body, person, audit) { + const request = mutable(app, body); + if (request.getString("status") !== "pending") fail("Complete and reconfirm the request details before marking it paid."); + const items = requestItems(app, request); + ensureMembership(request, items); + consistent(items, true); + if (!sameSnapshot(snapshot(items), plain(request).item_snapshot)) fail("Item supplier, quantity or cost changed. Reconfirm the request before payment."); + const amount = money(body.paid_amount); + if (Math.round(amount * 100) !== Math.round(Number(request.get("amount")) * 100)) fail("The paid amount must settle the full requested amount. Correct the pending request first.", 422); + const docs = mergeDocuments(plain(request).documents, body.documents); + validateAdditions(app, array(body.documents), items); + if (!evidence(app, docs, items, "support")) fail("Supporting payment documents are missing from one or more items.", 422); + if (!evidence(app, docs, items, "proof")) fail("Upload proof of payment for every item in this request before marking paid.", 422); + request.set("documents", docs); + request.set("status", "paid"); + request.set("paid_amount", amount); + request.set("payment_reference", text(body.payment_reference, 200)); + request.set("paid_by", person.name); + request.set("paid_at", new Date().toISOString()); + eventHistory(request, "paid", person, { amount: amount }); + bump(request); + save(e, app, request, audit, person); + items.forEach((item) => { + item.set("payment_status", "paid"); + save(e, app, item, audit, person); + }); + return request; +} +function cancelRequest(e, app, body, person, audit) { + const request = mutable(app, body); + const reason = text(body.reason, 2000); + if (!reason) fail("A reason is required to cancel a payment requirement.", 422); + const items = requestItems(app, request); + ensureMembership(request, items); + const removed = body.item_id ? items.filter((item) => item.id === body.item_id) : items; + if (!removed.length) fail("The item is not a member of this payment request.", 422); + const remaining = items.filter((item) => removed.every((r) => r.id !== item.id)); + eventHistory(request, "cancelled", person, { reason: reason, removed_items: removed.map((item) => item.id), previous_amount: request.get("amount") }); + request.set("cancellation_reason", reason); + request.set("status", remaining.length ? "draft" : "cancelled"); + // Keep membership on cancelled requests for historical lookup. + if (remaining.length) request.set("items", remaining.map((item) => item.id)); + request.set("amount", remaining.length ? 0 : request.get("amount")); + request.set("item_snapshot", snapshot(remaining.length ? remaining : items)); + bump(request); + save(e, app, request, audit, person); + removed.forEach((item) => { + item.set("payment_status", ""); + item.set("payment_request", ""); + save(e, app, item, audit, person); + }); + return request; +} +function combineRequests(e, app, body, person, audit) { + const ids = array(body.request_ids); + if (ids.length < 2 || ids.length > 100 || ids.some((id, index) => ids.indexOf(id) !== index)) fail("Select between two and 100 distinct unpaid requests.", 422); + const requests = ids.map((id) => mutable(app, { request_id: id, revision: body.revisions && body.revisions[id] })); + let items = []; + let docs = []; + requests.forEach((request) => { + const members = requestItems(app, request); + ensureMembership(request, members); + items = items.concat(members); + docs = mergeDocuments(docs, plain(request).documents); + }); + const group = consistent(items, true); + const merged = new Record(app.findCollectionByNameOrId(REQUESTS)); + merged.set("order", group.order); + merged.set("supplier", group.supplier); + merged.set("items", items.map((item) => item.id)); + merged.set("status", "draft"); + merged.set("revision", 1); + merged.set("requested_by", person.name); + merged.set("documents", docs); + merged.set("item_snapshot", snapshot(items)); + eventHistory(merged, "combined", person, { source_requests: ids }); + save(e, app, merged, audit, person); + requests.forEach((request) => { + request.set("status", "cancelled"); + request.set("cancellation_reason", "Combined into " + merged.id); + eventHistory(request, "combined_into", person, { request_id: merged.id }); + bump(request); + save(e, app, request, audit, person); + }); + items.forEach((item) => { + item.set("payment_request", merged.id); + save(e, app, item, audit, person); + }); + return merged; +} +function command(e, body, audit) { + if (!e.auth || !e.auth.isSuperuser || !e.auth.isSuperuser()) throw new ForbiddenError("Payment commands require the WFDASH server."); + if (!body || typeof body !== "object") fail("A payment command is required.", 422); + const person = actor(body); + let result; + e.app.runInTransaction((app) => { + assertSchema(app); + let request; + if (body.op === "require") request = requirePayment(e, app, body, person, audit); + else if (body.op === "save") request = saveRequest(e, app, body, person, audit); + else if (body.op === "paid") request = payRequest(e, app, body, person, audit); + else if (body.op === "cancel") request = cancelRequest(e, app, body, person, audit); + else if (body.op === "combine") request = combineRequests(e, app, body, person, audit); + else fail("Unknown payment command.", 422); + result = { request: plain(request) }; + }); + return result; +} +/** PB appends its ten-character suffix before the extension. */ +function storedDocument(files, originalName) { + const dot = originalName.lastIndexOf("."); + const stem = originalName.slice(0, dot).toLowerCase(); + const extension = originalName.slice(dot).toLowerCase(); + const matches = array(files).filter((filename) => { + const stored = String(filename).toLowerCase(); + if (stored.indexOf(stem + "_") !== 0 || stored.slice(-extension.length) !== extension) return false; + return /^[a-z0-9]{10}$/.test(stored.slice(stem.length + 1, stored.length - extension.length)); + }); + return matches.length ? matches[matches.length - 1] : ""; +} +function uploadDocument(e, body, audit) { + if (!e.auth || !e.auth.isSuperuser || !e.auth.isSuperuser()) throw new ForbiddenError("Payment uploads require the WFDASH server."); + const person = actor({ actor: { id: body.actor_id, name: body.actor_name } }); + const role = body.role; + if (["support", "proof"].indexOf(role) === -1) fail("Invalid payment document role.", 422); + const uploaded = e.findUploadedFiles("file"); + if (!uploaded || uploaded.length !== 1) fail("Upload exactly one payment document.", 422); + const file = uploaded[0]; + if (!Number(file.size) || Number(file.size) > 20 * 1024 * 1024) fail("Choose a non-empty payment file up to 20 MB.", 422); + const requestId = text(body.request_id, 64); + const originalName = String(file.originalName || ""); + const prefix = "pay_" + requestId + "_" + role + "_"; + if (originalName.indexOf(prefix) !== 0 || !/^[a-f0-9]{16}\.[a-z0-9]{1,8}$/.test(originalName.slice(prefix.length))) { + fail("Invalid payment upload identity. Refresh and retry the upload.", 422); + } + let result; + e.app.runInTransaction((app) => { + assertSchema(app); + const request = mutable(app, { request_id: requestId, revision: body.revision }); + const items = requestItems(app, request); + ensureMembership(request, items); + // All capacity checks precede all writes. A concurrent upload or settlement + // uses this same database transaction and sees committed attachment names. + const planned = items.map((item) => { + const current = array(plain(item).order_item_attachements); + const existing = storedDocument(current, originalName); + if (!existing && current.length >= 10) fail("Item " + item.id + " already has 10 attachments. Resolve capacity without removing payment history."); + return { item: item, existing: existing }; + }); + const documents = planned.map((entry) => { + let stored = entry.existing; + if (!stored) { + entry.item.set("order_item_attachements+", [file]); + save(e, app, entry.item, audit, person); + stored = storedDocument(plain(entry.item).order_item_attachements, originalName); + if (!stored) fail("The payment upload could not be verified. Payment remains pending."); + } + return { collection: ITEMS, recordId: entry.item.id, filename: stored, role: role }; + }); + result = { documents: documents }; + }); + return result; +} +function protectProjection(info) { + const body = info.body || {}; + if (Object.prototype.hasOwnProperty.call(body, "payment_status") || Object.prototype.hasOwnProperty.call(body, "payment_request")) { + throw new ForbiddenError("Payment state is managed by Payments and Approvals; use its payment actions."); + } +} +function protectPaidProofs(app, candidate) { + if (!candidate.id) return; + try { app.findCollectionByNameOrId(REQUESTS); } catch (_) { return; } + const paid = app.findRecordsByFilter(REQUESTS, + 'status = "paid" && (items ?= {:item} || documents ~ {:item})', "", 0, 0, { item: candidate.id }); + const attached = array(candidate.order_item_attachements); + paid.forEach((request) => { + array(plain(request).documents).forEach((doc) => { + if (doc.role === "proof" && doc.collection === ITEMS && doc.recordId === candidate.id && attached.indexOf(doc.filename) === -1) { + fail("Historical proof of payment must remain attached to this item."); + } + }); + }); +} +function checkReadiness(app, candidate) { + protectPaidProofs(app, candidate); + const state = candidate.payment_status || ""; + const requestId = candidate.payment_request || ""; + if (!state && !requestId) return; + let request; + try { request = app.findRecordById(REQUESTS, requestId); } + catch (_) { fail("Payment evidence is unavailable. Reconcile the payment request before collection."); } + const docs = array(plain(request).documents); + if (state === "paid" && request.getString("status") === "paid" && array(plain(request).items).indexOf(candidate.id) !== -1) { + const item = app.findRecordById(ITEMS, candidate.id); + if (!evidence(app, docs, [item], "proof", candidate)) fail("Paid proof of payment must remain attached to this item."); + const expected = (plain(request).item_snapshot || {})[candidate.id]; + if (!expected || expected.order !== candidate.order || expected.supplier !== candidate.supplier) { + fail("The paid item's order or supplier changed. Create a fresh payment requirement before changing them."); + } + const preCollection = NEW_HOLD_STAGES.concat(["assigned", "ready_delivery"]); + if (preCollection.indexOf(candidate.item_status) !== -1 || preCollection.indexOf(item.getString("item_status")) !== -1) { + if (["order_qty", "cost_price"].some((field) => JSON.stringify(expected[field]) !== JSON.stringify(candidate[field]))) { + fail("The paid item's supplier quantity or cost changed. Create a fresh payment requirement and reconcile it before collection."); + } + } + return; + } + if (candidate.ready_for_collection || candidate.drv_id || ["assigned", "en_route", "collected"].indexOf(candidate.drv_status) !== -1 || BLOCKED_STAGES.indexOf(candidate.item_status) !== -1) { + fail("Payment needed: mark the supplier payment paid with proof before this item can be ready for collection or dispatched."); + } +} +function invalidate(e, app, item, audit, person) { + if (item.getString("payment_status") !== "required" || !item.getString("payment_request")) return; + const request = lookup(app, REQUESTS, item.getString("payment_request")); + if (!active(request)) fail("Payment membership needs reconciliation."); + request.set("status", "draft"); + request.set("amount", 0); + eventHistory(request, "item_changed", person, { item_id: item.id }); + bump(request); + save(e, app, request, audit, person); +} +function inheritSplit(e, record, audit) { + let details = plain(record).order_item_details; + if (typeof details === "string") { try { details = JSON.parse(details); } catch (_) { details = null; } } + const marker = details && (details.split || details.shortCollection); + if (!marker || !marker.sourceItemId) return; + let source; + try { source = e.app.findRecordById(ITEMS, marker.sourceItemId); } catch (_) { return; } + if (source.getString("payment_status") !== "required") return; + if (source.getString("order") !== record.getString("order")) fail("Split payment source must belong to the same order."); + if (record.getString("payment_status") === "required") return; + assertSchema(e.app); + const person = { id: "", name: "Payment hold inherited from split" }; + createDraft(e, e.app, record, person, audit, { source_item_id: source.id }); + invalidate(e, e.app, source, audit, person); +} +function protectDeletion(app, record) { + if (record.getString("payment_request") || record.getString("payment_status")) throw new ForbiddenError("Payment item history and documents cannot be deleted."); + let collection; + try { collection = app.findCollectionByNameOrId(REQUESTS); } catch (_) { return; } + if (!collection) return; + const history = app.findRecordsByFilter(REQUESTS, + "items ?= {:item} || documents ~ {:item} || history ~ {:item}", "", 1, 0, { item: record.id }); + if (history.length) throw new ForbiddenError("This item's payment history and documents must be preserved. Cancel the item instead of deleting it."); +} +function protectModelCreate(e, audit) { + const originalApp = e.app; + try { + return originalApp.runInTransaction((app) => { + e.app = app; + e.next(); + inheritSplit(e, e.record, audit); + checkReadiness(app, plain(e.record)); + }); + } finally { e.app = originalApp; } +} +function protectItemUpdate(e, audit) { + protectProjection(e.requestInfo()); + const originalApp = e.app; + try { + return originalApp.runInTransaction((app) => { + e.app = app; + const stored = app.findRecordById(ITEMS, e.record.id); + // A read-before-payment mutation must not overwrite the newer hold. + e.record.set("payment_status", stored.getString("payment_status")); + e.record.set("payment_request", stored.getString("payment_request")); + checkReadiness(app, plain(e.record)); + const watched = ["order", "supplier", "quantity", "order_qty", "cost_price"]; + const changed = watched.some((field) => JSON.stringify(stored.get(field)) !== JSON.stringify(e.record.get(field))); + if (changed && stored.getString("payment_status") === "required") invalidate(e, app, stored, audit, null); + return e.next(); + }); + } finally { e.app = originalApp; } +} +module.exports = { command: command, uploadDocument: uploadDocument, assertSchema: assertSchema, protectProjection: protectProjection, checkReadiness: checkReadiness, inheritSplit: inheritSplit, protectItemUpdate: protectItemUpdate, protectDeletion: protectDeletion, protectModelCreate: protectModelCreate, plain: plain }; diff --git a/pocketbase/pb_hooks/order_payments.pb.js b/pocketbase/pb_hooks/order_payments.pb.js new file mode 100644 index 0000000..2e164a5 --- /dev/null +++ b/pocketbase/pb_hooks/order_payments.pb.js @@ -0,0 +1,57 @@ +/** Supplier payment commands and direct-write readiness enforcement. */ +routerAdd("GET", "/api/wfdash/order-payments-capability", (e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + payments.assertSchema(e.app); + return e.json(200, { version: 2 }); +}, $apis.requireSuperuserAuth()); + +routerAdd("POST", "/api/wfdash/order-payments", (e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + const audit = require(`${__hooks}/audit_log_utils.js`); + const result = payments.command(e, e.requestInfo().body, audit.log); + return e.json(200, result); +}, $apis.requireSuperuserAuth()); + +routerAdd("POST", "/api/wfdash/order-payments-document", (e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + const audit = require(`${__hooks}/audit_log_utils.js`); + const result = payments.uploadDocument(e, e.requestInfo().body, audit.log); + return e.json(200, result); +}, $apis.requireSuperuserAuth()); + +onRecordUpdateRequest((e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + const audit = require(`${__hooks}/audit_log_utils.js`); + return payments.protectItemUpdate(e, audit.log); +}, "order_items"); + +onRecordCreateRequest((e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + payments.protectProjection(e.requestInfo()); + return e.next(); +}, "order_items"); + +onRecordCreate((e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + const audit = require(`${__hooks}/audit_log_utils.js`); + return payments.protectModelCreate(e, audit.log); +}, "order_items"); + +// Also covers internal hook/model writes. Public API projections are protected +// above; internal payment commands update the request before their item writes. +onRecordUpdate((e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + payments.checkReadiness(e.app, payments.plain(e.record)); + return e.next(); +}, "order_items"); + +// These records are immutable to ordinary record API callers, including an +// admin client. Only the transactional command above may change them. +onRecordCreateRequest((e) => { throw new ForbiddenError("Use the supplier payment command."); }, "supplier_payment_requests"); +onRecordUpdateRequest((e) => { throw new ForbiddenError("Use the supplier payment command."); }, "supplier_payment_requests"); +onRecordDeleteRequest((e) => { throw new ForbiddenError("Payment history cannot be deleted."); }, "supplier_payment_requests"); +onRecordDeleteRequest((e) => { + const payments = require(`${__hooks}/order_payment_utils.js`); + payments.protectDeletion(e.app, e.record); + return e.next(); +}, "order_items"); diff --git a/scripts/migrations/2026-11-02-order-payments.ts b/scripts/migrations/2026-11-02-order-payments.ts new file mode 100644 index 0000000..ae6c3a7 --- /dev/null +++ b/scripts/migrations/2026-11-02-order-payments.ts @@ -0,0 +1,58 @@ +/** + * Supplier-payment metadata and item projections. Manual live step; idempotent. + * Documents remain on order_items.order_item_attachements. API mutations are + * null: the superuser-only transactional hook command is the only writer. + */ +import "./_env-loader"; +import PocketBase from "pocketbase"; + +type Field = Record & { name: string }; +const relation = (name: string, collectionId: string, maxSelect = 1): Field => ({ + name, type: "relation", collectionId, maxSelect, cascadeDelete: false, required: false, +}); +async function main() { + const { PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } = process.env; + if (!PB_ADMIN_EMAIL || !PB_ADMIN_PASSWORD) throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD."); + const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090"); + await pb.collection("_superusers").authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD); + const [orders, items, suppliers] = await Promise.all([ + pb.collections.getOne("orders"), pb.collections.getOne("order_items"), pb.collections.getOne("suppliers"), + ]); + const fields: Field[] = [ + relation("order", orders.id), relation("supplier", suppliers.id), relation("items", items.id, 100), + { name: "status", type: "select", maxSelect: 1, values: ["draft", "pending", "paid", "cancelled"] }, + ...["amount", "paid_amount", "revision"].map((name) => ({ name, type: "number", min: 0, required: false })), + ...["reference", "payment_reference", "requested_by", "paid_by"].map((name) => ({ name, type: "text", max: 200, required: false })), + { name: "note", type: "text", max: 4000 }, + { name: "cancellation_reason", type: "text", max: 2000 }, + { name: "paid_at", type: "date", required: false }, + ...["documents", "item_snapshot", "history"].map((name) => ({ name, type: "json", maxSize: 5000000, required: false })), + { name: "created", type: "autodate", onCreate: true, onUpdate: false }, + { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, + ]; + const all = await pb.collections.getFullList(); + let requests = all.find((c) => c.name === "supplier_payment_requests"); + if (!requests) { + requests = await pb.collections.create({ + name: "supplier_payment_requests", type: "base", fields, + listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, + indexes: ["CREATE INDEX idx_supplier_payment_requests_order ON supplier_payment_requests (`order`, status)"], + }); + console.log("Created supplier_payment_requests."); + } else { + const existing = new Set(requests.fields.map((f: { name: string }) => f.name)); + const missing = fields.filter((f) => !existing.has(f.name)); + if (missing.length) requests = await pb.collections.update(requests.id, { fields: [...requests.fields, ...missing] }); + await pb.collections.update(requests.id, { listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null }); + console.log("Verified supplier_payment_requests fields and locked API rules."); + } + const projections: Field[] = [ + { name: "payment_status", type: "select", maxSelect: 1, values: ["required", "paid"], required: false }, + relation("payment_request", requests.id), + ]; + const existing = new Set(items.fields.map((f: { name: string }) => f.name)); + const missing = projections.filter((f) => !existing.has(f.name)); + if (missing.length) await pb.collections.update(items.id, { fields: [...items.fields, ...missing] }); + console.log("Supplier payment schema ready. Publish the payment hooks before enabling the app feature."); +} +main().catch((error) => { console.error("Supplier payment migration failed:", error); process.exitCode = 1; }); diff --git a/scripts/smoke/order-payments-hooks.mjs b/scripts/smoke/order-payments-hooks.mjs new file mode 100644 index 0000000..d960021 --- /dev/null +++ b/scripts/smoke/order-payments-hooks.mjs @@ -0,0 +1,198 @@ +/** + * Real PocketBase hook smoke test. Never connects to the live instance. + * Usage: POCKETBASE_TEST_BINARY=/absolute/path/pocketbase node scripts/smoke/order-payments-hooks.mjs + * Downloads/installations are deliberately left to the caller. Uses the pinned + * deployment version (0.39.8), an ephemeral port, and a temporary data folder. + */ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { spawn, spawnSync } from "node:child_process"; +import PocketBase from "pocketbase"; +import { createHash } from "node:crypto"; + +const binary = process.env.POCKETBASE_TEST_BINARY; +if (!binary || !path.isAbsolute(binary)) throw new Error("Set POCKETBASE_TEST_BINARY to an absolute path to PocketBase 0.39.8."); +const version = spawnSync(binary, ["--version"], { encoding: "utf8" }); +assert.match(version.stdout || "", /0\.39\.8/, "Use the deployed PocketBase version, 0.39.8."); +const directory = await fs.mkdtemp(path.join(os.tmpdir(), "wfdash-payment-hooks-")); +const port = await new Promise((resolve, reject) => { + const socket = net.createServer(); + socket.once("error", reject); + socket.listen(0, "127.0.0.1", () => { + const address = socket.address(); + socket.close(() => resolve(address.port)); + }); +}); +const origin = `http://127.0.0.1:${port}`; +const data = path.join(directory, "data"); +const hooks = path.join(directory, "hooks"); +await fs.mkdir(hooks); +for (const name of ["order_payment_utils.js", "order_payments.pb.js", "audit_log_utils.js"]) { + await fs.copyFile(path.resolve("pocketbase/pb_hooks", name), path.join(hooks, name)); +} +const email = "payment-test@example.test"; +const password = "LocalPaymentTestPassword123"; +const createAdmin = spawnSync(binary, ["superuser", "create", email, password, `--dir=${data}`], { encoding: "utf8" }); +assert.equal(createAdmin.status, 0, createAdmin.stderr); +const server = spawn(binary, ["serve", `--http=127.0.0.1:${port}`, `--dir=${data}`, `--hooksDir=${hooks}`, "--hooksWatch=false"], { stdio: ["ignore", "pipe", "pipe"] }); +let logs = ""; +server.stdout.on("data", (chunk) => { logs += chunk; }); +server.stderr.on("data", (chunk) => { logs += chunk; }); +try { + let healthy = false; + for (let attempt = 0; attempt < 100; attempt++) { + try { healthy = (await fetch(`${origin}/api/health`)).ok; } catch { /* server is starting */ } + if (healthy) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.ok(healthy, `Local PocketBase failed to start: ${logs}`); + const pb = new PocketBase(origin); + pb.autoCancellation(false); + await pb.collection("_superusers").authWithPassword(email, password); + for (const name of ["orders", "suppliers", "purchase_orders"]) { + await pb.collections.create({ name, type: "base", fields: [ + { name: "label", type: "text" }, { name: "documents", type: "file", maxSelect: 10 }, { name: "documents_history", type: "file", maxSelect: 10 }, + ] }); + } + const order = await pb.collection("orders").create({ label: "Local order" }); + const supplier = await pb.collection("suppliers").create({ label: "Local supplier" }); + await pb.collections.create({ name: "order_items", type: "base", fields: [ + ...["order", "supplier", "purchase_order", "item_status", "drv_status", "drv_id", "description"].map((name) => ({ name, type: "text" })), + ...["quantity", "order_qty", "cost_price"].map((name) => ({ name, type: "number" })), + { name: "order_item_details", type: "json", maxSize: 100000 }, + { name: "ready_for_collection", type: "bool" }, + { name: "order_item_attachements", type: "file", maxSelect: 10, maxSize: 10485760 }, + ] }); + await pb.collections.create({ name: "audit_log", type: "base", fields: [ + ...["collection_name", "record_id", "parent_type", "parent_id", "action", "label", "actor_id", "actor_name", "actor_email", "source", "refs"].map((name) => ({ name, type: "text" })), + { name: "changes", type: "json", maxSize: 5000000 }, + ] }); + const migrate = spawnSync("npx", ["tsx", "scripts/migrations/2026-11-02-order-payments.ts"], { + cwd: process.cwd(), encoding: "utf8", + env: { ...process.env, NEXT_PUBLIC_POCKETBASE_URL: origin, PB_URL: origin, PB_ADMIN_EMAIL: email, PB_ADMIN_PASSWORD: password }, + }); + assert.equal(migrate.status, 0, migrate.stderr || migrate.stdout); + const migrateAgain = spawnSync("npx", ["tsx", "scripts/migrations/2026-11-02-order-payments.ts"], { + cwd: process.cwd(), encoding: "utf8", + env: { ...process.env, NEXT_PUBLIC_POCKETBASE_URL: origin, PB_URL: origin, PB_ADMIN_EMAIL: email, PB_ADMIN_PASSWORD: password }, + }); + assert.equal(migrateAgain.status, 0, "Migration must be idempotent."); + const actor = { id: "localtest", name: "Local Test" }; + const command = async (body) => (await pb.send("/api/wfdash/order-payments", { method: "POST", body: { actor, ...body } })).request; + const rejects = async (fn, pattern) => { + try { await fn(); assert.fail("Unexpected success"); } + catch (error) { if (error.code === "ERR_ASSERTION") throw error; assert.match(error.response?.message || error.message, pattern); } + }; + assert.equal((await pb.send("/api/wfdash/order-payments-capability", {})).version, 2); + const base = { order: order.id, supplier: supplier.id, item_status: "processed", quantity: 4, order_qty: 10, cost_price: 3 }; + let item = await pb.collection("order_items").create({ ...base, item_status: "approval_needed", description: "Local item" }); + const request = await command({ op: "require", item_id: item.id }); + assert.equal(request.status, "draft"); + assert.equal((await command({ op: "require", item_id: item.id })).id, request.id, "Require must be idempotent."); + item = await pb.collection("order_items").getOne(item.id); + assert.equal(item.payment_status, "required"); + assert.equal(item.item_status, "approval_needed"); + await rejects(() => pb.collection("order_items").update(item.id, { ready_for_collection: true }), /payment/i); + await rejects(() => pb.collection("order_items").update(item.id, { payment_status: "paid" }), /payment/i); + await rejects(() => pb.collection("order_items").update(item.id, { item_status: "assigned", drv_id: "driver1" }), /payment/i); + await pb.collection("order_items").update(item.id, { item_status: "po_generated", purchase_order: "localpo" }); + await pb.collection("order_items").update(item.id, { item_status: "collection_pool", ready_for_collection: false }); + const files = new FormData(); + files.append("order_item_attachements+", new Blob(["invoice"]), "invoice.pdf"); + files.append("order_item_attachements+", new Blob(["proof"]), "proof.pdf"); + item = await pb.collection("order_items").update(item.id, files); + const documents = item.order_item_attachements.map((filename) => ({ collection: "order_items", recordId: item.id, filename, role: filename.startsWith("invoice") ? "support" : "proof" })); + const pending = await command({ op: "save", request_id: request.id, revision: request.revision, amount: 30, reference: "INV1", documents: documents.filter((doc) => doc.role === "support") }); + assert.equal(pending.status, "pending"); + await rejects(() => command({ op: "paid", request_id: pending.id, revision: pending.revision, paid_amount: 30, documents: [] }), /proof/i); + const paid = await command({ op: "paid", request_id: pending.id, revision: pending.revision, paid_amount: 30, payment_reference: "EFT1", documents: documents.filter((doc) => doc.role === "proof") }); + assert.equal(paid.status, "paid"); + item = await pb.collection("order_items").getOne(item.id); + assert.equal(item.payment_status, "paid"); + assert.equal(item.ready_for_collection, false, "Paying must not mark ready."); + await rejects(() => pb.collection("order_items").update(item.id, { cost_price: 8 }), /changed|payment/i); + await rejects(() => pb.collection("order_items").update(item.id, { supplier: "supplier2" }), /changed|payment/i); + await pb.collection("order_items").update(item.id, { ready_for_collection: true }); + await rejects(() => pb.collection("order_items").update(item.id, { "order_item_attachements-": documents.find((doc) => doc.role === "proof").filename }), /proof|document/i); + const source = await pb.collection("order_items").create(base); + const sourceRequest = await command({ op: "require", item_id: source.id }); + let remainder = await pb.collection("order_items").create({ order: order.id, item_status: "pending", quantity: 1, order_qty: 1, cost_price: 3, order_item_details: { split: { sourceItemId: source.id } } }); + remainder = await pb.collection("order_items").getOne(remainder.id); + assert.equal(remainder.payment_status, "required", "JSON-field split marker must inherit a hold."); + const changed = await pb.collection("supplier_payment_requests").getOne(sourceRequest.id); + await command({ op: "cancel", request_id: changed.id, revision: changed.revision, reason: "Entered in error" }); + await rejects(() => pb.collection("order_items").delete(source.id), /history|payment/i); + // Regression: storage and deduplication are serialized with settlement by PB. + const uploadDocument = async (r, role, content) => { + const hash = createHash("sha256").update(content).digest("hex").slice(0, 16); + const form = new FormData(); + form.append("request_id", r.id); + form.append("revision", String(r.revision)); + form.append("role", role); + form.append("actor_id", actor.id); + form.append("actor_name", actor.name); + form.append("file", new Blob([content]), `pay_${r.id}_${role}_${hash}.pdf`); + return (await pb.send("/api/wfdash/order-payments-document", { method: "POST", body: form })).documents; + }; + const payItem = async (row) => { + let r = await command({ op: "require", item_id: row.id }); + const support = await uploadDocument(r, "support", "Atomic supplier invoice"); + r = await command({ op: "save", request_id: r.id, revision: r.revision, amount: 30, reference: "INV-atomic", documents: support }); + const proof = await uploadDocument(r, "proof", "Atomic payment proof"); + return command({ op: "paid", request_id: r.id, revision: r.revision, paid_amount: 30, payment_reference: "EFT-atomic", documents: proof }); + }; + // Match splitOrderItemRemainder's create-then-shrink ordering exactly. + const splitSource = await pb.collection("order_items").create({ ...base, quantity: 20, order_qty: 10 }); + const splitPayment = await payItem(splitSource); + const paidRemainder = await pb.collection("order_items").create({ order: order.id, item_status: "pending", quantity: 10, order_qty: 10, cost_price: 3, order_item_details: { split: { sourceItemId: splitSource.id } } }); + const shrunk = await pb.collection("order_items").update(splitSource.id, { quantity: 10, order_item_details: { split: { remainderItemId: paidRemainder.id } } }); + assert.equal(shrunk.quantity + paidRemainder.quantity, 20, "Paid split must conserve the original customer quantity."); + assert.equal(paidRemainder.payment_status, "", "A paid source must not automatically require or settle its new remainder."); + await rejects(() => pb.collection("order_items").update(splitSource.id, { order_qty: 20 }), /changed|payment/i); + const oldProof = splitPayment.documents.find((doc) => doc.role === "proof"); + const withoutOldProof = () => pb.collection("order_items").update(splitSource.id, { "order_item_attachements-": oldProof.filename }); + let freshRequirement = await command({ op: "require", item_id: splitSource.id }); + await rejects(withoutOldProof, /proof/i); + await command({ op: "cancel", request_id: freshRequirement.id, revision: freshRequirement.revision, reason: "Payment correction cancelled" }); + await rejects(withoutOldProof, /proof/i); + const secondPayment = await payItem(await pb.collection("order_items").getOne(splitSource.id)); + assert.notEqual(secondPayment.id, splitPayment.id); + await rejects(withoutOldProof, /proof/i); + assert.equal((await fetch(`${origin}/api/files/order_items/${splitSource.id}/${oldProof.filename}`)).status, 200); + + const groupedA = await pb.collection("order_items").create(base); + const groupedB = await pb.collection("order_items").create(base); + const groupedReqA = await command({ op: "require", item_id: groupedA.id }); + const groupedReqB = await command({ op: "require", item_id: groupedB.id }); + let grouped = await command({ op: "combine", request_ids: [groupedReqA.id, groupedReqB.id], revisions: { [groupedReqA.id]: groupedReqA.revision, [groupedReqB.id]: groupedReqB.revision } }); + const groupedSupport = await uploadDocument(grouped, "support", "One invoice for both selected items"); + grouped = await command({ op: "save", request_id: grouped.id, revision: grouped.revision, amount: 60, documents: groupedSupport }); + const [overlapA, overlapB] = await Promise.all([ + uploadDocument(grouped, "proof", "The same overlapping proof"), + uploadDocument(grouped, "proof", "The same overlapping proof"), + ]); + assert.deepEqual(overlapA, overlapB, "Overlapping uploads must return exactly the same stored names."); + for (const member of [groupedA, groupedB]) { + const files = (await pb.collection("order_items").getOne(member.id)).order_item_attachements; + assert.equal(files.length, 2, "Grouped concurrent uploads must append one support and one proof, without duplicates."); + assert.equal(files.filter((name) => name.startsWith(`pay_${grouped.id}_proof_`)).length, 1); + } + await command({ op: "paid", request_id: grouped.id, revision: grouped.revision, paid_amount: 60, payment_reference: "EFT-grouped", documents: overlapA }); + await rejects(() => uploadDocument(grouped, "proof", "The same overlapping proof"), /paid|changed|refresh/i); + for (const member of [groupedA, groupedB]) { + assert.equal((await pb.collection("order_items").getOne(member.id)).order_item_attachements.length, 2, "An upload attempted after settlement must append nothing."); + } + const audits = await pb.collection("audit_log").getFullList(); + assert.ok(audits.some((row) => row.actor_name === "Local Test" && row.collection_name === "supplier_payment_requests")); + console.log("PASS: real PB 0.39.8 payment commands, readiness/dispatch guards, proof, split inheritance, immutable historical proof, paid split conservation, overlapping upload deduplication and migration idempotence."); +} catch (error) { + console.error(logs.slice(-8000)); + throw error; +} finally { + server.kill("SIGTERM"); + if (server.exitCode === null && server.signalCode === null) await new Promise((resolve) => server.once("exit", resolve)); + await fs.rm(directory, { recursive: true, force: true }); +} diff --git a/src/app/(app)/orders/[id]/process/page.tsx b/src/app/(app)/orders/[id]/process/page.tsx index 008dc45..9ddacb6 100644 --- a/src/app/(app)/orders/[id]/process/page.tsx +++ b/src/app/(app)/orders/[id]/process/page.tsx @@ -38,7 +38,6 @@ import { Lock, Unlock, Paperclip, - MessageSquare, Split, Package, Plus, @@ -46,14 +45,9 @@ import { } from "lucide-react"; import { toast } from "sonner"; import getPocketBase from "@/lib/pocketbase"; -import { - getOrder, - getOrderItems, - getAllSuppliers, - getActiveStockItems, - getQuoteItems, - formatCurrency, -} from "@/lib/pb-helpers"; +import { formatCurrency } from "@/lib/currency"; +import { paymentFetch } from "@/lib/order-payment-client"; +import { OrderItemPayment } from "@/components/orders/order-item-payment"; import { cn } from "@/lib/utils"; import { FulfillFromStockCell } from "@/components/fulfill-from-stock-cell"; import { MarginApprovalDialog } from "@/components/margin-approval-dialog"; @@ -67,7 +61,7 @@ import { withOrderAiReview, } from "@/lib/order-assistant-save"; import { readShortCollectionMarker, type ShortCollectionMarker } from "@/lib/short-collection"; -import { AttachmentList, type MatchedAttachment } from "@/components/orders/attachment-list"; +import { type MatchedAttachment } from "@/components/orders/attachment-list"; import { QuoteLineContext, QuoteLineNoMatch, @@ -454,19 +448,12 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str const fetchAll = useCallback(async () => { setLoading(true); try { - const [o, supps, stock] = await Promise.all([ - getOrder(id), - getAllSuppliers(), - // Non-fatal — we wrap it in a try/catch below so a - // stock_items collection that doesn't exist yet - // (e.g. on a brand-new PB install that hasn't run - // the migration) doesn't break the whole page. - getActiveStockItems().catch(() => [] as StockItem[]), - ]); + const data = await paymentFetch<{ order: Order; items: OrderItem[]; suppliers: Supplier[]; stock: StockItem[]; quoteItems: QuoteItem[] }>(`/api/v1/orders/${id}/process-data`); + const o = data.order; setOrder(o); - setSuppliers(supps); - setStockItems(stock); - const orderItems = await getOrderItems(id); + setSuppliers(data.suppliers); + setStockItems(data.stock); + const orderItems = data.items; setItems(orderItems); const next: Record = {}; for (const it of orderItems) { @@ -474,14 +461,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str } setEdits(next); - // Pull matched quote items for inline per-item preview. - const qid = o.quote || o.potential_quote; - if (qid) { - const qItems = await getQuoteItems(qid); - setMatchedQuoteItems(qItems); - } else { - setMatchedQuoteItems([]); - } + setMatchedQuoteItems(data.quoteItems); // Quote-level + per-line attachments, fetched in parallel via // the dedicated server route. See /api/v1/orders/[id]/matched- @@ -922,7 +902,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str ); // Refresh PB state so the operator sees the // current (non-stock-booked) order_item row. - const fresh = await getOrderItems(id); + const fresh = (await paymentFetch<{ items: OrderItem[] }>(`/api/v1/orders/${id}/process-data?itemsOnly=1`)).items; setItems(fresh); setEdits((prev) => { const next = { ...prev }; @@ -1029,7 +1009,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str ); } - const fresh = await getOrderItems(id); + const fresh = (await paymentFetch<{ items: OrderItem[] }>(`/api/v1/orders/${id}/process-data?itemsOnly=1`)).items; setItems(fresh); setEdits((prev) => { const next = { ...prev }; @@ -1094,7 +1074,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str throw new Error(result.error || `Approval request failed (${response.status})`); } - const fresh = await getOrderItems(id); + const fresh = (await paymentFetch<{ items: OrderItem[] }>(`/api/v1/orders/${id}/process-data?itemsOnly=1`)).items; setItems(fresh); setEdits((prev) => { const next = { ...prev }; @@ -2109,6 +2089,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str onBackOrder={(ready) => void backOrderItem(item.id, ready)} backOrdering={backOrderingId === item.id} /> + setItems((prev) => prev.map((row) => row.id === item.id ? { ...row, ...patch } : row))} /> ); })} diff --git a/src/app/(app)/orders/payments-approvals/page.tsx b/src/app/(app)/orders/payments-approvals/page.tsx new file mode 100644 index 0000000..f5d4a5d --- /dev/null +++ b/src/app/(app)/orders/payments-approvals/page.tsx @@ -0,0 +1,5 @@ +import { PaymentsApprovalsPage } from "@/components/orders/payments-approvals-page"; +export default async function Page({ searchParams }: { searchParams: Promise<{ request?: string }> }) { + const params = await searchParams; + return ; +} diff --git a/src/app/api/v1/order-payments/[id]/documents/route.ts b/src/app/api/v1/order-payments/[id]/documents/route.ts new file mode 100644 index 0000000..d4f687b --- /dev/null +++ b/src/app/api/v1/order-payments/[id]/documents/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { uploadPaymentDocument } from "@/lib/server/order-payment-service"; +import { paymentRoute, paymentUpload } from "@/lib/server/order-payment-route"; +export const runtime = "nodejs"; +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return paymentRoute(req, async (actor) => { + const { id } = await params; + return { request: await uploadPaymentDocument(id, await paymentUpload(req), actor) }; + }); +} diff --git a/src/app/api/v1/order-payments/[id]/paid/route.ts b/src/app/api/v1/order-payments/[id]/paid/route.ts new file mode 100644 index 0000000..0a3ac27 --- /dev/null +++ b/src/app/api/v1/order-payments/[id]/paid/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; +import { markRequestPaid } from "@/lib/server/order-payment-service"; +import { paymentRoute, paymentUpload } from "@/lib/server/order-payment-route"; +export const runtime = "nodejs"; +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return paymentRoute(req, async (actor) => { + const { id } = await params; + const input = await paymentUpload(req); + return { request: await markRequestPaid(id, { ...input, amount: Number(input.form.get("paid_amount")), reference: String(input.form.get("payment_reference") || "") }, actor) }; + }); +} diff --git a/src/app/api/v1/order-payments/[id]/route.ts b/src/app/api/v1/order-payments/[id]/route.ts new file mode 100644 index 0000000..57952fe --- /dev/null +++ b/src/app/api/v1/order-payments/[id]/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from "next/server"; +import { savePaymentRequest, cancelPaymentRequest } from "@/lib/server/order-payment-service"; +import { badPaymentInput, paymentDocuments, paymentRevision, paymentRoute } from "@/lib/server/order-payment-route"; +export const runtime = "nodejs"; +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return paymentRoute(req, async (actor) => { + const { id } = await params; + const body = await req.json(); + const revision = paymentRevision(body.revision); + if (body.op === "cancel") return { request: await cancelPaymentRequest(id, { revision, reason: typeof body.reason === "string" ? body.reason : "", ...(typeof body.item_id === "string" ? { item_id: body.item_id } : {}) }, actor) }; + if (body.op !== "save") badPaymentInput("Unknown payment action."); + if (typeof body.amount !== "number") badPaymentInput("Enter a valid amount."); + return { request: await savePaymentRequest(id, { revision, amount: body.amount, reference: String(body.reference || ""), note: String(body.note || ""), documents: paymentDocuments(body.documents) }, actor) }; + }); +} diff --git a/src/app/api/v1/order-payments/combine/route.ts b/src/app/api/v1/order-payments/combine/route.ts new file mode 100644 index 0000000..71d0dec --- /dev/null +++ b/src/app/api/v1/order-payments/combine/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from "next/server"; +import { combinePaymentRequests } from "@/lib/server/order-payment-service"; +import { badPaymentInput, paymentRevision, paymentRoute } from "@/lib/server/order-payment-route"; +export const runtime = "nodejs"; +export async function POST(req: NextRequest) { + return paymentRoute(req, async (actor) => { + const body = await req.json(); + if (!Array.isArray(body.request_ids) || !body.request_ids.every((id: unknown) => typeof id === "string")) badPaymentInput("Choose payment requests to combine."); + const revisions: Record = {}; + for (const id of body.request_ids) revisions[id] = paymentRevision(body.revisions?.[id]); + return { request: await combinePaymentRequests({ request_ids: body.request_ids, revisions }, actor) }; + }); +} diff --git a/src/app/api/v1/order-payments/route.test.ts b/src/app/api/v1/order-payments/route.test.ts new file mode 100644 index 0000000..dcdf622 --- /dev/null +++ b/src/app/api/v1/order-payments/route.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +const mock = vi.hoisted(() => ({ session: vi.fn(), require: vi.fn(), list: vi.fn() })); +vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: mock.session })); +vi.mock("@/lib/server/order-payment-service", () => ({ requireItemPayment: mock.require, listPaymentsAndApprovals: mock.list })); +import { GET, POST } from "./route"; +beforeEach(() => { vi.resetAllMocks(); mock.session.mockResolvedValue({ id: "realuser", name: "Buyer" }); }); +describe("payment routes", () => { + it("rejects unauthenticated reads and writes before accessing data", async () => { + mock.session.mockRejectedValue(Object.assign(new Error("Not authenticated"), { status: 401 })); + expect((await GET(new NextRequest("http://localhost/api/v1/order-payments"))).status).toBe(401); + expect((await POST(new NextRequest("http://localhost/api/v1/order-payments", { method: "POST", body: JSON.stringify({ item_id: "line1" }) }))).status).toBe(401); + expect(mock.list).not.toHaveBeenCalled(); expect(mock.require).not.toHaveBeenCalled(); + }); + it("ignores a forged actor in the submitted JSON", async () => { + mock.require.mockResolvedValue({ id: "request1" }); + const response = await POST(new NextRequest("http://localhost/api/v1/order-payments", { method: "POST", body: JSON.stringify({ item_id: "line1", actor: { id: "manager" } }) })); + expect(response.status).toBe(200); + expect(mock.require).toHaveBeenCalledWith("line1", { id: "realuser", name: "Buyer" }); + }); +}); diff --git a/src/app/api/v1/order-payments/route.ts b/src/app/api/v1/order-payments/route.ts new file mode 100644 index 0000000..8b8acac --- /dev/null +++ b/src/app/api/v1/order-payments/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; +import { listPaymentsAndApprovals, requireItemPayment } from "@/lib/server/order-payment-service"; +import { paymentRoute } from "@/lib/server/order-payment-route"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET(req: NextRequest) { + return paymentRoute(req, () => listPaymentsAndApprovals()); +} +export async function POST(req: NextRequest) { + return paymentRoute(req, async (actor) => { + const body = await req.json(); + return { request: await requireItemPayment(body.item_id, actor) }; + }); +} diff --git a/src/app/api/v1/orders/[id]/process-data/route.ts b/src/app/api/v1/orders/[id]/process-data/route.ts new file mode 100644 index 0000000..8641509 --- /dev/null +++ b/src/app/api/v1/orders/[id]/process-data/route.ts @@ -0,0 +1,8 @@ +import { NextRequest } from "next/server"; +import { getOrderProcessData } from "@/lib/server/order-process-service"; +import { paymentRoute } from "@/lib/server/order-payment-route"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return paymentRoute(req, async () => getOrderProcessData((await params).id, req.nextUrl.searchParams.get("itemsOnly") === "1")); +} diff --git a/src/components/orders/order-item-payment.test.tsx b/src/components/orders/order-item-payment.test.tsx new file mode 100644 index 0000000..b28d1a4 --- /dev/null +++ b/src/components/orders/order-item-payment.test.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const fetchMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/order-payment-client", () => ({ paymentFetch: fetchMock })); +import { OrderItemPayment } from "./order-item-payment"; +import type { OrderItem } from "@/lib/types"; +afterEach(cleanup); +beforeEach(() => { vi.resetAllMocks(); }); +const item = { id: "item1", description: "M16 bolts", item_status: "processed", order: "order1", payment_status: "" } as OrderItem; +describe("item payment requirement", () => { + it("creates a hold immediately and shows its payment request link", async () => { + fetchMock.mockResolvedValue({ request: { id: "pay1", revision: 1, status: "draft" } }); + const updated = vi.fn(); + function OrderItemHarness() { + const [current, setCurrent] = useState(item); + return { updated(patch); setCurrent((old) => ({ ...old, ...patch })); }} />; + } + render(); + fireEvent.click(screen.getByRole("switch", { name: /payment required/i })); + await screen.findByText("Payment needed"); + expect(updated).toHaveBeenCalledWith({ payment_status: "required", payment_request: "pay1" }); + expect(screen.getByRole("link", { name: /payment details/i }).getAttribute("href")).toContain("request=pay1"); + }); + it("reflects refreshed payment state without remounting the item", () => { + const { rerender } = render(); + expect(screen.getByText("Payment needed")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("Paid")).toBeInTheDocument(); + expect(screen.queryByText("Payment needed")).not.toBeInTheDocument(); + }); + it("requires a reason before removing an existing requirement", async () => { + render(); + fireEvent.click(screen.getByRole("switch", { name: /payment required/i })); + expect(screen.getByRole("button", { name: /remove requirement/i })).toBeDisabled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("keeps the toggle off and displays a failed creation", async () => { + fetchMock.mockRejectedValue(new Error("Payment service not enabled")); + render(); + fireEvent.click(screen.getByRole("switch", { name: /payment required/i })); + await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("not enabled")); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + }); +}); diff --git a/src/components/orders/order-item-payment.tsx b/src/components/orders/order-item-payment.tsx new file mode 100644 index 0000000..4fa3fd2 --- /dev/null +++ b/src/components/orders/order-item-payment.tsx @@ -0,0 +1,57 @@ +"use client"; +import { useState } from "react"; +import Link from "next/link"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { paymentFetch } from "@/lib/order-payment-client"; +import type { OrderItem } from "@/lib/types"; +import type { PaymentsApprovalsData, SupplierPaymentRequest } from "@/lib/order-payments"; + +type Projection = Pick; +export function OrderItemPayment({ item, onUpdated }: { item: OrderItem; onUpdated: (patch: Projection) => void }) { + // The parent owns the projection, including fresh data after an item save. + const payment: Projection = item; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [removing, setRemoving] = useState(false); + const [reason, setReason] = useState(""); + const required = payment.payment_status === "required"; + const patch = (next: Projection) => { onUpdated(next); }; + async function requirePayment() { + setBusy(true); setError(""); + try { + const { request } = await paymentFetch<{ request: SupplierPaymentRequest }>("/api/v1/order-payments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ item_id: item.id }) }); + patch({ payment_status: "required", payment_request: request.id }); + } catch (e) { setError(e instanceof Error ? e.message : "Could not require payment."); } + finally { setBusy(false); } + } + async function remove() { + setBusy(true); setError(""); + try { + const data = await paymentFetch("/api/v1/order-payments"); + const request = data.requests.find((r) => r.id === payment.payment_request); + if (!request || !["pending", "draft"].includes(request.status)) throw new Error("This request changed. Refresh the order before removing the requirement."); + await paymentFetch(`/api/v1/order-payments/${request.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "cancel", revision: request.revision, item_id: item.id, reason }) }); + patch({ payment_status: "", payment_request: "" }); setRemoving(false); setReason(""); + } catch (e) { setError(e instanceof Error ? e.message : "Could not remove requirement."); } + finally { setBusy(false); } + } + return
+
+ { if (next) void requirePayment(); else setRemoving(true); }} /> + Payment required + {required && Payment needed} + {payment.payment_status === "paid" && Paid} + {payment.payment_request && Payment details} +
+ {required &&

Supplier POs continue normally. Payment is needed before collection readiness.

} + {removing &&
+ +

Other items in a shared request will keep their payment requirements.

+
+
} + {error &&

{error}

} +
; +} diff --git a/src/components/orders/payments-approvals-page.test.tsx b/src/components/orders/payments-approvals-page.test.tsx new file mode 100644 index 0000000..e586b39 --- /dev/null +++ b/src/components/orders/payments-approvals-page.test.tsx @@ -0,0 +1,48 @@ +import userEvent from "@testing-library/user-event"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const mock = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock("@/lib/order-payment-client", () => ({ paymentFetch: mock.fetch })); +import { PaymentsApprovalsPage } from "./payments-approvals-page"; +const request = { id: "pay1", revision: 1, status: "pending", amount: 120, order: "order1", supplier: "supplier1", companyName: "Acme", orderNumber: "ORD1", supplierName: "Fasteners", items: ["line1"], lines: [{ id: "line1", description: "Bolts", order_qty: 10, cost_price: 12 }], documents: [], purchaseOrders: [], reference: "INV7", note: "" }; +beforeEach(() => { vi.resetAllMocks(); mock.fetch.mockResolvedValue({ enabled: true, requests: [request], approvals: [{ id: "approval1", order: "order2", companyName: "Other Co", orderNumber: "ORD2", supplierName: "Supply", description: "Nuts", cost: 100, selling: 90, markup: -10 }] }); }); +afterEach(cleanup); +describe("payments and approvals", () => { + it("shows separate payment and margin approval tables with actual negative markup", async () => { + render(); + await screen.findByText("Acme"); + expect(screen.getAllByRole("table")).toHaveLength(2); + expect(screen.getByText("-10.0%")).toBeInTheDocument(); + }); + it("requires a proof file and reference to enable marking paid", async () => { + render(); + await screen.findByLabelText("Proof of payment"); + expect(screen.getByRole("button", { name: "Mark paid" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Payment reference"), { target: { value: "EFT123" } }); + expect(screen.getByRole("button", { name: "Mark paid" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Proof of payment"), { target: { files: [new File(["pdf"], "proof.pdf")] } }); + expect(screen.getByRole("button", { name: "Mark paid" })).toBeEnabled(); + }); + + it("submits the proof and full request amount from the paid form", async () => { + render(); + await screen.findByLabelText("Proof of payment"); + fireEvent.change(screen.getByLabelText("Payment reference"), { target: { value: "EFT123" } }); + await userEvent.upload(screen.getByLabelText("Proof of payment"), new File(["pdf"], "proof.pdf")); + const button = screen.getByRole("button", { name: "Mark paid" }) as HTMLButtonElement; + // jsdom reports a populated required file input as invalid; native + // submission is covered in the Playwright test with an actual browser. + fireEvent.submit(button.form!); + await waitFor(() => expect(mock.fetch).toHaveBeenCalledWith("/api/v1/order-payments/pay1/paid", expect.objectContaining({ method: "POST", body: expect.any(FormData) }))); + const form = mock.fetch.mock.calls.find(([url]) => url.endsWith("/paid"))?.[1].body as FormData; + expect(form.get("paid_amount")).toBe("120"); + expect(form.get("payment_reference")).toBe("EFT123"); + expect(form.get("file")).toBeInstanceOf(File); + }); + it("shows deployment unavailability while keeping existing approvals accessible", async () => { + mock.fetch.mockResolvedValue({ enabled: false, requests: [], approvals: [] }); + render(); + await screen.findByText(/payments are not enabled yet/i); + expect(screen.getByRole("heading", { name: /approvals/i, level: 2 })).toBeInTheDocument(); + }); +}); diff --git a/src/components/orders/payments-approvals-page.tsx b/src/components/orders/payments-approvals-page.tsx new file mode 100644 index 0000000..7d4cecc --- /dev/null +++ b/src/components/orders/payments-approvals-page.tsx @@ -0,0 +1,131 @@ +"use client"; +import { Fragment, useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { CheckCircle2, FileText, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { paymentFetch } from "@/lib/order-payment-client"; +import { formatCurrency } from "@/lib/currency"; +import type { PaymentDocument, PaymentRequestRow, PaymentsApprovalsData, SupplierPaymentRequest } from "@/lib/order-payments"; + +const stateLabels = { draft: "Needs details", pending: "Payment needed", paid: "Paid", cancelled: "Cancelled" }; +function fileUrl(d: PaymentDocument) { return `/api/v1/files/${encodeURIComponent(d.collection)}/${encodeURIComponent(d.recordId)}/${encodeURIComponent(d.filename)}`; } +function errorMessage(e: unknown) { return e instanceof Error ? e.message : "The request could not be completed."; } +function Evidence({ documents }: { documents: PaymentDocument[] }) { + // A grouped request stores one copy per item. Show each document once. + const unique = new Map(); + for (const d of documents || []) { + const key = d.filename.replace(/_[a-z0-9]{10}(\.[^.]+)$/, "$1"); + if (!unique.has(key)) unique.set(key, d); + } + return ; +} + +export function PaymentsApprovalsPage({ initialRequestId = "" }: { initialRequestId?: string }) { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [view, setView] = useState(initialRequestId ? "all" : "outstanding"); + const [open, setOpen] = useState(initialRequestId); + const [selected, setSelected] = useState([]); + const [combining, setCombining] = useState(false); + const load = useCallback(async () => { + setLoading(true); setError(""); + try { setData(await paymentFetch("/api/v1/order-payments")); } + catch (e) { setError(errorMessage(e)); } + finally { setLoading(false); } + }, []); + useEffect(() => { + let active = true; + paymentFetch("/api/v1/order-payments") + .then((result) => { if (active) setData(result); }) + .catch((e) => { if (active) setError(errorMessage(e)); }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, []); + async function combine() { + setCombining(true); setError(""); + try { + const requests = data!.requests.filter((r) => selected.includes(r.id)); + if (requests.length < 2 || requests.some((r) => r.order !== requests[0].order || r.supplier !== requests[0].supplier || !["pending", "draft"].includes(r.status))) throw new Error("Choose unpaid requests for the same order and supplier."); + const result = await paymentFetch<{ request: SupplierPaymentRequest }>("/api/v1/order-payments/combine", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_ids: selected, revisions: Object.fromEntries(requests.map((r) => [r.id, r.revision])) }) }); + setSelected([]); setOpen(result.request.id); await load(); + } catch (e) { setError(errorMessage(e)); } + finally { setCombining(false); } + } + const query = search.toLowerCase().trim(); + const requests = (data?.requests || []).filter((r) => { + const matches = [r.companyName, r.orderNumber, r.supplierName, r.reference, ...r.lines.map((l) => l.description)].join(" ").toLowerCase().includes(query); + const visible = view === "all" || (view === "outstanding" ? ["pending", "draft"].includes(r.status) : r.status === view); + return matches && visible; + }); + const approvals = (data?.approvals || []).filter((r) => [r.companyName, r.orderNumber, r.supplierName, r.description].join(" ").toLowerCase().includes(query)); + return
+

Payments and Approvals

Supplier payments before collection, and order items awaiting markup approval.

+
{ setSearch(e.target.value); setSelected([]); }} />
+ {error &&

{error}

} +
+

Payments {requests.length}

{selected.length > 0 && }
+ {data && !data.enabled &&

Supplier payments are not enabled yet. The schema and collection payment checks must be deployed first.

} +
SelectCompany / orderSupplier / referenceItems to payAmountStatus / requestedActions + {loading && !data ? Loading payments… : requests.length === 0 ? {query ? "No matching payment requests." : "No payment requests in this view. Flag an order item when its supplier needs payment."} : requests.map((r) => + {["draft", "pending"].includes(r.status) && setSelected((old) => e.target.checked ? [...old, r.id] : old.filter((id) => id !== r.id))} />}
{r.companyName}
{r.orderNumber}
{r.supplierName}
{r.reference || "Reference needed"}
{r.purchaseOrders.map((po) => {po.number})}
{r.lines.map((l) =>
{l.order_qty} × {l.description}
)}
{r.amount > 0 ? formatCurrency(r.amount) : "Confirm amount"}{stateLabels[r.status]}
{r.requested_by}{r.created ? ` · ${new Intl.DateTimeFormat("en-ZA", { day: "numeric", month: "short", timeZone: "Africa/Johannesburg" }).format(new Date(r.created))}` : ""}
+ {open === r.id && } +
)} +
+
+

Approvals {approvals.length}

Items waiting for the existing markup approval process.

Company / orderItem / supplierUnit costUnit sellingMarkupActions{approvals.length === 0 ? {loading && !data ? "Loading approvals…" : "No order items awaiting markup approval."} : approvals.map((r) =>
{r.companyName}
{r.orderNumber}
{r.description}
{r.supplierName}
{formatCurrency(r.cost)}{formatCurrency(r.selling)}{r.markup === null ? "Unpriced" : `${r.markup.toFixed(1)}%`}Open item
)}
+
; +} + +function PaymentEditor({ request: r, onSaved }: { request: PaymentRequestRow; onSaved: () => Promise }) { + const estimate = r.lines.reduce((sum, l) => sum + l.order_qty * l.cost_price, 0); + const [amount, setAmount] = useState(r.amount > 0 ? String(r.amount) : estimate > 0 ? estimate.toFixed(2) : ""); + const [reference, setReference] = useState(r.reference || ""); + const [note, setNote] = useState(r.note || ""); + const [file, setFile] = useState(null); + const [proof, setProof] = useState(null); + const [paymentReference, setPaymentReference] = useState(""); + const [reason, setReason] = useState(""); + const [cancelItem, setCancelItem] = useState(""); + const [showCancel, setShowCancel] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [poDocs, setPoDocs] = useState([]); + const active = ["draft", "pending"].includes(r.status); + const base = `/api/v1/order-payments/${r.id}`; + async function act(work: () => Promise) { + setBusy(true); setError(""); + try { await work(); await onSaved(); } + catch (e) { setError(errorMessage(e)); } + finally { setBusy(false); } + } + function json(body: object) { return { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision: r.revision, ...body }) }; } + function upload(chosen: File) { const f = new FormData(); f.set("file", chosen); f.set("revision", String(r.revision)); f.set("upload_id", crypto.randomUUID()); return f; } + return
+ {error &&

{error}

} + + {!active &&
{r.status === "paid" ?

Paid {formatCurrency(r.paid_amount)} · {r.payment_reference} · {r.paid_by}{r.paid_at ? ` · ${new Date(r.paid_at).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg" })}` : ""}

:

Cancelled: {r.cancellation_reason}

}{r.reference &&

Supplier reference: {r.reference}

}{r.note &&

{r.note}

}
} + {active && <> +
{ e.preventDefault(); void act(() => paymentFetch(base, json({ op: "save", amount: Number(amount), reference, note, documents: poDocs }))); }}> +

Payment request

+

{r.status === "draft" ? "This item is held for payment. Confirm the amount and attach supporting evidence to complete the request." : "Confirm the supplier’s document and the full amount to pay."}

+ {!r.supplier &&

Choose a supplier on the order item before completing this request.

} + + {r.amount <= 0 && estimate > 0 &&

Supplier cost estimate: {formatCurrency(estimate)}. Check tax and charges against the document.

} + +