Merge remote-tracking branch 'origin/main' into accounting-sales-receipts-coa-sync-expenses

# Conflicts:
#	src/integrations/supabase/types.ts
#	src/pages/BillDetailPage.tsx
This commit is contained in:
2026-06-06 23:24:45 -04:00
17 changed files with 789 additions and 84 deletions
@@ -0,0 +1,59 @@
import * as React from 'npm:react@18.3.1'
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
import type { TemplateEntry } from './registry.ts'
const SITE_NAME = 'Avria Community Management'
interface Props {
recipientName?: string
associationName?: string
link?: string
}
const BillApprovalRequestEmail = ({ recipientName, associationName, link }: Props) => (
<Html lang="en" dir="ltr">
<Head />
<Preview>Bills awaiting your approval{associationName ? `${associationName}` : ''}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={brandBar} />
<Heading style={h1}>Bills awaiting your approval</Heading>
<Text style={text}>Hello {recipientName || 'Board Member'},</Text>
<Text style={text}>
One or more bills have been uploaded{associationName ? ` for ${associationName}` : ''} that
require your review and approval. You will receive a separate email for each individual bill
with secure approve / deny links, or you can review them together using the dashboard link below.
</Text>
{link && <Button href={link} style={button}>Review bills</Button>}
<Text style={smallText}>
You may also have received individual emails for each bill those contain one-click approve and
deny buttons tied uniquely to your seat.
</Text>
<Text style={footer}>
If you weren't expecting this email, please contact your association manager. — {SITE_NAME}
</Text>
</Container>
</Body>
</Html>
)
export const template = {
component: BillApprovalRequestEmail,
subject: (d: Record<string, any>) =>
`Bills awaiting your approval${d.associationName ? ` — ${d.associationName}` : ''}`,
displayName: 'Bill approval request (summary)',
previewData: {
recipientName: 'Jane Director',
associationName: 'Sunset Hills HOA',
link: 'https://avria.cloud/dashboard/bill-approvals',
},
} satisfies TemplateEntry
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '14px 0 0' }
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
@@ -0,0 +1,119 @@
import * as React from 'npm:react@18.3.1'
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
import type { TemplateEntry } from './registry.ts'
const SITE_NAME = 'Avria Community Management'
interface Props {
memberName?: string
associationName?: string
vendorName?: string
invoiceNumber?: string
amount?: string
billDate?: string
dueDate?: string
description?: string
approveLink?: string
denyLink?: string
reviewLink?: string
}
const BillApprovalVoteInviteEmail = ({
memberName, associationName, vendorName, invoiceNumber, amount, billDate, dueDate, description,
approveLink, denyLink, reviewLink,
}: Props) => (
<Html lang="en" dir="ltr">
<Head />
<Preview>Approve or deny: {vendorName || 'Bill'} {invoiceNumber || ''}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={brandBar} />
<Heading style={h1}>Bill approval requested</Heading>
<Text style={text}>Hello {memberName || 'Board Member'},</Text>
<Text style={text}>
A bill{associationName ? ` for ${associationName}` : ''} requires your approval.
</Text>
<Section style={panel}>
<Text style={label}>Vendor</Text>
<Text style={value}>{vendorName || '—'}</Text>
{invoiceNumber && (
<>
<Text style={label}>Invoice #</Text>
<Text style={value}>{invoiceNumber}</Text>
</>
)}
<Text style={label}>Amount</Text>
<Text style={value}>{amount || '—'}</Text>
{(billDate || dueDate) && (
<>
<Text style={label}>{billDate ? 'Bill date' : ''}{billDate && dueDate ? ' · Due date' : (dueDate ? 'Due date' : '')}</Text>
<Text style={meta}>
{billDate || ''}{billDate && dueDate ? ' · ' : ''}{dueDate || ''}
</Text>
</>
)}
{description && (
<>
<Text style={label}>Description</Text>
<Text style={meta}>{description}</Text>
</>
)}
</Section>
<Section style={buttonRow}>
{approveLink && <Button href={approveLink} style={approveButton}>Approve</Button>}
{denyLink && <Button href={denyLink} style={denyButton}>Deny</Button>}
</Section>
{reviewLink && (
<Text style={smallText}>
Prefer to review the full bill first? <a href={reviewLink} style={link}>Open it here</a>.
</Text>
)}
<Text style={smallText}>
🔒 These approve / deny links are tied uniquely to your board seat. Please do not share or forward them.
</Text>
<Text style={footer}>
If you weren't expecting this email, please contact your association manager. — {SITE_NAME}
</Text>
</Container>
</Body>
</Html>
)
export const template = {
component: BillApprovalVoteInviteEmail,
subject: (d: Record<string, any>) =>
`Approve or deny: ${d.vendorName || 'Bill'}${d.invoiceNumber ? ` ${d.invoiceNumber}` : ''}`,
displayName: 'Bill approval vote invite',
previewData: {
memberName: 'John Director',
associationName: 'Sunset Hills HOA',
vendorName: 'Acme Plumbing',
invoiceNumber: 'INV-1042',
amount: '$1,250.00',
billDate: '2026-05-01',
dueDate: '2026-05-31',
description: 'Quarterly pump-station maintenance',
approveLink: 'https://avria.cloud/bill-approve/xxx?token=yyy&action=approve',
denyLink: 'https://avria.cloud/bill-approve/xxx?token=yyy&action=deny',
reviewLink: 'https://avria.cloud/bill-approve/xxx?token=yyy',
},
} satisfies TemplateEntry
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 14px' }
const panel = { backgroundColor: '#f8fafc', border: '1px solid #e5e7eb', borderRadius: '8px', padding: '16px', margin: '14px 0 22px' }
const label = { color: '#6b7280', fontSize: '11px', textTransform: 'uppercase' as const, letterSpacing: '0.04em', margin: '8px 0 2px' }
const value = { color: '#111827', fontSize: '15px', lineHeight: '22px', fontWeight: '600', margin: '0' }
const meta = { color: '#374151', fontSize: '14px', lineHeight: '22px', margin: '0', whiteSpace: 'pre-wrap' as const }
const buttonRow = { margin: '0 0 18px' }
const approveButton = { backgroundColor: '#16a34a', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px', marginRight: '10px' }
const denyButton = { backgroundColor: '#dc2626', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '0 0 10px' }
const link = { color: '#2563eb', textDecoration: 'underline' }
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
@@ -0,0 +1,52 @@
import * as React from 'npm:react@18.3.1'
import { Body, Container, Head, Heading, Html, Preview, Text, Button } from 'npm:@react-email/components@0.0.22'
import type { TemplateEntry } from './registry.ts'
const SITE_NAME = 'Avria Community Management'
interface Props {
memberName?: string
voteTitle?: string
voteDescription?: string
associationName?: string
link?: string
}
const BoardVoteInviteEmail = ({ memberName, voteTitle, voteDescription, associationName, link }: Props) => (
<Html lang="en" dir="ltr">
<Head />
<Preview>Board vote {voteTitle || 'Cast your vote'}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Board Vote: {voteTitle || 'New vote'}</Heading>
<Text style={text}>Hello {memberName || 'Board Member'},</Text>
<Text style={text}>
A board vote has been opened{associationName ? ` for ${associationName}` : ''}:
</Text>
{voteDescription && <Text style={descBox}>{voteDescription}</Text>}
<Text style={text}>Please cast your vote using the secure link below.</Text>
<Button href={link || '#'} style={button}>Cast Your Vote</Button>
<Text style={smallText}><strong>🔒 This is a secure link</strong> tied uniquely to your board seat. Please do not share or forward it.</Text>
<Text style={smallText}><strong>For recording purposes only:</strong> This electronic vote will be formally ratified at a later board meeting.</Text>
<Text style={footer}>If you did not expect this email, please contact your association manager. {SITE_NAME}</Text>
</Container>
</Body>
</Html>
)
export const template = {
component: BoardVoteInviteEmail,
subject: (d: Record<string, any>) =>
`Board Vote — ${d.voteTitle || 'New vote'}${d.associationName ? ` (${d.associationName})` : ''}`,
displayName: 'Board vote invite',
previewData: { memberName: 'John Director', voteTitle: 'Approve 2026 Budget', voteDescription: 'Approve the proposed 2026 operating budget as presented.', associationName: 'Sample HOA', link: 'https://avria.cloud/board-vote/xxx?token=yyy' },
} satisfies TemplateEntry
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
const h1 = { color: '#111827', fontSize: '22px', lineHeight: '30px', margin: '0 0 14px', fontWeight: '700' }
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 12px' }
const descBox = { background: '#f6f7f9', border: '1px solid #e5e7eb', borderRadius: '6px', padding: '12px 14px', color: '#374151', fontSize: '14px', whiteSpace: 'pre-wrap' as const, margin: '0 0 16px' }
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '0 0 10px' }
const button = { backgroundColor: '#2941a4', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px', margin: '12px 0 18px' }
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
@@ -0,0 +1,51 @@
import * as React from 'npm:react@18.3.1'
import { Body, Container, Head, Heading, Html, Preview, Text, Button } from 'npm:@react-email/components@0.0.22'
import type { TemplateEntry } from './registry.ts'
const SITE_NAME = 'Avria Community Management'
interface Props {
ownerName?: string
electionTitle?: string
associationName?: string
deadline?: string
votingUrl?: string
}
const ElectionInviteEmail = ({ ownerName, electionTitle, associationName, deadline, votingUrl }: Props) => (
<Html lang="en" dir="ltr">
<Head />
<Preview>Cast your vote {electionTitle || 'Election'}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Election Ballot {electionTitle || 'Election'}</Heading>
<Text style={text}>Dear {ownerName || 'Owner'},</Text>
<Text style={text}>
You are eligible to vote in the <strong>{electionTitle || 'election'}</strong>
{associationName ? ` for ${associationName}` : ''}.
</Text>
{deadline && <Text style={text}> <strong>Deadline:</strong> {deadline}</Text>}
<Button href={votingUrl || '#'} style={button}>Cast Your Vote</Button>
<Text style={smallText}>This is a secure, tokenized link tied to your unit. Your vote is anonymous we record that you voted but not how you voted.</Text>
<Text style={smallText}>You may change your vote until the deadline by clicking the link again.</Text>
<Text style={footer}>If you did not expect this email, please contact your association manager. {SITE_NAME}</Text>
</Container>
</Body>
</Html>
)
export const template = {
component: ElectionInviteEmail,
subject: (d: Record<string, any>) =>
`Vote Now: ${d.electionTitle || 'Election'}${d.associationName ? `${d.associationName}` : ''}`,
displayName: 'Election invite',
previewData: { ownerName: 'Jane Owner', electionTitle: '2026 Board Election', associationName: 'Sample HOA', deadline: 'January 15, 2026 5:00 PM', votingUrl: 'https://avria.cloud/vote/xxx?token=yyy' },
} satisfies TemplateEntry
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
const h1 = { color: '#111827', fontSize: '22px', lineHeight: '30px', margin: '0 0 14px', fontWeight: '700' }
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 14px' }
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '0 0 10px' }
const button = { backgroundColor: '#2941a4', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px', margin: '12px 0 18px' }
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
@@ -0,0 +1,35 @@
/// <reference types="npm:@types/react@18.3.1" />
import * as React from 'npm:react@18.3.1'
export interface TemplateEntry {
component: React.ComponentType<any>
subject: string | ((data: Record<string, any>) => string)
to?: string
displayName?: string
previewData?: Record<string, any>
}
import { template as tenantInfoRequest } from './tenant-info-request.tsx'
import { template as ticketSubmitted } from './ticket-submitted.tsx'
import { template as ticketResponse } from './ticket-response.tsx'
import { template as vendorInsuranceRequest } from './vendor-insurance-request.tsx'
import { template as vendorProfileRequest } from './vendor-profile-request.tsx'
import { template as signupCodeInvite } from './signup-code-invite.tsx'
import { template as taskNotification } from './task-notification.tsx'
import { template as electionInvite } from './election-invite.tsx'
import { template as boardVoteInvite } from './board-vote-invite.tsx'
import { template as billApprovalRequest } from './bill-approval-request.tsx'
import { template as billApprovalVoteInvite } from './bill-approval-vote-invite.tsx'
export const TEMPLATES: Record<string, TemplateEntry> = {
'ticket-submitted': ticketSubmitted,
'ticket-response': ticketResponse,
'vendor-insurance-request': vendorInsuranceRequest,
'vendor-profile-request': vendorProfileRequest,
'signup-code-invite': signupCodeInvite,
'task-notification': taskNotification,
'election-invite': electionInvite,
'board-vote-invite': boardVoteInvite,
'tenant-info-request': tenantInfoRequest,
'bill-approval-request': billApprovalRequest,
'bill-approval-vote-invite': billApprovalVoteInvite,
}
@@ -118,6 +118,10 @@ serve(async (req) => {
const denyLink = `${reviewLink}&action=deny`;
const { error: sendErr } = await admin.functions.invoke("send-transactional-email", {
// Explicit Authorization header — supabase-js doesn't reliably
// forward the service-role apikey when one edge function invokes
// another, so verify_jwt on the inner function returns 401.
headers: { Authorization: `Bearer ${serviceKey}` },
body: {
templateName: "bill-approval-vote-invite",
recipientEmail: m.member_email,
@@ -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);
@@ -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;