Add a PDF button that generates a board-ready proposed budget: income and
expense tables (monthly/annual per account with totals), a summary, and a
headline banner for the per-unit monthly assessment. Uses the workbook's
projected figures (overrides/inflation/unit override) via jsPDF + autotable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-unit assessment (annual expenses / 12 / units) used the live association
unit count only. Add an override so the rate can use weighted/excluded units,
persisted on accounting.budget_workbooks.unit_override (null = live count). New
"# Units" control with a reset link; summary card and CSV use the effective count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Banking-page deposit form required a homeowner, blocking general/cash
deposits. Drop that requirement: a deposit still needs an income account, and
post_transaction_gl already books Dr Bank / Cr income when no customer is set
(vs. Dr Bank / Cr A/R with one). The Homeowner field is now optional with an
explicit "No homeowner (general deposit)" choice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/dashboard/budget-management now renders a Budget Workbook that pulls YTD
actuals (from the accounting GL, through a chosen month), derives a monthly
average (YTD/N), takes a per-line inflation %, and projects an annual budget
(avg x 12 x (1+infl)). Footer rolls up annual budget / 12 / # units = per-unit
monthly assessment. Income + expense sections; all imported fields editable.
- Standalone saved worksheet (accounting.budget_workbooks/_lines, RLS like accounts)
- "Push to Budget" writes projected/12 into accounting.budgets + budget_entries
- Uses accounting.accounts (synced with the Accounting dashboard COA) and paginates
the GL fetch (1000-row cap). Nav relabeled Budget Management -> Budget Workbook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bank register fetched transactions in a single request, which
PostgREST caps at 1000 rows. An imported bank account with a longer
register (e.g. Bridgewater Checking, ~1,500 rows) truncated, throwing
off the displayed running balance. Page through all rows ordered by a
stable key (date, created_at, id).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PostgREST caps each response at 1000 rows. The P&L and Balance Sheet
fetch every journal_entry_line for the company and aggregate client-side,
so any association with >1000 GL lines (e.g. an imported Buildium GL) only
saw the first 1000 — accounts whose activity fell past that point read as
$0 and were hidden, making reports look empty/out-of-balance. Fetch the GL
in 1000-row pages ordered by a stable key so every line is included.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manual journal entries post straight to the GL, but saving/deleting one
only invalidated the journal-entries list — so the Reports data (P&L /
Balance Sheet) and the General Ledger tab kept showing stale figures
until a manual refresh. Invalidate reports-data and gl-journal-entries
on save and delete too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Buildium-style GL grouped by account type with date-range filter,
account filter, beginning/ending balances, per-line running balance,
and grand totals. Reads posted journal entries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Recent Deposits" list to the Deposits page with Edit (date, bank
account, memo — GL re-posts via the deposit trigger) and Delete (releases the
deposited transactions/payments back to "awaiting deposit" and reverses the GL).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Journal Entries: add Edit (row + detail drawer) that loads an entry into the
form and saves changes (updates the entry and replaces its lines). Warns when
editing an auto-generated entry (may be overwritten by its source sync).
- Reports: add a Refresh button that re-fetches the underlying data so the
report reflects the latest GL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The auto-provision effect could fire twice and create duplicate "Current Year
Earnings" accounts (seen on Village Grove). Use the idempotent
coa_get_or_create RPC with a once-per-company ref guard, and match existing
name variants (Current Year Income / Net Income / Retained Earnings) so it
won't create redundant accounts. Balance sheet now also folds
"Current Year Income"/"Net Income" equity accounts into the Net Income line.
Removed the existing Village Grove duplicate (zero activity).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-provision "Retained Earnings" and "Current Year Earnings" equity accounts
per company so they appear as inputtable rows in the Chart of Accounts Opening
Balances grid. The Balance Sheet folds the posted "Current Year Earnings"
account into the Net Income line (already did this for Retained Earnings), so a
mid-year import can seed both equity figures without entering income/expense
detail, and Total Equity stays balanced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A real "Retained Earnings" equity account is postable via journal entries, but
the balance sheet listed it separately from the calculated "Retained Earnings
(prior years)" line, so JE adjustments looked like they had no effect there.
Now the posted RE-account balance is added into the "Retained Earnings (prior
years)" line (and removed from the generic equity list). Total Equity is
unchanged — it already included that account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The equity section already carried a permanent current-year earnings line
(income − expenses for the fiscal year to date); rename it from "Current Year
Earnings" to "Net Income" to match the Movement of Equity report and the
requested terminology. No calc change — still YTD income minus expenses,
included in Total Equity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Banking's generateCheckPDF opts omitted fieldPositions (and micr gaps), so the
per-field x/y position adjustments saved in Check Setup had no effect on checks
printed from Banking. Pass them through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Banking check printing hardcoded companyAddress: undefined, so the association
mailing/office address (accounting.companies.address, set in General Settings)
never printed below the name. Now loads and passes it. Bills check printing
falls back to the company name/address when no company_check_layouts payer is set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PrintChecksPage was still using the old generator (utils/checkPdfGenerator +
check_layouts), so it had the same bugs as Bill Approvals (wrong return address,
missing vendor address, ignored x/y offsets). Route it through
lib/checkPdf.generateCheckPDF using the accounting company (return address),
check_settings (layout/offsets/field positions/signature/MICR), the selected
bank account (routing/account), and vendor address as payee. DB side effects
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bulk "Print Checks" was rendering via a separate generator (utils/checkPdfGenerator
+ check_layouts), so the printed PDF ignored the accounting Check Setup: wrong
return address (generic company vs association name + mailing address), missing
vendor address, and ignored x/y field-position offsets.
Route the print through the same generator as the working sample
(lib/checkPdf.generateCheckPDF) fed by the accounting company (return address),
check_settings (style/offsets/field_positions/signature/MICR gaps), the chosen
bank account (routing/account), and the vendor address as payee. DB side effects
(checks, transactions, bill paid status, check numbering) unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Map tab now binds to the association's active map-type amenity (the
reservation map) instead of a separate table — pins edited here are the public
reservation map. Adds a per-pin amount ($/mo) field (GoogleMapPicker showAmount
prop, backward-compatible). Shows a notice when no Map amenity is active and an
amenity picker when more than one exists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Sync to Public Page" button that creates/updates one rental_calendar
amenity per lot (name, size · rate · availability in the description, rate in
booking_config, shown on the public page). Idempotent via amenities.source_rv_lot_id;
removes synced amenities for deleted lots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Waitlist now captures a free-form Size (requested_size column) in place of
the type field in the internal form/table.
- Lot type selector (add + bulk edit) expanded to RV, Boat, Travel Trailer,
Fifth Wheel, Camper, Car, Truck, Trailer, Other.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Map tab that reuses the reservation-map pin picker. Staff drop/label
pins per lot and optionally link a directory unit (pin.linked_amenity_id =
unit id). Config persists per association in rv_boat_lot_maps.
GoogleMapPicker generalized with optional linkLabel + allowLinkAnyStatus
(defaults preserve the amenity reservation-map behavior).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Color-code each rental's insurance status like vendors: red when expired,
amber when expiring within 90 days (with days remaining), green otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4. Mirror the vendor insurance request flow for RV/boat renters:
- Migration: insurance fields on rv_boat_lot_rentals + rv_renter_insurance_requests
table + token-scoped lookup/submit SECURITY DEFINER RPCs (granted to anon).
- Edge fn send-rv-renter-insurance-request emails the renter a secure link
(reuses the vendor-insurance-request email template).
- Public page /rv-insurance/:token to submit carrier/policy/expiration + COI upload.
- "Request Insurance" button on each active rental + insurance status display.
DB RPCs verified end-to-end (rolled-back txn): submit matches token, updates the
rental, marks the request submitted. Edge function deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2: rental form now has optional Owner and Unit selectors (auto-fills
renter info/unit from the chosen owner), persisting owner_id/unit_id.
Phase 3: add RvRentalVacateButton on Owner and Unit profiles — shows only
when that owner/unit has an active/vacating RV-boat rental, and toggles the
rental status between active (renting) and vacating. Active Rentals tab now
includes vacating rentals with a badge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add row checkboxes + select-all to the Lots and Active Rentals tables with a
selection bar. Bulk edit lots (type, status, monthly rate) and bulk delete;
bulk edit rentals (status incl. end, owner/renter flag, monthly rate).
Phase 1 of the RV/Boat lots enhancements.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Manage Calendars" action (rental/meeting modes) that opens the full
AmenitiesManager for a chosen association in a dialog — so staff can create/
configure rental calendars, manage their bookings, and block/unblock
availability without going to the association overview page. Adding bookings
inline was already supported in these modes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The amount field had min="0.01", so the browser's native form validation
blocked a $0.00 charge before the JS check ran. Lower to min="0" so a $0.00
charge/note can be submitted; the JS still restricts $0 to charge-type
entries (payments require a positive amount).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relax the amount validation so a Charge/Expense/WriteOff can be posted with a
$0.00 amount (used as a note/memo on the ledger). Payments and prepayments
still require a positive amount; negatives remain blocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Owner Ledger page (/dashboard/owner-ledger) was a route with no menu
entry, so it was only reachable by URL. Add it to the Receivables group so
staff can reach it (and the new Add Note action).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the homeowner-facing exclusion — ledger notes now appear on the
homeowner ledger and statements views as intended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exclude transaction_type 'note' from the homeowner ledger and statements
pages so internal staff notes aren't shown to owners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "Add Note" action that records a memo on an owner's ledger as a
$0.00 entry (transaction_type 'note', debit/credit 0). Notes work on any
ledger including paid-up ($0.00 balance) accounts, render as styled memo
rows, and don't affect the running balance. The accounting sync treats a
zero entry as a no-op, so no phantom AR is created.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add row checkboxes (with per-category select-all) to the accounting Chart of
Accounts. A selection bar exposes Edit/Delete/Clear. Bulk edit applies any of
type, parent account, bank flag, reserve flag to all selected (each field has
a "No change" option); bulk delete removes selected accounts. Mirrors the
existing bulk-edit UX on the per-association chart_of_accounts page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list loaded all bills (~1,150) then fetched approvers with
.in("bill_id", [all ids]); that request URL exceeds server limits and
fails silently, so approvalsByBill was always empty. Fetch the small
bill_approvals table directly (RLS scopes per role) and group locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restrict marking a bill paid to admins only, per requirement.
- BillDetailPage: gate Mark Paid / Mark Unpaid on useAuth().isAdmin
(was only hidden in board view).
- BillApprovalsPage: gate Print Checks (which sets bills to paid) on isAdmin.
- Migration: BEFORE INSERT/UPDATE trigger enforce_admin_marks_bill_paid()
rejects the transition into 'paid' for authenticated non-admins. Service-role
/ system contexts (auth.uid() null: buildium-sync, accounting triggers,
autopay) remain allowed. Verified: admin allowed, non-admin blocked (23514).
Note: the approver column showing "None" in production is a stale-deploy
issue — the DB column was renamed vendor_name->approver_name (Jun 4) but
prod still ran code querying vendor_name. Deploying current main resolves it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the bill_approvals approver column from vendor_name to approver_name
across UI, hooks, types, and buildium-sync. Add a Chart of Accounts import
dialog (XLSX upload + column mapping + type-alias normalization) and a
"denied" status color.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Single vendor source (public.vendors) and single COA source (accounting.accounts)
across both bill flows:
- Forward sync now carries public.bills.expense_account_id into the mirrored
accounting.bill_items.account_id (when it resolves to accounting.accounts).
- Reverse trigger flows a GL change on a mirrored accounting bill line back to
public.bills.expense_account_id (loop-guarded).
- New public.ensure_accounting_vendor RPC resolves a chosen public vendor to its
accounting.vendors row; one-time backfill of mirrored line account_id.
- BillApprovalsPage GL pickers now use ChartOfAccountsDropdown (accounting.accounts).
- AccountingBillsPage vendor picker now lists public.vendors scoped to the
company's association and maps to accounting.vendors on save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI
- Dashboard BillApprovalsCard: render approver name chips (color-coded
by vote status) per pending bill instead of leaving the approver
identity invisible.
- BillDetailPage: collapse the duplicate "Requested Approvers" card
into the existing "Approvers" table. Approve/deny handler now stamps
approved_by = auth.uid() for audit trail.
- MasterBoardDashboardPage: the "pending approvals for me" count was
filtering on a non-existent bill_approvals.approver_user_id column.
Replaced with a board_members.member_name -> bill_approvals.approver_name
join (matches the RLS policy).
- BillApprovalRequestDialog + AIInvoiceParserPage: bill_approvals inserts
now set created_by.
Database
- Rename public.bill_approvals.vendor_name -> approver_name. RLS policies
auto-rewritten by ALTER TABLE RENAME COLUMN; the column was misnamed
(it stores the approver's board-member name, never a vendor).
- Restore the bill_approval_email_tokens table + lookup_/record_
bill_approval_by_token RPCs. The original 20260520153409 migration
was never applied successfully; rewrote it to use approver_name and
to populate approved_by/created_by from board_members.user_id on
token-driven votes. Added the v2 migration that matches the live DB
state.
- accounting trigger: void on accounting.bills cascades to
public.bills.status='cancelled' (existing forward sync then drops the
accounting row per accounting.bill_should_mirror).
Edge function
- send-transactional-email: add bill-approval-request and
bill-approval-vote-invite templates (caller paths in BillApprovalsPage
+ send-bill-approval-invites referenced templates that weren't in the
registry, so every email 404'd). Restored the local copies of
election-invite, board-vote-invite, and the missing registry.ts so the
repo matches what's deployed. Deployed to send-transactional-email v35.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each association now owns an independent set of chart_of_accounts rows. Two
associations can both have a "5000" meaning different things, edited
independently — previously buildium rows were shared across associations via the
association_ids[] array, so editing one association's number edited it for all.
- Data migration: split each shared buildium row into one row per association in
its association_ids (excluding nulls and ids of deleted associations). The
original row stays as the per-association row for its own association_id;
clones are added for the others — nothing is deleted, so no FK dangles.
94 -> 370 buildium rows. References repointed by each record's association
(bills, budgets, owner_ledger_entries, vendor_coa_mappings, units, vendors,
journal_entries; budget_actuals_monthly is a view). parent_account_id remapped
same-association; orphan-parent children become top-level. Pristine backup in
public._coa_perassoc_backup. Ran in one transaction with in-line verification.
- Uniqueness: drop (account_number, accounting_system); add
UNIQUE(association_id, account_number) WHERE accounting_system <> 'platform'
(platform rows mirror accounting.accounts and carry blank/dup codes). This also
finally backs the buildium importers' existing onConflict target.
- Keep association_ids as a single-element mirror of association_id during the
transition so the admin COA page and direct array-contains callers keep working.
- App: fetchChartOfAccounts scopes buildium/zoho by association_id when an
association is given (was system-wide). Importers and sync_account_to_public_coa
were already per-association; no change needed.
Verified on live data: 370 singleton-array rows across 12 associations, zero
intra-association duplicates, zero cross-association references or parents, and a
live two-association "5000" independence test (create/rename/isolate) passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deposits no longer force the credit side through Undeposited Funds — the
structural cause of negative Undeposited balances. A deposit can now credit any
account(s): interest income, a refund, an insurance reimbursement, cash straight
to the bank, etc.
- Schema: add accounting.deposit_lines (deposit_id, company_id, account_id,
amount, memo) for the credit side, plus deposits.source_account_id as a
single-source fallback. RLS mirrors deposits (staff + company member).
- post_deposit_gl: Dr bank for the total; Cr each deposit_lines row's account
for its amount; no lines -> Cr source_account_id; neither -> Cr Undeposited
Funds (backward compatible — existing deposits stay Dr Bank / Cr Undeposited).
Remainder safety net keeps the entry balanced. New trg_acct_deposit_line_gl
re-posts when lines change (header trigger fires before lines exist).
- Make Deposit page: GL-driven submit writes the deposit header + deposit_lines
and marks selected payments deposited. Adds an "Other deposit lines" grid
(account + amount + memo) alongside the existing Undeposited selection, with a
running grand total and a soft guard against over-crediting Undeposited.
Drops the old bank/Undeposited register-transaction inserts and manual balance
pokes (never exercised in production; carried a money-in sign bug). Deposits
are GL-only, consistent with the sync-created deposits already in the DB.
Verified Dr/Cr for single-source and multi-line scenarios against the live GL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under accrual, a vendor expense is recognized once — when the bill is entered
(Dr Expense / Cr A/P). Paying a bill must only relieve the liability
(Dr A/P / Cr Bank) and never re-hit the expense account. This hardens the
existing Pay Bills flow against re-recognition and double counting.
- Strict bill matching: extract a pure, dependency-free matcher into
lib/billMatch.ts (debitMatchesBill/matchOpenBills) — same vendor, status
open/overdue/partially_paid, amount within $0.01 of remaining (or <= remaining
for partials), debit date within ±30 days of due/issue date. Unit-tested in
billMatch.test.ts (covers the identical-recurring-charge regression).
- AccountingBankingPage.saveTx uses the strict rule (was "any open bill"), so a
thrice-paid identical charge only clears the in-window bill.
- Bank-feed categorizer (bulkSetCategory) matches open bills before assigning an
expense COA: single match clears A/P + links the bill; multi-match is skipped
with a prompt to resolve in Pay Bills; no match categorizes as a direct expense.
- DB guard: add accounting.transactions.bill_id (FK -> bills) and CHECK
chk_bill_payment_no_coa (bill_id IS NULL OR coa_account_id IS NULL) so a
bill-linked payment can never carry an expense category. Both writers set
bill_id on single-bill payments; partial payments now write partially_paid.
- One-time cleanup: clear coa_account_id on Ashley Manor's 8 double-counted bill
payments ($2,198.98) so the GL reposts them as Dr A/P / Cr Bank.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add Sales Receipts page (dashboard/accounting/sales-receipts): records a
cash sale (name, address, income account, price, qty) — deposits and books
income in one step via a transaction. New accounting.sales_receipts table.
- Sync chart of accounts to the accounting dashboard: mirror accounting.accounts
into public.chart_of_accounts for platform associations (one-way, same id) so
Bill Approvals and every COA consumer use the dashboard's accounts. Legacy
rows hidden; Bill Approvals made system-aware.
- Vendor-expense recognition: a vendor payment with no bill now books the
expense directly (Dr Expense / Cr Bank) on the payment date instead of going
to A/P; payments against open bills still clear A/P (applied FIFO). Backfill
reclassifies unbilled payments stuck in A/P. Expense Summary report made
GL-driven so it follows the same rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the "Authorized Signer" text from the payee/address block, moves the
"AUTHORIZED SIGNATURE" label to just below the signature line, and raises the
signature image cap (0.42 -> 0.65 in) so the signature renders full size in
the now-clear space above the line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bill-payment check generator (checkPdf.ts) now supports per-field
position offsets (X/Y inches) and show/hide for every element — check
number, return address, bank block, date, pay-to, amounts, payee address,
memo, signature, and MICR — layered on the existing layout (defaults render
identically). Edited in Settings → Check Setup ("Element positions").
Stored in accounting.check_settings.field_positions (jsonb). Also replaced a
"→" with ">" in the MICR placeholder to avoid the UTF-16 spacing artifact.
Migration applied: check_settings.field_positions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- P&L and Balance Sheet account rows are now clickable and open the
General Ledger filtered to that account (its transaction list for the COA).
Adds StructuredRow.accountId, threaded from the report builders, with a
clickable row in StructuredTable and an initialAccountId prop on
GeneralLedgerReport.
- Bids/Quotes: PDF upload on the New Bid/Quote dialog (bid-attachments
bucket + document_url/document_name columns), shown as a link on the
bid Details dialog.
Migration applied: bids_quotes_pdf_attachments.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bill/vendor payments:
- Bill "Pay" and bank-register vendor debits now clear Accounts Payable
(Dr A/P / Cr Bank) instead of re-debiting the expense, which had
double-counted expenses in the P&L and never cleared A/P. The expense
account is kept as a display-only category on the payment.
Checks:
- Bill-payment checks now print the return address (payer name/address)
from the company check layout (was hardcoded blank).
- Per-segment MICR gap control (check# / routing / account) in both check
generators, wired to Check Setup (bill payments) and the Check Layout
editor (Print Checks). New columns: check_settings.micr_gap_1/2 and
check_layouts/company_check_layouts.micr_gap_1/2.
Accounting Dashboard / Financial Overview:
- Date-range selector (presets + custom) drives the charts, top expenses,
and recent transactions.
- PDF title renamed to "Financial Overview" and shows the period.
- Fixed amounts rendering as "$ 5 0 0. 0 0": the U+2212 minus sign forced
jsPDF into a UTF-16 fallback; replaced with an ASCII hyphen (also in the
report PDF out-of-balance line).
DB migrations applied to the project: repost_gl_on_line_item_change,
budget_actuals_accrual_owner_income, add_micr_gap_controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a dashboard PDF generator that renders the metrics, an Income & Expenses
bar chart, a Top Expenses donut, the invoices overview, and recent transactions
as native PDF vector graphics + selectable text (not a screenshot), using the
shared branded header/footer. Wire an "Export PDF" button into the dashboard
header (association logo, ACM fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>