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 |
|
| POST | /transactions/:id/refund | manager+ | Refund a completed transaction |
|
||||||
| GET | /transactions/:id/receipt | Bearer | Structured receipt payload |
|
| GET | /transactions/:id/receipt | Bearer | Structured receipt payload |
|
||||||
| GET | /transactions/reports/summary | manager+ | Revenue/tax/top-product summary |
|
| 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";
|
const BASE = "/api/v1";
|
||||||
|
|
||||||
function getToken(): string | null {
|
|
||||||
return localStorage.getItem("accessToken");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setTokens(accessToken: string, refreshToken: string) {
|
export function setTokens(accessToken: string, refreshToken: string) {
|
||||||
localStorage.setItem("accessToken", accessToken);
|
localStorage.setItem("accessToken", accessToken);
|
||||||
localStorage.setItem("refreshToken", refreshToken);
|
localStorage.setItem("refreshToken", refreshToken);
|
||||||
@@ -14,22 +10,67 @@ export function clearTokens() {
|
|||||||
localStorage.removeItem("refreshToken");
|
localStorage.removeItem("refreshToken");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(
|
function getToken(): string | null {
|
||||||
path: string,
|
return localStorage.getItem("accessToken");
|
||||||
options: RequestInit = {}
|
}
|
||||||
): Promise<T> {
|
|
||||||
const token = getToken();
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(options.headers as Record<string, string>),
|
|
||||||
};
|
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE}${path}`, { ...options, headers });
|
// 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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
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);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +78,9 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body: unknown) =>
|
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
put: <T>(path: string, body: unknown) =>
|
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
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 {
|
} else {
|
||||||
setState({ user: null, loading: false });
|
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]);
|
}, [fetchMe]);
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
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 { parsePage, paginatedResponse } from "../lib/pagination.js";
|
||||||
import { AuthenticatedRequest } from "../types/index.js";
|
import { AuthenticatedRequest } from "../types/index.js";
|
||||||
import { logger } from "../lib/logger.js";
|
import { logger } from "../lib/logger.js";
|
||||||
|
import { processPayment } from "../lib/payments.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
|
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 ──────────────────────────────────────────────
|
// ─── GET /api/v1/transactions ──────────────────────────────────────────────
|
||||||
|
|
||||||
router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
|
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 ─────────────────────────────────
|
// ─── POST /api/v1/transactions/:id/refund ─────────────────────────────────
|
||||||
// Server-authoritative: only managers/owners can issue refunds.
|
// Server-authoritative: only managers/owners can issue refunds.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user