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:
parent
739ef8f846
commit
b60cdd8547
6 changed files with 357 additions and 0 deletions
51
src/app/api/v1/pos/catalog/route.test.ts
Normal file
51
src/app/api/v1/pos/catalog/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
src/app/api/v1/pos/catalog/route.ts
Normal file
23
src/app/api/v1/pos/catalog/route.ts
Normal 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 });
|
||||
}
|
||||
152
src/app/api/v1/pos/checkout/route.test.ts
Normal file
152
src/app/api/v1/pos/checkout/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
src/app/api/v1/pos/checkout/route.ts
Normal file
52
src/app/api/v1/pos/checkout/route.ts
Normal 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);
|
||||
}
|
||||
50
src/app/api/v1/pos/sales/route.test.ts
Normal file
50
src/app/api/v1/pos/sales/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
src/app/api/v1/pos/sales/route.ts
Normal file
29
src/app/api/v1/pos/sales/route.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue