From c426b19b7ce8626d1ebadd35a707a1570ea57d22 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 21 Mar 2026 07:06:59 -0500 Subject: [PATCH] Add token auto-refresh, single-transaction endpoint, shift summary, and CI - client/api/client.ts: shared refreshPromise prevents concurrent refresh races; dispatches auth:logout event when refresh fails - client/context/AuthContext.tsx: listen for auth:logout to clear user state - server/routes/transactions.ts: POST / real-time single transaction through payment abstraction (201 completed, 202 pending); GET /reports/shift shift window totals with averageTransaction, shiftOpen/shiftClose timestamps - .github/workflows/ci.yml: server typecheck+build, client typecheck+build, Docker smoke-test on push/PR to main Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 75 ++++++++++++++ INSTRUCTIONS.md | 1 + client/src/api/client.ts | 86 +++++++++++----- client/src/context/AuthContext.tsx | 5 + server/src/routes/transactions.ts | 160 +++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b1dad8f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + server: + name: Server — typecheck & build + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: server/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Typecheck + run: npx tsc --noEmit + + - name: Build + run: npm run build + + client: + name: Client — typecheck & build + runs-on: ubuntu-latest + defaults: + run: + working-directory: client + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: client/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npx tsc --noEmit + + - name: Build + run: npm run build + + docker: + name: Docker build (smoke test) + runs-on: ubuntu-latest + needs: [server, client] + + steps: + - uses: actions/checkout@v4 + + - name: Build image + run: | + docker build \ + --build-arg NODE_ENV=production \ + -t vendor-pos:ci . diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index c2a7bb1..47815a1 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -91,6 +91,7 @@ All endpoints live under `/api/v1`. | POST | /transactions/:id/refund | manager+ | Refund a completed transaction | | GET | /transactions/:id/receipt | Bearer | Structured receipt payload | | GET | /transactions/reports/summary | manager+ | Revenue/tax/top-product summary | +| GET | /transactions/reports/shift | manager+ | Shift window totals + avg tx value | --- diff --git a/client/src/api/client.ts b/client/src/api/client.ts index ebcd5bd..7c208b8 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,9 +1,5 @@ const BASE = "/api/v1"; -function getToken(): string | null { - return localStorage.getItem("accessToken"); -} - export function setTokens(accessToken: string, refreshToken: string) { localStorage.setItem("accessToken", accessToken); localStorage.setItem("refreshToken", refreshToken); @@ -14,22 +10,67 @@ export function clearTokens() { localStorage.removeItem("refreshToken"); } -async function request( - path: string, - options: RequestInit = {} -): Promise { - const token = getToken(); - const headers: Record = { - "Content-Type": "application/json", - ...(options.headers as Record), - }; - if (token) headers["Authorization"] = `Bearer ${token}`; +function getToken(): string | null { + return localStorage.getItem("accessToken"); +} - const res = await fetch(`${BASE}${path}`, { ...options, headers }); +// Single in-flight refresh promise so concurrent 401s don't fire multiple refreshes +let refreshPromise: Promise | null = null; + +async function tryRefresh(): Promise { + const refreshToken = localStorage.getItem("refreshToken"); + if (!refreshToken) return null; + + try { + const res = await fetch(`${BASE}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + if (!res.ok) { + clearTokens(); + return null; + } + const data = await res.json() as { accessToken: string; refreshToken: string }; + setTokens(data.accessToken, data.refreshToken); + return data.accessToken; + } catch { + clearTokens(); + return null; + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const doFetch = async (token: string | null) => { + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + if (token) headers["Authorization"] = `Bearer ${token}`; + return fetch(`${BASE}${path}`, { ...options, headers }); + }; + + let res = await doFetch(getToken()); + + // On 401, attempt one token refresh then retry + if (res.status === 401) { + if (!refreshPromise) { + refreshPromise = tryRefresh().finally(() => { refreshPromise = null; }); + } + const newToken = await refreshPromise; + + if (!newToken) { + // Refresh failed — signal the app to go to login + window.dispatchEvent(new CustomEvent("auth:logout")); + throw new Error("Session expired. Please sign in again."); + } + + res = await doFetch(newToken); + } if (!res.ok) { const body = await res.json().catch(() => ({})); - const message = body?.error?.message ?? `HTTP ${res.status}`; + const message = (body as { error?: { message?: string } })?.error?.message ?? `HTTP ${res.status}`; throw new Error(message); } @@ -37,12 +78,9 @@ async function request( } export const api = { - get: (path: string) => request(path), - post: (path: string, body: unknown) => - request(path, { method: "POST", body: JSON.stringify(body) }), - put: (path: string, body: unknown) => - request(path, { method: "PUT", body: JSON.stringify(body) }), - patch: (path: string, body: unknown) => - request(path, { method: "PATCH", body: JSON.stringify(body) }), - delete: (path: string) => request(path, { method: "DELETE" }), + get: (path: string) => request(path), + post: (path: string, body: unknown) => request(path, { method: "POST", body: JSON.stringify(body) }), + put: (path: string, body: unknown) => request(path, { method: "PUT", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: "DELETE" }), }; diff --git a/client/src/context/AuthContext.tsx b/client/src/context/AuthContext.tsx index 1d53a2c..2d2ded0 100644 --- a/client/src/context/AuthContext.tsx +++ b/client/src/context/AuthContext.tsx @@ -41,6 +41,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } else { setState({ user: null, loading: false }); } + + // When the API layer exhausts its refresh retry, force logout + const onForcedLogout = () => setState({ user: null, loading: false }); + window.addEventListener("auth:logout", onForcedLogout); + return () => window.removeEventListener("auth:logout", onForcedLogout); }, [fetchMe]); const login = async (email: string, password: string) => { diff --git a/server/src/routes/transactions.ts b/server/src/routes/transactions.ts index 3f44b4f..53449f4 100644 --- a/server/src/routes/transactions.ts +++ b/server/src/routes/transactions.ts @@ -6,6 +6,7 @@ import { AppError } from "../middleware/errorHandler.js"; import { parsePage, paginatedResponse } from "../lib/pagination.js"; import { AuthenticatedRequest } from "../types/index.js"; import { logger } from "../lib/logger.js"; +import { processPayment } from "../lib/payments.js"; const router = Router(); const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void; @@ -131,6 +132,96 @@ router.post("/batch", auth, async (req: Request, res: Response, next: NextFuncti } }); +// ─── POST /api/v1/transactions (single, real-time) ──────────────────────── +// Used by the Android POS for live transactions. Runs through the payment +// abstraction before persisting; returns immediately with success/failure. + +const SingleTransactionSchema = z.object({ + idempotencyKey: z.string().min(1).max(200), + paymentMethod: z.enum(["cash", "card"]), + subtotal: z.number().min(0), + taxTotal: z.number().min(0), + discountTotal: z.number().min(0), + total: z.number().min(0), + notes: z.string().max(500).optional(), + items: z.array(TransactionItemSchema).min(1), +}); + +router.post("/", auth, async (req: Request, res: Response, next: NextFunction) => { + try { + const authReq = req as AuthenticatedRequest; + const { vendorId, userId } = authReq.auth; + const body = SingleTransactionSchema.parse(req.body); + + // Idempotency guard + const existing = await prisma.transaction.findUnique({ + where: { idempotencyKey: body.idempotencyKey }, + }); + if (existing) { + res.json(existing); + return; + } + + // Run payment through provider abstraction + const paymentResult = await processPayment({ + method: body.paymentMethod, + amount: body.total, + currency: process.env.CURRENCY ?? "AUD", + reference: body.idempotencyKey, + }); + + const status = paymentResult.status === "completed" ? "completed" + : paymentResult.status === "pending" ? "pending" + : "failed"; + + const tx = await prisma.transaction.create({ + data: { + idempotencyKey: body.idempotencyKey, + vendorId, + userId, + status, + paymentMethod: body.paymentMethod, + subtotal: body.subtotal, + taxTotal: body.taxTotal, + discountTotal: body.discountTotal, + total: body.total, + notes: body.notes, + items: { + create: body.items.map((item) => ({ + productId: item.productId, + productName: item.productName, + quantity: item.quantity, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + discount: item.discount, + total: item.total, + })), + }, + }, + include: { items: true }, + }); + + logger.info("transaction.created", { + id: tx.id, + status, + total: tx.total, + providerRef: paymentResult.providerRef, + }); + + res.status(status === "completed" ? 201 : 202).json({ + ...tx, + payment: { + status: paymentResult.status, + providerRef: paymentResult.providerRef, + errorCode: paymentResult.errorCode, + errorMessage: paymentResult.errorMessage, + }, + }); + } catch (err) { + next(err); + } +}); + // ─── GET /api/v1/transactions ────────────────────────────────────────────── router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => { @@ -259,6 +350,75 @@ router.get("/reports/summary", auth, managerUp, async (req: Request, res: Respon } }); +// ─── GET /api/v1/transactions/reports/shift ─────────────────────────────── +// Totals for a single shift window (e.g. today). Same shape as summary but +// also returns an average transaction value and opening/closing time of the +// first and last completed transaction in the period. + +router.get("/reports/shift", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => { + try { + const authReq = req as AuthenticatedRequest; + const { vendorId } = authReq.auth; + const { from, to } = req.query as { from?: string; to?: string }; + + const dateFilter = { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }; + const where = { + vendorId, + status: "completed", + ...(from || to ? { createdAt: dateFilter } : {}), + }; + + const [totals, byPayment, firstTx, lastTx] = await Promise.all([ + prisma.transaction.aggregate({ + where, + _sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true }, + _count: { id: true }, + _avg: { total: true }, + }), + prisma.transaction.groupBy({ + by: ["paymentMethod"], + where, + _sum: { total: true }, + _count: { id: true }, + }), + prisma.transaction.findFirst({ + where, + orderBy: { createdAt: "asc" }, + select: { createdAt: true }, + }), + prisma.transaction.findFirst({ + where, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }), + ]); + + res.json({ + period: { from: from ?? null, to: to ?? null }, + shiftOpen: firstTx?.createdAt ?? null, + shiftClose: lastTx?.createdAt ?? null, + totals: { + revenue: totals._sum.total ?? 0, + subtotal: totals._sum.subtotal ?? 0, + tax: totals._sum.taxTotal ?? 0, + discounts: totals._sum.discountTotal ?? 0, + transactionCount: totals._count.id, + averageTransaction: totals._avg.total ?? 0, + }, + byPaymentMethod: byPayment.map((r) => ({ + method: r.paymentMethod, + revenue: r._sum.total ?? 0, + count: r._count.id, + })), + }); + } catch (err) { + next(err); + } +}); + // ─── POST /api/v1/transactions/:id/refund ───────────────────────────────── // Server-authoritative: only managers/owners can issue refunds.