Add 2026-09-18 Payments & Remittance design spec

This commit is contained in:
Ysterbaard 2026-09-18 18:08:19 +02:00
parent 922cd01dd4
commit a8bacb8eb5

View file

@ -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="<value>"`; 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="<id>"'})` 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-<id>`.
- 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/<id>/<pop_file>`).
- **Replace POP** button (file picker; POSTs to `/api/payments/<id>/replace-pop`).
- **Unpay batch** button (confirms via `window.confirm`; POSTs to `/api/payments/<id>/unpay`).
- Expanded view: table of invoices in the batch. Columns: `Date`, `No`, `Buyer`, `Total (£)`, plus a link to `/invoices/<id>` 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 `<Link>`s after `Invoices`:
```tsx
<Link href="/unpaid" className="hover:underline">Unpaid</Link>
<Link href="/remittance" className="hover:underline">Remittance</Link>
```
---
## 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: <record> }`.
**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="<id>"'`).
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.