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:
2026-03-21 07:06:59 -05:00
parent 2aa041d45e
commit c426b19b7c
5 changed files with 303 additions and 24 deletions

View File

@@ -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.