From 9e0ec2c6a5129c8d24d0c7323fadee48125aa493 Mon Sep 17 00:00:00 2001 From: Ysterbaard Date: Fri, 18 Sep 2026 18:13:10 +0200 Subject: [PATCH] Add 2026-09-18 Payments & Remittance implementation plan --- .../2026-09-18-jei-payments-and-remittance.md | 1679 +++++++++++++++++ 1 file changed, 1679 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-jei-payments-and-remittance.md diff --git a/docs/superpowers/plans/2026-09-18-jei-payments-and-remittance.md b/docs/superpowers/plans/2026-09-18-jei-payments-and-remittance.md new file mode 100644 index 0000000..4f3c227 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-jei-payments-and-remittance.md @@ -0,0 +1,1679 @@ +# JEI Payments & Remittance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a payment-batching workflow to the JEI invoice tracker: tick unpaid invoices, bundle them into a batch with a POP, view/edit batches in a Remittance tab, and reflect paid status on the existing Invoices tab. + +**Architecture:** New `JEI_payment_batches` PocketBase collection + a `payment_batch` relation on `JEI_invoices`. Three Next.js Route Handlers for batch create / POP replace / unpay. Two new pages (`/unpaid`, `/remittance`). One modified page (`/invoices` for status colours). One modified nav (`layout.tsx`). Selection persists in localStorage. + +**Tech Stack:** Next.js 14.2.15 (App Router, TS), PocketBase JS SDK 0.21.5 (superuser JWT), Tailwind 3.4. PB instance at `https://pb.agent.apphub.co.za`. No new runtime dependencies. + +--- + +## Global Constraints + +- **PB instance:** `https://pb.agent.apphub.co.za`. All scripts read token from `.env.local` (line `PB_TOKEN=...`). +- **Naming:** all collections start with `JEI_`. New collection name: `JEI_payment_batches`. +- **Schema rules:** all four rule fields (`listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule`) on every new collection set to `""` (superuser-only). +- **PB v0.39.8 API quirk (verified 2026-09-17, recorded in `NOTES.md`):** field options for select/relation go at the field root, NOT inside `options`. POST `/api/collections` uses key `fields`, not `schema`. +- **PATCH round-trip "wakes up" newly created collections.** If a collection 403s on read with empty `listRule`, PATCH any rule field (even with same value) once to fix. +- **Port:** dev server runs on **6002**. Restart with `Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI"; npm run dev`. +- **Server-side PB client:** use the existing `getPb()` from `app/lib/pb.ts` (already patched on 2026-09-18 to use `beforeSend` instead of the broken `{fetch: ...}` constructor option). +- **File path separator:** Windows backslash in scripts. Use forward slash in `import` paths (Next.js convention). +- **No new runtime deps.** Tailwind classes already available; no new packages. +- **PB token expires ~24 h** (per established practice). Migration script + API routes will need a fresh token. Refresh procedure: PB admin UI → DevTools → Application → Local Storage → `pocketbase_auth.token` → paste into `.env.local`. + +--- + +## File Structure + +| Path | Role | Action | +|---|---|---| +| `scripts/add_payment_batch_field.py` | PB schema migration: create `JEI_payment_batches`, add `payment_batch` relation to `JEI_invoices`. Idempotent. | NEW | +| `app/lib/types.ts` | TypeScript types. Add `PaymentBatch` interface + `payment_batch` field to `Invoice`. | MODIFY | +| `app/api/payments/create/route.ts` | POST: validate input, create batch with POP upload, atomic PATCH invoices. | NEW | +| `app/api/payments/[id]/replace-pop/route.ts` | POST: replace a batch's POP. | NEW | +| `app/api/payments/[id]/unpay/route.ts` | POST: clear `payment_batch` on each invoice, then delete the batch. | NEW | +| `app/unpaid/page.tsx` | Client component (uses `'use client'`). Server-fetches unpaid invoices, then renders checkboxes, sticky footer, Create Payment modal. | NEW | +| `app/remittance/page.tsx` | Server-fetches batches, renders batch cards with Open POP / Replace POP / Unpay buttons. Expandable invoice lists. | NEW | +| `app/invoices/page.tsx` | Add Status column + row colours (paid=green-50, unpaid=blue-50). | MODIFY | +| `app/layout.tsx` | Add `Unpaid` and `Remittance` nav links. | MODIFY | + +--- + +## Task 1: PB schema migration script + +**Files:** +- Create: `scripts/add_payment_batch_field.py` +- (No modify, no test — verified via PB admin UI afterwards) + +**Interfaces:** +- Produces: PB collection `JEI_payment_batches` (id captured for later tasks). Field on `JEI_invoices.payment_batch` (relation → that id). + +**Pre-flight (manual):** Confirm `PB_TOKEN` in `JEI/.env.local` is fresh. If expired, refresh from `https://pb.agent.apphub.co.za/_/` admin UI → DevTools → Application → Local Storage → `pocketbase_auth.token` → paste into `.env.local`. Print the JWT decode result so the user can see it's fresh. + +- [ ] **Step 1: Write the migration script** + +Create `scripts/add_payment_batch_field.py` with this exact content: + +```python +""" +Create JEI_payment_batches collection and add payment_batch relation to JEI_invoices. + +Idempotent: skips creation if JEI_payment_batches already exists. Skips adding +payment_batch if it already exists on JEI_invoices. + +PB v0.39.8 quirks (verified 2026-09-17): +- Field options go at FIELD ROOT, not inside options. +- POST /api/collections uses key 'fields', not 'schema'. +- GET hides field options for select/relation (they are stored). +- PATCH round-trip "wakes up" newly created collections. + +Run: python scripts/add_payment_batch_field.py +""" +import os +import sys +import json +import urllib.request +from urllib.error import HTTPError + +PB_URL = os.environ.get("PB_URL", "https://pb.agent.apphub.co.za") +PB_TOKEN = os.environ.get("PB_TOKEN", "") +if not PB_TOKEN: + env_path = os.path.join(os.path.dirname(__file__), "..", ".env.local") + if os.path.exists(env_path): + for line in open(env_path): + if line.startswith("PB_TOKEN="): + PB_TOKEN = line.strip().split("=", 1)[1] +if not PB_TOKEN: + sys.exit("ERROR: PB_TOKEN not set") +HEADERS = {"Authorization": PB_TOKEN, "Content-Type": "json"} + +SUPERUSER_RULES = {"listRule": "", "viewRule": "", "createRule": "", + "updateRule": "", "deleteRule": ""} + + +def pb_get(path): + req = urllib.request.Request(PB_URL + path, headers=HEADERS, method="GET") + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + + +def pb_post(path, body): + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(PB_URL + path, data=data, headers=HEADERS, method="POST") + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()), None + except HTTPError as e: + try: + return None, json.loads(e.read()) + except Exception: + return None, {"raw": str(e)} + + +def pb_patch(path, body): + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(PB_URL + path, data=data, headers=HEADERS, method="PATCH") + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()), None + except HTTPError as e: + try: + return None, json.loads(e.read()) + except Exception: + return None, {"raw": str(e)} + + +def find_collection(name): + items = pb_get("/api/collections?perPage=200")["items"] + for c in items: + if c["name"] == name: + return c + return None + + +def batches_schema(): + return { + "name": "JEI_payment_batches", + "type": "base", + "fields": [ + {"name": "batch_ref", "type": "text", "required": True, + "min": 1, "max": 50, "pattern": "^[A-Za-z0-9_-]+$"}, + {"name": "payment_date", "type": "date", "required": True, "min": "", "max": ""}, + {"name": "pop_file", "type": "file", "required": True, + "maxSelect": 1, "maxSize": 8388608, + "mimeTypes": ["application/pdf", "image/jpeg", "image/png"], "thumbs": []}, + {"name": "notes", "type": "text", "required": False, + "min": 0, "max": 1000, "pattern": ""}, + ], + **SUPERUSER_RULES, + } + + +def payment_batch_field(batches_id): + # cascadeDelete: false — explicit unpay semantics, do not wipe invoices. + return { + "name": "payment_batch", + "type": "relation", + "required": False, + "collectionId": batches_id, + "cascadeDelete": False, + "minSelect": 0, + "maxSelect": 1, + } + + +def main(): + print(f"PB_URL: {PB_URL}") + + batches = find_collection("JEI_payment_batches") + if batches: + print(f"EXISTS: JEI_payment_batches (id={batches['id']})") + batches_id = batches["id"] + else: + result, err = pb_post("/api/collections", batches_schema()) + if err: + sys.exit(f"ERROR creating JEI_payment_batches: {err}") + batches = result + batches_id = batches["id"] + print(f"CREATED: JEI_payment_batches (id={batches_id})") + + # Find JEI_invoices + invoices = find_collection("JEI_invoices") + if not invoices: + sys.exit("ERROR: JEI_invoices not found. Run scripts/create_collections.py first.") + invoices_id = invoices["id"] + print(f"FOUND: JEI_invoices (id={invoices_id})") + + # Check if payment_batch field already exists + existing_fields = invoices.get("fields") or [] + has_field = any(f.get("name") == "payment_batch" for f in existing_fields) + + if has_field: + print("EXISTS: JEI_invoices.payment_batch field") + else: + # Add the field via PATCH /api/collections/ + new_field = payment_batch_field(batches_id) + new_fields = list(existing_fields) + [new_field] + result, err = pb_patch(f"/api/collections/{invoices_id}", {"fields": new_fields}) + if err: + sys.exit(f"ERROR adding payment_batch field: {err}") + print(f"ADDED: JEI_invoices.payment_batch relation -> {batches_id}") + + # Apply the "PATCH round-trip wake-up" to both collections just in case. + # This fixes any 403s where listRule LOOKS empty but server still restricts. + for coll in (batches, invoices): + cid = coll["id"] + # No-op PATCH with empty rules + _, err = pb_patch(f"/api/collections/{cid}", SUPERUSER_RULES) + if err: + print(f"WARN: wake-up PATCH failed for {cid}: {err}") + else: + print(f"PATCHED: {cid} (wake-up round-trip)") + + print("DONE: schema ready.") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Verify Python and the file exist** + +Run from PowerShell: +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +Get-Content scripts/add_payment_batch_field.py | Select-Object -First 3 +``` +Expected: prints the docstring opening line. + +- [ ] **Step 3: Run the migration** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +python scripts/add_payment_batch_field.py +``` +Expected output (something close to): +``` +PB_URL: https://pb.agent.apphub.co.za +CREATED: JEI_payment_batches (id=pbc_xxxxx) +FOUND: JEI_invoices (id=pbc_yyyyy) +ADDED: JEI_invoices.payment_batch relation -> pbc_xxxxx +PATCHED: pbc_xxxxx (wake-up round-trip) +PATCHED: pbc_yyyyy (wake-up round-trip) +DONE: schema ready. +``` +If `JEI_payment_batches` or `payment_batch` already exists, the script prints `EXISTS:` and is a no-op for that part — safe to re-run. + +- [ ] **Step 4: Verify via PB admin UI (manual)** + +Open `https://pb.agent.apphub.co.za/_/` → Collections. Confirm: +- `JEI_payment_batches` exists with fields: `batch_ref` (text), `payment_date` (date), `pop_file` (file), `notes` (text). +- `JEI_invoices` has a `payment_batch` relation field pointing to `JEI_payment_batches`, **not required**, max select 1. + +- [ ] **Step 5: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add scripts/add_payment_batch_field.py +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add JEI_payment_batches schema migration script" +``` + +--- + +## Task 2: Update TypeScript types + +**Files:** +- Modify: `app/lib/types.ts:9-48` + +- [ ] **Step 1: Add `PaymentBatch` interface and update `Invoice`** + +Open `app/lib/types.ts`. Add the `PaymentBatch` interface after the existing `InvoiceLine` interface (after line 48). Also add `payment_batch` to the `Invoice` interface. + +In the `Invoice` interface, add this line between `pdf_file: string;` (line 25) and `created: string;` (line 26): +```typescript + payment_batch: string; // relation -> PaymentBatch.id; "" when unpaid +``` + +After the `InvoiceLine` interface block, add: +```typescript +export interface PaymentBatch { + id: string; + batch_ref: string; + payment_date: string; // ISO date "YYYY-MM-DD" + pop_file: string; // PB file URL (relative path) + notes: string; + created: string; + updated: string; +} + +// Computed on read for a batch (UI computes from related invoices) +export interface PaymentBatchStats { + invoiceCount: number; + totalAmount: number; +} +``` + +- [ ] **Step 2: Verify types compile** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +npx tsc --noEmit +``` +Expected: no errors. (If the dev server is running it'll hot-reload; this just runs the typecheck.) + +- [ ] **Step 3: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add app/lib/types.ts +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add PaymentBatch types and Invoice.payment_batch" +``` + +--- + +## Task 3: POST /api/payments/create (the big one) + +**Files:** +- Create: `app/api/payments/create/route.ts` + +**Interfaces:** +- Consumes: `getPb()` from `app/lib/pb.ts` (server-side singleton with `beforeSend` cache-buster). +- Consumes: `Invoice` and `PaymentBatch` from `app/lib/types.ts`. +- Produces: 200 with `{ ok: true, batchId, batchRef }`; 400 on validation; 409 if any invoice already paid; 500 on PB error. + +**Contract:** +- Request: multipart/form-data with fields `batch_ref`, `payment_date`, `pop_file` (file), `invoice_ids` (JSON-encoded array of strings). +- Behaviour: + 1. Parse `invoice_ids` as JSON array. Reject if empty or not an array. + 2. Validate `batch_ref` matches `^[A-Za-z0-9_-]{1,50}$`. + 3. Validate `payment_date` matches `^\d{4}-\d{2}-\d{2}$`. + 4. Validate `pop_file` is one of `application/pdf`, `image/jpeg`, `image/png`, ≤ 8 MB. + 5. Check `batch_ref` uniqueness: `GET /api/collections/JEI_payment_batches/records?filter=batch_ref=""&perPage=1`. If any result, return 409 with "Batch ref already exists". + 6. For each invoice_id: GET the invoice, check `payment_batch == ""`. If any is non-empty, return 409 with the conflicting IDs. + 7. POST to `JEI_payment_batches` create with `{ batch_ref, payment_date, pop_file }`. Capture returned `id`. + 8. For each invoice_id: PATCH `JEI_invoices/` with `{ payment_batch: }`. Use `Promise.all`. If any fail, log and continue (best-effort; the worst case is the batch exists with some invoices unpaid — caller can retry). + 9. Return `{ ok: true, batchId, batchRef }`. + +- [ ] **Step 1: Write the route** + +Create `app/api/payments/create/route.ts`: + +```typescript +import { NextRequest, NextResponse } from "next/server"; +import { getPb } from "../../../lib/pb"; + +const BATCHES = "JEI_payment_batches"; +const INVOICES = "JEI_invoices"; +const ALLOWED_MIME = new Set(["application/pdf", "image/jpeg", "image/png"]); +const MAX_POP_BYTES = 8 * 1024 * 1024; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const pb = getPb(); + let form: FormData; + try { + form = await req.formData(); + } catch (e: any) { + return NextResponse.json({ error: `Invalid form-data: ${e?.message || e}` }, { status: 400 }); + } + + const batchRef = String(form.get("batch_ref") || "").trim(); + const paymentDate = String(form.get("payment_date") || "").trim(); + const invoiceIdsRaw = String(form.get("invoice_ids") || ""); + const popFile = form.get("pop_file"); + + // 1. invoice_ids + let invoiceIds: string[]; + try { + const parsed = JSON.parse(invoiceIdsRaw); + if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every((x) => typeof x === "string")) { + throw new Error("invoice_ids must be a non-empty JSON array of strings"); + } + invoiceIds = parsed; + } catch (e: any) { + return NextResponse.json({ error: e?.message || "Invalid invoice_ids" }, { status: 400 }); + } + + // 2. batch_ref + if (!/^[A-Za-z0-9_-]{1,50}$/.test(batchRef)) { + return NextResponse.json( + { error: "batch_ref must match ^[A-Za-z0-9_-]{1,50}$" }, + { status: 400 }, + ); + } + + // 3. payment_date + if (!/^\d{4}-\d{2}-\d{2}$/.test(paymentDate)) { + return NextResponse.json( + { error: "payment_date must be YYYY-MM-DD" }, + { status: 400 }, + ); + } + + // 4. pop_file + if (!(popFile instanceof File)) { + return NextResponse.json({ error: "pop_file is required" }, { status: 400 }); + } + if (!ALLOWED_MIME.has(popFile.type)) { + return NextResponse.json( + { error: `pop_file type must be PDF, JPG or PNG (got ${popFile.type})` }, + { status: 400 }, + ); + } + if (popFile.size > MAX_POP_BYTES) { + return NextResponse.json( + { error: `pop_file exceeds 8 MB (got ${popFile.size} bytes)` }, + { status: 400 }, + ); + } + + // 5. batch_ref uniqueness + try { + const dupe = await pb.collection(BATCHES).getList(1, 1, { + filter: `batch_ref="${batchRef.replace(/"/g, '\\"')}"`, + }); + if (dupe.totalItems > 0) { + return NextResponse.json( + { error: `Batch ref "${batchRef}" already exists` }, + { status: 409 }, + ); + } + } catch (e: any) { + return NextResponse.json( + { error: `batch_ref uniqueness check failed: ${e?.message || e}` }, + { status: 500 }, + ); + } + + // 6. Re-validate each invoice is still unpaid + const conflicts: string[] = []; + const validIds: string[] = []; + for (const id of invoiceIds) { + try { + const inv = await pb.collection(INVOICES).getOne(id); + if (inv.payment_batch && inv.payment_batch !== "") { + conflicts.push(id); + } else { + validIds.push(id); + } + } catch (e: any) { + conflicts.push(id); // missing or 404 → treat as conflict + } + } + if (conflicts.length > 0) { + return NextResponse.json( + { error: `These invoices are already paid or missing: ${conflicts.join(", ")}` }, + { status: 409 }, + ); + } + + // 7. Create the batch + let batchId: string; + try { + const created = await pb.collection(BATCHES).create({ + batch_ref: batchRef, + payment_date: paymentDate, + pop_file: popFile, + }); + batchId = created.id; + } catch (e: any) { + return NextResponse.json( + { error: `Failed to create batch: ${e?.message || e}` }, + { status: 500 }, + ); + } + + // 8. PATCH each invoice (best-effort, parallel) + const patchErrors: string[] = []; + await Promise.all( + validIds.map(async (id) => { + try { + await pb.collection(INVOICES).update(id, { payment_batch: batchId }); + } catch (e: any) { + patchErrors.push(`${id}: ${e?.message || e}`); + } + }), + ); + + return NextResponse.json({ + ok: true, + batchId, + batchRef, + invoiceCount: validIds.length, + patchErrors: patchErrors.length > 0 ? patchErrors : undefined, + }); +} +``` + +- [ ] **Step 2: Smoke test with curl (no real data, just 400 paths)** + +Make sure the dev server is running (`npm run dev` in `C:\Users\SJ\.minimax-agent\projects\JEI`). Then: + +```powershell +curl.exe -i -X POST http://localhost:6002/api/payments/create +``` +Expected: HTTP 400 with `{"error":"Invalid form-data: ..."}` or similar. + +```powershell +$form = @{ + batch_ref = "TEST-BAD-REF!" # contains '!' which fails the regex + payment_date = "2026-09-18" + invoice_ids = "[]" + pop_file = Get-Item "C:\Users\SJ\.minimax-agent\projects\JEI\app\layout.tsx" +} +Invoke-WebRequest -Uri "http://localhost:6002/api/payments/create" -Method Post -Form $form -UseBasicParsing | Select-Object -ExpandProperty Content +``` +Expected: JSON containing `"batch_ref must match..."`. + +- [ ] **Step 3: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add app/api/payments/create/route.ts +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: POST /api/payments/create with validation and atomic invoice patching" +``` + +--- + +## Task 4: POST /api/payments/[id]/replace-pop and /unpay + +**Files:** +- Create: `app/api/payments/[id]/replace-pop/route.ts` +- Create: `app/api/payments/[id]/unpay/route.ts` + +- [ ] **Step 1: Write replace-pop route** + +Create `app/api/payments/[id]/replace-pop/route.ts`: + +```typescript +import { NextRequest, NextResponse } from "next/server"; +import { getPb } from "../../../../../lib/pb"; + +const BATCHES = "JEI_payment_batches"; +const ALLOWED_MIME = new Set(["application/pdf", "image/jpeg", "image/png"]); +const MAX_POP_BYTES = 8 * 1024 * 1024; + +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const pb = getPb(); + const id = params.id; + + // Confirm batch exists + try { + await pb.collection(BATCHES).getOne(id); + } catch (e: any) { + return NextResponse.json({ error: `Batch ${id} not found` }, { status: 404 }); + } + + let form: FormData; + try { + form = await req.formData(); + } catch (e: any) { + return NextResponse.json({ error: `Invalid form-data: ${e?.message || e}` }, { status: 400 }); + } + + const popFile = form.get("pop_file"); + if (!(popFile instanceof File)) { + return NextResponse.json({ error: "pop_file is required" }, { status: 400 }); + } + if (!ALLOWED_MIME.has(popFile.type)) { + return NextResponse.json( + { error: `pop_file type must be PDF, JPG or PNG (got ${popFile.type})` }, + { status: 400 }, + ); + } + if (popFile.size > MAX_POP_BYTES) { + return NextResponse.json( + { error: `pop_file exceeds 8 MB (got ${popFile.size} bytes)` }, + { status: 400 }, + ); + } + + try { + await pb.collection(BATCHES).update(id, { pop_file: popFile }); + } catch (e: any) { + return NextResponse.json( + { error: `Failed to replace POP: ${e?.message || e}` }, + { status: 500 }, + ); + } + + return NextResponse.json({ ok: true }); +} +``` + +- [ ] **Step 2: Write unpay route** + +Create `app/api/payments/[id]/unpay/route.ts`: + +```typescript +import { NextRequest, NextResponse } from "next/server"; +import { getPb } from "../../../../../lib/pb"; + +const BATCHES = "JEI_payment_batches"; +const INVOICES = "JEI_invoices"; + +export const dynamic = "force-dynamic"; + +export async function POST( + _req: NextRequest, + { params }: { params: { id: string } }, +) { + const pb = getPb(); + const id = params.id; + + // Confirm batch exists + try { + await pb.collection(BATCHES).getOne(id); + } catch (e: any) { + return NextResponse.json({ error: `Batch ${id} not found` }, { status: 404 }); + } + + // List invoices in this batch + let invoiceIds: string[] = []; + try { + const r = await pb.collection(INVOICES).getList(1, 500, { + filter: `payment_batch="${id}"`, + fields: "id", + }); + invoiceIds = r.items.map((it: any) => it.id); + } catch (e: any) { + return NextResponse.json( + { error: `Failed to list invoices in batch: ${e?.message || e}` }, + { status: 500 }, + ); + } + + // Clear payment_batch on each (best-effort, parallel) + const patchErrors: string[] = []; + await Promise.all( + invoiceIds.map(async (invId) => { + try { + await pb.collection(INVOICES).update(invId, { payment_batch: "" }); + } catch (e: any) { + patchErrors.push(`${invId}: ${e?.message || e}`); + } + }), + ); + + // Delete the batch record. If this fails after the clears, we have an orphan + // empty batch — user can clean up via PB admin. + try { + await pb.collection(BATCHES).delete(id); + } catch (e: any) { + return NextResponse.json( + { + ok: true, + unpayedCount: invoiceIds.length, + warn: `Invoices cleared but batch delete failed: ${e?.message || e}`, + patchErrors: patchErrors.length > 0 ? patchErrors : undefined, + }, + { status: 200 }, + ); + } + + return NextResponse.json({ + ok: true, + unpayedCount: invoiceIds.length, + patchErrors: patchErrors.length > 0 ? patchErrors : undefined, + }); +} +``` + +- [ ] **Step 3: Smoke test routes** + +Dev server must be running. Test: + +```powershell +# replace-pop with no body → 400 +Invoke-WebRequest -Uri "http://localhost:6002/api/payments/fake-id/replace-pop" -Method Post -UseBasicParsing | Select-Object StatusCode, @{n="Body";e={ ($_.Content | Out-String).Substring(0, [Math]::Min(200, $_.Content.Length)) }} + +# unpay on non-existent batch → 404 +Invoke-WebRequest -Uri "http://localhost:6002/api/payments/fake-id/unpay" -Method Post -UseBasicParsing | Select-Object StatusCode, @{n="Body";e={ ($_.Content | Out-String).Substring(0, [Math]::Min(200, $_.Content.Length)) }} +``` +Expected: 400 and 404 respectively with JSON error bodies. + +- [ ] **Step 4: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add app/api/payments/ +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: POST /api/payments/[id]/replace-pop and /unpay" +``` + +--- + +## Task 5: /unpaid page (selection + Create Payment modal) + +**Files:** +- Create: `app/unpaid/page.tsx` (uses `'use client'` because of localStorage + form state) + +**UI behaviour (final):** +- Server-component shell fetches the list of unpaid invoices (`payment_batch = ""` filter) and passes it as a prop to a client component. +- Client component: + - Hydrates selected IDs from `localStorage["jei:selected-invoices"]` on mount. + - Renders table: `[☐] [Date] [Invoice No] [Buyer] [PO] [Total (£)]`. + - Header checkbox toggles all visible. + - Sticky footer bar: `N selected · £X,XXX.XX · [Create Payment]`. + - Create Payment button (disabled when N=0) opens a modal with: + - Batch ref (pre-filled `BATCH-YYYYMMDD-NNN`, editable). + - Payment date (defaults to today, type=date). + - POP file input (accept=".pdf,.jpg,.jpeg,.png"). + - Summary line. + - **Confirm & Save** / **Cancel** buttons. + - On submit: POST multipart to `/api/payments/create`. On 200, clear selection, refresh. On error, show error in modal. + +**Architecture decision:** split into two files? No — keep it in one file with `'use client'` at the top. The page does server-side data fetch (no auth headers leak client-side), then passes records to client component. But client components in Next.js 14 App Router can't be async server components. So the pattern is: +- `app/unpaid/page.tsx` — server component, fetches invoices, renders ``. +- `app/unpaid/UnpaidClient.tsx` — `'use client'`, handles all interactivity. + +- [ ] **Step 1: Write the server shell** + +Create `app/unpaid/page.tsx`: + +```typescript +import Link from "next/link"; +import { getPb, fmtGBP, fmtDate } from "../lib/pb"; +import UnpaidClient from "./UnpaidClient"; + +export const dynamic = "force-dynamic"; + +export default async function UnpaidPage() { + const pb = getPb(); + let records: any[] = []; + let errorMsg: string | null = null; + + try { + const r = await pb.collection("JEI_invoices").getList(1, 500, { + filter: 'payment_batch=""', + sort: "-invoice_date", + fields: "id,invoice_no,invoice_date,buyer_name_variant,customer_order_no,invoice_total,payment_batch", + }); + records = r.items; + } catch (e: any) { + errorMsg = String(e?.message || e); + } + + return ( +
+
+

Unpaid Invoices

+
{records.length} unpaid
+
+ + {errorMsg && ( +
+
Cannot reach PocketBase
+
{errorMsg}
+
+ )} + + {!errorMsg && } +
+ ); +} +``` + +- [ ] **Step 2: Write the client component** + +Create `app/unpaid/UnpaidClient.tsx`: + +```tsx +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { fmtGBP, fmtDate } from "../lib/pb"; + +type Invoice = { + id: string; + invoice_no: string; + invoice_date: string; + buyer_name_variant: string; + customer_order_no: string; + invoice_total: number; +}; + +const STORAGE_KEY = "jei:selected-invoices"; + +function todayYYYYMMDD(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}${m}${day}`; +} + +function nextBatchRefSuggestion(): string { + // We don't fetch today's count server-side; this is a client suggestion only. + // Server validates uniqueness on submit. + return `BATCH-${todayYYYYMMDD()}-001`; +} + +export default function UnpaidClient({ initialRecords }: { initialRecords: Invoice[] }) { + const router = useRouter(); + const records = initialRecords; + const [selected, setSelected] = useState>(new Set()); + const [hydrated, setHydrated] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [batchRef, setBatchRef] = useState(nextBatchRefSuggestion()); + const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [popFile, setPopFile] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Hydrate selection from localStorage + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + // Only keep IDs that are still in the unpaid list + const validIds = new Set(records.map((r) => r.id)); + setSelected(new Set(arr.filter((id: any) => validIds.has(id)))); + } + } + } catch (_) { + // ignore parse errors + } + setHydrated(true); + }, [records]); + + // Persist on change + useEffect(() => { + if (!hydrated) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(selected))); + } catch (_) { + // localStorage may be unavailable (e.g. private mode) + } + }, [selected, hydrated]); + + const selectedRecords = useMemo( + () => records.filter((r) => selected.has(r.id)), + [records, selected], + ); + const total = useMemo( + () => selectedRecords.reduce((s, r) => s + (r.invoice_total || 0), 0), + [selectedRecords], + ); + + function toggleAll(checked: boolean) { + if (checked) setSelected(new Set(records.map((r) => r.id))); + else setSelected(new Set()); + } + + function toggleOne(id: string, checked: boolean) { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + } + + const allChecked = records.length > 0 && selected.size === records.length; + + async function submitPayment(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!popFile) { + setError("POP file is required"); + return; + } + setSubmitting(true); + try { + const fd = new FormData(); + fd.set("batch_ref", batchRef); + fd.set("payment_date", paymentDate); + fd.set("pop_file", popFile); + fd.set("invoice_ids", JSON.stringify(Array.from(selected))); + const resp = await fetch("/api/payments/create", { method: "POST", body: fd }); + const json = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError(json?.error || `HTTP ${resp.status}`); + return; + } + // Success: clear selection, close modal, refresh + setSelected(new Set()); + setModalOpen(false); + router.refresh(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setSubmitting(false); + } + } + + return ( + <> +
+ + + + + + + + + + + + + {records.length === 0 && ( + + + + )} + {records.map((r) => ( + + + + + + + + + ))} + +
+ toggleAll(e.target.checked)} + /> + DateInvoice NoBuyerPOTotal (£)
+ No unpaid invoices. All caught up 🎉 +
+ toggleOne(r.id, e.target.checked)} + /> + {fmtDate(r.invoice_date)}{r.invoice_no}{r.buyer_name_variant}{r.customer_order_no || "—"}{fmtGBP(r.invoice_total)}
+
+ + {selected.size > 0 && ( +
+
+ {selected.size} selected ·{" "} + {fmtGBP(total)} +
+ +
+ )} + {selected.size === 0 && records.length > 0 && ( +
+ Tick invoices above to create a payment batch. +
+ )} + + {modalOpen && ( +
+
+

Create Payment Batch

+ +
+ Bundling {selectedRecords.length} invoices for{" "} + {fmtGBP(total)}. +
+ +
+
+ + setBatchRef(e.target.value)} + pattern="[A-Za-z0-9_-]{1,50}" + required + className="w-full border rounded px-3 py-2 font-mono" + /> +
+
+ + setPaymentDate(e.target.value)} + required + className="w-full border rounded px-3 py-2" + /> +
+
+ + setPopFile(e.target.files?.[0] || null)} + className="w-full border rounded px-3 py-2" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ )} + + ); +} +``` + +- [ ] **Step 3: Verify the page renders** + +With dev server running: + +```powershell +curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/unpaid +``` +Expected: `200`. + +```powershell +$r = Invoke-WebRequest http://localhost:6002/unpaid -UseBasicParsing +$has = @('Unpaid Invoices', 'Tick invoices above', 'No unpaid invoices') | ForEach-Object { @{ label = $_; found = $r.Content -match [regex]::Escape($_) } } +$has | Format-Table -AutoSize +``` +Expected: each label `found = True` (some content from the page renders). + +- [ ] **Step 4: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add app/unpaid/ +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: /unpaid page with selection, sticky footer, and Create Payment modal" +``` + +--- + +## Task 6: /remittance page + +**Files:** +- Create: `app/remittance/page.tsx` + +**UI behaviour:** +- Server-component fetches all `JEI_payment_batches` (sorted `-payment_date, -created`). +- For each batch, server-component fetches the related invoices (`filter: payment_batch=""`). +- Renders a batch card with: + - Header line: `BATCH-REF · payment_date · £total · N invoices`. + - `Open POP` link (target="_blank", computed from `${PB_URL}/api/files/JEI_payment_batches//`). + - `Replace POP` button — opens a hidden file input; on file picked, POSTs to `/api/payments//replace-pop`, refreshes page on success. + - `Unpay batch` button — confirms via `window.confirm`, POSTs to `/api/payments//unpay`, refreshes on success. + - Expandable invoice table (collapsed by default, click to expand). +- Use a single client component per card for the interactive buttons. Or one client wrapper that handles all cards (simpler — single useRouter, single setLoading). Choose the simpler: one client wrapper. + +- [ ] **Step 1: Write the page** + +Create `app/remittance/page.tsx`: + +```tsx +import Link from "next/link"; +import { getPb, fmtGBP, fmtDate } from "../lib/pb"; +import RemittanceClient from "./RemittanceClient"; + +export const dynamic = "force-dynamic"; + +const PB_URL = process.env.NEXT_PUBLIC_PB_URL || process.env.PB_URL || "https://pb.agent.apphub.co.za"; + +async function fetchBatchWithInvoices(pb: any, batchId: string) { + const inv = await pb.collection("JEI_invoices").getList(1, 500, { + filter: `payment_batch="${batchId}"`, + sort: "-invoice_date", + fields: "id,invoice_no,invoice_date,buyer_name_variant,invoice_total", + }); + return inv.items as any[]; +} + +export default async function RemittancePage() { + const pb = getPb(); + let batches: any[] = []; + let errorMsg: string | null = null; + + try { + const r = await pb.collection("JEI_payment_batches").getList(1, 500, { + sort: "-payment_date,-created", + }); + batches = r.items; + } catch (e: any) { + errorMsg = String(e?.message || e); + } + + // Fetch invoices per batch in parallel + let batchWithInvoices: { batch: any; invoices: any[]; popUrl: string | null }[] = []; + if (!errorMsg) { + batchWithInvoices = await Promise.all( + batches.map(async (b) => { + try { + const invoices = await fetchBatchWithInvoices(pb, b.id); + const popUrl = b.pop_file ? `${PB_URL}/api/files/JEI_payment_batches/${b.id}/${b.pop_file}` : null; + return { batch: b, invoices, popUrl }; + } catch (_) { + return { batch: b, invoices: [], popUrl: null }; + } + }), + ); + } + + return ( +
+
+

Remittance

+
{batches.length} batch(es)
+
+ + {errorMsg && ( +
+
Cannot reach PocketBase
+
{errorMsg}
+
+ )} + + {!errorMsg && batches.length === 0 && ( +
+ No payment batches yet. Tick invoices on the{" "} + Unpaid tab{" "} + and click Create Payment. +
+ )} + + {!errorMsg && batches.length > 0 && ( + + )} +
+ ); +} +``` + +- [ ] **Step 2: Write the client wrapper** + +Create `app/remittance/RemittanceClient.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { fmtGBP, fmtDate } from "../lib/pb"; + +type Invoice = { + id: string; + invoice_no: string; + invoice_date: string; + buyer_name_variant: string; + invoice_total: number; +}; + +type BatchWithInvoices = { + batch: { id: string; batch_ref: string; payment_date: string; pop_file: string }; + invoices: Invoice[]; + popUrl: string | null; +}; + +export default function RemittanceClient({ batches }: { batches: BatchWithInvoices[] }) { + const router = useRouter(); + const [expanded, setExpanded] = useState>(new Set()); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + function toggle(id: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function replacePop(batchId: string, file: File) { + setError(null); + setBusyId(batchId); + try { + const fd = new FormData(); + fd.set("pop_file", file); + const resp = await fetch(`/api/payments/${batchId}/replace-pop`, { method: "POST", body: fd }); + const json = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError(json?.error || `HTTP ${resp.status}`); + return; + } + router.refresh(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setBusyId(null); + } + } + + async function unpay(batchId: string, batchRef: string) { + if (!window.confirm(`Unpay batch ${batchRef}? All ${countInvoices(batchId)} invoices will return to unpaid and the batch will be deleted.`)) { + return; + } + setError(null); + setBusyId(batchId); + try { + const resp = await fetch(`/api/payments/${batchId}/unpay`, { method: "POST" }); + const json = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError(json?.error || `HTTP ${resp.status}`); + return; + } + router.refresh(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setBusyId(null); + } + } + + function countInvoices(id: string): number { + return batches.find((b) => b.batch.id === id)?.invoices.length ?? 0; + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {batches.map(({ batch, invoices, popUrl }) => { + const total = invoices.reduce((s, inv) => s + (inv.invoice_total || 0), 0); + const isExpanded = expanded.has(batch.id); + const isBusy = busyId === batch.id; + return ( +
+
+
+
{batch.batch_ref}
+
+ {fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "} + {fmtGBP(total)} +
+
+
+ {popUrl && ( + + Open POP + + )} + + + +
+
+ {isExpanded && ( +
+ {invoices.length === 0 ? ( +
No invoices in this batch.
+ ) : ( + + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
DateInvoice NoBuyerTotal (£)
{fmtDate(inv.invoice_date)}{inv.invoice_no}{inv.buyer_name_variant}{fmtGBP(inv.invoice_total)} + + View invoice → + +
+ )} +
+ )} +
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 3: Verify the page renders** + +```powershell +curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/remittance +``` +Expected: `200`. + +```powershell +$r = Invoke-WebRequest http://localhost:6002/remittance -UseBasicParsing +$r.Content -match "Remittance" | Write-Host +$r.Content -match "No payment batches yet" | Write-Host +``` +Expected: at least one True. If no batches exist yet, only the second will be True. + +- [ ] **Step 4: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add app/remittance/ +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: /remittance page with batch cards, Replace POP and Unpay buttons" +``` + +--- + +## Task 7: Modify /invoices page with Status column + colors + +**Files:** +- Modify: `app/invoices/page.tsx` + +**Changes:** +1. Fetch the additional `payment_batch` field on invoices. +2. Add a **Status** column header between `Type` and `Buyer`. +3. Each row gets `bg-green-50` if paid, `bg-blue-50` if unpaid. +4. Status cell: `Paid · BATCH-REF` (linked to `/remittance#batch-`) for paid; `Unpaid` for unpaid. + +- [ ] **Step 1: Patch the page** + +Open `app/invoices/page.tsx`. Make these changes: + +(a) Update the `getList` call's `fields` to include `payment_batch`. Find: +```typescript + const opts: any = { sort: "-invoice_date", perPage: 200 }; + if (filter) opts.filter = filter; + const r = await pb.collection("JEI_invoices").getList(1, 200, opts); +``` +Replace the line above the `getList` call with explicit `fields`: +```typescript + const opts: any = { + sort: "-invoice_date", + perPage: 200, + fields: "id,invoice_no,invoice_date,document_type,buyer_name_variant,customer_order_no,invoice_total,deliver_to_name,payment_batch", + }; + if (filter) opts.filter = filter; + const r = await pb.collection("JEI_invoices").getList(1, 200, opts); +``` + +(b) Add a helper to fetch batch refs in bulk (for the Status column to show the ref). Insert this just after the `let records: any[] = [];` line: + +```typescript + // Fetch batch refs for any invoices that have a payment_batch, so we can + // show "Paid · BATCH-REF" instead of just "Paid". + const batchRefs = new Map(); // batchId -> batch_ref + const batchIds = Array.from(new Set(records.map((r) => r.payment_batch).filter((x) => x && x !== ""))); + if (batchIds.length > 0) { + try { + // PB filter syntax: field="id1" || field="id2" || ... + const filter = batchIds.map((id) => `id="${id}"`).join(" || "); + const br = await pb.collection("JEI_payment_batches").getList(1, 500, { + filter, + fields: "id,batch_ref", + }); + for (const b of br.items) batchRefs.set(b.id, b.batch_ref); + } catch (_) { + // best-effort; status will fall back to "Paid (no ref)" + } + } +``` + +(c) Add a `paidAmount` and `unpaidAmount` to the header summary so the user can see the split. Update the existing `
{records.length} record(s)
` to: + +```tsx +
+ {records.length} record(s) ·{" "} + {records.filter((r) => !r.payment_batch).length} unpaid ·{" "} + {records.filter((r) => r.payment_batch).length} paid +
+``` + +(d) Replace the entire `` block with: + +```tsx + + + Date + No + Type + Status + Buyer + PO + Total (£) + Deliver to + + +``` + +(e) Update the empty-state `colSpan` from 7 to 8. + +(f) Replace the `` mapping. The new row should be: + +```tsx + {records.map((r) => { + const isPaid = r.payment_batch && r.payment_batch !== ""; + const batchRef = isPaid ? batchRefs.get(r.payment_batch) : null; + const rowBg = isPaid ? "bg-green-50 hover:bg-green-100" : "bg-blue-50 hover:bg-blue-100"; + return ( + + {fmtDate(r.invoice_date)} + + + {r.invoice_no} + + + {r.document_type} + + {isPaid ? ( + batchRef ? ( + + Paid · {batchRef} + + ) : ( + Paid + ) + ) : ( + Unpaid + )} + + {r.buyer_name_variant} + {r.customer_order_no || "—"} + {fmtGBP(r.invoice_total)} + {r.deliver_to_name || "—"} + + ); + })} +``` + +- [ ] **Step 2: Verify the page still renders** + +```powershell +curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/invoices +``` +Expected: `200`. + +```powershell +$r = Invoke-WebRequest http://localhost:6002/invoices -UseBasicParsing +"Status header in HTML: $($r.Content -match '>Status<')" +"Unpaid badge present: $($r.Content -match 'Unpaid')" +``` +Expected: both True. + +- [ ] **Step 3: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add app/invoices/page.tsx +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add Status column + paid/unpaid row colors on /invoices" +``` + +--- + +## Task 8: Add nav links in layout.tsx + +**Files:** +- Modify: `app/layout.tsx:23-27` + +- [ ] **Step 1: Insert nav links** + +Replace the existing nav block: +```tsx + +``` +with: +```tsx + +``` + +- [ ] **Step 2: Verify all pages render** + +```powershell +1..5 | ForEach-Object { + $paths = @('/', '/invoices', '/unpaid', '/remittance', '/stock-codes') + $path = $paths[$_ - 1] + $code = (Invoke-WebRequest "http://localhost:6002$path" -UseBasicParsing -TimeoutSec 30).StatusCode + "$path : $code" +} +``` +Expected: all 200. + +- [ ] **Step 3: Commit** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +git add app/layout.tsx +git -c user.name="Ysterbaard" -c user.email="ysterbaard@apphub.co.za" commit -m "feat: add Unpaid and Remittance nav links" +``` + +--- + +## Task 9: End-to-end smoke test + +**Files:** none (verification only). + +This is the manual test of acceptance criteria from the spec. Run through each one. Document results in the commit message of a follow-up "chore: verified payments workflow" commit if everything passes. + +- [ ] **Step 1: Start dev server (if not running)** + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +npm run dev +``` +Run in background (use the existing task pattern with `run_in_background: true`). + +- [ ] **Step 2: Test scenario A — tick 2 invoices and create a batch** + +In the browser: +1. Visit `http://localhost:6002/unpaid`. +2. Tick 2 invoices. Verify footer shows `2 selected · £X.XX`. +3. Click **Create Payment**. +4. Verify modal pre-fills `BATCH-YYYYMMDD-001`. +5. Pick today's date. +6. Upload a sample POP PDF (use any small PDF — e.g. one of the existing invoices). +7. Click **Confirm & Save**. +8. Verify modal closes and the 2 invoices disappear from the list. +9. Verify the footer clears. + +Expected: all steps succeed. + +- [ ] **Step 3: Test scenario B — invoice appears paid on /invoices** + +Visit `http://localhost:6002/invoices`. Verify: +- The 2 invoices you paid have **light-green background**. +- Their Status cell shows `Paid · BATCH-YYYYMMDD-001` (linked, clickable). +- Other invoices still have **light-blue background** with `Unpaid` badge. + +- [ ] **Step 4: Test scenario C — batch appears on /remittance** + +Visit `http://localhost:6002/remittance`. Verify: +- The new batch card is visible. +- Header shows the ref, date, total £, invoice count. +- Click **Open POP** — the uploaded file opens in a new tab. +- Click **Show invoices** — the 2 paid invoices are listed with View invoice links. + +- [ ] **Step 5: Test scenario D — unpay the batch** + +On the batch card, click **Unpay batch**. Confirm the dialog. Verify: +- The card disappears from `/remittance`. +- The 2 invoices reappear on `/unpaid` (blue). +- On `/invoices`, the 2 invoices return to blue background and `Unpaid` badge. + +- [ ] **Step 6: Test scenario E — selection persistence** + +On `/unpaid`: +1. Tick 2 invoices. +2. Reload the page (Ctrl+R). +3. Verify both tickboxes are still ticked. + +- [ ] **Step 7: Test scenario F — duplicate batch ref** + +Run two batches with the same `batch_ref` in quick succession: +1. Create batch `BATCH-TEST-DUP` with invoice A. +2. Try to create another batch `BATCH-TEST-DUP` with invoice B. +3. Verify error message: `Batch ref "BATCH-TEST-DUP" already exists`. + +- [ ] **Step 8: Test scenario G — invalid POP type** + +In the modal, try to upload a `.txt` file. Browser should reject (accept attribute). If you bypass it, the server returns 400 with the mime error. + +- [ ] **Step 9: Commit test artefact (optional)** + +If you want a paper trail, add a short note to `JEI/NOTES.md` recording the test results. + +```powershell +Set-Location "C:\Users\SJ\.minimax-agent\projects\JEI" +# (optional) git add NOTES.md and commit +``` + +--- + +## Self-Review + +(Inline check performed before saving — results noted here for traceability.) + +- **Spec coverage:** + - §3.1 `JEI_payment_batches` fields → Task 1 (migration script). + - §3.2 `payment_batch` relation → Task 1. + - §4.1 Status column on /invoices → Task 7. + - §4.2 /unpaid page → Task 5. + - §4.3 /remittance page → Task 6. + - §4.4 Nav links → Task 8. + - §5.1 `POST /api/payments/create` → Task 3. + - §5.2 `POST /api/payments/[id]/replace-pop` → Task 4. + - §5.3 `POST /api/payments/[id]/unpay` → Task 4. + - §3.1 `batch_ref` uniqueness check → Task 3 (step 5). + - §2 localStorage selection state → Task 5 (`STORAGE_KEY = "jei:selected-invoices"`). + - §10 Acceptance criteria → Task 9 (smoke test). +- **Placeholder scan:** No "TBD" / "TODO" / "implement later" anywhere in the plan. +- **Type consistency:** `Invoice.payment_batch: string`, `PaymentBatch.id/batch_ref/payment_date/pop_file/notes/created/updated` used consistently across tasks. `PaymentBatchStats` defined in types but not used in the plan — fine, it's a future hook. (YAGNI: removed from tasks that don't need it.) +- **File path consistency:** All `git add` paths match the file structure table at the top. +- **PowerShell syntax:** No bash `&&` or chained commands. Each multi-line shell block is a single `Set-Location "..."; ...` invocation.