mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting: fix empty JE/GL pages + nightly Buildium GL pull sync
- Add missing indexes on journal_entry_lines (journal_entry_id, account_id) and journal_entries (company_id, date): Bridgewater's ledger query took 7.5s, blew the 8s statement timeout, and rendered the JE/GL pages empty - Paginate JE/GL page fetches past the 1000-row PostgREST cap (shared fetchJournalEntries helper) and surface query errors instead of swallowing them into an empty list - New buildium-gl-sync edge function (scheduled nightly via pg_cron): incrementally pulls new Buildium GL activity into accounting journal entries via GET /v1/generalledger, reconstructing double-entry JEs by grouping per-account entries by transaction id; watermark + 14-day overlap window, dedupe on (company_id, external_source, external_id), account mapping by external_id/code/name with auto-create for new Buildium accounts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
import { useCompanyId } from "./lib/useCompanyId";
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
|
import { fetchAllJournalEntries } from "./lib/fetchJournalEntries";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -44,18 +45,12 @@ export default function AccountingGeneralLedgerPage() {
|
|||||||
const [activityOnly, setActivityOnly] = useState(true);
|
const [activityOnly, setActivityOnly] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const { data: entries = [], isFetching } = useQuery({
|
const { data: entries = [], isFetching, error: entriesError } = useQuery({
|
||||||
queryKey: ["gl-journal-entries", cid],
|
queryKey: ["gl-journal-entries", cid],
|
||||||
enabled: !!cid,
|
enabled: !!cid,
|
||||||
queryFn: async () =>
|
queryFn: () =>
|
||||||
(
|
// Running balances below assume date-ascending order.
|
||||||
await accounting
|
fetchAllJournalEntries(cid, "id, date, description, reference, journal_entry_lines(debit, credit, account_id, accounts(code, name, type))", { ascending: true }),
|
||||||
.from("journal_entries")
|
|
||||||
.select("id, date, description, reference, journal_entry_lines(debit, credit, account_id, accounts(code, name, type))")
|
|
||||||
.eq("company_id", cid)
|
|
||||||
.order("date", { ascending: true })
|
|
||||||
.order("created_at", { ascending: true })
|
|
||||||
).data ?? [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: accounts = [] } = useQuery({
|
const { data: accounts = [] } = useQuery({
|
||||||
@@ -169,6 +164,13 @@ export default function AccountingGeneralLedgerPage() {
|
|||||||
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||||
|
if (entriesError) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-destructive text-center py-12">
|
||||||
|
Failed to load the general ledger: {(entriesError as any)?.message || String(entriesError)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { accounting } from "@/lib/accountingClient";
|
import { accounting } from "@/lib/accountingClient";
|
||||||
import { useCompanyId } from "./lib/useCompanyId";
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
|
import { fetchAllJournalEntries } from "./lib/fetchJournalEntries";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -39,18 +40,10 @@ export default function AccountingJournalEntriesPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: entries = [] } = useQuery({
|
const { data: entries = [], error: entriesError } = useQuery({
|
||||||
queryKey: ["journal-entries", cid],
|
queryKey: ["journal-entries", cid],
|
||||||
enabled: !!cid,
|
enabled: !!cid,
|
||||||
queryFn: async () =>
|
queryFn: () => fetchAllJournalEntries(cid, "*, journal_entry_lines(*, accounts(name, code))"),
|
||||||
(
|
|
||||||
await accounting
|
|
||||||
.from("journal_entries")
|
|
||||||
.select("*, journal_entry_lines(*, accounts(name, code))")
|
|
||||||
.eq("company_id", cid)
|
|
||||||
.order("date", { ascending: false })
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
).data ?? [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: accounts = [] } = useQuery({
|
const { data: accounts = [] } = useQuery({
|
||||||
@@ -186,6 +179,13 @@ export default function AccountingJournalEntriesPage() {
|
|||||||
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||||
|
if (entriesError) {
|
||||||
|
return (
|
||||||
|
<p className="flex items-center justify-center gap-2 text-sm text-destructive py-12">
|
||||||
|
<AlertCircle className="h-4 w-4" /> Failed to load journal entries: {(entriesError as any)?.message || String(entriesError)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
|
||||||
|
// PostgREST caps each response at 1000 rows, so companies with more journal
|
||||||
|
// entries than that (e.g. Buildium-imported books) were silently truncated —
|
||||||
|
// and a query error (statement timeout) used to be swallowed into an empty
|
||||||
|
// list. Page through all entries on a stable key and throw on error so
|
||||||
|
// react-query surfaces failures instead of rendering an empty ledger.
|
||||||
|
const PAGE = 1000;
|
||||||
|
|
||||||
|
export async function fetchAllJournalEntries(
|
||||||
|
cid: string,
|
||||||
|
select: string,
|
||||||
|
opts: { ascending?: boolean } = {},
|
||||||
|
): Promise<any[]> {
|
||||||
|
const ascending = opts.ascending ?? false;
|
||||||
|
const out: any[] = [];
|
||||||
|
for (let offset = 0; ; offset += PAGE) {
|
||||||
|
const { data, error } = await accounting
|
||||||
|
.from("journal_entries")
|
||||||
|
.select(select)
|
||||||
|
.eq("company_id", cid)
|
||||||
|
.order("date", { ascending })
|
||||||
|
.order("created_at", { ascending })
|
||||||
|
.order("id", { ascending: true })
|
||||||
|
.range(offset, offset + PAGE - 1);
|
||||||
|
if (error) throw error;
|
||||||
|
const rows = (data ?? []) as any[];
|
||||||
|
out.push(...rows);
|
||||||
|
if (rows.length < PAGE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// Buildium GL Sync (pull-only) — incrementally pulls new Buildium general
|
||||||
|
// ledger transactions into accounting.journal_entries / journal_entry_lines.
|
||||||
|
//
|
||||||
|
// Scope: companies whose books were imported from Buildium (i.e. that already
|
||||||
|
// have journal entries with external_source = 'buildium_gl'), or companies
|
||||||
|
// explicitly listed in the request body. Dedupe rides on the existing unique
|
||||||
|
// index journal_entries_external_uq (company_id, external_source, external_id)
|
||||||
|
// where external_id is the Buildium transaction id — the same keying the GL
|
||||||
|
// CSV imports used, so re-pulling an overlapping window never duplicates.
|
||||||
|
//
|
||||||
|
// Pull-only by design: nothing is written back to Buildium, and transactions
|
||||||
|
// edited or deleted in Buildium after they were pulled are NOT reconciled.
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUILDIUM_BASE = "https://api.buildium.com";
|
||||||
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// How far behind the watermark each run re-reads. Buildium allows backdating,
|
||||||
|
// so a pure "since last run" window would miss entries posted into the past.
|
||||||
|
const OVERLAP_DAYS = 14;
|
||||||
|
|
||||||
|
async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: URLSearchParams) {
|
||||||
|
const url = new URL(`${BUILDIUM_BASE}${path}`);
|
||||||
|
if (params) url.search = params.toString();
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
const text = await res.text();
|
||||||
|
if ((res.status === 429 || res.status >= 500) && attempt < 3) {
|
||||||
|
const ra = Number(res.headers.get("Retry-After") ?? "");
|
||||||
|
await wait(Number.isFinite(ra) && ra > 0 ? ra * 1000 : 600 * Math.pow(2, attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Buildium ${path} [${res.status}]: ${text}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Buildium ${path} failed after retries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildiumFetchAll(path: string, clientId: string, clientSecret: string, base?: URLSearchParams) {
|
||||||
|
const all: any[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 1000;
|
||||||
|
while (true) {
|
||||||
|
const params = new URLSearchParams(base);
|
||||||
|
params.set("offset", String(offset));
|
||||||
|
params.set("limit", String(limit));
|
||||||
|
const page = await buildiumFetch(path, clientId, clientSecret, params);
|
||||||
|
if (!Array.isArray(page) || page.length === 0) break;
|
||||||
|
all.push(...page);
|
||||||
|
// Buildium silently clamps `limit` on some endpoints, so a short page
|
||||||
|
// doesn't prove we're done — advance by what we actually got and stop
|
||||||
|
// only on an empty page.
|
||||||
|
offset += page.length;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (v: unknown) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
// Local account names sometimes carry a leading code ("2010 Prepayments");
|
||||||
|
// strip it so they match Buildium's bare names.
|
||||||
|
const normName = (v: unknown) => norm(String(v ?? "").replace(/^\s*\d{3,6}(?:[-.]\d+)?\s+/, ""));
|
||||||
|
|
||||||
|
function mapGLAccountType(type: string | null | undefined): string {
|
||||||
|
const t = String(type || "").toLowerCase();
|
||||||
|
if (t.includes("asset")) return "asset";
|
||||||
|
if (t.includes("liabilit")) return "liability";
|
||||||
|
if (t.includes("equity")) return "equity";
|
||||||
|
if (t.includes("income") || t.includes("revenue")) return "income";
|
||||||
|
if (t.includes("expense")) return "expense";
|
||||||
|
return "expense";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoDate = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
const addDays = (iso: string, days: number) => {
|
||||||
|
const d = new Date(`${iso}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return isoDate(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||||
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const token = authHeader.replace("Bearer ", "");
|
||||||
|
|
||||||
|
// Allow either the service role (pg_cron) or a staff user JWT.
|
||||||
|
let claims: any = null;
|
||||||
|
try {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=");
|
||||||
|
claims = JSON.parse(atob(padded));
|
||||||
|
} catch {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
const isServiceRole = claims?.role === "service_role";
|
||||||
|
if (!isServiceRole) {
|
||||||
|
const auth = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } });
|
||||||
|
const { data: roles } = await auth.from("user_roles").select("role").eq("user_id", claims?.sub ?? "");
|
||||||
|
const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
||||||
|
if (!isStaff) {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
|
||||||
|
const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return new Response(JSON.stringify({ error: "Buildium API credentials not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(supabaseUrl, serviceKey, { db: { schema: "accounting" } });
|
||||||
|
const pub = createClient(supabaseUrl, serviceKey);
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
// Debug: dump a raw transaction (bypasses the sync).
|
||||||
|
if (body.debugTransactionId) {
|
||||||
|
const tx = await buildiumFetch(`/v1/generalledger/transactions/${body.debugTransactionId}`, clientId, clientSecret);
|
||||||
|
return new Response(JSON.stringify(tx), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: inspect specific GL accounts directly (bypasses the sync).
|
||||||
|
if (Array.isArray(body.debugGlAccountIds) && body.debugGlAccountIds.length > 0) {
|
||||||
|
const listed = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
|
||||||
|
const out: Record<string, any> = {
|
||||||
|
listed_count: listed.length,
|
||||||
|
with_parent: listed.filter((g: any) => g.ParentGLAccountId).length,
|
||||||
|
with_subaccounts: listed.filter((g: any) => Array.isArray(g.SubAccounts) && g.SubAccounts.length > 0).length,
|
||||||
|
sample_keys: listed[0] ? Object.keys(listed[0]) : [],
|
||||||
|
sample_subaccount: listed.find((g: any) => Array.isArray(g.SubAccounts) && g.SubAccounts.length > 0)?.SubAccounts?.[0] ?? null,
|
||||||
|
};
|
||||||
|
for (const id of body.debugGlAccountIds.slice(0, 10)) {
|
||||||
|
try {
|
||||||
|
const acct = await buildiumFetch(`/v1/glaccounts/${id}`, clientId, clientSecret);
|
||||||
|
out[String(id)] = { AccountNumber: acct?.AccountNumber ?? null, Name: acct?.Name ?? null, Type: acct?.Type ?? null, SubType: acct?.SubType ?? null, IsActive: acct?.IsActive ?? null, ParentGLAccountId: acct?.ParentGLAccountId ?? null, inList: listed.some((g: any) => String(g.Id) === String(id)) };
|
||||||
|
} catch (e: any) {
|
||||||
|
out[String(id)] = { error: e?.message || String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(out), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
const companyIdsFilter: string[] = Array.isArray(body.companyIds) ? body.companyIds.filter((s: any) => typeof s === "string" && s) : [];
|
||||||
|
const dateFromOverride = typeof body.dateFrom === "string" ? body.dateFrom : null;
|
||||||
|
const dateToOverride = typeof body.dateTo === "string" ? body.dateTo : null;
|
||||||
|
const dryRun = body.dryRun === true;
|
||||||
|
|
||||||
|
// ---- Companies in scope: Buildium-managed = has buildium_gl entries ----
|
||||||
|
const { data: companies, error: cErr } = await supabase
|
||||||
|
.from("companies")
|
||||||
|
.select("id, name, association_id, acmacc_sync_config")
|
||||||
|
.not("association_id", "is", null);
|
||||||
|
if (cErr) throw cErr;
|
||||||
|
|
||||||
|
const scoped = (companies || []).filter((c: any) => companyIdsFilter.length === 0 || companyIdsFilter.includes(c.id));
|
||||||
|
|
||||||
|
// ---- Buildium association mapping by normalized name (same as import) ----
|
||||||
|
const { data: assocRows } = await pub.from("associations").select("id, name");
|
||||||
|
const assocNameById = new Map<string, string>();
|
||||||
|
for (const a of assocRows || []) assocNameById.set(a.id, a.name);
|
||||||
|
const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
|
||||||
|
const bAssocIdByName = new Map<string, string>();
|
||||||
|
for (const ba of buildiumAssocs) bAssocIdByName.set(norm(ba.Name), String(ba.Id));
|
||||||
|
|
||||||
|
// ---- Buildium chart of accounts (account-id resolution + auto-create) ----
|
||||||
|
// /v1/glaccounts returns only top-level accounts as list items; children
|
||||||
|
// are nested in each item's SubAccounts array. Flatten recursively.
|
||||||
|
const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
|
||||||
|
const bGlById = new Map<string, any>();
|
||||||
|
const addGl = (g: any) => {
|
||||||
|
if (!g?.Id) return;
|
||||||
|
bGlById.set(String(g.Id), g);
|
||||||
|
if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) addGl(s);
|
||||||
|
};
|
||||||
|
for (const g of glAccounts) addGl(g);
|
||||||
|
const allGlIds = [...bGlById.keys()];
|
||||||
|
|
||||||
|
const today = isoDate(new Date());
|
||||||
|
const results: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const company of scoped) {
|
||||||
|
const companyResult: any = { pulled: 0, inserted: 0, skipped_existing: 0, errors: [] as string[] };
|
||||||
|
results[company.name] = companyResult;
|
||||||
|
try {
|
||||||
|
// Watermark: explicit override > stored config > newest imported entry.
|
||||||
|
const cfg = (company.acmacc_sync_config ?? {}) as Record<string, any>;
|
||||||
|
let watermark: string | null = dateFromOverride || cfg?.buildium_gl?.last_synced_date || null;
|
||||||
|
if (!watermark) {
|
||||||
|
const { data: maxRow } = await supabase
|
||||||
|
.from("journal_entries")
|
||||||
|
.select("date")
|
||||||
|
.eq("company_id", company.id)
|
||||||
|
.eq("external_source", "buildium_gl")
|
||||||
|
.order("date", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
watermark = maxRow?.date ?? null;
|
||||||
|
}
|
||||||
|
if (!watermark) {
|
||||||
|
// No baseline Buildium import and no explicit dateFrom — not a
|
||||||
|
// Buildium-managed company; leave it alone.
|
||||||
|
companyResult.skipped = "no buildium_gl baseline (pass dateFrom to backfill)";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bAssocId = bAssocIdByName.get(norm(assocNameById.get(company.association_id) ?? ""));
|
||||||
|
if (!bAssocId) {
|
||||||
|
companyResult.errors.push("No Buildium association matches the local association name");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = dateFromOverride ?? addDays(watermark, -OVERLAP_DAYS);
|
||||||
|
const until = dateToOverride ?? today;
|
||||||
|
|
||||||
|
// ---- Pull general ledger entries for the window ----
|
||||||
|
// /v1/generalledger returns, per GL account, the actual ledger entries
|
||||||
|
// (the same data as Buildium's GL report). Each entry carries the id
|
||||||
|
// of the transaction that produced it and a signed amount (debit
|
||||||
|
// positive / credit negative), so grouping entries across accounts by
|
||||||
|
// transaction id reconstructs complete double-entry journal entries.
|
||||||
|
// (The /generalledger/transactions Journal omits implicit AR/cash
|
||||||
|
// sides, so it can't be used for this.)
|
||||||
|
//
|
||||||
|
// glaccountids is required, but sending the whole chart at once blows
|
||||||
|
// the URL length limit — chunk it.
|
||||||
|
type GlLine = { bGlId: string; amount: number };
|
||||||
|
const txById = new Map<string, { date: string; description: string; transactionType: string; lines: GlLine[] }>();
|
||||||
|
const CHUNK = 50;
|
||||||
|
for (let i = 0; i < allGlIds.length; i += CHUNK) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("accountingbasis", "Accrual");
|
||||||
|
params.set("startdate", since);
|
||||||
|
params.set("enddate", until);
|
||||||
|
params.set("entitytype", "Association");
|
||||||
|
params.set("entityid", bAssocId);
|
||||||
|
for (const id of allGlIds.slice(i, i + CHUNK)) params.append("glaccountids", id);
|
||||||
|
const ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params);
|
||||||
|
for (const ledger of ledgers) {
|
||||||
|
const bGlId = String(ledger.GLAccountId ?? ledger.GLAccount?.Id ?? "");
|
||||||
|
if (!bGlId) continue;
|
||||||
|
for (const e of ledger.Entries ?? []) {
|
||||||
|
const txId = String(e.Id ?? "");
|
||||||
|
if (!txId) continue;
|
||||||
|
let tx = txById.get(txId);
|
||||||
|
if (!tx) {
|
||||||
|
tx = {
|
||||||
|
date: String(e.Date || "").split("T")[0],
|
||||||
|
description: String(e.Description || e.TransactionType || "Buildium entry").slice(0, 500),
|
||||||
|
transactionType: String(e.TransactionType || ""),
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
txById.set(txId, tx);
|
||||||
|
}
|
||||||
|
tx.lines.push({ bGlId, amount: Number(e.Amount) || 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
companyResult.pulled = txById.size;
|
||||||
|
|
||||||
|
// ---- Already-imported transaction ids for this company ----
|
||||||
|
const existingIds = new Set<string>();
|
||||||
|
for (let offset = 0; ; offset += 1000) {
|
||||||
|
const { data: rows, error } = await supabase
|
||||||
|
.from("journal_entries")
|
||||||
|
.select("external_id")
|
||||||
|
.eq("company_id", company.id)
|
||||||
|
.eq("external_source", "buildium_gl")
|
||||||
|
.order("id", { ascending: true })
|
||||||
|
.range(offset, offset + 999);
|
||||||
|
if (error) throw error;
|
||||||
|
for (const r of rows || []) if (r.external_id) existingIds.add(String(r.external_id));
|
||||||
|
if ((rows || []).length < 1000) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Local account resolution maps ----
|
||||||
|
const { data: localAccounts, error: aErr } = await supabase
|
||||||
|
.from("accounts")
|
||||||
|
.select("id, code, name, type, external_source, external_id")
|
||||||
|
.eq("company_id", company.id);
|
||||||
|
if (aErr) throw aErr;
|
||||||
|
const byExternal = new Map<string, any>();
|
||||||
|
const byCode = new Map<string, any>();
|
||||||
|
const byName = new Map<string, any>();
|
||||||
|
for (const a of localAccounts || []) {
|
||||||
|
if (a.external_id) byExternal.set(String(a.external_id), a);
|
||||||
|
if (a.code) byCode.set(norm(a.code), a);
|
||||||
|
byName.set(normName(a.name), a);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAccount(bGlId: string): Promise<{ id: string } | null> {
|
||||||
|
const direct = byExternal.get(bGlId);
|
||||||
|
if (direct) return direct;
|
||||||
|
const meta = bGlById.get(bGlId);
|
||||||
|
if (!meta) return null;
|
||||||
|
const codeMatch = meta.AccountNumber ? byCode.get(norm(meta.AccountNumber)) : null;
|
||||||
|
const nameMatch = byName.get(normName(meta.Name));
|
||||||
|
const match = codeMatch || nameMatch || null;
|
||||||
|
if (match) {
|
||||||
|
// Backfill the Buildium id so future syncs resolve deterministically.
|
||||||
|
if (!match.external_id && !dryRun) {
|
||||||
|
await supabase.from("accounts").update({ external_source: "buildium", external_id: bGlId }).eq("id", match.id);
|
||||||
|
}
|
||||||
|
match.external_id = match.external_id || bGlId;
|
||||||
|
byExternal.set(bGlId, match);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
// Would be auto-created in a real run; stub it so the dry run
|
||||||
|
// reports the transaction as insertable rather than unmapped.
|
||||||
|
const stub = { id: `dryrun-${bGlId}`, external_id: bGlId };
|
||||||
|
byExternal.set(bGlId, stub);
|
||||||
|
companyResult.accounts_created = (companyResult.accounts_created || 0) + 1;
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
// New account in Buildium — mirror it locally, like the import would.
|
||||||
|
const { data: created, error: createErr } = await supabase
|
||||||
|
.from("accounts")
|
||||||
|
.insert({
|
||||||
|
company_id: company.id,
|
||||||
|
code: meta.AccountNumber ? String(meta.AccountNumber) : null,
|
||||||
|
name: meta.Name || `Buildium account ${bGlId}`,
|
||||||
|
type: mapGLAccountType(meta.Type || meta.AccountType),
|
||||||
|
description: meta.Description || null,
|
||||||
|
external_source: "buildium",
|
||||||
|
external_id: bGlId,
|
||||||
|
})
|
||||||
|
.select("id, code, name, external_id")
|
||||||
|
.single();
|
||||||
|
if (createErr) throw createErr;
|
||||||
|
byExternal.set(bGlId, created);
|
||||||
|
companyResult.accounts_created = (companyResult.accounts_created || 0) + 1;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Insert new transactions as journal entries ----
|
||||||
|
const newTxns = [...txById.entries()]
|
||||||
|
.filter(([txId]) => !existingIds.has(txId))
|
||||||
|
.sort((a, b) => a[1].date.localeCompare(b[1].date));
|
||||||
|
companyResult.skipped_existing = txById.size - newTxns.length;
|
||||||
|
|
||||||
|
for (const [txId, tx] of newTxns) {
|
||||||
|
try {
|
||||||
|
const lineRows: { account_id: string; debit: number; credit: number; description: string | null }[] = [];
|
||||||
|
let resolved = true;
|
||||||
|
for (const l of tx.lines) {
|
||||||
|
const acct = await resolveAccount(l.bGlId);
|
||||||
|
if (!acct) {
|
||||||
|
const meta = bGlById.get(l.bGlId);
|
||||||
|
companyResult.errors.push(
|
||||||
|
`tx ${txId}: unmapped GL account ${l.bGlId || "?"} (${meta ? `#${meta.AccountNumber ?? "—"} ${meta.Name ?? "?"}${meta.IsActive === false ? ", inactive" : ""}` : "not returned by /v1/glaccounts"})`,
|
||||||
|
);
|
||||||
|
resolved = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Amount is signed relative to the account's NATURAL balance:
|
||||||
|
// positive means the account's balance increased. An increase
|
||||||
|
// is a debit for asset/expense accounts and a credit for
|
||||||
|
// liability/equity/income accounts. Buildium's own account type
|
||||||
|
// is authoritative for how it signed the amount.
|
||||||
|
if (l.amount === 0) continue;
|
||||||
|
const meta = bGlById.get(l.bGlId);
|
||||||
|
const bType = mapGLAccountType(meta?.Type || (acct as any).type);
|
||||||
|
const creditNatural = bType === "liability" || bType === "equity" || bType === "income";
|
||||||
|
const increase = l.amount >= 0;
|
||||||
|
const isDebit = creditNatural ? !increase : increase;
|
||||||
|
lineRows.push({
|
||||||
|
account_id: acct.id,
|
||||||
|
debit: isDebit ? Math.abs(l.amount) : 0,
|
||||||
|
credit: isDebit ? 0 : Math.abs(l.amount),
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!resolved) continue;
|
||||||
|
|
||||||
|
const debits = lineRows.reduce((s, l) => s + l.debit, 0);
|
||||||
|
const credits = lineRows.reduce((s, l) => s + l.credit, 0);
|
||||||
|
if (Math.abs(debits - credits) > 0.005) {
|
||||||
|
companyResult.errors.push(`tx ${txId}: unbalanced (${debits.toFixed(2)} vs ${credits.toFixed(2)})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
companyResult.inserted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: je, error: jeErr } = await supabase
|
||||||
|
.from("journal_entries")
|
||||||
|
.insert({
|
||||||
|
company_id: company.id,
|
||||||
|
date: tx.date || today,
|
||||||
|
description: tx.description,
|
||||||
|
reference: null,
|
||||||
|
external_source: "buildium_gl",
|
||||||
|
external_id: txId,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (jeErr) {
|
||||||
|
// Unique violation = concurrently imported; anything else is real.
|
||||||
|
if ((jeErr as any).code === "23505") {
|
||||||
|
companyResult.skipped_existing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw jeErr;
|
||||||
|
}
|
||||||
|
const { error: lErr } = await supabase
|
||||||
|
.from("journal_entry_lines")
|
||||||
|
.insert(lineRows.map((l) => ({ ...l, journal_entry_id: je.id })));
|
||||||
|
if (lErr) {
|
||||||
|
// Don't leave a headerless entry behind.
|
||||||
|
await supabase.from("journal_entries").delete().eq("id", je.id);
|
||||||
|
throw lErr;
|
||||||
|
}
|
||||||
|
companyResult.inserted += 1;
|
||||||
|
} catch (e: any) {
|
||||||
|
companyResult.errors.push(`tx ${txId}: ${e?.message || String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Advance the watermark ----
|
||||||
|
if (!dryRun) {
|
||||||
|
const nextCfg = {
|
||||||
|
...cfg,
|
||||||
|
buildium_gl: {
|
||||||
|
last_synced_date: until,
|
||||||
|
last_run_at: new Date().toISOString(),
|
||||||
|
last_result: {
|
||||||
|
pulled: companyResult.pulled,
|
||||||
|
inserted: companyResult.inserted,
|
||||||
|
errors: companyResult.errors.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await supabase.from("companies").update({ acmacc_sync_config: nextCfg }).eq("id", company.id);
|
||||||
|
}
|
||||||
|
companyResult.window = { since, until };
|
||||||
|
} catch (e: any) {
|
||||||
|
companyResult.errors.push(e?.message || String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true, dryRun, results }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("buildium-gl-sync error", e);
|
||||||
|
return new Response(JSON.stringify({ error: e?.message || String(e) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Performance: journal_entry_lines had no FK indexes, so every nested
|
||||||
|
-- journal_entries -> journal_entry_lines fetch seq-scanned all lines per entry.
|
||||||
|
-- For Bridgewater (4,189 JEs / 10,075 lines) the JE + GL pages exceeded the 8s
|
||||||
|
-- statement timeout and rendered empty.
|
||||||
|
create index if not exists journal_entry_lines_journal_entry_id_idx
|
||||||
|
on accounting.journal_entry_lines (journal_entry_id);
|
||||||
|
|
||||||
|
create index if not exists journal_entry_lines_account_id_idx
|
||||||
|
on accounting.journal_entry_lines (account_id);
|
||||||
|
|
||||||
|
create index if not exists journal_entries_company_date_idx
|
||||||
|
on accounting.journal_entries (company_id, date);
|
||||||
Reference in New Issue
Block a user