// Auto-applies late fees and interest based on association_fee_rules. // Idempotent: skips owners that already received the same fee type within the current period. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", }; type FeeExclusion = { fee_type: "late_fee" | "interest"; mode: "waive" | "override_amount" | "override_percent"; override_amount: number | null; override_percent: number | null; }; function periodKey(today: Date, schedule: string): string { const y = today.getUTCFullYear(); const m = today.getUTCMonth(); if (schedule === "quarterly") return `${y}-Q${Math.floor(m / 3) + 1}`; if (schedule === "annual") return `${y}`; return `${y}-${String(m + 1).padStart(2, "0")}`; } function periodWindow(today: Date, schedule: string): { start: string; end: string } { const y = today.getUTCFullYear(); const m = today.getUTCMonth(); let startMonth = m; let monthsInPeriod = 1; if (schedule === "quarterly") { startMonth = Math.floor(m / 3) * 3; monthsInPeriod = 3; } else if (schedule === "annual") { startMonth = 0; monthsInPeriod = 12; } const start = new Date(Date.UTC(y, startMonth, 1)).toISOString().slice(0, 10); const end = new Date(Date.UTC(y, startMonth + monthsInPeriod, 0)).toISOString().slice(0, 10); return { start, end }; } function resolveAmount(requested: number, exclusion: FeeExclusion | null, balance: number): number { if (!(requested > 0)) return 0; if (!exclusion) return requested; if (exclusion.mode === "waive") return 0; if (exclusion.mode === "override_amount") { const ov = Number(exclusion.override_amount || 0); return ov > 0 ? Number(ov.toFixed(2)) : 0; } if (exclusion.mode === "override_percent" && exclusion.override_percent != null) { const pct = Number(exclusion.override_percent); // stored as fraction (e.g. 0.015) if (pct <= 0 || balance <= 0) return 0; return Number((balance * pct).toFixed(2)); } return requested; } Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); try { const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, ); const url = new URL(req.url); const dryRun = url.searchParams.get("dry_run") === "1"; const forceAssoc = url.searchParams.get("association_id"); const force = url.searchParams.get("force") === "1"; const today = new Date(); const todayISO = today.toISOString().slice(0, 10); const todayDay = today.getUTCDate(); // 1. Load all enabled rules let rulesQuery = supabase .from("association_fee_rules") .select("*") .eq("auto_apply_enabled", true); if (forceAssoc) rulesQuery = rulesQuery.eq("association_id", forceAssoc); const { data: rules, error: rulesErr } = await rulesQuery; if (rulesErr) throw rulesErr; const eligibleRules = (rules || []).filter((r: any) => { if (!r.late_fee_enabled && !r.interest_enabled) return false; if (force) return true; // Only run when today >= configured day in current period const day = Number(r.auto_apply_day || 1); if (todayDay < day) return false; // Only run for current month boundaries based on schedule const sched = r.auto_apply_schedule || "monthly"; if (sched === "quarterly" && today.getUTCMonth() % 3 !== 0) return false; if (sched === "annual" && today.getUTCMonth() !== 0) return false; return true; }); const summary: any[] = []; let totalLateFees = 0; let totalInterest = 0; for (const rule of eligibleRules) { const associationId = rule.association_id; const sched = rule.auto_apply_schedule || "monthly"; const { start: periodStart, end: periodEnd } = periodWindow(today, sched); // 2. Owners + units const { data: owners, error: ownersErr } = await supabase .from("owners") .select("id, unit_id, association_id, status") .eq("association_id", associationId) .eq("status", "active"); if (ownersErr) { summary.push({ associationId, error: ownersErr.message }); continue; } const ownerIds = (owners || []).map((o: any) => o.id); if (ownerIds.length === 0) { summary.push({ associationId, posted: 0, note: "no active owners" }); continue; } const ownerById = new Map(); (owners || []).forEach((o: any) => ownerById.set(o.id, o)); // 3. Unit fee exclusions const { data: exclusions } = await supabase .from("unit_fee_exclusions") .select("unit_id, fee_type, mode, override_amount, override_percent") .eq("association_id", associationId); const exByUnit: Record = {}; (exclusions || []).forEach((e: any) => { if (!e.unit_id) return; if (!exByUnit[e.unit_id]) exByUnit[e.unit_id] = {}; exByUnit[e.unit_id][e.fee_type as "late_fee" | "interest"] = e; }); // 4. Pull all ledger entries for these owners (paginated) const ledgerByOwner: Record = {}; const PAGE = 1000; // chunk owner ids to avoid URL bloat const chunkSize = 100; for (let i = 0; i < ownerIds.length; i += chunkSize) { const chunk = ownerIds.slice(i, i + chunkSize); let from = 0; while (true) { const { data, error } = await supabase .from("owner_ledger_entries") .select("owner_id, transaction_type, debit, credit, date") .in("owner_id", chunk) .order("date", { ascending: true }) .range(from, from + PAGE - 1); if (error) throw error; (data || []).forEach((e: any) => { if (!ledgerByOwner[e.owner_id]) ledgerByOwner[e.owner_id] = []; ledgerByOwner[e.owner_id].push(e); }); if (!data || data.length < PAGE) break; from += PAGE; } } const inserts: any[] = []; let assocLate = 0; let assocInterest = 0; let skippedAlreadyPosted = 0; let skippedNoBalance = 0; let waived = 0; for (const owner of owners || []) { const entries = ledgerByOwner[owner.id] || []; let balance = 0; let lastPaymentDate: string | null = null; let postedLateThisPeriod = false; let postedInterestThisPeriod = false; for (const e of entries) { balance += (Number(e.debit) || 0) - (Number(e.credit) || 0); const t = (e.transaction_type || "").toLowerCase(); const d = e.date as string | null; if (!d) continue; if ((t.includes("payment") || (Number(e.credit) > 0 && !t)) && (!lastPaymentDate || d > lastPaymentDate)) { lastPaymentDate = d; } if (d >= periodStart && d <= periodEnd) { if (t.includes("late")) postedLateThisPeriod = true; if (t.includes("interest")) postedInterestThisPeriod = true; } } if (balance <= 0) { skippedNoBalance++; continue; } const unitId = owner.unit_id; const unitEx = unitId ? exByUnit[unitId] || {} : {}; // ---------- Late fee ---------- if (rule.late_fee_enabled && !postedLateThisPeriod) { // Determine if past trigger const triggerDays = Number(rule.late_fee_trigger_days || 0); let triggered = false; if (lastPaymentDate) { const ageMs = today.getTime() - new Date(lastPaymentDate + "T12:00:00Z").getTime(); const ageDays = Math.floor(ageMs / 86400000); triggered = ageDays >= triggerDays; } else { triggered = true; // never paid, has balance } // If non-recurring, only post once ever if (triggered && !rule.late_fee_recurring) { const everPostedLate = entries.some((e) => (e.transaction_type || "").toLowerCase().includes("late")); if (everPostedLate) triggered = false; } if (triggered) { let requested = 0; if ((rule.late_fee_type || "flat") === "percentage") { const pct = Number(rule.late_fee_amount || 0); requested = Number((balance * pct / 100).toFixed(2)); if (rule.late_fee_max && requested > Number(rule.late_fee_max)) requested = Number(rule.late_fee_max); } else { requested = Number(rule.late_fee_amount || 0); } const exc = (unitEx.late_fee as FeeExclusion | undefined) || null; const amount = resolveAmount(requested, exc, balance); if (amount > 0) { inserts.push({ association_id: associationId, owner_id: owner.id, unit_id: unitId, date: todayISO, transaction_type: "late_fee", description: exc?.mode === "override_amount" ? "Late fee (override per unit exclusion) — auto" : "Late fee (auto-applied)", debit: amount, credit: 0, }); assocLate += amount; } else if (exc?.mode === "waive") { waived++; } } } else if (postedLateThisPeriod) { skippedAlreadyPosted++; } // ---------- Interest ---------- if (rule.interest_enabled && !postedInterestThisPeriod) { const graceDays = Number(rule.interest_grace_days || 0); let triggered = false; if (lastPaymentDate) { const ageMs = today.getTime() - new Date(lastPaymentDate + "T12:00:00Z").getTime(); const ageDays = Math.floor(ageMs / 86400000); triggered = ageDays >= graceDays; } else { triggered = true; } if (triggered) { const annualRate = Number(rule.interest_rate || 0); const compound = (rule.interest_compound || "monthly").toLowerCase(); // interest_rate is stored as APR percent. monthly = APR/12, simple/monthly schedule both apply once per period. let periodicRate = annualRate / 100; if (compound === "monthly" || compound === "simple") periodicRate = annualRate / 100 / 12; else if (compound === "quarterly") periodicRate = annualRate / 100 / 4; else if (compound === "daily") periodicRate = annualRate / 100 / 365 * 30; const requested = Number((balance * periodicRate).toFixed(2)); const exc = (unitEx.interest as FeeExclusion | undefined) || null; const amount = resolveAmount(requested, exc, balance); if (amount > 0) { inserts.push({ association_id: associationId, owner_id: owner.id, unit_id: unitId, date: todayISO, transaction_type: "interest", description: exc?.mode === "override_amount" ? "Interest (override per unit exclusion) — auto" : exc?.mode === "override_percent" ? `Interest @ ${(Number(exc.override_percent) * 100).toFixed(2)}% (per unit override) — auto` : `Interest @ ${annualRate}% per annum — auto`, debit: amount, credit: 0, }); assocInterest += amount; } else if (exc?.mode === "waive") { waived++; } } } else if (postedInterestThisPeriod) { skippedAlreadyPosted++; } } let posted = 0; if (inserts.length && !dryRun) { // batch in chunks of 500 for (let i = 0; i < inserts.length; i += 500) { const batch = inserts.slice(i, i + 500); const { error: insErr } = await supabase.from("owner_ledger_entries").insert(batch); if (insErr) { summary.push({ associationId, error: insErr.message, posted }); break; } posted += batch.length; } } else { posted = inserts.length; } totalLateFees += assocLate; totalInterest += assocInterest; summary.push({ associationId, period: periodKey(today, sched), posted, late_fees_total: assocLate, interest_total: assocInterest, waived_by_exclusion: waived, skipped_already_posted: skippedAlreadyPosted, skipped_no_balance: skippedNoBalance, }); } return new Response( JSON.stringify({ ok: true, dry_run: dryRun, rules_processed: eligibleRules.length, total_late_fees: totalLateFees, total_interest: totalInterest, results: summary, }), { headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); } catch (err) { console.error("post-recurring-fees error:", err); return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } });