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:
@@ -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 ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium text-destructive">Bank ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Int ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Late ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Admin ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Legal ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Viol ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Int ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium text-destructive">Bank ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Assess ($)</th>
|
||||
<th className="text-right px-1 py-2 font-medium">Pay (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>
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user