absoulute full due added

This commit is contained in:
2025-08-20 19:56:48 +05:30
parent 061fd45efd
commit 2c467b75e4
6 changed files with 370 additions and 124 deletions

View File

@@ -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<any> => {
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<any> => {
});
}
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<any> => {
}
});
// 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" });
}
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<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" });
}
// Build reversal transactions (negating whats 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",

View File

@@ -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<Payment> {
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<Payment> {
await validateTransactions(paymentId, serviceLineTransactions, options);
return applyTransactions(paymentId, serviceLineTransactions, userId);
}

View File

@@ -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,

View File

@@ -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<number | null>(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({
<Edit className="h-4 w-4" />
</Button>
)}
{/* Pay Full Due */}
<Button
variant="outline"
size="sm"
onClick={() => handlePayAbsoluteFullDue(payment.id)}
>
Pay Full Due
</Button>
{/* Revert Full Due */}
<Button
variant="destructive"
size="sm"
onClick={() => {
setRevertPaymentId(payment.id);
setIsRevertOpen(true);
}}
>
Revert Full Due
</Button>
</div>
</TableCell>
</TableRow>
@@ -553,6 +627,17 @@ export default function PaymentsRecentTable({
</Table>
</div>
{/* Revert Confirmation Dialog */}
<ConfirmationDialog
isOpen={isRevertOpen}
title="Confirm Revert"
message={`Do you want to revert all Service Line payments for Payment ID: ${revertPaymentId}?`}
confirmLabel="Revert"
confirmColor="bg-yellow-600 hover:bg-yellow-700"
onConfirm={handleRevert}
onCancel={() => setIsRevertOpen(false)}
/>
<DeleteConfirmationDialog
isOpen={isDeletePaymentOpen}
onConfirm={handleConfirmDeletePayment}

View File

@@ -0,0 +1,44 @@
export const ConfirmationDialog = ({
isOpen,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
confirmColor = "bg-blue-600 hover:bg-blue-700",
onConfirm,
onCancel,
}: {
isOpen: boolean;
title: string;
message: string | React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
confirmColor?: string;
onConfirm: () => void;
onCancel: () => void;
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white p-6 rounded-md shadow-md w-[90%] max-w-md">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<p>{message}</p>
<div className="mt-6 flex justify-end space-x-4">
<button
className="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={`${confirmColor} text-white px-4 py-2 rounded`}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};