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
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
-- 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';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Add the legacy SEK bank fields required by currency-specific invoice accounts.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN IF NOT EXISTS bank_name text,
|
||||
ADD COLUMN IF NOT EXISTS clearing_number text,
|
||||
ADD COLUMN IF NOT EXISTS account_number text;
|
||||
|
||||
COMMENT ON COLUMN public.company_settings.bank_name IS
|
||||
'Legacy SEK invoice bank name mirrored from invoice_payment_accounts.SEK.';
|
||||
COMMENT ON COLUMN public.company_settings.clearing_number IS
|
||||
'Legacy SEK invoice clearing number mirrored from invoice_payment_accounts.SEK.';
|
||||
COMMENT ON COLUMN public.company_settings.account_number IS
|
||||
'Legacy SEK invoice account number mirrored from invoice_payment_accounts.SEK.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Store invoice payment instructions by settlement currency.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN invoice_payment_accounts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD CONSTRAINT company_settings_invoice_payment_accounts_object
|
||||
CHECK (jsonb_typeof(invoice_payment_accounts) = 'object'),
|
||||
ADD CONSTRAINT company_settings_invoice_payment_account_currencies
|
||||
CHECK (
|
||||
invoice_payment_accounts - ARRAY['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']::text[]
|
||||
= '{}'::jsonb
|
||||
);
|
||||
|
||||
UPDATE public.company_settings
|
||||
SET invoice_payment_accounts = jsonb_build_object(
|
||||
'SEK',
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'bank_name', NULLIF(trim(bank_name), ''),
|
||||
'clearing_number', NULLIF(trim(clearing_number), ''),
|
||||
'account_number', NULLIF(trim(account_number), ''),
|
||||
'bankgiro', NULLIF(trim(bankgiro), ''),
|
||||
'plusgiro', NULLIF(trim(plusgiro), ''),
|
||||
'swish', NULLIF(trim(swish), ''),
|
||||
'iban', NULLIF(trim(iban), ''),
|
||||
'bic', NULLIF(trim(bic), '')
|
||||
))
|
||||
)
|
||||
WHERE COALESCE(
|
||||
NULLIF(trim(bank_name), ''),
|
||||
NULLIF(trim(clearing_number), ''),
|
||||
NULLIF(trim(account_number), ''),
|
||||
NULLIF(trim(bankgiro), ''),
|
||||
NULLIF(trim(plusgiro), ''),
|
||||
NULLIF(trim(swish), ''),
|
||||
NULLIF(trim(iban), ''),
|
||||
NULLIF(trim(bic), '')
|
||||
) IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.company_settings.invoice_payment_accounts IS
|
||||
'Invoice payment instructions keyed by supported invoice currency. Foreign invoices never fall back to legacy SEK details.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,661 @@
|
||||
-- Keep exact invoice email payloads visible only to the sending user.
|
||||
-- Other company members consume a data-minimized SECURITY DEFINER summary.
|
||||
|
||||
DROP POLICY IF EXISTS invoice_deliveries_select ON public.invoice_deliveries;
|
||||
|
||||
CREATE POLICY invoice_deliveries_select
|
||||
ON public.invoice_deliveries FOR SELECT TO authenticated
|
||||
USING (
|
||||
company_id = public.current_active_company_id()
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.list_invoice_delivery_summaries(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
channel text,
|
||||
status text,
|
||||
to_addresses text[],
|
||||
cc_addresses text[],
|
||||
provider text,
|
||||
error_code text,
|
||||
document_attachment_id uuid,
|
||||
sent_at timestamptz,
|
||||
failed_at timestamptz,
|
||||
created_at timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL
|
||||
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid()
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'not authorized to list invoice delivery summaries'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
d.id,
|
||||
d.channel,
|
||||
d.status,
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(d.to_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(d.cc_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
d.provider,
|
||||
d.error_code,
|
||||
d.document_attachment_id,
|
||||
d.sent_at,
|
||||
d.failed_at,
|
||||
d.created_at
|
||||
FROM public.invoice_deliveries d
|
||||
WHERE d.company_id = p_company_id
|
||||
AND d.invoice_id = p_invoice_id
|
||||
AND d.status <> 'preparing'
|
||||
ORDER BY d.created_at DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) IS
|
||||
'Returns active-company invoice delivery status with masked To and CC addresses. Exact payload and BCC remain server-side.';
|
||||
|
||||
-- Fixed CC and BCC settings can redirect every future invoice email. Keep
|
||||
-- these fields owner/admin controlled even when PostgREST is called directly.
|
||||
CREATE OR REPLACE FUNCTION public.enforce_invoice_email_recipient_settings_admin()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND NEW.invoice_email_cc_addresses IS NOT DISTINCT FROM OLD.invoice_email_cc_addresses
|
||||
AND NEW.invoice_email_bcc_addresses IS NOT DISTINCT FROM OLD.invoice_email_bcc_addresses
|
||||
THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'INSERT'
|
||||
AND NEW.invoice_email_cc_addresses IS NULL
|
||||
AND NEW.invoice_email_bcc_addresses IS NULL
|
||||
THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF auth.role() = 'service_role' OR auth.uid() IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
WHERE cm.company_id = NEW.company_id
|
||||
AND cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'owner or admin role required to change invoice email recipients'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS enforce_invoice_email_recipient_settings_admin
|
||||
ON public.company_settings;
|
||||
CREATE TRIGGER enforce_invoice_email_recipient_settings_admin
|
||||
BEFORE INSERT OR UPDATE OF invoice_email_cc_addresses, invoice_email_bcc_addresses
|
||||
ON public.company_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.enforce_invoice_email_recipient_settings_admin();
|
||||
|
||||
-- Direct authenticated delivery writes can forge evidence. All delivery state
|
||||
-- changes now go through service-role-only RPCs that bind the row to a verified
|
||||
-- company member supplied by the server route.
|
||||
DROP POLICY IF EXISTS invoice_deliveries_insert ON public.invoice_deliveries;
|
||||
DROP POLICY IF EXISTS invoice_deliveries_update ON public.invoice_deliveries;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.authorize_invoice_delivery_service_actor(
|
||||
p_company_id uuid,
|
||||
p_actor_user_id uuid
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.role() IS DISTINCT FROM 'service_role' THEN
|
||||
RAISE EXCEPTION 'invoice delivery writes require a server-controlled service role'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF p_actor_user_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = p_actor_user_id
|
||||
AND cm.role IN ('owner', 'admin', 'member')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invoice delivery actor is not a writable company member'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN p_actor_user_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.authorize_invoice_delivery_service_actor(uuid, uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- A preparing row contains no recipient or message payload. It may be removed
|
||||
-- after a bounded timeout so another authorized sender can recover from a
|
||||
-- crashed render without deleting accounting evidence.
|
||||
CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF OLD.status = 'preparing'
|
||||
AND OLD.created_at <= now() - interval '15 minutes'
|
||||
THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
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.reserve_invoice_delivery(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_actor_user_id uuid
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
actor_id uuid;
|
||||
delivery_id uuid;
|
||||
existing_actor_id uuid;
|
||||
existing_created_at timestamptz;
|
||||
BEGIN
|
||||
actor_id := public.authorize_invoice_delivery_service_actor(
|
||||
p_company_id,
|
||||
p_actor_user_id
|
||||
);
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = p_invoice_id AND i.company_id = p_company_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invoice not found for delivery reservation'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
LOOP
|
||||
BEGIN
|
||||
INSERT INTO public.invoice_deliveries (
|
||||
company_id, user_id, invoice_id, channel, status
|
||||
) VALUES (
|
||||
p_company_id, actor_id, p_invoice_id, 'email', 'preparing'
|
||||
)
|
||||
RETURNING id INTO delivery_id;
|
||||
RETURN delivery_id;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
SELECT d.id, d.user_id, d.created_at
|
||||
INTO delivery_id, existing_actor_id, existing_created_at
|
||||
FROM public.invoice_deliveries d
|
||||
WHERE d.company_id = p_company_id
|
||||
AND d.invoice_id = p_invoice_id
|
||||
AND d.status = 'preparing'
|
||||
FOR UPDATE;
|
||||
|
||||
IF delivery_id IS NULL THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
IF existing_actor_id = actor_id THEN
|
||||
RETURN delivery_id;
|
||||
END IF;
|
||||
IF existing_created_at > now() - interval '15 minutes' THEN
|
||||
RAISE EXCEPTION 'invoice delivery is already being prepared'
|
||||
USING ERRCODE = '55P03';
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.invoice_deliveries d WHERE d.id = delivery_id;
|
||||
delivery_id := NULL;
|
||||
END;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.capture_invoice_delivery_payload(
|
||||
p_delivery_id uuid,
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_actor_user_id uuid,
|
||||
p_to_addresses text[],
|
||||
p_cc_addresses text[],
|
||||
p_bcc_addresses text[],
|
||||
p_reply_to text,
|
||||
p_from_name text,
|
||||
p_subject text,
|
||||
p_body_text text,
|
||||
p_body_html text,
|
||||
p_document_attachment_id uuid,
|
||||
p_attachment_filename text,
|
||||
p_attachment_content_type text,
|
||||
p_attachment_sha256 text
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
actor_id uuid;
|
||||
captured_id uuid;
|
||||
BEGIN
|
||||
actor_id := public.authorize_invoice_delivery_service_actor(
|
||||
p_company_id,
|
||||
p_actor_user_id
|
||||
);
|
||||
|
||||
UPDATE public.invoice_deliveries
|
||||
SET status = 'pending',
|
||||
to_addresses = p_to_addresses,
|
||||
cc_addresses = p_cc_addresses,
|
||||
bcc_addresses = p_bcc_addresses,
|
||||
reply_to = p_reply_to,
|
||||
from_name = p_from_name,
|
||||
subject = p_subject,
|
||||
body_text = p_body_text,
|
||||
body_html = p_body_html,
|
||||
document_attachment_id = p_document_attachment_id,
|
||||
attachment_filename = p_attachment_filename,
|
||||
attachment_content_type = p_attachment_content_type,
|
||||
attachment_sha256 = p_attachment_sha256
|
||||
WHERE id = p_delivery_id
|
||||
AND company_id = p_company_id
|
||||
AND invoice_id = p_invoice_id
|
||||
AND user_id = actor_id
|
||||
AND status = 'preparing'
|
||||
RETURNING id INTO captured_id;
|
||||
|
||||
IF captured_id IS NULL THEN
|
||||
RAISE EXCEPTION 'invoice delivery reservation cannot capture payload'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN captured_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.finalize_invoice_delivery(
|
||||
p_delivery_id uuid,
|
||||
p_company_id uuid,
|
||||
p_actor_user_id uuid,
|
||||
p_status text,
|
||||
p_provider text,
|
||||
p_provider_message_id text,
|
||||
p_error_code text
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
actor_id uuid;
|
||||
finalized_id uuid;
|
||||
BEGIN
|
||||
actor_id := public.authorize_invoice_delivery_service_actor(
|
||||
p_company_id,
|
||||
p_actor_user_id
|
||||
);
|
||||
|
||||
IF p_status = 'sent' THEN
|
||||
UPDATE public.invoice_deliveries
|
||||
SET status = 'sent',
|
||||
provider = p_provider,
|
||||
provider_message_id = p_provider_message_id,
|
||||
error_code = NULL,
|
||||
sent_at = now(),
|
||||
failed_at = NULL
|
||||
WHERE id = p_delivery_id
|
||||
AND company_id = p_company_id
|
||||
AND user_id = actor_id
|
||||
AND status = 'pending'
|
||||
RETURNING id INTO finalized_id;
|
||||
ELSIF p_status = 'failed' THEN
|
||||
UPDATE public.invoice_deliveries
|
||||
SET status = 'failed',
|
||||
provider = p_provider,
|
||||
provider_message_id = NULL,
|
||||
error_code = COALESCE(p_error_code, 'provider_failed'),
|
||||
document_attachment_id = NULL,
|
||||
sent_at = NULL,
|
||||
failed_at = now()
|
||||
WHERE id = p_delivery_id
|
||||
AND company_id = p_company_id
|
||||
AND user_id = actor_id
|
||||
AND status = 'pending'
|
||||
RETURNING id INTO finalized_id;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'invoice delivery terminal status must be sent or failed'
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
IF finalized_id IS NULL THEN
|
||||
RAISE EXCEPTION 'invoice delivery cannot be finalized'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN finalized_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_manual_invoice_delivery(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid,
|
||||
p_actor_user_id uuid,
|
||||
p_sent_at timestamptz DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
actor_id uuid;
|
||||
delivery public.invoice_deliveries%ROWTYPE;
|
||||
BEGIN
|
||||
actor_id := public.authorize_invoice_delivery_service_actor(
|
||||
p_company_id,
|
||||
p_actor_user_id
|
||||
);
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = p_invoice_id
|
||||
AND i.company_id = p_company_id
|
||||
AND i.status IN ('sent', 'overdue', 'partially_paid', 'paid', 'credited')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invoice is not in a manually sent state'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.invoice_deliveries (
|
||||
company_id, user_id, invoice_id, channel, status, sent_at
|
||||
) VALUES (
|
||||
p_company_id, actor_id, p_invoice_id, 'manual', 'marked_sent',
|
||||
COALESCE(p_sent_at, now())
|
||||
)
|
||||
RETURNING * INTO delivery;
|
||||
|
||||
RETURN to_jsonb(delivery);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Deferred booking needs only the archived document id, not the email payload.
|
||||
CREATE OR REPLACE FUNCTION public.latest_sent_invoice_delivery_document(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
document_id uuid;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL
|
||||
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id AND cm.user_id = auth.uid()
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'not authorized to find delivered invoice document'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT d.document_attachment_id
|
||||
INTO document_id
|
||||
FROM public.invoice_deliveries d
|
||||
WHERE d.company_id = p_company_id
|
||||
AND d.invoice_id = p_invoice_id
|
||||
AND d.status = 'sent'
|
||||
AND d.document_attachment_id IS NOT NULL
|
||||
ORDER BY d.sent_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
RETURN document_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Full statutory exports are an explicit need-to-know exception to routine
|
||||
-- sender-only payload access. Authenticated callers must be owner/admin; server
|
||||
-- service paths are already authorized before invoking the exporter.
|
||||
CREATE OR REPLACE FUNCTION public.export_invoice_delivery_evidence(
|
||||
p_company_id uuid
|
||||
)
|
||||
RETURNS SETOF public.invoice_deliveries
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.role() IS DISTINCT FROM 'service_role' AND (
|
||||
auth.uid() IS NULL
|
||||
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin')
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'owner or admin role required to export invoice delivery evidence'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT d.*
|
||||
FROM public.invoice_deliveries d
|
||||
WHERE d.company_id = p_company_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.capture_invoice_delivery_payload(
|
||||
uuid, uuid, uuid, uuid, text[], text[], text[], text, text, text, text, text,
|
||||
uuid, text, text, text
|
||||
) FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.finalize_invoice_delivery(
|
||||
uuid, uuid, uuid, text, text, text, text
|
||||
) FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.record_manual_invoice_delivery(uuid, uuid, uuid, timestamptz)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid)
|
||||
TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.capture_invoice_delivery_payload(
|
||||
uuid, uuid, uuid, uuid, text[], text[], text[], text, text, text, text, text,
|
||||
uuid, text, text, text
|
||||
) TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.finalize_invoice_delivery(
|
||||
uuid, uuid, uuid, text, text, text, text
|
||||
) TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.record_manual_invoice_delivery(uuid, uuid, uuid, timestamptz)
|
||||
TO service_role;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid)
|
||||
FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid)
|
||||
TO authenticated, service_role;
|
||||
REVOKE ALL ON FUNCTION public.export_invoice_delivery_evidence(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.export_invoice_delivery_evidence(uuid)
|
||||
TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid) IS
|
||||
'Service-only reservation for an invoice email delivery. Reclaims payload-free reservations after 15 minutes.';
|
||||
COMMENT ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid) IS
|
||||
'Returns only the latest sent delivery document id for active-company deferred booking.';
|
||||
COMMENT ON FUNCTION public.export_invoice_delivery_evidence(uuid) IS
|
||||
'Returns exact company delivery evidence only to owner/admin audit exports and service-role server paths.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Repair only unambiguous final-closing links created by the canonical
|
||||
-- year-end workflow. Other legacy closed periods remain unlinked so statutory
|
||||
-- reporting fails explicitly instead of excluding unrelated year-end entries.
|
||||
|
||||
WITH exact_candidates AS (
|
||||
SELECT
|
||||
fp.id AS fiscal_period_id,
|
||||
(array_agg(je.id))[1] AS closing_entry_id
|
||||
FROM public.fiscal_periods fp
|
||||
JOIN public.journal_entries je
|
||||
ON je.company_id = fp.company_id
|
||||
AND je.fiscal_period_id = fp.id
|
||||
AND je.source_type = 'year_end'
|
||||
AND je.status = 'posted'
|
||||
AND je.description = 'Årsbokslut ' || fp.name
|
||||
WHERE fp.is_closed = true
|
||||
AND fp.closing_entry_id IS NULL
|
||||
GROUP BY fp.id
|
||||
HAVING count(*) = 1
|
||||
)
|
||||
UPDATE public.fiscal_periods fp
|
||||
SET closing_entry_id = candidate.closing_entry_id
|
||||
FROM exact_candidates candidate
|
||||
WHERE fp.id = candidate.fiscal_period_id
|
||||
AND fp.closing_entry_id IS NULL;
|
||||
Reference in New Issue
Block a user