JEI_Import_Documents-App/docs/superpowers/plans/2026-09-18-jei-payments-and-remittance.md

60 KiB

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:

"""
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:

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
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

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):

  payment_batch: string; // relation -> PaymentBatch.id; "" when unpaid

After the InvoiceLine interface block, add:

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
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
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:

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:

curl.exe -i -X POST http://localhost:6002/api/payments/create

Expected: HTTP 400 with {"error":"Invalid form-data: ..."} or similar.

$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
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:

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:

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:

# 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
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:

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:

"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:

curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/unpaid

Expected: 200.

$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
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:

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",
    });
    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:

"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
curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/remittance

Expected: 200.

$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
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:

    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:

    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:

  // 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:

        <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:

          <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:

            {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
curl.exe -s -o $null -w "%{http_code}\n" http://localhost:6002/invoices

Expected: 200.

$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
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"

Files:

  • Modify: app/layout.tsx:23-27

  • Step 1: Insert nav links

Replace the existing nav block:

            <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:

            <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
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
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)
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.

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.