-- ============================================================================= -- 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();