Rewrote collections ledger UI

X-Lovable-Edit-ID: edt-e110d3f5-d709-4f4c-bf2d-a1694377097a
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 01:52:02 +00:00
co-authored by renee-png
4 changed files with 752 additions and 459 deletions
File diff suppressed because it is too large Load Diff
+75
View File
@@ -205,6 +205,10 @@ export type Database = {
}
collection_ledger_entries: {
Row: {
account: string | null
admin: number
assess: number
bank: number
collection_id: string
created_at: string
created_by: string | null
@@ -213,10 +217,19 @@ export type Database = {
description: string | null
entry_date: string
id: string
interest: number
late: number
legal: number
payment: number
transaction_type: string
updated_at: string
viol: number
}
Insert: {
account?: string | null
admin?: number
assess?: number
bank?: number
collection_id: string
created_at?: string
created_by?: string | null
@@ -225,10 +238,19 @@ export type Database = {
description?: string | null
entry_date?: string
id?: string
interest?: number
late?: number
legal?: number
payment?: number
transaction_type: string
updated_at?: string
viol?: number
}
Update: {
account?: string | null
admin?: number
assess?: number
bank?: number
collection_id?: string
created_at?: string
created_by?: string | null
@@ -237,8 +259,13 @@ export type Database = {
description?: string | null
entry_date?: string
id?: string
interest?: number
late?: number
legal?: number
payment?: number
transaction_type?: string
updated_at?: string
viol?: number
}
Relationships: [
{
@@ -250,6 +277,48 @@ export type Database = {
},
]
}
collection_payment_allocations: {
Row: {
amount: number
bucket: string
collection_id: string
created_at: string
entry_id: string
id: string
}
Insert: {
amount?: number
bucket: string
collection_id: string
created_at?: string
entry_id: string
id?: string
}
Update: {
amount?: number
bucket?: string
collection_id?: string
created_at?: string
entry_id?: string
id?: string
}
Relationships: [
{
foreignKeyName: "collection_payment_allocations_collection_id_fkey"
columns: ["collection_id"]
isOneToOne: false
referencedRelation: "collections"
referencedColumns: ["id"]
},
{
foreignKeyName: "collection_payment_allocations_entry_id_fkey"
columns: ["entry_id"]
isOneToOne: false
referencedRelation: "collection_ledger_entries"
referencedColumns: ["id"]
},
]
}
collection_tasks: {
Row: {
assignee_id: string | null
@@ -391,6 +460,8 @@ export type Database = {
current_stage: string | null
homeowner_id: string
id: string
interest_rate_override: number | null
name: string | null
notes: string | null
opened_at: string
status: string
@@ -404,6 +475,8 @@ export type Database = {
current_stage?: string | null
homeowner_id: string
id?: string
interest_rate_override?: number | null
name?: string | null
notes?: string | null
opened_at?: string
status?: string
@@ -417,6 +490,8 @@ export type Database = {
current_stage?: string | null
homeowner_id?: string
id?: string
interest_rate_override?: number | null
name?: string | null
notes?: string | null
opened_at?: string
status?: string
+95
View File
@@ -0,0 +1,95 @@
// Priority order for payment allocation: Bank → Interest → Late → Legal → Admin → Violations → Assessments
export const BUCKETS = ["bank", "interest", "late", "legal", "admin", "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",
viol: "Violations",
assess: "Assessments",
};
export const BUCKET_SHORT: Record<Bucket, string> = {
bank: "Bank",
interest: "Int",
late: "Late",
legal: "Legal",
admin: "Admin",
viol: "Viol",
assess: "Assess",
};
export interface LedgerEntry {
id: string;
collection_id: string;
entry_date: string;
description: string | null;
account: string | null;
assess: number;
late: number;
admin: number;
legal: number;
viol: number;
interest: number;
bank: number;
payment: number;
debit?: number;
credit?: number;
transaction_type?: string;
}
export const num = (v: any) => Number(v) || 0;
/** Sum of all charge columns for one row */
export const rowCharges = (e: Pick<LedgerEntry, Bucket>) =>
BUCKETS.reduce((s, b) => s + num((e as any)[b]), 0);
/** Compute live remaining balance per bucket given chronological entries (FIFO payment allocation by priority). */
export function computeBucketBalances(
entries: LedgerEntry[],
opening = 0,
): Record<Bucket, number> & { total: number } {
const bal: Record<Bucket, number> = {
bank: 0,
interest: 0,
late: 0,
legal: 0,
admin: 0,
viol: 0,
assess: 0,
};
// Treat opening balance as outstanding assessments
bal.assess += opening;
for (const e of entries) {
BUCKETS.forEach((b) => {
bal[b] += num((e as any)[b]);
});
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;
pay -= take;
}
// any leftover credit goes against assessments (overpayment)
if (pay > 0) bal.assess -= pay;
}
}
const total = BUCKETS.reduce((s, b) => s + bal[b], 0);
return { ...bal, total };
}
/** Running balance per row */
export function withRunningBalance(entries: LedgerEntry[], opening = 0) {
let bal = opening;
return entries.map((e) => {
bal += rowCharges(e) - num(e.payment);
return { ...e, runningBalance: bal };
});
}
@@ -0,0 +1,58 @@
-- Add category columns to ledger entries
ALTER TABLE public.collection_ledger_entries
ADD COLUMN IF NOT EXISTS assess numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS late numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS admin numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS legal numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS viol numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS interest numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS bank numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS payment numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS account text;
-- Backfill: split existing debit/credit into the appropriate column based on transaction_type
UPDATE public.collection_ledger_entries
SET
assess = CASE WHEN transaction_type = 'assessment' THEN COALESCE(debit,0) ELSE 0 END,
late = CASE WHEN transaction_type = 'late_fee' THEN COALESCE(debit,0) ELSE 0 END,
admin = CASE WHEN transaction_type = 'admin_fee' THEN COALESCE(debit,0) ELSE 0 END,
legal = CASE WHEN transaction_type = 'legal_fee' THEN COALESCE(debit,0) ELSE 0 END,
viol = CASE WHEN transaction_type = 'violation' THEN COALESCE(debit,0) ELSE 0 END,
interest = CASE WHEN transaction_type = 'interest' THEN COALESCE(debit,0) ELSE 0 END,
bank = CASE WHEN transaction_type IN ('bank_fee') THEN COALESCE(debit,0) ELSE 0 END,
payment = CASE WHEN transaction_type IN ('payment','adjustment') THEN COALESCE(credit,0) ELSE 0 END,
account = transaction_type
WHERE assess = 0 AND late = 0 AND admin = 0 AND legal = 0 AND viol = 0 AND interest = 0 AND bank = 0 AND payment = 0;
-- Add ledger metadata to collections
ALTER TABLE public.collections
ADD COLUMN IF NOT EXISTS name text,
ADD COLUMN IF NOT EXISTS interest_rate_override numeric;
-- Payment allocations: how a payment row is applied across priority buckets
CREATE TABLE IF NOT EXISTS public.collection_payment_allocations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entry_id uuid NOT NULL REFERENCES public.collection_ledger_entries(id) ON DELETE CASCADE,
collection_id uuid NOT NULL REFERENCES public.collections(id) ON DELETE CASCADE,
bucket text NOT NULL, -- 'bank' | 'interest' | 'late' | 'legal' | 'admin' | 'viol' | 'assess'
amount numeric NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.collection_payment_allocations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "cpa_select_case" ON public.collection_payment_allocations
FOR SELECT TO authenticated USING (
EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND can_access_case(c.case_id, auth.uid()))
);
CREATE POLICY "cpa_insert_case" ON public.collection_payment_allocations
FOR INSERT TO authenticated WITH CHECK (
EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND can_access_case(c.case_id, auth.uid()))
);
CREATE POLICY "cpa_delete_case" ON public.collection_payment_allocations
FOR DELETE TO authenticated USING (
EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND can_access_case(c.case_id, auth.uid()))
);
CREATE INDEX IF NOT EXISTS idx_cpa_entry ON public.collection_payment_allocations(entry_id);
CREATE INDEX IF NOT EXISTS idx_cpa_collection ON public.collection_payment_allocations(collection_id);