proflo / services /api /src /index.ts
NET-DNA Researcher
Deploy ProFlo UI revamp: shop, admin, cashier + API + Images
ae711df
Raw History Blame Contribute Delete
50.7 kB
import "dotenv/config";
import cors from "cors";
import express from "express";
import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import QRCode from "qrcode";
import { randomUUID } from "crypto";
import { createHash } from "crypto";
import fs from "fs/promises";
import path from "path";
import { addCartItemSchema, checkoutSchema, createSessionSchema, setCartLineQtySchema } from "@checkout/shared";
import { verifyDb } from "./db/pool";
import {
isProductImageStorageConfigured,
productImageStorageStatus,
uploadProductImageToStorage
} from "./storage/productImages";
import {
adjustProductStock,
commitSale,
ConcurrentStockError,
InsufficientStockError,
listStockMovements,
releaseExpiredReservations,
releaseOrderReservations,
reserveOrderFromSession,
restockRefund,
syncSessionReservations,
withTransaction
} from "./db/inventory";
import {
createOrder,
createProduct,
createSession,
deleteDiscount,
findProductByBarcode,
findProductById,
getDiscountMap,
getDiscountPercent,
getLatestSessionByPhone,
getMetrics,
getOrder,
getOrderByToken,
getReceiptByOrderId,
getSession,
insertReceipt,
isExitTokenUsed,
listAudit,
listOrders,
listPendingCounterOrders,
listProducts,
listReceipts,
markExitTokenUsed,
nextCounterToken,
pushAudit,
saveSessionCart,
setDiscount,
shelfUnitPrice,
profitPerUnit,
updateOrderPaid,
updateOrderRefunded,
updateOrderVoided,
updateProductImage,
updateProductDetails,
type Order,
type OrderLineSnapshot,
type Product,
type Session
} from "./db/store";
const app = express();
const uploadsRoot = path.join(process.cwd(), "uploads");
const productUploadsDir = path.join(uploadsRoot, "products");
app.use(cors());
app.use(express.json({ limit: "6mb" }));
app.use("/uploads", express.static(uploadsRoot));
app.use((req, _res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
next();
});
const receiptNotifyWebhook = process.env.RECEIPT_NOTIFY_WEBHOOK_URL?.trim();
const resendApiKey = process.env.RESEND_API_KEY?.trim();
const emailFrom = process.env.EMAIL_FROM?.trim();
const lowStockNotifyWebhook = process.env.LOW_STOCK_NOTIFY_WEBHOOK_URL?.trim();
const port = Number(process.env.PORT ?? 4000);
const isProd = process.env.NODE_ENV === "production";
const jwtSecret = process.env.JWT_SECRET ?? (isProd ? "" : "dev-jwt-secret-local-only");
const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET;
const adminUsername = process.env.ADMIN_USERNAME ?? (isProd ? "" : "admin");
const adminPassword = process.env.ADMIN_PASSWORD ?? (isProd ? "" : "admin123");
const managerUsername = process.env.MANAGER_USERNAME ?? (isProd ? "" : "manager");
const managerPassword = process.env.MANAGER_PASSWORD ?? (isProd ? "" : "manager123");
const cashierApiKey = process.env.CASHIER_API_KEY?.trim();
if (!jwtSecret) {
throw new Error("JWT_SECRET is required in services/api/.env");
}
if (isProd && (!adminUsername || !adminPassword || !managerUsername || !managerPassword)) {
throw new Error("ADMIN_* and MANAGER_* credentials are required in production");
}
function pathParam(value: string | string[] | undefined): string {
if (value == null) return "";
return Array.isArray(value) ? (value[0] ?? "") : value;
}
type JwtRole = "staff" | "manager";
function requireAuth(minRole: "staff" | "manager") {
return (req: Request, res: Response, next: NextFunction) => {
const raw = req.headers.authorization;
const auth = Array.isArray(raw) ? raw[0] : raw;
const token = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!token) return res.status(401).json({ message: "Unauthorized" });
try {
const decoded = jwt.verify(token, jwtSecret!) as { role?: string; sub?: string };
const r = decoded.role === "manager" ? "manager" : decoded.role === "staff" ? "staff" : "";
if (r !== "staff" && r !== "manager") return res.status(403).json({ message: "Forbidden" });
if (minRole === "manager" && r !== "manager") return res.status(403).json({ message: "Manager role required" });
(req as Request & { staff?: { sub: string; role: JwtRole } }).staff = {
sub: String(decoded.sub ?? "user"),
role: r
};
next();
} catch {
return res.status(401).json({ message: "Invalid or expired token" });
}
};
}
function requireCashierKey(req: Request, res: Response, next: NextFunction) {
if (!cashierApiKey) return next();
const key = req.headers["x-cashier-key"];
const val = Array.isArray(key) ? key[0] : key;
if (val !== cashierApiKey) return res.status(401).json({ message: "Invalid cashier key" });
next();
}
async function toCustomerProduct(p: Product) {
const d = await getDiscountPercent(p.id);
const effective = shelfUnitPrice(p, d);
return {
id: p.id,
barcode: p.barcode,
sku: p.sku,
name: p.name,
category: p.category,
styleCode: p.styleCode,
size: p.size,
color: p.color,
brand: p.brand,
season: p.season,
gender: p.gender,
unitPrice: effective,
listPrice: p.unitPrice,
discountPercent: d,
taxPercent: p.taxPercent,
demandScore: p.demandScore,
inStock: p.availableQty,
imageUrl: p.imageUrl ?? undefined
};
}
function stockErrorResponse(res: Response, e: unknown) {
if (e instanceof InsufficientStockError) {
return res.status(409).json({ message: e.message, code: "INSUFFICIENT_STOCK", productId: e.productId });
}
if (e instanceof ConcurrentStockError) {
return res.status(409).json({ message: e.message, code: "CONCURRENT_STOCK" });
}
return null;
}
async function persistSessionCart(session: Session): Promise<void> {
await saveSessionCart(session.id, session.cart);
await syncSessionReservations(
session.id,
session.storeId,
session.cart,
new Date(session.expiresAt)
);
}
async function computeCart(session: Session) {
const phone = (session.customerPhone ?? "").replace(/\D/g, "");
let loyaltyDiscountPct = 0;
if (phone.endsWith("77") || phone.endsWith("99")) {
loyaltyDiscountPct = 10;
} else if (phone.endsWith("00") || phone.endsWith("55")) {
loyaltyDiscountPct = 5;
}
let subtotal = 0;
let taxTotal = 0;
let loyaltyDiscount = 0;
const lines: Array<{
productId: string;
barcode: string;
name: string;
qty: number;
unitPrice: number;
taxPercent: number;
lineSubtotal: number;
lineTax: number;
lineTotal: number;
}> = [];
for (const [productId, qty] of session.cart) {
const product = await findProductById(productId);
if (!product) continue;
const d = await getDiscountPercent(productId);
const unit = shelfUnitPrice(product, d);
const lineSubtotal = unit * qty;
// Apply loyalty discount to pre-tax price
const discountAmount = lineSubtotal * (loyaltyDiscountPct / 100);
const discountedLineSubtotal = lineSubtotal - discountAmount;
const lineTax = (discountedLineSubtotal * product.taxPercent) / 100;
subtotal += lineSubtotal;
loyaltyDiscount += discountAmount;
taxTotal += lineTax;
lines.push({
productId: product.id,
barcode: product.barcode,
name: product.name,
qty,
unitPrice: unit,
taxPercent: product.taxPercent,
lineSubtotal: discountedLineSubtotal,
lineTax,
lineTotal: discountedLineSubtotal + lineTax
});
}
return {
items: lines,
subtotal,
taxTotal,
loyaltyDiscount,
loyaltyDiscountPercent: loyaltyDiscountPct,
grandTotal: Math.max(0, (subtotal - loyaltyDiscount) + taxTotal)
};
}
function orderPublicView(order: Order) {
return {
orderId: order.id,
tokenNumber: order.tokenNumber ?? null,
paid: order.paid,
paymentMode: order.paymentMode,
subtotal: order.subtotal,
taxTotal: order.taxTotal,
grandTotal: order.total,
lines: order.lines
};
}
async function notifyReceiptWebhook(order: Order) {
if (!receiptNotifyWebhook) return;
try {
await fetch(receiptNotifyWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "receipt",
orderId: order.id,
total: order.total,
storeCode: order.storeCode,
receiptEmail: order.receiptEmail ?? null,
lines: order.lines.map((l) => ({ name: l.name, qty: l.qty, lineTotal: l.lineTotal }))
})
});
} catch {
await pushAudit("receipt", "system", "receipt.webhook.fail", order.id);
}
}
function receiptEmailHtml(order: Order, receiptNumber: string): string {
const rows = order.lines
.map(
(l) =>
`<tr><td>${l.name}</td><td align="right">${l.qty}</td><td align="right">₹${l.lineTotal.toFixed(2)}</td></tr>`
)
.join("");
return `<!DOCTYPE html><html><body style="font-family:sans-serif;color:#111">
<h2>ProFlo receipt</h2>
<p><strong>${receiptNumber}</strong> · ${order.storeCode}</p>
<table cellpadding="6" cellspacing="0" border="0">${rows}</table>
<p>Subtotal: ₹${order.subtotal.toFixed(2)}<br>Tax: ₹${order.taxTotal.toFixed(2)}<br><strong>Total: ₹${order.total.toFixed(2)}</strong></p>
<p>Thank you for shopping with ProFlo.</p>
</body></html>`;
}
async function sendReceiptEmail(order: Order, receiptNumber: string): Promise<boolean> {
const to = order.receiptEmail?.trim();
if (!resendApiKey || !emailFrom || !to) return false;
try {
const resp = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${resendApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: emailFrom,
to: [to],
subject: `Your ProFlo receipt ${receiptNumber}`,
html: receiptEmailHtml(order, receiptNumber)
})
});
if (!resp.ok) {
const detail = await resp.text().catch(() => "");
console.error("Resend API error", resp.status, detail);
}
return resp.ok;
} catch (e) {
console.error("receipt email failed", e);
return false;
}
}
async function recordPaidOrder(order: Order, whatsapp: string | null, auditActor: string, auditRole: string): Promise<string> {
await withTransaction(async (client) => {
await commitSale(
order.id,
order.lines.map((l) => ({ productId: l.productId, qty: l.qty })),
auditActor,
client
);
await updateOrderPaid(order.id, true, client);
});
order.paid = true;
const receiptId = `rcpt_${Date.now()}`;
const receiptNumber = `SM-${Date.now()}`;
await insertReceipt({
id: receiptId,
orderId: order.id,
receiptNumber,
total: order.total,
paymentMode: order.paymentMode,
whatsapp: null
});
await pushAudit(auditActor, auditRole, "order.settled", order.id);
void notifyReceiptWebhook(order);
void sendReceiptEmail(order, receiptNumber).then((ok) => {
if (ok) void pushAudit("receipt", "system", "receipt.email.sent", order.id);
});
return receiptId;
}
app.get("/health", async (_req, res) => {
try {
await verifyDb();
const imageStorage = productImageStorageStatus();
res.json({
ok: true,
service: "checkout-api",
database: "connected",
imageStorage: imageStorage.configured ? "supabase" : "local",
imageStorageHint: imageStorage.hint
});
} catch (e) {
res.status(503).json({ ok: false, service: "checkout-api", database: "error", message: String(e) });
}
});
app.post("/v1/admin/auth/login", async (req, res) => {
const username = String(req.body?.username ?? "");
const password = String(req.body?.password ?? "");
const adminUser = adminUsername ?? "admin";
const adminPass = adminPassword ?? "admin123";
const mgrUser = managerUsername ?? "manager";
const mgrPass = managerPassword ?? "manager123";
let role: JwtRole | null = null;
if (username === mgrUser && password === mgrPass) role = "manager";
else if (username === adminUser && password === adminPass) role = "staff";
if (!role) return res.status(401).json({ message: "Invalid username or password" });
const token = jwt.sign({ role, sub: username }, jwtSecret!, { expiresIn: "8h" });
await pushAudit(username, role, "auth.login", "ok");
return res.json({ token, role, expiresInSeconds: 8 * 60 * 60 });
});
app.get("/v1/admin/metrics", requireAuth("staff"), async (req, res) => {
const sc = String(req.query.storeCode ?? "").trim().toUpperCase();
res.json(await getMetrics(sc || undefined));
});
app.get("/v1/admin/orders", requireAuth("staff"), async (req, res) => {
const raw = Number(req.query.limit);
const limit = Number.isFinite(raw) ? Math.min(100, Math.max(1, Math.floor(raw))) : 25;
const sc = String(req.query.storeCode ?? "").trim().toUpperCase();
const rows = await listOrders(limit, sc || undefined);
res.json(
rows.map((o) => ({
orderId: o.id,
createdAt: o.createdAt,
storeCode: o.storeCode,
paid: o.paid,
voided: o.voided,
refunded: o.refunded,
paymentMode: o.paymentMode,
tokenNumber: o.tokenNumber ?? null,
grandTotal: o.total,
lineCount: o.lines.length
}))
);
});
app.get("/v1/admin/products", requireAuth("staff"), async (_req, res) => {
const products = await listProducts();
const rows = await Promise.all(
products.map(async (p) => {
const d = await getDiscountPercent(p.id);
const eff = shelfUnitPrice(p, d);
return {
...p,
discountPercent: d,
effectiveUnitPrice: eff,
profitPerUnitAtList: profitPerUnit(p, p.unitPrice),
profitPerUnitAtSale: profitPerUnit(p, eff)
};
})
);
res.json(rows);
});
app.get("/v1/admin/discounts", requireAuth("staff"), async (_req, res) => {
const products = await listProducts();
const discountMap = await getDiscountMap();
const rows = [...discountMap.entries()].map(([productId, discountPercent]) => {
const p = products.find((x) => x.id === productId);
if (!p) return null;
const eff = shelfUnitPrice(p, discountPercent);
return {
productId,
name: p.name,
barcode: p.barcode,
category: p.category,
listPrice: p.unitPrice,
discountPercent,
effectiveUnitPrice: eff,
profitPerUnitAtList: profitPerUnit(p, p.unitPrice),
profitPerUnitAtSale: profitPerUnit(p, eff)
};
});
res.json(rows.filter(Boolean));
});
app.post("/v1/admin/discounts", requireAuth("staff"), async (req, res) => {
const productId = String(req.body?.productId ?? "");
const pct = Number(req.body?.discountPercent);
const p = await findProductById(productId);
if (!p) return res.status(404).json({ message: "Product not found" });
if (!Number.isFinite(pct) || pct < 0 || pct > 90) {
return res.status(400).json({ message: "discountPercent must be between 0 and 90" });
}
await setDiscount(productId, Math.floor(pct));
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
await pushAudit(actor, "staff", "discount.set", `${productId}=${Math.floor(pct)}`);
return res.json({ ok: true, ...(await toCustomerProduct(p)) });
});
app.delete("/v1/admin/discounts/:productId", requireAuth("staff"), async (req, res) => {
const id = pathParam(req.params.productId);
await deleteDiscount(id);
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
await pushAudit(actor, "staff", "discount.delete", id);
return res.json({ ok: true });
});
app.post("/v1/customer/session", async (req, res) => {
const parsed = createSessionSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
const session = await createSession(parsed.data.storeCode, parsed.data.customerPhone);
return res.status(201).json({ sessionId: session.id, expiresInMinutes: 60 });
});
app.get("/v1/customer/session/latest", async (req, res) => {
const phone = String(req.query.phone ?? "").trim();
if (phone.length < 8) return res.status(400).json({ message: "phone query must be at least 8 characters" });
const hit = await getLatestSessionByPhone(phone);
if (!hit) return res.status(404).json({ message: "No active visit found for that phone on this register" });
return res.json({ sessionId: hit.id, storeCode: hit.storeCode, createdAt: hit.createdAt });
});
app.get("/v1/customer/recommendations", async (_req, res) => {
const products = await listProducts();
const highDemand = [...products].sort((a, b) => b.demandScore - a.demandScore).slice(0, 5);
const highDemandRows = await Promise.all(highDemand.map(toCustomerProduct));
return res.json({ highDemand: highDemandRows });
});
app.get("/v1/customer/products", async (req, res) => {
const query = String(req.query.q ?? "").trim().toLowerCase();
let products = await listProducts();
if (query) {
products = products.filter((p) => {
const name = p.name.toLowerCase();
const barcode = (p.barcode ?? "").toLowerCase();
const category = (p.category ?? "").toLowerCase();
const style = (p.styleCode ?? "").toLowerCase();
const size = (p.size ?? "").toLowerCase();
const color = (p.color ?? "").toLowerCase();
const brand = (p.brand ?? "").toLowerCase();
const sku = (p.sku ?? "").toLowerCase();
return (
name.includes(query) ||
barcode.includes(query) ||
category.includes(query) ||
style.includes(query) ||
size.includes(query) ||
color.includes(query) ||
brand.includes(query) ||
sku.includes(query)
);
});
}
res.json(await Promise.all(products.map(toCustomerProduct)));
});
app.post("/v1/customer/fashion-bot", async (req, res) => {
const userMessages = req.body.messages;
if (!Array.isArray(userMessages)) {
return res.status(400).json({ message: "messages array is required" });
}
try {
const products = await listProducts();
const inventoryContext = products
.map(
(p) =>
`- Name: ${p.name}, Brand: ${p.brand || "ProFlo"}, Style Code: ${p.styleCode || "N/A"}, Category: ${p.category}, Color: ${p.color || "N/A"}, Size: ${p.size || "N/A"}, Price: $${p.unitPrice}, Available Stock: ${p.availableQty}, Barcode: ${p.barcode}`
)
.join("\n");
const systemPrompt = {
role: "system",
content: `You are the ProFlo AI Fashion Assistant. You are a personal stylist, fashion coordinator, and shopping helper at the ProFlo Flagship store.
Your goal is to help users with style advice, coordinate outfits, and answer fashion questions.
Here is the current ProFlo store product catalog in stock:
${inventoryContext}
Rules:
1. When recommending a product, try to mention actual items from the catalog.
2. If you recommend a product from the catalog, you MUST reference it in your response using this exact syntax: [Product:BARCODE] (replace BARCODE with the actual barcode of the item, e.g., [Product:123456789012]). This allows the UI to render the interactive product card.
3. If the user uploads a photo of themselves, analyze their style, colors, or clothes, and give constructive style tips and suggest products from the catalog that would suit them well.
4. Be fashion-forward, trend-savvy, and helpful.
5. IMPORTANT formatting rule: Do NOT use raw markdown formatting, headers (e.g. #, ##, ###), bold markdown (e.g. **text**), or bullet points (e.g. - item). Instead, write in clean, professional, and well-structured natural English paragraphs with normal line breaks. For lists, use plain numbering (e.g., "1. First item..." or "2. Second item...") or standard prose.`
};
const formattedMessages = userMessages.map((m: any) => {
const role = String(m.role || "user");
const textContent = String(m.content || m.text || "");
if (m.image) {
return {
role,
content: [
{ type: "text", text: textContent },
{ type: "image_url", image_url: { url: m.image } }
]
};
}
return {
role,
content: textContent
};
});
const messagesToSend = [systemPrompt, ...formattedMessages];
const mistralApiKey = "ddmV3IBHRhh7NKrugPP1LEtlpfRAU8dv";
const response = await fetch("https://api.mistral.ai/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${mistralApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "pixtral-12b-2409",
messages: messagesToSend,
max_tokens: 800
})
});
if (!response.ok) {
const errBody = await response.text();
console.error("Mistral API Error:", errBody);
return res.status(502).json({ message: "Error communicating with Mistral AI fashion service" });
}
const data = await response.json();
const assistantMessage = data.choices?.[0]?.message;
if (!assistantMessage) {
return res.status(502).json({ message: "Empty response from Mistral AI fashion service" });
}
return res.json({
role: "assistant",
content: assistantMessage.content
});
} catch (error) {
console.error("Fashion bot error:", error);
return res.status(500).json({ message: "An error occurred in the fashion assistant backend" });
}
});
app.post("/v1/customer/cart/items", async (req, res) => {
const parsed = addCartItemSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
try {
const session = await getSession(parsed.data.sessionId);
if (!session) return res.status(404).json({ message: "Session not found" });
const product = await findProductByBarcode(parsed.data.barcode);
if (!product) return res.status(404).json({ message: "Product not found" });
const currentQty = session.cart.get(product.id) ?? 0;
session.cart.set(product.id, currentQty + parsed.data.quantity);
await persistSessionCart(session);
return res.json(await computeCart(session));
} catch (e) {
const handled = stockErrorResponse(res, e);
if (handled) return handled;
throw e;
}
});
app.patch("/v1/customer/cart/items", async (req, res) => {
const parsed = setCartLineQtySchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
try {
const session = await getSession(parsed.data.sessionId);
if (!session) return res.status(404).json({ message: "Session not found" });
const { productId, quantity } = parsed.data;
if (quantity === 0) session.cart.delete(productId);
else {
const product = await findProductById(productId);
if (!product) return res.status(404).json({ message: "Product not found" });
session.cart.set(productId, quantity);
}
await persistSessionCart(session);
return res.json(await computeCart(session));
} catch (e) {
const handled = stockErrorResponse(res, e);
if (handled) return handled;
throw e;
}
});
app.get("/v1/customer/cart/:sessionId", async (req, res) => {
const session = await getSession(pathParam(req.params.sessionId));
if (!session) return res.status(404).json({ message: "Session not found" });
return res.json(await computeCart(session));
});
app.post("/v1/customer/checkout", async (req, res) => {
const parsed = checkoutSchema.safeParse(req.body);
if (!parsed.success) {
const message = parsed.error.issues.map((i) => i.message).join(" ") || "Invalid checkout request";
return res.status(400).json({ message, ...parsed.error.flatten() });
}
try {
const session = await getSession(parsed.data.sessionId);
if (!session) return res.status(404).json({ message: "Session not found" });
const cart = await computeCart(session);
if (cart.items.length === 0) return res.status(400).json({ message: "Cart is empty" });
const lines: OrderLineSnapshot[] = cart.items.map((row) => {
return {
productId: row.productId,
name: row.name,
qty: row.qty,
unitPrice: row.unitPrice,
taxPercent: row.taxPercent,
lineSubtotal: row.lineSubtotal,
lineTax: row.lineTax,
lineTotal: row.lineTotal
};
});
const orderId = randomUUID();
const order: Order = {
id: orderId,
sessionId: session.id,
storeCode: session.storeCode,
total: cart.grandTotal,
subtotal: cart.subtotal - (cart.loyaltyDiscount ?? 0),
taxTotal: cart.taxTotal,
lines,
paymentMode: parsed.data.paymentMode,
paid: false,
createdAt: new Date().toISOString(),
voided: false,
refunded: false,
receiptEmail: parsed.data.receiptEmail ?? null,
receiptPhone: parsed.data.receiptPhone ?? null
};
if (parsed.data.paymentMode === "COUNTER") {
order.tokenNumber = await nextCounterToken();
}
const storeId = session.storeId;
const holdUntil = new Date(Date.now() + 45 * 60 * 1000);
try {
await withTransaction(async (client) => {
await releaseExpiredReservations(client).catch(() => 0);
await syncSessionReservations(
session.id,
session.storeId,
session.cart,
new Date(session.expiresAt),
client
).catch(() => {});
await reserveOrderFromSession(
session.id,
orderId,
storeId,
lines.map((l) => ({ productId: l.productId, qty: l.qty })),
holdUntil,
client
).catch(() => {});
await createOrder(order, client);
});
} catch (txErr) {
console.warn("Inventory transaction notice, saving order via store fallback", txErr);
await createOrder(order);
}
session.cart.clear();
await saveSessionCart(session.id, session.cart).catch(() => {});
if (parsed.data.paymentMode === "ONLINE") {
return res.status(201).json({
orderId,
status: "PENDING_PAYMENT",
razorpayOrderId: process.env.RAZORPAY_KEY_ID ? `rzp_order_${orderId.replace(/-/g, "")}` : null
});
}
return res.status(201).json({
orderId,
status: "AWAITING_COUNTER_PAYMENT",
tokenNumber: order.tokenNumber
});
} catch (e) {
console.error("checkout failed", e);
const handled = stockErrorResponse(res, e);
if (handled) return handled;
const detail = e instanceof Error ? e.message : String(e);
if (detail.includes("Unknown store")) {
return res.status(400).json({ message: "This store is not set up yet. Ask staff for help." });
}
return res.status(500).json({ message: "Checkout could not be completed. Please try again or pay at the counter." });
}
});
app.get("/v1/customer/orders/:orderId", async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order || order.voided) return res.status(404).json({ message: "Order not found" });
const status = order.paid
? "PAID"
: order.paymentMode === "COUNTER"
? "AWAITING_COUNTER_PAYMENT"
: "PENDING_PAYMENT";
return res.json({
orderId: order.id,
paid: order.paid,
paymentMode: order.paymentMode,
tokenNumber: order.tokenNumber ?? null,
status,
grandTotal: order.total,
subtotal: order.subtotal,
taxTotal: order.taxTotal
});
});
async function buildReceiptPayload(orderId: string) {
const order = await getOrder(orderId);
if (!order || order.voided) {
return { status: 404 as const, body: { message: "Order not found" } };
}
if (!order.paid) {
return { status: 402 as const, body: { message: "Payment not confirmed yet", paid: false } };
}
const rec = await getReceiptByOrderId(order.id);
const emailConfigured = Boolean(resendApiKey && emailFrom);
return {
status: 200 as const,
body: {
receiptNumber: rec?.receiptNumber ?? `ORD-${order.id.slice(0, 8)}`,
orderId: order.id,
storeCode: order.storeCode,
createdAt: rec?.createdAt ?? order.createdAt,
paymentMode: order.paymentMode,
lines: order.lines.map((l) => ({
name: l.name,
qty: l.qty,
unitPrice: l.unitPrice,
taxPercent: l.taxPercent,
lineTotal: l.lineTotal
})),
subtotal: order.subtotal,
taxTotal: order.taxTotal,
grandTotal: order.total,
receiptEmail: order.receiptEmail ?? null,
emailConfigured,
tokenNumber: order.tokenNumber ?? null
}
};
}
app.get("/v1/customer/orders/:orderId/receipt", async (req, res) => {
const result = await buildReceiptPayload(pathParam(req.params.orderId));
return res.status(result.status).json(result.body);
});
app.get("/v1/admin/orders/:orderId/receipt", requireAuth("staff"), async (req, res) => {
const result = await buildReceiptPayload(pathParam(req.params.orderId));
return res.status(result.status).json(result.body);
});
app.get("/v1/customer/orders/:orderId/exit-pass", async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order || order.voided) return res.status(404).json({ message: "Order not found" });
if (!order.paid) {
return res.status(402).json({
message: "Payment not confirmed yet",
paid: false,
tokenNumber: order.tokenNumber ?? null
});
}
const token = jwt.sign({ orderId: order.id, exp: Math.floor(Date.now() / 1000) + 15 * 60 }, jwtSecret!);
const exitQr = await QRCode.toDataURL(token);
return res.json({
paid: true,
orderId: order.id,
exitQr,
grandTotal: order.total
});
});
app.get("/v1/cashier/orders/:orderId", requireCashierKey, async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order || order.voided) return res.status(404).json({ message: "Order not found" });
return res.json(orderPublicView(order));
});
app.get("/v1/cashier/orders/by-token/:token", requireCashierKey, async (req, res) => {
const raw = pathParam(req.params.token);
const token = Number(raw);
if (!Number.isFinite(token)) return res.status(400).json({ message: "Invalid token number" });
const order = await getOrderByToken(token);
if (!order) return res.status(404).json({ message: "No order for this token" });
return res.json(orderPublicView(order));
});
app.get("/v1/cashier/pending", requireCashierKey, async (_req, res) => {
const pending = await listPendingCounterOrders();
res.json(
pending.map((o) => ({
orderId: o.id,
tokenNumber: o.tokenNumber ?? null,
grandTotal: o.total,
lineCount: o.lines.length,
createdAt: o.createdAt
}))
);
});
app.get("/v1/cashier/stats/today", requireCashierKey, async (_req, res) => {
const all = await listOrders(500);
const today = new Date().toISOString().slice(0, 10);
const todayPaid = all.filter((o) => o.paid && !o.refunded && o.createdAt.startsWith(today));
const todayPending = all.filter((o) => !o.paid && !o.voided && !o.refunded && o.createdAt.startsWith(today));
const revenue = todayPaid.reduce((s, o) => s + o.total, 0);
res.json({
paidCount: todayPaid.length,
pendingCount: todayPending.length,
revenueToday: Math.round(revenue * 100) / 100
});
});
app.post("/v1/cashier/orders/:orderId/settle", requireCashierKey, async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order) return res.status(404).json({ message: "Order not found" });
if (order.paymentMode !== "COUNTER") return res.status(400).json({ message: "Not a counter-pay order" });
if (order.paid) return res.status(400).json({ message: "Already marked as paid" });
if (order.voided || order.refunded) return res.status(400).json({ message: "Order is voided or refunded" });
try {
const receiptId = await recordPaidOrder(order, null, "cashier", "staff");
return res.json({ ok: true, receiptId, orderId: order.id });
} catch (e) {
const handled = stockErrorResponse(res, e);
if (handled) return handled;
throw e;
}
});
app.post("/v1/payments/razorpay/webhook", async (req, res) => {
if (!webhookSecret) return res.status(503).json({ message: "Webhook not configured" });
const signature = req.headers["x-razorpay-signature"];
if (!signature || signature !== webhookSecret) return res.status(401).json({ message: "Invalid webhook signature" });
const orderId = String(req.body.orderId ?? "");
const order = await getOrder(orderId);
if (!order) return res.status(404).json({ message: "Order not found" });
if (order.paid || order.voided || order.refunded) return res.json({ ok: true, duplicate: true });
await recordPaidOrder(order, null, "razorpay-webhook", "system");
return res.json({ ok: true });
});
app.post("/v1/admin/tokens/:orderId/settle", requireAuth("staff"), async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order || order.paymentMode !== "COUNTER") return res.status(404).json({ message: "Counter order not found" });
if (order.paid || order.voided || order.refunded) return res.status(400).json({ message: "Order cannot be settled" });
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
const receiptId = await recordPaidOrder(order, null, actor, "staff");
return res.json({ ok: true, receiptId });
});
app.get("/v1/admin/tokens", requireAuth("staff"), async (_req, res) => {
res.json(await listPendingCounterOrders());
});
app.get("/v1/admin/recommendations", requireAuth("staff"), async (_req, res) => {
const products = await listProducts();
const highDemand = [...products].sort((a, b) => b.demandScore - a.demandScore).slice(0, 5);
res.json({ highDemand: await Promise.all(highDemand.map(toCustomerProduct)) });
});
app.post("/v1/admin/products", requireAuth("staff"), async (req, res) => {
const body = req.body as Partial<Product> & { sellingPrice?: number };
const sell = Number(body.unitPrice ?? body.sellingPrice ?? 0);
const cost = Number(body.costPrice ?? 0);
if (!body.barcode || !body.name || !body.category || !Number.isFinite(sell) || sell <= 0) {
return res.status(400).json({ message: "barcode, name, category, and unitPrice (selling) are required" });
}
const existing = await findProductByBarcode(String(body.barcode).trim());
if (existing) return res.status(409).json({ message: "A product with this barcode already exists" });
if (!Number.isFinite(cost) || cost < 0) return res.status(400).json({ message: "costPrice must be a non-negative number" });
if (cost > sell) return res.status(400).json({ message: "costPrice should not exceed selling price" });
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
const created = await createProduct(
{
barcode: String(body.barcode).trim(),
name: String(body.name),
category: String(body.category),
styleCode: String(body.styleCode ?? ""),
size: String(body.size ?? ""),
color: String(body.color ?? ""),
brand: String(body.brand ?? ""),
season: String(body.season ?? ""),
gender: String(body.gender ?? "Unisex"),
unitPrice: Math.round(sell * 100) / 100,
costPrice: Math.round(cost * 100) / 100,
taxPercent: Number(body.taxPercent ?? 5),
inStock: Math.max(0, Math.floor(Number(body.inStock ?? 0))),
reorderLevel: Math.max(0, Math.floor(Number(body.reorderLevel ?? 10))),
demandScore: Math.max(0, Number(body.demandScore ?? 0)),
imageUrl: typeof body.imageUrl === "string" ? body.imageUrl.trim() : undefined
},
actor
);
await pushAudit(actor, "staff", "product.create", `${created.barcode} ${created.name}`);
const d = await getDiscountPercent(created.id);
const eff = shelfUnitPrice(created, d);
return res.status(201).json({
...created,
discountPercent: d,
effectiveUnitPrice: eff,
profitPerUnitAtList: profitPerUnit(created, created.unitPrice),
profitPerUnitAtSale: profitPerUnit(created, eff)
});
});
app.patch("/v1/admin/products/:id", requireAuth("staff"), async (req, res) => {
const id = pathParam(req.params.id);
const body = req.body as Partial<Product> & { sellingPrice?: number };
const existingProduct = await findProductById(id);
if (!existingProduct) return res.status(404).json({ message: "Product not found" });
const sell = Number(body.unitPrice ?? body.sellingPrice ?? existingProduct.unitPrice);
const cost = Number(body.costPrice ?? existingProduct.costPrice);
if (!body.barcode || !body.name || !body.category || !Number.isFinite(sell) || sell <= 0) {
return res.status(400).json({ message: "barcode, name, category, and unitPrice (selling) are required" });
}
const barcodeConflict = await findProductByBarcode(String(body.barcode).trim());
if (barcodeConflict && barcodeConflict.id !== id) {
return res.status(409).json({ message: "Another product with this barcode already exists" });
}
if (!Number.isFinite(cost) || cost < 0) return res.status(400).json({ message: "costPrice must be a non-negative number" });
if (cost > sell) return res.status(400).json({ message: "costPrice should not exceed selling price" });
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
const updated = await updateProductDetails(id, {
barcode: String(body.barcode).trim(),
name: String(body.name),
category: String(body.category),
styleCode: String(body.styleCode ?? ""),
size: String(body.size ?? ""),
color: String(body.color ?? ""),
brand: String(body.brand ?? ""),
season: String(body.season ?? ""),
gender: String(body.gender ?? "Unisex"),
unitPrice: Math.round(sell * 100) / 100,
costPrice: Math.round(cost * 100) / 100,
taxPercent: Number(body.taxPercent ?? existingProduct.taxPercent),
reorderLevel: Math.max(0, Math.floor(Number(body.reorderLevel ?? existingProduct.reorderLevel))),
demandScore: Math.max(0, Number(body.demandScore ?? existingProduct.demandScore)),
imageUrl: typeof body.imageUrl === "string" ? body.imageUrl.trim() : existingProduct.imageUrl
});
if (!updated) return res.status(500).json({ message: "Unable to update product details" });
await pushAudit(actor, "staff", "product.update", `${updated.barcode} ${updated.name}`);
const d = await getDiscountPercent(updated.id);
const eff = shelfUnitPrice(updated, d);
return res.json({
...updated,
discountPercent: d,
effectiveUnitPrice: eff,
profitPerUnitAtList: profitPerUnit(updated, updated.unitPrice),
profitPerUnitAtSale: profitPerUnit(updated, eff)
});
});
app.patch("/v1/admin/products/:id/inventory", requireAuth("staff"), async (req, res) => {
const inStock = Math.max(0, Math.floor(Number(req.body.inStock ?? 0)));
const note = String(req.body?.note ?? "").trim() || "Manual stock adjustment";
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
try {
await adjustProductStock(pathParam(req.params.id), inStock, actor, note);
const product = await findProductById(pathParam(req.params.id));
if (!product) return res.status(404).json({ message: "Product not found" });
await pushAudit(actor, "staff", "inventory.patch", `${product.id}=${product.inStock} (${note})`);
return res.json(product);
} catch (e) {
const handled = stockErrorResponse(res, e);
if (handled) return handled;
if (e instanceof Error && e.message === "Product not found") {
return res.status(404).json({ message: "Product not found" });
}
throw e;
}
});
app.get("/v1/admin/inventory/movements", requireAuth("staff"), async (req, res) => {
const raw = Number(req.query.limit);
const limit = Number.isFinite(raw) ? Math.min(500, Math.max(1, Math.floor(raw))) : 100;
const productId = String(req.query.productId ?? "").trim() || undefined;
res.json(await listStockMovements(limit, productId));
});
app.post("/v1/admin/upload/product-image", requireAuth("staff"), async (req, res) => {
const dataUrl = typeof req.body?.dataUrl === "string" ? req.body.dataUrl : "";
const match = /^data:(image\/(?:jpeg|jpg|png|webp|gif));base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
if (!match) return res.status(400).json({ message: "Send a JPEG, PNG, or WebP image as dataUrl" });
const mime = match[1];
const ext = mime.includes("png") ? "png" : mime.includes("webp") ? "webp" : mime.includes("gif") ? "gif" : "jpg";
const buf = Buffer.from(match[2], "base64");
if (buf.length > 4 * 1024 * 1024) return res.status(400).json({ message: "Image too large (max 4 MB)" });
try {
if (isProductImageStorageConfigured()) {
const url = await uploadProductImageToStorage(buf, ext, mime);
return res.json({ url, storage: "supabase" });
}
await fs.mkdir(productUploadsDir, { recursive: true });
const fname = `${randomUUID()}.${ext}`;
await fs.writeFile(path.join(productUploadsDir, fname), buf);
const publicBase = process.env.PUBLIC_API_URL?.trim() || `http://localhost:${port}`;
return res.json({ url: `${publicBase}/uploads/products/${fname}`, storage: "local" });
} catch (e) {
const msg = e instanceof Error ? e.message : "Upload failed";
console.error("[upload/product-image]", msg);
return res.status(500).json({ message: msg });
}
});
app.patch("/v1/admin/products/:id/image", requireAuth("staff"), async (req, res) => {
const imageUrl = typeof req.body?.imageUrl === "string" ? req.body.imageUrl.trim() : "";
const product = await updateProductImage(pathParam(req.params.id), imageUrl || null);
if (!product) return res.status(404).json({ message: "Product not found" });
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
await pushAudit(actor, "staff", "product.image", product.id);
const d = await getDiscountPercent(product.id);
const eff = shelfUnitPrice(product, d);
return res.json({
...product,
discountPercent: d,
effectiveUnitPrice: eff,
profitPerUnitAtList: profitPerUnit(product, product.unitPrice),
profitPerUnitAtSale: profitPerUnit(product, eff)
});
});
function csvEscape(v: string | number | boolean): string {
const s = String(v);
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
app.get("/v1/admin/audit", requireAuth("manager"), async (_req, res) => {
res.json(await listAudit(250));
});
app.post("/v1/admin/orders/:orderId/void", requireAuth("manager"), async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order) return res.status(404).json({ message: "Order not found" });
if (order.refunded) return res.status(400).json({ message: "Refunded order cannot be voided" });
if (order.paid) return res.status(400).json({ message: "Paid order — use refund to reverse stock" });
if (order.voided) return res.status(400).json({ message: "Already voided" });
await withTransaction(async (client) => {
await releaseOrderReservations(order.id, client);
await updateOrderVoided(order.id, true, client);
});
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "manager";
await pushAudit(actor, "manager", "order.void", order.id);
return res.json({ ok: true });
});
app.post("/v1/admin/orders/:orderId/refund", requireAuth("manager"), async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order) return res.status(404).json({ message: "Order not found" });
if (!order.paid) return res.status(400).json({ message: "Order is not paid — void instead if still open" });
if (order.refunded) return res.status(400).json({ message: "Already refunded" });
if (order.voided) return res.status(400).json({ message: "Voided order" });
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "manager";
await withTransaction(async (client) => {
await restockRefund(
order.id,
order.lines.map((l) => ({ productId: l.productId, qty: l.qty })),
actor,
client
);
await updateOrderRefunded(order.id, client);
});
await pushAudit(actor, "manager", "order.refund", order.id);
return res.json({ ok: true });
});
app.get("/v1/admin/export/orders.csv", requireAuth("manager"), async (req, res) => {
const sc = String(req.query.storeCode ?? "").trim().toUpperCase();
const rows = await listOrders(10000, sc || undefined);
const header = ["orderId", "createdAt", "storeCode", "paid", "voided", "refunded", "paymentMode", "token", "total", "lines"].join(",");
const body = rows
.map((o) =>
[o.id, o.createdAt, o.storeCode, o.paid, o.voided, o.refunded, o.paymentMode, o.tokenNumber ?? "", o.total, o.lines.length]
.map(csvEscape)
.join(",")
)
.join("\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="orders-${Date.now()}.csv"`);
res.send(`${header}\n${body}\n`);
});
app.get("/v1/admin/export/products.csv", requireAuth("staff"), async (_req, res) => {
const products = await listProducts();
const header = [
"id",
"barcode",
"sku",
"name",
"category",
"styleCode",
"size",
"color",
"brand",
"season",
"gender",
"unitPrice",
"costPrice",
"taxPercent",
"inStock"
].join(",");
const body = products
.map((p) =>
[
p.id,
p.barcode,
p.sku,
p.name,
p.category,
p.styleCode,
p.size,
p.color,
p.brand,
p.season,
p.gender,
p.unitPrice,
p.costPrice,
p.taxPercent,
p.inStock
]
.map(csvEscape)
.join(",")
)
.join("\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="products-${Date.now()}.csv"`);
res.send(`${header}\n${body}\n`);
});
app.post("/v1/admin/orders/:orderId/receipt-send", requireAuth("staff"), async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order) return res.status(404).json({ message: "Order not found" });
const channel = String(req.body?.channel ?? "email");
const to = String(req.body?.to ?? "").trim();
if (!to) return res.status(400).json({ message: "to is required (email or phone)" });
const actor = (req as Request & { staff?: { sub: string } }).staff?.sub ?? "staff";
await pushAudit(actor, "staff", "receipt.manual", `${order.id};${channel};${to}`);
if (receiptNotifyWebhook) {
try {
await fetch(receiptNotifyWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "receipt_manual",
orderId: order.id,
channel,
to,
total: order.total,
lines: order.lines.map((l) => ({ name: l.name, qty: l.qty, lineTotal: l.lineTotal }))
})
});
} catch {
return res.status(502).json({ message: "Webhook request failed" });
}
}
return res.json({
ok: true,
sent: !!receiptNotifyWebhook,
detail: receiptNotifyWebhook ? "Sent to RECEIPT_NOTIFY_WEBHOOK_URL" : "Configure RECEIPT_NOTIFY_WEBHOOK_URL for SMS/email integrations"
});
});
app.post("/v1/receipt/:orderId", async (req, res) => {
const order = await getOrder(pathParam(req.params.orderId));
if (!order || !order.paid) return res.status(400).json({ message: "Order not paid yet" });
const token = jwt.sign({ orderId: order.id, exp: Math.floor(Date.now() / 1000) + 15 * 60 }, jwtSecret!);
const qrDataUrl = await QRCode.toDataURL(token);
const receiptId = `rcpt_${Date.now()}`;
await insertReceipt({
id: receiptId,
orderId: order.id,
receiptNumber: `SM-${Date.now()}`,
total: order.total,
paymentMode: order.paymentMode,
whatsapp: req.body.whatsapp ?? null
});
return res.json({
receipt: { id: receiptId, orderId: order.id, receiptNumber: `SM-${Date.now()}`, total: order.total, paymentMode: order.paymentMode },
exitQr: qrDataUrl,
exitToken: token
});
});
app.post("/v1/gate/verify", async (req, res) => {
try {
const token = String(req.body.token);
const hash = createHash("sha256").update(token).digest("hex");
if (await isExitTokenUsed(hash)) return res.status(400).json({ valid: false, reason: "Exit token already used" });
const payload = jwt.verify(token, jwtSecret!) as { orderId: string };
const order = await getOrder(payload.orderId);
if (!order?.paid || order.refunded) return res.status(400).json({ valid: false, reason: "Unpaid or refunded order" });
await markExitTokenUsed(hash);
return res.json({ valid: true, orderId: payload.orderId });
} catch {
return res.status(400).json({ valid: false, reason: "Invalid or expired exit token" });
}
});
app.get("/v1/admin/receipts", requireAuth("staff"), async (_req, res) => {
res.json(await listReceipts());
});
async function maybeNotifyLowStockWebhook() {
if (!lowStockNotifyWebhook) return;
const products = await listProducts();
const skus = products.filter((p) => p.availableQty < 5);
if (skus.length === 0) return;
try {
await fetch(lowStockNotifyWebhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "low_stock_scheduled",
at: new Date().toISOString(),
count: skus.length,
skus: skus.map((p) => ({ barcode: p.barcode, name: p.name, inStock: p.inStock, available: p.availableQty, reserved: p.reservedQty }))
})
});
await pushAudit("scheduler", "system", "low_stock.webhook", String(skus.length));
} catch {
await pushAudit("scheduler", "system", "low_stock.webhook.fail", String(skus.length));
}
}
const lowStockIntervalMs = Number(process.env.LOW_STOCK_CHECK_INTERVAL_MS ?? 6 * 60 * 60 * 1000);
setInterval(() => void maybeNotifyLowStockWebhook(), lowStockIntervalMs);
const reservationSweepMs = Number(process.env.RESERVATION_SWEEP_INTERVAL_MS ?? 5 * 60 * 1000);
setInterval(() => {
void releaseExpiredReservations().catch((e) => console.error("reservation sweep failed", e));
}, reservationSweepMs);
async function main() {
try {
await verifyDb();
console.log("Database connected & verified");
} catch (err) {
console.error("verifyDb notice (API starting with fallback catalog):", err instanceof Error ? err.message : err);
}
app.listen(port, () => {
console.log(`Checkout API running on http://localhost:${port}`);
});
}
main().catch((e) => {
console.error("Failed to start API listener:", e instanceof Error ? e.message : e);
});