2026-09-18 16:13:10 +00:00
# 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/< invoices_id >
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="<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/<id>` with `{ payment_batch: <newBatchId> }` . 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";
2026-09-18 17:08:28 +00:00
import { getPb } from "../../../../lib/pb";
2026-09-18 16:13:10 +00:00
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";
2026-09-18 17:08:28 +00:00
import { getPb } from "../../../../lib/pb";
2026-09-18 16:13:10 +00:00
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 `<UnpaidClient initialRecords={...} />` .
- `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 (
< div className = "space-y-6" >
< div className = "flex items-center justify-between" >
< h1 className = "text-2xl font-bold text-brand-dark" > Unpaid Invoices< / h1 >
< div className = "text-sm text-slate-500" > {records.length} unpaid< / div >
< / div >
{errorMsg & & (
< div className = "bg-red-50 border border-red-200 text-red-800 rounded p-4" >
< div className = "font-semibold" > Cannot reach PocketBase< / div >
< div className = "text-sm mt-1" > {errorMsg}< / div >
< / div >
)}
{!errorMsg & & < UnpaidClient initialRecords = {records} / > }
< / div >
);
}
```
- [ ] **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< Set < string > >(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< File | null > (null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState< string | null > (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 (
< >
< div className = "bg-white rounded-lg shadow overflow-hidden" >
< table className = "w-full text-sm" >
< thead className = "bg-slate-100 text-left" >
< tr >
< th className = "px-3 py-2 w-10" >
< input
type="checkbox"
aria-label="Select all"
checked={allChecked}
onChange={(e) => toggleAll(e.target.checked)}
/>
< / th >
< th className = "px-3 py-2" > Date< / th >
< th className = "px-3 py-2" > Invoice No< / th >
< th className = "px-3 py-2" > Buyer< / th >
< th className = "px-3 py-2" > PO< / th >
< th className = "px-3 py-2 text-right" > Total (£)< / th >
< / tr >
< / thead >
< tbody >
{records.length === 0 & & (
< tr >
< td colSpan = {6} className = "px-3 py-8 text-center text-slate-500" >
No unpaid invoices. All caught up 🎉
< / td >
< / tr >
)}
{records.map((r) => (
< tr key = {r.id} className = "border-t hover:bg-blue-50" >
< td className = "px-3 py-2" >
< input
type="checkbox"
aria-label={`Select invoice ${r.invoice_no}`}
checked={selected.has(r.id)}
onChange={(e) => toggleOne(r.id, e.target.checked)}
/>
< / td >
< td className = "px-3 py-2" > {fmtDate(r.invoice_date)}< / td >
< td className = "px-3 py-2 font-medium" > {r.invoice_no}< / td >
< td className = "px-3 py-2" > {r.buyer_name_variant}< / td >
< td className = "px-3 py-2 text-slate-600" > {r.customer_order_no || "—"}< / td >
< td className = "px-3 py-2 text-right tabular-nums" > {fmtGBP(r.invoice_total)}< / td >
< / tr >
))}
< / tbody >
< / table >
< / div >
{selected.size > 0 & & (
< div className = "sticky bottom-0 bg-white border-t shadow-lg p-4 flex items-center justify-between" >
< div className = "text-sm" >
< span className = "font-semibold" > {selected.size}< / span > selected ·{" "}
< span className = "font-semibold tabular-nums" > {fmtGBP(total)}< / span >
< / div >
< button
className="bg-brand text-white rounded px-4 py-2 hover:bg-brand-dark disabled:opacity-50"
disabled={selected.size === 0}
onClick={() => {
setBatchRef(nextBatchRefSuggestion());
setModalOpen(true);
}}
>
Create Payment
< / button >
< / div >
)}
{selected.size === 0 & & records.length > 0 & & (
< div className = "text-sm text-slate-500 text-center py-4" >
Tick invoices above to create a payment batch.
< / div >
)}
{modalOpen & & (
< div className = "fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" >
< div className = "bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4" >
< h2 className = "text-lg font-semibold" > Create Payment Batch< / h2 >
< div className = "text-sm bg-slate-50 rounded p-3" >
Bundling < strong > {selectedRecords.length}< / strong > invoices for{" "}
< strong className = "tabular-nums" > {fmtGBP(total)}< / strong > .
< / div >
< form onSubmit = {submitPayment} className = "space-y-3" >
< div >
< label className = "text-xs text-slate-500" > Batch reference< / label >
< input
type="text"
value={batchRef}
onChange={(e) => setBatchRef(e.target.value)}
pattern="[A-Za-z0-9_-]{1,50}"
required
className="w-full border rounded px-3 py-2 font-mono"
/>
< / div >
< div >
< label className = "text-xs text-slate-500" > Payment date< / label >
< input
type="date"
value={paymentDate}
onChange={(e) => setPaymentDate(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
< / div >
< div >
< label className = "text-xs text-slate-500" > POP file (PDF, JPG, PNG — max 8 MB)< / label >
< input
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
required
onChange={(e) => setPopFile(e.target.files?.[0] || null)}
className="w-full border rounded px-3 py-2"
/>
< / div >
{error & & (
< div className = "bg-red-50 border border-red-200 text-red-800 rounded p-3 text-sm" >
{error}
< / div >
)}
< div className = "flex gap-2 justify-end" >
< button
type="button"
className="px-4 py-2 rounded border hover:bg-slate-50"
onClick={() => {
setModalOpen(false);
setError(null);
}}
disabled={submitting}
>
Cancel
< / button >
< button
type="submit"
className="bg-brand text-white rounded px-4 py-2 hover:bg-brand-dark disabled:opacity-50"
disabled={submitting}
>
{submitting ? "Saving…" : "Confirm & Save"}
< / button >
< / div >
< / form >
< / div >
< / div >
)}
< />
);
}
```
- [ ] **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="< id > "`).
- 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/<id>/<pop_file>` ).
- `Replace POP` button — opens a hidden file input; on file picked, POSTs to `/api/payments/<id>/replace-pop` , refreshes page on success.
- `Unpay batch` button — confirms via `window.confirm` , POSTs to `/api/payments/<id>/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, {
2026-09-18 17:53:09 +00:00
sort: "-payment_date",
2026-09-18 16:13:10 +00:00
});
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 (
< div className = "space-y-6" >
< div className = "flex items-center justify-between" >
< h1 className = "text-2xl font-bold text-brand-dark" > Remittance< / h1 >
< div className = "text-sm text-slate-500" > {batches.length} batch(es)< / div >
< / div >
{errorMsg & & (
< div className = "bg-red-50 border border-red-200 text-red-800 rounded p-4" >
< div className = "font-semibold" > Cannot reach PocketBase< / div >
< div className = "text-sm mt-1" > {errorMsg}< / div >
< / div >
)}
{!errorMsg & & batches.length === 0 & & (
< div className = "bg-white rounded-lg shadow p-8 text-center text-slate-500" >
No payment batches yet. Tick invoices on the{" "}
< Link href = "/unpaid" className = "text-brand hover:underline" > Unpaid tab< / Link > {" "}
and click < strong > Create Payment< / strong > .
< / div >
)}
{!errorMsg & & batches.length > 0 & & (
< RemittanceClient batches = {batchWithInvoices} / >
)}
< / div >
);
}
```
- [ ] **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< Set < string > >(new Set());
const [busyId, setBusyId] = useState< string | null > (null);
const [error, setError] = useState< string | null > (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 (
< div className = "space-y-4" >
{error & & (
< div className = "bg-red-50 border border-red-200 text-red-800 rounded p-3 text-sm" >
{error}
< / div >
)}
{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 (
< div key = {batch.id} id = {`batch-${batch.id}`} className = "bg-white rounded-lg shadow" >
< div className = "px-6 py-4 border-b flex items-center justify-between gap-4 flex-wrap" >
< div >
< div className = "font-mono font-semibold text-brand-dark" > {batch.batch_ref}< / div >
< div className = "text-xs text-slate-500" >
{fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "}
< span className = "tabular-nums" > {fmtGBP(total)}< / span >
< / div >
< / div >
< div className = "flex gap-2 flex-wrap" >
{popUrl & & (
< a
href={popUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
>
Open POP
< / a >
)}
< label className = "text-sm border rounded px-3 py-1 hover:bg-slate-50 cursor-pointer" >
{isBusy ? "Uploading…" : "Replace POP"}
< input
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
className="hidden"
disabled={isBusy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) replacePop(batch.id, f);
e.target.value = ""; // allow re-selecting same file
}}
/>
< / label >
< button
className="text-sm border border-red-200 text-red-700 rounded px-3 py-1 hover:bg-red-50 disabled:opacity-50"
disabled={isBusy}
onClick={() => unpay(batch.id, batch.batch_ref)}
>
{isBusy ? "Working…" : "Unpay batch"}
< / button >
< button
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
onClick={() => toggle(batch.id)}
>
{isExpanded ? "Hide" : "Show"} invoices
< / button >
< / div >
< / div >
{isExpanded & & (
< div className = "px-6 py-3" >
{invoices.length === 0 ? (
< div className = "text-sm text-slate-500 py-2" > No invoices in this batch.< / div >
) : (
< table className = "w-full text-sm" >
< thead className = "text-left text-slate-500" >
< tr >
< th className = "py-1" > Date< / th >
< th className = "py-1" > Invoice No< / th >
< th className = "py-1" > Buyer< / th >
< th className = "py-1 text-right" > Total (£)< / th >
< th > < / th >
< / tr >
< / thead >
< tbody >
{invoices.map((inv) => (
< tr key = {inv.id} className = "border-t" >
< td className = "py-2" > {fmtDate(inv.invoice_date)}< / td >
< td className = "py-2 font-medium" > {inv.invoice_no}< / td >
< td className = "py-2" > {inv.buyer_name_variant}< / td >
< td className = "py-2 text-right tabular-nums" > {fmtGBP(inv.invoice_total)}< / td >
< td className = "py-2 text-right" >
< Link href = {`/invoices/${inv.id}`} className = "text-brand hover:underline text-xs" >
View invoice →
< / Link >
< / td >
< / tr >
))}
< / tbody >
< / table >
)}
< / div >
)}
< / div >
);
})}
< / div >
);
}
```
- [ ] **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-<id>` ) 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< string , string > (); // 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 `<div className="text-sm text-slate-500">{records.length} record(s)</div>` to:
```tsx
< div className = "text-sm text-slate-500" >
{records.length} record(s) ·{" "}
{records.filter((r) => !r.payment_batch).length} unpaid ·{" "}
{records.filter((r) => r.payment_batch).length} paid
< / div >
```
(d) Replace the entire `<thead>` block with:
```tsx
< thead className = "bg-slate-100 text-left" >
< tr >
< th className = "px-3 py-2" > Date< / th >
< th className = "px-3 py-2" > No< / th >
< th className = "px-3 py-2" > Type< / th >
< th className = "px-3 py-2" > Status< / th >
< th className = "px-3 py-2" > Buyer< / th >
< th className = "px-3 py-2" > PO< / th >
< th className = "px-3 py-2 text-right" > Total (£)< / th >
< th className = "px-3 py-2" > Deliver to< / th >
< / tr >
< / thead >
```
(e) Update the empty-state `colSpan` from 7 to 8.
(f) Replace the `<tr key={r.id} className="border-t hover:bg-slate-50">` 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 (
< tr key = {r.id} className = {`border-t $ { rowBg } ` } >
< td className = "px-3 py-2" > {fmtDate(r.invoice_date)}< / td >
< td className = "px-3 py-2" >
< Link href = {`/invoices/${r.id}`} className = "text-brand hover:underline font-medium" >
{r.invoice_no}
< / Link >
< / td >
< td className = "px-3 py-2 capitalize" > {r.document_type}< / td >
< td className = "px-3 py-2 text-sm" >
{isPaid ? (
batchRef ? (
< Link href = {`/remittance#batch-${r.payment_batch}`} className = "text-green-800 hover:underline" >
Paid · < span className = "font-mono" > {batchRef}< / span >
< / Link >
) : (
< span className = "text-green-800" > Paid< / span >
)
) : (
< span className = "text-blue-800" > Unpaid< / span >
)}
< / td >
< td className = "px-3 py-2" > {r.buyer_name_variant}< / td >
< td className = "px-3 py-2 text-slate-600" > {r.customer_order_no || "—"}< / td >
< td className = "px-3 py-2 text-right tabular-nums" > {fmtGBP(r.invoice_total)}< / td >
< td className = "px-3 py-2 text-slate-600 truncate max-w-xs" > {r.deliver_to_name || "—"}< / td >
< / tr >
);
})}
```
- [ ] **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< / span > ')"
```
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
< nav className = "flex gap-6 text-sm" >
< Link href = "/" className = "hover:underline" > Dashboard< / Link >
< Link href = "/invoices" className = "hover:underline" > Invoices< / Link >
< Link href = "/stock-codes" className = "hover:underline" > Stock Codes< / Link >
< / nav >
```
with:
```tsx
< nav className = "flex gap-6 text-sm" >
< Link href = "/" className = "hover:underline" > Dashboard< / Link >
< Link href = "/invoices" className = "hover:underline" > Invoices< / Link >
< Link href = "/unpaid" className = "hover:underline" > Unpaid< / Link >
< Link href = "/remittance" className = "hover:underline" > Remittance< / Link >
< Link href = "/stock-codes" className = "hover:underline" > Stock Codes< / Link >
< / nav >
```
- [ ] **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.