diff --git a/docs/superpowers/specs/2026-09-18-jei-payments-and-remittance-design.md b/docs/superpowers/specs/2026-09-18-jei-payments-and-remittance-design.md new file mode 100644 index 0000000..cdb1ac2 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-jei-payments-and-remittance-design.md @@ -0,0 +1,280 @@ +# JEI South Africa — Payments & Remittance — Design Spec + +**Date:** 2026-09-18 +**Owner:** Ysterbaard (SJ, accountant) +**Status:** Draft — awaiting user review +**Workspace:** `C:\Users\SJ\.minimax-agent\projects\JEI` +**Builds on:** `2026-09-17-jei-invoice-tracker-design.md` +**PB instance:** `https://pb.agent.apphub.co.za` (existing; collections prefixed `JEI_`) +**Port:** 6002 +**App name:** JEI South Africa + +--- + +## 1. Purpose + +Layer a payments workflow onto the existing invoice tracker. The accountant needs to: + +1. See all unpaid invoices in one place with checkboxes, running total in £, and quick batch creation. +2. Bundle any subset of unpaid invoices into one **payment batch** with a reference number, a payment date, and an uploaded POP (proof of payment — PDF / JPG / PNG). +3. Mark them as paid atomically — once paid, the invoices no longer appear in the selection list. +4. See paid invoices with their batch reference, and view each batch's POP + invoice list in a **Remittance** tab. +5. Fix mistakes — replace a POP, or "unpay" invoices if a batch was created in error. + +--- + +## 2. Scope + +### In scope (this spec) + +- One new PocketBase collection: **`JEI_payment_batches`**. Superuser-only via `listRule: ""`, etc. (Same pattern as existing collections.) +- One new relation field on `JEI_invoices`: **`payment_batch`** (relation → `JEI_payment_batches`, **optional**, cascadeDelete **false**). +- Two new UI pages: + - **`/unpaid`** — workflow tab: list of unpaid invoices with checkbox selection, running £ total, and "Create Payment" action. + - **`/remittance`** — ledger tab: list of payment batches, each expandable to show POP + invoice list. +- Modifications to existing UI: + - **`/invoices`** gains a **Status** column. Paid rows show the batch reference (linked); row background is light green. Unpaid rows have a light blue background. + - **Nav header** gains `Unpaid` and `Remittance` links. +- Three server actions (Next.js Route Handlers): + - `POST /api/payments/create` — create a batch, upload POP, patch selected invoices. + - `POST /api/payments/[id]/replace-pop` — replace the POP on a batch. + - `POST /api/payments/[id]/unpay` — clear `payment_batch` on every invoice in the batch and delete the batch record. +- Selection state persisted in **`localStorage`** under key `jei:selected-invoices` (JSON array of invoice IDs). + +### Out of scope (this spec) + +- Multi-user / role-based access (single accountant). +- Partial payment of an invoice (splitting one invoice across two batches). YAGNI — invoices are atomic units. +- Currency conversion (GBP ↔ ZAR) at payment time. The batch total stays in £. +- Reconciliation against supplier statement. The Remittance tab is read-only; no auto-match. +- Exporting the remittance view to PDF / CSV. +- Bank-feed / OFX / automatic POP ingestion. + +--- + +## 3. Data model + +### 3.1 New collection: `JEI_payment_batches` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `batch_ref` | text | yes | Free text, max 50 chars, pattern `^[A-Za-z0-9_-]{1,50}$`. **Auto-suggested** as `BATCH-YYYYMMDD-NNN` where `NNN` is the count of batches created that day + 1 (per-day sequence, zero-padded). User can edit before submit. **Uniqueness** is enforced at submit-time by `GET /api/collections/JEI_payment_batches/records?filter=batch_ref=""`; if any record is returned, abort with 409. +| `payment_date` | date | yes | ISO date `YYYY-MM-DD`. User picks in the modal. | +| `pop_file` | file | yes | Single file. Mime types: `application/pdf`, `image/jpeg`, `image/png`. Max size: 8 MB. | +| `notes` | text | no | Free text, max 1000 chars. Optional. | +| `invoice_count` | number | no | **Computed on read**, not stored. UI calls `pb.collection("JEI_invoices").getList(1, 1, {filter: 'payment_batch=""'})` to get `totalItems`. | +| `total_amount` | number | no | **Computed on read**. Sum of `invoice_total` across the batch's invoices. | + +**No `created`/`updated` fields need to be added** — PB auto-populates those. + +**Collection rules:** `listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule` all `""` (superuser-only). + +### 3.2 Modified collection: `JEI_invoices` + +Add one new field: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `payment_batch` | relation → `JEI_payment_batches` | **no** | `maxSelect: 1`, `minSelect: 0`, **`cascadeDelete: false`**. When the batch is deleted via "unpay", the app code clears this field on each invoice before deleting the batch. | + +**Why `cascadeDelete: false`?** Deleting a batch must not silently wipe invoice records — we want explicit unpay semantics. + +### 3.3 Invariants + +- **An invoice is unpaid iff `payment_batch` is empty.** There is no separate `status` field. This keeps the schema tight. +- **At most one batch per invoice.** `maxSelect: 1`. +- **A batch has ≥ 1 invoice.** The create action refuses to submit if zero invoices are selected. +- **All invoices in a batch share the same `payment_date` and POP.** Stored once on the batch. + +--- + +## 4. UI pages + +### 4.1 Modified `/invoices` + +- Add column **Status** between `Type` and `Buyer`. +- Paid rows: light green background (`bg-green-50`). Status cell shows `Paid · BATCH-20260918-001` with the batch_ref as a link to `/remittance#batch-`. +- Unpaid rows: light blue background (`bg-blue-50`). Status cell shows `Unpaid`. +- Sort order unchanged (`-invoice_date`). + +### 4.2 New `/unpaid` (workflow tab) + +- Fetches `JEI_invoices` with filter `payment_batch = ""` (i.e. `payment_batch = NULL` in PB filter syntax: `payment_batch=""`). +- Columns: `[☐ checkbox] [Date] [Invoice No] [Buyer] [PO] [Total (£)]`. +- Header checkbox selects all visible rows. +- Each row's checkbox state is hydrated from `localStorage["jei:selected-invoices"]` (array of invoice IDs). +- **Sticky footer bar** with: + - Live count: `N selected`. + - Live £ total: `£X,XXX.XX`. + - **Create Payment** button (disabled when N = 0). +- Clicking **Create Payment** opens a modal (inline component, not a new page) with: + - Batch ref (pre-filled with `BATCH-YYYYMMDD-NNN` — auto-computed client-side; user can edit; uniqueness validated on submit by trying POST and showing an error if 400). + - Payment date (date picker, defaults to today). + - POP file input (accepts `.pdf`, `.jpg`, `.png`, max 8 MB). + - Summary line: "Bundling N invoices for £X,XXX.XX". + - **Confirm & Save** button. + - Cancel button. +- On success: clear selection in localStorage, close modal, refresh the unpaid list (now missing those rows). +- On error: show the error message inline; don't clear selection. + +### 4.3 New `/remittance` (ledger tab) + +- Fetches all `JEI_payment_batches`, sorted `-payment_date, -created`. +- Each batch renders as a **card** (collapsible) with: + - Header: `BATCH-20260918-001 · 2026-09-18 · £1,234.56 · 3 invoices` (all four pieces visible at a glance). + - **Open POP** button (opens the file in a new tab via `${PB_URL}/api/files/JEI_payment_batches//`). + - **Replace POP** button (file picker; POSTs to `/api/payments//replace-pop`). + - **Unpay batch** button (confirms via `window.confirm`; POSTs to `/api/payments//unpay`). +- Expanded view: table of invoices in the batch. Columns: `Date`, `No`, `Buyer`, `Total (£)`, plus a link to `/invoices/` for the original detail page. +- Empty state: "No payment batches yet. Tick invoices on the Unpaid tab and click Create Payment." + +### 4.4 Modified nav header (`app/layout.tsx`) + +Add two ``s after `Invoices`: + +```tsx +Unpaid +Remittance +``` + +--- + +## 5. Server actions + +All endpoints live under `app/api/payments/`. They run on the Next.js server, use the same `getPb()` singleton (which already loads the superuser token from `.env.local`), and return JSON. + +### 5.1 `POST /api/payments/create` + +**Request:** multipart/form-data +- `batch_ref` (text) +- `payment_date` (text, ISO date) +- `pop_file` (file) +- `invoice_ids` (text — JSON-encoded array of strings) + +**Steps:** +1. Validate `invoice_ids` is a non-empty array. +2. Validate `batch_ref` is non-empty, ≤ 50 chars, matches a safe regex (alphanumeric + dash + underscore). +3. Validate `pop_file` is one of the allowed mime types and ≤ 8 MB. +4. Use `pb.collection("JEI_payment_batches").create({...})` with `pop_file` as a `File` object from `formData`. PB uploads the file and stores the filename in the record. +5. **Re-validate** each invoice: GET each by id, confirm `payment_batch == ""`. If any is already paid, abort with 409 + which IDs were already paid. Do NOT partially commit. +6. For each invoice: `pb.collection("JEI_invoices").update(id, { payment_batch: batchId })`. Use `Promise.all` for parallelism. +7. Return `{ ok: true, batchId, paymentBatch: }`. + +**Error responses:** `400` (validation), `409` (some invoices already paid), `500` (PB error with `message`). + +### 5.2 `POST /api/payments/[id]/replace-pop` + +**Request:** multipart/form-data with `pop_file` only. + +**Steps:** +1. Validate mime + size. +2. Confirm the batch exists (`getOne`). +3. `pb.collection("JEI_payment_batches").update(id, { pop_file: file })`. +4. Return `{ ok: true }`. + +### 5.3 `POST /api/payments/[id]/unpay` + +**Request:** none (or empty body). Confirmation already done client-side. + +**Steps:** +1. Fetch the batch. +2. List all invoices in the batch (`filter: 'payment_batch=""'`). +3. For each: `pb.collection("JEI_invoices").update(id, { payment_batch: "" })`. Use `Promise.all`. +4. Delete the batch record: `pb.collection("JEI_payment_batches").delete(id)`. +5. Return `{ ok: true, unpayedCount: N }`. + +**Note:** Step 3 happens before step 4. If step 4 fails (very unlikely), the batch remains but has zero invoices — recoverable manually via PB admin. + +--- + +## 6. File layout + +``` +JEI/ +├── app/ +│ ├── layout.tsx # MODIFIED: add Unpaid + Remittance nav +│ ├── page.tsx # unchanged +│ ├── invoices/ +│ │ ├── page.tsx # MODIFIED: add Status column, color rows +│ │ └── [id]/page.tsx # unchanged +│ ├── unpaid/ +│ │ └── page.tsx # NEW: selection + Create Payment modal +│ ├── remittance/ +│ │ └── page.tsx # NEW: batch cards with POP + invoice list +│ └── api/ +│ └── payments/ +│ ├── create/route.ts # NEW +│ └── [id]/ +│ ├── replace-pop/route.ts # NEW +│ └── unpay/route.ts # NEW +├── app/lib/types.ts # MODIFIED: add payment_batch to Invoice; new PaymentBatch interface +├── scripts/ +│ └── add_payment_batch_field.py # NEW: PB schema migration +└── docs/superpowers/specs/2026-09-18-jei-payments-and-remittance-design.md # this file +``` + +--- + +## 7. Edge cases + +| Scenario | Behaviour | +|---|---| +| User clicks Create Payment with 0 selected | Button disabled. No modal opens. | +| User edits `batch_ref` to one that already exists | PB rejects with 400. Modal shows "Batch ref already exists — please choose another." | +| Some invoices in selection were paid by another tab meanwhile | `POST /api/payments/create` returns 409 with the conflicting IDs. Modal shows "These invoices were already paid: 164912, 165001. Refresh and try again." Selection is NOT cleared. | +| POP file > 8 MB | Client-side check before submit. Server-side re-check returns 400. | +| POP file is wrong type (e.g. `.docx`) | Client-side accept attribute + server-side mime check. | +| Network error during batch creation | Modal shows error. Selection preserved. | +| User un-pays a batch, then changes their mind | The batch is gone. To recreate, they have to re-tick the invoices (they're unpaid again) and re-upload the POP. YAGNI for "undo unpay". | +| Two batches created on the same day | Auto-suggested refs differ by `NNN`. User can override. | +| `/remittance` opened while a batch is being created in another tab | Stale read; refresh picks it up. No optimistic UI in v1. | +| PB token expired during any action | Server returns 401; client surfaces the same "Cannot reach PocketBase" banner pattern already in use elsewhere. | + +--- + +## 8. Testing approach + +- **Schema sanity:** After migration, open PB admin UI; confirm `JEI_payment_batches` exists with all fields, and `JEI_invoices` has the new `payment_batch` relation. +- **Smoke flow (manual):** + 1. Tick 2 invoices on `/unpaid`. Verify running total. + 2. Create a batch. Verify modal shows preview, click confirm. + 3. Confirm those 2 invoices now have light-green row bg on `/invoices` and show the batch ref. + 4. Confirm `/remittance` shows the new batch card with POP link working. + 5. Click "Unpay batch" on the card. Confirm invoices return to light-blue on `/invoices`. Card disappears from `/remittance`. +- **Edit flow:** + 1. Create a batch, then click "Replace POP" with a different file. Confirm the new POP opens. + 2. Try to create two batches with the same `batch_ref`. Confirm the error message. +- **Selection persistence:** Tick invoices, reload `/unpaid`, confirm tickboxes remain. +- **PB token expiry:** Wait for token expiry, try to create a batch. Confirm a clean error, not a crash. + +--- + +## 9. Open notes for the user + +- **Migration is a Python script** (`scripts/add_payment_batch_field.py`). It will: + - Create `JEI_payment_batches`. + - Add the `payment_batch` relation field to `JEI_invoices`. + - Set superuser-only rules on both. + - Be **idempotent** — safe to re-run. + - **The user must provide a fresh PB_TOKEN** before running it (token in `.env.local` expires every ~24 h; today it expires 2026-09-18 19:10 SAST). The script reads from `.env.local` automatically. +- **`batch_ref` auto-generation** is computed in the browser from `new Date()` + a count fetched at page load. If two browser tabs both compute "BATCH-20260918-001" and submit simultaneously, the second one gets a 400 — the user retries, gets the next number, all good. No locking needed for a single-user app. +- **Why no `invoice_count`/`total_amount` columns stored?** Computing on read keeps the source of truth single. If the user later wants to filter/sort remittance by total without expanding each batch, we can add denormalised fields then. +- **Existing `JEI_invoices` records** all have `payment_batch = ""` (since the field doesn't exist yet). After migration, the filter `payment_batch = ""` will match them all — every existing invoice becomes "unpaid", which is the right starting state. +- **Why `cascadeDelete: false`?** If we delete a batch record with cascade, the linked invoices would be deleted too — disaster. We want explicit unpay semantics. + +--- + +## 10. Acceptance criteria + +A successful build is when **all** of the following are true: + +1. The migration script creates `JEI_payment_batches` and adds the `payment_batch` relation on `JEI_invoices`, both idempotent and superuser-only. +2. `/unpaid` lists all 24 currently-imported invoices (none have a batch yet). +3. Ticking 2 invoices shows `2 selected · £X.XX` in the sticky footer. +4. Creating a batch moves those 2 invoices off `/unpaid` and onto `/remittance` as a single batch card. +5. On `/invoices`, the 2 paid rows are light-green and show the `batch_ref` as a link. The remaining 22 are light-blue and show "Unpaid". +6. On `/remittance`, clicking "Open POP" opens the uploaded file in a new tab. +7. Clicking "Unpay batch" on `/remittance` clears the batch; the invoices return to `/unpaid` and to light-blue on `/invoices`. +8. Clicking "Replace POP" on `/remittance` swaps the file; the new file opens correctly. +9. Reloading `/unpaid` after ticking preserves the tick state. +10. Trying to create two batches with the same `batch_ref` produces a clear error.