* fix(invoices): anchor the PDF logo to the top-left of its header cell The logo box is always the full 240x80pt reserved area (any larger logo is clamped to exactly that), so objectFit: 'contain' placed the image inside it with the default 50% 50% centering. A wide banner logo fills the width and lands on the left margin, but a near-square logo scaled down to the 80pt height cap is only ~117pt wide and got pushed ~60pt in from the margin, which reads as a misaligned logo and forced companies to reshape their artwork. Anchor the image top-left so every aspect ratio starts at the margin. Covered by a test that renders the real PDF and reads the image placement matrix out of the content stream, for both a wide and a near-square logo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(invoices): show the real delivery outcome in the send history "Skickad" only meant the email provider accepted the message, so a bounced invoice looked identical to one that arrived. Resend reports the outcome asynchronously; that report now lands on the delivery row and drives the history: green is reserved for a confirmed delivery, bounce/blocked reads red, delayed and spam-marked read amber, and an accepted-but-unconfirmed send is neutral instead of falsely green. The report arrives on a signed webhook and may only touch the three new provider status columns of an already sent, unredacted row: the WORM trigger proves nothing else changed, and a lower ranked or older report can never downgrade an observed failure. The provider reason text can quote the failing address, so it is masked on read and cleared by the daily PII redaction job. Timestamps also formatted in Europe/Stockholm instead of falling back to the runtime zone, which rendered a 14:05 send as 12:05 on Vercel. Delivery reports are per message, never per recipient: Resend sends one event for the whole message, so splitting a send per recipient would be the only way to get finer granularity, at the cost of CC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(stripe): make the integration feed-only Stripe sync now only imports balance transactions into the transactions inbox, like any bank feed; nothing auto-books. The event/settlement sync (lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to any route or cron: the 15-min sync cron is removed from vercel.json. Payment links on invoice send are unchanged; their payments arrive as feed rows and are matched manually. - /sync runs only syncStripeBalanceTransactions; response is { success, transactions } - connecting via OAuth enables the nightly feed by default (toggle stays as opt-out) - panel: needs-review section and plumbing removed, copy rewritten to transactions-first (sv + en), toast reports fetched/imported/linked and calls out an empty result instead of silent all-zeros Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): return the article currency from the v1 article list The dashboard, importer, export and MCP article surfaces all learned to carry a non-SEK article price (#1166, #1183, #1184), but the v1 projection still omitted currency. An API or agent caller therefore read price_excl_vat with nothing marking it as EUR and would copy the number straight onto a SEK invoice line, at a nine-to-one error. Adds currency to the projection, the response shape and the example, plus a pitfall stating the price is not always SEK and that this endpoint does no FX conversion. Additive field only; no migration (articles.currency already exists). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(settings): replace the settings modal with a routed panel sheet Settings now renders as a sheet that fills the main panel, sliding up over the page the user came from and back down on close, with the sidebar and frame left visible and usable. Behind it sits one shared master-detail surface: underline search across every section and subsection, the grouped section rail, and the active section as a direct-editing accordion. All 11 sections are decomposed into subsections, and the legacy *SettingsContent components compose the same pieces so the stacked and accordion layouts cannot drift. The sheet is the only presentation, on every entry path. The intercepting route handles in-app navigation and closes by popping the history entry, landing back on the page underneath. @settingsModal/default.tsx handles cold loads (refresh, deep link, new tab), where interception never fires; nothing is mounted underneath there, so it closes to the dashboard. Both branch on one shared predicate, isSheetSection, together with the settings layout, which must render nothing for those sections or the surface would stack twice behind the sheet and run every section's fetches twice. Closing is deliberate rather than incidental: the X, Esc, or navigating away. The dialog is non-modal so the sidebar's account popover and company switcher keep working with settings up, and an outside click no longer dismisses it. Sections land fully collapsed, and the scroll position of the page behind survives opening and closing the sheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: enhance article management and settings UI - Add PATCH test for toggling article active state without other fields. - Remove unused MessageCircle icon from DashboardContent. - Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display. - Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text. - Remove redundant headings and intros in several settings components to streamline UI. - Improve help text for various settings in English and Swedish translations. - Update structured error messages for better clarity on article deletion. * refactor(ArticleDetailPage): remove unused imports and duplicate state variable * fix(settings): own deep-linked settings routes by route list, not nav visibility Review fixes from the settings panel sheet work: * isSheetSection reads the full settings route list so a hidden-but-deep-linked section (assistant before BankID, banking in sandbox, api without MCP) is claimed by the sheet instead of rendering the legacy shell around an empty panel * keep 503 on the Resend delivery webhook when the signing secret is unset, with a test pinning the behaviour * stripe callback route test coverage Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: update salary, tax, and templates settings components - Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI. - Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions. - Updated TemplatesSettingsContent to remove legacy comments and improve readability. - Simplified navigation items by removing unnecessary constants and directly using hrefs. - Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
480 lines
16 KiB
PL/PgSQL
480 lines
16 KiB
PL/PgSQL
-- Provider delivery outcome for customer invoice emails.
|
|
--
|
|
-- Until now a delivery row stopped at 'sent', which only means the email
|
|
-- provider accepted the message. Whether the receiving server took it,
|
|
-- rejected it, or deferred it stayed invisible, so a bounced invoice still
|
|
-- looked green in the delivery history.
|
|
--
|
|
-- Resend reports the outcome per message (never per recipient), so the state
|
|
-- lives on the delivery row itself: one provider status, when it was observed,
|
|
-- and the provider's own reason text for the failure cases.
|
|
--
|
|
-- The row stays WORM: exactly these three columns may change after a send,
|
|
-- and only while the row has not been redacted.
|
|
|
|
ALTER TABLE public.invoice_deliveries
|
|
ADD COLUMN provider_status text,
|
|
ADD COLUMN provider_status_at timestamptz,
|
|
ADD COLUMN provider_status_detail text;
|
|
|
|
ALTER TABLE public.invoice_deliveries
|
|
ADD CONSTRAINT invoice_deliveries_provider_status_shape CHECK (
|
|
(
|
|
provider_status IS NULL
|
|
AND provider_status_at IS NULL
|
|
AND provider_status_detail IS NULL
|
|
)
|
|
OR (
|
|
provider_status IN (
|
|
'delayed', 'delivered', 'complained', 'bounced', 'failed', 'suppressed'
|
|
)
|
|
AND provider_status_at IS NOT NULL
|
|
AND channel = 'email'
|
|
AND status = 'sent'
|
|
)
|
|
);
|
|
|
|
COMMENT ON COLUMN public.invoice_deliveries.provider_status IS
|
|
'Latest delivery outcome reported by the email provider for the whole message. NULL means no report received yet: accepted by the provider, nothing more.';
|
|
COMMENT ON COLUMN public.invoice_deliveries.provider_status_at IS
|
|
'Provider timestamp for the reported outcome, not the time the report was ingested.';
|
|
COMMENT ON COLUMN public.invoice_deliveries.provider_status_detail IS
|
|
'Provider reason text for a failed outcome. May name the failing recipient, so it is redacted with the rest of the delivery PII and masked before it leaves the server.';
|
|
|
|
-- Ranking makes out-of-order webhooks safe: a late "delayed" can never
|
|
-- overwrite an observed bounce. Equal ranks fall back to the provider clock.
|
|
CREATE OR REPLACE FUNCTION public.invoice_delivery_provider_status_rank(
|
|
p_status text
|
|
)
|
|
RETURNS integer
|
|
LANGUAGE sql
|
|
IMMUTABLE
|
|
SET search_path = pg_catalog, public
|
|
AS $$
|
|
SELECT CASE p_status
|
|
WHEN 'delayed' THEN 1
|
|
WHEN 'delivered' THEN 2
|
|
WHEN 'complained' THEN 3
|
|
WHEN 'bounced' THEN 4
|
|
WHEN 'failed' THEN 4
|
|
WHEN 'suppressed' THEN 4
|
|
ELSE 0
|
|
END
|
|
$$;
|
|
|
|
COMMENT ON FUNCTION public.invoice_delivery_provider_status_rank(text) IS
|
|
'Monotonic severity rank for provider delivery outcomes. Keeps out-of-order webhook events from downgrading an observed failure.';
|
|
|
|
-- Audit trail keeps metadata only: the outcome and its timestamp are metadata,
|
|
-- the provider reason text is not and stays out of the log.
|
|
CREATE OR REPLACE FUNCTION public.invoice_delivery_audit_state(
|
|
delivery public.invoice_deliveries
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE sql
|
|
IMMUTABLE
|
|
SET search_path = public
|
|
AS $$
|
|
SELECT jsonb_build_object(
|
|
'id', delivery.id,
|
|
'company_id', delivery.company_id,
|
|
'user_id', delivery.user_id,
|
|
'invoice_id', delivery.invoice_id,
|
|
'channel', delivery.channel,
|
|
'status', delivery.status,
|
|
'document_attachment_id', delivery.document_attachment_id,
|
|
'provider', delivery.provider,
|
|
'provider_status', delivery.provider_status,
|
|
'provider_status_at', delivery.provider_status_at,
|
|
'error_code', delivery.error_code,
|
|
'sent_at', delivery.sent_at,
|
|
'failed_at', delivery.failed_at,
|
|
'retention_expires_at', delivery.retention_expires_at,
|
|
'pii_redacted_at', delivery.pii_redacted_at,
|
|
'created_at', delivery.created_at
|
|
)
|
|
$$;
|
|
|
|
-- Rewritten from 20260723003000. Unchanged except for the provider status
|
|
-- columns: the sending flow may never set them, an already sent row may change
|
|
-- nothing else, and redaction must clear the reason text with the rest of PII.
|
|
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.provider_status IS NOT NULL
|
|
OR NEW.provider_status_at IS NOT NULL
|
|
OR NEW.provider_status_detail 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.provider_status IS NOT NULL
|
|
OR NEW.provider_status_at IS NOT NULL
|
|
OR NEW.provider_status_detail IS NOT NULL
|
|
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;
|
|
|
|
-- The provider reports the outcome after the send is already terminal. Only
|
|
-- the three provider status columns may move, and only on an unredacted sent
|
|
-- row: subtracting them from the row image proves nothing else changed, so a
|
|
-- column added later is covered without revisiting this branch.
|
|
IF OLD.status = 'sent'
|
|
AND OLD.pii_redacted_at IS NULL
|
|
AND NEW.provider_status IS NOT NULL
|
|
AND (to_jsonb(NEW)
|
|
- 'provider_status' - 'provider_status_at' - 'provider_status_detail' - 'updated_at')
|
|
IS NOT DISTINCT FROM
|
|
(to_jsonb(OLD)
|
|
- 'provider_status' - 'provider_status_at' - 'provider_status_detail' - 'updated_at')
|
|
THEN
|
|
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.provider_status IS NOT DISTINCT FROM OLD.provider_status
|
|
AND NEW.provider_status_at IS NOT DISTINCT FROM OLD.provider_status_at
|
|
AND NEW.provider_status_detail 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;
|
|
$$;
|
|
|
|
-- The provider reason text is recipient-related personal data and expires with
|
|
-- the rest of it. Rewritten from 20260722150000 to clear the new column too.
|
|
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,
|
|
provider_status_detail = 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;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.redact_expired_invoice_delivery_pii() FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() TO service_role;
|
|
|
|
-- Applied from a signed provider webhook, which has no user session: the
|
|
-- provider is the actor. The message id is the provider's own identifier and
|
|
-- is already unique per provider, so it is the only lookup key needed.
|
|
CREATE OR REPLACE FUNCTION public.apply_invoice_delivery_provider_status(
|
|
p_provider text,
|
|
p_provider_message_id text,
|
|
p_status text,
|
|
p_occurred_at timestamptz,
|
|
p_detail text
|
|
)
|
|
RETURNS uuid
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = pg_catalog, public
|
|
AS $$
|
|
DECLARE
|
|
target public.invoice_deliveries%ROWTYPE;
|
|
new_rank integer;
|
|
current_rank integer;
|
|
observed_at timestamptz;
|
|
updated_id uuid;
|
|
BEGIN
|
|
IF auth.role() IS DISTINCT FROM 'service_role' THEN
|
|
RAISE EXCEPTION 'invoice delivery provider status requires a server-controlled service role'
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
new_rank := public.invoice_delivery_provider_status_rank(p_status);
|
|
IF new_rank = 0 THEN
|
|
RAISE EXCEPTION 'unsupported invoice delivery provider status: %', p_status
|
|
USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
IF p_provider IS NULL OR p_provider_message_id IS NULL THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
SELECT * INTO target
|
|
FROM public.invoice_deliveries d
|
|
WHERE d.provider = p_provider
|
|
AND d.provider_message_id = p_provider_message_id
|
|
FOR UPDATE;
|
|
|
|
-- Events for mail that is not a tracked invoice delivery, or for a row that
|
|
-- has passed its retention date, are acknowledged and dropped.
|
|
IF target.id IS NULL
|
|
OR target.status <> 'sent'
|
|
OR target.pii_redacted_at IS NOT NULL
|
|
THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
observed_at := COALESCE(p_occurred_at, now());
|
|
current_rank := public.invoice_delivery_provider_status_rank(target.provider_status);
|
|
|
|
IF new_rank < current_rank
|
|
OR (
|
|
new_rank = current_rank
|
|
AND target.provider_status_at IS NOT NULL
|
|
AND observed_at <= target.provider_status_at
|
|
)
|
|
THEN
|
|
RETURN target.id;
|
|
END IF;
|
|
|
|
UPDATE public.invoice_deliveries
|
|
SET provider_status = p_status,
|
|
provider_status_at = observed_at,
|
|
provider_status_detail = NULLIF(
|
|
left(regexp_replace(COALESCE(p_detail, ''), '\s+', ' ', 'g'), 500),
|
|
''
|
|
)
|
|
WHERE id = target.id
|
|
RETURNING id INTO updated_id;
|
|
|
|
RETURN updated_id;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.apply_invoice_delivery_provider_status(
|
|
text, text, text, timestamptz, text
|
|
) FROM PUBLIC, anon, authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.apply_invoice_delivery_provider_status(
|
|
text, text, text, timestamptz, text
|
|
) TO service_role;
|
|
|
|
COMMENT ON FUNCTION public.apply_invoice_delivery_provider_status(
|
|
text, text, text, timestamptz, text
|
|
) IS
|
|
'Applies a signed provider delivery report to the matching sent invoice delivery. Idempotent and monotonic: a lower ranked or older report is a no-op.';
|
|
|
|
-- Reason text can quote the failing address, so it is masked exactly like the
|
|
-- recipient list before it leaves the server.
|
|
DROP FUNCTION IF EXISTS public.list_invoice_delivery_summaries(uuid, uuid);
|
|
|
|
CREATE 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,
|
|
provider_status text,
|
|
provider_status_at timestamptz,
|
|
provider_status_detail text,
|
|
error_code text,
|
|
document_attachment_id uuid,
|
|
attachment_filename text,
|
|
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.provider_status,
|
|
d.provider_status_at,
|
|
regexp_replace(d.provider_status_detail, '[A-Za-z0-9._%+-]+@', '***@', 'g'),
|
|
d.error_code,
|
|
d.document_attachment_id,
|
|
d.attachment_filename,
|
|
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, including the provider delivery outcome, with masked To and CC addresses and a masked provider reason text. Exact payload and BCC remain server-side.';
|
|
|
|
NOTIFY pgrst, 'reload schema';
|