feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878)

* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps

- MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server)
- DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000)
- Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate
- Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3

Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool

Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid
ROT/RUT invoices — no submission API exists, the file is uploaded manually
at skatteverket.se. Headless by design for now: API routes + MCP tool
(gnubok_generate_rot_rut_file), no UI surfaces.

- lib/invoices/rot-rut-file.ts: pure XML generator with deterministic
  per-invoice blockers (hours, work type, personnummer, property info,
  mixed rot+rut, XSD limits) + 31 January deadline warnings
- rot_rut_payout_requests(+items) tables: one active begäran per invoice
  (DB triggers incl. reactivation guard), RLS, audit, pg-real tests
- Settlement: POST /settle books debit 1930 / credit 1513 via the engine
  (source_type rot_rut_payout); partial payouts → partially_paid
- Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only,
  snöskottning/tillsyn/tvätt added (schablontjänster utfört-only)
- Fix: invoice-level fastighetsbeteckning was validated but never
  persisted — now stamped onto rot lines in build-invoice-write; API
  accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred)
- invoice_items.brf_org_number migration + MCP scope invoices:write

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): per-company editable invoice email texts

Add an "E-posttexter" section under Settings -> Fakturering where the
subject, greeting, body and sign-off of the standard invoice email can
be customized per company in Swedish and English. Fields pre-fill with
the standard texts and only diffs from the standard are stored
(company_settings.invoice_email_texts JSONB), so future improvements to
the stock wording still reach companies that have not customized. Each
field has a reset-to-standard button; cleared fields snap back.

Texts support a fixed placeholder set (invoice number, customer name,
first name, company, due date, amount) substituted at send time in a
single pass; unknown placeholders stay literal. Custom texts are
HTML-escaped after substitution, newlines become <br> in the HTML
variant, and subject lines are flattened to a single header line.
Overrides apply to standard invoices only - credit notes, proforma and
delivery notes keep the stock texts. All send paths (UI, v1 API, MCP
approval, recurring) pick the texts up via the existing settings row.

The Zod schema half of this change (InvoiceEmailTextsSchema in
lib/api/schemas.ts) was inadvertently included in 8291f745.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400

detectFileMagic required the %PDF- signature at byte 0 (BOM aside),
rejecting genuine PDFs that carry a leading newline or junk bytes —
files every ISO 32000 reader opens fine. Now scan the first 1024 bytes
for the signature, matching real-reader behavior. Image types stay
strict at offset 0 to keep the anti-placeholder defense tight.

Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED
(500 'Filen kunde inte sparas'), blaming storage for a client-side file
problem. Both upload routes now map them to a new
DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): full keyboard flow for manual journal entry

Enter now drives the whole verifikat flow: verifikationstext drops into
the first row missing an account, konto commits advance to debet, Enter
on an empty debet hops to kredit, and an entered amount jumps to the
next row. Once the voucher balances, Enter opens the review (unchanged
gate) and the auto-focused confirm posts it — including through the
no-underlag warning dialog. Escape in the inline review goes back to
the form.

Also fixes an Enter footgun in AccountCombobox: a bare Enter on a
freshly focused field no longer selects the first account in the list —
selection now requires typing or arrow navigation; otherwise Enter
re-commits the current value or bubbles to the form-level handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add custom inbound domains management for companies

- Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API.
- Created a new table `company_inbound_domains` to store domain information, including status and DNS records.
- Added necessary RLS policies to restrict access based on user roles (owner/admin).
- Developed functions for domain normalization, validation, claiming, verification, and removal.
- Implemented webhook handling for domain status updates from Resend.
- Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature.

* fix: address PR #878 review findings and CI failures

- migrations: drop the ai_usage_tracking policy block from the role-gate
  migration — the table was removed by 20260504120000_remove_ai_subsystem
  and only lingers on staging as drift; a from-scratch chain (pg-real,
  Supabase preview) failed on it
- invoice-inbox: never flip a custom domain to verified off a domain.updated
  webhook alone — confirm the receiving capability with Resend first
  (fail-closed); normalize both sides of the orphan-adoption domain match
- rot/rut: block files where begärt belopp exceeds what the buyer paid
  (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real
  orgnr shapes; parameterize the settlement bank account (19xx, default 1930)
- rot/rut routes: log acting user on financial mutations, stop swallowing
  item mirror errors, narrow response projections (no customer ids through
  the invoice join); document the deliberate inline-XML decision
- documents: stop echoing raw storage-layer error messages to clients

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: round-2 CI + compliance findings on PR #878

- migrations: the role-gate migration targeted automation_webhooks, which
  20260515170000_webhooks_v2 renamed to webhooks on the canonical chain
  (staging kept the old name — drift); gate public.webhooks instead,
  dropping legacy schema-sync policy names defensively. Restore the
  20260623130000 owner fallback in next_voucher_number that the stale
  copied-verbatim body silently reverted (caught by engine.pg locally).
  Full migration chain verified from scratch against supabase/postgres:15.
- mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877
  qualified-identifier schemas plus this branch's rot/rut tool crossed the
  ceiling only in combination; documented in the test's history log.
- rot/rut: refuse partial settlement before Skatteverkets beslut is
  recorded (would bypass the PATCH lifecycle and strand the request);
  block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on
  12-digit brf orgnr in both schema validation and normalizeBrfOrgNr

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: rename branch migrations off main's colliding versions

After the merge with main, two versions were shared by two files each
(20260702100000: rot_rut_payout_requests vs company_settings_dimensions_
enabled; 20260702130000: invoice_email_texts vs pending_operations_add_
create_dimension_value). psql-based CI applies by filename and doesn't
care, but Supabase branching records migrations by version (PK) — the
second file with the same version breaks the preview with a
schema_migrations_pkey duplicate. Neither branch migration is version-
recorded on staging or prod, so renaming to fresh 20260703 versions is
safe; nothing between the old and new positions depends on these objects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces

Any Authorization header — attacker-controlled — used to skip the AAL2
gate for every /api route, so a stolen-password AAL1 cookie session could
reach cookie-authenticated routes (which ignore the header) by attaching
`Authorization: x`. The skip is now scoped to the surfaces whose auth
contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth
tokens); pure Bearer callers elsewhere (cron secret, signed webhooks)
carry no cookie session and were never touched by the gate, which only
fires for cookie users. Superagent P2 on PR #878.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: normalize path separators in dimension statutory guard scan

The route scan compared walked file paths against a POSIX-path allowlist,
so the suite failed on Windows (backslash separators) while passing on
Linux CI. Normalize the scanned paths to forward slashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-03 13:57:59 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 678f2ccffd
commit 237b77a366
61 changed files with 7695 additions and 394 deletions
@@ -0,0 +1,114 @@
-- Custom inbound email domains for the invoice-inbox extension.
--
-- Today every company receives supplier invoices on a generated address on
-- the single shared Resend inbound domain (company_inboxes.local_part @
-- RESEND_INBOUND_DOMAIN). This table lets a company verify its own domain
-- (or subdomain) via Resend's domain API — once status = 'verified', the
-- inbound webhook routes mail for ANY local part on that domain to the
-- company (catch-all), with no forwarding step.
--
-- Design notes:
-- * Separate table (not columns on company_inboxes): domain verification
-- is a domain lifecycle, while company_inboxes models rotating addresses
-- on the shared domain. Keeping them apart leaves rotate_company_inbox()
-- and its unique indexes untouched.
-- * No user_id column — mirrors company_inboxes. The row is company
-- configuration that must outlive the user who created it; a cascade on
-- user deletion would silently drop a working mail route.
-- * Global unique on lower(domain): one company owns a domain, across all
-- tenants. Rows are hard-deleted on removal, which frees the name.
-- * One custom domain per company (unique on company_id) — v1 scope;
-- relaxing later is a constraint drop, not a remodel.
-- * Only rows with status = 'verified' ever route mail. 'pending' claims
-- must never receive email — DNS verification is the ownership proof.
-- =============================================================================
-- 1. Table
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.company_inbound_domains (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- Lowercased, punycoded hostname (validated app-side before insert).
domain text NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'verified', 'failed')),
-- Resend's domain id + the DNS records the user must publish (records[]
-- from the Resend API response, rendered verbatim in the UI).
resend_domain_id text,
dns_records jsonb,
verified_at timestamptz,
last_checked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- A domain belongs to exactly one company, across all tenants.
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_inbound_domains_domain
ON public.company_inbound_domains (lower(domain));
-- One custom domain per company (v1).
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_inbound_domains_company
ON public.company_inbound_domains (company_id);
-- =============================================================================
-- 2. RLS — SELECT for members, writes for owner/admin only
-- (mirrors the tightened company_inboxes policies from
-- 20260420190000_inbox_hardening.sql)
-- =============================================================================
ALTER TABLE public.company_inbound_domains ENABLE ROW LEVEL SECURITY;
-- Idempotent: staging gets this DDL applied manually ahead of the branch
-- merge, so a later replay of this migration must not fail on existing
-- policies/triggers.
DROP POLICY IF EXISTS "company_inbound_domains_select" ON public.company_inbound_domains;
CREATE POLICY "company_inbound_domains_select" ON public.company_inbound_domains
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
DROP POLICY IF EXISTS "company_inbound_domains_insert" ON public.company_inbound_domains;
CREATE POLICY "company_inbound_domains_insert" ON public.company_inbound_domains
FOR INSERT WITH CHECK (
company_id IN (
SELECT cm.company_id FROM public.company_members cm
WHERE cm.user_id = auth.uid()
AND cm.role IN ('owner', 'admin')
)
);
DROP POLICY IF EXISTS "company_inbound_domains_update" ON public.company_inbound_domains;
CREATE POLICY "company_inbound_domains_update" ON public.company_inbound_domains
FOR UPDATE USING (
company_id IN (
SELECT cm.company_id FROM public.company_members cm
WHERE cm.user_id = auth.uid()
AND cm.role IN ('owner', 'admin')
)
);
DROP POLICY IF EXISTS "company_inbound_domains_delete" ON public.company_inbound_domains;
CREATE POLICY "company_inbound_domains_delete" ON public.company_inbound_domains
FOR DELETE USING (
company_id IN (
SELECT cm.company_id FROM public.company_members cm
WHERE cm.user_id = auth.uid()
AND cm.role IN ('owner', 'admin')
)
);
-- =============================================================================
-- 3. Triggers
-- =============================================================================
DROP TRIGGER IF EXISTS company_inbound_domains_updated_at ON public.company_inbound_domains;
CREATE TRIGGER company_inbound_domains_updated_at
BEFORE UPDATE ON public.company_inbound_domains
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Domain claims change where a company's mail is routed — audit them.
DROP TRIGGER IF EXISTS audit_company_inbound_domains ON public.company_inbound_domains;
CREATE TRIGGER audit_company_inbound_domains
AFTER INSERT OR UPDATE OR DELETE ON public.company_inbound_domains
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,761 @@
-- Role-gated write authorization + tenant guards on voucher SECURITY DEFINER RPCs.
--
-- WHY THIS MIGRATION EXISTS
-- -------------------------
-- The staging/dev database has carried, for some time, an authorization
-- refactor that was never captured as a migration and therefore never reached
-- production:
--
-- * public.current_active_company_id() — the single active company for the
-- caller (user_preferences.active_company_id, validated against a live,
-- non-archived membership; falls back to the earliest membership).
-- * public.current_user_can_write() — true iff the caller is a non-viewer
-- member of that active company.
-- * Every company-scoped WRITE policy AND-s in current_user_can_write() and
-- scopes the company to current_active_company_id(), so a `viewer` (or any
-- non-member) cannot INSERT/UPDATE/DELETE tenant data by calling PostgREST
-- directly — the app-layer requireWritePermission() guard is no longer the
-- only thing standing between a viewer and a write.
--
-- lib/supabase/middleware.ts and lib/auth/require-write.ts already document and
-- rely on these functions; the application is written for this design. On
-- production the write policies still gate on membership only
-- (company_id IN (SELECT user_company_ids())), so a viewer CAN currently write
-- via the API. This migration makes the repository the source of truth and,
-- when deployed, brings production in line with staging.
--
-- It ALSO hardens three voucher SECURITY DEFINER RPCs that were EXECUTE-able by
-- anon/authenticated with NO caller-membership check (a cross-tenant hole: any
-- authenticated caller who knows a draft UUID could POST
-- /rest/v1/rpc/commit_journal_entry with a foreign company_id and post that
-- tenant's draft). The guard is the canonical one from
-- 20260619130100_securitydefiner_write_rpc_tenant_guards.sql: it reads the
-- request.jwt.claims role and only constrains anon/authenticated callers.
-- service_role / backend callers (no JWT role — the engine's service-role
-- commit path, the MCP/API-key path whose company scoping happens in TS, the
-- pg-real harness, migrations) bypass BY DESIGN, so those flows are unaffected.
--
-- Everything below is idempotent (CREATE OR REPLACE / DROP POLICY IF EXISTS),
-- so it is a no-op against staging (which already has the policies) and a
-- corrective apply against production.
-- =============================================================================
-- 1. Authorization helper functions
-- =============================================================================
CREATE OR REPLACE FUNCTION public.current_active_company_id()
RETURNS uuid
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT COALESCE(
(
SELECT up.active_company_id
FROM public.user_preferences up
JOIN public.company_members cm
ON cm.user_id = up.user_id AND cm.company_id = up.active_company_id
JOIN public.companies c
ON c.id = cm.company_id AND c.archived_at IS NULL
WHERE up.user_id = auth.uid()
),
(
SELECT cm.company_id
FROM public.company_members cm
JOIN public.companies c
ON c.id = cm.company_id AND c.archived_at IS NULL
WHERE cm.user_id = auth.uid()
ORDER BY cm.created_at ASC
LIMIT 1
)
);
$function$;
CREATE OR REPLACE FUNCTION public.current_user_can_write()
RETURNS boolean
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.user_id = auth.uid()
AND cm.company_id = public.current_active_company_id()
AND cm.role <> 'viewer'
);
$function$;
-- =============================================================================
-- 2. Role-gated write policies (39 tables)
-- DDL generated verbatim from the live staging catalog (pg_policies) so the
-- predicates match exactly what the app has been running against.
-- =============================================================================
-- NOTE: the staging catalog also carried an ai_usage_tracking_insert policy,
-- but public.ai_usage_tracking was dropped by
-- 20260504120000_remove_ai_subsystem.sql and no longer exists in the canonical
-- schema — the table on staging is drift. That policy is intentionally NOT
-- recreated here; a from-scratch migration chain would fail on it.
-- automation_webhooks was renamed to public.webhooks by
-- 20260515170000_webhooks_v2 — the staging catalog still carried the
-- pre-rename table name (drift). Gate the canonical table, dropping both the
-- staging-era policy names and the legacy schema-sync names defensively
-- (policies follow a table rename but keep their original names).
DROP POLICY IF EXISTS automation_webhooks_insert ON public.webhooks;
DROP POLICY IF EXISTS "Members can insert company webhooks" ON public.webhooks;
DROP POLICY IF EXISTS webhooks_insert ON public.webhooks;
CREATE POLICY webhooks_insert ON public.webhooks FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS automation_webhooks_update ON public.webhooks;
DROP POLICY IF EXISTS "Members can update company webhooks" ON public.webhooks;
DROP POLICY IF EXISTS webhooks_update ON public.webhooks;
CREATE POLICY webhooks_update ON public.webhooks FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS automation_webhooks_delete ON public.webhooks;
DROP POLICY IF EXISTS "Members can delete company webhooks" ON public.webhooks;
DROP POLICY IF EXISTS webhooks_delete ON public.webhooks;
CREATE POLICY webhooks_delete ON public.webhooks FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS bank_connections_insert ON public.bank_connections;
CREATE POLICY bank_connections_insert ON public.bank_connections FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS bank_connections_update ON public.bank_connections;
CREATE POLICY bank_connections_update ON public.bank_connections FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS bank_connections_delete ON public.bank_connections;
CREATE POLICY bank_connections_delete ON public.bank_connections FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS bank_file_imports_insert ON public.bank_file_imports;
CREATE POLICY bank_file_imports_insert ON public.bank_file_imports FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS bank_file_imports_update ON public.bank_file_imports;
CREATE POLICY bank_file_imports_update ON public.bank_file_imports FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS bank_file_imports_delete ON public.bank_file_imports;
CREATE POLICY bank_file_imports_delete ON public.bank_file_imports FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS btl_insert ON public.booking_template_library;
CREATE POLICY btl_insert ON public.booking_template_library FOR INSERT TO public
WITH CHECK (((NOT is_system) AND current_user_can_write() AND ((company_id = current_active_company_id()) OR ((company_id IS NULL) AND (team_id IN ( SELECT user_team_ids() AS user_team_ids))))));
DROP POLICY IF EXISTS btl_update ON public.booking_template_library;
CREATE POLICY btl_update ON public.booking_template_library FOR UPDATE TO public
USING (((NOT is_system) AND current_user_can_write() AND ((company_id IN ( SELECT user_company_ids() AS user_company_ids)) OR ((company_id IS NULL) AND (team_id IN ( SELECT user_team_ids() AS user_team_ids))))));
DROP POLICY IF EXISTS btl_delete ON public.booking_template_library;
CREATE POLICY btl_delete ON public.booking_template_library FOR DELETE TO public
USING (((NOT is_system) AND current_user_can_write() AND ((company_id IN ( SELECT user_company_ids() AS user_company_ids)) OR ((company_id IS NULL) AND (team_id IN ( SELECT user_team_ids() AS user_team_ids))))));
DROP POLICY IF EXISTS calendar_feeds_insert ON public.calendar_feeds;
CREATE POLICY calendar_feeds_insert ON public.calendar_feeds FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS calendar_feeds_update ON public.calendar_feeds;
CREATE POLICY calendar_feeds_update ON public.calendar_feeds FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS calendar_feeds_delete ON public.calendar_feeds;
CREATE POLICY calendar_feeds_delete ON public.calendar_feeds FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS categorization_templates_insert ON public.categorization_templates;
CREATE POLICY categorization_templates_insert ON public.categorization_templates FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS categorization_templates_update ON public.categorization_templates;
CREATE POLICY categorization_templates_update ON public.categorization_templates FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS categorization_templates_delete ON public.categorization_templates;
CREATE POLICY categorization_templates_delete ON public.categorization_templates FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chart_of_accounts_insert ON public.chart_of_accounts;
CREATE POLICY chart_of_accounts_insert ON public.chart_of_accounts FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chart_of_accounts_update ON public.chart_of_accounts;
CREATE POLICY chart_of_accounts_update ON public.chart_of_accounts FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chart_of_accounts_delete ON public.chart_of_accounts;
CREATE POLICY chart_of_accounts_delete ON public.chart_of_accounts FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chat_messages_insert ON public.chat_messages;
CREATE POLICY chat_messages_insert ON public.chat_messages FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chat_messages_update ON public.chat_messages;
CREATE POLICY chat_messages_update ON public.chat_messages FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chat_messages_delete ON public.chat_messages;
CREATE POLICY chat_messages_delete ON public.chat_messages FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chat_sessions_insert ON public.chat_sessions;
CREATE POLICY chat_sessions_insert ON public.chat_sessions FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chat_sessions_update ON public.chat_sessions;
CREATE POLICY chat_sessions_update ON public.chat_sessions FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS chat_sessions_delete ON public.chat_sessions;
CREATE POLICY chat_sessions_delete ON public.chat_sessions FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS company_settings_delete ON public.company_settings;
CREATE POLICY company_settings_delete ON public.company_settings FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS cost_centers_insert ON public.cost_centers;
CREATE POLICY cost_centers_insert ON public.cost_centers FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS cost_centers_update ON public.cost_centers;
CREATE POLICY cost_centers_update ON public.cost_centers FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS cost_centers_delete ON public.cost_centers;
CREATE POLICY cost_centers_delete ON public.cost_centers FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS customers_insert ON public.customers;
CREATE POLICY customers_insert ON public.customers FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS customers_update ON public.customers;
CREATE POLICY customers_update ON public.customers FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS customers_delete ON public.customers;
CREATE POLICY customers_delete ON public.customers FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS deadlines_insert ON public.deadlines;
CREATE POLICY deadlines_insert ON public.deadlines FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS deadlines_update ON public.deadlines;
CREATE POLICY deadlines_update ON public.deadlines FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS deadlines_delete ON public.deadlines;
CREATE POLICY deadlines_delete ON public.deadlines FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS document_attachments_insert ON public.document_attachments;
CREATE POLICY document_attachments_insert ON public.document_attachments FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS document_attachments_update ON public.document_attachments;
CREATE POLICY document_attachments_update ON public.document_attachments FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS document_attachments_delete ON public.document_attachments;
CREATE POLICY document_attachments_delete ON public.document_attachments FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS extension_data_insert ON public.extension_data;
CREATE POLICY extension_data_insert ON public.extension_data FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS extension_data_update ON public.extension_data;
CREATE POLICY extension_data_update ON public.extension_data FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS extension_data_delete ON public.extension_data;
CREATE POLICY extension_data_delete ON public.extension_data FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS fiscal_periods_insert ON public.fiscal_periods;
CREATE POLICY fiscal_periods_insert ON public.fiscal_periods FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS fiscal_periods_update ON public.fiscal_periods;
CREATE POLICY fiscal_periods_update ON public.fiscal_periods FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS fiscal_periods_delete ON public.fiscal_periods;
CREATE POLICY fiscal_periods_delete ON public.fiscal_periods FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_inbox_items_insert ON public.invoice_inbox_items;
CREATE POLICY invoice_inbox_items_insert ON public.invoice_inbox_items FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_inbox_items_update ON public.invoice_inbox_items;
CREATE POLICY invoice_inbox_items_update ON public.invoice_inbox_items FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_inbox_items_delete ON public.invoice_inbox_items;
CREATE POLICY invoice_inbox_items_delete ON public.invoice_inbox_items FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_items_insert ON public.invoice_items;
CREATE POLICY invoice_items_insert ON public.invoice_items FOR INSERT TO public
WITH CHECK (((EXISTS ( SELECT 1
FROM invoices i
WHERE ((i.id = invoice_items.invoice_id) AND (i.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_items_update ON public.invoice_items;
CREATE POLICY invoice_items_update ON public.invoice_items FOR UPDATE TO public
USING (((EXISTS ( SELECT 1
FROM invoices i
WHERE ((i.id = invoice_items.invoice_id) AND (i.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_items_delete ON public.invoice_items;
CREATE POLICY invoice_items_delete ON public.invoice_items FOR DELETE TO public
USING (((EXISTS ( SELECT 1
FROM invoices i
WHERE ((i.id = invoice_items.invoice_id) AND (i.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_payments_insert ON public.invoice_payments;
CREATE POLICY invoice_payments_insert ON public.invoice_payments FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_payments_update ON public.invoice_payments;
CREATE POLICY invoice_payments_update ON public.invoice_payments FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_payments_delete ON public.invoice_payments;
CREATE POLICY invoice_payments_delete ON public.invoice_payments FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_reminders_insert ON public.invoice_reminders;
CREATE POLICY invoice_reminders_insert ON public.invoice_reminders FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_reminders_update ON public.invoice_reminders;
CREATE POLICY invoice_reminders_update ON public.invoice_reminders FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoice_reminders_delete ON public.invoice_reminders;
CREATE POLICY invoice_reminders_delete ON public.invoice_reminders FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoices_insert ON public.invoices;
CREATE POLICY invoices_insert ON public.invoices FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoices_update ON public.invoices;
CREATE POLICY invoices_update ON public.invoices FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS invoices_delete ON public.invoices;
CREATE POLICY invoices_delete ON public.invoices FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS journal_entries_insert ON public.journal_entries;
CREATE POLICY journal_entries_insert ON public.journal_entries FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS journal_entries_update ON public.journal_entries;
CREATE POLICY journal_entries_update ON public.journal_entries FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS journal_entries_delete ON public.journal_entries;
CREATE POLICY journal_entries_delete ON public.journal_entries FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS journal_entry_lines_insert ON public.journal_entry_lines;
CREATE POLICY journal_entry_lines_insert ON public.journal_entry_lines FOR INSERT TO public
WITH CHECK (((EXISTS ( SELECT 1
FROM journal_entries je
WHERE ((je.id = journal_entry_lines.journal_entry_id) AND (je.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS journal_entry_lines_update ON public.journal_entry_lines;
CREATE POLICY journal_entry_lines_update ON public.journal_entry_lines FOR UPDATE TO public
USING (((EXISTS ( SELECT 1
FROM journal_entries je
WHERE ((je.id = journal_entry_lines.journal_entry_id) AND (je.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS journal_entry_lines_delete ON public.journal_entry_lines;
CREATE POLICY journal_entry_lines_delete ON public.journal_entry_lines FOR DELETE TO public
USING (((EXISTS ( SELECT 1
FROM journal_entries je
WHERE ((je.id = journal_entry_lines.journal_entry_id) AND (je.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS mapping_rules_insert ON public.mapping_rules;
CREATE POLICY mapping_rules_insert ON public.mapping_rules FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS mapping_rules_update ON public.mapping_rules;
CREATE POLICY mapping_rules_update ON public.mapping_rules FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS mapping_rules_delete ON public.mapping_rules;
CREATE POLICY mapping_rules_delete ON public.mapping_rules FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS payment_match_log_insert ON public.payment_match_log;
CREATE POLICY payment_match_log_insert ON public.payment_match_log FOR INSERT TO public
WITH CHECK ((((company_id = current_active_company_id()) OR (company_id IS NULL)) AND current_user_can_write()));
DROP POLICY IF EXISTS pending_operations_update ON public.pending_operations;
CREATE POLICY pending_operations_update ON public.pending_operations FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS projects_insert ON public.projects;
CREATE POLICY projects_insert ON public.projects FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS projects_update ON public.projects;
CREATE POLICY projects_update ON public.projects FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS projects_delete ON public.projects;
CREATE POLICY projects_delete ON public.projects FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS provider_consents_insert ON public.provider_consents;
CREATE POLICY provider_consents_insert ON public.provider_consents FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS provider_consents_update ON public.provider_consents;
CREATE POLICY provider_consents_update ON public.provider_consents FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS provider_consents_delete ON public.provider_consents;
CREATE POLICY provider_consents_delete ON public.provider_consents FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS receipt_line_items_insert ON public.receipt_line_items;
CREATE POLICY receipt_line_items_insert ON public.receipt_line_items FOR INSERT TO public
WITH CHECK (((EXISTS ( SELECT 1
FROM receipts r
WHERE ((r.id = receipt_line_items.receipt_id) AND (r.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS receipt_line_items_update ON public.receipt_line_items;
CREATE POLICY receipt_line_items_update ON public.receipt_line_items FOR UPDATE TO public
USING (((EXISTS ( SELECT 1
FROM receipts r
WHERE ((r.id = receipt_line_items.receipt_id) AND (r.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS receipt_line_items_delete ON public.receipt_line_items;
CREATE POLICY receipt_line_items_delete ON public.receipt_line_items FOR DELETE TO public
USING (((EXISTS ( SELECT 1
FROM receipts r
WHERE ((r.id = receipt_line_items.receipt_id) AND (r.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS receipts_insert ON public.receipts;
CREATE POLICY receipts_insert ON public.receipts FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS receipts_update ON public.receipts;
CREATE POLICY receipts_update ON public.receipts FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS receipts_delete ON public.receipts;
CREATE POLICY receipts_delete ON public.receipts FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS sie_account_mappings_insert ON public.sie_account_mappings;
CREATE POLICY sie_account_mappings_insert ON public.sie_account_mappings FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS sie_account_mappings_update ON public.sie_account_mappings;
CREATE POLICY sie_account_mappings_update ON public.sie_account_mappings FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS sie_account_mappings_delete ON public.sie_account_mappings;
CREATE POLICY sie_account_mappings_delete ON public.sie_account_mappings FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS sie_imports_insert ON public.sie_imports;
CREATE POLICY sie_imports_insert ON public.sie_imports FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS sie_imports_update ON public.sie_imports;
CREATE POLICY sie_imports_update ON public.sie_imports FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS sie_imports_delete ON public.sie_imports;
CREATE POLICY sie_imports_delete ON public.sie_imports FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS skatteverket_tokens_insert ON public.skatteverket_tokens;
CREATE POLICY skatteverket_tokens_insert ON public.skatteverket_tokens FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS skatteverket_tokens_update ON public.skatteverket_tokens;
CREATE POLICY skatteverket_tokens_update ON public.skatteverket_tokens FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS skatteverket_tokens_delete ON public.skatteverket_tokens;
CREATE POLICY skatteverket_tokens_delete ON public.skatteverket_tokens FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoice_items_insert ON public.supplier_invoice_items;
CREATE POLICY supplier_invoice_items_insert ON public.supplier_invoice_items FOR INSERT TO public
WITH CHECK (((EXISTS ( SELECT 1
FROM supplier_invoices si
WHERE ((si.id = supplier_invoice_items.supplier_invoice_id) AND (si.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoice_items_update ON public.supplier_invoice_items;
CREATE POLICY supplier_invoice_items_update ON public.supplier_invoice_items FOR UPDATE TO public
USING (((EXISTS ( SELECT 1
FROM supplier_invoices si
WHERE ((si.id = supplier_invoice_items.supplier_invoice_id) AND (si.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoice_items_delete ON public.supplier_invoice_items;
CREATE POLICY supplier_invoice_items_delete ON public.supplier_invoice_items FOR DELETE TO public
USING (((EXISTS ( SELECT 1
FROM supplier_invoices si
WHERE ((si.id = supplier_invoice_items.supplier_invoice_id) AND (si.company_id = current_active_company_id())))) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoice_payments_insert ON public.supplier_invoice_payments;
CREATE POLICY supplier_invoice_payments_insert ON public.supplier_invoice_payments FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoice_payments_update ON public.supplier_invoice_payments;
CREATE POLICY supplier_invoice_payments_update ON public.supplier_invoice_payments FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoice_payments_delete ON public.supplier_invoice_payments;
CREATE POLICY supplier_invoice_payments_delete ON public.supplier_invoice_payments FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoices_insert ON public.supplier_invoices;
CREATE POLICY supplier_invoices_insert ON public.supplier_invoices FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoices_update ON public.supplier_invoices;
CREATE POLICY supplier_invoices_update ON public.supplier_invoices FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS supplier_invoices_delete ON public.supplier_invoices;
CREATE POLICY supplier_invoices_delete ON public.supplier_invoices FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS suppliers_insert ON public.suppliers;
CREATE POLICY suppliers_insert ON public.suppliers FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS suppliers_update ON public.suppliers;
CREATE POLICY suppliers_update ON public.suppliers FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS suppliers_delete ON public.suppliers;
CREATE POLICY suppliers_delete ON public.suppliers FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS transactions_insert ON public.transactions;
CREATE POLICY transactions_insert ON public.transactions FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS transactions_update ON public.transactions;
CREATE POLICY transactions_update ON public.transactions FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS transactions_delete ON public.transactions;
CREATE POLICY transactions_delete ON public.transactions FOR DELETE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS voucher_gap_explanations_insert ON public.voucher_gap_explanations;
CREATE POLICY voucher_gap_explanations_insert ON public.voucher_gap_explanations FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write() AND (EXISTS ( SELECT 1
FROM (team_members tm
JOIN companies c ON ((c.team_id = tm.team_id)))
WHERE ((c.id = voucher_gap_explanations.company_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['owner'::text, 'admin'::text])))))));
DROP POLICY IF EXISTS voucher_gap_explanations_update ON public.voucher_gap_explanations;
CREATE POLICY voucher_gap_explanations_update ON public.voucher_gap_explanations FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write() AND (EXISTS ( SELECT 1
FROM (team_members tm
JOIN companies c ON ((c.team_id = tm.team_id)))
WHERE ((c.id = voucher_gap_explanations.company_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['owner'::text, 'admin'::text])))))));
DROP POLICY IF EXISTS voucher_sequences_insert ON public.voucher_sequences;
CREATE POLICY voucher_sequences_insert ON public.voucher_sequences FOR INSERT TO public
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
DROP POLICY IF EXISTS voucher_sequences_update ON public.voucher_sequences;
CREATE POLICY voucher_sequences_update ON public.voucher_sequences FOR UPDATE TO public
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
-- =============================================================================
-- 3. Tenant guards on voucher SECURITY DEFINER RPCs
-- Bodies copied verbatim from their latest definitions; only the v_jwt_role
-- DECLARE + guard block, SET search_path = public, and the REVOKE/GRANT are
-- added. anon/authenticated cross-tenant callers are refused (42501);
-- service_role / backend (no JWT role) bypass BY DESIGN.
-- =============================================================================
-- 3a. commit_journal_entry
CREATE OR REPLACE FUNCTION public.commit_journal_entry(
p_company_id uuid,
p_entry_id uuid,
p_commit_method text DEFAULT NULL::text,
p_rubric_version text DEFAULT NULL::text,
p_actor_type text DEFAULT NULL::text,
p_actor_label text DEFAULT NULL::text
)
RETURNS TABLE(voucher_number integer)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $function$
DECLARE
v_next integer;
v_fiscal_period_id uuid;
v_series text;
v_entry_user_id uuid;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
BEGIN
-- Tenant guard: anon/authenticated may only commit entries in their own
-- companies; service_role / backend (no JWT role) bypasses BY DESIGN.
IF v_jwt_role IN ('anon', 'authenticated')
AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
PERFORM set_config('gnubok.actor_type', coalesce(p_actor_type, ''), true);
PERFORM set_config('gnubok.actor_label', coalesce(p_actor_label, ''), true);
SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A'), je.user_id
INTO v_fiscal_period_id, v_series, v_entry_user_id
FROM public.journal_entries je
WHERE je.id = p_entry_id
AND je.company_id = p_company_id
AND je.status = 'draft'
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Draft journal entry not found: %', p_entry_id;
END IF;
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
VALUES (p_company_id, COALESCE(auth.uid(), v_entry_user_id), v_fiscal_period_id, v_series, 1)
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
DO UPDATE SET
last_number = public.voucher_sequences.last_number + 1,
updated_at = now()
RETURNING last_number INTO v_next;
UPDATE public.journal_entries
SET voucher_number = v_next,
status = 'posted',
commit_method = p_commit_method,
rubric_version = p_rubric_version,
committed_actor_type = p_actor_type,
committed_actor_label = p_actor_label
WHERE id = p_entry_id
AND company_id = p_company_id;
RETURN QUERY SELECT v_next;
END;
$function$;
REVOKE ALL ON FUNCTION public.commit_journal_entry(uuid, uuid, text, text, text, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.commit_journal_entry(uuid, uuid, text, text, text, text) TO authenticated;
-- 3b. next_voucher_number
CREATE OR REPLACE FUNCTION public.next_voucher_number(
p_company_id uuid,
p_fiscal_period_id uuid,
p_series text DEFAULT 'A'::text
)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $function$
DECLARE
v_next integer;
v_user_id uuid;
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
BEGIN
IF v_jwt_role IN ('anon', 'authenticated')
AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- Preserved from 20260623130000: under a service-role client auth.uid() is
-- NULL and the INSERT would fail user_id NOT NULL before ON CONFLICT
-- arbitration — fall back to the company owner.
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
SELECT created_by INTO v_user_id
FROM public.companies
WHERE id = p_company_id;
END IF;
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'next_voucher_number: no attributable user for company %', p_company_id;
END IF;
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
VALUES (p_company_id, v_user_id, p_fiscal_period_id, p_series, 1)
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
DO UPDATE SET
last_number = public.voucher_sequences.last_number + 1,
updated_at = now()
RETURNING last_number INTO v_next;
RETURN v_next;
END;
$function$;
REVOKE ALL ON FUNCTION public.next_voucher_number(uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.next_voucher_number(uuid, uuid, text) TO authenticated;
-- 3c. detect_voucher_gaps
CREATE OR REPLACE FUNCTION public.detect_voucher_gaps(
p_company_id uuid,
p_fiscal_period_id uuid,
p_series text DEFAULT 'A'::text
)
RETURNS TABLE(gap_start integer, gap_end integer)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $function$
DECLARE
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
BEGIN
IF v_jwt_role IN ('anon', 'authenticated')
AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
RETURN QUERY
WITH numbered AS (
SELECT voucher_number,
LEAD(voucher_number) OVER (ORDER BY voucher_number) AS next_number
FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND voucher_series = p_series
AND status != 'draft'
ORDER BY voucher_number
)
SELECT
voucher_number + 1 AS gap_start,
next_number - 1 AS gap_end
FROM numbered
WHERE next_number IS NOT NULL
AND next_number > voucher_number + 1;
END;
$function$;
REVOKE ALL ON FUNCTION public.detect_voucher_gaps(uuid, uuid, text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.detect_voucher_gaps(uuid, uuid, text) TO authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,269 @@
-- Rot & rut: begäran om utbetalning (Skatteverkets husavdragstjänst)
--
-- Tracks generated payout-request files (HUS XML, schema V6 — see
-- dev_docs/skatteverket/husavdrag/) so an invoice can never end up in two
-- active begäran, and so the Skatteverket outcome (utbetalt/avslag) can be
-- recorded and settled against BAS 1513.
--
-- Also adds invoice_items.brf_org_number: ROT i bostadsrätt is reported with
-- bostadsrättsföreningens orgnr + lägenhetsnummer instead of
-- fastighetsbeteckning (BegaranCOMPONENT.xsd: BrfOrgNr, max 12 chars).
-- =============================================================================
-- 1. invoice_items.brf_org_number
-- =============================================================================
ALTER TABLE public.invoice_items
ADD COLUMN IF NOT EXISTS brf_org_number TEXT NULL;
-- Format (digits/dash, XSD BrfOrgNrTYPE) is validated at the API layer via
-- Zod, same approach as the other ROT/RUT columns (20260526121700). The DB
-- only guards the hard XSD length cap.
ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_brf_org_number_check;
ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_brf_org_number_check
CHECK (brf_org_number IS NULL OR char_length(brf_org_number) <= 12);
-- =============================================================================
-- 2. rot_rut_payout_requests — one row per generated begäran-fil
-- =============================================================================
CREATE TABLE public.rot_rut_payout_requests (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
deduction_type TEXT NOT NULL CHECK (deduction_type IN ('rot', 'rut')),
-- NamnPaBegaranTYPE: 1–16 chars, shown in Skatteverkets e-tjänst.
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 16),
-- Lifecycle: generated → submitted → paid | partially_paid | rejected.
-- cancelled is allowed from generated/submitted (file never uploaded, or
-- withdrawn before beslut). Transitions are enforced at the API layer;
-- the DB constrains the value set.
status TEXT NOT NULL DEFAULT 'generated' CHECK (status IN (
'generated', 'submitted', 'paid', 'partially_paid', 'rejected', 'cancelled'
)),
-- Sum of item requested_amount (kr). Denormalized for list views.
requested_total NUMERIC(12,2) NOT NULL CHECK (requested_total >= 0),
-- Filled in when Skatteverkets beslut is recorded.
decided_total NUMERIC(12,2) NULL CHECK (decided_total >= 0),
-- The archived XML file (räkenskapsinformation, 7-year retention via the
-- document_attachments WORM chain).
file_name TEXT NOT NULL,
file_document_id uuid NULL REFERENCES public.document_attachments(id) ON DELETE SET NULL,
-- Settlement voucher (debit 1930 / credit 1513) once utbetalningen booked.
settlement_journal_entry_id uuid NULL REFERENCES public.journal_entries(id) ON DELETE SET NULL,
submitted_at timestamptz NULL,
decided_at timestamptz NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.rot_rut_payout_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company rot_rut_payout_requests"
ON public.rot_rut_payout_requests FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company rot_rut_payout_requests"
ON public.rot_rut_payout_requests FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company rot_rut_payout_requests"
ON public.rot_rut_payout_requests FOR UPDATE
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "delete own-company rot_rut_payout_requests"
ON public.rot_rut_payout_requests FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
CREATE INDEX idx_rot_rut_payout_requests_company_id
ON public.rot_rut_payout_requests (company_id);
CREATE INDEX idx_rot_rut_payout_requests_company_status
ON public.rot_rut_payout_requests (company_id, status);
CREATE TRIGGER set_updated_at_rot_rut_payout_requests
BEFORE UPDATE ON public.rot_rut_payout_requests
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_rot_rut_payout_requests
AFTER INSERT OR UPDATE OR DELETE ON public.rot_rut_payout_requests
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- =============================================================================
-- 3. rot_rut_payout_request_items — one row per (request, invoice)
-- =============================================================================
CREATE TABLE public.rot_rut_payout_request_items (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
request_id uuid NOT NULL REFERENCES public.rot_rut_payout_requests(id) ON DELETE CASCADE,
-- RESTRICT: an invoice referenced by a begäran is bokföringsunderlag and
-- must not disappear from under the request.
invoice_id uuid NOT NULL REFERENCES public.invoices(id) ON DELETE RESTRICT,
-- BegartBelopp for this invoice (kr, whole kronor in the file; stored with
-- öre precision because it mirrors invoices.deduction_total).
requested_amount NUMERIC(12,2) NOT NULL CHECK (requested_amount > 0),
-- Godkänt belopp from Skatteverkets beslut (null until decided).
decided_amount NUMERIC(12,2) NULL CHECK (decided_amount >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (request_id, invoice_id)
);
ALTER TABLE public.rot_rut_payout_request_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company rot_rut_payout_request_items"
ON public.rot_rut_payout_request_items FOR SELECT
USING (EXISTS (
SELECT 1 FROM public.rot_rut_payout_requests r
WHERE r.id = rot_rut_payout_request_items.request_id
AND r.company_id IN (SELECT user_company_ids())
));
CREATE POLICY "insert own-company rot_rut_payout_request_items"
ON public.rot_rut_payout_request_items FOR INSERT
WITH CHECK (EXISTS (
SELECT 1 FROM public.rot_rut_payout_requests r
WHERE r.id = rot_rut_payout_request_items.request_id
AND r.company_id IN (SELECT user_company_ids())
));
CREATE POLICY "update own-company rot_rut_payout_request_items"
ON public.rot_rut_payout_request_items FOR UPDATE
USING (EXISTS (
SELECT 1 FROM public.rot_rut_payout_requests r
WHERE r.id = rot_rut_payout_request_items.request_id
AND r.company_id IN (SELECT user_company_ids())
));
CREATE POLICY "delete own-company rot_rut_payout_request_items"
ON public.rot_rut_payout_request_items FOR DELETE
USING (EXISTS (
SELECT 1 FROM public.rot_rut_payout_requests r
WHERE r.id = rot_rut_payout_request_items.request_id
AND r.company_id IN (SELECT user_company_ids())
));
CREATE INDEX idx_rot_rut_payout_request_items_request_id
ON public.rot_rut_payout_request_items (request_id);
CREATE INDEX idx_rot_rut_payout_request_items_invoice_id
ON public.rot_rut_payout_request_items (invoice_id);
CREATE TRIGGER set_updated_at_rot_rut_payout_request_items
BEFORE UPDATE ON public.rot_rut_payout_request_items
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_rot_rut_payout_request_items
AFTER INSERT OR UPDATE OR DELETE ON public.rot_rut_payout_request_items
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- =============================================================================
-- 4. Integrity guard: one active begäran per invoice + same-company check
--
-- An invoice may appear in any number of cancelled/rejected requests (retry
-- after avslag) but in at most ONE active (generated/submitted/paid/
-- partially_paid) request — otherwise the same deduction could be requested
-- twice. Cross-table invariants can't be expressed as a UNIQUE index across
-- a JOIN, hence the trigger.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.enforce_single_active_rot_rut_request()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_request_company uuid;
v_invoice_company uuid;
BEGIN
SELECT company_id INTO v_request_company
FROM public.rot_rut_payout_requests
WHERE id = NEW.request_id;
SELECT company_id INTO v_invoice_company
FROM public.invoices
WHERE id = NEW.invoice_id;
IF v_invoice_company IS NULL OR v_invoice_company != v_request_company THEN
RAISE EXCEPTION 'Invoice % does not belong to the same company as payout request %',
NEW.invoice_id, NEW.request_id;
END IF;
IF EXISTS (
SELECT 1
FROM public.rot_rut_payout_request_items i
JOIN public.rot_rut_payout_requests r ON r.id = i.request_id
WHERE i.invoice_id = NEW.invoice_id
AND i.id != NEW.id
AND r.status NOT IN ('cancelled', 'rejected')
) THEN
RAISE EXCEPTION 'Invoice % is already included in an active rot/rut payout request',
NEW.invoice_id
USING ERRCODE = '23505';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_single_active_rot_rut_request
BEFORE INSERT OR UPDATE OF invoice_id, request_id ON public.rot_rut_payout_request_items
FOR EACH ROW EXECUTE FUNCTION public.enforce_single_active_rot_rut_request();
-- The item-level trigger can be bypassed by flipping a cancelled/rejected
-- request back to an active status while its invoices have meanwhile been
-- included in another active request. Guard the reactivation path too.
CREATE OR REPLACE FUNCTION public.enforce_rot_rut_request_reactivation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.status IN ('cancelled', 'rejected')
AND NEW.status NOT IN ('cancelled', 'rejected')
AND EXISTS (
SELECT 1
FROM public.rot_rut_payout_request_items mine
JOIN public.rot_rut_payout_request_items other
ON other.invoice_id = mine.invoice_id AND other.request_id != mine.request_id
JOIN public.rot_rut_payout_requests r ON r.id = other.request_id
WHERE mine.request_id = NEW.id
AND r.status NOT IN ('cancelled', 'rejected')
) THEN
RAISE EXCEPTION 'Cannot reactivate payout request %: an invoice is already included in another active request',
NEW.id
USING ERRCODE = '23505';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_rot_rut_request_reactivation
BEFORE UPDATE OF status ON public.rot_rut_payout_requests
FOR EACH ROW EXECUTE FUNCTION public.enforce_rot_rut_request_reactivation();
-- =============================================================================
-- 5. journal_entries.source_type: add 'rot_rut_payout' for the settlement
-- voucher (debit 1930 / credit 1513) booked when Skatteverket pays out.
-- =============================================================================
ALTER TABLE public.journal_entries
DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
ALTER TABLE public.journal_entries
ADD CONSTRAINT journal_entries_source_type_check
CHECK (source_type IN (
'manual', 'bank_transaction', 'invoice_created',
'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment',
'opening_balance', 'year_end',
'storno', 'correction', 'import', 'system',
'inbox_item',
'supplier_invoice_registered', 'supplier_invoice_paid',
'supplier_invoice_cash_payment', 'supplier_credit_note',
'currency_revaluation',
'supplier_invoice_privately_paid',
'reminder_fee',
'accrual',
'result_appropriation',
'rot_rut_payout'
));
-- Reload PostgREST schema cache
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,21 @@
-- Per-company editable invoice email texts (subject, greeting, body, sign-off)
-- in sv + en. NULL column / missing keys / whitespace-only values fall back to
-- the hardcoded defaults in lib/email/invoice-templates.ts. Overrides apply
-- ONLY to standard invoices (document_type = 'invoice' or absent, and not a
-- credit note) — enforced in the template lib, not here.
-- Length limits are enforced by UpdateSettingsSchema (the only write path);
-- mirrors the invoice_late_fee_text / invoice_credit_terms_text precedent.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS invoice_email_texts JSONB NULL;
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_invoice_email_texts_object;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_invoice_email_texts_object
CHECK (invoice_email_texts IS NULL OR jsonb_typeof(invoice_email_texts) = 'object');
COMMENT ON COLUMN public.company_settings.invoice_email_texts IS
'Overrides for the standard-invoice email: { sv?: { subject?, greeting?, body?, signoff? }, en?: {...} }. Placeholders {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp} are substituted at send time by lib/email/invoice-templates.ts. NULL / missing / whitespace-only fields fall back to the hardcoded defaults. Ignored for credit notes, proforma and delivery notes.';
NOTIFY pgrst, 'reload schema';