initial commit
This commit is contained in:
459
apps/Backend/src/routes/payments.ts
Executable file
459
apps/Backend/src/routes/payments.ts
Executable file
@@ -0,0 +1,459 @@
|
||||
import { Router } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
insertPaymentSchema,
|
||||
NewTransactionPayload,
|
||||
newTransactionPayloadSchema,
|
||||
paymentMethodOptions,
|
||||
PaymentStatus,
|
||||
paymentStatusOptions,
|
||||
claimStatusOptions,
|
||||
} from "@repo/db/types";
|
||||
import { prisma } from "@repo/db/client";
|
||||
import { PaymentStatusSchema } from "@repo/db/types";
|
||||
import * as paymentService from "../services/paymentService";
|
||||
|
||||
const paymentFilterSchema = z.object({
|
||||
from: z.string().datetime(),
|
||||
to: z.string().datetime(),
|
||||
});
|
||||
|
||||
function parseIntOrError(input: string | undefined, name: string) {
|
||||
if (!input) throw new Error(`${name} is required`);
|
||||
const value = parseInt(input, 10);
|
||||
if (isNaN(value)) throw new Error(`${name} must be a valid number`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function handleRouteError(
|
||||
res: Response,
|
||||
error: unknown,
|
||||
defaultMsg: string
|
||||
) {
|
||||
if (error instanceof ZodError) {
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: error.format(),
|
||||
});
|
||||
}
|
||||
|
||||
const msg = error instanceof Error ? error.message : defaultMsg;
|
||||
return res.status(500).json({ message: msg });
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/payments/recent
|
||||
router.get("/recent", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const [payments, totalCount] = await Promise.all([
|
||||
storage.getRecentPayments(limit, offset),
|
||||
storage.getTotalPaymentCount(),
|
||||
]);
|
||||
|
||||
res.status(200).json({ payments, totalCount });
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch payments:", err);
|
||||
res.status(500).json({ message: "Failed to fetch recent payments" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/payments/claim/:claimId
|
||||
router.get(
|
||||
"/claim/:claimId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const parsedClaimId = parseIntOrError(req.params.claimId, "Claim ID");
|
||||
|
||||
const payments = await storage.getPaymentsByClaimId(parsedClaimId);
|
||||
if (!payments)
|
||||
return res.status(404).json({ message: "No payments found for claim" });
|
||||
|
||||
res.status(200).json(payments);
|
||||
} catch (error) {
|
||||
console.error("Error fetching payments:", error);
|
||||
res.status(500).json({ message: "Failed to retrieve payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/payments/patient/:patientId
|
||||
router.get(
|
||||
"/patient/:patientId",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const patientIdParam = req.params.patientId;
|
||||
if (!patientIdParam) {
|
||||
return res.status(400).json({ message: "Missing patientId" });
|
||||
}
|
||||
const patientId = parseInt(patientIdParam);
|
||||
if (isNaN(patientId)) {
|
||||
return res.status(400).json({ message: "Invalid patientId" });
|
||||
}
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
if (isNaN(patientId)) {
|
||||
return res.status(400).json({ message: "Invalid patient ID" });
|
||||
}
|
||||
|
||||
const [payments, totalCount] = await Promise.all([
|
||||
storage.getRecentPaymentsByPatientId(patientId, limit, offset),
|
||||
storage.getTotalPaymentCountByPatient(patientId),
|
||||
]);
|
||||
|
||||
res.json({ payments, totalCount });
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve payments for patient:", error);
|
||||
res.status(500).json({ message: "Failed to retrieve patient payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/payments/filter
|
||||
router.get("/filter", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const validated = paymentFilterSchema.safeParse(req.query);
|
||||
if (!validated.success) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid date format",
|
||||
errors: validated.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { from, to } = validated.data;
|
||||
const payments = await storage.getPaymentsByDateRange(
|
||||
new Date(from),
|
||||
new Date(to)
|
||||
);
|
||||
res.status(200).json(payments);
|
||||
} catch (err) {
|
||||
console.error("Failed to filter payments:", err);
|
||||
res.status(500).json({ message: "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/payments/:id
|
||||
router.get("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const id = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
const payment = await storage.getPaymentById(id);
|
||||
if (!payment) return res.status(404).json({ message: "Payment not found" });
|
||||
|
||||
res.status(200).json(payment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to retrieve payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/payments/full-ocr-import
|
||||
router.post(
|
||||
"/full-ocr-import",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { rows } = req.body;
|
||||
if (!rows || !Array.isArray(rows)) {
|
||||
return res.status(400).json({ message: "Invalid OCR payload" });
|
||||
}
|
||||
|
||||
const paymentIds = await paymentService.fullOcrPaymentService.importRows(
|
||||
rows,
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: "OCR rows imported successfully",
|
||||
paymentIds,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Unknown error importing OCR payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/payments/:claimId
|
||||
router.post("/:claimId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const claimId = parseIntOrError(req.params.claimId, "Claim ID");
|
||||
|
||||
const validated = insertPaymentSchema.safeParse({
|
||||
...req.body,
|
||||
claimId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!validated.success) {
|
||||
return res.status(400).json({
|
||||
message: "Validation failed",
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const payment = await storage.createPayment(validated.data);
|
||||
res.status(201).json(payment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to create payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/payments/:id
|
||||
router.put("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
const validated = newTransactionPayloadSchema.safeParse(
|
||||
req.body.data as NewTransactionPayload
|
||||
);
|
||||
if (!validated.success) {
|
||||
return res.status(400).json({
|
||||
message: "Validation failed",
|
||||
errors: validated.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const { serviceLineTransactions } = validated.data;
|
||||
|
||||
const updatedPayment = await paymentService.updatePayment(
|
||||
paymentId,
|
||||
serviceLineTransactions,
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json(updatedPayment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/payments/:id/pay-absolute-full-claim
|
||||
router.put(
|
||||
"/:id/pay-absolute-full-claim",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
const paymentRecord = await storage.getPaymentById(paymentId);
|
||||
if (!paymentRecord) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
// Collect service lines from either claim or direct payment(OCR based data)
|
||||
const serviceLines = paymentRecord.claim
|
||||
? paymentRecord.claim.serviceLines
|
||||
: paymentRecord.serviceLines;
|
||||
|
||||
if (!serviceLines || serviceLines.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "No service lines available for this payment" });
|
||||
}
|
||||
|
||||
const serviceLineTransactions = serviceLines
|
||||
.filter((line) => line.totalDue.gt(0))
|
||||
.map((line) => ({
|
||||
serviceLineId: line.id,
|
||||
paidAmount: line.totalDue.toNumber(),
|
||||
adjustedAmount: 0,
|
||||
method: paymentMethodOptions.CHECK,
|
||||
receivedDate: new Date(),
|
||||
notes: "Full claim payment",
|
||||
}));
|
||||
|
||||
if (serviceLineTransactions.length === 0) {
|
||||
return res.status(400).json({ message: "No outstanding balance" });
|
||||
}
|
||||
|
||||
// Use updatePayment for consistency & validation
|
||||
const updatedPayment = await paymentService.updatePayment(
|
||||
paymentId,
|
||||
serviceLineTransactions,
|
||||
userId
|
||||
);
|
||||
|
||||
res.status(200).json(updatedPayment);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ message: "Failed to pay full claim" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/payments/:id/revert-full-claim
|
||||
router.put(
|
||||
"/:id/revert-full-claim",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
const paymentRecord = await storage.getPaymentById(paymentId);
|
||||
if (!paymentRecord) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
const serviceLines = paymentRecord.claim
|
||||
? paymentRecord.claim.serviceLines
|
||||
: paymentRecord.serviceLines;
|
||||
|
||||
if (!serviceLines || serviceLines.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "No service lines available for this payment" });
|
||||
}
|
||||
|
||||
// Build reversal transactions (negating what’s already paid/adjusted)
|
||||
const serviceLineTransactions = serviceLines
|
||||
.filter((line) => line.totalPaid.gt(0) || line.totalAdjusted.gt(0))
|
||||
.map((line) => ({
|
||||
serviceLineId: line.id,
|
||||
paidAmount: line.totalPaid.negated().toNumber(), // negative to undo
|
||||
adjustedAmount: line.totalAdjusted.negated().toNumber(),
|
||||
method: paymentMethodOptions.OTHER,
|
||||
receivedDate: new Date(),
|
||||
notes: "Reverted full claim",
|
||||
}));
|
||||
|
||||
if (serviceLineTransactions.length === 0) {
|
||||
return res.status(400).json({ message: "Nothing to revert" });
|
||||
}
|
||||
|
||||
const updatedPayment = await paymentService.updatePayment(
|
||||
paymentId,
|
||||
serviceLineTransactions,
|
||||
userId,
|
||||
{ isReversal: true }
|
||||
);
|
||||
|
||||
res.status(200).json(updatedPayment);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ message: "Failed to revert claim payments" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/payments/:id/status
|
||||
router.patch(
|
||||
"/:id/status",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: "Unauthorized" });
|
||||
}
|
||||
|
||||
const paymentId = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
// Parse & coerce to PaymentStatus enum
|
||||
const rawStatus = PaymentStatusSchema.parse(req.body.data.status);
|
||||
if (
|
||||
!Object.values(paymentStatusOptions).includes(
|
||||
rawStatus as PaymentStatus
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ message: "Invalid payment status" });
|
||||
}
|
||||
const status = rawStatus as PaymentStatus;
|
||||
|
||||
// Load existing payment
|
||||
const existingPayment = await storage.getPayment(paymentId);
|
||||
if (!existingPayment) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
// If changing to VOID and linked to a claim -> update both atomically
|
||||
if (status === paymentStatusOptions.VOID && existingPayment.claimId) {
|
||||
const [updatedPayment, updatedClaim] = await prisma.$transaction([
|
||||
prisma.payment.update({
|
||||
where: { id: paymentId },
|
||||
data: { status, updatedById: userId },
|
||||
}),
|
||||
prisma.claim.update({
|
||||
where: { id: existingPayment.claimId },
|
||||
data: { status: claimStatusOptions.VOID },
|
||||
}),
|
||||
]);
|
||||
|
||||
return res.json(updatedPayment);
|
||||
}
|
||||
|
||||
// Otherwise just update payment (use storage helper)
|
||||
const updatedPayment = await storage.updatePaymentStatus(
|
||||
paymentId,
|
||||
{ status } as any,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json(updatedPayment);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to update payment status";
|
||||
return res.status(500).json({ message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/payments/:id
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const id = parseIntOrError(req.params.id, "Payment ID");
|
||||
|
||||
// Check if payment exists and belongs to this user
|
||||
const existingPayment = await storage.getPayment(id);
|
||||
if (!existingPayment) {
|
||||
return res.status(404).json({ message: "Payment not found" });
|
||||
}
|
||||
|
||||
if (existingPayment.userId !== req.user!.id) {
|
||||
return res.status(403).json({
|
||||
message:
|
||||
"Forbidden: Payment belongs to a different user, you can't delete this.",
|
||||
});
|
||||
}
|
||||
await storage.deletePayment(id, userId);
|
||||
|
||||
res.status(200).json({ message: "Payment deleted successfully" });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to delete payment";
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user