An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.
An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.
`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.
Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.
Also in here:
- DN number collision retry. The generator reads the highest existing number
and adds one, which is not atomic, while PB holds a unique index on it. The
POS adds a second concurrent writer, so two creates could collide and 500
on a benign condition.
- GET /api/v1/pos/sales now expands customer, customer.company and
delivery_note so history.html can rebuild the DN card on a reprint. The
nested customer.company matters — customer.company is itself a relation,
so without it the card printed a raw PB id where the name belongs.
- "Print slip" is hidden for account sales; that tender produces a delivery
note, not a slip.
New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.
Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
155 lines
4.6 KiB
TypeScript
155 lines
4.6 KiB
TypeScript
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,
|
|
deliveryNote: null,
|
|
});
|
|
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 400 when a stock item can't be found (e.g. deleted since being added to the cart)", async () => {
|
|
vi.mocked(checkoutSale).mockResolvedValue({
|
|
ok: false,
|
|
reason: "pb_error",
|
|
message: "Stock item si1 could not be found (it may have been deleted) — remove it from the cart and try again: not found",
|
|
});
|
|
const res = await POST(
|
|
checkoutRequest({
|
|
items: [{ stock_item_id: "si1", qty: 5, unit_price: 10 }],
|
|
payment_method: "cash",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
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,
|
|
deliveryNote: null,
|
|
});
|
|
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,
|
|
deliveryNote: null,
|
|
});
|
|
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,
|
|
deliveryNote: null,
|
|
});
|
|
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);
|
|
});
|
|
});
|