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

@@ -25,13 +25,15 @@
--- ---
## Milestone 3 — Android & Offline Sync ## Milestone 3 — Android & Offline Sync ✅ (server-side)
- [ ] `GET /api/v1/catalog/sync?since=` for delta syncs - [x] `GET /api/v1/catalog/sync?since=<ISO>` delta sync for products, categories, taxes
- [ ] `POST /api/v1/transactions/batch` with idempotency keys - [x] `POST /api/v1/transactions/batch` idempotency-keyed batch upload (207 Multi-Status)
- [ ] Android Kotlin app: MVVM, Room, offline-first flows - [x] `GET /api/v1/transactions` — paginated list with date/status/payment filters
- [x] `GET /api/v1/transactions/reports/summary` — revenue, tax, top products, payment breakdown
- [x] Reports page: stat cards, payment method breakdown, top products, transaction table
- [ ] Android Kotlin app: MVVM, Room, offline-first flows (separate deliverable)
- [ ] Background sync worker (Android) - [ ] Background sync worker (Android)
- [ ] Conflict resolution strategy: server-authoritative for payments - [ ] Conflict resolution: server-authoritative for payments (enforced via idempotency)
- [ ] Admin reporting: Android-originated transactions appear in reports
--- ---

View File

@@ -6,6 +6,7 @@ import DashboardPage from "./pages/DashboardPage";
import UsersPage from "./pages/UsersPage"; import UsersPage from "./pages/UsersPage";
import CatalogPage from "./pages/CatalogPage"; import CatalogPage from "./pages/CatalogPage";
import VendorPage from "./pages/VendorPage"; import VendorPage from "./pages/VendorPage";
import ReportsPage from "./pages/ReportsPage";
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -44,6 +45,7 @@ export default function App() {
<Route path="catalog" element={<CatalogPage />} /> <Route path="catalog" element={<CatalogPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="vendor" element={<VendorPage />} /> <Route path="vendor" element={<VendorPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -7,6 +7,7 @@ const NAV = [
{ to: "/catalog", label: "Catalog" }, { to: "/catalog", label: "Catalog" },
{ to: "/users", label: "Users" }, { to: "/users", label: "Users" },
{ to: "/vendor", label: "Vendor" }, { to: "/vendor", label: "Vendor" },
{ to: "/reports", label: "Reports" },
]; ];
export default function Layout() { export default function Layout() {

View File

@@ -0,0 +1,220 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { PageHeader } from "../components/PageHeader";
import { Table } from "../components/Table";
interface Summary {
period: { from: string | null; to: string | null };
totals: {
revenue: number;
subtotal: number;
tax: number;
discounts: number;
transactionCount: number;
};
byPaymentMethod: { method: string; revenue: number; count: number }[];
topProducts: { productId: string; productName: string; revenue: number; unitsSold: number }[];
}
interface Transaction {
id: string;
status: string;
paymentMethod: string;
total: number;
taxTotal: number;
discountTotal: number;
createdAt: string;
user: { name: string; email: string };
items: { productName: string; quantity: number; unitPrice: number; total: number }[];
}
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; totalPages: number }; }
export default function ReportsPage() {
const [tab, setTab] = useState<"summary" | "transactions">("summary");
const [summary, setSummary] = useState<Summary | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txTotal, setTxTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const loadSummary = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to + "T23:59:59").toISOString());
const s = await api.get<Summary>(`/transactions/reports/summary?${params}`);
setSummary(s);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [from, to]);
const loadTransactions = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (from) params.set("from", new Date(from).toISOString());
if (to) params.set("to", new Date(to + "T23:59:59").toISOString());
const res = await api.get<ApiList<Transaction>>(`/transactions?limit=50&${params}`);
setTransactions(res.data);
setTxTotal(res.pagination.total);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => {
if (tab === "summary") loadSummary();
else loadTransactions();
}, [tab, loadSummary, loadTransactions]);
const txColumns = [
{ key: "createdAt", header: "Date", render: (t: Transaction) => new Date(t.createdAt).toLocaleString() },
{ key: "user", header: "Staff", render: (t: Transaction) => t.user.name },
{ key: "paymentMethod", header: "Payment", render: (t: Transaction) => <span style={methodBadge(t.paymentMethod)}>{t.paymentMethod}</span> },
{ key: "status", header: "Status", render: (t: Transaction) => <span style={statusBadge(t.status)}>{t.status}</span> },
{ key: "items", header: "Items", render: (t: Transaction) => t.items.length },
{ key: "total", header: "Total", render: (t: Transaction) => `$${t.total.toFixed(2)}` },
];
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader title="Reports" subtitle="Sales and tax summaries" />
{/* Date range filter */}
<div style={filterRow}>
<label style={filterLabel}>From</label>
<input type="date" style={dateInput} value={from} onChange={(e) => setFrom(e.target.value)} />
<label style={filterLabel}>To</label>
<input type="date" style={dateInput} value={to} onChange={(e) => setTo(e.target.value)} />
<button type="button" style={applyBtn} onClick={() => tab === "summary" ? loadSummary() : loadTransactions()}>
Apply
</button>
{(from || to) && (
<button type="button" style={clearBtn} onClick={() => { setFrom(""); setTo(""); }}>
Clear
</button>
)}
</div>
{/* Tabs */}
<div style={tabs}>
{(["summary", "transactions"] as const).map((t) => (
<button key={t} type="button" style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "summary" && (
loading ? <div style={{ color: "var(--color-text-muted)" }}>Loading</div> :
summary && (
<div>
{/* Stat cards */}
<div style={statGrid}>
<StatCard label="Total Revenue" value={`$${summary.totals.revenue.toFixed(2)}`} />
<StatCard label="Transactions" value={String(summary.totals.transactionCount)} />
<StatCard label="Tax Collected" value={`$${summary.totals.tax.toFixed(2)}`} />
<StatCard label="Discounts Given" value={`$${summary.totals.discounts.toFixed(2)}`} />
</div>
<div style={twoCol}>
{/* Payment method breakdown */}
<div style={card}>
<div style={cardTitle}>By Payment Method</div>
{summary.byPaymentMethod.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No data</p>
: summary.byPaymentMethod.map((m) => (
<div key={m.method} style={methodRow}>
<span style={methodBadge(m.method)}>{m.method}</span>
<span style={{ marginLeft: "auto" }}>${m.revenue.toFixed(2)}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12, marginLeft: 8 }}>({m.count} txns)</span>
</div>
))
}
</div>
{/* Top products */}
<div style={card}>
<div style={cardTitle}>Top Products</div>
{summary.topProducts.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No data</p>
: summary.topProducts.map((p, i) => (
<div key={p.productId} style={productRow}>
<span style={rank}>{i + 1}</span>
<span style={{ flex: 1 }}>{p.productName}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>{p.unitsSold} sold</span>
<span style={{ fontWeight: 600, marginLeft: 12 }}>${p.revenue.toFixed(2)}</span>
</div>
))
}
</div>
</div>
</div>
)
)}
{tab === "transactions" && (
<div>
<div style={{ color: "var(--color-text-muted)", fontSize: 13, marginBottom: 12 }}>
{txTotal} transaction{txTotal !== 1 ? "s" : ""}
</div>
<Table columns={txColumns} data={transactions} keyField="id" loading={loading} emptyText="No transactions found." />
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={statCard}>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 26, fontWeight: 700 }}>{value}</div>
</div>
);
}
function methodBadge(method: string): React.CSSProperties {
return {
display: "inline-block", padding: "2px 10px", borderRadius: 999,
fontSize: 12, fontWeight: 600, textTransform: "capitalize",
background: method === "cash" ? "#f0fdf4" : "#eff6ff",
color: method === "cash" ? "#166534" : "#1e40af",
};
}
function statusBadge(status: string): React.CSSProperties {
const map: Record<string, { bg: string; color: string }> = {
completed: { bg: "#f0fdf4", color: "#166534" },
pending: { bg: "#fefce8", color: "#854d0e" },
failed: { bg: "#fef2f2", color: "#991b1b" },
refunded: { bg: "#f5f3ff", color: "#4c1d95" },
};
const c = map[status] ?? { bg: "#f1f5f9", color: "#475569" };
return { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, textTransform: "capitalize", ...c };
}
const filterRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, marginBottom: 20, flexWrap: "wrap" };
const filterLabel: React.CSSProperties = { fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" };
const dateInput: React.CSSProperties = { border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "6px 10px", fontSize: 13 };
const applyBtn: React.CSSProperties = { background: "var(--color-primary)", color: "#fff", border: "none", borderRadius: "var(--radius)", padding: "6px 14px", fontWeight: 600, fontSize: 13, cursor: "pointer" };
const clearBtn: React.CSSProperties = { background: "none", color: "var(--color-text-muted)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "6px 12px", fontSize: 13, cursor: "pointer" };
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)", paddingBottom: 0 };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };
const statGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 16, marginBottom: 24 };
const statCard: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "20px 24px", boxShadow: "var(--shadow)" };
const twoCol: React.CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 };
const card: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const cardTitle: React.CSSProperties = { fontWeight: 600, marginBottom: 12, fontSize: 14 };
const methodRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "8px 0", borderBottom: "1px solid var(--color-border)" };
const productRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: "1px solid var(--color-border)" };
const rank: React.CSSProperties = { width: 20, height: 20, borderRadius: "50%", background: "#e2e8f0", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 };

View File

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