Merge codex/order-item-payments into main
The item supplier payments and approvals queue: a payment hold on order items, the Payments and Approvals page, supporting documents and proof of payment, and the PB hook that stops an unpaid item becoming ready for collection.
This commit is contained in:
commit
4eb6ebf838
38 changed files with 2236 additions and 38 deletions
39
docs/order-payments.md
Normal file
39
docs/order-payments.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Supplier payments and order-item approvals
|
||||
|
||||
Under Orders, open **Payments and Approvals** (`/orders/payments-approvals`).
|
||||
|
||||
1. On the order process page, toggle **Payment required** for an item. Create and send its supplier PO normally.
|
||||
2. The payment row automatically links the PO. Choose **Upload supplier invoice** to add an invoice, pro forma or photo.
|
||||
3. Choose **Upload proof of payment**, then **Mark paid** once payment has been made. No amount, reference, notes or request-confirmation form is needed; the supplier document tells the payer what to pay.
|
||||
4. The item can now become ready through the normal collection flow. Payment itself does not mark ready, assign a driver, change markup approval or move the workflow forward.
|
||||
|
||||
Rows show Company/order, Supplier, Items, PO, Supplier documents, Proof of payment and Paid. The history filter retains paid/cancelled records. Approvals is the existing order-item markup approval queue; its current WhatsApp/n8n process remains unchanged.
|
||||
|
||||
The backend requires supporting evidence (uploaded supplier document or linked PO PDF), proof and a current item revision before recording Paid. If an item changes while a payer has the page open, refresh before retrying. Existing file preservation, atomic upload deduplication, historical paid proof protection and collection-readiness checks remain in force.
|
||||
|
||||
## 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": 3 }`. It verifies required schema fields and identifies the simplified document-and-proof workflow with transactionally deduplicated 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.
|
||||
79
docs/superpowers/plans/2026-11-02-order-item-payments.md
Normal file
79
docs/superpowers/plans/2026-11-02-order-item-payments.md
Normal file
|
|
@ -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<string, unknown>`, `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<string, number> }`; 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.
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# Order-item supplier payments and approvals
|
||||
|
||||
Payment form sections superseded by [the approved simpler workflow](2026-11-03-simplified-order-payments.md).
|
||||
|
||||
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.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Simplified order-item supplier payments
|
||||
|
||||
Approved by the operator on 2026-11-03. Supersedes the payment-request form portions of the 2026-11-02 design.
|
||||
|
||||
The buyer toggles Payment required on an order item and creates/sends the supplier PO normally. A row appears in Payments with company/order, supplier, items, automatically linked PO, supplier documents, proof and Paid. Supplier invoices/pro formas/photos upload directly from that row. Uploading proof and clicking Mark paid records payment, its actor and time; no amount, payment reference, notes, manual combining or request-confirmation form is needed. The supplier document is the source for what must be paid.
|
||||
|
||||
Keep Approvals as the separate existing margin-approval table. Payment status stays independent of item_status. Payment is only a hold on collection readiness/dispatch; marking paid does not mark ready or change the workflow/approval status.
|
||||
|
||||
The server verifies supporting evidence (an uploaded supplier document or an automatically linked PO PDF), proof and a current revision. Supplier/order/quantity/cost edits bump the revision, so stale tabs refresh; a fresh payer does not need to reconfirm a form. Snapshot actual supplier coverage when marking paid and preserve the existing paid coverage checks. Keep additive item attachment storage, atomic upload deduplication, historical proof protection, session authentication and unpaid split inheritance.
|
||||
|
||||
Capability3 identifies these simplified semantics. Legacy amount/reference metadata may remain in the new collection for compatibility but is not collected, required or used to authorise payment. No live deployment has been performed.
|
||||
|
||||
Supplier/order edits atomically update pending metadata, retire affected document associations into history without deleting files, and require appropriate current supplier evidence. The active row uses current item supplier/company; retired legacy PO PDFs are not shown as current payment evidence.
|
||||
35
e2e/order-payments.spec.ts
Normal file
35
e2e/order-payments.spec.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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: [{ id: "po1", number: "PO202611001FAS", documents: [{ collection: "purchase_orders", recordId: "po1", filename: "po_example.pdf", role: "support" }] }], 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: [{ id: "po1", number: "PO202611001FAS", documents: [{ collection: "purchase_orders", recordId: "po1", filename: "po_example.pdf", role: "support" }] }], 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 row = page.getByRole("row").filter({ hasText: "M16 × 50 hex bolts" });
|
||||
const markPaid = row.getByRole("button", { name: "Mark paid", exact: true });
|
||||
await expect(markPaid).toBeDisabled();
|
||||
await row.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()).not.toContain("paid_amount");
|
||||
expect(request.postData()).not.toContain("payment_reference");
|
||||
await expect(page.getByRole("columnheader", { name: "Amount" })).toHaveCount(0);
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
481
pocketbase/pb_hooks/order_payment_utils.js
Normal file
481
pocketbase/pb_hooks/order_payment_utils.js
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
/** 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", "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 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 the payment list and try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
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 marking it paid.", 422);
|
||||
items.forEach((item) => {
|
||||
if (item.getString("order") !== order || item.getString("supplier") !== supplier) fail("Payment items must belong to the same order and supplier. Refresh the payment list before continuing.");
|
||||
});
|
||||
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 retiredForItem(request, item, doc) {
|
||||
if (!request) return false;
|
||||
return array(plain(request).history).some((entry) => entry.action === "routing_changed" && entry.details && entry.details.item_id === item.id &&
|
||||
array(entry.details.invalidated_documents).some((old) => documentKey(old) === documentKey(doc)));
|
||||
}
|
||||
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; }
|
||||
if (po.supplier && po.supplier !== item.getString("supplier")) return false;
|
||||
return array(po.documents).concat(array(po.documents_history)).indexOf(doc.filename) !== -1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Linked supplier PO PDFs are supporting evidence without operator selection. */
|
||||
function linkedPoDocuments(app, items, request) {
|
||||
let docs = [];
|
||||
items.forEach((item) => {
|
||||
const poId = item.getString("purchase_order");
|
||||
if (!poId) return;
|
||||
let po;
|
||||
try { po = plain(app.findRecordById("purchase_orders", poId)); } catch (_) { return; }
|
||||
if (po.supplier && po.supplier !== item.getString("supplier")) return;
|
||||
const files = array(po.documents).concat(array(po.documents_history));
|
||||
const linked = files.filter((filename) => /\.pdf$/i.test(filename)).map((filename) => ({
|
||||
collection: "purchase_orders", recordId: poId, filename: filename, role: "support",
|
||||
})).filter((doc) => !retiredForItem(request, item, doc));
|
||||
docs = mergeDocuments(docs, linked);
|
||||
});
|
||||
return docs;
|
||||
}
|
||||
function validateAdditions(app, docs, items, request) {
|
||||
array(docs).forEach((doc) => {
|
||||
if (!items.some((item) => docExists(app, doc, item) && !retiredForItem(request, item, doc))) fail("A payment document is missing or does not belong to the selected items.", 422);
|
||||
});
|
||||
}
|
||||
function evidence(app, docs, items, role, candidate, request) {
|
||||
return items.every((item) => docs.some((doc) => doc.role === role && docExists(app, doc, item, candidate) && !retiredForItem(request, item, doc)));
|
||||
}
|
||||
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("revision", 1);
|
||||
request.set("requested_by", person.name);
|
||||
request.set("documents", linkedPoDocuments(app, [item]));
|
||||
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 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 docs = mergeDocuments(mergeDocuments(plain(request).documents, body.documents), linkedPoDocuments(app, items, request));
|
||||
validateAdditions(app, array(body.documents), items, request);
|
||||
request.set("order", group.order);
|
||||
request.set("supplier", group.supplier);
|
||||
request.set("documents", docs);
|
||||
request.set("item_snapshot", snapshot(items));
|
||||
request.set("status", group.supplier && evidence(app, docs, items, "support", null, request) ? "pending" : "draft");
|
||||
eventHistory(request, "documents_added", person);
|
||||
bump(request);
|
||||
return save(e, app, request, audit, person);
|
||||
}
|
||||
function payRequest(e, app, body, person, audit) {
|
||||
const request = mutable(app, body);
|
||||
const items = requestItems(app, request);
|
||||
ensureMembership(request, items);
|
||||
const group = consistent(items, true);
|
||||
const docs = mergeDocuments(mergeDocuments(plain(request).documents, body.documents), linkedPoDocuments(app, items, request));
|
||||
validateAdditions(app, array(body.documents), items, request);
|
||||
if (!evidence(app, docs, items, "support", null, request)) fail("Upload the supplier invoice or pro forma, or attach the supplier PO PDF, before marking paid.", 422);
|
||||
if (!evidence(app, docs, items, "proof", null, request)) fail("Upload proof of payment for every item in this request before marking paid.", 422);
|
||||
request.set("order", group.order);
|
||||
request.set("supplier", group.supplier);
|
||||
request.set("documents", docs);
|
||||
// Current revision is the stale-tab guard. The settled coverage snapshot is
|
||||
// taken now, without requiring an operator to reconfirm a separate form.
|
||||
request.set("item_snapshot", snapshot(items));
|
||||
request.set("status", "paid");
|
||||
request.set("paid_by", person.name);
|
||||
request.set("paid_at", new Date().toISOString());
|
||||
eventHistory(request, "paid", person);
|
||||
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) });
|
||||
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("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, request)) 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, candidate) {
|
||||
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.");
|
||||
const next = candidate || item;
|
||||
const routingChanged = item.getString("supplier") !== next.getString("supplier") || item.getString("order") !== next.getString("order");
|
||||
if (routingChanged) {
|
||||
const before = mergeDocuments(plain(request).documents, linkedPoDocuments(app, [item], request));
|
||||
const oldPo = item.getString("purchase_order");
|
||||
const invalidated = before.filter((doc) => (doc.collection === ITEMS && doc.recordId === item.id) || (doc.collection === "purchase_orders" && doc.recordId === oldPo));
|
||||
const otherItems = requestItems(app, request).filter((member) => member.id !== item.id);
|
||||
// Shared PO evidence may still serve another member. The per-member retired
|
||||
// association below prevents that shared reference serving the changed item.
|
||||
const remaining = array(plain(request).documents).filter((doc) => !invalidated.some((old) => documentKey(old) === documentKey(doc)) || otherItems.some((member) => docExists(app, doc, member)));
|
||||
request.set("documents", remaining);
|
||||
eventHistory(request, "routing_changed", person, {
|
||||
item_id: item.id,
|
||||
previous: { order: item.getString("order"), supplier: item.getString("supplier") },
|
||||
current: { order: next.getString("order"), supplier: next.getString("supplier") },
|
||||
invalidated_documents: invalidated,
|
||||
});
|
||||
if (array(plain(request).items).length === 1) {
|
||||
request.set("order", next.getString("order"));
|
||||
request.set("supplier", next.getString("supplier"));
|
||||
}
|
||||
} else eventHistory(request, "item_changed", person, { item_id: item.id });
|
||||
request.set("status", "draft");
|
||||
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, e.record);
|
||||
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 };
|
||||
57
pocketbase/pb_hooks/order_payments.pb.js
Normal file
57
pocketbase/pb_hooks/order_payments.pb.js
Normal file
|
|
@ -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: 3 });
|
||||
}, $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");
|
||||
58
scripts/migrations/2026-11-02-order-payments.ts
Normal file
58
scripts/migrations/2026-11-02-order-payments.ts
Normal file
|
|
@ -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<string, unknown> & { 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; });
|
||||
242
scripts/smoke/order-payments-hooks.mjs
Normal file
242
scripts/smoke/order-payments-hooks.mjs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* 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, 3);
|
||||
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, documents: documents.filter((doc) => doc.role === "support") });
|
||||
assert.equal(pending.status, "pending");
|
||||
await rejects(() => command({ op: "paid", request_id: pending.id, revision: pending.revision, documents: [] }), /proof/i);
|
||||
const paid = await command({ op: "paid", request_id: pending.id, revision: pending.revision, 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) => {
|
||||
const r = await command({ op: "require", item_id: row.id });
|
||||
const support = await uploadDocument(r, "support", "Atomic supplier invoice");
|
||||
const proof = await uploadDocument(r, "proof", "Atomic payment proof");
|
||||
return command({ op: "paid", request_id: r.id, revision: r.revision, documents: [...support, ...proof] });
|
||||
};
|
||||
// A supplier PO linked after the toggle is automatic supporting evidence.
|
||||
const poForm = new FormData();
|
||||
poForm.append("label", "Automatically linked supplier PO");
|
||||
poForm.append("documents", new Blob(["Existing supplier purchase-order PDF"]), "supplier_po.pdf");
|
||||
const supplierPo = await pb.collection("purchase_orders").create(poForm);
|
||||
const poItem = await pb.collection("order_items").create(base);
|
||||
let poRequest = await command({ op: "require", item_id: poItem.id });
|
||||
await pb.collection("order_items").update(poItem.id, { purchase_order: supplierPo.id, item_status: "po_generated" });
|
||||
poRequest = await pb.collection("supplier_payment_requests").getOne(poRequest.id);
|
||||
assert.equal(poRequest.status, "draft", "Linked PO payment needs no operator save form.");
|
||||
await rejects(() => command({ op: "paid", request_id: poRequest.id, revision: poRequest.revision, documents: [] }), /proof/i);
|
||||
const poProof = await uploadDocument(poRequest, "proof", "Proof for automatically linked PO");
|
||||
const poPaid = await command({ op: "paid", request_id: poRequest.id, revision: poRequest.revision, documents: poProof });
|
||||
assert.equal(poPaid.status, "paid");
|
||||
assert.ok(poPaid.documents.some((doc) => doc.collection === "purchase_orders" && doc.recordId === supplierPo.id && doc.role === "support"));
|
||||
assert.equal((await pb.collection("order_items").getOne(poItem.id)).item_status, "po_generated");
|
||||
|
||||
// Procurement edits invalidate stale payer tabs, without a reconfirmation form.
|
||||
const changedItem = await pb.collection("order_items").create({ ...base, purchase_order: supplierPo.id });
|
||||
const staleRequest = await command({ op: "require", item_id: changedItem.id });
|
||||
await pb.collection("order_items").update(changedItem.id, { cost_price: 4 });
|
||||
await rejects(() => command({ op: "paid", request_id: staleRequest.id, revision: staleRequest.revision, documents: [] }), /changed|refresh/i);
|
||||
const refreshedRequest = await pb.collection("supplier_payment_requests").getOne(staleRequest.id);
|
||||
const changedProof = await uploadDocument(refreshedRequest, "proof", "Proof after refreshed procurement details");
|
||||
const refreshedPaid = await command({ op: "paid", request_id: refreshedRequest.id, revision: refreshedRequest.revision, documents: changedProof });
|
||||
assert.equal(refreshedPaid.item_snapshot[changedItem.id].cost_price, 4);
|
||||
|
||||
// Supplier changes update request routing and retire the previous invoice.
|
||||
const routedItem = await pb.collection("order_items").create(base);
|
||||
let routed = await command({ op: "require", item_id: routedItem.id });
|
||||
const oldInvoice = await uploadDocument(routed, "support", "Invoice for the original supplier");
|
||||
routed = await command({ op: "save", request_id: routed.id, revision: routed.revision, documents: oldInvoice });
|
||||
const newSupplier = await pb.collection("suppliers").create({ label: "New supplier" });
|
||||
await pb.collection("order_items").update(routedItem.id, { supplier: newSupplier.id });
|
||||
routed = await pb.collection("supplier_payment_requests").getOne(routed.id);
|
||||
assert.equal(routed.supplier, newSupplier.id, "Payment metadata must follow the current single-item supplier.");
|
||||
assert.equal(routed.documents.some((doc) => doc.filename === oldInvoice[0].filename), false);
|
||||
assert.ok(JSON.stringify(routed.history).includes(oldInvoice[0].filename), "Retired evidence must remain in history.");
|
||||
assert.ok((await pb.collection("order_items").getOne(routedItem.id)).order_item_attachements.includes(oldInvoice[0].filename), "Retiring evidence must not remove its file.");
|
||||
const newSupplierProof = await uploadDocument(routed, "proof", "Proof for the new supplier");
|
||||
await rejects(() => command({ op: "paid", request_id: routed.id, revision: routed.revision, documents: newSupplierProof }), /supplier invoice|support/i);
|
||||
const newInvoice = await uploadDocument(routed, "support", "Invoice from the new supplier");
|
||||
routed = await command({ op: "save", request_id: routed.id, revision: routed.revision, documents: newInvoice });
|
||||
assert.equal((await command({ op: "paid", request_id: routed.id, revision: routed.revision, documents: newSupplierProof })).status, "paid");
|
||||
|
||||
// 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, 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, 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 });
|
||||
}
|
||||
|
|
@ -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<string, EditableItem> = {};
|
||||
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}
|
||||
/>
|
||||
<OrderItemPayment item={item} onUpdated={(patch) => setItems((prev) => prev.map((row) => row.id === item.id ? { ...row, ...patch } : row))} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
5
src/app/(app)/orders/payments-approvals/page.tsx
Normal file
5
src/app/(app)/orders/payments-approvals/page.tsx
Normal file
|
|
@ -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 <PaymentsApprovalsPage initialRequestId={typeof params.request === "string" ? params.request : ""} />;
|
||||
}
|
||||
10
src/app/api/v1/order-payments/[id]/documents/route.ts
Normal file
10
src/app/api/v1/order-payments/[id]/documents/route.ts
Normal file
|
|
@ -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) };
|
||||
});
|
||||
}
|
||||
11
src/app/api/v1/order-payments/[id]/paid/route.ts
Normal file
11
src/app/api/v1/order-payments/[id]/paid/route.ts
Normal file
|
|
@ -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, actor) };
|
||||
});
|
||||
}
|
||||
14
src/app/api/v1/order-payments/[id]/route.ts
Normal file
14
src/app/api/v1/order-payments/[id]/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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.");
|
||||
return { request: await savePaymentRequest(id, { revision, documents: paymentDocuments(body.documents) }, actor) };
|
||||
});
|
||||
}
|
||||
13
src/app/api/v1/order-payments/combine/route.ts
Normal file
13
src/app/api/v1/order-payments/combine/route.ts
Normal file
|
|
@ -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<string, number> = {};
|
||||
for (const id of body.request_ids) revisions[id] = paymentRevision(body.revisions?.[id]);
|
||||
return { request: await combinePaymentRequests({ request_ids: body.request_ids, revisions }, actor) };
|
||||
});
|
||||
}
|
||||
30
src/app/api/v1/order-payments/route.test.ts
Normal file
30
src/app/api/v1/order-payments/route.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// @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(), save: vi.fn(), cancel: vi.fn() }));
|
||||
vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: mock.session }));
|
||||
vi.mock("@/lib/server/order-payment-service", () => ({ requireItemPayment: mock.require, listPaymentsAndApprovals: mock.list, savePaymentRequest: mock.save, cancelPaymentRequest: mock.cancel }));
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts document registration without an amount or reference", async () => {
|
||||
const { PATCH } = await import("./[id]/route");
|
||||
mock.save.mockResolvedValue({ id: "pay1" });
|
||||
const response = await PATCH(new NextRequest("http://localhost/api/v1/order-payments/pay1", { method: "PATCH", body: JSON.stringify({ op: "save", revision: 1, documents: [] }) }), { params: Promise.resolve({ id: "pay1" }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(mock.save).toHaveBeenCalledWith("pay1", { revision: 1, documents: [] }, { id: "realuser", name: "Buyer" });
|
||||
});
|
||||
14
src/app/api/v1/order-payments/route.ts
Normal file
14
src/app/api/v1/order-payments/route.ts
Normal file
|
|
@ -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) };
|
||||
});
|
||||
}
|
||||
8
src/app/api/v1/orders/[id]/process-data/route.ts
Normal file
8
src/app/api/v1/orders/[id]/process-data/route.ts
Normal file
|
|
@ -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"));
|
||||
}
|
||||
45
src/components/orders/order-item-payment.test.tsx
Normal file
45
src/components/orders/order-item-payment.test.tsx
Normal file
|
|
@ -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 <OrderItemPayment item={current} onUpdated={(patch) => { updated(patch); setCurrent((old) => ({ ...old, ...patch })); }} />;
|
||||
}
|
||||
render(<OrderItemHarness />);
|
||||
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(<OrderItemPayment item={{ ...item, payment_status: "required", payment_request: "pay1" }} onUpdated={vi.fn()} />);
|
||||
expect(screen.getByText("Payment needed")).toBeInTheDocument();
|
||||
rerender(<OrderItemPayment item={{ ...item, payment_status: "paid", payment_request: "pay1" }} onUpdated={vi.fn()} />);
|
||||
expect(screen.getByText("Paid")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Payment needed")).not.toBeInTheDocument();
|
||||
});
|
||||
it("requires a reason before removing an existing requirement", async () => {
|
||||
render(<OrderItemPayment item={{ ...item, payment_status: "required", payment_request: "pay1" }} onUpdated={vi.fn()} />);
|
||||
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(<OrderItemPayment item={item} onUpdated={vi.fn()} />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
57
src/components/orders/order-item-payment.tsx
Normal file
57
src/components/orders/order-item-payment.tsx
Normal file
|
|
@ -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<OrderItem, "payment_status" | "payment_request">;
|
||||
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<PaymentsApprovalsData>("/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 <div className="mt-1 rounded-md border bg-background px-3 py-2 text-xs sm:px-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Switch aria-label={`Payment required for ${item.description}`} checked={required} disabled={busy} onCheckedChange={(next) => { if (next) void requirePayment(); else setRemoving(true); }} />
|
||||
<span>Payment required</span>
|
||||
{required && <Badge variant="outline">Payment needed</Badge>}
|
||||
{payment.payment_status === "paid" && <Badge variant="outline">Paid</Badge>}
|
||||
{payment.payment_request && <Link className="ml-auto font-medium text-primary underline underline-offset-4" href={`/orders/payments-approvals?request=${encodeURIComponent(payment.payment_request)}`}>Payment details</Link>}
|
||||
</div>
|
||||
{required && <p className="mt-1 text-muted-foreground">Supplier POs continue normally. Payment is needed before collection readiness.</p>}
|
||||
{removing && <div className="mt-3 space-y-2">
|
||||
<label className="block space-y-1"><span>Reason for removing this item’s payment requirement</span><Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="For example, supplier account terms confirmed" /></label>
|
||||
<p className="text-muted-foreground">Other items in a shared request will keep their payment requirements.</p>
|
||||
<div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy || !reason.trim()} onClick={() => void remove()}>Remove requirement</Button><Button size="sm" variant="ghost" disabled={busy} onClick={() => setRemoving(false)}>Keep requirement</Button></div>
|
||||
</div>}
|
||||
{error && <p role="alert" className="mt-2 text-destructive">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
50
src/components/orders/payments-approvals-page.test.tsx
Normal file
50
src/components/orders/payments-approvals-page.test.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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(<PaymentsApprovalsPage />);
|
||||
await screen.findByText("Acme");
|
||||
expect(screen.getAllByRole("table")).toHaveLength(2);
|
||||
expect(screen.getByText("-10.0%")).toBeInTheDocument();
|
||||
});
|
||||
it("shows a document checklist without payment amount or request forms", async () => {
|
||||
render(<PaymentsApprovalsPage />);
|
||||
await screen.findByText("Acme");
|
||||
expect(screen.queryByRole("columnheader", { name: "Amount" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Save request")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Payment reference")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "PO" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Upload supplier invoice")).toBeInTheDocument();
|
||||
});
|
||||
it("requires only proof to enable marking paid", async () => {
|
||||
render(<PaymentsApprovalsPage />);
|
||||
await screen.findByLabelText("Proof of payment");
|
||||
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 proof without asking for an amount or payment reference", async () => {
|
||||
render(<PaymentsApprovalsPage />);
|
||||
await screen.findByLabelText("Proof of payment");
|
||||
await userEvent.upload(screen.getByLabelText("Proof of payment"), new File(["pdf"], "proof.pdf"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mark paid" }));
|
||||
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.has("paid_amount")).toBe(false);
|
||||
expect(form.has("payment_reference")).toBe(false);
|
||||
expect(form.get("file")).toBeInstanceOf(File);
|
||||
});
|
||||
it("shows deployment unavailability while keeping existing approvals accessible", async () => {
|
||||
mock.fetch.mockResolvedValue({ enabled: false, requests: [], approvals: [] });
|
||||
render(<PaymentsApprovalsPage />);
|
||||
await screen.findByText(/payments are not enabled yet/i);
|
||||
expect(screen.getByRole("heading", { name: /approvals/i, level: 2 })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
90
src/components/orders/payments-approvals-page.tsx
Normal file
90
src/components/orders/payments-approvals-page.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 } from "@/lib/order-payments";
|
||||
|
||||
function fileUrl(d: PaymentDocument) {
|
||||
return `/api/v1/files/${encodeURIComponent(d.collection)}/${encodeURIComponent(d.recordId)}/${encodeURIComponent(d.filename)}`;
|
||||
}
|
||||
function message(e: unknown) { return e instanceof Error ? e.message : "Could not update payment."; }
|
||||
function Documents({ documents, label }: { documents: PaymentDocument[]; label: string }) {
|
||||
const unique = new Map<string, PaymentDocument>();
|
||||
for (const doc of documents || []) {
|
||||
const key = doc.filename.replace(/_[a-z0-9]{10}(\.[^.]+)$/, "$1");
|
||||
if (!unique.has(key)) unique.set(key, doc);
|
||||
}
|
||||
return <div className="space-y-1">{[...unique.values()].map((d, i) => <a className="block text-xs text-primary underline underline-offset-4" key={`${d.recordId}-${d.filename}`} href={fileUrl(d)} target="_blank" rel="noreferrer">{label}{unique.size > 1 ? ` ${i + 1}` : ""}</a>)}</div>;
|
||||
}
|
||||
|
||||
export function PaymentsApprovalsPage({ initialRequestId = "" }: { initialRequestId?: string }) {
|
||||
const [data, setData] = useState<PaymentsApprovalsData | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [view, setView] = useState(initialRequestId ? "all" : "outstanding");
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError("");
|
||||
try { setData(await paymentFetch<PaymentsApprovalsData>("/api/v1/order-payments")); }
|
||||
catch (e) { setError(message(e)); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
paymentFetch<PaymentsApprovalsData>("/api/v1/order-payments")
|
||||
.then((value) => { if (active) setData(value); })
|
||||
.catch((e) => { if (active) setError(message(e)); })
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
const query = search.trim().toLowerCase();
|
||||
const requests = (data?.requests || []).filter((r) => {
|
||||
const matches = [r.companyName, r.orderNumber, r.supplierName, ...r.lines.map((l) => l.description)].join(" ").toLowerCase().includes(query);
|
||||
return matches && (view === "all" || (view === "outstanding" ? ["draft", "pending"].includes(r.status) : r.status === view));
|
||||
});
|
||||
const approvals = (data?.approvals || []).filter((r) => [r.companyName, r.orderNumber, r.supplierName, r.description].join(" ").toLowerCase().includes(query));
|
||||
return <div className="space-y-7">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3"><h1 className="text-2xl font-semibold tracking-tight">Payments and Approvals</h1><Button size="sm" variant="outline" disabled={loading} onClick={() => void load()}><RefreshCw className="mr-2 size-4" />Refresh</Button></header>
|
||||
<div className="flex flex-wrap gap-3"><Input className="max-w-sm" aria-label="Search payments and approvals" placeholder="Search company, order, supplier or item" value={search} onChange={(e) => setSearch(e.target.value)} /><select aria-label="Payment history filter" className="h-9 rounded-md border border-input bg-background px-3 text-sm" value={view} onChange={(e) => setView(e.target.value)}><option value="outstanding">Outstanding payments</option><option value="paid">Paid</option><option value="all">All history</option></select></div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<section aria-labelledby="payments-heading" className="space-y-3">
|
||||
<h2 id="payments-heading" className="text-lg font-semibold">Payments <span className="text-sm font-normal text-muted-foreground">{requests.length}</span></h2>
|
||||
{data && !data.enabled && <p className="text-sm text-muted-foreground">Supplier payments are not enabled yet. The payment setup must be deployed first.</p>}
|
||||
<div className="overflow-hidden rounded-md border"><Table aria-label="Supplier payments"><TableHeader><TableRow><TableHead>Company / order</TableHead><TableHead>Supplier</TableHead><TableHead>Items</TableHead><TableHead>PO</TableHead><TableHead>Supplier documents</TableHead><TableHead>Proof of payment</TableHead><TableHead>Paid</TableHead></TableRow></TableHeader><TableBody>
|
||||
{!data && loading ? <TableRow><TableCell colSpan={7} className="h-20 text-center text-muted-foreground">Loading payments…</TableCell></TableRow> : requests.length === 0 ? <TableRow><TableCell colSpan={7} className="h-20 text-center text-muted-foreground">No outstanding payments in this view.</TableCell></TableRow> : requests.map((r) => <PaymentRow key={`${r.id}:${r.revision}`} request={r} onSaved={load} highlighted={r.id === initialRequestId} />)}
|
||||
</TableBody></Table></div>
|
||||
</section>
|
||||
<section aria-labelledby="approvals-heading" className="space-y-3"><h2 id="approvals-heading" className="text-lg font-semibold">Approvals <span className="text-sm font-normal text-muted-foreground">{approvals.length}</span></h2><p className="text-sm text-muted-foreground">Items waiting for the existing markup approval process.</p><div className="overflow-hidden rounded-md border"><Table aria-label="Markup approvals"><TableHeader><TableRow><TableHead>Company / order</TableHead><TableHead>Item / supplier</TableHead><TableHead>Unit cost</TableHead><TableHead>Unit selling</TableHead><TableHead>Markup</TableHead><TableHead><span className="sr-only">Actions</span></TableHead></TableRow></TableHeader><TableBody>{approvals.length === 0 ? <TableRow><TableCell colSpan={6} className="h-24 text-center text-muted-foreground">{loading && !data ? "Loading approvals…" : "No order items awaiting markup approval."}</TableCell></TableRow> : approvals.map((r) => <TableRow key={r.id}><TableCell><div className="font-medium">{r.companyName}</div><div className="text-xs text-muted-foreground">{r.orderNumber}</div></TableCell><TableCell className="max-w-sm whitespace-normal"><div>{r.description}</div><div className="text-xs text-muted-foreground">{r.supplierName}</div></TableCell><TableCell className="tabular-nums">{formatCurrency(r.cost)}</TableCell><TableCell className="tabular-nums">{formatCurrency(r.selling)}</TableCell><TableCell className="tabular-nums">{r.markup === null ? "Unpriced" : `${r.markup.toFixed(1)}%`}</TableCell><TableCell><Link className="text-sm text-primary underline underline-offset-4" href={`/orders/${r.order}/process`}>Open item</Link></TableCell></TableRow>)}</TableBody></Table></div></section> </div>;
|
||||
}
|
||||
|
||||
function PaymentRow({ request: r, onSaved, highlighted }: { request: PaymentRequestRow; onSaved: () => Promise<void>; highlighted: boolean }) {
|
||||
const [proof, setProof] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const active = ["draft", "pending"].includes(r.status);
|
||||
async function upload(file: File, operation: "documents" | "paid") {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("file", file); form.set("revision", String(r.revision)); form.set("upload_id", crypto.randomUUID());
|
||||
await paymentFetch(`/api/v1/order-payments/${r.id}/${operation}`, { method: "POST", body: form });
|
||||
await onSaved();
|
||||
} catch (e) { setError(message(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <TableRow className={highlighted ? "bg-muted/30" : undefined}>
|
||||
<TableCell><div className="font-medium">{r.companyName}</div><Link className="text-xs text-primary underline" href={`/orders/${r.order}/process`}>{r.orderNumber}</Link></TableCell>
|
||||
<TableCell>{r.supplierName}</TableCell>
|
||||
<TableCell className="min-w-40 max-w-xs whitespace-normal">{r.lines.map((line) => <div key={line.id}>{line.order_qty} × {line.description}</div>)}</TableCell>
|
||||
<TableCell>{r.purchaseOrders.length ? r.purchaseOrders.map((po) => <div key={po.id}><Documents documents={po.documents} label={po.number} />{po.documents.length === 0 && <span className="text-xs text-muted-foreground">{po.number} · PDF pending</span>}</div>) : <span className="text-xs text-muted-foreground">PO not created yet</span>}</TableCell>
|
||||
<TableCell><Documents documents={r.documents.filter((d) => d.role === "support" && d.collection === "order_items")} label="Supplier document" />{active && <label className="mt-2 inline-flex cursor-pointer text-xs font-medium text-primary underline underline-offset-4">Upload supplier invoice<input className="sr-only" type="file" aria-label={`Supplier invoice for ${r.orderNumber}`} disabled={busy} onChange={(e) => { const file = e.target.files?.[0]; if (file) void upload(file, "documents"); }} /></label>}</TableCell>
|
||||
<TableCell><Documents documents={r.documents.filter((d) => d.role === "proof")} label="View proof" />{active && <label className="inline-flex cursor-pointer text-xs font-medium text-primary underline underline-offset-4">{proof ? proof.name : "Upload proof of payment"}<input className="sr-only" type="file" aria-label="Proof of payment" disabled={busy} onChange={(e) => setProof(e.target.files?.[0] || null)} /></label>}</TableCell>
|
||||
<TableCell>{active ? <Button size="sm" disabled={busy || !proof} onClick={() => { if (proof) void upload(proof, "paid"); }}>{busy ? "Saving…" : "Mark paid"}</Button> : <><Badge variant="outline">{r.status === "paid" ? "Paid" : "Cancelled"}</Badge>{r.status === "paid" && <p className="mt-1 text-xs text-muted-foreground">{r.paid_by}</p>}</>}{error && <p role="alert" className="mt-2 max-w-52 whitespace-normal text-xs text-destructive">{error}</p>}</TableCell>
|
||||
</TableRow>;
|
||||
}
|
||||
|
|
@ -101,6 +101,7 @@ const orderItems: NavEntry[] = [
|
|||
// Revert by uncommenting the line below.
|
||||
// { href: "/orders/sprint", label: "Order Sprint", icon: Truck },
|
||||
{ href: "/orders/tasks", label: "Buyer Tasks", icon: ClipboardList },
|
||||
{ href: "/orders/payments-approvals", label: "Payments and Approvals", icon: CheckCircle },
|
||||
];
|
||||
|
||||
// Office-side view of the driver / collection workflow. The driver app
|
||||
|
|
@ -257,7 +258,7 @@ function NavLink({
|
|||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className={item.href === "/orders/payments-approvals" ? "whitespace-normal leading-tight" : "truncate"}>{item.label}</span>
|
||||
{active && (
|
||||
<span
|
||||
className="ml-auto flex h-2 items-center gap-[2px] rounded-full p-[2px]"
|
||||
|
|
|
|||
4
src/lib/currency.ts
Normal file
4
src/lib/currency.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** Currency formatting that is safe to import in client components. */
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(amount);
|
||||
}
|
||||
12
src/lib/order-payment-client.ts
Normal file
12
src/lib/order-payment-client.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
|
||||
/** Browser-session authentication is distinct from the audit attribution header. */
|
||||
export async function paymentFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${getPocketBase().authStore.token}`);
|
||||
const response = await fetch(url, { ...init, headers, cache: "no-store" });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`);
|
||||
return result as T;
|
||||
}
|
||||
58
src/lib/order-payments.ts
Normal file
58
src/lib/order-payments.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { OrderItem } from "@/lib/types";
|
||||
|
||||
export type PaymentStatus = "draft" | "pending" | "paid" | "cancelled";
|
||||
export interface PaymentDocument {
|
||||
collection: string;
|
||||
recordId: string;
|
||||
filename: string;
|
||||
role: "support" | "proof";
|
||||
}
|
||||
export interface SupplierPaymentRequest {
|
||||
id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
order: string;
|
||||
supplier: string;
|
||||
items: string[];
|
||||
status: PaymentStatus;
|
||||
amount: number;
|
||||
paid_amount: number;
|
||||
reference: string;
|
||||
payment_reference: string;
|
||||
note: string;
|
||||
requested_by: string;
|
||||
paid_by: string;
|
||||
paid_at: string;
|
||||
cancellation_reason: string;
|
||||
documents: PaymentDocument[];
|
||||
item_snapshot: Record<string, unknown>;
|
||||
revision: number;
|
||||
history: unknown[];
|
||||
}
|
||||
export interface PaymentRequestRow extends SupplierPaymentRequest {
|
||||
companyName: string;
|
||||
orderNumber: string;
|
||||
supplierName: string;
|
||||
lines: Array<{ id: string; description: string; order_qty: number; cost_price: number }>;
|
||||
purchaseOrders: Array<{ id: string; number: string; documents: PaymentDocument[] }>;
|
||||
}
|
||||
export interface ApprovalRow {
|
||||
id: string;
|
||||
order: string;
|
||||
companyName: string;
|
||||
orderNumber: string;
|
||||
supplierName: string;
|
||||
description: string;
|
||||
cost: number;
|
||||
selling: number;
|
||||
markup: number | null;
|
||||
}
|
||||
export interface PaymentsApprovalsData {
|
||||
enabled: boolean;
|
||||
requests: PaymentRequestRow[];
|
||||
approvals: ApprovalRow[];
|
||||
}
|
||||
export type PaymentOrderItem = OrderItem & {
|
||||
payment_status?: "required" | "paid" | "";
|
||||
payment_request?: string;
|
||||
};
|
||||
|
|
@ -75,3 +75,16 @@ describe("markDueLinesReady", () => {
|
|||
expect(r.marked.map((m) => m.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unpaid items pending without errors, while paid and legacy items become ready", async () => {
|
||||
getFullList.mockResolvedValueOnce([
|
||||
{ id: "unpaid", payment_status: "required", collection_date: "2026-10-28" },
|
||||
{ id: "paid", payment_status: "paid", collection_date: "2026-10-28" },
|
||||
{ id: "legacy", collection_date: "2026-10-28" },
|
||||
]);
|
||||
update.mockResolvedValue({});
|
||||
const result = await markDueLinesReady(new Date("2026-10-30T10:00:00Z"));
|
||||
expect(result.marked.map((row) => row.id)).toEqual(["paid", "legacy"]);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(update.mock.calls.map(([id]) => id)).toEqual(["paid", "legacy"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ export async function markDueLinesReady(now: Date = new Date()): Promise<ReadyDa
|
|||
|
||||
await runWithAuditActor({ name: ACTOR.name, source: "agent", ignoreRequestUser: true }, async () => {
|
||||
for (const row of due) {
|
||||
// A forecast date cannot release an unpaid supplier payment hold.
|
||||
// The PB hook also enforces this against concurrent changes/direct writes.
|
||||
if (row.payment_status === "required") continue;
|
||||
const id = String(row.id);
|
||||
const date = String(row.collection_date ?? "").slice(0, 10);
|
||||
try {
|
||||
|
|
|
|||
361
src/lib/server/order-payment-hooks.test.ts
Normal file
361
src/lib/server/order-payment-hooks.test.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any -- The fixture models dynamic PocketBase JSVM bindings. */
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import path from "node:path";
|
||||
|
||||
type Data = Record<string, any>;
|
||||
const collection = "supplier_payment_requests";
|
||||
function harness() {
|
||||
let sequence = 0;
|
||||
let tables: Record<string, Record<string, Data>> = {
|
||||
order_items: {}, supplier_payment_requests: {}, purchase_orders: {}, audit_log: {},
|
||||
};
|
||||
const fields: Record<string, string[]> = {
|
||||
order_items: ["payment_status", "payment_request", "ready_for_collection", "order_item_attachements"],
|
||||
supplier_payment_requests: ["order", "supplier", "items", "status", "amount", "paid_amount", "reference", "payment_reference", "note", "requested_by", "paid_by", "paid_at", "cancellation_reason", "documents", "item_snapshot", "revision", "history"],
|
||||
};
|
||||
class FakeRecord {
|
||||
data: Data;
|
||||
prior: Data;
|
||||
constructor(c: { name: string }, data: Data = {}) {
|
||||
this.data = { id: data.id || `rec${++sequence}`, collectionName: c.name, ...structuredClone(data) };
|
||||
this.prior = structuredClone(this.data);
|
||||
}
|
||||
get id() { return this.data.id; }
|
||||
set id(value: string) { this.data.id = value; }
|
||||
get(name: string) { return this.data[name]; }
|
||||
getString(name: string) { return String(this.data[name] || ""); }
|
||||
getBool(name: string) { return !!this.data[name]; }
|
||||
getInt(name: string) { return Number(this.data[name] || 0); }
|
||||
getFloat(name: string) { return Number(this.data[name] || 0); }
|
||||
set(name: string, value: unknown) {
|
||||
if (name === "order_item_attachements+") {
|
||||
const files = value as Array<{ originalName: string; name?: string }>;
|
||||
this.data.order_item_attachements = [...(this.data.order_item_attachements || []), ...files.map((file) => {
|
||||
const dot = file.originalName.lastIndexOf(".");
|
||||
return `${file.originalName.slice(0, dot)}_${String(++sequence).padStart(10, "0")}${file.originalName.slice(dot)}`;
|
||||
})];
|
||||
} else this.data[name] = structuredClone(value);
|
||||
}
|
||||
collection() { return { name: this.data.collectionName }; }
|
||||
original() { return new FakeRecord(this.collection(), this.prior); }
|
||||
toJSON() { return structuredClone(this.data); }
|
||||
}
|
||||
const app: any = {
|
||||
findCollectionByNameOrId(name: string) {
|
||||
if (!tables[name]) throw Error("missing collection");
|
||||
return { name, fields: { getByName: (field: string) => fields[name]?.includes(field) ? { name: field } : null } };
|
||||
},
|
||||
findRecordById(name: string, id: string) {
|
||||
const data = tables[name]?.[id];
|
||||
if (!data) throw Error("not found");
|
||||
return new FakeRecord({ name }, data);
|
||||
},
|
||||
findRecordsByFilter(name: string, _filter: string, _sort: string, _limit: number, _offset: number, params: any) {
|
||||
return Object.values(tables[name]).filter((data) => (!_filter.includes('status = "paid"') || data.status === "paid")).filter((data) => !params?.item || ((data.items || []).includes(params.item) || JSON.stringify(data.documents || []).includes(params.item) || JSON.stringify(data.history || []).includes(params.item))).map((data) => new FakeRecord({ name }, data));
|
||||
},
|
||||
save(record: FakeRecord) {
|
||||
tables[record.data.collectionName][record.id] = structuredClone(record.data);
|
||||
record.prior = structuredClone(record.data);
|
||||
},
|
||||
runInTransaction(fn: (tx: unknown) => unknown) {
|
||||
const before = structuredClone(tables);
|
||||
try { const result = fn(app); if (result !== undefined) throw Error("Transaction callback must not return a value"); } catch (error) { tables = before; throw error; }
|
||||
},
|
||||
};
|
||||
const sandbox: any = {
|
||||
module: { exports: {} }, Record: FakeRecord,
|
||||
BadRequestError: class extends Error {}, ForbiddenError: class extends Error {},
|
||||
ApiError: class extends Error { constructor(_status: number, message: string) { super(message); } },
|
||||
Date, JSON, console,
|
||||
};
|
||||
const source = fs.readFileSync(path.resolve("pocketbase/pb_hooks/order_payment_utils.js"), "utf8");
|
||||
vm.runInNewContext(source, sandbox);
|
||||
const utils = sandbox.module.exports;
|
||||
const actor = { id: "user1", name: "Buyer" };
|
||||
const event: any = { app, auth: { isSuperuser: () => true }, requestInfo: () => ({ headers: {} }) };
|
||||
const command = (body: Data) => utils.command(event, { actor, ...body }, () => {});
|
||||
const upload = (request: Data, role = "proof", originalName = `pay_${request.id}_${role}_0123456789abcdef.pdf`) => utils.uploadDocument({
|
||||
...event, findUploadedFiles: () => [{ originalName, size: 10 }],
|
||||
}, { request_id: request.id, revision: String(request.revision), role, actor_id: actor.id, actor_name: actor.name }, () => {});
|
||||
const seed = (data: Data = {}, name = "order_items") => {
|
||||
const record = new FakeRecord({ name }, {
|
||||
order: "order1", supplier: "supplier1", item_status: "processed", quantity: 4, order_qty: 10,
|
||||
cost_price: 3, payment_status: "", payment_request: "", ready_for_collection: false,
|
||||
order_item_attachements: ["photo.jpg", "invoice.pdf", "proof.pdf"], ...data,
|
||||
});
|
||||
app.save(record);
|
||||
return record;
|
||||
};
|
||||
const read = (id: string, name = collection) => app.findRecordById(name, id).toJSON();
|
||||
const support = (itemId: string) => ({ collection: "order_items", recordId: itemId, filename: "invoice.pdf", role: "support" });
|
||||
const proof = (itemId: string) => ({ collection: "order_items", recordId: itemId, filename: "proof.pdf", role: "proof" });
|
||||
const request = (item: FakeRecord) => command({ op: "require", item_id: item.id }).request;
|
||||
const pending = (item: FakeRecord) => {
|
||||
const r = request(item);
|
||||
return command({ op: "save", request_id: r.id, revision: r.revision, documents: [support(item.id)] }).request;
|
||||
};
|
||||
return { utils, app, event, command, upload, seed, read, support, proof, request, pending, fields };
|
||||
}
|
||||
|
||||
describe("supplier payment PocketBase enforcement", () => {
|
||||
it("pays a new draft directly from its automatically linked supplier PO and proof", () => {
|
||||
const h = harness();
|
||||
const po = h.seed({ documents: ["supplier_po.pdf"], documents_history: ["old_supplier_po.pdf"] }, "purchase_orders");
|
||||
const item = h.seed({ purchase_order: po.id });
|
||||
const r = h.request(item);
|
||||
expect(r.documents).toEqual(expect.arrayContaining([
|
||||
{ collection: "purchase_orders", recordId: po.id, filename: "supplier_po.pdf", role: "support" },
|
||||
{ collection: "purchase_orders", recordId: po.id, filename: "old_supplier_po.pdf", role: "support" },
|
||||
]));
|
||||
expect(() => h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [] })).toThrow(/proof/i);
|
||||
const paid = h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] }).request;
|
||||
expect(paid).toMatchObject({ status: "paid", paid_by: "Buyer" });
|
||||
expect(paid.paid_at).toBeTruthy();
|
||||
expect(h.read(item.id, "order_items")).toMatchObject({ payment_status: "paid", ready_for_collection: false, item_status: "processed" });
|
||||
});
|
||||
it("discovers a supplier PO linked after the toggle without requiring a separate save", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.request(item);
|
||||
const po = h.seed({ documents: ["new_po.pdf"] }, "purchase_orders");
|
||||
const changed = h.app.findRecordById("order_items", item.id); changed.set("purchase_order", po.id); changed.set("item_status", "po_generated");
|
||||
h.utils.protectItemUpdate({ ...h.event, record: changed, requestInfo: () => ({ body: { purchase_order: po.id, item_status: "po_generated" } }), next: () => { h.app.save(changed); } }, () => {});
|
||||
const fresh = h.read(r.id);
|
||||
const paid = h.command({ op: "paid", request_id: r.id, revision: fresh.revision, documents: [h.proof(item.id)] }).request;
|
||||
expect(paid.documents).toEqual(expect.arrayContaining([{ collection: "purchase_orders", recordId: po.id, filename: "new_po.pdf", role: "support" }]));
|
||||
expect(h.read(item.id, "order_items").item_status).toBe("po_generated");
|
||||
});
|
||||
it("rejects both newly discovered and earlier recorded PO evidence for a different supplier", () => {
|
||||
const h = harness();
|
||||
const wrongPo = h.seed({ supplier: "supplier2", documents: ["wrong_supplier.pdf"] }, "purchase_orders");
|
||||
const wrongItem = h.seed({ purchase_order: wrongPo.id });
|
||||
const wrongRequest = h.request(wrongItem);
|
||||
expect(wrongRequest.documents).toEqual([]);
|
||||
expect(() => h.command({ op: "save", request_id: wrongRequest.id, revision: wrongRequest.revision, documents: [{ collection: "purchase_orders", recordId: wrongPo.id, filename: "wrong_supplier.pdf", role: "support" }] })).toThrow(/document|belong/i);
|
||||
const po = h.seed({ supplier: "supplier1", documents: ["original_supplier.pdf"] }, "purchase_orders");
|
||||
const item = h.seed({ purchase_order: po.id }); const r = h.request(item);
|
||||
expect(r.documents).toHaveLength(1);
|
||||
const changed = h.app.findRecordById("order_items", item.id); changed.set("supplier", "supplier2");
|
||||
h.utils.protectItemUpdate({ ...h.event, record: changed, requestInfo: () => ({ body: { supplier: "supplier2" } }), next: () => { h.app.save(changed); } }, () => {});
|
||||
const fresh = h.read(r.id);
|
||||
expect(() => h.command({ op: "paid", request_id: r.id, revision: fresh.revision, documents: [h.proof(item.id)] })).toThrow(/supplier invoice|support/i);
|
||||
});
|
||||
it("updates routing metadata and retires the old supplier invoice without deleting its file", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.pending(item);
|
||||
const changed = h.app.findRecordById("order_items", item.id); changed.set("supplier", "supplier2"); changed.set("order", "order2");
|
||||
h.utils.protectItemUpdate({ ...h.event, record: changed, requestInfo: () => ({ body: { supplier: "supplier2", order: "order2" } }), next: () => { h.app.save(changed); } }, () => {});
|
||||
const fresh = h.read(r.id);
|
||||
expect(fresh).toMatchObject({ supplier: "supplier2", order: "order2", status: "draft", revision: r.revision + 1 });
|
||||
expect(fresh.documents).not.toContainEqual(h.support(item.id));
|
||||
expect(JSON.stringify(fresh.history)).toContain("invoice.pdf");
|
||||
expect(h.read(item.id, "order_items").order_item_attachements).toContain("invoice.pdf");
|
||||
expect(() => h.command({ op: "paid", request_id: fresh.id, revision: fresh.revision, documents: [h.proof(item.id)] })).toThrow(/supplier invoice|support/i);
|
||||
const latest = h.app.findRecordById("order_items", item.id); latest.set("order_item_attachements", [...latest.get("order_item_attachements"), "supplier2_invoice.pdf"]); h.app.save(latest);
|
||||
const registered = h.command({ op: "save", request_id: fresh.id, revision: fresh.revision, documents: [{ ...h.support(item.id), filename: "supplier2_invoice.pdf" }] }).request;
|
||||
expect(h.command({ op: "paid", request_id: registered.id, revision: registered.revision, documents: [h.proof(item.id)] }).request.status).toBe("paid");
|
||||
});
|
||||
it("does not automatically revive a retired legacy PO PDF after the item's supplier changes", () => {
|
||||
const h = harness(); const po = h.seed({ supplier: "", documents: ["legacy_po.pdf"] }, "purchase_orders");
|
||||
const item = h.seed({ purchase_order: po.id }); const r = h.request(item);
|
||||
const changed = h.app.findRecordById("order_items", item.id); changed.set("supplier", "supplier2");
|
||||
h.utils.protectItemUpdate({ ...h.event, record: changed, requestInfo: () => ({ body: { supplier: "supplier2" } }), next: () => { h.app.save(changed); } }, () => {});
|
||||
const fresh = h.read(r.id);
|
||||
expect(fresh.supplier).toBe("supplier2");
|
||||
expect(() => h.command({ op: "paid", request_id: fresh.id, revision: fresh.revision, documents: [h.proof(item.id)] })).toThrow(/supplier invoice|support/i);
|
||||
expect(fresh.documents).toEqual([]);
|
||||
});
|
||||
it("keeps other grouped members held and preserves their own support after one member changes supplier", () => {
|
||||
const h = harness(); const a = h.seed(); const b = h.seed(); const x = h.pending(a); const y = h.pending(b);
|
||||
const r = h.command({ op: "combine", request_ids: [x.id, y.id], revisions: { [x.id]: x.revision, [y.id]: y.revision } }).request;
|
||||
const changed = h.app.findRecordById("order_items", a.id); changed.set("supplier", "supplier2");
|
||||
h.utils.protectItemUpdate({ ...h.event, record: changed, requestInfo: () => ({ body: { supplier: "supplier2" } }), next: () => { h.app.save(changed); } }, () => {});
|
||||
const fresh = h.read(r.id);
|
||||
expect(fresh.documents).not.toContainEqual(h.support(a.id));
|
||||
expect(fresh.documents).toContainEqual(h.support(b.id));
|
||||
expect(h.read(b.id, "order_items").payment_status).toBe("required");
|
||||
expect(() => h.command({ op: "paid", request_id: fresh.id, revision: fresh.revision, documents: [h.proof(a.id), h.proof(b.id)] })).toThrow(/same order and supplier/i);
|
||||
});
|
||||
it("requires supplier support even when proof is uploaded, and requires a supplier", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.request(item);
|
||||
expect(() => h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] })).toThrow(/support|invoice/i);
|
||||
const noSupplier = h.seed({ supplier: "" }); const empty = h.request(noSupplier);
|
||||
expect(() => h.command({ op: "paid", request_id: empty.id, revision: empty.revision, documents: [h.support(noSupplier.id), h.proof(noSupplier.id)] })).toThrow(/supplier/i);
|
||||
});
|
||||
it("creates an immediate idempotent hold without changing markup approval or blocking a PO", () => {
|
||||
const h = harness();
|
||||
const item = h.seed({ item_status: "approval_needed" });
|
||||
const request = h.request(item);
|
||||
expect(h.request(item).id).toBe(request.id);
|
||||
expect(h.read(item.id, "order_items")).toMatchObject({ payment_status: "required", payment_request: request.id, item_status: "approval_needed" });
|
||||
expect(request).toMatchObject({ status: "draft" });
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...h.read(item.id, "order_items"), purchase_order: "po1", item_status: "po_generated" })).not.toThrow();
|
||||
});
|
||||
it("blocks ready flags, assignment and collection while allowing pending collection-pool membership", () => {
|
||||
const h = harness(); const item = h.seed(); h.request(item);
|
||||
const row = h.read(item.id, "order_items");
|
||||
for (const patch of [{ ready_for_collection: true }, { item_status: "assigned" }, { item_status: "collected" }, { drv_status: "assigned" }]) {
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, ...patch })).toThrow(/payment/i);
|
||||
}
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, item_status: "collection_pool" })).not.toThrow();
|
||||
});
|
||||
it("requires verified supplier evidence and proof, releasing only payment state", () => {
|
||||
const h = harness(); const item = h.seed({ item_status: "approval_needed" }); const r = h.pending(item);
|
||||
const pay = { op: "paid", request_id: r.id, revision: r.revision };
|
||||
expect(() => h.command({ ...pay, documents: [] })).toThrow(/proof/i);
|
||||
expect(h.read(r.id).status).toBe("pending");
|
||||
const paid = h.command({ ...pay, documents: [h.proof(item.id)] }).request;
|
||||
expect(paid).toMatchObject({ status: "paid", paid_by: "Buyer" });
|
||||
expect(h.read(item.id, "order_items")).toMatchObject({ payment_status: "paid", item_status: "approval_needed", ready_for_collection: false });
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...h.read(item.id, "order_items"), ready_for_collection: true })).not.toThrow();
|
||||
expect(() => h.command({ ...pay, documents: [h.proof(item.id)] })).toThrow(/changed|paid|refresh/i);
|
||||
});
|
||||
it("rejects missing or cross-item proof without releasing any member", () => {
|
||||
const h = harness(); const a = h.seed(); const b = h.seed(); const x = h.pending(a); const y = h.pending(b);
|
||||
const merged = h.command({ op: "combine", request_ids: [x.id, y.id], revisions: { [x.id]: x.revision, [y.id]: y.revision } }).request;
|
||||
const r = h.command({ op: "save", request_id: merged.id, revision: merged.revision }).request;
|
||||
expect(() => h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(a.id)] })).toThrow(/proof/i);
|
||||
expect(h.read(a.id, "order_items").payment_status).toBe("required");
|
||||
expect(h.read(b.id, "order_items").payment_status).toBe("required");
|
||||
});
|
||||
it("rejects a stale tab after item edits but lets a refreshed payer settle current procurement without a form", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.pending(item);
|
||||
const changed = h.app.findRecordById("order_items", item.id); changed.set("order_qty", 20);
|
||||
h.utils.protectItemUpdate({ ...h.event, record: changed, requestInfo: () => ({ body: { order_qty: 20 } }), next: () => { h.app.save(changed); } }, () => {});
|
||||
expect(() => h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] })).toThrow(/changed|refresh/i);
|
||||
const current = h.read(r.id);
|
||||
const paid = h.command({ op: "paid", request_id: r.id, revision: current.revision, documents: [h.proof(item.id)] }).request;
|
||||
expect(paid.item_snapshot[item.id].order_qty).toBe(20);
|
||||
expect(paid.status).toBe("paid");
|
||||
});
|
||||
it("accepts stored JSON snapshots whose object keys were reordered by PocketBase", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.pending(item);
|
||||
const request = h.app.findRecordById(collection, r.id);
|
||||
const current = request.get("item_snapshot")[item.id];
|
||||
request.set("item_snapshot", { [item.id]: { cost_price: current.cost_price, order: current.order, order_qty: current.order_qty, quantity: current.quantity, supplier: current.supplier } });
|
||||
h.app.save(request);
|
||||
expect(h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] }).request.status).toBe("paid");
|
||||
});
|
||||
it("reloads current projections so a stale direct item edit cannot erase a newly created hold", () => {
|
||||
const h = harness(); const item = h.seed(); const stale = h.app.findRecordById("order_items", item.id);
|
||||
const r = h.request(item); stale.set("item_status", "collection_pool");
|
||||
const e = { ...h.event, record: stale, requestInfo: () => ({ body: { item_status: "collection_pool" } }), next: () => { h.app.save(stale); } };
|
||||
h.utils.protectItemUpdate(e, () => {});
|
||||
expect(h.read(item.id, "order_items")).toMatchObject({ payment_status: "required", payment_request: r.id, item_status: "collection_pool" });
|
||||
stale.set("ready_for_collection", true);
|
||||
expect(() => h.utils.protectItemUpdate(e, () => {})).toThrow(/payment/i);
|
||||
});
|
||||
it("increments the request revision during an ordinary supplier-cost edit", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.pending(item);
|
||||
const edited = h.app.findRecordById("order_items", item.id); edited.set("cost_price", 4);
|
||||
const e = { ...h.event, record: edited, requestInfo: () => ({ body: { cost_price: 4 } }), next: () => { h.app.save(edited); } };
|
||||
h.utils.protectItemUpdate(e, () => {});
|
||||
expect(h.read(r.id)).toMatchObject({ status: "draft", revision: r.revision + 1 });
|
||||
expect(h.read(item.id, "order_items").cost_price).toBe(4);
|
||||
});
|
||||
it("cancels just one grouped item while preserving the other hold", () => {
|
||||
const h = harness(); const a = h.seed(); const b = h.seed(); const x = h.pending(a); const y = h.pending(b);
|
||||
const r = h.command({ op: "combine", request_ids: [x.id, y.id], revisions: { [x.id]: x.revision, [y.id]: y.revision } }).request;
|
||||
expect(() => h.command({ op: "cancel", request_id: r.id, revision: r.revision, item_id: a.id, reason: "" })).toThrow(/reason/i);
|
||||
const result = h.command({ op: "cancel", request_id: r.id, revision: r.revision, item_id: a.id, reason: "On account" }).request;
|
||||
expect(result).toMatchObject({ status: "draft", items: [b.id] });
|
||||
expect(h.read(a.id, "order_items").payment_status).toBe("");
|
||||
expect(h.read(b.id, "order_items").payment_status).toBe("required");
|
||||
expect(result.documents.length).toBeGreaterThan(0);
|
||||
expect(result.history.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("refuses combining different suppliers and new holds on ready/assigned items", () => {
|
||||
const h = harness(); const a = h.seed(); const b = h.seed({ supplier: "supplier2" }); const x = h.request(a); const y = h.request(b);
|
||||
expect(() => h.command({ op: "combine", request_ids: [x.id, y.id], revisions: { [x.id]: x.revision, [y.id]: y.revision } })).toThrow(/supplier/i);
|
||||
expect(() => h.request(h.seed({ ready_for_collection: true }))).toThrow(/ready/i);
|
||||
expect(() => h.request(h.seed({ item_status: "assigned" }))).toThrow(/assigned|collection/i);
|
||||
});
|
||||
it("protects direct payment projection changes and refuses missing schema", () => {
|
||||
const h = harness();
|
||||
expect(() => h.utils.protectProjection({ body: { payment_status: "paid" } })).toThrow(/payment/i);
|
||||
h.fields.supplier_payment_requests = h.fields.supplier_payment_requests.filter((field) => !["amount", "paid_amount", "reference", "payment_reference", "note"].includes(field));
|
||||
expect(() => h.utils.assertSchema(h.app)).not.toThrow();
|
||||
h.fields.order_items = [];
|
||||
expect(() => h.utils.assertSchema(h.app)).toThrow(/schema|migration/i);
|
||||
});
|
||||
it("makes a split remainder inherit a fresh hold while invalidating stale source revisions", () => {
|
||||
const h = harness(); const source = h.seed(); const r = h.pending(source);
|
||||
const remainder = h.seed({ supplier: "", order_item_details: JSON.stringify({ split: { sourceItemId: source.id } }) });
|
||||
h.utils.inheritSplit(h.event, remainder, () => {});
|
||||
expect(h.read(remainder.id, "order_items").payment_status).toBe("required");
|
||||
expect(h.read(r.id)).toMatchObject({ status: "draft" });
|
||||
const remainderRequest = h.read(h.read(remainder.id, "order_items").payment_request);
|
||||
expect(remainderRequest).toMatchObject({ status: "draft", supplier: "" });
|
||||
});
|
||||
it("does not let old paid evidence release a changed supplier or pre-collection amount", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.pending(item);
|
||||
h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] });
|
||||
const row = h.read(item.id, "order_items");
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, supplier: "different" })).toThrow(/changed|payment/i);
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, order_qty: 20 })).toThrow(/changed|payment/i);
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, cost_price: 8 })).toThrow(/changed|payment/i);
|
||||
const collected = h.app.findRecordById("order_items", item.id); collected.set("item_status", "collected"); h.app.save(collected);
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, item_status: "ready_invoice", quantity: 2, order_qty: 2 })).not.toThrow();
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, item_status: "collection_pool", quantity: 2, order_qty: 2 })).toThrow(/changed|payment/i);
|
||||
});
|
||||
it("allows customer quantity-only split reconciliation while preserving supplier payment coverage", () => {
|
||||
const h = harness(); const item = h.seed({ quantity: 20, order_qty: 10 }); const r = h.pending(item);
|
||||
h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] });
|
||||
const row = h.read(item.id, "order_items");
|
||||
const remainder = h.seed({ supplier: "", quantity: 10, order_qty: 10, order_item_details: { split: { sourceItemId: item.id } } });
|
||||
h.utils.inheritSplit(h.event, remainder, () => {});
|
||||
expect(h.read(remainder.id, "order_items").payment_status).toBe("");
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, quantity: 10 })).not.toThrow();
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, quantity: 10, order_qty: 20 })).toThrow(/changed|payment/i);
|
||||
});
|
||||
it("preserves every earlier paid proof through a fresh requirement, cancellation, and another payment", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.pending(item);
|
||||
h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] });
|
||||
let fresh = h.request(h.app.findRecordById("order_items", item.id));
|
||||
const withoutOldProof = () => ({ ...h.read(item.id, "order_items"), order_item_attachements: ["photo.jpg", "invoice.pdf", "proof2.pdf"] });
|
||||
expect(() => h.utils.checkReadiness(h.app, withoutOldProof())).toThrow(/proof/i);
|
||||
h.command({ op: "cancel", request_id: fresh.id, revision: fresh.revision, reason: "Not required" });
|
||||
expect(() => h.utils.checkReadiness(h.app, withoutOldProof())).toThrow(/proof/i);
|
||||
fresh = h.request(h.app.findRecordById("order_items", item.id));
|
||||
const pending = h.command({ op: "save", request_id: fresh.id, revision: fresh.revision, documents: [h.support(item.id)] }).request;
|
||||
const stored = h.app.findRecordById("order_items", item.id); stored.set("order_item_attachements", [...stored.get("order_item_attachements"), "proof2.pdf"]); h.app.save(stored);
|
||||
h.command({ op: "paid", request_id: pending.id, revision: pending.revision, documents: [{ ...h.proof(item.id), filename: "proof2.pdf" }] });
|
||||
expect(() => h.utils.checkReadiness(h.app, withoutOldProof())).toThrow(/proof/i);
|
||||
});
|
||||
it("uploads one stable document identity to every grouped item and deduplicates retries", () => {
|
||||
const h = harness(); const a = h.seed(); const b = h.seed(); const x = h.pending(a); const y = h.pending(b);
|
||||
const r = h.command({ op: "combine", request_ids: [x.id, y.id], revisions: { [x.id]: x.revision, [y.id]: y.revision } }).request;
|
||||
const first = h.upload(r);
|
||||
const second = h.upload(r);
|
||||
expect(first.documents).toEqual(second.documents);
|
||||
expect(first.documents).toHaveLength(2);
|
||||
expect(h.read(a.id, "order_items").order_item_attachements).toHaveLength(4);
|
||||
expect(h.read(b.id, "order_items").order_item_attachements).toHaveLength(4);
|
||||
expect(h.read(r.id).revision).toBe(r.revision);
|
||||
});
|
||||
it("preflights all grouped capacities and rejects stale uploads before appending any file", () => {
|
||||
const h = harness(); const a = h.seed(); const b = h.seed({ order_item_attachements: Array.from({ length: 10 }, (_, i) => `file${i}.pdf`) });
|
||||
const x = h.request(a); const y = h.request(b);
|
||||
const r = h.command({ op: "combine", request_ids: [x.id, y.id], revisions: { [x.id]: x.revision, [y.id]: y.revision } }).request;
|
||||
expect(() => h.upload(r)).toThrow(/10|capacity|attachments/i);
|
||||
expect(h.read(a.id, "order_items").order_item_attachements).toHaveLength(3);
|
||||
const pending = h.pending(h.seed()); const proof = h.upload(pending);
|
||||
h.command({ op: "paid", request_id: pending.id, revision: pending.revision, documents: proof.documents });
|
||||
const before = h.read(pending.items[0], "order_items").order_item_attachements;
|
||||
expect(() => h.upload(pending)).toThrow(/paid|changed|refresh/i);
|
||||
expect(h.read(pending.items[0], "order_items").order_item_attachements).toEqual(before);
|
||||
});
|
||||
it("preserves item files after cancelling a requirement by refusing historical item deletion", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.request(item);
|
||||
h.command({ op: "cancel", request_id: r.id, revision: r.revision, reason: "Created in error" });
|
||||
expect(h.read(item.id, "order_items").payment_status).toBe("");
|
||||
expect(() => h.utils.protectDeletion(h.app, h.app.findRecordById("order_items", item.id))).toThrow(/history|payment/i);
|
||||
});
|
||||
it("rejects deletion of a paid proof but permits adding another unrelated file", () => {
|
||||
const h = harness(); const item = h.seed(); const r = h.pending(item);
|
||||
h.command({ op: "paid", request_id: r.id, revision: r.revision, documents: [h.proof(item.id)] });
|
||||
const row = h.read(item.id, "order_items");
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, order_item_attachements: ["photo.jpg"] })).toThrow(/proof|document/i);
|
||||
expect(() => h.utils.checkReadiness(h.app, { ...row, order_item_attachements: [...row.order_item_attachements, "driver2.jpg"] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
32
src/lib/server/order-payment-route.ts
Normal file
32
src/lib/server/order-payment-route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBrowserSessionUser, type BrowserSessionUser } from "./browser-session";
|
||||
import type { PaymentDocument } from "@/lib/order-payments";
|
||||
|
||||
export async function paymentRoute(req: NextRequest, work: (actor: BrowserSessionUser) => Promise<unknown>) {
|
||||
try {
|
||||
const actor = await getBrowserSessionUser(req);
|
||||
return NextResponse.json(await work(actor));
|
||||
} catch (error) {
|
||||
const e = error as { status?: number; message?: string };
|
||||
return NextResponse.json({ error: e.message || "Payment request failed." }, { status: e.status && e.status >= 400 && e.status <= 599 ? e.status : 500 });
|
||||
}
|
||||
}
|
||||
export function badPaymentInput(message: string): never { throw Object.assign(new Error(message), { status: 422 }); }
|
||||
export function paymentRevision(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1) badPaymentInput("A current payment revision is required. Refresh and try again.");
|
||||
return raw;
|
||||
}
|
||||
export function paymentDocuments(raw: unknown): PaymentDocument[] | undefined {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw) || raw.length > 200) badPaymentInput("Invalid supporting documents.");
|
||||
return raw.map((d) => {
|
||||
if (!d || typeof d !== "object" || d.role !== "support" || !["order_items", "purchase_orders"].includes(d.collection) || ![d.recordId, d.filename].every((s) => typeof s === "string" && s.length > 0 && s.length < 300 && !s.includes("/"))) badPaymentInput("Invalid supporting document reference.");
|
||||
return { collection: d.collection, recordId: d.recordId, filename: d.filename, role: "support" };
|
||||
});
|
||||
}
|
||||
export async function paymentUpload(req: NextRequest) {
|
||||
const form = await req.formData();
|
||||
const file = form.get("file");
|
||||
if (!file || typeof file === "string" || file.size <= 0) badPaymentInput("Choose a document to upload.");
|
||||
return { form, file: file as File, revision: paymentRevision(Number(form.get("revision"))), uploadId: String(form.get("upload_id") || "") };
|
||||
}
|
||||
98
src/lib/server/order-payment-service.test.ts
Normal file
98
src/lib/server/order-payment-service.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({ getAdmin: vi.fn(), send: vi.fn(), getOne: vi.fn(), update: vi.fn(), getFullList: vi.fn() }));
|
||||
vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: mocks.getAdmin }));
|
||||
import { requireItemPayment, uploadPaymentDocument, markRequestPaid } from "./order-payment-service";
|
||||
|
||||
const actor = { id: "user1", name: "Buyer" };
|
||||
const request = { id: "request1", items: ["item1", "item2"], status: "pending", revision: 2, amount: 100, documents: [], order: "order1" };
|
||||
const storedDocuments = ["item1", "item2"].map((recordId) => ({ collection: "order_items", recordId, filename: "proof_abcdefghij.pdf", role: "proof" }));
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.getAdmin.mockResolvedValue({ send: mocks.send, collection: () => ({ getOne: mocks.getOne, update: mocks.update, getFullList: mocks.getFullList }) });
|
||||
mocks.send.mockImplementation(async (path: string, options?: { body?: FormData }) => path.includes("capability") ? { version: 3 } : path.endsWith("-document") ? { documents: storedDocuments.map((doc) => ({ ...doc, role: options?.body?.get("role") })) } : { request });
|
||||
mocks.getOne.mockImplementation(async (id: string) => id === "request1" ? request : { id, order_item_attachements: ["driver_abcdefghij.jpg"] });
|
||||
});
|
||||
describe("supplier payment service", () => {
|
||||
it("refuses to create a hold when enforcement is not deployed", async () => {
|
||||
mocks.send.mockRejectedValueOnce({ status: 404 });
|
||||
await expect(requireItemPayment("item1", actor)).rejects.toMatchObject({ status: 503 });
|
||||
expect(mocks.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("requires the atomic-upload guard capability before creating holds", async () => {
|
||||
mocks.send.mockResolvedValueOnce({ version: 1 });
|
||||
await expect(requireItemPayment("item1", actor)).rejects.toMatchObject({ status: 503 });
|
||||
expect(mocks.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("takes identity from its server caller and delegates atomic require", async () => {
|
||||
await requireItemPayment("item1", actor);
|
||||
expect(mocks.send).toHaveBeenLastCalledWith("/api/wfdash/order-payments", expect.objectContaining({ body: { op: "require", item_id: "item1", actor } }));
|
||||
});
|
||||
it("delegates grouped proof creation to PB before settling the exact stored copies", async () => {
|
||||
await markRequestPaid("request1", { revision: 2, file: new File(["pdf"], "proof.pdf", { type: "application/pdf" }), uploadId: "abcdefgh1234" }, actor);
|
||||
const upload = mocks.send.mock.calls.find(([path]) => path.endsWith("-document"));
|
||||
expect(upload).toBeDefined();
|
||||
const form = upload![1].body as FormData;
|
||||
expect(form.get("role")).toBe("proof");
|
||||
expect(form.get("request_id")).toBe("request1");
|
||||
expect(form.get("revision")).toBe("2");
|
||||
expect(form.get("actor_id")).toBe("user1");
|
||||
expect((form.get("file") as File).name).toMatch(/^pay_request1_proof_[a-f0-9]{16}\.pdf$/);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
expect(mocks.send).toHaveBeenLastCalledWith("/api/wfdash/order-payments", expect.objectContaining({ body: expect.objectContaining({ op: "paid", documents: storedDocuments }) }));
|
||||
});
|
||||
it("does not settle on atomic upload failure", async () => {
|
||||
mocks.send.mockImplementation(async (path: string) => {
|
||||
if (path.includes("capability")) return { version: 3 };
|
||||
if (path.endsWith("-document")) throw new Error("Upload failed");
|
||||
return { request };
|
||||
});
|
||||
await expect(markRequestPaid("request1", { revision: 2, file: new File(["pdf"], "proof.pdf"), uploadId: "abcdefgh1234" }, actor)).rejects.toThrow("Upload failed");
|
||||
expect(mocks.send.mock.calls.some(([, options]) => options?.body?.op === "paid")).toBe(false);
|
||||
});
|
||||
it("accepts a flagged draft item without amount or reference fields", async () => {
|
||||
mocks.getOne.mockResolvedValue({ ...request, status: "draft", amount: 0 });
|
||||
await markRequestPaid("request1", { revision: 2, file: new File(["pdf"], "proof.pdf"), uploadId: "abcdefgh1234" }, actor);
|
||||
const body = mocks.send.mock.calls.at(-1)?.[1].body;
|
||||
expect(body.op).toBe("paid");
|
||||
expect(body).not.toHaveProperty("paid_amount");
|
||||
expect(body).not.toHaveProperty("payment_reference");
|
||||
});
|
||||
it("propagates capacity rejection without registering evidence or settling", async () => {
|
||||
mocks.send.mockImplementation(async (path: string) => {
|
||||
if (path.includes("capability")) return { version: 3 };
|
||||
if (path.endsWith("-document")) throw Object.assign(new Error("An item already has 10 attachments"), { status: 409 });
|
||||
return { request };
|
||||
});
|
||||
await expect(uploadPaymentDocument("request1", { revision: 2, file: new File(["pdf"], "invoice.pdf"), uploadId: "abcdefgh1234" }, actor)).rejects.toMatchObject({ status: 409 });
|
||||
expect(mocks.send.mock.calls.some(([, options]) => options?.body?.op === "save")).toBe(false);
|
||||
});
|
||||
it("uses the same persistent document identity for concurrent calls without raw item appends", async () => {
|
||||
const input = { revision: 2, file: new File(["pdf"], "proof.pdf"), uploadId: "abcdefgh1234" };
|
||||
await Promise.all([markRequestPaid("request1", input, actor), markRequestPaid("request1", { ...input, uploadId: "otherupload123" }, actor)]);
|
||||
const uploads = mocks.send.mock.calls.filter(([path]) => path.endsWith("-document"));
|
||||
expect(uploads).toHaveLength(2);
|
||||
expect((uploads[0][1].body.get("file") as File).name).toBe((uploads[1][1].body.get("file") as File).name);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the active item's current supplier and company after a routing edit", async () => {
|
||||
mocks.getFullList.mockImplementation(async (options: { filter?: string }) => options.filter ? [] : [{
|
||||
...request, supplier: "old-supplier", order: "old-order",
|
||||
expand: { supplier: { suppliername: "Old Supplier" }, order: { order_number: "OLD", expand: { company: { company: "Old Company" } } }, items: [{ id: "item1", supplier: "new-supplier", order: "new-order", description: "Bolts", order_qty: 10, expand: { supplier: { suppliername: "New Supplier" }, order: { order_number: "NEW", expand: { company: { company: "New Company" } } }, purchase_order: { id: "old-po", supplier: "old-supplier", po_number: "OLD-PO", documents: ["old.pdf"] } } }] },
|
||||
}]);
|
||||
const { listPaymentsAndApprovals } = await import("./order-payment-service");
|
||||
const result = await listPaymentsAndApprovals();
|
||||
expect(result.requests[0]).toMatchObject({ supplier: "new-supplier", supplierName: "New Supplier", order: "new-order", companyName: "New Company", orderNumber: "NEW", purchaseOrders: [] });
|
||||
});
|
||||
|
||||
it("does not link a retired legacy PO PDF under the new supplier", async () => {
|
||||
mocks.getFullList.mockImplementation(async (options: { filter?: string }) => options.filter ? [] : [{
|
||||
...request,
|
||||
history: [{ action: "routing_changed", details: { item_id: "item1", invalidated_documents: [{ collection: "purchase_orders", recordId: "po1", filename: "old.pdf", role: "support" }] } }],
|
||||
expand: { items: [{ id: "item1", order: "order1", supplier: "new-supplier", expand: { supplier: { suppliername: "New Supplier" }, purchase_order: { id: "po1", supplier: "", po_number: "PO1", documents: ["old.pdf"] } } }] },
|
||||
}]);
|
||||
const { listPaymentsAndApprovals } = await import("./order-payment-service");
|
||||
expect((await listPaymentsAndApprovals()).requests[0].purchaseOrders[0].documents).toEqual([]);
|
||||
});
|
||||
145
src/lib/server/order-payment-service.ts
Normal file
145
src/lib/server/order-payment-service.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import { runWithAuditActor } from "./audit-context";
|
||||
import { attachmentList } from "./order-item-files";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { PaymentDocument, PaymentRequestRow, PaymentsApprovalsData, SupplierPaymentRequest, ApprovalRow } from "@/lib/order-payments";
|
||||
|
||||
type Actor = { id: string; name: string };
|
||||
type Pb = Awaited<ReturnType<typeof getAdminPocketBase>>;
|
||||
const REQUESTS = "supplier_payment_requests";
|
||||
const CAPABILITY = "/api/wfdash/order-payments-capability";
|
||||
function retiredPoFile(request: SupplierPaymentRequest, itemId: string, poId: string, filename: string): boolean {
|
||||
return (request.history || []).some((entry) => {
|
||||
const event = entry as { action?: string; details?: { item_id?: string; invalidated_documents?: PaymentDocument[] } } | null;
|
||||
return event?.action === "routing_changed" && event.details?.item_id === itemId &&
|
||||
!!event.details.invalidated_documents?.some((doc) => doc.collection === "purchase_orders" && doc.recordId === poId && doc.filename === filename);
|
||||
});
|
||||
}
|
||||
function fail(message: string, status = 409): never {
|
||||
throw Object.assign(new Error(message), { status });
|
||||
}
|
||||
export async function paymentCapability(pb: Pb): Promise<boolean> {
|
||||
try {
|
||||
const result = await pb.send<{ version: number }>(CAPABILITY, { method: "GET" });
|
||||
return result.version === 3;
|
||||
} catch (error) {
|
||||
if ([404, 503].includes(Number((error as { status?: number }).status))) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function enabledPb(): Promise<Pb> {
|
||||
const pb = await getAdminPocketBase();
|
||||
if (!await paymentCapability(pb)) fail("Supplier payments are not enabled yet. Complete the payment schema and hooks rollout first.", 503);
|
||||
return pb;
|
||||
}
|
||||
async function command(pb: Pb, body: Record<string, unknown>, actor: Actor): Promise<SupplierPaymentRequest> {
|
||||
return runWithAuditActor({ ...actor, source: "web" }, async () => {
|
||||
try {
|
||||
const response = await pb.send<{ request: SupplierPaymentRequest }>("/api/wfdash/order-payments", {
|
||||
method: "POST", body: { ...body, actor },
|
||||
});
|
||||
return response.request;
|
||||
} catch (error) {
|
||||
const e = error as { status?: number; response?: { message?: string }; message?: string };
|
||||
fail(e.response?.message || e.message || "Payment request could not be saved.", e.status === 400 ? 409 : e.status || 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
export async function requireItemPayment(itemId: string, actor: Actor) {
|
||||
if (typeof itemId !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(itemId)) fail("An order item is required.", 422);
|
||||
return command(await enabledPb(), { op: "require", item_id: itemId }, actor);
|
||||
}
|
||||
export async function savePaymentRequest(id: string, input: { revision: number; documents?: PaymentDocument[] }, actor: Actor) {
|
||||
return command(await enabledPb(), { op: "save", request_id: id, ...input }, actor);
|
||||
}
|
||||
export async function cancelPaymentRequest(id: string, input: { revision: number; reason: string; item_id?: string }, actor: Actor) {
|
||||
if (!input.reason?.trim()) fail("A reason is required to remove a payment requirement.", 422);
|
||||
return command(await enabledPb(), { op: "cancel", request_id: id, ...input }, actor);
|
||||
}
|
||||
export async function combinePaymentRequests(input: { request_ids: string[]; revisions: Record<string, number> }, actor: Actor) {
|
||||
if (!Array.isArray(input.request_ids) || input.request_ids.length < 2 || input.request_ids.length > 100) fail("Select between 2 and 100 payment requests.", 422);
|
||||
return command(await enabledPb(), { op: "combine", ...input }, actor);
|
||||
}
|
||||
|
||||
async function currentRequest(pb: Pb, id: string, revision: number): Promise<SupplierPaymentRequest> {
|
||||
const r = await pb.collection(REQUESTS).getOne<SupplierPaymentRequest>(id);
|
||||
if (r.revision !== revision) fail("This payment request changed. Refresh before continuing.");
|
||||
if (!["draft", "pending"].includes(r.status)) fail("This request is already paid or cancelled.");
|
||||
return r;
|
||||
}
|
||||
type Upload = { revision: number; file: File; uploadId: string };
|
||||
async function uploadToItems(pb: Pb, request: SupplierPaymentRequest, input: Upload, role: PaymentDocument["role"], actor: Actor): Promise<PaymentDocument[]> {
|
||||
if (!input.file || input.file.size <= 0 || input.file.size > 20 * 1024 * 1024) fail("Choose a non-empty file up to 20 MB.", 422);
|
||||
if (!/^[a-zA-Z0-9_-]{8,64}$/.test(input.uploadId)) fail("Invalid upload reference. Refresh and try again.", 422);
|
||||
const ext = input.file.name.match(/\.([a-zA-Z0-9]{1,8})$/)?.[1].toLowerCase() || "bin";
|
||||
const hash = createHash("sha256").update(Buffer.from(await input.file.arrayBuffer())).digest("hex").slice(0, 16);
|
||||
const filename = `pay_${request.id}_${role}_${hash}.${ext}`;
|
||||
const form = new FormData();
|
||||
form.set("file", input.file, filename);
|
||||
form.set("request_id", request.id);
|
||||
form.set("revision", String(request.revision));
|
||||
form.set("role", role);
|
||||
form.set("actor_id", actor.id);
|
||||
form.set("actor_name", actor.name);
|
||||
// The record read, content-identity lookup and append must share a PB
|
||||
// transaction. Client-side read-then-append duplicates files across tabs.
|
||||
let documents: PaymentDocument[];
|
||||
try {
|
||||
const response = await pb.send<{ documents: PaymentDocument[] }>("/api/wfdash/order-payments-document", { method: "POST", body: form });
|
||||
documents = response.documents;
|
||||
} catch (error) {
|
||||
const e = error as { status?: number; response?: { message?: string }; message?: string };
|
||||
fail(e.response?.message || e.message || "The document could not be uploaded. Payment remains pending.", e.status === 400 ? 409 : e.status || 500);
|
||||
}
|
||||
if (!Array.isArray(documents) || documents.length !== request.items.length ||
|
||||
!request.items.every((id) => documents.some((doc) => doc.collection === "order_items" && doc.recordId === id && doc.role === role && typeof doc.filename === "string" && doc.filename.length > 0))) {
|
||||
fail("The uploaded document could not be verified for every item. Payment remains pending.", 503);
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
export async function uploadPaymentDocument(id: string, input: Upload, actor: Actor) {
|
||||
const pb = await enabledPb();
|
||||
const r = await currentRequest(pb, id, input.revision);
|
||||
const documents = await uploadToItems(pb, r, input, "support", actor);
|
||||
return command(pb, { op: "save", request_id: id, revision: r.revision, documents }, actor);
|
||||
}
|
||||
export async function markRequestPaid(id: string, input: Upload, actor: Actor) {
|
||||
const pb = await enabledPb();
|
||||
const r = await currentRequest(pb, id, input.revision);
|
||||
const documents = await uploadToItems(pb, r, input, "proof", actor);
|
||||
return command(pb, { op: "paid", request_id: id, revision: r.revision, documents }, actor);
|
||||
}
|
||||
|
||||
type ExpandedOrder = { order_number?: string; expand?: { company?: { company?: string } } };
|
||||
type ExpandedItem = { id: string; order?: string; supplier?: string; description?: string; order_qty?: number; quantity?: number; cost_price?: number; expand?: { supplier?: { suppliername?: string }; order?: ExpandedOrder; purchase_order?: { id: string; supplier?: string; po_number?: string; documents?: string | string[]; documents_history?: string[] } } };
|
||||
type ExpandedRequest = SupplierPaymentRequest & { expand?: { order?: { order_number?: string; expand?: { company?: { company?: string } } }; supplier?: { suppliername?: string }; items?: ExpandedItem[] } };
|
||||
export async function listPaymentsAndApprovals(): Promise<PaymentsApprovalsData> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const [enabled, items] = await Promise.all([
|
||||
paymentCapability(pb),
|
||||
pb.collection("order_items").getFullList({ filter: 'item_status = "approval_needed" && order != ""', expand: "order.company,supplier", sort: "created" }),
|
||||
]);
|
||||
const approvals: ApprovalRow[] = items.map((r) => ({
|
||||
id: r.id, order: String(r.order), companyName: String(r.expand?.order?.expand?.company?.company || "Unknown company"),
|
||||
orderNumber: String(r.expand?.order?.order_number || r.order), supplierName: String(r.expand?.supplier?.suppliername || "Choose supplier"),
|
||||
description: String(r.description || ""), cost: Number(r.cost_price || 0), selling: Number(r.selling_price || 0),
|
||||
markup: Number(r.cost_price) > 0 ? (Number(r.selling_price || 0) - Number(r.cost_price)) / Number(r.cost_price) * 100 : null,
|
||||
}));
|
||||
if (!enabled) return { enabled, requests: [], approvals };
|
||||
const records = await pb.collection(REQUESTS).getFullList<ExpandedRequest>({ expand: "order.company,supplier,items.purchase_order,items.supplier,items.order.company", sort: "-created" });
|
||||
const requests: PaymentRequestRow[] = records.map((r) => {
|
||||
const lines = r.expand?.items || [];
|
||||
const purchaseOrders = new Map<string, PaymentRequestRow["purchaseOrders"][number]>();
|
||||
for (const item of lines) {
|
||||
const po = item.expand?.purchase_order;
|
||||
if (po && (!po.supplier || po.supplier === item.supplier)) purchaseOrders.set(po.id, { id: po.id, number: po.po_number || po.id, documents: [...new Set([...attachmentList(po.documents), ...attachmentList(po.documents_history)])].filter((filename) => !retiredPoFile(r, item.id, po.id, filename)).map((filename) => ({ collection: "purchase_orders", recordId: po.id, filename, role: "support" })) });
|
||||
}
|
||||
const current = ["draft", "pending"].includes(r.status) && lines.length > 0;
|
||||
const sameSupplier = lines.every((item) => item.supplier === lines[0]?.supplier);
|
||||
const sameOrder = lines.every((item) => item.order === lines[0]?.order);
|
||||
const order = current && sameOrder ? lines[0].expand?.order : r.expand?.order;
|
||||
const supplierName = current ? (sameSupplier ? lines[0].expand?.supplier?.suppliername || "Choose supplier on item" : "Suppliers changed: review items") : r.expand?.supplier?.suppliername || "Choose supplier on item";
|
||||
return { ...r, ...(current && sameOrder ? { order: lines[0].order || r.order } : {}), ...(current && sameSupplier ? { supplier: lines[0].supplier || "" } : {}), companyName: order?.expand?.company?.company || "Unknown company", orderNumber: order?.order_number || r.order, supplierName, lines: lines.map((item) => ({ id: item.id, description: item.description || "", order_qty: Number(item.order_qty ?? item.quantity ?? 0), cost_price: Number(item.cost_price || 0) })), purchaseOrders: [...purchaseOrders.values()] };
|
||||
});
|
||||
return { enabled, requests, approvals };
|
||||
}
|
||||
20
src/lib/server/order-process-service.ts
Normal file
20
src/lib/server/order-process-service.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { getOrderDetail } from "./orders-service";
|
||||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import type { OrderItem, QuoteItem, StockItem, Supplier } from "@/lib/types";
|
||||
|
||||
/** Process-page data, shared by initial load and item-only refreshes. */
|
||||
export async function getOrderProcessData(orderId: string, itemsOnly = false) {
|
||||
const pb = await getAdminPocketBase();
|
||||
if (itemsOnly) {
|
||||
const items = await pb.collection("order_items").getFullList<OrderItem>({ filter: pb.filter("order = {:id}", { id: orderId }), expand: "supplier" });
|
||||
return { items };
|
||||
}
|
||||
const [{ order, items }, suppliers, stock] = await Promise.all([
|
||||
getOrderDetail(orderId),
|
||||
pb.collection("suppliers").getFullList<Supplier>({ sort: "-created" }),
|
||||
pb.collection("stock_items").getFullList<StockItem>({ filter: "is_active = true", sort: "name", expand: "supplier" }).catch(() => [] as StockItem[]),
|
||||
]);
|
||||
const quoteId = order.quote || order.potential_quote;
|
||||
const quoteItems = quoteId ? await pb.collection("quote_items").getFullList<QuoteItem>({ filter: pb.filter("quote = {:id}", { id: quoteId }), expand: "supplier" }) : [];
|
||||
return { order, items, suppliers, stock, quoteItems };
|
||||
}
|
||||
|
|
@ -1146,6 +1146,9 @@ export interface Order extends BaseRecord {
|
|||
}
|
||||
|
||||
export interface OrderItem extends BaseRecord {
|
||||
/** Supplier payment state, independent of the buying/approval lifecycle. */
|
||||
payment_status?: "required" | "paid" | "";
|
||||
payment_request?: string;
|
||||
/**
|
||||
* Relation to the parent `orders` record. Optional as of the
|
||||
* 2026-07-19-delivery-notes-v3 migration: ad-hoc delivery
|
||||
|
|
|
|||
Loading…
Reference in a new issue