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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-31 01:35:51 +02:00
parent 739ef8f846
commit b60cdd8547
6 changed files with 357 additions and 0 deletions

View file

@ -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<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);
});
});

View file

@ -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 });
}

View file

@ -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<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: [],
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);
});
});

View file

@ -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);
}

View file

@ -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<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);
});
});

View file

@ -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);
}