mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user