Milestone 4: payment abstraction, receipts, refunds, logging, hardened Docker

- lib/payments.ts: provider-agnostic payment interface; cash (immediate) and
  card stub (swappable for Square/Stripe Terminal/Tyro)
- POST /transactions/:id/refund — manager+, server-authoritative, blocks double-refund
- GET /transactions/:id/receipt — structured receipt payload for print/email/SMS
- lib/logger.ts: minimal structured JSON logger respecting LOG_LEVEL env var
- middleware/requestLogger.ts: per-request method/path/status/ms logging
- errorHandler now uses structured logger instead of console.error
- Dockerfile: non-root user (appuser), HEALTHCHECK via /api/v1/health,
  npm cache cleared in runtime stage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 06:57:33 -05:00
parent d78ce35104
commit 2aa041d45e
9 changed files with 249 additions and 28 deletions

View File

@@ -12,6 +12,7 @@ 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 { requestLogger } from "./middleware/requestLogger.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -27,6 +28,7 @@ export function createApp() {
);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);
// API routes
app.use("/api/v1", healthRouter);

38
server/src/lib/logger.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Minimal structured logger.
* In production, swap the console calls for a real sink (Pino, Winston, etc.).
*/
type Level = "debug" | "info" | "warn" | "error";
const LEVEL_RANK: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };
function currentLevel(): Level {
const env = (process.env.LOG_LEVEL ?? "info").toLowerCase() as Level;
return LEVEL_RANK[env] !== undefined ? env : "info";
}
function log(level: Level, msg: string, data?: unknown) {
if (LEVEL_RANK[level] < LEVEL_RANK[currentLevel()]) return;
const entry = {
ts: new Date().toISOString(),
level,
msg,
...(data !== undefined ? { data } : {}),
};
const line = JSON.stringify(entry);
if (level === "error") {
console.error(line);
} else if (level === "warn") {
console.warn(line);
} else {
console.log(line);
}
}
export const logger = {
debug: (msg: string, data?: unknown) => log("debug", msg, data),
info: (msg: string, data?: unknown) => log("info", msg, data),
warn: (msg: string, data?: unknown) => log("warn", msg, data),
error: (msg: string, data?: unknown) => log("error", msg, data),
};

View File

@@ -0,0 +1,66 @@
/**
* Payment provider abstraction.
*
* All payment processing goes through this interface so the rest of the
* codebase is decoupled from any specific provider SDK.
*
* Current implementations:
* - "cash" — no external call needed; always succeeds immediately.
* - "card" — stub that simulates a card terminal response.
* Replace processCard() body with a real provider SDK
* (Square, Stripe Terminal, Tyro, etc.) when ready.
*/
export type PaymentMethod = "cash" | "card";
export type PaymentStatus = "completed" | "failed" | "pending";
export interface PaymentRequest {
method: PaymentMethod;
amount: number; // in dollars (e.g. 12.50)
currency: string; // ISO 4217, e.g. "AUD"
reference: string; // idempotency key / order ref
metadata?: Record<string, string>;
}
export interface PaymentResult {
status: PaymentStatus;
providerRef?: string; // provider transaction ID
errorCode?: string;
errorMessage?: string;
}
// ─── Provider implementations ─────────────────────────────────────────────
async function processCash(_req: PaymentRequest): Promise<PaymentResult> {
return { status: "completed", providerRef: `cash-${Date.now()}` };
}
async function processCard(req: PaymentRequest): Promise<PaymentResult> {
// STUB — replace with real terminal SDK (Square, Stripe Terminal, etc.)
// Simulates ~300ms network latency and a 5% random failure rate for testing.
await new Promise((r) => setTimeout(r, 300));
if (Math.random() < 0.05) {
return { status: "failed", errorCode: "DECLINED", errorMessage: "Card declined (stub)" };
}
return {
status: "completed",
providerRef: `card-stub-${req.reference}-${Date.now()}`,
};
}
// ─── Public API ───────────────────────────────────────────────────────────
export async function processPayment(req: PaymentRequest): Promise<PaymentResult> {
switch (req.method) {
case "cash":
return processCash(req);
case "card":
return processCard(req);
default:
return {
status: "failed",
errorCode: "UNSUPPORTED_METHOD",
errorMessage: `Payment method "${req.method}" is not supported`,
};
}
}

View File

@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
import { logger } from "../lib/logger.js";
export class AppError extends Error {
constructor(
@@ -41,7 +42,7 @@ export function errorHandler(
return;
}
console.error("Unhandled error:", err);
logger.error("unhandled_error", err instanceof Error ? { message: err.message, stack: err.stack } : err);
res.status(500).json({
error: {
code: "INTERNAL_ERROR",

View File

@@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "../lib/logger.js";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info";
logger[level]("http", {
method: req.method,
path: req.path,
status: res.statusCode,
ms,
ip: req.ip,
});
});
next();
}

View File

@@ -5,6 +5,7 @@ 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";
import { logger } from "../lib/logger.js";
const router = Router();
const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void;
@@ -258,4 +259,74 @@ router.get("/reports/summary", auth, managerUp, async (req: Request, res: Respon
}
});
// ─── POST /api/v1/transactions/:id/refund ─────────────────────────────────
// Server-authoritative: only managers/owners can issue refunds.
router.post("/:id/refund", 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 },
});
if (!tx) throw new AppError(404, "NOT_FOUND", "Transaction not found");
if (tx.status !== "completed") {
throw new AppError(400, "BAD_REQUEST", `Cannot refund a transaction with status "${tx.status}"`);
}
const updated = await prisma.transaction.update({
where: { id: tx.id },
data: { status: "refunded" },
include: { user: { select: { id: true, name: true } }, items: true },
});
logger.info("transaction.refunded", { id: tx.id, vendorId: tx.vendorId, total: tx.total });
res.json(updated);
} catch (err) {
next(err);
}
});
// ─── GET /api/v1/transactions/:id/receipt ─────────────────────────────────
// Returns a structured receipt payload. Clients can render it, print it,
// or forward it to an email/SMS hook. No delivery logic here.
router.get("/:id/receipt", auth, 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: {
vendor: true,
user: { select: { id: true, name: true } },
items: true,
},
});
if (!tx) throw new AppError(404, "NOT_FOUND", "Transaction not found");
res.json({
receiptNumber: tx.id.slice(-8).toUpperCase(),
vendor: { name: tx.vendor.name, businessNum: tx.vendor.businessNum },
cashier: tx.user.name,
paymentMethod: tx.paymentMethod,
status: tx.status,
issuedAt: new Date().toISOString(),
transactionDate: tx.createdAt,
lineItems: tx.items.map((item) => ({
name: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
discount: item.discount,
total: item.total,
})),
subtotal: tx.subtotal,
taxTotal: tx.taxTotal,
discountTotal: tx.discountTotal,
total: tx.total,
});
} catch (err) {
next(err);
}
});
export default router;