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