Accounting: add Import G/L feature on Journal Entries page

New GLImportDialog imports a Buildium General Ledger / journal export
(CSV or XLSX) into journal entries. Lines are grouped into transactions by
Buildium Id (Amount is signed: + debit / - credit), parent-rollup and
cash-book rows are skipped, and accounts are matched to the association's
chart of accounts by code then name. Shows a preview (counts, date range,
trial balance by type) and blocks import on unmatched accounts or
unbalanced transactions. Keyed on the Buildium Id, so re-importing the same
file skips anything already imported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:42:11 -04:00
parent 7b1d6a59e9
commit 1296b9449d
3 changed files with 417 additions and 2 deletions
@@ -12,10 +12,11 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Plus, Trash2, ChevronRight, AlertCircle, Loader2, Pencil, Repeat } from "lucide-react"; import { Plus, Trash2, ChevronRight, AlertCircle, Loader2, Pencil, Repeat, Upload } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { money, fmtDate } from "./lib/format"; import { money, fmtDate } from "./lib/format";
import GLImportDialog from "./components/GLImportDialog";
// Lines use a single signed amount: + = debit, = credit // Lines use a single signed amount: + = debit, = credit
type JELine = { id: string; account_id: string; amount: string; description: string }; type JELine = { id: string; account_id: string; amount: string; description: string };
@@ -34,6 +35,7 @@ export default function AccountingJournalEntriesPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [detailId, setDetailId] = useState<string | null>(null); const [detailId, setDetailId] = useState<string | null>(null);
const [date, setDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" })); const [date, setDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }));
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -213,12 +215,23 @@ export default function AccountingJournalEntriesPage() {
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link to="/dashboard/accounting/recurring"><Repeat className="h-4 w-4 mr-1" /> Recurring</Link> <Link to="/dashboard/accounting/recurring"><Repeat className="h-4 w-4 mr-1" /> Recurring</Link>
</Button> </Button>
<Button variant="outline" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4 mr-1" /> Import G/L
</Button>
<Button onClick={() => { resetForm(); setOpen(true); }}> <Button onClick={() => { resetForm(); setOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> New entry <Plus className="h-4 w-4 mr-1" /> New entry
</Button> </Button>
</div> </div>
</div> </div>
<GLImportDialog
open={importOpen}
onOpenChange={setImportOpen}
companyId={cid}
accounts={accounts as any[]}
onSuccess={() => qc.invalidateQueries({ queryKey: ["journal-entries", cid] })}
/>
<Card> <Card>
<CardContent className="py-4"> <CardContent className="py-4">
<PeriodPicker <PeriodPicker
@@ -0,0 +1,402 @@
import { useMemo, useRef, useState } from "react";
import * as XLSX from "xlsx";
import { toast } from "sonner";
import { accounting } from "@/lib/accountingClient";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle, CheckCircle2, AlertTriangle, Loader2, Upload } from "lucide-react";
import { money } from "../lib/format";
// Importer for a Buildium General Ledger / journal export (CSV or XLSX).
//
// Buildium's journal export has one row per GL line. Lines that share an `Id`
// form one transaction (PostingId is the per-line id). `Amount` is signed —
// debit-positive / credit-negative — and the lines of a transaction sum to 0.
// Beginning-balance rows carry a blank `Id` and are grouped into one opening
// entry. Parent rollup accounts (IsParent) and the cash book (IsCashPosting)
// are skipped so the accrual book isn't double-counted.
//
// Accounts are matched to the association's chart of accounts by code
// (AccountNumber) first, then by name (GLAccountName, leading code stripped).
// Transactions are keyed on the Buildium `Id` (external_id) so re-importing the
// same file is a no-op — already-imported transactions are skipped.
const EXTERNAL_SOURCE = "buildium_gl_csv";
type CoaAccount = { id: string; code: string | null; name: string; type: string };
type ParsedLine = {
txnId: string; // Buildium Id ("" -> opening)
date: string; // YYYY-MM-DD
acctCode: string;
acctName: string; // GLAccountName with any leading code stripped
amount: number; // signed: + debit, - credit
memo: string;
reference: string;
payee: string;
};
type Txn = {
externalId: string;
date: string;
description: string;
reference: string;
lines: { accountId: string; debit: number; credit: number; description: string }[];
balanced: boolean;
imbalance: number;
};
const norm = (s: string) => s.toLowerCase().replace(/[\s_\-./,]/g, "");
const stripLeadingCode = (name: string) => name.replace(/^\s*\d+[\s\-]+/, "").trim();
function toIso(raw: string): string | null {
const s = (raw || "").trim();
if (!s) return null;
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (iso) return `${iso[1]}-${iso[2].padStart(2, "0")}-${iso[3].padStart(2, "0")}`;
const mdy = s.match(/^(\d{1,2})[/](\d{1,2})[/](\d{2,4})/);
if (mdy) {
let [, m, d, y] = mdy;
if (y.length === 2) y = `20${y}`;
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
}
return null;
}
// Read the first sheet of a CSV/XLSX file into rows of objects keyed by header.
async function readRows(file: File): Promise<Record<string, string>[]> {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, any>>(sheet, { defval: "", raw: false });
return rows.map((r) => {
const o: Record<string, string> = {};
for (const k of Object.keys(r)) o[k.trim()] = String(r[k] ?? "").trim();
return o;
});
}
// Find a column whose header matches one of the candidates (case/space-insensitive).
function findCol(headers: string[], candidates: string[]): string | null {
const map = new Map(headers.map((h) => [norm(h), h]));
for (const c of candidates) {
const hit = map.get(norm(c));
if (hit) return hit;
}
return null;
}
export default function GLImportDialog({
open, onOpenChange, companyId, accounts, onSuccess,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
companyId: string;
accounts: CoaAccount[];
onSuccess?: () => void;
}) {
const [parsing, setParsing] = useState(false);
const [importing, setImporting] = useState(false);
const [fileName, setFileName] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [lines, setLines] = useState<ParsedLine[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
// Account lookup: by code, then by normalized name.
const byCode = useMemo(() => {
const m = new Map<string, CoaAccount>();
for (const a of accounts) if (a.code) m.set(String(a.code).trim(), a);
return m;
}, [accounts]);
const byName = useMemo(() => {
const m = new Map<string, CoaAccount>();
for (const a of accounts) m.set(norm(stripLeadingCode(a.name)), a);
return m;
}, [accounts]);
const resolveAccount = (code: string, name: string): CoaAccount | null =>
(code && byCode.get(code.trim())) || byName.get(norm(name)) || null;
function reset() {
setLines([]); setError(null); setFileName("");
if (fileRef.current) fileRef.current.value = "";
}
async function onFile(file: File) {
setParsing(true); setError(null); setLines([]); setFileName(file.name);
try {
const rows = await readRows(file);
if (rows.length === 0) { setError("No rows found in the file."); return; }
const headers = Object.keys(rows[0]);
const col = {
id: findCol(headers, ["Id", "JournalId", "TransactionId"]),
date: findCol(headers, ["EntryDate", "Date", "PostDate", "TransactionDate"]),
amount: findCol(headers, ["Amount"]),
code: findCol(headers, ["AccountNumber", "GLAccountNumber", "AccountCode"]),
name: findCol(headers, ["GLAccountName", "AccountName", "GL Account", "Account"]),
memo: findCol(headers, ["PostingMemo", "JournalMemo", "Memo", "Description"]),
jmemo: findCol(headers, ["JournalMemo"]),
ref: findCol(headers, ["ReferenceNumber", "Reference", "CheckNumber"]),
payee: findCol(headers, ["PayeeName", "Payee", "Name"]),
isParent: findCol(headers, ["IsParent"]),
isCash: findCol(headers, ["IsCashPosting"]),
};
if (!col.amount || !col.name || !col.date) {
setError("This doesn't look like a Buildium G/L export — missing Date, Amount, or Account columns.");
return;
}
const isTrue = (v: string) => ["true", "1", "yes", "y"].includes(String(v).trim().toLowerCase());
const out: ParsedLine[] = [];
for (const r of rows) {
if (col.isParent && isTrue(r[col.isParent])) continue; // skip rollup parents
if (col.isCash && isTrue(r[col.isCash])) continue; // skip cash book (accrual only)
const amount = Number(String(r[col.amount]).replace(/[$,]/g, "")) || 0;
if (amount === 0) continue;
const txnId = col.id ? String(r[col.id]).trim() : "";
const date = toIso(r[col.date]) ?? "";
out.push({
txnId,
date,
acctCode: col.code ? String(r[col.code]).trim() : "",
acctName: stripLeadingCode(String(r[col.name])),
amount,
memo: col.memo ? String(r[col.memo]).trim() : "",
reference: col.ref ? String(r[col.ref]).trim() : "",
payee: col.payee ? String(r[col.payee]).trim() : "",
});
}
if (out.length === 0) { setError("No postable lines found (all rows were parents, cash-book, or zero)."); return; }
setLines(out);
} catch (e: any) {
setError(e?.message || "Failed to read file.");
} finally {
setParsing(false);
}
}
// Build the preview: group into balanced transactions, find unmatched accounts.
const preview = useMemo(() => {
if (lines.length === 0) return null;
const unmatched = new Map<string, { code: string; name: string; count: number }>();
const groups = new Map<string, ParsedLine[]>();
const dates: string[] = [];
for (const l of lines) {
const acct = resolveAccount(l.acctCode, l.acctName);
if (!acct) {
const key = l.acctCode || l.acctName;
const e = unmatched.get(key) ?? { code: l.acctCode, name: l.acctName, count: 0 };
e.count++; unmatched.set(key, e);
}
const gk = l.txnId || "OPENING";
(groups.get(gk) ?? groups.set(gk, []).get(gk)!).push(l);
if (l.date) dates.push(l.date);
}
const txns: Txn[] = [];
let typeTotals: Record<string, number> = {};
const typeById = new Map(accounts.map((a) => [a.id, a.type]));
for (const [gk, ls] of groups) {
const isOpening = gk === "OPENING";
const ext = isOpening ? `OPENING-${(dates.slice().sort()[0] ?? "").trim() || "open"}` : gk;
const tlines = ls
.map((l) => {
const acct = resolveAccount(l.acctCode, l.acctName);
if (!acct) return null;
if (typeById.get(acct.id)) {
const t = typeById.get(acct.id)!;
typeTotals[t] = (typeTotals[t] ?? 0) + l.amount;
}
return {
accountId: acct.id,
debit: l.amount > 0 ? l.amount : 0,
credit: l.amount < 0 ? -l.amount : 0,
description: l.memo,
};
})
.filter(Boolean) as Txn["lines"];
const first = ls[0];
const base = first.memo || "Buildium entry";
const desc = isOpening ? "Beginning balance"
: first.payee && !base.toLowerCase().includes(first.payee.toLowerCase())
? `${base} · ${first.payee}` : base;
const imbalance = ls.reduce((s, l) => s + l.amount, 0);
txns.push({
externalId: ext,
date: isOpening ? (dates.slice().sort()[0] ?? "") : (first.date || dates.slice().sort()[0] || ""),
description: desc.slice(0, 200),
reference: first.reference,
lines: tlines,
balanced: Math.abs(imbalance) < 0.005,
imbalance,
});
}
const sorted = dates.slice().sort();
return {
txns,
unmatched: [...unmatched.values()].sort((a, b) => b.count - a.count),
unbalanced: txns.filter((t) => !t.balanced),
typeTotals,
lineCount: lines.length,
dateRange: sorted.length ? [sorted[0], sorted[sorted.length - 1]] : ["", ""],
};
}, [lines, accounts]);
const canImport = preview && preview.unmatched.length === 0 && preview.unbalanced.length === 0 && !importing;
async function doImport() {
if (!preview) return;
setImporting(true); setError(null);
try {
// Skip transactions already imported (idempotent by external_id).
const exts = preview.txns.map((t) => t.externalId);
const existing = new Set<string>();
for (let i = 0; i < exts.length; i += 500) {
const { data } = await accounting.from("journal_entries")
.select("external_id")
.eq("company_id", companyId).eq("external_source", EXTERNAL_SOURCE)
.in("external_id", exts.slice(i, i + 500));
for (const r of data ?? []) if (r.external_id) existing.add(String(r.external_id));
}
const todo = preview.txns.filter((t) => !existing.has(t.externalId));
if (todo.length === 0) { toast.info("Everything in this file is already imported."); setImporting(false); return; }
// Insert headers, then lines (chunked).
const headerRows = todo.map((t) => ({
company_id: companyId, date: t.date, description: t.description,
reference: t.reference || null, external_source: EXTERNAL_SOURCE, external_id: t.externalId,
}));
const idByExt = new Map<string, string>();
for (let i = 0; i < headerRows.length; i += 200) {
const { data, error } = await accounting.from("journal_entries")
.insert(headerRows.slice(i, i + 200)).select("id,external_id");
if (error) throw error;
for (const r of data ?? []) idByExt.set(String(r.external_id), r.id);
}
const lineRows = todo.flatMap((t) =>
t.lines.map((l) => ({
journal_entry_id: idByExt.get(t.externalId)!,
account_id: l.accountId, debit: l.debit, credit: l.credit, description: l.description || null,
})),
);
for (let i = 0; i < lineRows.length; i += 500) {
const { error } = await accounting.from("journal_entry_lines").insert(lineRows.slice(i, i + 500));
if (error) throw error;
}
toast.success(`Imported ${todo.length} transactions (${lineRows.length} lines).`);
onSuccess?.();
onOpenChange(false);
reset();
} catch (e: any) {
setError(e?.message || "Import failed.");
} finally {
setImporting(false);
}
}
return (
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) reset(); }}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Import General Ledger</DialogTitle>
<DialogDescription>
Upload a Buildium G/L export (CSV or Excel). Transactions are grouped by Buildium Id and
posted as journal entries; re-importing the same file skips anything already imported.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<input
ref={fileRef} type="file" accept=".csv,.xlsx,.xls"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); }}
/>
<Button variant="outline" onClick={() => fileRef.current?.click()} disabled={parsing}>
{parsing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Upload className="h-4 w-4 mr-1" />}
{fileName || "Choose file…"}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Couldn't import</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{preview && (
<>
<div className="grid grid-cols-3 gap-3 text-sm">
<Stat label="Transactions" value={String(preview.txns.length)} />
<Stat label="Lines" value={String(preview.lineCount)} />
<Stat label="Date range" value={preview.dateRange[0] ? `${preview.dateRange[0]} → ${preview.dateRange[1]}` : "—"} />
</div>
<div className="rounded-md border p-3">
<p className="text-sm font-medium mb-2">Trial balance (debit + / credit )</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm tabular-nums">
{Object.entries(preview.typeTotals).sort().map(([t, v]) => (
<div key={t} className="flex justify-between"><span className="capitalize text-muted-foreground">{t}</span><span>{money(v, "USD")}</span></div>
))}
</div>
</div>
{preview.unmatched.length > 0 ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{preview.unmatched.length} account(s) not in this association's chart of accounts</AlertTitle>
<AlertDescription>
Add these to the chart of accounts (matching code or name), then re-upload:
<Table className="mt-2">
<TableHeader><TableRow><TableHead>Code</TableHead><TableHead>Name</TableHead><TableHead className="text-right">Lines</TableHead></TableRow></TableHeader>
<TableBody>
{preview.unmatched.slice(0, 15).map((u) => (
<TableRow key={u.code + u.name}><TableCell>{u.code || "—"}</TableCell><TableCell>{u.name}</TableCell><TableCell className="text-right">{u.count}</TableCell></TableRow>
))}
</TableBody>
</Table>
</AlertDescription>
</Alert>
) : preview.unbalanced.length > 0 ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{preview.unbalanced.length} transaction(s) don't balance</AlertTitle>
<AlertDescription>
e.g. Id {preview.unbalanced[0].externalId} is off by {money(preview.unbalanced[0].imbalance, "USD")}. Check the export.
</AlertDescription>
</Alert>
) : (
<Alert>
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>All accounts matched and every transaction balances.</AlertDescription>
</Alert>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={doImport} disabled={!canImport}>
{importing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Import{preview ? ` ${preview.txns.length}` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium mt-0.5">{value}</p>
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
v2.106.0 v2.107.0