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:
@@ -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") {
|
||||
|
||||
66
server/src/routes/catalog.ts
Normal file
66
server/src/routes/catalog.ts
Normal 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;
|
||||
261
server/src/routes/transactions.ts
Normal file
261
server/src/routes/transactions.ts
Normal 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;
|
||||
Reference in New Issue
Block a user