From 2c467b75e4b8add052741d11cc6b3f331b3465ca Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 20 Aug 2025 19:56:48 +0530 Subject: [PATCH] absoulute full due added --- apps/Backend/src/routes/payments.ts | 218 ++++++++---------- apps/Backend/src/services/paymentService.ts | 144 ++++++++++++ .../payments/payment-edit-modal.tsx | 2 - .../payments/payments-recent-table.tsx | 85 +++++++ .../src/components/ui/confirmationDialog.tsx | 44 ++++ packages/db/types/payment-types.ts | 1 - 6 files changed, 370 insertions(+), 124 deletions(-) create mode 100644 apps/Backend/src/services/paymentService.ts create mode 100644 apps/Frontend/src/components/ui/confirmationDialog.tsx diff --git a/apps/Backend/src/routes/payments.ts b/apps/Backend/src/routes/payments.ts index 3ff57ab..3866cc1 100644 --- a/apps/Backend/src/routes/payments.ts +++ b/apps/Backend/src/routes/payments.ts @@ -7,11 +7,11 @@ import { insertPaymentSchema, NewTransactionPayload, newTransactionPayloadSchema, - updatePaymentSchema, + paymentMethodOptions, } from "@repo/db/types"; -import Decimal from "decimal.js"; 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(), @@ -201,9 +201,6 @@ router.put("/:id", async (req: Request, res: Response): Promise => { 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 validated = newTransactionPayloadSchema.safeParse( req.body.data as NewTransactionPayload @@ -215,124 +212,15 @@ router.put("/:id", async (req: Request, res: Response): Promise => { }); } - const { status, serviceLineTransactions } = validated.data; + const { serviceLineTransactions } = validated.data; - // validation if req is valid - for (const txn of serviceLineTransactions) { - const line = paymentRecord.claim.serviceLines.find( - (sl) => sl.id === txn.serviceLineId - ); - if (!line) - return res - .status(400) - .json({ message: `Invalid service line: ${txn.serviceLineId}` }); + const updatedPayment = await paymentService.updatePayment( + paymentId, + serviceLineTransactions, + userId + ); - const paidAmount = new Decimal(txn.paidAmount ?? 0); - const adjustedAmount = new Decimal(txn.adjustedAmount ?? 0); - if (paidAmount.lt(0) || adjustedAmount.lt(0)) { - return res.status(400).json({ message: "Amounts cannot be negative" }); - } - if (paidAmount.eq(0) && adjustedAmount.eq(0)) { - return res - .status(400) - .json({ message: "Must provide a payment or adjustment" }); - } - if (paidAmount.gt(line.totalDue)) { - return res.status(400).json({ - message: `Paid amount exceeds due for service line ${txn.serviceLineId}`, - }); - } - } - - // Wrap everything in a transaction - const result = await prisma.$transaction(async (tx) => { - // 1. Create all new service line transactions - for (const txn of serviceLineTransactions) { - await tx.serviceLineTransaction.create({ - data: { - paymentId, - serviceLineId: txn.serviceLineId, - transactionId: txn.transactionId, - paidAmount: new Decimal(txn.paidAmount || 0), - adjustedAmount: new Decimal(txn.adjustedAmount || 0), - method: txn.method, - receivedDate: txn.receivedDate, - payerName: txn.payerName, - notes: txn.notes, - }, - }); - - // 2. Recalculate that specific service line's totals - const aggLine = await tx.serviceLineTransaction.aggregate({ - _sum: { - paidAmount: true, - adjustedAmount: true, - }, - where: { serviceLineId: txn.serviceLineId }, - }); - - const serviceLine = await tx.serviceLine.findUniqueOrThrow({ - where: { id: txn.serviceLineId }, - select: { totalBilled: true }, - }); - - const totalPaid = aggLine._sum.paidAmount || new Decimal(0); - const totalAdjusted = aggLine._sum.adjustedAmount || new Decimal(0); - const totalDue = serviceLine.totalBilled - .minus(totalPaid) - .minus(totalAdjusted); - - await tx.serviceLine.update({ - where: { id: txn.serviceLineId }, - data: { - totalPaid, - totalAdjusted, - totalDue, - status: - totalDue.lte(0) && totalPaid.gt(0) - ? "PAID" - : totalPaid.gt(0) - ? "PARTIALLY_PAID" - : "UNPAID", - }, - }); - } - - // 3. Recalculate payment totals - const aggPayment = await tx.serviceLineTransaction.aggregate({ - _sum: { - paidAmount: true, - adjustedAmount: true, - }, - where: { paymentId }, - }); - - const payment = await tx.payment.findUniqueOrThrow({ - where: { id: paymentId }, - select: { totalBilled: true }, - }); - - const totalPaid = aggPayment._sum.paidAmount || new Decimal(0); - const totalAdjusted = aggPayment._sum.adjustedAmount || new Decimal(0); - const totalDue = payment.totalBilled - .minus(totalPaid) - .minus(totalAdjusted); - - const updatedPayment = await tx.payment.update({ - where: { id: paymentId }, - data: { - totalPaid, - totalAdjusted, - totalDue, - status, - updatedById: userId, - }, - }); - - return updatedPayment; - }); - - res.status(200).json(result); + res.status(200).json(updatedPayment); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Failed to update payment"; @@ -340,6 +228,94 @@ router.put("/:id", async (req: Request, res: Response): Promise => { } }); +// PUT /api/payments/:id/pay-absolute-full-claim +router.put( + "/:id/pay-absolute-full-claim", + async (req: Request, res: Response): Promise => { + 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 serviceLineTransactions = paymentRecord.claim.serviceLines + .filter((line) => line.totalDue.gt(0)) + .map((line) => ({ + serviceLineId: line.id, + paidAmount: line.totalDue.toNumber(), + adjustedAmount: 0, + method: paymentMethodOptions[1], + 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 => { + 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" }); + } + // Build reversal transactions (negating what’s already paid/adjusted) + const serviceLineTransactions = paymentRecord.claim.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[4], + 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", diff --git a/apps/Backend/src/services/paymentService.ts b/apps/Backend/src/services/paymentService.ts new file mode 100644 index 0000000..19c5163 --- /dev/null +++ b/apps/Backend/src/services/paymentService.ts @@ -0,0 +1,144 @@ +import Decimal from "decimal.js"; +import { NewTransactionPayload, Payment, PaymentStatus } from "@repo/db/types"; +import { storage } from "../storage"; +import { prisma } from "@repo/db/client"; + +/** + * Validate transactions against a payment record + */ +export async function validateTransactions( + paymentId: number, + serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"], + options?: { isReversal?: boolean } +) { + const paymentRecord = await storage.getPaymentById(paymentId); + if (!paymentRecord) { + throw new Error("Payment not found"); + } + + for (const txn of serviceLineTransactions) { + const line = paymentRecord.claim.serviceLines.find( + (sl) => sl.id === txn.serviceLineId + ); + + if (!line) { + throw new Error(`Invalid service line: ${txn.serviceLineId}`); + } + + const paidAmount = new Decimal(txn.paidAmount ?? 0); + const adjustedAmount = new Decimal(txn.adjustedAmount ?? 0); + + if (!options?.isReversal && (paidAmount.lt(0) || adjustedAmount.lt(0))) { + throw new Error("Amounts cannot be negative"); + } + + if (paidAmount.eq(0) && adjustedAmount.eq(0)) { + throw new Error("Must provide a payment or adjustment"); + } + if (!options?.isReversal && paidAmount.gt(line.totalDue)) { + throw new Error( + `Paid amount exceeds due for service line ${txn.serviceLineId}` + ); + } + } + + return paymentRecord; +} + +/** + * Apply transactions to a payment & recalc totals + */ +export async function applyTransactions( + paymentId: number, + serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"], + userId: number +): Promise { + return prisma.$transaction(async (tx) => { + // 1. Insert service line transactions + recalculate each service line + for (const txn of serviceLineTransactions) { + await tx.serviceLineTransaction.create({ + data: { + paymentId, + serviceLineId: txn.serviceLineId, + transactionId: txn.transactionId, + paidAmount: new Decimal(txn.paidAmount), + adjustedAmount: new Decimal(txn.adjustedAmount || 0), + method: txn.method, + receivedDate: txn.receivedDate, + payerName: txn.payerName, + notes: txn.notes, + }, + }); + + // Recalculate service line totals + const aggLine = await tx.serviceLineTransaction.aggregate({ + _sum: { paidAmount: true, adjustedAmount: true }, + where: { serviceLineId: txn.serviceLineId }, + }); + + const serviceLine = await tx.serviceLine.findUniqueOrThrow({ + where: { id: txn.serviceLineId }, + select: { totalBilled: true }, + }); + + const totalPaid = aggLine._sum.paidAmount || new Decimal(0); + const totalAdjusted = aggLine._sum.adjustedAmount || new Decimal(0); + const totalDue = serviceLine.totalBilled + .minus(totalPaid) + .minus(totalAdjusted); + + await tx.serviceLine.update({ + where: { id: txn.serviceLineId }, + data: { + totalPaid, + totalAdjusted, + totalDue, + status: + totalDue.lte(0) && totalPaid.gt(0) + ? "PAID" + : totalPaid.gt(0) + ? "PARTIALLY_PAID" + : "UNPAID", + }, + }); + } + + // 2. Recalc payment totals + const aggPayment = await tx.serviceLineTransaction.aggregate({ + _sum: { paidAmount: true, adjustedAmount: true }, + where: { paymentId }, + }); + + const payment = await tx.payment.findUniqueOrThrow({ + where: { id: paymentId }, + select: { totalBilled: true }, + }); + + const totalPaid = aggPayment._sum.paidAmount || new Decimal(0); + const totalAdjusted = aggPayment._sum.adjustedAmount || new Decimal(0); + const totalDue = payment.totalBilled.minus(totalPaid).minus(totalAdjusted); + + let status: PaymentStatus; + if (totalDue.lte(0) && totalPaid.gt(0)) status = "PAID"; + else if (totalPaid.gt(0)) status = "PARTIALLY_PAID"; + else status = "PENDING"; + + return tx.payment.update({ + where: { id: paymentId }, + data: { totalPaid, totalAdjusted, totalDue, status, updatedById: userId }, + }); + }); +} + +/** + * Main entry point for updating payments + */ +export async function updatePayment( + paymentId: number, + serviceLineTransactions: NewTransactionPayload["serviceLineTransactions"], + userId: number, + options?: { isReversal?: boolean } +): Promise { + await validateTransactions(paymentId, serviceLineTransactions, options); + return applyTransactions(paymentId, serviceLineTransactions, userId); +} diff --git a/apps/Frontend/src/components/payments/payment-edit-modal.tsx b/apps/Frontend/src/components/payments/payment-edit-modal.tsx index b38996f..3fb102e 100644 --- a/apps/Frontend/src/components/payments/payment-edit-modal.tsx +++ b/apps/Frontend/src/components/payments/payment-edit-modal.tsx @@ -161,7 +161,6 @@ export default function PaymentEditModal({ const payload: NewTransactionPayload = { paymentId: payment.id, - status: paymentStatus, serviceLineTransactions: [ { serviceLineId: formState.serviceLineId, @@ -213,7 +212,6 @@ export default function PaymentEditModal({ const payload: NewTransactionPayload = { paymentId: payment.id, - status: paymentStatus, serviceLineTransactions: [ { serviceLineId: line.id, diff --git a/apps/Frontend/src/components/payments/payments-recent-table.tsx b/apps/Frontend/src/components/payments/payments-recent-table.tsx index 8e6eb84..16a9c27 100644 --- a/apps/Frontend/src/components/payments/payments-recent-table.tsx +++ b/apps/Frontend/src/components/payments/payments-recent-table.tsx @@ -42,6 +42,7 @@ import { } from "@repo/db/types"; import EditPaymentModal from "./payment-edit-modal"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { ConfirmationDialog } from "../ui/confirmationDialog"; interface PaymentApiResponse { payments: PaymentWithExtras[]; @@ -81,6 +82,9 @@ export default function PaymentsRecentTable({ null ); + const [isRevertOpen, setIsRevertOpen] = useState(false); + const [revertPaymentId, setRevertPaymentId] = useState(null); + const handleSelectPayment = (payment: PaymentWithExtras) => { const isSelected = selectedPaymentId === payment.id; const newSelectedId = isSelected ? null : payment.id; @@ -210,6 +214,57 @@ export default function PaymentsRecentTable({ }, }); + const fullPaymentMutation = useMutation({ + mutationFn: async ({ + paymentId, + type, + }: { + paymentId: number; + type: "pay" | "revert"; + }) => { + const endpoint = + type === "pay" + ? `/api/payments/${paymentId}/pay-absolute-full-claim` + : `/api/payments/${paymentId}/revert-full-claim`; + const response = await apiRequest("PUT", endpoint); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to update Payment"); + } + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Success", + description: "Payment updated successfully!", + }); + queryClient.invalidateQueries({ queryKey: getPaymentsQueryKey() }); + }, + onError: (error: any) => { + toast({ + title: "Error", + description: `Operation failed: ${error.message}`, + variant: "destructive", + }); + }, + }); + + const handlePayAbsoluteFullDue = (paymentId: number) => { + fullPaymentMutation.mutate({ paymentId, type: "pay" }); + }; + + const handleRevert = () => { + if (!revertPaymentId) return; + + fullPaymentMutation.mutate({ + paymentId: revertPaymentId, + type: "revert", + }); + + setRevertPaymentId(null); + setIsRevertOpen(false); + }; + const deletePaymentMutation = useMutation({ mutationFn: async (id: number) => { const res = await apiRequest("DELETE", `/api/payments/${id}`); @@ -543,6 +598,25 @@ export default function PaymentsRecentTable({ )} + {/* Pay Full Due */} + + {/* Revert Full Due */} + @@ -553,6 +627,17 @@ export default function PaymentsRecentTable({ + {/* Revert Confirmation Dialog */} + setIsRevertOpen(false)} + /> + void; + onCancel: () => void; +}) => { + if (!isOpen) return null; + + return ( +
+
+

{title}

+

{message}

+
+ + +
+
+
+ ); +}; diff --git a/packages/db/types/payment-types.ts b/packages/db/types/payment-types.ts index f038e4f..c6739c8 100644 --- a/packages/db/types/payment-types.ts +++ b/packages/db/types/payment-types.ts @@ -82,7 +82,6 @@ export type PaymentWithExtras = Prisma.PaymentGetPayload<{ export const newTransactionPayloadSchema = z.object({ paymentId: z.number(), - status: PaymentStatusSchema, serviceLineTransactions: z.array( z.object({ serviceLineId: z.number(),