Fixed payment bucket order

X-Lovable-Edit-ID: edt-285c539b-ae00-4eaa-8a6e-2fe3ed741e69
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-26 08:31:21 +00:00
co-authored by renee-png
3 changed files with 100 additions and 29 deletions
+23 -21
View File
@@ -57,7 +57,7 @@ import {
BUCKET_LABEL,
computeBucketBalances,
num,
withRunningBalance,
withAllocations,
} from "@/lib/ledger";
import {
DndContext,
@@ -533,7 +533,7 @@ export function CollectionDetail({
[entries, opening],
);
const withRunning = useMemo(
() => withRunningBalance(entries as any, opening),
() => withAllocations(entries as any, opening),
[entries, opening],
);
@@ -602,18 +602,18 @@ export function CollectionDetail({
const exportStatement = () => {
const rows = [
["Date", "Description", "Account", "Assess", "Late", "Admin", "Legal", "Viol", "Int", "Bank", "Payment", "Balance"].join(","),
["Date", "Description", "Account", "Bank", "Int", "Late", "Admin", "Legal", "Viol", "Assess", "Payment", "Balance"].join(","),
...withRunning.map((e: any) => [
e.entry_date,
`"${(e.description || "").replace(/"/g, '""')}"`,
e.account || e.transaction_type || "",
num(e.assess).toFixed(2),
num(e.bank).toFixed(2),
num(e.interest).toFixed(2),
num(e.late).toFixed(2),
num(e.admin).toFixed(2),
num(e.legal).toFixed(2),
num(e.viol).toFixed(2),
num(e.interest).toFixed(2),
num(e.bank).toFixed(2),
num(e.assess).toFixed(2),
num(e.payment).toFixed(2),
e.runningBalance.toFixed(2),
].join(",")),
@@ -676,8 +676,9 @@ export function CollectionDetail({
formatDate(collection.opened_at),
"Opening balance",
"opening",
"0.00", "0.00", "0.00", "0.00", "0.00", "0.00",
opening > 0 ? opening.toFixed(2) : "0.00",
"0.00", "0.00", "0.00", "0.00", "0.00", "0.00", "0.00",
"0.00",
formatCurrency(opening),
]);
}
@@ -686,33 +687,33 @@ export function CollectionDetail({
formatDate(e.entry_date),
e.description || "—",
e.account || e.transaction_type || "",
num(e.assess) ? num(e.assess).toFixed(2) : "",
num(e.bank) ? num(e.bank).toFixed(2) : "",
num(e.interest) ? num(e.interest).toFixed(2) : "",
num(e.late) ? num(e.late).toFixed(2) : "",
num(e.admin) ? num(e.admin).toFixed(2) : "",
num(e.legal) ? num(e.legal).toFixed(2) : "",
num(e.viol) ? num(e.viol).toFixed(2) : "",
num(e.interest) ? num(e.interest).toFixed(2) : "",
num(e.bank) ? num(e.bank).toFixed(2) : "",
num(e.assess) ? num(e.assess).toFixed(2) : "",
num(e.payment) ? num(e.payment).toFixed(2) : "",
formatCurrency(e.runningBalance ?? 0),
]);
});
body.push([
{ content: "Totals", colSpan: 3, styles: { halign: "right", fontStyle: "bold" } },
totals.assess.toFixed(2),
totals.bank.toFixed(2),
totals.interest.toFixed(2),
totals.late.toFixed(2),
totals.admin.toFixed(2),
totals.legal.toFixed(2),
totals.viol.toFixed(2),
totals.interest.toFixed(2),
totals.bank.toFixed(2),
totals.assess.toFixed(2),
totals.payment.toFixed(2),
formatCurrency(computed.total),
]);
autoTable(doc, {
startY: startY + 36,
head: [["Date", "Description", "Account", "Assess", "Late", "Admin", "Legal", "Viol", "Int", "Bank", "Payment", "Balance"]],
head: [["Date", "Description", "Account", "Bank", "Int", "Late", "Admin", "Legal", "Viol", "Assess", "Payment", "Balance"]],
body,
styles: { fontSize: 8, cellPadding: 3 },
headStyles: { fillColor: [40, 40, 40], textColor: 255 },
@@ -972,13 +973,13 @@ export function CollectionDetail({
<th className="text-left px-2 py-2 font-medium">Date</th>
<th className="text-left px-2 py-2 font-medium">Description</th>
<th className="text-left px-2 py-2 font-medium">Account</th>
<th className="text-right px-1 py-2 font-medium">Assess&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium text-destructive">Bank&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Int&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Late&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Admin&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Legal&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Viol&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Int&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium text-destructive">Bank&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Assess&nbsp;($)</th>
<th className="text-right px-1 py-2 font-medium">Pay&nbsp;(AR)</th>
<th className="text-right px-3 py-2 font-medium">Balance</th>
<th className="px-2 py-2"></th>
@@ -1000,8 +1001,9 @@ export function CollectionDetail({
<td className="px-2 py-2 text-muted-foreground whitespace-nowrap">{formatDate(collection.opened_at)}</td>
<td className="px-2 py-2 text-muted-foreground italic">Opening balance</td>
<td className="px-2 py-2 text-muted-foreground">opening</td>
<td colSpan={6} className="px-1 py-2 text-right font-mono text-muted-foreground">0.00</td>
<td className="px-1 py-2 text-right font-mono">{opening > 0 ? opening.toFixed(2) : "0.00"}</td>
<td colSpan={7} className="px-1 py-2 text-right font-mono text-muted-foreground">0.00</td>
<td className="px-1 py-2 text-right font-mono text-muted-foreground">0.00</td>
<td className="px-3 py-2 text-right font-mono font-semibold">{formatCurrency(opening)}</td>
<td></td>
</tr>
@@ -1019,13 +1021,13 @@ export function CollectionDetail({
))}
<tr className="border-t bg-muted/30 font-medium">
<td colSpan={4} className="px-2 py-2 text-right text-muted-foreground uppercase text-[11px] tracking-wider">Totals:</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.assess)}</td>
<td className="px-1 py-2 text-right font-mono text-destructive">{formatCurrency(totals.bank)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.interest)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.late)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.admin)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.legal)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.viol)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.interest)}</td>
<td className="px-1 py-2 text-right font-mono text-destructive">{formatCurrency(totals.bank)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.assess)}</td>
<td className="px-1 py-2 text-right font-mono">{formatCurrency(totals.payment)}</td>
<td className="px-3 py-2 text-right font-mono">{formatCurrency(computed.total)}</td>
<td></td>
+23 -4
View File
@@ -3,7 +3,7 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BUCKETS, num } from "@/lib/ledger";
import { BUCKETS, num, type Bucket } from "@/lib/ledger";
import { formatCurrency } from "@/lib/format";
export interface EditableEntry {
@@ -20,6 +20,7 @@ export interface EditableEntry {
bank: number;
payment: number;
runningBalance?: number;
allocations?: Record<Bucket, number>;
}
interface Props {
@@ -29,13 +30,13 @@ interface Props {
}
const NUM_COLS: { key: keyof EditableEntry; danger?: boolean; positive?: boolean }[] = [
{ key: "assess" },
{ key: "bank", danger: true },
{ key: "interest" },
{ key: "late" },
{ key: "admin" },
{ key: "legal" },
{ key: "viol" },
{ key: "interest" },
{ key: "bank", danger: true },
{ key: "assess" },
{ key: "payment", positive: true },
];
@@ -111,6 +112,24 @@ export function SortableLedgerRow({ entry, onPatch, onRemove }: Props) {
</td>
{NUM_COLS.map(({ key, danger, positive }) => {
const v = num((draft as any)[key]);
// For non-payment columns: if this row is a payment with an allocation
// to this bucket, show the allocation as a green negative deduction.
const isPaymentCol = key === "payment";
const allocAmt = !isPaymentCol && entry.allocations
? num((entry.allocations as any)[key])
: 0;
if (!isPaymentCol && allocAmt > 0 && v === 0) {
return (
<td key={key} className="px-0.5 py-1">
<div
className="w-20 text-right font-mono text-sm text-emerald-600 px-1.5 py-1"
title={`Payment applied to ${String(key)}`}
>
−{allocAmt.toFixed(2)}
</div>
</td>
);
}
return (
<td key={key} className="px-0.5 py-1">
<input
+54 -4
View File
@@ -1,13 +1,13 @@
// Priority order for payment allocation: Bank → Interest → Late → Legal → Admin → Violations → Assessments
export const BUCKETS = ["bank", "interest", "late", "legal", "admin", "viol", "assess"] as const;
// Priority order for payment allocation: Bank → Interest → Late → Admin → Legal → Violations → Assessments
export const BUCKETS = ["bank", "interest", "late", "admin", "legal", "viol", "assess"] as const;
export type Bucket = (typeof BUCKETS)[number];
export const BUCKET_LABEL: Record<Bucket, string> = {
bank: "Bank Fees",
interest: "Interest",
late: "Late Fees",
legal: "Legal Fees",
admin: "Admin Fees",
legal: "Legal Fees",
viol: "Violations",
assess: "Assessments",
};
@@ -16,8 +16,8 @@ export const BUCKET_SHORT: Record<Bucket, string> = {
bank: "Bank",
interest: "Int",
late: "Late",
legal: "Legal",
admin: "Admin",
legal: "Legal",
viol: "Viol",
assess: "Assess",
};
@@ -93,3 +93,53 @@ export function withRunningBalance(entries: LedgerEntry[], opening = 0) {
return { ...e, runningBalance: bal };
});
}
/**
* For each row, compute:
* - runningBalance (overall)
* - allocations: Record<Bucket, number> — how much of THIS row's payment was
* applied to each bucket (in priority order). Empty / zero for non-payment rows.
* - bucketBalancesAfter: per-bucket remaining balance after this row.
*/
export function withAllocations(entries: LedgerEntry[], opening = 0) {
const bal: Record<Bucket, number> = {
bank: 0, interest: 0, late: 0, admin: 0, legal: 0, viol: 0, assess: 0,
};
bal.assess += opening;
let running = opening;
return entries.map((e) => {
// First add this row's charges
BUCKETS.forEach((b) => { bal[b] += num((e as any)[b]); });
running += rowCharges(e);
// Then allocate this row's payment in priority order
const allocations: Record<Bucket, number> = {
bank: 0, interest: 0, late: 0, admin: 0, legal: 0, viol: 0, assess: 0,
};
let pay = num(e.payment);
if (pay > 0) {
for (const b of BUCKETS) {
if (pay <= 0) break;
if (bal[b] <= 0) continue;
const take = Math.min(bal[b], pay);
bal[b] -= take;
allocations[b] += take;
pay -= take;
}
// leftover overpayment → reduce assess (creates credit)
if (pay > 0) {
bal.assess -= pay;
allocations.assess += pay;
}
running -= num(e.payment);
}
return {
...e,
runningBalance: running,
allocations,
bucketBalancesAfter: { ...bal },
};
});
}