2124 lines
75 KiB
Markdown
2124 lines
75 KiB
Markdown
# WFSALES POS 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:** Turn the existing WFSALES POS mockup into a safe, correctly-wired counter-sale app served from WFDASH-WEBAPP, backed by `stock_items`/`stock_ledger`, with no PocketBase credentials shipped to the client.
|
|
|
|
**Architecture:** New `src/lib/server/pos-service.ts` + three thin `/api/v1/pos/*` routes (`catalog`, `checkout`, `sales`) hold all PocketBase access server-side, authenticated by a `POS_API_KEY` header (same shape as the existing `AGENT_API_KEY`/`assertAgentAuth` pattern). The existing static PWA (HTML/JS, currently living outside this repo) is relocated into `public/pos/` and rewired to call those routes instead of talking to PocketBase directly with a hardcoded superuser password.
|
|
|
|
**Tech Stack:** Next.js 16 (App Router, route handlers), PocketBase JS SDK (server-side admin client via `pocketbase-admin.ts`), Vitest for tests, vanilla JS/HTML for the POS frontend (unchanged from the existing mockup's approach — no React).
|
|
|
|
**Reference:** design spec at `docs/superpowers/specs/2026-08-30-wfsales-pos-design.md`. One refinement vs. the spec: instead of adding a new `pos_sale` reason value to `stock_ledger` (which would require a PB admin step before any code could run), checkout uses the existing `customer_fulfillment` reason value (already valid per `STOCK_REASON_VALUES` in `src/lib/types.ts` — "qty booked out when a customer order is fulfilled from existing stock"), with `reference_type: "pos_sale"` and `reference_id: <pos_sales id>` distinguishing POS sales in the ledger. This removes one manual PB-admin dependency from the rollout.
|
|
|
|
**Source mockup location** (read-only reference, not part of this repo):
|
|
`/Volumes/KIOXIA/Library/Application Support/Open Design/namespaces/release-stable/data/projects/9147f587-4959-4659-a6ba-a11f629a783c/`
|
|
|
|
---
|
|
|
|
## Manual prerequisite (user, PocketBase admin) — do this before Task 9
|
|
|
|
Per `AGENTS.md`, schema changes are made by the user in the PB admin UI, not by app code. Create these before the checkout endpoint is used against real data (backend tasks 1-8 can be built and unit-tested without this, since tests use a stubbed PB client):
|
|
|
|
1. New collection **`pos_sales`**: `sale_number` (text), `payment_method` (select: `cash`, `card`), `cash_given` (number), `change_given` (number), `subtotal` (number), `vat` (number), `total` (number), `customer` (relation → `customers`, single, optional).
|
|
2. New collection **`pos_sale_items`**: `sale` (relation → `pos_sales`, single, required), `stock_item` (relation → `stock_items`, single, required), `sku` (text), `description` (text), `qty` (number), `unit_cost` (number), `unit_price` (number).
|
|
3. Both collections: no public API rules (list/view/create/update/delete all blank/`null`) — only the server's superuser credentials touch them, same as `stock_items`/`stock_ledger`.
|
|
4. Set a `POS_API_KEY` env var (any long random string) on the WFDASH-WEBAPP deployment, alongside the existing `AGENT_API_KEY`/`PB_ADMIN_EMAIL`/`PB_ADMIN_PASSWORD`.
|
|
|
|
---
|
|
|
|
## Part 1 — Backend
|
|
|
|
### Task 1: POS types
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/types.ts` (append after the `StockLedger` interface, around line 1525)
|
|
|
|
- [ ] **Step 1: Add the types**
|
|
|
|
```ts
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// WFSALES POS (standalone counter-sale app) — added 2026-08-30
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Shape returned by GET /api/v1/pos/catalog — flattened stock_items row. */
|
|
export interface PosCatalogItem {
|
|
id: string;
|
|
sku: string;
|
|
name: string;
|
|
description: string;
|
|
unit: string;
|
|
/** Expanded category name, or "" if uncategorised. */
|
|
category: string;
|
|
current_qty: number;
|
|
selling_price: number;
|
|
cost_price: number;
|
|
}
|
|
|
|
export interface PosSale extends BaseRecord {
|
|
sale_number: string;
|
|
payment_method: "cash" | "card";
|
|
cash_given: number;
|
|
change_given: number;
|
|
subtotal: number;
|
|
vat: number;
|
|
total: number;
|
|
/** customers relation id, or "" for a walk-in with no customer picked. */
|
|
customer: string;
|
|
}
|
|
|
|
export interface PosSaleItem extends BaseRecord {
|
|
sale: string;
|
|
stock_item: string;
|
|
sku: string;
|
|
description: string;
|
|
qty: number;
|
|
unit_cost: number;
|
|
unit_price: number;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: no new errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/lib/types.ts
|
|
git commit -m "feat(pos): add PosCatalogItem/PosSale/PosSaleItem types"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: `pos-auth.ts`
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/pos-auth.ts`
|
|
- Test: `src/lib/server/pos-auth.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```ts
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import { assertPosAuth } from "./pos-auth";
|
|
|
|
describe("assertPosAuth", () => {
|
|
const ORIGINAL_KEY = process.env.POS_API_KEY;
|
|
|
|
beforeEach(() => {
|
|
process.env.POS_API_KEY = "test-pos-secret";
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_KEY === undefined) {
|
|
delete process.env.POS_API_KEY;
|
|
} else {
|
|
process.env.POS_API_KEY = ORIGINAL_KEY;
|
|
}
|
|
});
|
|
|
|
function reqWith(authHeader: string | null) {
|
|
const headers: Record<string, string> = {};
|
|
if (authHeader !== null) headers.authorization = authHeader;
|
|
return new NextRequest("http://localhost/api/v1/pos/catalog", { headers });
|
|
}
|
|
|
|
it("throws when POS_API_KEY env var is missing", () => {
|
|
delete process.env.POS_API_KEY;
|
|
expect(() => assertPosAuth(reqWith("Bearer test-pos-secret"))).toThrow(
|
|
/POS_API_KEY is not configured/
|
|
);
|
|
});
|
|
|
|
it("accepts a matching bearer token", () => {
|
|
expect(() => assertPosAuth(reqWith("Bearer test-pos-secret"))).not.toThrow();
|
|
});
|
|
|
|
it("rejects a wrong bearer token", () => {
|
|
expect(() => assertPosAuth(reqWith("Bearer wrong"))).toThrow(/Unauthorized/);
|
|
});
|
|
|
|
it("rejects a request with no Authorization header", () => {
|
|
expect(() => assertPosAuth(reqWith(null))).toThrow(/Unauthorized/);
|
|
});
|
|
|
|
it("tags the thrown error with status 401", () => {
|
|
try {
|
|
assertPosAuth(reqWith("Bearer wrong"));
|
|
throw new Error("expected to throw");
|
|
} catch (err) {
|
|
expect((err as Error & { status?: number }).status).toBe(401);
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-auth.test.ts`
|
|
Expected: FAIL — cannot find module `./pos-auth`.
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
```ts
|
|
import { NextRequest } from "next/server";
|
|
|
|
/**
|
|
* Device-scoped secret, distinct from AGENT_API_KEY — a POS device
|
|
* only needs checkout/catalog/sales access, not the full agent API
|
|
* surface, so it gets its own key rather than reusing the agent's.
|
|
*/
|
|
export function assertPosAuth(req: NextRequest) {
|
|
const expected = process.env.POS_API_KEY;
|
|
if (!expected) {
|
|
throw new Error("POS_API_KEY is not configured.");
|
|
}
|
|
|
|
const authHeader = req.headers.get("authorization") || "";
|
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
|
|
if (token !== expected) {
|
|
const error = new Error("Unauthorized");
|
|
(error as Error & { status?: number }).status = 401;
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-auth.test.ts`
|
|
Expected: PASS (5 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/pos-auth.ts src/lib/server/pos-auth.test.ts
|
|
git commit -m "feat(pos): add assertPosAuth for POS_API_KEY-gated routes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: `pos-service.ts` — `listActiveStockCatalog`
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/pos-service.ts`
|
|
- Test: `src/lib/server/pos-service.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```ts
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { listActiveStockCatalog } from "./pos-service";
|
|
|
|
function stubPb(items: unknown[]) {
|
|
return {
|
|
collection: () => ({
|
|
getFullList: vi.fn().mockResolvedValue(items),
|
|
getList: vi.fn(),
|
|
getOne: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("listActiveStockCatalog", () => {
|
|
it("flattens stock_items rows, pulling the expanded category name", async () => {
|
|
const pb = stubPb([
|
|
{
|
|
id: "si1",
|
|
sku: "SKU-1",
|
|
name: "Widget",
|
|
description: "A widget",
|
|
unit: "ea",
|
|
current_qty: 12,
|
|
selling_price: 99.5,
|
|
cost_price: 50,
|
|
expand: { category: { name: "Hardware" } },
|
|
},
|
|
{
|
|
id: "si2",
|
|
sku: "SKU-2",
|
|
name: "Uncategorised thing",
|
|
description: "",
|
|
unit: "ea",
|
|
current_qty: 0,
|
|
selling_price: 10,
|
|
cost_price: 5,
|
|
},
|
|
]);
|
|
|
|
const result = await listActiveStockCatalog(pb);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
id: "si1",
|
|
sku: "SKU-1",
|
|
name: "Widget",
|
|
description: "A widget",
|
|
unit: "ea",
|
|
category: "Hardware",
|
|
current_qty: 12,
|
|
selling_price: 99.5,
|
|
cost_price: 50,
|
|
},
|
|
{
|
|
id: "si2",
|
|
sku: "SKU-2",
|
|
name: "Uncategorised thing",
|
|
description: "",
|
|
unit: "ea",
|
|
category: "",
|
|
current_qty: 0,
|
|
selling_price: 10,
|
|
cost_price: 5,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-service.test.ts`
|
|
Expected: FAIL — cannot find module `./pos-service`.
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
```ts
|
|
import type { PosCatalogItem, PosSale, PosSaleItem, StockItem } from "@/lib/types";
|
|
|
|
/**
|
|
* PB client type kept loose so both the real `PocketBase` instance
|
|
* and test stubs satisfy the contract — same approach as
|
|
* `stock-items-service.ts`'s `StockItemsPb`.
|
|
*/
|
|
export type PosPb = {
|
|
collection: (name: string) => {
|
|
getFullList: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
|
|
getList: <T = unknown>(
|
|
page: number,
|
|
perPage: number,
|
|
opts?: Record<string, unknown>,
|
|
) => Promise<{ items: T[] }>;
|
|
getOne: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
|
|
create: <T = unknown>(data: Record<string, unknown>) => Promise<T>;
|
|
update: <T = unknown>(id: string, data: Record<string, unknown>) => Promise<T>;
|
|
};
|
|
};
|
|
|
|
// ─── listActiveStockCatalog ────────────────────────────────────────
|
|
|
|
type StockItemWithCategory = StockItem & {
|
|
expand?: { category?: { name?: string } };
|
|
};
|
|
|
|
/**
|
|
* Catalog source for the POS: active stock_items only, flattened so
|
|
* the frontend doesn't need to know about PB's relation/expand shape.
|
|
*/
|
|
export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[]> {
|
|
const items = await pb.collection("stock_items").getFullList<StockItemWithCategory>({
|
|
filter: "is_active = true",
|
|
sort: "name",
|
|
expand: "category",
|
|
});
|
|
|
|
return items.map((it) => ({
|
|
id: it.id,
|
|
sku: it.sku,
|
|
name: it.name,
|
|
description: it.description,
|
|
unit: it.unit,
|
|
category: it.expand?.category?.name || "",
|
|
current_qty: Number(it.current_qty ?? 0),
|
|
selling_price: Number(it.selling_price ?? 0),
|
|
cost_price: Number(it.cost_price ?? 0),
|
|
}));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-service.test.ts`
|
|
Expected: PASS (1 test)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts
|
|
git commit -m "feat(pos): add listActiveStockCatalog"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: `pos-service.ts` — `checkoutSale` (happy path + validation)
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/server/pos-service.ts`
|
|
- Modify: `src/lib/server/pos-service.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests** (append to the test file)
|
|
|
|
```ts
|
|
import { checkoutSale } from "./pos-service";
|
|
|
|
function stubCheckoutPb(opts: {
|
|
stockItems: Record<string, { current_qty: number; cost_price?: number }>;
|
|
}) {
|
|
const created: Record<string, unknown[]> = { pos_sales: [], pos_sale_items: [], stock_ledger: [] };
|
|
const updates: Array<{ collection: string; id: string; data: Record<string, unknown> }> = [];
|
|
let saleCounter = 0;
|
|
let itemCounter = 0;
|
|
|
|
return {
|
|
pb: {
|
|
collection: (name: string) => ({
|
|
getFullList: vi.fn(),
|
|
getList: vi.fn(),
|
|
getOne: vi.fn(async (id: string) => {
|
|
const item = opts.stockItems[id];
|
|
if (!item) throw new Error(`not found: ${id}`);
|
|
return { id, current_qty: item.current_qty, cost_price: item.cost_price ?? 0 };
|
|
}),
|
|
create: vi.fn(async (data: Record<string, unknown>) => {
|
|
if (name === "pos_sales") {
|
|
const id = `sale-${++saleCounter}`;
|
|
const row = { id, created: "", updated: "", collectionId: "", collectionName: "pos_sales", ...data };
|
|
created.pos_sales.push(row);
|
|
return row;
|
|
}
|
|
if (name === "pos_sale_items") {
|
|
const id = `sale-item-${++itemCounter}`;
|
|
const row = { id, created: "", updated: "", collectionId: "", collectionName: "pos_sale_items", ...data };
|
|
created.pos_sale_items.push(row);
|
|
return row;
|
|
}
|
|
if (name === "stock_ledger") {
|
|
created.stock_ledger.push(data);
|
|
return { id: "ledger-1", ...data };
|
|
}
|
|
throw new Error(`unexpected create on ${name}`);
|
|
}),
|
|
update: vi.fn(async (id: string, data: Record<string, unknown>) => {
|
|
updates.push({ collection: name, id, data });
|
|
return { id, ...data };
|
|
}),
|
|
}),
|
|
},
|
|
created,
|
|
updates,
|
|
};
|
|
}
|
|
|
|
describe("checkoutSale", () => {
|
|
it("rejects an empty cart", async () => {
|
|
const { pb } = stubCheckoutPb({ stockItems: {} });
|
|
const result = await checkoutSale(pb, { items: [], payment_method: "cash" });
|
|
expect(result).toEqual({ ok: false, reason: "no_items", message: expect.any(String) });
|
|
});
|
|
|
|
it("rejects a line with a non-positive qty", async () => {
|
|
const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } } });
|
|
const result = await checkoutSale(pb, {
|
|
items: [{ stock_item_id: "si1", qty: 0, unit_price: 10 }],
|
|
payment_method: "cash",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("invalid_line");
|
|
});
|
|
|
|
it("rejects checkout when a line exceeds available stock", async () => {
|
|
const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 2 } } });
|
|
const result = await checkoutSale(pb, {
|
|
items: [{ stock_item_id: "si1", qty: 5, unit_price: 10 }],
|
|
payment_method: "cash",
|
|
cash_given: 50,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("insufficient_stock");
|
|
expect(result.shortages).toEqual([{ stock_item_id: "si1", requested: 5, available: 2 }]);
|
|
}
|
|
});
|
|
|
|
it("creates the sale, sale items, ledger rows, and decrements stock on a valid cash sale", async () => {
|
|
const { pb, created, updates } = stubCheckoutPb({
|
|
stockItems: { si1: { current_qty: 10, cost_price: 40 } },
|
|
});
|
|
const result = await checkoutSale(pb, {
|
|
items: [{ stock_item_id: "si1", qty: 3, unit_price: 100, sku: "SKU-1", description: "Widget" }],
|
|
payment_method: "cash",
|
|
cash_given: 350,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.sale.total).toBe(300);
|
|
expect(result.sale.subtotal).toBeCloseTo(300 / 1.15, 5);
|
|
expect(result.sale.vat).toBeCloseTo(300 - 300 / 1.15, 5);
|
|
expect(result.sale.cash_given).toBe(350);
|
|
expect(result.sale.change_given).toBe(50);
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.stockErrors).toEqual([]);
|
|
|
|
expect(created.stock_ledger).toEqual([
|
|
{
|
|
stock_item: "si1",
|
|
change_qty: -3,
|
|
reason: "customer_fulfillment",
|
|
reference_type: "pos_sale",
|
|
reference_id: result.sale.id,
|
|
notes: "",
|
|
created_by: "",
|
|
},
|
|
]);
|
|
expect(updates).toEqual([
|
|
{ collection: "stock_items", id: "si1", data: { current_qty: 7 } },
|
|
]);
|
|
});
|
|
|
|
it("defaults card sales to cash_given = total with no change", async () => {
|
|
const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } } });
|
|
const result = await checkoutSale(pb, {
|
|
items: [{ stock_item_id: "si1", qty: 1, unit_price: 115 }],
|
|
payment_method: "card",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.sale.cash_given).toBe(115);
|
|
expect(result.sale.change_given).toBe(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-service.test.ts`
|
|
Expected: FAIL — `checkoutSale` is not exported.
|
|
|
|
- [ ] **Step 3: Add the implementation** (append to `pos-service.ts`)
|
|
|
|
```ts
|
|
// ─── checkoutSale ──────────────────────────────────────────────────
|
|
|
|
export type PosCheckoutLineInput = {
|
|
stock_item_id: string;
|
|
qty: number;
|
|
unit_price: number;
|
|
sku?: string;
|
|
description?: string;
|
|
};
|
|
|
|
export type PosCheckoutInput = {
|
|
items: PosCheckoutLineInput[];
|
|
payment_method: "cash" | "card";
|
|
cash_given?: number;
|
|
customer_id?: string;
|
|
};
|
|
|
|
export type PosCheckoutResult =
|
|
| {
|
|
ok: true;
|
|
sale: PosSale;
|
|
items: PosSaleItem[];
|
|
/** Lines whose ledger/qty write failed after the sale record was created. */
|
|
stockErrors: Array<{ stock_item_id: string; error: string }>;
|
|
}
|
|
| {
|
|
ok: false;
|
|
reason: "no_items" | "invalid_line" | "insufficient_stock" | "pb_error";
|
|
message: string;
|
|
shortages?: Array<{ stock_item_id: string; requested: number; available: number }>;
|
|
};
|
|
|
|
/**
|
|
* Confirms a POS sale: validates stock availability, creates the
|
|
* `pos_sales`/`pos_sale_items` rows, then for each line writes a
|
|
* `stock_ledger` row and decrements `stock_items.current_qty`.
|
|
*
|
|
* Uses the existing `customer_fulfillment` stock_ledger reason
|
|
* (qty booked out against a customer, from `STOCK_REASON_VALUES`)
|
|
* rather than a new POS-specific reason — `reference_type: "pos_sale"`
|
|
* plus `reference_id: sale.id` is enough to distinguish these rows in
|
|
* the ledger without needing a PB schema change first.
|
|
*
|
|
* If a line's ledger/qty write fails after the sale record already
|
|
* exists, the sale is NOT rolled back — it's reported in
|
|
* `stockErrors` for manual reconciliation, mirroring the
|
|
* compensation-needed pattern in the existing
|
|
* `/api/v1/stock/items/[id]/adjust` route.
|
|
*/
|
|
export async function checkoutSale(
|
|
pb: PosPb,
|
|
input: PosCheckoutInput,
|
|
): Promise<PosCheckoutResult> {
|
|
if (!Array.isArray(input.items) || input.items.length === 0) {
|
|
return { ok: false, reason: "no_items", message: "items must be a non-empty array" };
|
|
}
|
|
for (const line of input.items) {
|
|
if (!line.stock_item_id || !Number.isFinite(line.qty) || line.qty <= 0) {
|
|
return {
|
|
ok: false,
|
|
reason: "invalid_line",
|
|
message: `each item needs a stock_item_id and a positive qty (got ${JSON.stringify(line)})`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 1. Check availability for every line before writing anything.
|
|
const shortages: Array<{ stock_item_id: string; requested: number; available: number }> = [];
|
|
const stockItems = new Map<string, StockItem>();
|
|
for (const line of input.items) {
|
|
const item = (await pb.collection("stock_items").getOne(line.stock_item_id)) as StockItem;
|
|
stockItems.set(line.stock_item_id, item);
|
|
const available = Number(item.current_qty ?? 0);
|
|
if (available < line.qty) {
|
|
shortages.push({ stock_item_id: line.stock_item_id, requested: line.qty, available });
|
|
}
|
|
}
|
|
if (shortages.length > 0) {
|
|
return {
|
|
ok: false,
|
|
reason: "insufficient_stock",
|
|
message: "one or more items do not have enough stock",
|
|
shortages,
|
|
};
|
|
}
|
|
|
|
// 2. Totals — VAT-inclusive shelf pricing (matches the existing POS
|
|
// cart's own calc: sub = total / 1.15, vat = total - sub).
|
|
const total = input.items.reduce((sum, l) => sum + l.unit_price * l.qty, 0);
|
|
const subtotal = total / 1.15;
|
|
const vat = total - subtotal;
|
|
const cashGiven = input.payment_method === "cash" ? Number(input.cash_given ?? 0) : total;
|
|
const changeGiven = input.payment_method === "cash" ? Math.max(0, cashGiven - total) : 0;
|
|
|
|
// 3. Create the sale header.
|
|
let sale: PosSale;
|
|
try {
|
|
sale = (await pb.collection("pos_sales").create({
|
|
sale_number: "S-" + Date.now().toString(36).toUpperCase(),
|
|
payment_method: input.payment_method,
|
|
cash_given: cashGiven,
|
|
change_given: changeGiven,
|
|
subtotal,
|
|
vat,
|
|
total,
|
|
customer: input.customer_id || "",
|
|
})) as PosSale;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return { ok: false, reason: "pb_error", message: `Failed to create pos_sales row: ${message}` };
|
|
}
|
|
|
|
// 4. Per line: sale item + ledger + qty decrement.
|
|
const items: PosSaleItem[] = [];
|
|
const stockErrors: Array<{ stock_item_id: string; error: string }> = [];
|
|
for (const line of input.items) {
|
|
try {
|
|
const saleItem = (await pb.collection("pos_sale_items").create({
|
|
sale: sale.id,
|
|
stock_item: line.stock_item_id,
|
|
sku: line.sku || "",
|
|
description: line.description || "",
|
|
qty: line.qty,
|
|
unit_cost: stockItems.get(line.stock_item_id)?.cost_price ?? 0,
|
|
unit_price: line.unit_price,
|
|
})) as PosSaleItem;
|
|
items.push(saleItem);
|
|
|
|
await pb.collection("stock_ledger").create({
|
|
stock_item: line.stock_item_id,
|
|
change_qty: -line.qty,
|
|
reason: "customer_fulfillment",
|
|
reference_type: "pos_sale",
|
|
reference_id: sale.id,
|
|
notes: "",
|
|
created_by: "",
|
|
});
|
|
|
|
const current = stockItems.get(line.stock_item_id)!;
|
|
const nextQty = Number(current.current_qty ?? 0) - line.qty;
|
|
await pb.collection("stock_items").update(line.stock_item_id, { current_qty: nextQty });
|
|
} catch (err) {
|
|
stockErrors.push({
|
|
stock_item_id: line.stock_item_id,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { ok: true, sale, items, stockErrors };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-service.test.ts`
|
|
Expected: PASS (6 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts
|
|
git commit -m "feat(pos): add checkoutSale with stock validation and ledger writes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: `pos-service.ts` — `listSalesInRange`
|
|
|
|
**Files:**
|
|
- Modify: `src/lib/server/pos-service.ts`
|
|
- Modify: `src/lib/server/pos-service.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test** (append)
|
|
|
|
```ts
|
|
import { listSalesInRange } from "./pos-service";
|
|
|
|
describe("listSalesInRange", () => {
|
|
it("fetches sales in the range and their line items in one follow-up call", async () => {
|
|
const salesGetList = vi.fn().mockResolvedValue({
|
|
items: [
|
|
{ id: "sale-1", total: 100 },
|
|
{ id: "sale-2", total: 200 },
|
|
],
|
|
});
|
|
const itemsGetList = vi.fn().mockResolvedValue({
|
|
items: [
|
|
{ id: "si-1", sale: "sale-1", qty: 2 },
|
|
{ id: "si-2", sale: "sale-2", qty: 1 },
|
|
],
|
|
});
|
|
const pb = {
|
|
collection: (name: string) => {
|
|
if (name === "pos_sales") {
|
|
return { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: salesGetList };
|
|
}
|
|
if (name === "pos_sale_items") {
|
|
return { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: itemsGetList };
|
|
}
|
|
throw new Error(`unexpected collection ${name}`);
|
|
},
|
|
};
|
|
|
|
const result = await listSalesInRange(pb, { from: "2026-08-30 00:00:00", to: "2026-08-30 23:59:59" });
|
|
|
|
expect(result.sales).toHaveLength(2);
|
|
expect(result.items).toHaveLength(2);
|
|
expect(salesGetList).toHaveBeenCalledWith(1, 500, {
|
|
filter: "created >= '2026-08-30 00:00:00' && created <= '2026-08-30 23:59:59'",
|
|
sort: "-created",
|
|
});
|
|
expect(itemsGetList).toHaveBeenCalledWith(1, 2000, {
|
|
filter: "sale = 'sale-1' || sale = 'sale-2'",
|
|
});
|
|
});
|
|
|
|
it("skips the items query when there are no sales in range", async () => {
|
|
const salesGetList = vi.fn().mockResolvedValue({ items: [] });
|
|
const itemsGetList = vi.fn();
|
|
const pb = {
|
|
collection: (name: string) =>
|
|
name === "pos_sales"
|
|
? { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: salesGetList }
|
|
: { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: itemsGetList },
|
|
};
|
|
|
|
const result = await listSalesInRange(pb, { from: "2026-08-30 00:00:00", to: "2026-08-30 23:59:59" });
|
|
|
|
expect(result).toEqual({ sales: [], items: [] });
|
|
expect(itemsGetList).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-service.test.ts`
|
|
Expected: FAIL — `listSalesInRange` is not exported.
|
|
|
|
- [ ] **Step 3: Add the implementation** (append to `pos-service.ts`)
|
|
|
|
```ts
|
|
// ─── listSalesInRange ───────────────────────────────────────────────
|
|
|
|
export type PosSalesRangeResult = {
|
|
sales: PosSale[];
|
|
items: PosSaleItem[];
|
|
};
|
|
|
|
/**
|
|
* Sales + their line items for history.html / cashup.html. `from`/`to`
|
|
* are PB datetime filter strings (e.g. "2026-08-30 00:00:00").
|
|
*/
|
|
export async function listSalesInRange(
|
|
pb: PosPb,
|
|
range: { from: string; to: string },
|
|
): Promise<PosSalesRangeResult> {
|
|
const salesPage = await pb.collection("pos_sales").getList<PosSale>(1, 500, {
|
|
filter: `created >= '${range.from}' && created <= '${range.to}'`,
|
|
sort: "-created",
|
|
});
|
|
const sales = salesPage.items;
|
|
if (sales.length === 0) return { sales: [], items: [] };
|
|
|
|
const filter = sales.map((s) => `sale = '${s.id}'`).join(" || ");
|
|
const itemsPage = await pb.collection("pos_sale_items").getList<PosSaleItem>(1, 2000, { filter });
|
|
return { sales, items: itemsPage.items };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/lib/server/pos-service.test.ts`
|
|
Expected: PASS (8 tests total)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts
|
|
git commit -m "feat(pos): add listSalesInRange for history/cashup"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: `GET /api/v1/pos/catalog`
|
|
|
|
**Files:**
|
|
- Create: `src/app/api/v1/pos/catalog/route.ts`
|
|
- Test: `src/app/api/v1/pos/catalog/route.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import { GET } from "./route";
|
|
import { listActiveStockCatalog } from "@/lib/server/pos-service";
|
|
|
|
vi.mock("@/lib/server/pos-service", () => ({
|
|
listActiveStockCatalog: vi.fn().mockResolvedValue([
|
|
{ id: "si1", sku: "SKU-1", name: "Widget", description: "", unit: "ea", category: "", current_qty: 5, selling_price: 10, cost_price: 5 },
|
|
]),
|
|
}));
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
const POS_API_KEY = "test-pos-key";
|
|
|
|
function catalogRequest(authHeader: string | null = `Bearer ${POS_API_KEY}`): NextRequest {
|
|
const headers: Record<string, string> = {};
|
|
if (authHeader !== null) headers.authorization = authHeader;
|
|
return new NextRequest("http://localhost/api/v1/pos/catalog", { headers });
|
|
}
|
|
|
|
describe("GET /api/v1/pos/catalog", () => {
|
|
beforeEach(() => {
|
|
process.env.POS_API_KEY = POS_API_KEY;
|
|
});
|
|
|
|
it("returns the catalog with a totalItems count", async () => {
|
|
const res = await GET(catalogRequest());
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.totalItems).toBe(1);
|
|
expect(body.items[0].sku).toBe("SKU-1");
|
|
expect(listActiveStockCatalog).toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 401 without a valid POS_API_KEY", async () => {
|
|
const res = await GET(catalogRequest("Bearer wrong"));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx vitest run src/app/api/v1/pos/catalog/route.test.ts`
|
|
Expected: FAIL — cannot find module `./route`.
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { assertPosAuth } from "@/lib/server/pos-auth";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { listActiveStockCatalog } from "@/lib/server/pos-service";
|
|
|
|
/**
|
|
* GET /api/v1/pos/catalog
|
|
*
|
|
* Active stock_items, flattened for the POS frontend. Requires the
|
|
* POS_API_KEY bearer token — see src/lib/server/pos-auth.ts.
|
|
*/
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
assertPosAuth(req);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
return NextResponse.json({ error: message }, { status: 401 });
|
|
}
|
|
|
|
const pb = await getAdminPocketBase();
|
|
const items = await listActiveStockCatalog(pb);
|
|
return NextResponse.json({ items, totalItems: items.length });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx vitest run src/app/api/v1/pos/catalog/route.test.ts`
|
|
Expected: PASS (2 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/app/api/v1/pos/catalog/route.ts src/app/api/v1/pos/catalog/route.test.ts
|
|
git commit -m "feat(pos): add GET /api/v1/pos/catalog"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: `POST /api/v1/pos/checkout`
|
|
|
|
**Files:**
|
|
- Create: `src/app/api/v1/pos/checkout/route.ts`
|
|
- Test: `src/app/api/v1/pos/checkout/route.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import { POST } from "./route";
|
|
import { checkoutSale } from "@/lib/server/pos-service";
|
|
|
|
vi.mock("@/lib/server/pos-service", () => ({
|
|
checkoutSale: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
const POS_API_KEY = "test-pos-key";
|
|
|
|
function checkoutRequest(body: unknown, authHeader: string | null = `Bearer ${POS_API_KEY}`): NextRequest {
|
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
if (authHeader !== null) headers.authorization = authHeader;
|
|
return new NextRequest("http://localhost/api/v1/pos/checkout", {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("POST /api/v1/pos/checkout", () => {
|
|
beforeEach(() => {
|
|
process.env.POS_API_KEY = POS_API_KEY;
|
|
vi.mocked(checkoutSale).mockReset();
|
|
});
|
|
|
|
it("returns 200 with the sale on success", async () => {
|
|
vi.mocked(checkoutSale).mockResolvedValue({
|
|
ok: true,
|
|
sale: { id: "sale-1", total: 100 } as never,
|
|
items: [],
|
|
stockErrors: [],
|
|
});
|
|
const res = await POST(checkoutRequest({ items: [{ stock_item_id: "si1", qty: 1, unit_price: 100 }], payment_method: "cash", cash_given: 100 }));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(true);
|
|
expect(body.sale.id).toBe("sale-1");
|
|
});
|
|
|
|
it("returns 409 when stock is insufficient", async () => {
|
|
vi.mocked(checkoutSale).mockResolvedValue({
|
|
ok: false,
|
|
reason: "insufficient_stock",
|
|
message: "one or more items do not have enough stock",
|
|
shortages: [{ stock_item_id: "si1", requested: 5, available: 2 }],
|
|
});
|
|
const res = await POST(checkoutRequest({ items: [{ stock_item_id: "si1", qty: 5, unit_price: 10 }], payment_method: "cash" }));
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it("returns 400 for other checkout failures", async () => {
|
|
vi.mocked(checkoutSale).mockResolvedValue({ ok: false, reason: "no_items", message: "items must be a non-empty array" });
|
|
const res = await POST(checkoutRequest({ items: [], payment_method: "cash" }));
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("defaults an unrecognised payment_method to cash", async () => {
|
|
vi.mocked(checkoutSale).mockResolvedValue({ ok: true, sale: { id: "sale-1" } as never, items: [], stockErrors: [] });
|
|
await POST(checkoutRequest({ items: [{ stock_item_id: "si1", qty: 1, unit_price: 10 }], payment_method: "bitcoin" }));
|
|
expect(checkoutSale).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ payment_method: "cash" }));
|
|
});
|
|
|
|
it("returns 401 without a valid POS_API_KEY", async () => {
|
|
const res = await POST(checkoutRequest({ items: [] }, "Bearer wrong"));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx vitest run src/app/api/v1/pos/checkout/route.test.ts`
|
|
Expected: FAIL — cannot find module `./route`.
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { assertPosAuth } from "@/lib/server/pos-auth";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { checkoutSale } from "@/lib/server/pos-service";
|
|
|
|
/**
|
|
* POST /api/v1/pos/checkout
|
|
*
|
|
* Confirms a POS sale. Body: { items: [{stock_item_id, qty, unit_price,
|
|
* sku?, description?}], payment_method: "cash"|"card", cash_given?,
|
|
* customer_id? }.
|
|
*
|
|
* Returns 200 with `{ ok: true, sale, items, stockErrors }` on
|
|
* success (stockErrors non-empty means the sale was recorded but one
|
|
* or more lines didn't decrement stock — needs manual reconciliation),
|
|
* 409 when stock is insufficient, 400 for other validation failures,
|
|
* 401 for a missing/wrong POS_API_KEY.
|
|
*/
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
assertPosAuth(req);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
return NextResponse.json({ error: message }, { status: 401 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const pb = await getAdminPocketBase();
|
|
const result = await checkoutSale(pb, {
|
|
items: Array.isArray(body?.items) ? body.items : [],
|
|
payment_method: body?.payment_method === "card" ? "card" : "cash",
|
|
cash_given: body?.cash_given,
|
|
customer_id: body?.customer_id,
|
|
});
|
|
|
|
if (!result.ok) {
|
|
const status = result.reason === "insufficient_stock" ? 409 : 400;
|
|
return NextResponse.json(result, { status });
|
|
}
|
|
return NextResponse.json(result);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx vitest run src/app/api/v1/pos/checkout/route.test.ts`
|
|
Expected: PASS (5 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/app/api/v1/pos/checkout/route.ts src/app/api/v1/pos/checkout/route.test.ts
|
|
git commit -m "feat(pos): add POST /api/v1/pos/checkout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: `GET /api/v1/pos/sales`
|
|
|
|
**Files:**
|
|
- Create: `src/app/api/v1/pos/sales/route.ts`
|
|
- Test: `src/app/api/v1/pos/sales/route.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```ts
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import { GET } from "./route";
|
|
import { listSalesInRange } from "@/lib/server/pos-service";
|
|
|
|
vi.mock("@/lib/server/pos-service", () => ({
|
|
listSalesInRange: vi.fn().mockResolvedValue({ sales: [{ id: "sale-1" }], items: [] }),
|
|
}));
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
const POS_API_KEY = "test-pos-key";
|
|
|
|
function salesRequest(query = "", authHeader: string | null = `Bearer ${POS_API_KEY}`): NextRequest {
|
|
const headers: Record<string, string> = {};
|
|
if (authHeader !== null) headers.authorization = authHeader;
|
|
return new NextRequest(`http://localhost/api/v1/pos/sales${query}`, { headers });
|
|
}
|
|
|
|
describe("GET /api/v1/pos/sales", () => {
|
|
beforeEach(() => {
|
|
process.env.POS_API_KEY = POS_API_KEY;
|
|
vi.mocked(listSalesInRange).mockClear();
|
|
});
|
|
|
|
it("passes explicit from/to through to the service", async () => {
|
|
const res = await GET(salesRequest("?from=2026-08-01+00:00:00&to=2026-08-01+23:59:59"));
|
|
expect(res.status).toBe(200);
|
|
expect(listSalesInRange).toHaveBeenCalledWith(expect.anything(), {
|
|
from: "2026-08-01 00:00:00",
|
|
to: "2026-08-01 23:59:59",
|
|
});
|
|
});
|
|
|
|
it("defaults to today's date range when from/to are omitted", async () => {
|
|
await GET(salesRequest());
|
|
const call = vi.mocked(listSalesInRange).mock.calls[0][1];
|
|
expect(call.from).toMatch(/^\d{4}-\d{2}-\d{2} 00:00:00$/);
|
|
expect(call.to).toMatch(/^\d{4}-\d{2}-\d{2} 23:59:59$/);
|
|
});
|
|
|
|
it("returns 401 without a valid POS_API_KEY", async () => {
|
|
const res = await GET(salesRequest("", "Bearer wrong"));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `npx vitest run src/app/api/v1/pos/sales/route.test.ts`
|
|
Expected: FAIL — cannot find module `./route`.
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { assertPosAuth } from "@/lib/server/pos-auth";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { listSalesInRange } from "@/lib/server/pos-service";
|
|
|
|
/**
|
|
* GET /api/v1/pos/sales?from=&to=
|
|
*
|
|
* Sales + line items in a date range, for history.html/cashup.html.
|
|
* `from`/`to` are PB datetime filter strings (space, not "T" —
|
|
* matches PB's own `created` format); both default to today.
|
|
*/
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
assertPosAuth(req);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
return NextResponse.json({ error: message }, { status: 401 });
|
|
}
|
|
|
|
const { searchParams } = new URL(req.url);
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const from = searchParams.get("from") || `${today} 00:00:00`;
|
|
const to = searchParams.get("to") || `${today} 23:59:59`;
|
|
|
|
const pb = await getAdminPocketBase();
|
|
const result = await listSalesInRange(pb, { from, to });
|
|
return NextResponse.json(result);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `npx vitest run src/app/api/v1/pos/sales/route.test.ts`
|
|
Expected: PASS (3 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/app/api/v1/pos/sales/route.ts src/app/api/v1/pos/sales/route.test.ts
|
|
git commit -m "feat(pos): add GET /api/v1/pos/sales"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Run the full backend test suite
|
|
|
|
- [ ] **Step 1: Run the full suite to confirm no regressions**
|
|
|
|
Run: `npx vitest run`
|
|
Expected: PASS — all existing tests plus the 4 new POS test files (pos-auth, pos-service, catalog route, checkout route, sales route) green.
|
|
|
|
- [ ] **Step 2: Typecheck the whole project**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: no errors.
|
|
|
|
(No commit — this is a verification checkpoint. If either command fails, fix before moving to Part 2.)
|
|
|
|
---
|
|
|
|
## Part 2 — Frontend (relocate + rewire the existing mockup)
|
|
|
|
The mockup already implements catalog browsing, cart, cash/card payment UI, an offline sale queue, and Bluetooth thermal printing. These tasks move it into this repo and repoint every PocketBase call at the new `/api/v1/pos/*` routes instead.
|
|
|
|
### Task 10: Copy the mockup into `public/pos/`
|
|
|
|
**Files:**
|
|
- Create: `public/pos/index.html`, `public/pos/pos.html`, `public/pos/receipt.html`, `public/pos/history.html`, `public/pos/cashup.html`, `public/pos/pos-app.js`, `public/pos/theme.js`, `public/pos/sw.js`, `public/pos/manifest.json`
|
|
|
|
- [ ] **Step 1: Create the directory and copy the static files (everything except `pb-config.js` and the `.artifact.json`/`.od-skills` tooling metadata)**
|
|
|
|
```bash
|
|
mkdir -p public/pos
|
|
SRC="/Volumes/KIOXIA/Library/Application Support/Open Design/namespaces/release-stable/data/projects/9147f587-4959-4659-a6ba-a11f629a783c"
|
|
cp "$SRC/index.html" "$SRC/pos.html" "$SRC/receipt.html" "$SRC/history.html" "$SRC/cashup.html" "$SRC/pos-app.js" "$SRC/theme.js" "$SRC/sw.js" "$SRC/manifest.json" public/pos/
|
|
```
|
|
|
|
- [ ] **Step 2: Verify `pb-config.js` was not copied**
|
|
|
|
Run: `ls public/pos/`
|
|
Expected: the 9 files listed above, no `pb-config.js`, no `.artifact.json` files.
|
|
|
|
- [ ] **Step 3: Commit the raw copy** (subsequent tasks rewire it — keeping this as its own commit makes the diffs in later tasks readable)
|
|
|
|
```bash
|
|
git add public/pos/
|
|
git commit -m "chore(pos): copy WFSALES POS mockup into public/pos/ (unwired)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Replace `pb-config.js` with `pos-config.js` (no PocketBase credentials)
|
|
|
|
**Files:**
|
|
- Create: `public/pos/pos-config.js`
|
|
- Modify: `public/pos/index.html`, `public/pos/pos.html`, `public/pos/receipt.html`, `public/pos/history.html`, `public/pos/cashup.html` (each has a `<script src="pb-config.js"></script>` tag to update)
|
|
|
|
- [ ] **Step 1: Create `pos-config.js`**
|
|
|
|
```js
|
|
// POS config — no PocketBase credentials here. The device only knows
|
|
// its own API key for this app's /api/v1/pos/* routes; PocketBase
|
|
// auth happens server-side (see src/lib/server/pocketbase-admin.ts).
|
|
window.POS_CONFIG = (function () {
|
|
const STORAGE_KEY = "pos.apiKey";
|
|
let apiKey = localStorage.getItem(STORAGE_KEY) || "";
|
|
if (!apiKey) {
|
|
apiKey = window.prompt("Enter this till's POS API key (ask the office for it):") || "";
|
|
if (apiKey) localStorage.setItem(STORAGE_KEY, apiKey);
|
|
}
|
|
return { apiBase: "/api/v1/pos", apiKey };
|
|
})();
|
|
|
|
window.posFetch = async function (path, opts = {}) {
|
|
const cfg = window.POS_CONFIG;
|
|
const headers = Object.assign(
|
|
{ Authorization: "Bearer " + cfg.apiKey },
|
|
opts.headers || {},
|
|
);
|
|
return fetch(cfg.apiBase + path, Object.assign({}, opts, { headers }));
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Swap the script tag in every HTML file**
|
|
|
|
In `public/pos/index.html`, `public/pos/pos.html`, `public/pos/receipt.html`, `public/pos/history.html`, `public/pos/cashup.html`, replace:
|
|
|
|
```html
|
|
<script src="pb-config.js"></script>
|
|
```
|
|
|
|
with:
|
|
|
|
```html
|
|
<script src="pos-config.js"></script>
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add public/pos/pos-config.js public/pos/index.html public/pos/pos.html public/pos/receipt.html public/pos/history.html public/pos/cashup.html
|
|
git commit -m "feat(pos): replace hardcoded PB superuser creds with POS_API_KEY config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Rewire `pos-app.js` catalog loading
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/pos-app.js`
|
|
|
|
- [ ] **Step 1: Replace `loadProducts()` and `loadLetterhead()`**
|
|
|
|
Replace this block (originally lines 76-121 — the exact line numbers will differ slightly after Task 11's edits, match by content):
|
|
|
|
```js
|
|
async function loadProducts(){
|
|
// Try IDB first
|
|
try{
|
|
const cached = await dbAll(STORE_PRODUCTS);
|
|
if(cached.length){
|
|
state.products = cached;
|
|
state.productsById = new Map(cached.map(p=>[p.id,p]));
|
|
buildCategories();
|
|
render();
|
|
}
|
|
}catch{}
|
|
// Then network refresh
|
|
try{
|
|
await window.pbAuth();
|
|
const all = [];
|
|
let page = 1;
|
|
while(true){
|
|
const r = await window.pbFetch(`/api/collections/${cfg.collections.products}/records?perPage=200&page=${page}&sort=description`);
|
|
const j = await r.json();
|
|
all.push(...j.items);
|
|
if(j.page >= j.totalPages) break;
|
|
page++;
|
|
if(page > 30) break; // hard cap
|
|
}
|
|
state.products = all;
|
|
state.productsById = new Map(all.map(p=>[p.id,p]));
|
|
buildCategories();
|
|
render();
|
|
// Persist
|
|
for(const p of all) await dbPut(STORE_PRODUCTS, p);
|
|
}catch(e){
|
|
console.warn('Network load failed', e);
|
|
if(!state.products.length){
|
|
const el = document.getElementById('empty');
|
|
el.innerHTML = '<h3>Offline</h3><p>No cached catalog. Reconnect and reload.</p>';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadLetterhead(){
|
|
try{
|
|
const r = await window.pbFetch(`/api/collections/${cfg.collections.letterhead}/records?perPage=1&filter=is_active=true`);
|
|
const j = await r.json();
|
|
if(j.items && j.items[0]) state.letterhead = j.items[0];
|
|
}catch{}
|
|
}
|
|
```
|
|
|
|
with:
|
|
|
|
```js
|
|
async function loadProducts(){
|
|
// Try IDB first
|
|
try{
|
|
const cached = await dbAll(STORE_PRODUCTS);
|
|
if(cached.length){
|
|
state.products = cached;
|
|
state.productsById = new Map(cached.map(p=>[p.id,p]));
|
|
buildCategories();
|
|
render();
|
|
}
|
|
}catch{}
|
|
// Then network refresh — single call, the API route returns the
|
|
// full active catalog (no pagination needed at this catalog size).
|
|
try{
|
|
const r = await window.posFetch('/catalog');
|
|
const j = await r.json();
|
|
const all = (j.items || []).map(p => ({
|
|
...p,
|
|
description: p.description || p.name,
|
|
}));
|
|
state.products = all;
|
|
state.productsById = new Map(all.map(p=>[p.id,p]));
|
|
buildCategories();
|
|
render();
|
|
for(const p of all) await dbPut(STORE_PRODUCTS, p);
|
|
}catch(e){
|
|
console.warn('Network load failed', e);
|
|
if(!state.products.length){
|
|
const el = document.getElementById('empty');
|
|
el.innerHTML = '<h3>Offline</h3><p>No cached catalog. Reconnect and reload.</p>';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadLetterhead(){
|
|
// Letterhead comes from the main app in a later task if needed;
|
|
// the receipt already falls back to "WFSALES POS" when absent.
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add public/pos/pos-app.js
|
|
git commit -m "feat(pos): load catalog from /api/v1/pos/catalog instead of PB directly"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Rewire `pos-app.js` checkout and offline queue
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/pos-app.js`
|
|
|
|
- [ ] **Step 1: Replace `confirmSale()`**
|
|
|
|
Replace the whole function (originally lines 454-547 — locate by the `async function confirmSale()` signature) with:
|
|
|
|
```js
|
|
async function confirmSale(){
|
|
const btn = document.getElementById('confirmBtn');
|
|
if(btn.disabled) return;
|
|
btn.disabled = true; btn.innerHTML = 'Saving…';
|
|
const items = Array.from(state.cart.values()).map(({p,qty})=>({
|
|
stock_item_id: p.id, qty, unit_price: p.selling_price ?? p.cost_price ?? 0,
|
|
sku: p.sku, description: p.description,
|
|
}));
|
|
const totals = cartTotals();
|
|
const checkoutBody = {
|
|
items,
|
|
payment_method: state.paymentMethod,
|
|
cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
|
|
customer_id: state.customer ? state.customer.id : undefined,
|
|
};
|
|
|
|
const sale = {
|
|
localId: uid(),
|
|
createdAt: new Date().toISOString(),
|
|
payment: state.paymentMethod,
|
|
cashGiven: state.cashGiven || null,
|
|
change: state.paymentMethod==='cash' ? Math.max(0, state.cashGiven - totals.total) : 0,
|
|
sub: totals.sub, vat: totals.vat, total: totals.total,
|
|
items: Array.from(state.cart.values()).map(({p,qty})=>({
|
|
sku: p.sku, description: p.description, unit: p.unit,
|
|
cost_price: p.cost_price, selling_price: p.selling_price, qty, product_id: p.id,
|
|
})),
|
|
customer: state.customer || null,
|
|
synced: false,
|
|
};
|
|
|
|
try{
|
|
const r = await window.posFetch('/checkout', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(checkoutBody),
|
|
});
|
|
if(r.ok){
|
|
const j = await r.json();
|
|
sale.synced = true;
|
|
sale.serverRef = j.sale && j.sale.id;
|
|
} else {
|
|
throw new Error('server returned ' + r.status);
|
|
}
|
|
}catch(e){
|
|
console.warn('Sale queued for sync', e);
|
|
try{
|
|
await dbPut(STORE_QUEUE, {...sale, checkoutBody});
|
|
}catch(qErr){
|
|
console.error('IndexedDB queue failed too — saving to localStorage as fallback', qErr);
|
|
try{
|
|
const arr = JSON.parse(localStorage.getItem('wfsales.queue') || '[]');
|
|
arr.push({...sale, checkoutBody});
|
|
localStorage.setItem('wfsales.queue', JSON.stringify(arr));
|
|
}catch(_){}
|
|
}
|
|
}
|
|
|
|
// ALWAYS navigate to receipt — never leave the user stuck on "Saving…"
|
|
try{ sessionStorage.setItem('pos.lastSale', JSON.stringify(sale)); }catch(_){}
|
|
state.cart.clear(); persistCart(); paintCart(); render();
|
|
closePayment();
|
|
setTimeout(()=>{ location.href = 'receipt.html?id=' + encodeURIComponent(sale.localId); }, 60);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `flushQueue()`**
|
|
|
|
Replace the function (originally lines 558-577) with:
|
|
|
|
```js
|
|
async function flushQueue(){
|
|
if(!navigator.onLine) return;
|
|
const q = await dbAll(STORE_QUEUE);
|
|
for(const s of q){
|
|
try{
|
|
const r = await window.posFetch('/checkout', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s.checkoutBody),
|
|
});
|
|
if(r.ok){
|
|
await dbDel(STORE_QUEUE, s.localId);
|
|
} else {
|
|
break; // stop on first failure, retry next time we come online
|
|
}
|
|
}catch(e){ console.warn('queue item failed', e); break; }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add public/pos/pos-app.js
|
|
git commit -m "feat(pos): checkout via /api/v1/pos/checkout, drop orders/order_items/delivery_notes writes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Rewire `index.html` boot script
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/index.html`
|
|
|
|
- [ ] **Step 1: Replace the boot `<script>` block**
|
|
|
|
Replace:
|
|
|
|
```html
|
|
<script>
|
|
(async function(){
|
|
const cfg = window.PB_CONFIG;
|
|
// Letterhead (subline only — the app name is fixed; letterhead supplies the trading name)
|
|
try{
|
|
const r = await window.pbFetch('/api/collections/letterhead_settings/records?perPage=1&filter=is_active=true');
|
|
const j = await r.json();
|
|
if(j.items && j.items[0]){
|
|
const lh = j.items[0];
|
|
// Append the trading name to the subline, never replace the app name
|
|
if(lh.address) document.getElementById('brandSub').textContent = lh.address.split('\n')[0];
|
|
}
|
|
}catch{}
|
|
// Product count
|
|
try{
|
|
const r = await window.pbFetch('/api/collections/pos/records?perPage=1');
|
|
const j = await r.json();
|
|
document.getElementById('prodCount').textContent = j.totalItems?.toLocaleString() || '—';
|
|
}catch{}
|
|
// Cache state
|
|
try{
|
|
const c = await caches.open('pos-v1');
|
|
const keys = await c.keys();
|
|
document.getElementById('cacheState').textContent = keys.length ? 'ready' : 'cold';
|
|
}catch{ document.getElementById('cacheState').textContent = '—'; }
|
|
// Network pill
|
|
function paintNet(){
|
|
const on = navigator.onLine;
|
|
const p = document.getElementById('netPill');
|
|
const l = document.getElementById('netLabel');
|
|
p.classList.toggle('offline', !on);
|
|
l.textContent = on ? 'Online' : 'Offline';
|
|
const s = document.getElementById('statusMsg');
|
|
s.textContent = on ? 'Connected to PocketBase' : 'Offline — sales will sync when you reconnect';
|
|
}
|
|
window.addEventListener('online', paintNet);
|
|
window.addEventListener('offline', paintNet);
|
|
paintNet();
|
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(()=>{});
|
|
})();
|
|
</script>
|
|
```
|
|
|
|
with:
|
|
|
|
```html
|
|
<script>
|
|
(async function(){
|
|
// Product count via the same catalog endpoint pos.html uses.
|
|
try{
|
|
const r = await window.posFetch('/catalog');
|
|
const j = await r.json();
|
|
document.getElementById('prodCount').textContent = (j.totalItems||0).toLocaleString();
|
|
}catch{}
|
|
// Cache state
|
|
try{
|
|
const c = await caches.open('pos-v1');
|
|
const keys = await c.keys();
|
|
document.getElementById('cacheState').textContent = keys.length ? 'ready' : 'cold';
|
|
}catch{ document.getElementById('cacheState').textContent = '—'; }
|
|
// Network pill
|
|
function paintNet(){
|
|
const on = navigator.onLine;
|
|
const p = document.getElementById('netPill');
|
|
const l = document.getElementById('netLabel');
|
|
p.classList.toggle('offline', !on);
|
|
l.textContent = on ? 'Online' : 'Offline';
|
|
const s = document.getElementById('statusMsg');
|
|
s.textContent = on ? 'Connected' : 'Offline — sales will sync when you reconnect';
|
|
}
|
|
window.addEventListener('online', paintNet);
|
|
window.addEventListener('offline', paintNet);
|
|
paintNet();
|
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(()=>{});
|
|
})();
|
|
</script>
|
|
```
|
|
|
|
(The letterhead lookup is dropped — it required direct PB access from the client. If a trading-name subline is wanted later, expose it via a small addition to the `/catalog` response, not a new direct PB call.)
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add public/pos/index.html
|
|
git commit -m "feat(pos): rewire index.html product count to /api/v1/pos/catalog"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Rewire `history.html`
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/history.html`
|
|
|
|
- [ ] **Step 1: Replace the `load()` function**
|
|
|
|
Replace:
|
|
|
|
```js
|
|
async function load(){
|
|
const list = document.getElementById('list');
|
|
list.innerHTML = '<div class="empty"><h3>Loading…</h3></div>';
|
|
document.getElementById('kpiCount').textContent = '—';
|
|
document.getElementById('kpiTotal').textContent = 'R 0';
|
|
const start = activeDay + ' 00:00:00';
|
|
const end = activeDay + ' 23:59:59';
|
|
const filter = `created >= '${start}' && created <= '${end}'`;
|
|
try{
|
|
await window.pbAuth();
|
|
const r = await window.pbFetch(`/api/collections/${cfg.collections.orders}/records?perPage=200&sort=-created&filter=${encodeURIComponent(filter)}`);
|
|
const j = await r.json();
|
|
const rows = [];
|
|
let grandTotal = 0;
|
|
for(const o of j.items){
|
|
const li = await window.pbFetch(`/api/collections/${cfg.collections.orderItems}/records?perPage=200&filter=order='${o.id}'`);
|
|
const liJ = await li.json();
|
|
const items = liJ.items || [];
|
|
const total = items.reduce((s,it)=> s + (it.selling_price||0) * (it.quantity||0), 0);
|
|
grandTotal += total;
|
|
const isAccount = /ACCOUNT/.test(o.notes||'');
|
|
const method = isAccount ? 'Account' : (/EFT/.test(o.notes||'')?'EFT':(/CARD/.test(o.notes||'')?'Card':'Cash'));
|
|
const dt = new Date(o.created);
|
|
rows.push({o, items, total, method, dt});
|
|
}
|
|
document.getElementById('kpiCount').textContent = j.items.length;
|
|
document.getElementById('kpiTotal').textContent = fmt(grandTotal);
|
|
if(!rows.length){
|
|
list.innerHTML = '<div class="empty"><h3>No sales</h3><p>No sales recorded on this day.</p></div>';
|
|
return;
|
|
}
|
|
list.innerHTML = rows.map(r=>{
|
|
const dt = r.dt.toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'});
|
|
const mClass = r.method.toLowerCase();
|
|
return `<button class="sale-row" data-od-id="sale-${r.o.id}" onclick="reprint('${r.o.id}')">
|
|
<div class="nm">
|
|
<b>${esc(r.o.order_number||r.o.id.slice(-6))}</b>
|
|
<small>${dt} · ${r.items.length} item${r.items.length===1?'':'s'}</small>
|
|
</div>
|
|
<span class="method ${mClass}">${esc(r.method)}</span>
|
|
<div class="pr">${fmt(r.total)}</div>
|
|
</button>`;
|
|
}).join('');
|
|
}catch(e){
|
|
list.innerHTML = '<div class="empty"><h3>Offline</h3><p>Could not reach the register. Reconnect to view today\'s sales.</p></div>';
|
|
}
|
|
}
|
|
```
|
|
|
|
with:
|
|
|
|
```js
|
|
async function load(){
|
|
const list = document.getElementById('list');
|
|
list.innerHTML = '<div class="empty"><h3>Loading…</h3></div>';
|
|
document.getElementById('kpiCount').textContent = '—';
|
|
document.getElementById('kpiTotal').textContent = 'R 0';
|
|
const from = activeDay + ' 00:00:00';
|
|
const to = activeDay + ' 23:59:59';
|
|
try{
|
|
const r = await window.posFetch(`/sales?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
const j = await r.json();
|
|
const sales = j.sales || [];
|
|
const itemsBySale = new Map();
|
|
for(const it of (j.items||[])){
|
|
if(!itemsBySale.has(it.sale)) itemsBySale.set(it.sale, []);
|
|
itemsBySale.get(it.sale).push(it);
|
|
}
|
|
const rows = sales.map(s => ({
|
|
s, items: itemsBySale.get(s.id)||[], total: s.total,
|
|
method: s.payment_method==='card' ? 'Card' : 'Cash',
|
|
dt: new Date(s.created),
|
|
}));
|
|
const grandTotal = rows.reduce((sum,r)=>sum+r.total,0);
|
|
document.getElementById('kpiCount').textContent = sales.length;
|
|
document.getElementById('kpiTotal').textContent = fmt(grandTotal);
|
|
if(!rows.length){
|
|
list.innerHTML = '<div class="empty"><h3>No sales</h3><p>No sales recorded on this day.</p></div>';
|
|
return;
|
|
}
|
|
list.innerHTML = rows.map(r=>{
|
|
const dt = r.dt.toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'});
|
|
const mClass = r.method.toLowerCase();
|
|
return `<button class="sale-row" data-od-id="sale-${r.s.id}" onclick="reprint('${r.s.id}')">
|
|
<div class="nm">
|
|
<b>${esc(r.s.sale_number||r.s.id.slice(-6))}</b>
|
|
<small>${dt} · ${r.items.length} item${r.items.length===1?'':'s'}</small>
|
|
</div>
|
|
<span class="method ${mClass}">${esc(r.method)}</span>
|
|
<div class="pr">${fmt(r.total)}</div>
|
|
</button>`;
|
|
}).join('');
|
|
}catch(e){
|
|
list.innerHTML = '<div class="empty"><h3>Offline</h3><p>Could not reach the register. Reconnect to view today\'s sales.</p></div>';
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add public/pos/history.html
|
|
git commit -m "feat(pos): rewire history.html to /api/v1/pos/sales"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Rewire `cashup.html`
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/cashup.html`
|
|
|
|
- [ ] **Step 1: Find the equivalent fetch block** (mirrors `history.html`'s pattern — locate the two `window.pbFetch` calls at what were originally lines 351 and 356)
|
|
|
|
Replace:
|
|
|
|
```js
|
|
const r = await window.pbFetch(`/api/collections/${cfg.collections.orders}/records?perPage=500&filter=${encodeURIComponent(`created >= '${start}' && created <= '${end}'`)}`);
|
|
```
|
|
|
|
and its accompanying per-order `orderItems` loop (same shape as the block removed in Task 15) with a single call:
|
|
|
|
```js
|
|
const r = await window.posFetch(`/sales?from=${encodeURIComponent(start)}&to=${encodeURIComponent(end)}`);
|
|
const j = await r.json();
|
|
const sales = j.sales || [];
|
|
const itemsBySale = new Map();
|
|
for(const it of (j.items||[])){
|
|
if(!itemsBySale.has(it.sale)) itemsBySale.set(it.sale, []);
|
|
itemsBySale.get(it.sale).push(it);
|
|
}
|
|
```
|
|
|
|
Then update every downstream reference from the old `orders`/`order_items` field names (`selling_price`/`quantity` on order items, `notes`-based payment-method sniffing) to the `pos_sales`/`pos_sale_items` fields: `sale.payment_method` (already `"cash"`/`"card"`, no regex needed), `sale.total`/`sale.cash_given`/`sale.change_given` for the cash-drawer math, and `item.unit_price`/`item.qty` from `itemsBySale.get(sale.id)`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add public/pos/cashup.html
|
|
git commit -m "feat(pos): rewire cashup.html to /api/v1/pos/sales"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: `receipt.html` — customer's company name + price toggle on the delivery slip
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/receipt.html`
|
|
- Modify: `public/pos/pos-app.js` (customer search needs to expand `company`)
|
|
|
|
- [ ] **Step 1: In `pos-app.js`, expand `company` when searching customers**
|
|
|
|
Find `searchCustomers` and change the fetch line from:
|
|
|
|
```js
|
|
const r = await window.pbFetch(`/api/collections/${cfg.collections.customers}/records?perPage=8&filter=${encodeURIComponent(f)}`);
|
|
```
|
|
|
|
This direct-PB customer search must also move behind the backend — add it to the checkout route's surface instead of a raw PB call. Replace the whole `searchCustomers` function with:
|
|
|
|
```js
|
|
async function searchCustomers(q){
|
|
const box = document.getElementById('custResults');
|
|
if(!q){ box.innerHTML=''; return; }
|
|
try{
|
|
const r = await window.posFetch(`/customers?q=${encodeURIComponent(q)}`);
|
|
const j = await r.json();
|
|
box.innerHTML = (j.items||[]).map(c=>`<div data-id="${c.id}" data-name="${escapeAttr(c.name)}" data-company="${escapeAttr(c.company||'')}">
|
|
<span>${escapeHTML(c.name)}</span>
|
|
<small>${escapeHTML(c.company || c.number || c.email || '')}</small>
|
|
</div>`).join('');
|
|
box.querySelectorAll('div').forEach(d=>{
|
|
d.addEventListener('click',()=>{
|
|
state.customer = {id:d.dataset.id, name:d.dataset.name, company:d.dataset.company};
|
|
const pk = document.getElementById('custPicked');
|
|
pk.classList.add('show');
|
|
pk.querySelector('b').textContent = d.dataset.name;
|
|
document.getElementById('custQ').value = '';
|
|
box.innerHTML = '';
|
|
validateConfirm();
|
|
});
|
|
});
|
|
}catch{}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add a `GET /api/v1/pos/customers` route + `listCustomersForPos` service function** (small, follows the same pattern as Tasks 3/6 — no separate TDD walkthrough needed given how closely it mirrors `listActiveStockCatalog`/`catalog/route.ts`)
|
|
|
|
`src/lib/server/pos-service.ts` — append:
|
|
|
|
```ts
|
|
export type PosCustomerResult = { id: string; name: string; company: string };
|
|
|
|
export async function searchCustomersForPos(pb: PosPb, query: string): Promise<PosCustomerResult[]> {
|
|
if (!query.trim()) return [];
|
|
const safe = query.replace(/'/g, "\\'");
|
|
const rows = await pb.collection("customers").getFullList<{
|
|
id: string;
|
|
customer: string;
|
|
number?: string;
|
|
email?: string;
|
|
expand?: { company?: { company?: string } };
|
|
}>({
|
|
filter: `customer ~ '${safe}' || number ~ '${safe}' || email ~ '${safe}'`,
|
|
expand: "company",
|
|
sort: "customer",
|
|
});
|
|
return rows.slice(0, 8).map((c) => ({
|
|
id: c.id,
|
|
name: c.customer || c.email || c.number || "—",
|
|
company: c.expand?.company?.company || "",
|
|
}));
|
|
}
|
|
```
|
|
|
|
`src/app/api/v1/pos/customers/route.ts` — create:
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { assertPosAuth } from "@/lib/server/pos-auth";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { searchCustomersForPos } from "@/lib/server/pos-service";
|
|
|
|
/** GET /api/v1/pos/customers?q= — customer picker for account sales. */
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
assertPosAuth(req);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
return NextResponse.json({ error: message }, { status: 401 });
|
|
}
|
|
const { searchParams } = new URL(req.url);
|
|
const q = searchParams.get("q") || "";
|
|
const pb = await getAdminPocketBase();
|
|
const items = await searchCustomersForPos(pb, q);
|
|
return NextResponse.json({ items });
|
|
}
|
|
```
|
|
|
|
`src/lib/server/pos-service.test.ts` — append:
|
|
|
|
```ts
|
|
import { searchCustomersForPos } from "./pos-service";
|
|
|
|
describe("searchCustomersForPos", () => {
|
|
it("returns an empty array for a blank query without calling PB", async () => {
|
|
const getFullList = vi.fn();
|
|
const pb = { collection: () => ({ getFullList, getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: vi.fn() }) };
|
|
const result = await searchCustomersForPos(pb, " ");
|
|
expect(result).toEqual([]);
|
|
expect(getFullList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps customer rows to {id, name, company}", async () => {
|
|
const pb = {
|
|
collection: () => ({
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
{ id: "c1", customer: "Jane Doe", expand: { company: { company: "Acme Ltd" } } },
|
|
]),
|
|
getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: vi.fn(),
|
|
}),
|
|
};
|
|
const result = await searchCustomersForPos(pb, "jane");
|
|
expect(result).toEqual([{ id: "c1", name: "Jane Doe", company: "Acme Ltd" }]);
|
|
});
|
|
});
|
|
```
|
|
|
|
Run: `npx vitest run src/lib/server/pos-service.test.ts src/app/api/v1/pos/customers`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 3: In `pos-app.js`, carry `company` onto the sale object** — find `confirmSale()` (edited in Task 13) and confirm `state.customer` (which now includes `company`) flows into `sale.customer` unchanged — no further edit needed since the whole object is already assigned.
|
|
|
|
- [ ] **Step 4: In `receipt.html`, show the company name and add a price toggle to the delivery note**
|
|
|
|
Replace `renderDeliveryNote`:
|
|
|
|
```js
|
|
function renderDeliveryNote(s){
|
|
const lh = s.letterhead || {};
|
|
const date = new Date(s.createdAt);
|
|
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
|
const showPrices = !!document.getElementById('dnShowPrices')?.checked;
|
|
const lines = s.items.map(it=>{
|
|
const price = showPrices ? `<div class="pr">${fmt((it.selling_price||0)*it.qty)}</div>` : '';
|
|
return `<div class="dn-line">
|
|
<div class="nm">${esc(it.description||it.sku)}</div>
|
|
<div class="qt">${it.qty} ${esc(it.unit||'ea')}</div>
|
|
${price}
|
|
</div>`;
|
|
}).join('');
|
|
const companyLine = s.customer?.company ? `<div class="v">${esc(s.customer.company)}</div>` : '';
|
|
const html = `
|
|
<div class="dn-head">
|
|
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
|
|
<div class="nm">
|
|
<div class="t">Delivery note</div>
|
|
<div class="s">${esc(s.customer?.name || '—')}</div>
|
|
</div>
|
|
</div>
|
|
<div class="dn-meta">
|
|
<div><div class="k">Customer</div><div class="v">${esc(s.customer?.name||'—')}</div>${companyLine}</div>
|
|
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
|
|
<div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
|
|
${showPrices ? `<div><div class="k">Sale total</div><div class="v">${fmt(s.total)}</div></div>` : ''}
|
|
</div>
|
|
<label class="dn-toggle"><input type="checkbox" id="dnShowPrices" ${showPrices?'checked':''} onchange="renderDeliveryNote(window.__lastSale)"> Include prices</label>
|
|
<div class="dn-items">${lines}</div>
|
|
<div class="dn-sign"><div>Received by</div><div>Driver</div></div>
|
|
`;
|
|
document.getElementById('deliveryCard').innerHTML = html;
|
|
}
|
|
```
|
|
|
|
And where `renderDeliveryNote(sale)` is first called (in the `if(sale.payment==='account')` block), stash the sale for the toggle's re-render:
|
|
|
|
```js
|
|
if(sale.payment==='account'){
|
|
window.__lastSale = sale;
|
|
document.getElementById('deliveryWrap').style.display='block';
|
|
document.getElementById('printDnBtn').style.display='flex';
|
|
renderDeliveryNote(sale);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Add minimal CSS for the new toggle** — in the `<style>` block, near the existing `.dn` rules, add:
|
|
|
|
```css
|
|
.dn-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-soft);margin:10px 0;padding:0 2px}
|
|
.dn-toggle input{width:16px;height:16px}
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add public/pos/receipt.html public/pos/pos-app.js src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts src/app/api/v1/pos/customers/route.ts
|
|
git commit -m "feat(pos): delivery-note slip shows company name and optional prices; move customer search behind /api/v1/pos/customers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 18: `sw.js` — cache list + version bump
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/sw.js`
|
|
|
|
- [ ] **Step 1: Update `APP_SHELL` and bump `CACHE`**
|
|
|
|
Replace:
|
|
|
|
```js
|
|
const CACHE = "pos-v12";
|
|
const APP_SHELL = [
|
|
'./',
|
|
'./index.html',
|
|
'./pos.html',
|
|
'./receipt.html',
|
|
'./history.html',
|
|
'./cashup.html',
|
|
'./pb-config.js',
|
|
'./pos-app.js',
|
|
'./theme.js',
|
|
'./manifest.json'
|
|
];
|
|
```
|
|
|
|
with:
|
|
|
|
```js
|
|
const CACHE = "pos-v13";
|
|
const APP_SHELL = [
|
|
'./',
|
|
'./index.html',
|
|
'./pos.html',
|
|
'./receipt.html',
|
|
'./history.html',
|
|
'./cashup.html',
|
|
'./pos-config.js',
|
|
'./pos-app.js',
|
|
'./theme.js',
|
|
'./manifest.json'
|
|
];
|
|
```
|
|
|
|
- [ ] **Step 2: Update the "never cache" hostname check** — the existing rule skips caching for `pocketbase`/`sslip.io` hostnames, which no longer matters since the frontend calls same-origin `/api/v1/pos/*` now. Replace:
|
|
|
|
```js
|
|
// Never cache PocketBase API
|
|
if(url.hostname.includes('pocketbase') || url.hostname.includes('sslip.io')) return;
|
|
```
|
|
|
|
with:
|
|
|
|
```js
|
|
// Never cache API responses — only the static app shell.
|
|
if(url.pathname.startsWith('/api/')) return;
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add public/pos/sw.js
|
|
git commit -m "chore(pos): update service worker cache list and skip /api/ from caching"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 19: `manifest.json` — scope to `/pos/`
|
|
|
|
**Files:**
|
|
- Modify: `public/pos/manifest.json`
|
|
|
|
- [ ] **Step 1: Set `start_url` and `scope` to the app's real path** (they're currently relative, written for a root-hosted deploy; now the app lives under `/pos/`)
|
|
|
|
Change:
|
|
|
|
```json
|
|
"start_url": "index.html",
|
|
"scope": ".",
|
|
```
|
|
|
|
to:
|
|
|
|
```json
|
|
"start_url": "/pos/index.html",
|
|
"scope": "/pos/",
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add public/pos/manifest.json
|
|
git commit -m "chore(pos): scope PWA manifest to /pos/"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: Manual end-to-end verification
|
|
|
|
Not automatable (this is a static PWA with no existing test harness in this repo, plus it depends on physical hardware — Bluetooth printer, card machine). Do this against a dev server once the PB admin prerequisite (top of this plan) is done and `POS_API_KEY`/`PB_ADMIN_EMAIL`/`PB_ADMIN_PASSWORD` are set in `.env.local`:
|
|
|
|
- [ ] Run `npm run dev`, open `http://localhost:3000/pos/index.html` (or the configured dev port), enter the POS API key when prompted.
|
|
- [ ] Confirm the catalog loads real `stock_items` (check a known SKU's price/qty matches the main app's Stock page).
|
|
- [ ] Add items to cart, pay cash with change due — confirm the receipt shows the correct change, and that the sold item's `current_qty` decreased in the main app's Stock page.
|
|
- [ ] Repeat with card payment — confirm no change is shown.
|
|
- [ ] Pick an existing customer (with a company set), confirm the receipt's delivery-note slip shows the company name; toggle "Include prices" and confirm the per-line and total prices appear/disappear.
|
|
- [ ] Turn off network (devtools offline), make a sale, confirm it still reaches the receipt screen and shows "Saved locally"; turn network back on and confirm `flushQueue()` syncs it (check `pos_sales` in PB admin).
|
|
- [ ] Open `history.html` and `cashup.html`, confirm today's sales appear with correct totals.
|
|
- [ ] If a Bluetooth thermal printer is available, confirm `Print slip` and `Print delivery note` both produce readable output.
|
|
|
|
---
|
|
|
|
## Plan self-review notes
|
|
|
|
- **Spec coverage:** superuser-credential removal (Tasks 10-11), stock_items/stock_ledger-backed catalog and checkout (Tasks 3-4, 6-7, 12-13), pos_sales/pos_sale_items data model (manual prerequisite + Task 4), account-sale delivery slip without a new workflow record (Task 17), history/cashup off pos_sales (Tasks 15-16), POS_API_KEY auth (Task 2, wired into every route) — all covered.
|
|
- **Deviation from spec, called out explicitly at the top:** `customer_fulfillment` reused instead of a new `pos_sale` ledger reason, removing a manual PB-admin step. `customers` search also moved behind a new `/api/v1/pos/customers` route (Task 17) — the spec didn't call this out explicitly, but it was a direct-PB call in the original mockup that would otherwise still need the removed superuser credentials; folding it into the POS API surface was necessary to actually satisfy "no PocketBase credentials shipped to the client."
|
|
- Letterhead branding on the receipt is intentionally dropped rather than wired up — it's cosmetic, wasn't asked for in the design's goals, and would need another new endpoint; noted inline in Task 14 as a place to extend later if wanted.
|
|
|
|
---
|
|
|
|
## Implementation notes (2026-08-31)
|
|
|
|
Implemented on branch `feat/wfsales-pos`. Two deliberate deviations from the
|
|
plan as written, both agreed with the user before any code was written:
|
|
|
|
1. **All four tender types, not two.** `pos.html` offers Cash, Card, EFT and
|
|
Account. The plan's backend accepted only `cash`/`card` and coerced
|
|
everything else to `cash`, which would have booked account (pay-later) and
|
|
EFT sales as money in the drawer. `PosPaymentMethod` is now
|
|
`cash | card | eft | account`; only `cash` records `cash_given`/
|
|
`change_given`, the rest record zero. Stock decrements for all four —
|
|
including `account`, since the goods physically leave on a delivery note.
|
|
`cashup.html` already rendered four method rows, so under the original plan
|
|
the EFT and Account rows would have been permanently R0.
|
|
|
|
2. **Checkout is idempotent via `client_ref`.** The plan queued a sale on *any*
|
|
checkout failure, including a request the server had already committed but
|
|
whose response was lost. `flushQueue()` would then re-post it, duplicating
|
|
the sale and decrementing stock twice — contradicting the design's "a sale is
|
|
never double-decremented". The till now sends its local sale id as
|
|
`client_ref`; `checkoutSale` returns the existing record with
|
|
`duplicate: true` and performs no writes when that key is already present.
|
|
|
|
Smaller adjustments made along the way:
|
|
|
|
- `searchCustomersForPos` + `GET /api/v1/pos/customers` landed with the other
|
|
routes (Task 8) rather than inside Task 17, since it is backend work.
|
|
- The delivery-note price toggle drives the Bluetooth thermal print as well as
|
|
the on-screen slip, and the printed slip now carries the company name.
|
|
- `index.html` distinguishes a rejected API key from being offline, instead of
|
|
showing "Offline" for both.
|
|
- PB filter strings built by hand are quote-escaped (`escapePbValue`).
|
|
|
|
### Verification status
|
|
|
|
- 35 POS unit/route tests pass; `tsc --noEmit` clean for all POS files.
|
|
- Pre-existing suite failures (9 files) were confirmed identical at the merge
|
|
base — this branch introduces none.
|
|
- Live smoke test against the dev server: all 9 static files serve from
|
|
`/pos/*`, all four endpoints 401 without a valid key, and `/catalog` +
|
|
`/customers` return real PocketBase data with one.
|
|
|
|
### PocketBase collections — created 2026-08-31
|
|
|
|
Created directly against the live instance via the superuser API (the user
|
|
asked for this explicitly; `AGENTS.md`'s "user does it in PB admin" convention
|
|
was waived for this one-off). Script kept at `/tmp/pb-create-pos.mjs`, not
|
|
committed — the repo still runs no migrations against the live instance.
|
|
|
|
- **`pos_sales`** (`pbc_4138824761`) — all API rules null (superuser-only).
|
|
Fields: `created`/`updated` autodate, `sale_number`, `payment_method`
|
|
(select: cash/card/eft/account), `cash_given`, `change_given`, `subtotal`,
|
|
`vat`, `total`, `customer` (relation → customers), `client_ref`.
|
|
Indexes: `idx_pos_sales_created`, and a **partial** unique index
|
|
`idx_pos_sales_client_ref ... WHERE client_ref != ''` — a plain UNIQUE would
|
|
have rejected every sale after the first that carried an empty `client_ref`.
|
|
- **`pos_sale_items`** (`pbc_1600525088`) — all API rules null. Fields:
|
|
`created`/`updated` autodate, `sale` (relation → pos_sales, required,
|
|
cascadeDelete), `stock_item` (relation → stock_items, required), `sku`,
|
|
`description`, `qty`, `unit_cost`, `unit_price`. Index on `sale`.
|
|
|
|
### Live end-to-end results (dev server, real PocketBase)
|
|
|
|
- Cash sale of 3 units: sale written, VAT split correct (R300 → R260.87 +
|
|
R39.13), change R50, `current_qty` 7 → 4, one ledger row.
|
|
- Replay of the same `client_ref`: returned the original sale with
|
|
`duplicate: true`, wrote nothing, `current_qty` stayed 4. Idempotency holds.
|
|
- `account` and `eft` sales: recorded under their own method with
|
|
`cash_given` 0, customer relation populated, stock still decremented.
|
|
- Over-selling (999 of 7): HTTP 409 with per-line shortage detail.
|
|
- All test rows deleted and `current_qty` restored to 7 afterwards.
|
|
|
|
### Still blocked before the till can take real money
|
|
|
|
- **No sellable stock.** Of 373 active `stock_items`, zero have a
|
|
`selling_price` and zero have a `markup`; only 98 have a `cost_price`. The POS
|
|
prices from `selling_price`, so every line currently rings up at R0.00. The
|
|
user opted to populate `selling_price` in the main app rather than add a
|
|
code-side fallback.
|
|
- Bluetooth thermal printing is still unverified — needs the physical printer.
|
|
|
|
### Catalog paging (added 2026-08-31, beyond the original plan)
|
|
|
|
The till was rendering all 373 items at once. `listActiveStockCatalog` now
|
|
sorts `-created` and `PosCatalogItem` carries `created`; the frontend renders
|
|
50 at a time behind a Show-more button that also auto-loads on scroll. Header
|
|
shows "Recently added" while browsing, "Results" once filtered; changing the
|
|
search or category resets to the first chunk. Scroll position survives
|
|
re-renders so adding to the cart no longer jumps to the top.
|