mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
|
||||
-- Per-bill, per-board-member secure approval tokens for email approve/deny
|
||||
-- Per-bill, per-board-member secure approval tokens for email approve/deny.
|
||||
-- Updated to use approver_name (post bill_approvals.vendor_name rename) and
|
||||
-- populate approved_by/created_by from the board member's user_id.
|
||||
CREATE TABLE IF NOT EXISTS public.bill_approval_email_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bill_id uuid NOT NULL REFERENCES public.bills(id) ON DELETE CASCADE,
|
||||
@@ -18,12 +20,15 @@ CREATE INDEX IF NOT EXISTS idx_baet_token ON public.bill_approval_email_tokens(t
|
||||
CREATE INDEX IF NOT EXISTS idx_baet_bill ON public.bill_approval_email_tokens(bill_id);
|
||||
|
||||
ALTER TABLE public.bill_approval_email_tokens ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS "Staff manage bill approval tokens" ON public.bill_approval_email_tokens;
|
||||
CREATE POLICY "Staff manage bill approval tokens" ON public.bill_approval_email_tokens
|
||||
TO authenticated
|
||||
USING (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'))
|
||||
WITH CHECK (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'));
|
||||
|
||||
-- Lookup RPC for the public page (anonymous-friendly, security definer)
|
||||
-- Lookup RPC for the public page (anonymous-friendly, security definer).
|
||||
-- The "vendor_name" returned here is the bill's vendor (from public.vendors),
|
||||
-- not the approver.
|
||||
CREATE OR REPLACE FUNCTION public.lookup_bill_approval_by_token(p_token uuid)
|
||||
RETURNS TABLE (
|
||||
bill_id uuid,
|
||||
@@ -54,14 +59,20 @@ AS $$
|
||||
$$;
|
||||
GRANT EXECUTE ON FUNCTION public.lookup_bill_approval_by_token(uuid) TO anon, authenticated;
|
||||
|
||||
-- Record action via token
|
||||
CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(p_token uuid, p_action text, p_notes text DEFAULT NULL)
|
||||
-- Record action via token. Anonymous callers identify via the secret token;
|
||||
-- the linked board_member.user_id (if any) is the audit identity we record.
|
||||
CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(
|
||||
p_token uuid,
|
||||
p_action text,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
tok record;
|
||||
bill record;
|
||||
tok bill_approval_email_tokens%ROWTYPE;
|
||||
bill bills%ROWTYPE;
|
||||
v_user_id uuid;
|
||||
BEGIN
|
||||
IF p_action NOT IN ('approved','denied') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid action');
|
||||
@@ -72,7 +83,10 @@ BEGIN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid token');
|
||||
END IF;
|
||||
IF tok.acted_at IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('ok', false, 'already_voted', true, 'error', 'You have already responded', 'action', tok.action);
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false, 'already_voted', true,
|
||||
'error', 'You have already responded', 'action', tok.action
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO bill FROM bills WHERE id = tok.bill_id;
|
||||
@@ -80,15 +94,22 @@ BEGIN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Bill not found');
|
||||
END IF;
|
||||
|
||||
INSERT INTO bill_approvals (bill_id, association_id, vendor_name, amount, status, notes, approved_date)
|
||||
VALUES (tok.bill_id, bill.association_id, COALESCE(tok.member_name,'Board Member'), bill.amount,
|
||||
p_action, p_notes, CURRENT_DATE);
|
||||
SELECT user_id INTO v_user_id FROM board_members WHERE id = tok.board_member_id;
|
||||
|
||||
UPDATE bills SET status = p_action, updated_at = now() WHERE id = tok.bill_id;
|
||||
INSERT INTO bill_approvals (
|
||||
bill_id, association_id, approver_name, amount,
|
||||
status, notes, approved_date, approved_by, created_by
|
||||
) VALUES (
|
||||
tok.bill_id, bill.association_id, COALESCE(tok.member_name, 'Board Member'), bill.amount,
|
||||
p_action, p_notes, CURRENT_DATE, v_user_id, v_user_id
|
||||
);
|
||||
|
||||
-- public.bills.status is derived by the recompute_bill_status_from_approvals
|
||||
-- trigger from the votes — don't force-update here.
|
||||
|
||||
UPDATE bill_approval_email_tokens
|
||||
SET acted_at = now(), action = p_action, notes = p_notes
|
||||
WHERE token = p_token;
|
||||
SET acted_at = now(), action = p_action, notes = p_notes
|
||||
WHERE token = p_token;
|
||||
|
||||
RETURN jsonb_build_object('ok', true, 'action', p_action);
|
||||
END;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
-- =============================================================================
|
||||
-- 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();
|
||||
@@ -0,0 +1,10 @@
|
||||
-- The forward sync added in 20260604191117 duplicates the pre-existing
|
||||
-- accounting.sync_public_bill / trg_acct_sync_public_bill chain (which also
|
||||
-- creates accounting.bill_items, posts the GL, and auto-creates
|
||||
-- accounting.vendors). Removing the redundant copy.
|
||||
-- The reverse trigger (trg_acct_bill_status_back_sync) and the external lookup
|
||||
-- index are kept.
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_public_bills_sync_accounting ON public.bills;
|
||||
DROP FUNCTION IF EXISTS public.tg_bills_sync_to_accounting();
|
||||
DROP FUNCTION IF EXISTS accounting.sync_public_bill_to_accounting(uuid);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- bill_approvals.vendor_name is misnamed — it actually stores the approver's
|
||||
-- person name (e.g. board member name), not a vendor. Renaming for clarity.
|
||||
-- RENAME COLUMN automatically rewrites references in dependent RLS policies,
|
||||
-- triggers, and indexes.
|
||||
ALTER TABLE public.bill_approvals RENAME COLUMN vendor_name TO approver_name;
|
||||
COMMENT ON COLUMN public.bill_approvals.approver_name IS
|
||||
'Display name of the approver (typically a board_members.member_name). Previously named vendor_name.';
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Per-bill, per-board-member secure approval tokens for email approve/deny.
|
||||
-- Recreated to use approver_name (post-rename) and populate approved_by/created_by
|
||||
-- from the linked board member's user_id (audit trail for who clicked the email link).
|
||||
-- The earlier 20260520153409 migration was never applied successfully; this is the
|
||||
-- corrected idempotent version.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.bill_approval_email_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bill_id uuid NOT NULL REFERENCES public.bills(id) ON DELETE CASCADE,
|
||||
board_member_id uuid NOT NULL REFERENCES public.board_members(id) ON DELETE CASCADE,
|
||||
token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
|
||||
email text NOT NULL,
|
||||
member_name text,
|
||||
sent_at timestamptz,
|
||||
acted_at timestamptz,
|
||||
action text CHECK (action IN ('approved','denied')),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (bill_id, board_member_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_baet_token ON public.bill_approval_email_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_baet_bill ON public.bill_approval_email_tokens(bill_id);
|
||||
|
||||
ALTER TABLE public.bill_approval_email_tokens ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS "Staff manage bill approval tokens" ON public.bill_approval_email_tokens;
|
||||
CREATE POLICY "Staff manage bill approval tokens" ON public.bill_approval_email_tokens
|
||||
TO authenticated
|
||||
USING (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'))
|
||||
WITH CHECK (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'));
|
||||
|
||||
-- Lookup RPC for the public page (anonymous-friendly, security definer).
|
||||
-- Note: the "vendor_name" returned here is the bill's vendor (from public.vendors),
|
||||
-- NOT the approver. That naming is for the public landing page UI.
|
||||
CREATE OR REPLACE FUNCTION public.lookup_bill_approval_by_token(p_token uuid)
|
||||
RETURNS TABLE (
|
||||
bill_id uuid,
|
||||
member_name text,
|
||||
member_email text,
|
||||
acted_at timestamptz,
|
||||
action text,
|
||||
vendor_name text,
|
||||
invoice_number text,
|
||||
bill_date date,
|
||||
due_date date,
|
||||
amount numeric,
|
||||
description text,
|
||||
attachment_url text,
|
||||
bill_status text,
|
||||
association_name text
|
||||
)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
SELECT t.bill_id, t.member_name, t.email AS member_email, t.acted_at, t.action,
|
||||
v.name AS vendor_name, b.invoice_number, b.bill_date, b.due_date, b.amount,
|
||||
b.description, b.attachment_url, b.status AS bill_status, a.name AS association_name
|
||||
FROM bill_approval_email_tokens t
|
||||
JOIN bills b ON b.id = t.bill_id
|
||||
LEFT JOIN vendors v ON v.id = b.vendor_id
|
||||
LEFT JOIN associations a ON a.id = b.association_id
|
||||
WHERE t.token = p_token;
|
||||
$$;
|
||||
GRANT EXECUTE ON FUNCTION public.lookup_bill_approval_by_token(uuid) TO anon, authenticated;
|
||||
|
||||
-- Record action via token. Anonymous callers identify via the secret token;
|
||||
-- the linked board_member.user_id (if any) is the audit identity we record.
|
||||
CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(
|
||||
p_token uuid,
|
||||
p_action text,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
tok bill_approval_email_tokens%ROWTYPE;
|
||||
bill bills%ROWTYPE;
|
||||
v_user_id uuid;
|
||||
BEGIN
|
||||
IF p_action NOT IN ('approved','denied') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid action');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO tok FROM bill_approval_email_tokens WHERE token = p_token;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid token');
|
||||
END IF;
|
||||
IF tok.acted_at IS NOT NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false, 'already_voted', true,
|
||||
'error', 'You have already responded', 'action', tok.action
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO bill FROM bills WHERE id = tok.bill_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Bill not found');
|
||||
END IF;
|
||||
|
||||
SELECT user_id INTO v_user_id FROM board_members WHERE id = tok.board_member_id;
|
||||
|
||||
INSERT INTO bill_approvals (
|
||||
bill_id, association_id, approver_name, amount,
|
||||
status, notes, approved_date, approved_by, created_by
|
||||
) VALUES (
|
||||
tok.bill_id, bill.association_id, COALESCE(tok.member_name, 'Board Member'), bill.amount,
|
||||
p_action, p_notes, CURRENT_DATE, v_user_id, v_user_id
|
||||
);
|
||||
|
||||
-- Note: the recompute_bill_status_from_approvals trigger on bill_approvals
|
||||
-- will derive public.bills.status from the votes (denied / approved when
|
||||
-- threshold met). We do NOT force-update bills.status here.
|
||||
|
||||
UPDATE bill_approval_email_tokens
|
||||
SET acted_at = now(), action = p_action, notes = p_notes
|
||||
WHERE token = p_token;
|
||||
|
||||
RETURN jsonb_build_object('ok', true, 'action', p_action);
|
||||
END;
|
||||
$$;
|
||||
GRANT EXECUTE ON FUNCTION public.record_bill_approval_by_token(uuid, text, text) TO anon, authenticated;
|
||||
Reference in New Issue
Block a user