From b60cdd8547b4be00ac33675288fdecdbee636fb3 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 01:35:51 +0200 Subject: [PATCH] feat(pos): add catalog, checkout and sales API routes All three gated on POS_API_KEY. Checkout accepts all four tender types and forwards client_ref for idempotent offline-queue replays. Co-Authored-By: Claude --- src/app/api/v1/pos/catalog/route.test.ts | 51 ++++++++ src/app/api/v1/pos/catalog/route.ts | 23 ++++ src/app/api/v1/pos/checkout/route.test.ts | 152 ++++++++++++++++++++++ src/app/api/v1/pos/checkout/route.ts | 52 ++++++++ src/app/api/v1/pos/sales/route.test.ts | 50 +++++++ src/app/api/v1/pos/sales/route.ts | 29 +++++ 6 files changed, 357 insertions(+) create mode 100644 src/app/api/v1/pos/catalog/route.test.ts create mode 100644 src/app/api/v1/pos/catalog/route.ts create mode 100644 src/app/api/v1/pos/checkout/route.test.ts create mode 100644 src/app/api/v1/pos/checkout/route.ts create mode 100644 src/app/api/v1/pos/sales/route.test.ts create mode 100644 src/app/api/v1/pos/sales/route.ts diff --git a/src/app/api/v1/pos/catalog/route.test.ts b/src/app/api/v1/pos/catalog/route.test.ts new file mode 100644 index 0000000..7ba822b --- /dev/null +++ b/src/app/api/v1/pos/catalog/route.test.ts @@ -0,0 +1,51 @@ +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 = {}; + 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); + }); +}); diff --git a/src/app/api/v1/pos/catalog/route.ts b/src/app/api/v1/pos/catalog/route.ts new file mode 100644 index 0000000..2ea7838 --- /dev/null +++ b/src/app/api/v1/pos/catalog/route.ts @@ -0,0 +1,23 @@ +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 }); +} diff --git a/src/app/api/v1/pos/checkout/route.test.ts b/src/app/api/v1/pos/checkout/route.test.ts new file mode 100644 index 0000000..d47e701 --- /dev/null +++ b/src/app/api/v1/pos/checkout/route.test.ts @@ -0,0 +1,152 @@ +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 = { "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: [], + duplicate: false, + }); + 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); + }); + + // All four tender types the POS UI offers must survive the round trip. + // Coercing eft/account to cash would book money into the drawer that + // was never taken. + it.each(["cash", "card", "eft", "account"] as const)( + "passes payment_method %s through unchanged", + async (method) => { + vi.mocked(checkoutSale).mockResolvedValue({ + ok: true, + sale: { id: "sale-1" } as never, + items: [], + stockErrors: [], + duplicate: false, + }); + await POST( + checkoutRequest({ + items: [{ stock_item_id: "si1", qty: 1, unit_price: 10 }], + payment_method: method, + }), + ); + expect(checkoutSale).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ payment_method: method }), + ); + }, + ); + + it("defaults an unrecognised payment_method to cash", async () => { + vi.mocked(checkoutSale).mockResolvedValue({ + ok: true, + sale: { id: "sale-1" } as never, + items: [], + stockErrors: [], + duplicate: false, + }); + 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("forwards client_ref so a replayed offline sale stays idempotent", async () => { + vi.mocked(checkoutSale).mockResolvedValue({ + ok: true, + sale: { id: "sale-1" } as never, + items: [], + stockErrors: [], + duplicate: true, + }); + await POST( + checkoutRequest({ + items: [{ stock_item_id: "si1", qty: 1, unit_price: 10 }], + payment_method: "cash", + client_ref: "local-42", + }), + ); + expect(checkoutSale).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ client_ref: "local-42" }), + ); + }); + + it("returns 401 without a valid POS_API_KEY", async () => { + const res = await POST(checkoutRequest({ items: [] }, "Bearer wrong")); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/v1/pos/checkout/route.ts b/src/app/api/v1/pos/checkout/route.ts new file mode 100644 index 0000000..fce97dc --- /dev/null +++ b/src/app/api/v1/pos/checkout/route.ts @@ -0,0 +1,52 @@ +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"; +import { POS_PAYMENT_METHOD_VALUES, type PosPaymentMethod } from "@/lib/types"; + +/** + * POST /api/v1/pos/checkout + * + * Confirms a POS sale. Body: { items: [{stock_item_id, qty, unit_price, + * sku?, description?}], payment_method: "cash"|"card"|"eft"|"account", + * cash_given?, customer_id?, client_ref? }. + * + * `client_ref` is the till's local sale id. Passing it makes checkout + * idempotent, so the offline queue can safely retry a sale whose + * response was lost without decrementing stock twice. + * + * Returns 200 with `{ ok: true, sale, items, stockErrors, duplicate }` + * on success (stockErrors non-empty means the sale was recorded but one + * or more lines didn't decrement stock — needs manual reconciliation; + * duplicate true means this was a replay of an existing sale), + * 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 method = POS_PAYMENT_METHOD_VALUES.includes(body?.payment_method as PosPaymentMethod) + ? (body.payment_method as PosPaymentMethod) + : "cash"; + + const pb = await getAdminPocketBase(); + const result = await checkoutSale(pb, { + items: Array.isArray(body?.items) ? body.items : [], + payment_method: method, + cash_given: body?.cash_given, + customer_id: body?.customer_id, + client_ref: body?.client_ref, + }); + + if (!result.ok) { + const status = result.reason === "insufficient_stock" ? 409 : 400; + return NextResponse.json(result, { status }); + } + return NextResponse.json(result); +} diff --git a/src/app/api/v1/pos/sales/route.test.ts b/src/app/api/v1/pos/sales/route.test.ts new file mode 100644 index 0000000..937b2da --- /dev/null +++ b/src/app/api/v1/pos/sales/route.test.ts @@ -0,0 +1,50 @@ +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 = {}; + 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); + }); +}); diff --git a/src/app/api/v1/pos/sales/route.ts b/src/app/api/v1/pos/sales/route.ts new file mode 100644 index 0000000..2e4fd84 --- /dev/null +++ b/src/app/api/v1/pos/sales/route.ts @@ -0,0 +1,29 @@ +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); +}