mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
// 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<string, any>();
|
|
(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<string, { late_fee?: FeeExclusion; interest?: FeeExclusion }> = {};
|
|
(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<string, any[]> = {};
|
|
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" },
|
|
});
|
|
}
|
|
});
|