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 <noreply@anthropic.com>
This commit is contained in:
75
.github/workflows/ci.yml
vendored
Normal file
75
.github/workflows/ci.yml
vendored
Normal file
@@ -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 .
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("accessToken");
|
||||
}
|
||||
|
||||
// Single in-flight refresh promise so concurrent 401s don't fire multiple refreshes
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const doFetch = async (token: string | null) => {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
return fetch(`${BASE}${path}`, { ...options, headers });
|
||||
};
|
||||
|
||||
const res = await 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);
|
||||
}
|
||||
|
||||
@@ -38,11 +79,8 @@ async function request<T>(
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user