Files
accounted/supabase/migrations/20260722190000_invoice_email_cc_bcc.sql
T
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00

261 lines
8.7 KiB
PL/PgSQL

-- Add configurable invoice email copies and retain the exact BCC delivery payload.
ALTER TABLE public.company_settings
ADD COLUMN invoice_email_cc_addresses text[],
ADD COLUMN invoice_email_bcc_addresses text[],
ADD CONSTRAINT company_settings_invoice_email_cc_limit
CHECK (invoice_email_cc_addresses IS NULL OR cardinality(invoice_email_cc_addresses) <= 20),
ADD CONSTRAINT company_settings_invoice_email_bcc_limit
CHECK (invoice_email_bcc_addresses IS NULL OR cardinality(invoice_email_bcc_addresses) <= 20);
ALTER TABLE public.invoice_deliveries
ADD COLUMN bcc_addresses text[] NOT NULL DEFAULT '{}';
ALTER TABLE public.invoice_deliveries
DROP CONSTRAINT invoice_deliveries_payload_shape,
ADD CONSTRAINT invoice_deliveries_payload_shape CHECK (
(
channel = 'email'
AND status = 'preparing'
AND cardinality(to_addresses) = 0
AND cardinality(cc_addresses) = 0
AND cardinality(bcc_addresses) = 0
AND reply_to IS NULL
AND from_name IS NULL
AND subject IS NULL
AND body_text IS NULL
AND body_html IS NULL
AND provider IS NULL
AND provider_message_id IS NULL
AND error_code IS NULL
AND document_attachment_id IS NULL
AND attachment_filename IS NULL
AND attachment_content_type IS NULL
AND attachment_sha256 IS NULL
AND pii_redacted_at IS NULL
)
OR
(
channel = 'email'
AND status IN ('pending', 'sent', 'failed')
AND pii_redacted_at IS NULL
AND cardinality(to_addresses) > 0
AND subject IS NOT NULL
AND body_text IS NOT NULL
AND body_html IS NOT NULL
AND attachment_filename IS NOT NULL
AND attachment_content_type IS NOT NULL
AND attachment_sha256 IS NOT NULL
AND (
(status IN ('pending', 'sent') AND document_attachment_id IS NOT NULL)
OR status = 'failed'
)
)
OR
(
channel = 'email'
AND status IN ('sent', 'failed')
AND pii_redacted_at IS NOT NULL
AND cardinality(to_addresses) = 0
AND cardinality(cc_addresses) = 0
AND cardinality(bcc_addresses) = 0
AND reply_to IS NULL
AND from_name IS NULL
AND subject IS NULL
AND body_text IS NULL
AND body_html IS NULL
AND provider_message_id IS NULL
AND attachment_filename IS NULL
AND attachment_sha256 IS NULL
)
OR
(
channel = 'manual'
AND status = 'marked_sent'
AND cardinality(to_addresses) = 0
AND cardinality(cc_addresses) = 0
AND cardinality(bcc_addresses) = 0
AND reply_to IS NULL
AND from_name IS NULL
AND subject IS NULL
AND body_text IS NULL
AND body_html IS NULL
AND provider IS NULL
AND provider_message_id IS NULL
AND error_code IS NULL
AND document_attachment_id IS NULL
AND attachment_filename IS NULL
AND attachment_content_type IS NULL
AND attachment_sha256 IS NULL
AND pii_redacted_at IS NULL
)
);
CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO public.audit_log (
user_id,
company_id,
action,
table_name,
record_id,
actor_id,
old_state,
description
) VALUES (
OLD.user_id,
OLD.company_id,
'SECURITY_EVENT',
'invoice_deliveries',
OLD.id,
auth.uid(),
public.invoice_delivery_audit_state(OLD),
'Blocked deletion of immutable invoice delivery history.'
);
RETURN NULL;
END IF;
IF OLD.status = 'preparing' THEN
IF NEW.status <> 'pending'
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.provider IS NOT NULL
OR NEW.provider_message_id IS NOT NULL
OR NEW.error_code IS NOT NULL
OR NEW.sent_at IS NOT NULL
OR NEW.failed_at IS NOT NULL
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS NOT NULL
OR NEW.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'preparing invoice delivery may only capture its pending payload'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'pending' THEN
IF NEW.status NOT IN ('sent', 'failed') THEN
RAISE EXCEPTION 'pending invoice delivery may only transition to sent or failed'
USING ERRCODE = '23514';
END IF;
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.to_addresses IS DISTINCT FROM OLD.to_addresses
OR NEW.cc_addresses IS DISTINCT FROM OLD.cc_addresses
OR NEW.bcc_addresses IS DISTINCT FROM OLD.bcc_addresses
OR NEW.reply_to IS DISTINCT FROM OLD.reply_to
OR NEW.from_name IS DISTINCT FROM OLD.from_name
OR NEW.subject IS DISTINCT FROM OLD.subject
OR NEW.body_text IS DISTINCT FROM OLD.body_text
OR NEW.body_html IS DISTINCT FROM OLD.body_html
OR NEW.attachment_filename IS DISTINCT FROM OLD.attachment_filename
OR NEW.attachment_content_type IS DISTINCT FROM OLD.attachment_content_type
OR NEW.attachment_sha256 IS DISTINCT FROM OLD.attachment_sha256
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS DISTINCT FROM OLD.pii_redacted_at
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR (
NEW.status = 'sent'
AND NEW.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
)
OR (
NEW.status = 'failed'
AND NEW.document_attachment_id IS NOT NULL
)
THEN
RAISE EXCEPTION 'invoice delivery payload is immutable'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status IN ('sent', 'failed')
AND OLD.pii_redacted_at IS NULL
AND CURRENT_DATE >= OLD.retention_expires_at
AND NEW.pii_redacted_at IS NOT NULL
AND NEW.company_id IS NOT DISTINCT FROM OLD.company_id
AND NEW.user_id IS NOT DISTINCT FROM OLD.user_id
AND NEW.invoice_id IS NOT DISTINCT FROM OLD.invoice_id
AND NEW.channel IS NOT DISTINCT FROM OLD.channel
AND NEW.status IS NOT DISTINCT FROM OLD.status
AND cardinality(NEW.to_addresses) = 0
AND cardinality(NEW.cc_addresses) = 0
AND cardinality(NEW.bcc_addresses) = 0
AND NEW.reply_to IS NULL
AND NEW.from_name IS NULL
AND NEW.subject IS NULL
AND NEW.body_text IS NULL
AND NEW.body_html IS NULL
AND NEW.provider IS NOT DISTINCT FROM OLD.provider
AND NEW.provider_message_id IS NULL
AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code
AND NEW.document_attachment_id IS NOT DISTINCT FROM OLD.document_attachment_id
AND NEW.attachment_filename IS NULL
AND NEW.attachment_content_type IS NOT DISTINCT FROM OLD.attachment_content_type
AND NEW.attachment_sha256 IS NULL
AND NEW.sent_at IS NOT DISTINCT FROM OLD.sent_at
AND NEW.failed_at IS NOT DISTINCT FROM OLD.failed_at
AND NEW.retention_expires_at IS NOT DISTINCT FROM OLD.retention_expires_at
AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
USING ERRCODE = '23514';
END;
$$;
CREATE OR REPLACE FUNCTION public.redact_expired_invoice_delivery_pii()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
redacted_count integer;
BEGIN
UPDATE public.invoice_deliveries
SET to_addresses = '{}',
cc_addresses = '{}',
bcc_addresses = '{}',
reply_to = NULL,
from_name = NULL,
subject = NULL,
body_text = NULL,
body_html = NULL,
provider_message_id = NULL,
attachment_filename = NULL,
attachment_sha256 = NULL,
pii_redacted_at = now()
WHERE channel = 'email'
AND status IN ('sent', 'failed')
AND pii_redacted_at IS NULL
AND retention_expires_at <= CURRENT_DATE;
GET DIAGNOSTICS redacted_count = ROW_COUNT;
RETURN redacted_count;
END;
$$;
COMMENT ON COLUMN public.company_settings.invoice_email_cc_addresses IS
'Fixed CC recipients for invoice emails. NULL keeps the historical company-email fallback; an empty array disables it.';
COMMENT ON COLUMN public.company_settings.invoice_email_bcc_addresses IS
'Fixed BCC recipients for invoice emails.';
COMMENT ON COLUMN public.invoice_deliveries.bcc_addresses IS
'Exact BCC recipients submitted to the email provider. Redacted after the statutory retention period.';
NOTIFY pgrst, 'reload schema';