Add 2026-09-18 Payments & Remittance design spec
This commit is contained in:
parent
922cd01dd4
commit
a8bacb8eb5
1 changed files with 280 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Reference in a new issue