Milestone 3: catalog sync, batch transactions, and reports

- GET /api/v1/catalog/sync?since= — delta sync for Android offline-first
- POST /api/v1/transactions/batch — idempotency-keyed batch upload (207 Multi-Status),
  validates product ownership, skips duplicates silently
- GET /api/v1/transactions + /reports/summary — paginated list and aggregated
  revenue/tax/top-product reporting with date range filters
- ReportsPage: stat cards, payment method breakdown, top-10 products, transaction table
- Reports added to sidebar nav and router

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 06:51:27 -05:00
parent c35f92f18b
commit 91e1a1ffbf
7 changed files with 562 additions and 6 deletions

View File

@@ -9,6 +9,8 @@ import usersRouter from "./routes/users.js";
import categoriesRouter from "./routes/categories.js";
import taxesRouter from "./routes/taxes.js";
import productsRouter from "./routes/products.js";
import catalogRouter from "./routes/catalog.js";
import transactionsRouter from "./routes/transactions.js";
import { errorHandler } from "./middleware/errorHandler.js";
const __filename = fileURLToPath(import.meta.url);
@@ -34,6 +36,8 @@ export function createApp() {
app.use("/api/v1/categories", categoriesRouter);
app.use("/api/v1/taxes", taxesRouter);
app.use("/api/v1/products", productsRouter);
app.use("/api/v1/catalog", catalogRouter);
app.use("/api/v1/transactions", transactionsRouter);
// Serve React admin UI static assets in production
if (process.env.NODE_ENV === "production") {

View File

@@ -0,0 +1,66 @@
import { Router, Request, Response, NextFunction } from "express";
import { prisma } from "../lib/prisma.js";
import { requireAuth } from "../middleware/auth.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
/**
* GET /api/v1/catalog/sync?since=<ISO8601>
*
* Delta-sync endpoint for Android offline-first client.
* Returns all catalog entities updated after `since` (or all if omitted).
* Response is versioned so Android can detect stale caches.
*/
router.get("/sync", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const sinceRaw = req.query.since as string | undefined;
const since = sinceRaw ? new Date(sinceRaw) : undefined;
if (since && isNaN(since.getTime())) {
res.status(400).json({
error: { code: "BAD_REQUEST", message: "Invalid `since` date" },
});
return;
}
const updatedAfter = since ? { updatedAt: { gt: since } } : {};
const [products, categories, taxes] = await Promise.all([
prisma.product.findMany({
where: { vendorId, ...updatedAfter },
include: { category: true, tax: true },
orderBy: { updatedAt: "asc" },
}),
prisma.category.findMany({
where: { vendorId, ...updatedAfter },
orderBy: { updatedAt: "asc" },
}),
prisma.tax.findMany({
where: { vendorId, ...updatedAfter },
orderBy: { updatedAt: "asc" },
}),
]);
res.json({
syncedAt: new Date().toISOString(),
since: since?.toISOString() ?? null,
products,
categories,
taxes,
counts: {
products: products.length,
categories: categories.length,
taxes: taxes.length,
},
});
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,261 @@
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { AppError } from "../middleware/errorHandler.js";
import { parsePage, paginatedResponse } from "../lib/pagination.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
const managerUp = requireRole("owner", "manager") as unknown as (r: Request, s: Response, n: NextFunction) => void;
// ─── Schemas ──────────────────────────────────────────────────────────────
const TransactionItemSchema = z.object({
productId: z.string().min(1),
productName: z.string().min(1),
quantity: z.number().int().positive(),
unitPrice: z.number().min(0),
taxRate: z.number().min(0).max(100),
discount: z.number().min(0).default(0),
total: z.number().min(0),
});
const TransactionSchema = z.object({
idempotencyKey: z.string().min(1).max(200),
status: z.enum(["pending", "completed", "failed", "refunded"]),
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),
// Android includes a local timestamp for ordering
createdAt: z.string().datetime().optional(),
});
const BatchSchema = z.object({
transactions: z.array(TransactionSchema).min(1).max(500),
});
// ─── POST /api/v1/transactions/batch ──────────────────────────────────────
// Android pushes locally-recorded transactions. Server is authoritative on
// payments — duplicate idempotency keys are silently skipped (already processed).
router.post("/batch", auth, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId, userId } = authReq.auth;
const { transactions } = BatchSchema.parse(req.body);
const results: Array<{ idempotencyKey: string; status: "created" | "duplicate" | "error"; id?: string; error?: string }> = [];
for (const tx of transactions) {
try {
// Check for existing transaction with same idempotency key
const existing = await prisma.transaction.findUnique({
where: { idempotencyKey: tx.idempotencyKey },
select: { id: true },
});
if (existing) {
results.push({ idempotencyKey: tx.idempotencyKey, status: "duplicate", id: existing.id });
continue;
}
// Validate all product IDs belong to this vendor
const productIds = tx.items.map((i) => i.productId);
const products = await prisma.product.findMany({
where: { id: { in: productIds }, vendorId },
select: { id: true },
});
const validIds = new Set(products.map((p) => p.id));
const invalidIds = productIds.filter((id) => !validIds.has(id));
if (invalidIds.length > 0) {
results.push({
idempotencyKey: tx.idempotencyKey,
status: "error",
error: `Invalid product IDs: ${invalidIds.join(", ")}`,
});
continue;
}
const created = await prisma.transaction.create({
data: {
idempotencyKey: tx.idempotencyKey,
vendorId,
userId,
status: tx.status,
paymentMethod: tx.paymentMethod,
subtotal: tx.subtotal,
taxTotal: tx.taxTotal,
discountTotal: tx.discountTotal,
total: tx.total,
notes: tx.notes,
...(tx.createdAt ? { createdAt: new Date(tx.createdAt) } : {}),
items: {
create: tx.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
discount: item.discount,
total: item.total,
})),
},
},
select: { id: true },
});
results.push({ idempotencyKey: tx.idempotencyKey, status: "created", id: created.id });
} catch (err) {
results.push({
idempotencyKey: tx.idempotencyKey,
status: "error",
error: err instanceof Error ? err.message : "Unknown error",
});
}
}
const created = results.filter((r) => r.status === "created").length;
const duplicates = results.filter((r) => r.status === "duplicate").length;
const errors = results.filter((r) => r.status === "error").length;
res.status(207).json({ results, summary: { created, duplicates, errors } });
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions ──────────────────────────────────────────────
router.get("/", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { vendorId } = authReq.auth;
const { page, limit, skip } = parsePage(req.query as Record<string, unknown>);
const { status, paymentMethod, from, to } = req.query as Record<string, string>;
const where = {
vendorId,
...(status ? { status } : {}),
...(paymentMethod ? { paymentMethod } : {}),
...(from || to
? {
createdAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
};
const [data, total] = await Promise.all([
prisma.transaction.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true, email: true } }, items: true },
}),
prisma.transaction.count({ where }),
]);
res.json(paginatedResponse(data, total, { page, limit, skip }));
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions/:id ─────────────────────────────────────────
router.get("/:id", auth, managerUp, async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const tx = await prisma.transaction.findFirst({
where: { id: req.params.id, vendorId: authReq.auth.vendorId },
include: {
user: { select: { id: true, name: true, email: true } },
items: true,
},
});
if (!tx) throw new AppError(404, "NOT_FOUND", "Transaction not found");
res.json(tx);
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions/reports/summary ─────────────────────────────
// Daily totals, payment method breakdown, top products.
router.get("/reports/summary", 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, topProducts] = await Promise.all([
// Overall totals
prisma.transaction.aggregate({
where,
_sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true },
_count: { id: true },
}),
// Breakdown by payment method
prisma.transaction.groupBy({
by: ["paymentMethod"],
where,
_sum: { total: true },
_count: { id: true },
}),
// Top 10 products by revenue
prisma.transactionItem.groupBy({
by: ["productId", "productName"],
where: { transaction: where },
_sum: { total: true, quantity: true },
orderBy: { _sum: { total: "desc" } },
take: 10,
}),
]);
res.json({
period: { from: from ?? null, to: to ?? 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,
},
byPaymentMethod: byPayment.map((r) => ({
method: r.paymentMethod,
revenue: r._sum.total ?? 0,
count: r._count.id,
})),
topProducts: topProducts.map((r) => ({
productId: r.productId,
productName: r.productName,
revenue: r._sum.total ?? 0,
unitsSold: r._sum.quantity ?? 0,
})),
});
} catch (err) {
next(err);
}
});
export default router;