Files
acmcc/supabase/migrations/20260604191117_sync_public_bills_to_accounting_bills.sql
T
admin 2c723410a4 Bill approvals: surface approvers, fix email path, schema cleanup
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>
2026-06-04 17:17:05 -04:00

212 lines
7.6 KiB
PL/PgSQL

-- =============================================================================
-- Two-way sync: public.bills <-> accounting.bills
--
-- Forward: public.bills changes -> upsert accounting.bills (linked via
-- external_source='acmacc_bill', external_id=public.bills.id::text).
-- Approvals reach accounting indirectly: the existing
-- recompute_bill_status_from_approvals trigger flips
-- public.bills.status, which then fires the forward trigger below.
--
-- Reverse: accounting.bills.status='void' -> public.bills.status='cancelled'.
-- accounting.bills paid sync (paid_amount >= total) is already
-- handled by accounting.sync_accounting_bill_paid; left untouched.
--
-- Note: the forward sync function and trigger added here are dropped by the
-- next migration (drop_redundant_bills_forward_sync) because the existing
-- accounting.sync_public_bill / trg_acct_sync_public_bill chain already does
-- the forward direction more thoroughly (vendor auto-create, bill_items,
-- GL posting). The reverse trigger and index stay.
-- =============================================================================
-- Fast lookups for the external link used by both sync directions.
CREATE INDEX IF NOT EXISTS accounting_bills_external_lookup_idx
ON accounting.bills (external_source, external_id);
-- ---------------------------------------------------------------------------
-- Forward sync function
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION accounting.sync_public_bill_to_accounting(_public_bill_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'accounting'
AS $$
DECLARE
pb public.bills%ROWTYPE;
v_company_id uuid;
v_vendor_id uuid;
v_status accounting.bill_status;
v_existing accounting.bills%ROWTYPE;
v_number text;
BEGIN
SELECT * INTO pb FROM public.bills WHERE id = _public_bill_id;
IF NOT FOUND THEN
RETURN;
END IF;
-- Resolve accounting company for the association (try ensure_ then fallback)
BEGIN
v_company_id := accounting.ensure_company_for_association(pb.association_id);
EXCEPTION WHEN OTHERS THEN
BEGIN
v_company_id := accounting.company_id_for_association(pb.association_id);
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'accounting sync: cannot resolve company for association % (bill %): %',
pb.association_id, pb.id, SQLERRM;
RETURN;
END;
END;
IF v_company_id IS NULL THEN
RAISE WARNING 'accounting sync: no company resolved for association % (bill %)',
pb.association_id, pb.id;
RETURN;
END IF;
-- Status mapping
v_status := CASE pb.status
WHEN 'approved' THEN 'open'::accounting.bill_status
WHEN 'paid' THEN 'paid'::accounting.bill_status
WHEN 'denied' THEN 'void'::accounting.bill_status
WHEN 'cancelled' THEN 'void'::accounting.bill_status
WHEN 'pending' THEN 'draft'::accounting.bill_status
WHEN 'draft' THEN 'draft'::accounting.bill_status
ELSE 'draft'::accounting.bill_status
END;
SELECT * INTO v_existing
FROM accounting.bills
WHERE external_source = 'acmacc_bill' AND external_id = pb.id::text;
-- Look up matching accounting vendor (NULL if none — column is nullable)
IF pb.vendor_id IS NOT NULL THEN
SELECT id INTO v_vendor_id
FROM accounting.vendors
WHERE company_id = v_company_id
AND external_source = 'acmacc_vendor'
AND external_id = pb.vendor_id::text
LIMIT 1;
END IF;
v_number := COALESCE(NULLIF(pb.invoice_number, ''), 'BILL-' || substr(pb.id::text, 1, 8));
IF v_existing.id IS NULL THEN
-- Only materialize the accounting row once the public bill has reached
-- a state worth posting; skip pure-draft creations.
IF pb.status NOT IN ('approved', 'paid', 'denied', 'cancelled') THEN
RETURN;
END IF;
INSERT INTO accounting.bills (
company_id, vendor_id, number, issue_date, due_date,
status, subtotal, tax, total, paid_amount, notes,
external_source, external_id
) VALUES (
v_company_id, v_vendor_id, v_number, pb.bill_date, pb.due_date,
v_status, pb.amount, 0, pb.amount, pb.amount_paid, pb.notes,
'acmacc_bill', pb.id::text
);
ELSE
UPDATE accounting.bills SET
vendor_id = COALESCE(v_vendor_id, vendor_id),
number = v_number,
issue_date = pb.bill_date,
due_date = pb.due_date,
status = v_status,
subtotal = pb.amount,
total = pb.amount,
paid_amount = pb.amount_paid,
notes = pb.notes,
updated_at = now()
WHERE id = v_existing.id
AND (
v_existing.vendor_id IS DISTINCT FROM COALESCE(v_vendor_id, v_existing.vendor_id)
OR v_existing.number IS DISTINCT FROM v_number
OR v_existing.issue_date IS DISTINCT FROM pb.bill_date
OR v_existing.due_date IS DISTINCT FROM pb.due_date
OR v_existing.status IS DISTINCT FROM v_status
OR v_existing.subtotal IS DISTINCT FROM pb.amount
OR v_existing.total IS DISTINCT FROM pb.amount
OR v_existing.paid_amount IS DISTINCT FROM pb.amount_paid
OR v_existing.notes IS DISTINCT FROM pb.notes
);
END IF;
END;
$$;
-- ---------------------------------------------------------------------------
-- Forward trigger
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.tg_bills_sync_to_accounting()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
BEGIN
PERFORM accounting.sync_public_bill_to_accounting(NEW.id);
EXCEPTION WHEN OTHERS THEN
-- Never break the originating bill write because of a sync failure.
RAISE WARNING 'public.bills -> accounting.bills sync failed for %: %', NEW.id, SQLERRM;
END;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_public_bills_sync_accounting ON public.bills;
CREATE TRIGGER trg_public_bills_sync_accounting
AFTER INSERT OR UPDATE OF
status, amount, amount_paid, bill_date, due_date, vendor_id, invoice_number, notes
ON public.bills
FOR EACH ROW
EXECUTE FUNCTION public.tg_bills_sync_to_accounting();
-- ---------------------------------------------------------------------------
-- Reverse trigger: accounting.bills.status='void' -> public.bills.status='cancelled'
-- (paid back-sync is already done by accounting.sync_accounting_bill_paid)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION accounting.tg_accounting_bill_status_back_sync()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'accounting'
AS $$
DECLARE
_public_id uuid;
BEGIN
IF NEW.external_source IS DISTINCT FROM 'acmacc_bill' OR NEW.external_id IS NULL THEN
RETURN NEW;
END IF;
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN
RETURN NEW;
END IF;
BEGIN
_public_id := NEW.external_id::uuid;
EXCEPTION WHEN OTHERS THEN
RETURN NEW;
END;
IF NEW.status = 'void' THEN
UPDATE public.bills
SET status = 'cancelled', updated_at = now()
WHERE id = _public_id
AND status NOT IN ('paid', 'cancelled');
UPDATE public.bill_approvals
SET status = 'cancelled', updated_at = now()
WHERE bill_id = _public_id
AND status IS DISTINCT FROM 'cancelled';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_acct_bill_status_back_sync ON accounting.bills;
CREATE TRIGGER trg_acct_bill_status_back_sync
AFTER UPDATE OF status ON accounting.bills
FOR EACH ROW
EXECUTE FUNCTION accounting.tg_accounting_bill_status_back_sync();