Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
@@ -0,0 +1,344 @@
// 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" },
});
}
});