chore: remove Sentry, consolidate migrations, add test coverage (#244)

* chore: remove Sentry, consolidate migrations, add test coverage

Remove @sentry/nextjs and all Sentry integration code — error tracking
now handled by Recapt. Consolidate 22 incremental migrations into a
single schema sync migration. Add 6 new test suites (auth, invoice
matching, VAT rules, opening balances) and extend report tests with
edge cases. Update Docker image name to gnubok, sync crontabs and
extension presets, fix CSP missing space, simplify journal entry
missing-document dialog.

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

* fix: remove viewer bank import migration never applied to production

20260413150000_viewer_bank_import_permissions.sql (PR #234) was merged
to main but never applied to the production database. It references
current_active_company_id() which does not exist in production either.
This breaks fresh installs and Supabase preview branches because the
migration runs before the consolidated schema sync.

Remove it so the migration chain matches production. The viewer bank
import RLS policies should be re-added in a future migration alongside
the helper functions they depend on.

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

* fix: correct delete policies for tables without company_id column

Seven tables in the generic delete-policy loop don't have a direct
company_id column, causing fresh installs to fail with "column
company_id does not exist". Fix by moving them out of the loop:

- invoice_items, journal_entry_lines, receipt_line_items,
  supplier_invoice_items → join through parent table
- extension_toggles, notification_settings, push_subscriptions →
  user-scoped (auth.uid() = user_id)

All policies match their existing production definitions.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-15 10:52:00 +02:00
committed by GitHub
parent a3fea6fb7c
commit b387a77bfd
62 changed files with 2461 additions and 5486 deletions
@@ -0,0 +1,61 @@
---
name: swedish-tax-planning
description: >
Swedish corporate tax planning (skatteplanering AB) for aktiebolag and fåmansbolag. Covers periodiseringsfond, överavskrivningar, koncernbidrag, 3:12-reglerna (gränsbelopp, löneunderlag, K10, 2026 reform), kapitalförsäkring i bolagskontext, ränteavdragsbegränsningar (EBITDA/N9), lön vs utdelning optimization, and tool interaction strategy. Trigger on ANY Swedish corporate tax planning question: "skatteplanering", "periodiseringsfond", "överavskrivningar", "koncernbidrag", "3:12", "fåmansbolag", "gränsbelopp", "K10", "löneunderlag", "kapitalförsäkring i bolaget", "ränteavdrag", "lön eller utdelning", "minimera skatt AB", "obeskattade reserver", "utdelningsutrymme", or questions about minimizing tax in a Swedish AB. Handles planning logic, not year-end booking mechanics (use swedish-year-end-closing for that).
---
# Swedish Tax Planning for Aktiebolag (Skatteplanering AB)
This skill covers the **planning and optimization logic** for Swedish corporate taxation. It is distinct from the year-end closing skill (which handles booking mechanics) and the VAT skill (which handles moms). The focus here is on how to use tax deferral and rate arbitrage tools to minimize the effective tax burden for AB owners.
## Key base figures (update annually)
| Parameter | 2024 | 2025 | 2026 |
|-----------|------|------|------|
| Bolagsskatt | 20.6% | 20.6% | 20.6% |
| IBB | 76,200 | 80,600 | 83,400 |
| PBB | 57,300 | 58,800 | 59,200 |
| SLR (30 Nov prior year) | 2.62% | 1.96% | 2.55% |
| Arbetsgivaravgifter | 31.42% | 31.42% | 31.42% |
## Reference files
Read the relevant file(s) based on the user's question:
- **`references/periodiseringsfond.md`** -- IL 30 kap, 25% cap, 6-year reversal, schablonintäkt, BAS accounts, pitfalls
- **`references/overavskrivningar.md`** -- IL 18 kap, 30-regeln vs 20-regeln, direktavdrag, BAS accounts, interaction with periodiseringsfond
- **`references/koncernbidrag.md`** -- IL 35 kap, >90% ownership, öppna vs dolda, BAS accounts, underskottsspärr
- **`references/312-regler.md`** -- IL 56-57 kap, fåmansbolag definition, gränsbelopp (förenklingsregeln/huvudregeln), löneunderlag, sparat utdelningsutrymme, K10, 2026 reform
- **`references/kapitalforsakring.md`** -- KF for AB, avkastningsskatt mechanics, BAS accounts, KF vs direktägande vs näringsbetingade andelar
- **`references/ranteavdragsbegransningar.md`** -- IL 24 kap, EBITDA 30%, förenklingsregeln 5 MSEK, riktade regler, carry-forward, N9-blankett
- **`references/strategy-and-interactions.md`** -- Year-end sequencing, lön vs utdelning optimization, tool interactions, Skatteverket audit triggers, skatteflyktslagen
## How to use this skill
When a user asks a tax planning question:
1. Identify the specific tool(s) involved (periodiseringsfond, 3:12, etc.)
2. Read the relevant reference file(s)
3. Answer with specific IL chapter/section references, BAS accounts, and current thresholds
4. Flag the 2026 3:12 reform changes when relevant to the question
5. Always distinguish between items booked in räkenskaperna vs items only in deklarationen
6. When the question spans multiple tools, read `references/strategy-and-interactions.md` for sequencing and interaction effects
## Critical distinctions
- **Periodiseringsfond for AB** must be booked as obeskattad reserv (formellt samband). For enskild firma, it is only in deklarationen. This skill covers AB.
- **Överavskrivningar** create obeskattade reserver (BAS 2150). Do NOT confuse with periodiseringsfond (BAS 2110-2139).
- **3:12-reglerna** apply to the physical person (delägare), not the company. The K10 is filed with INK1, not INK2.
- **Kapitalförsäkring**: the AB does NOT pay avkastningsskatt on a Swedish KF. The insurance company does. The AB only books insättningar/uttag.
- **Ränteavdragsbegränsningar**: förenklingsregeln 5 MSEK applies per intressegemenskap, not per bolag.
## Boundary with other skills
| Question type | Use this skill | Use other skill |
|--------------|----------------|-----------------|
| "How much periodiseringsfond should I set aside?" | Yes | |
| "How do I book periodiseringsfond?" | | swedish-year-end-closing |
| "Should I take lön or utdelning?" | Yes | |
| "What VAT code for EU services?" | | swedish-vat |
| "What is the deadline for årsredovisning?" | | swedish-year-end-closing |
| "How do I minimize total tax on 2 MSEK profit?" | Yes | |
+1 -1
View File
@@ -6,7 +6,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: erp-mafia/erp-base
IMAGE_NAME: erp-mafia/gnubok
jobs:
build-and-push:
-4
View File
@@ -4,7 +4,6 @@ import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { SentryIdentify } from '@/components/SentryIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
@@ -102,7 +101,6 @@ export default async function DashboardLayout({
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
</div>
</CompanyProvider>
)
@@ -150,7 +148,6 @@ export default async function DashboardLayout({
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
</div>
</CompanyProvider>
)
@@ -225,7 +222,6 @@ export default async function DashboardLayout({
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
{!isSandbox && (
<RecaptIdentify
userId={user.id}
-2
View File
@@ -1,7 +1,6 @@
import Link from 'next/link'
import { Settings } from 'lucide-react'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { SentryIdentify } from '@/components/SentryIdentify'
export default async function OnboardingLayout({
children,
@@ -53,7 +52,6 @@ export default async function OnboardingLayout({
</Link>
)}
{user && <SentryIdentify userId={user.id} email={user.email} />}
</div>
)
}
-6
View File
@@ -4,7 +4,6 @@ import { useState, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import * as Sentry from '@sentry/nextjs'
import { createClient } from '@/lib/supabase/client'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
@@ -56,10 +55,6 @@ function logError(message: string, extra?: Record<string, unknown>) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `new-company: ${message}`, extra }),
}).catch(() => {})
Sentry.captureMessage(`new-company: ${message}`, {
level: 'error',
extra: { ...extra, component: 'new-company' },
})
}
function NewCompanyContent() {
@@ -186,7 +181,6 @@ function NewCompanyContent() {
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
Sentry.captureException(err)
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
} finally {
setIsSaving(false)
-7
View File
@@ -1,8 +1,5 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
@@ -10,10 +7,6 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="sv" translate="no">
<head>
-27
View File
@@ -1,27 +0,0 @@
"use client";
import * as Sentry from "@sentry/nextjs";
export default function SentryExamplePage() {
return (
<div className="flex min-h-screen items-center justify-center p-8">
<div className="text-center space-y-4">
<h1 className="text-xl font-semibold">Sentry Test</h1>
<p className="text-muted-foreground text-sm">
Click the button to send a test error to Sentry.
</p>
<button
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
onClick={() => {
Sentry.captureException(
new Error("Sentry test error from gnubok")
);
alert("Test error sent to Sentry!");
}}
>
Throw Test Error
</button>
</div>
</div>
);
}
-21
View File
@@ -1,21 +0,0 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export function SentryIdentify({
userId,
email,
}: {
userId: string;
email?: string;
}) {
useEffect(() => {
Sentry.setUser({ id: userId, email });
return () => {
Sentry.setUser(null);
};
}, [userId, email]);
return null;
}
+3 -10
View File
@@ -777,18 +777,11 @@ export default function JournalEntryForm({
}}
isSubmitting={false}
title="Underlag saknas"
warningText="Inget underlag har bifogats. Enligt bokföringslagen (BFL) krävs underlag för varje bokföringspost."
warningText="Inget underlag bifogat. Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som underlag. Du kan bifoga underlag nu eller fortsätta utan."
confirmLabel="Bokför utan underlag"
>
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-4">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="text-sm text-warning-foreground">
<p className="font-medium mb-1">Inget underlag bifogat</p>
<p>
Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som
underlag. Du kan bifoga underlag nu eller fortsätta utan.
</p>
</div>
<div className="text-sm text-muted-foreground">
Granska uppgifterna innan du bekräftar.
</div>
</ConfirmationDialog>
@@ -2,7 +2,6 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import * as Sentry from '@sentry/nextjs'
import { createClient } from '@/lib/supabase/client'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
@@ -50,10 +49,6 @@ function logError(message: string, extra?: Record<string, unknown>) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `welcome-onboarding: ${message}`, extra }),
}).catch(() => {})
Sentry.captureMessage(`welcome-onboarding: ${message}`, {
level: 'error',
extra: { ...extra, component: 'welcome-onboarding' },
})
}
interface WelcomeOnboardingProps {
@@ -209,7 +204,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logError('create company action threw', { error: message })
Sentry.captureException(err)
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
} finally {
setIsSaving(false)
+2
View File
@@ -2,4 +2,6 @@
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
+2
View File
@@ -1,4 +1,6 @@
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 8 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/invoices/reminders/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
+1 -1
View File
@@ -1 +1 @@
{"extensions": ["enable-banking", "ai-categorization", "email"]}
{"extensions": ["enable-banking", "email", "arcim-migration", "tic", "mcp-server"]}
+1 -1
View File
@@ -1 +1 @@
{"extensions": ["ai-categorization", "ai-chat", "receipt-ocr", "invoice-inbox", "email", "push-notifications", "calendar"]}
{"extensions": ["email", "invoice-inbox", "push-notifications", "calendar", "mcp-server"]}
+2 -11
View File
@@ -1,13 +1,4 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
// Instrumentation hook — currently a no-op.
// Add runtime-specific setup here if needed.
}
export const onRequestError = Sentry.captureRequestError;
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
import {
generateApiKey,
hashApiKey,
extractBearerToken,
validateScopes,
hasScope,
validateApiKey,
DEFAULT_SCOPES,
} from '../api-keys'
import { createClient } from '@supabase/supabase-js'
const mockCreateClient = vi.mocked(createClient)
beforeEach(() => {
vi.clearAllMocks()
})
// ============================================================
// generateApiKey
// ============================================================
describe('generateApiKey', () => {
it('returns key starting with "gnubok_sk_"', () => {
const { key } = generateApiKey()
expect(key.startsWith('gnubok_sk_')).toBe(true)
})
it('returns 64-char hex SHA-256 hash', () => {
const { hash } = generateApiKey()
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('returns prefix of KEY_PREFIX + 8 chars', () => {
const { key, prefix } = generateApiKey()
expect(prefix).toBe(key.slice(0, 'gnubok_sk_'.length + 8))
})
it('generates unique keys on successive calls', () => {
const a = generateApiKey()
const b = generateApiKey()
expect(a.key).not.toBe(b.key)
expect(a.hash).not.toBe(b.hash)
})
it('hash matches hashApiKey(key)', () => {
const { key, hash } = generateApiKey()
expect(hashApiKey(key)).toBe(hash)
})
})
// ============================================================
// hashApiKey
// ============================================================
describe('hashApiKey', () => {
it('returns 64-char hex string', () => {
const hash = hashApiKey('gnubok_sk_test-key')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashApiKey('gnubok_sk_deterministic')
const hash2 = hashApiKey('gnubok_sk_deterministic')
expect(hash1).toBe(hash2)
})
it('produces different hashes for different inputs', () => {
const hash1 = hashApiKey('gnubok_sk_key-a')
const hash2 = hashApiKey('gnubok_sk_key-b')
expect(hash1).not.toBe(hash2)
})
})
// ============================================================
// extractBearerToken
// ============================================================
describe('extractBearerToken', () => {
it('extracts token from valid Bearer header', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer my-secret-token' },
})
expect(extractBearerToken(request)).toBe('my-secret-token')
})
it('returns null when no authorization header', () => {
const request = new Request('http://localhost')
expect(extractBearerToken(request)).toBeNull()
})
it('returns null when header is not Bearer scheme', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Basic dXNlcjpwYXNz' },
})
expect(extractBearerToken(request)).toBeNull()
})
it('handles token with special characters', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer gnubok_sk_abc+def/ghi=jkl' },
})
expect(extractBearerToken(request)).toBe('gnubok_sk_abc+def/ghi=jkl')
})
})
// ============================================================
// validateScopes
// ============================================================
describe('validateScopes', () => {
it('returns null for null input', () => {
expect(validateScopes(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(validateScopes(undefined)).toBeNull()
})
it('returns null for non-array input', () => {
expect(validateScopes('transactions:read')).toBeNull()
expect(validateScopes(42)).toBeNull()
expect(validateScopes({ scope: 'transactions:read' })).toBeNull()
})
it('filters to only valid API_KEY_SCOPES', () => {
const result = validateScopes(['transactions:read', 'invalid:scope', 'reports:read'])
expect(result).toEqual(['transactions:read', 'reports:read'])
})
it('returns null when no valid scopes remain after filter', () => {
expect(validateScopes(['invalid:scope', 'also:invalid'])).toBeNull()
})
it('preserves valid scopes from mixed input', () => {
const result = validateScopes(['customers:write', 'bogus', 'invoices:read'])
expect(result).toEqual(['customers:write', 'invoices:read'])
})
})
// ============================================================
// hasScope
// ============================================================
describe('hasScope', () => {
it('returns true when scope present in array', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'transactions:read')).toBe(true)
})
it('returns false when scope absent', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'invoices:write')).toBe(false)
})
})
// ============================================================
// validateApiKey
// ============================================================
describe('validateApiKey', () => {
function setupMockRpc(response: { data: unknown; error: unknown }) {
const mockRpc = vi.fn().mockResolvedValue(response)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClient.mockReturnValue({ rpc: mockRpc } as any)
}
it('rejects keys not starting with "gnubok_sk_"', async () => {
const result = await validateApiKey('invalid-key-format')
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
})
it('rejects when RPC returns error', async () => {
setupMockRpc({ data: null, error: { message: 'db error' } })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('rejects when RPC returns empty data array', async () => {
setupMockRpc({ data: [], error: null })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('returns rate limit error when rate_limited is true', async () => {
setupMockRpc({
data: [{ user_id: 'u1', company_id: 'c1', scopes: null, rate_limited: true }],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Rate limit exceeded', status: 429 })
})
it('returns userId, companyId, scopes on success', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: ['transactions:read', 'reports:read'],
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
scopes: ['transactions:read', 'reports:read'],
})
})
it('falls back to DEFAULT_SCOPES when row.scopes is null', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: null,
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
scopes: DEFAULT_SCOPES,
})
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { generateInviteToken, hashInviteToken, getInviteExpiry } from '../invite-tokens'
describe('generateInviteToken', () => {
it('returns token starting with "gnubok_inv_"', () => {
const { token } = generateInviteToken()
expect(token.startsWith('gnubok_inv_')).toBe(true)
})
it('returns a 64-char hex SHA-256 hash', () => {
const { hash } = generateInviteToken()
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('hash matches hashInviteToken(token)', () => {
const { token, hash } = generateInviteToken()
expect(hashInviteToken(token)).toBe(hash)
})
it('generates unique tokens on successive calls', () => {
const a = generateInviteToken()
const b = generateInviteToken()
expect(a.token).not.toBe(b.token)
expect(a.hash).not.toBe(b.hash)
})
})
describe('hashInviteToken', () => {
it('returns 64-char hex string', () => {
const hash = hashInviteToken('gnubok_inv_test-token')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashInviteToken('gnubok_inv_deterministic')
const hash2 = hashInviteToken('gnubok_inv_deterministic')
expect(hash1).toBe(hash2)
})
})
describe('getInviteExpiry', () => {
afterEach(() => {
vi.useRealTimers()
})
it('returns a Date exactly 7 days in the future', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const expiry = getInviteExpiry()
expect(expiry.toISOString()).toBe('2026-04-21T12:00:00.000Z')
})
})
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createAuthCode, decryptAuthCode, verifyPkce, hashAuthCode } from '../oauth-codes'
beforeEach(() => {
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-secret-for-oauth-tests')
})
afterEach(() => {
vi.unstubAllEnvs()
vi.useRealTimers()
})
// ============================================================
// createAuthCode + decryptAuthCode round-trip
// ============================================================
describe('createAuthCode + decryptAuthCode round-trip', () => {
it('encrypts and decrypts preserving userId, codeChallenge, redirectUri', () => {
const payload = {
userId: 'user-123',
codeChallenge: 'challenge-abc',
redirectUri: 'https://claude.ai/api/callback',
}
const code = createAuthCode(payload)
const decrypted = decryptAuthCode(code)
expect(decrypted).not.toBeNull()
expect(decrypted!.userId).toBe('user-123')
expect(decrypted!.codeChallenge).toBe('challenge-abc')
expect(decrypted!.redirectUri).toBe('https://claude.ai/api/callback')
})
it('sets exp approximately 5 minutes in future', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
const decrypted = decryptAuthCode(code)
expect(decrypted).not.toBeNull()
// exp should be Date.now() + 5 * 60 * 1000
const expectedExp = new Date('2026-04-14T12:00:00Z').getTime() + 5 * 60 * 1000
expect(decrypted!.exp).toBe(expectedExp)
})
it('returns null for expired code', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
// Advance past 5 minute TTL
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
const decrypted = decryptAuthCode(code)
expect(decrypted).toBeNull()
})
it('returns null for tampered ciphertext', () => {
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
// Flip a character in the middle of the encrypted string
const chars = code.split('')
const mid = Math.floor(chars.length / 2)
chars[mid] = chars[mid] === 'A' ? 'B' : 'A'
const tampered = chars.join('')
expect(decryptAuthCode(tampered)).toBeNull()
})
it('returns null for completely invalid base64url', () => {
expect(decryptAuthCode('not-a-valid-code!!!')).toBeNull()
})
it('throws when SUPABASE_SERVICE_ROLE_KEY is not set', () => {
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
expect(() =>
createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
})
})
// ============================================================
// verifyPkce
// ============================================================
describe('verifyPkce', () => {
it('returns true when SHA256(verifier) matches challenge', () => {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
// Compute expected challenge using base64url(SHA-256(verifier))
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
expect(verifyPkce(verifier, challenge)).toBe(true)
})
it('returns false when verifier does not match challenge', () => {
expect(verifyPkce('correct-verifier', 'wrong-challenge')).toBe(false)
})
})
// ============================================================
// hashAuthCode
// ============================================================
describe('hashAuthCode', () => {
it('returns 64-char hex string', () => {
const hash = hashAuthCode('some-auth-code')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashAuthCode('deterministic-code')
const hash2 = hashAuthCode('deterministic-code')
expect(hash1).toBe(hash2)
})
})
-1
View File
@@ -25,7 +25,6 @@ const REQUIRED_EXTENSION_VARS = [
] as const
const OPTIONAL_VARS = [
'SENTRY_DSN',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_PUBLIC_KEY',
] as const
@@ -0,0 +1,355 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
amountsMatchExact,
amountsMatchFuzzy,
customerNameMatches,
calculateMatchScore,
findMatchingInvoices,
getBestInvoiceMatch,
} from '../invoice-matching'
import type { Transaction, Invoice, Customer } from '@/types'
import { makeTransaction, makeInvoice, makeCustomer, createMockSupabase } from '@/tests/helpers'
// ============================================================
// amountsMatchExact
// ============================================================
describe('amountsMatchExact', () => {
it('matches identical amounts', () => {
expect(amountsMatchExact(1000, 1000)).toBe(true)
})
it('matches amounts differing only in floating-point noise', () => {
// 1000.004 rounds to 1000.00, same as 1000.00
expect(amountsMatchExact(1000.004, 1000)).toBe(true)
})
it('rejects amounts differing by 0.01', () => {
expect(amountsMatchExact(1000.01, 1000)).toBe(false)
})
})
// ============================================================
// amountsMatchFuzzy
// ============================================================
describe('amountsMatchFuzzy', () => {
it('matches amounts within 1% tolerance', () => {
// 990 vs 1000 → diff=10, tolerance=min(10,500)=10 → 10 <= 10
expect(amountsMatchFuzzy(990, 1000)).toBe(true)
})
it('rejects amounts outside 1% tolerance', () => {
// 980 vs 1000 → diff=20, tolerance=min(10,500)=10 → 20 > 10
expect(amountsMatchFuzzy(980, 1000)).toBe(false)
})
it('returns false when invoiceTotal is 0', () => {
expect(amountsMatchFuzzy(100, 0)).toBe(false)
})
it('caps tolerance at 500 SEK for large invoices', () => {
// 100000 vs 100600 → diff=600, tolerance=min(100000*0.01=1000, 500)=500 → 600 > 500
expect(amountsMatchFuzzy(100600, 100000)).toBe(false)
// 100000 vs 100400 → diff=400, tolerance=500 → 400 <= 500
expect(amountsMatchFuzzy(100400, 100000)).toBe(true)
})
})
// ============================================================
// customerNameMatches
// ============================================================
describe('customerNameMatches', () => {
it('matches when significant word from customer name appears in description', () => {
expect(customerNameMatches('Kontorsbolaget AB', 'Betalning Kontorsbolaget', null)).toBe(true)
})
it('ignores words shorter than 3 characters', () => {
// "AB" is 2 chars, filtered out
expect(customerNameMatches('AB', 'AB payment', null)).toBe(false)
})
it('matches against merchant_name', () => {
expect(customerNameMatches('Kontorsbolaget', 'Random description', 'Kontorsbolaget AB')).toBe(true)
})
it('returns false when customerName is undefined', () => {
expect(customerNameMatches(undefined as unknown as string, 'Description', null)).toBe(false)
})
it('is case-insensitive', () => {
expect(customerNameMatches('KONTORSBOLAGET', 'betalning kontorsbolaget', null)).toBe(true)
})
})
// ============================================================
// calculateMatchScore
// ============================================================
describe('calculateMatchScore', () => {
function makeTx(overrides: Partial<Transaction> = {}): Transaction {
return makeTransaction({ amount: 12500, description: 'Betalning Kundnamn AB', merchant_name: null, ...overrides })
}
function makeInv(overrides: Partial<Invoice & { customer?: Customer }> = {}): Invoice & { customer?: Customer } {
return {
...makeInvoice({ total: 12500 }),
customer: makeCustomer({ name: 'Kundnamn AB' }),
...overrides,
}
}
it('returns 0.95 for exact amount + customer name match', () => {
const { confidence } = calculateMatchScore(makeTx(), makeInv())
expect(confidence).toBe(0.95)
})
it('returns 0.80 for exact amount without customer match', () => {
const { confidence } = calculateMatchScore(
makeTx({ description: 'Random payment', merchant_name: null }),
makeInv({ customer: makeCustomer({ name: 'Completely Different Co' }) })
)
expect(confidence).toBe(0.80)
})
it('returns 0.70 for fuzzy amount + customer name match', () => {
// 12375 is within 1% of 12500 (diff=125, tolerance=min(125,500)=125)
const { confidence } = calculateMatchScore(
makeTx({ amount: 12375 }),
makeInv()
)
expect(confidence).toBe(0.70)
})
it('returns 0.50 for fuzzy amount without customer match', () => {
const { confidence } = calculateMatchScore(
makeTx({ amount: 12375, description: 'Random', merchant_name: null }),
makeInv({ customer: makeCustomer({ name: 'Completely Different Co' }) })
)
expect(confidence).toBe(0.50)
})
it('returns 0 confidence when no amount match', () => {
const { confidence } = calculateMatchScore(
makeTx({ amount: 99999 }),
makeInv()
)
expect(confidence).toBe(0)
})
})
// ============================================================
// findMatchingInvoices (integration — mock Supabase)
// ============================================================
describe('findMatchingInvoices', () => {
const { supabase, mockResult } = createMockSupabase()
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty for expense transactions (amount <= 0)', async () => {
const tx = makeTransaction({ amount: -1000 })
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toEqual([])
})
it('returns empty when Supabase query errors', async () => {
mockResult({ data: null, error: { message: 'db error' } })
const tx = makeTransaction({ amount: 12500 })
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toEqual([])
})
it('matches by OCR reference with confidence 0.99', async () => {
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
mockResult({
data: [
{ ...makeInvoice({ invoice_number: 'F-2024001', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }) },
],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.99)
expect(result[0].matchReason).toContain('OCR-referens')
})
it('returns immediately on OCR match without further scoring', async () => {
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
mockResult({
data: [
{ ...makeInvoice({ invoice_number: 'F-2024001', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }) },
// Second invoice with exact amount — should not be scored
{
...makeInvoice({ id: 'inv-2', invoice_number: 'F-2024002', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Test match description' }),
},
],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
// Only the OCR match should be returned
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.99)
})
it('scores exact amount + customer name at 0.95', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Betalning Testbolaget', reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Testbolaget AB' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.95)
})
it('scores exact amount only at 0.80', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated text', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Completely Different Name' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.80)
})
it('sorts matches by confidence descending', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Betalning Testbolaget', merchant_name: null, reference: null })
mockResult({
data: [
// Exact amount, no name match → 0.80
{
...makeInvoice({ id: 'inv-low', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Nope Corp' }),
},
// Exact amount + name match → 0.95
{
...makeInvoice({ id: 'inv-high', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Testbolaget AB' }),
},
],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(2)
expect(result[0].confidence).toBe(0.95)
expect(result[1].confidence).toBe(0.80)
})
it('filters out matches below 0.50 threshold', async () => {
const tx = makeTransaction({ amount: 99999, description: 'No match', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 50000, status: 'sent', remaining_amount: 50000, currency: 'SEK' }),
customer: makeCustomer({ name: 'Irrelevant' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toEqual([])
})
it('uses remaining_amount for partially_paid invoices', async () => {
const tx = makeTransaction({ amount: 5000, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, remaining_amount: 5000, status: 'partially_paid', currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.80) // exact amount match
})
it('skips invoices with non-matching currency', async () => {
const tx = makeTransaction({ amount: 12500, currency: 'SEK', description: 'Payment', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, remaining_amount: 12500, status: 'sent', currency: 'EUR', total_sek: null }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
// EUR invoice with no total_sek → currency mismatch → skipped
expect(result).toEqual([])
})
})
// ============================================================
// getBestInvoiceMatch
// ============================================================
describe('getBestInvoiceMatch', () => {
const { supabase, mockResult } = createMockSupabase()
beforeEach(() => {
vi.clearAllMocks()
})
it('returns highest-confidence match when above minConfidence', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx)
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.80)
})
it('returns null when best match below minConfidence', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
// minConfidence 0.90 → 0.80 match rejected
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx, 0.90)
expect(result).toBeNull()
})
it('defaults minConfidence to 0.80', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
// Exact amount only → 0.80, meets default threshold
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx)
expect(result).not.toBeNull()
})
})
+281
View File
@@ -0,0 +1,281 @@
import { describe, it, expect } from 'vitest'
import {
getAvailableVatRates,
getVatTreatmentForRate,
getVatRules,
calculateVat,
calculateTotal,
formatVatRate,
getVatTreatmentLabel,
getVatSummaryFromItems,
getMomsRutaDescription,
} from '../vat-rules'
// ============================================================
// getAvailableVatRates
// ============================================================
describe('getAvailableVatRates', () => {
it('returns all 4 Swedish rates for individual customer', () => {
const rates = getAvailableVatRates('individual')
expect(rates).toHaveLength(4)
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
expect(rates.map((r) => r.treatment)).toEqual([
'standard_25',
'reduced_12',
'reduced_6',
'exempt',
])
})
it('returns all 4 Swedish rates for swedish_business', () => {
const rates = getAvailableVatRates('swedish_business')
expect(rates).toHaveLength(4)
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
})
it('returns only reverse_charge 0% for eu_business with validated VAT', () => {
const rates = getAvailableVatRates('eu_business', true)
expect(rates).toHaveLength(1)
expect(rates[0]).toEqual({
rate: 0,
label: '0% (omvänd skattskyldighet)',
treatment: 'reverse_charge',
})
})
it('returns all 4 rates for eu_business WITHOUT validated VAT', () => {
// ML compliance: must charge Swedish VAT when VAT number not validated
const rates = getAvailableVatRates('eu_business', false)
expect(rates).toHaveLength(4)
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
})
it('returns only export 0% for non_eu_business', () => {
const rates = getAvailableVatRates('non_eu_business')
expect(rates).toHaveLength(1)
expect(rates[0]).toEqual({
rate: 0,
label: '0% (export)',
treatment: 'export',
})
})
it('defaults vatNumberValidated to false', () => {
// eu_business without explicit vatNumberValidated should get all rates
const rates = getAvailableVatRates('eu_business')
expect(rates).toHaveLength(4)
})
})
// ============================================================
// getVatTreatmentForRate
// ============================================================
describe('getVatTreatmentForRate', () => {
it('maps 25 → standard_25', () => {
expect(getVatTreatmentForRate(25)).toBe('standard_25')
})
it('maps 12 → reduced_12', () => {
expect(getVatTreatmentForRate(12)).toBe('reduced_12')
})
it('maps 6 → reduced_6', () => {
expect(getVatTreatmentForRate(6)).toBe('reduced_6')
})
it('maps 0 → exempt', () => {
expect(getVatTreatmentForRate(0)).toBe('exempt')
})
it('defaults unknown rates to standard_25', () => {
expect(getVatTreatmentForRate(15)).toBe('standard_25')
expect(getVatTreatmentForRate(99)).toBe('standard_25')
})
})
// ============================================================
// getVatRules
// ============================================================
describe('getVatRules', () => {
it('returns standard_25 / rate 25 / ruta 05 for individual', () => {
const rules = getVatRules('individual')
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
it('returns standard_25 / rate 25 / ruta 05 for swedish_business', () => {
const rules = getVatRules('swedish_business')
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
it('returns reverse_charge / rate 0 / ruta 39 for eu_business with validated VAT', () => {
const rules = getVatRules('eu_business', true)
expect(rules.treatment).toBe('reverse_charge')
expect(rules.rate).toBe(0)
expect(rules.momsRuta).toBe('39')
// Verify text references Article 196 of Council Directive 2006/112/EC
expect(rules.reverseChargeText).toContain('Article 196')
expect(rules.reverseChargeText).toContain('2006/112/EC')
})
it('returns standard_25 / rate 25 / ruta 05 for eu_business WITHOUT validated VAT', () => {
const rules = getVatRules('eu_business', false)
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
it('returns export / rate 0 / ruta 40 for non_eu_business', () => {
const rules = getVatRules('non_eu_business')
expect(rules.treatment).toBe('export')
expect(rules.rate).toBe(0)
expect(rules.momsRuta).toBe('40')
// Verify text references ML 10 kap
expect(rules.reverseChargeText).toContain('ML 10 kap')
})
it('defaults to standard_25 for unknown customerType', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rules = getVatRules('unknown_type' as any)
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
})
// ============================================================
// calculateVat
// Pin: vatRate is a whole number (25, not 0.25).
// Formula: Math.round(subtotal * vatRate) / 100
// ============================================================
describe('calculateVat', () => {
it('calculates 25% of 10000 → 2500', () => {
expect(calculateVat(10000, 25)).toBe(2500)
})
it('calculates 12% of 5000 → 600', () => {
expect(calculateVat(5000, 12)).toBe(600)
})
it('calculates 6% of 3000 → 180', () => {
expect(calculateVat(3000, 6)).toBe(180)
})
it('calculates 0% of 10000 → 0', () => {
expect(calculateVat(10000, 0)).toBe(0)
})
it('rounds correctly: 99.99 at 25% → 25', () => {
// Math.round(99.99 * 25) / 100 = Math.round(2499.75) / 100 = 2500 / 100 = 25
expect(calculateVat(99.99, 25)).toBe(25)
})
})
// ============================================================
// calculateTotal
// ============================================================
describe('calculateTotal', () => {
it('returns subtotal + VAT rounded: 10000 at 25% → 12500', () => {
expect(calculateTotal(10000, 25)).toBe(12500)
})
it('handles 0% VAT: total equals subtotal', () => {
expect(calculateTotal(5000, 0)).toBe(5000)
})
})
// ============================================================
// formatVatRate
// ============================================================
describe('formatVatRate', () => {
it('formats 25 as "25%"', () => {
expect(formatVatRate(25)).toBe('25%')
})
it('formats 0 as "0%"', () => {
expect(formatVatRate(0)).toBe('0%')
})
})
// ============================================================
// getVatTreatmentLabel
// ============================================================
describe('getVatTreatmentLabel', () => {
it('returns correct Swedish label for each treatment', () => {
expect(getVatTreatmentLabel('standard_25')).toBe('25% moms')
expect(getVatTreatmentLabel('reduced_12')).toBe('12% moms')
expect(getVatTreatmentLabel('reduced_6')).toBe('6% moms')
expect(getVatTreatmentLabel('reverse_charge')).toBe('Omvänd skattskyldighet (0%)')
expect(getVatTreatmentLabel('export')).toBe('Export (0%)')
expect(getVatTreatmentLabel('exempt')).toBe('Momsfritt')
})
})
// ============================================================
// getVatSummaryFromItems
// ============================================================
describe('getVatSummaryFromItems', () => {
it('returns single rate info when all items have same rate', () => {
const result = getVatSummaryFromItems([{ vat_rate: 25 }, { vat_rate: 25 }])
expect(result.isMixed).toBe(false)
expect(result.rate).toBe(25)
expect(result.treatment).toBe('standard_25')
expect(result.label).toBe('25% moms')
})
it('returns isMixed=true when items have different rates', () => {
const result = getVatSummaryFromItems([{ vat_rate: 25 }, { vat_rate: 12 }])
expect(result.isMixed).toBe(true)
expect(result.rate).toBeNull()
expect(result.treatment).toBeNull()
expect(result.label).toBe('Blandade momssatser')
})
it('treats null vat_rate as 0', () => {
const result = getVatSummaryFromItems([{ vat_rate: null }, { vat_rate: null }])
expect(result.isMixed).toBe(false)
expect(result.rate).toBe(0)
expect(result.treatment).toBe('exempt')
})
})
// ============================================================
// getMomsRutaDescription
// ============================================================
describe('getMomsRutaDescription', () => {
it('maps ruta 05 → "Utgående moms 25%"', () => {
expect(getMomsRutaDescription('05')).toBe('Utgående moms 25%')
})
it('maps ruta 39 → "Försäljning av tjänster till annat EU-land"', () => {
expect(getMomsRutaDescription('39')).toBe('Försäljning av tjänster till annat EU-land')
})
it('maps ruta 40 → "Export utanför EU"', () => {
expect(getMomsRutaDescription('40')).toBe('Export utanför EU')
})
it('returns the ruta string itself for unknown rutor', () => {
expect(getMomsRutaDescription('99')).toBe('99')
})
})
+4 -4
View File
@@ -27,7 +27,7 @@ const FUZZY_TOLERANCE = 0.01
/**
* Check if two amounts match exactly (within rounding)
*/
function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boolean {
export function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boolean {
// Round to 2 decimal places for comparison
const txRounded = Math.round(transactionAmount * 100) / 100
const invRounded = Math.round(invoiceTotal * 100) / 100
@@ -37,7 +37,7 @@ function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boo
/**
* Check if two amounts match within fuzzy tolerance (±1%)
*/
function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boolean {
export function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boolean {
if (invoiceTotal === 0) return false
const diff = Math.abs(transactionAmount - invoiceTotal)
// Cap fuzzy tolerance at 500 SEK to prevent false positives on large invoices
@@ -48,7 +48,7 @@ function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boo
/**
* Check if customer name appears in transaction counterparty
*/
function customerNameMatches(
export function customerNameMatches(
customerName: string | undefined,
transactionDescription: string,
merchantName: string | null
@@ -65,7 +65,7 @@ function customerNameMatches(
/**
* Calculate confidence score and match reason for an invoice match
*/
function calculateMatchScore(
export function calculateMatchScore(
transaction: Transaction,
invoice: Invoice & { customer?: Customer }
): { confidence: number; matchReason: string } {
@@ -172,6 +172,49 @@ describe('generateBalanceSheet', () => {
expect(report.total_equity_liabilities).toBe(32500)
})
it('handles negative asset balance (net credit on class 1 account)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// Receivables with net credit (customer overpayment)
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 0, closing_credit: 5000 }),
],
totalDebit: 50000,
totalCredit: 5000,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const receivables = report.asset_sections.find(s => s.rows.some(r => r.account_number === '1510'))
expect(receivables).toBeDefined()
expect(receivables!.rows.find(r => r.account_number === '1510')!.amount).toBe(-5000)
expect(report.total_assets).toBe(45000) // 50000 - 5000
})
it('rounding boundary: 0.004 rounds to 0 and is excluded, 0.005 rounds to 0.01 and is included', async () => {
// Amounts go through Math.round(x * 100) / 100 before the > 0.005 filter.
// Due to IEEE 754, 0.005 * 100 is slightly above 0.5, so Math.round rounds UP to 1,
// giving 0.01 which passes > 0.005. Meanwhile 0.004 rounds to 0.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 1000, closing_credit: 0 }),
makeRow({ account_number: '1940', account_name: 'Excluded', account_class: 1, closing_debit: 0.004, closing_credit: 0 }),
makeRow({ account_number: '1950', account_name: 'Included', account_class: 1, closing_debit: 0.005, closing_credit: 0 }),
],
totalDebit: 1000.009,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
// 1930 (1000) and 1950 (0.005 → rounds to 0.01) are included; 1940 (0.004 → rounds to 0) is excluded
expect(bankSection.rows).toHaveLength(2)
expect(bankSection.rows.map(r => r.account_number)).toEqual(['1930', '1950'])
})
it('uses Math.round for monetary precision on subtotals', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
@@ -171,6 +171,49 @@ describe('generateIncomeStatement', () => {
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
})
it('rounding boundary: 0.004 rounds to 0 and is excluded, 0.005 rounds to 0.01 and is included', async () => {
// Amounts go through Math.round(x * 100) / 100 before the > 0.005 filter.
// 0.004 → Math.round(0.4) = 0 → excluded. 0.005 → Math.round(0.5+ε) = 1 → 0.01 → included.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
makeRow({ account_number: '3002', account_name: 'Excluded', account_class: 3, closing_credit: 0.004, closing_debit: 0 }),
makeRow({ account_number: '3003', account_name: 'Included', account_class: 3, closing_credit: 0.005, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 10000.009,
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
const section = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
// 3001 and 3003 included; 3002 excluded
expect(section.rows).toHaveLength(2)
expect(section.rows.map(r => r.account_number)).toEqual(['3001', '3003'])
})
it('subtotal includes sub-threshold rows that are filtered from visible rows', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
// Amount 0.004 — below threshold, filtered from rows but included in subtotal
makeRow({ account_number: '3002', account_name: 'Micro', account_class: 3, closing_credit: 0.004, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 10000.004,
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
const section = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
// Only the 10000 row is visible
expect(section.rows).toHaveLength(1)
// But subtotal includes both rows (Math.round((10000 + 0.004) * 100) / 100 = 10000)
expect(section.subtotal).toBe(10000)
})
it('ignores class 1-2 accounts (balance sheet)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn(),
}))
import { getOpeningBalances } from '../opening-balances'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
const mockFetchAllRows = vi.mocked(fetchAllRows)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = {} as any
beforeEach(() => {
vi.clearAllMocks()
})
describe('getOpeningBalances', () => {
it('returns empty map and null obEntryId when period is null', async () => {
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', null)
expect(balances.size).toBe(0)
expect(obEntryId).toBeNull()
})
describe('with opening_balance_entry_id (OB entry path)', () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: 'ob-entry-123',
}
it('returns balances from the OB entry lines', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2440', debit_amount: 0, credit_amount: 10000 },
])
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 50000, credit: 0 })
expect(balances.get('2440')).toEqual({ debit: 0, credit: 10000 })
expect(obEntryId).toBe('ob-entry-123')
})
it('aggregates multiple lines for the same account', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: 30000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 20000, credit_amount: 0 },
])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 50000, credit: 0 })
})
it('returns the obEntryId string', async () => {
mockFetchAllRows.mockResolvedValue([])
const { obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(obEntryId).toBe('ob-entry-123')
})
})
describe('without opening_balance_entry_id (fallback path)', () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: null,
}
it('computes balances from all prior entries', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: 100000, credit_amount: 5000 },
{ account_number: '3001', debit_amount: 0, credit_amount: 80000 },
])
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 100000, credit: 5000 })
expect(balances.get('3001')).toEqual({ debit: 0, credit: 80000 })
expect(obEntryId).toBeNull()
})
it('aggregates multiple lines per account', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1510', debit_amount: 5000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 3000, credit_amount: 1000 },
])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1510')).toEqual({ debit: 8000, credit: 1000 })
})
it('returns null obEntryId', async () => {
mockFetchAllRows.mockResolvedValue([])
const { obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(obEntryId).toBeNull()
})
})
it('coerces null/undefined debit/credit to 0', async () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: 'ob-entry-1',
}
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: null, credit_amount: undefined },
])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 0, credit: 0 })
})
it('returns empty map when no lines found', async () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: null,
}
mockFetchAllRows.mockResolvedValue([])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.size).toBe(0)
})
})
@@ -817,4 +817,50 @@ describe('calculateVatDeclaration — reverse charge', () => {
// Only the posted entry's invoice (5000) should count
expect(result.rutor.ruta21).toBe(5000)
})
it('handles zero output VAT on some rates but non-zero on others', async () => {
// Only 12% sales in period — no 25% or 6% activity
results = [
{
data: [
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2641', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(0) // no 25% output VAT
expect(result.rutor.ruta11).toBe(600) // 12% output VAT
expect(result.rutor.ruta12).toBe(0) // no 6% output VAT
expect(result.rutor.ruta48).toBe(200)
// ruta49 = (0 + 600 + 0 + 0 + 0 + 0 + 0 + 0 + 0) - 200 = 400
expect(result.rutor.ruta49).toBe(400)
})
it('includes sub-öre ledger amounts in ruta sums (no threshold filtering)', async () => {
results = [
{
data: [
// Very small amount — VAT declaration uses raw summation, no 0.005 filtering
{ account_number: '2611', debit_amount: 0, credit_amount: 0.001 },
{ account_number: '3001', debit_amount: 0, credit_amount: 0.004 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Sub-öre amounts still included in ruta sums
expect(result.rutor.ruta10).toBe(0) // rounded: Math.round(0.001 * 100) / 100 = 0
expect(result.rutor.ruta05).toBe(0) // rounded: Math.round(0.004 * 100) / 100 = 0
})
})
+3 -9
View File
@@ -1,5 +1,4 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const isDev = process.env.NODE_ENV === "development";
@@ -9,7 +8,7 @@ const activepiecesUrl = process.env.ACTIVEPIECES_URL ?? "";
const cspDirectives = [
"default-src 'self'",
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.ingest.sentry.io https://*.enablebanking.com https://*.recapt.app`,
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://*.recapt.app`,
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
"img-src 'self' data: blob: https:",
@@ -20,7 +19,7 @@ const cspDirectives = [
].join("; ");
const nextConfig: NextConfig = {
output: process.env.VERCEL ? 'standalone' : undefined,
output: 'standalone',
async redirects() {
return [
{
@@ -65,9 +64,4 @@ const nextConfig: NextConfig = {
},
};
export default withSentryConfig(nextConfig, {
silent: !process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
...(process.env.SENTRY_AUTH_TOKEN ? {} : { sourcemaps: { disable: true } }),
});
export default nextConfig;
+76 -2261
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -28,7 +28,6 @@
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.40.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.1",
"@tailwindcss/typography": "^0.5.19",
-13
View File
@@ -1,13 +0,0 @@
import * as Sentry from "@sentry/nextjs";
const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
enabled: isHosted && !!process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
});
-10
View File
@@ -1,10 +0,0 @@
import * as Sentry from "@sentry/nextjs";
const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
Sentry.init({
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,
enabled: isHosted && !!(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN),
environment: process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
});
-10
View File
@@ -1,10 +0,0 @@
import * as Sentry from "@sentry/nextjs";
const isHosted = process.env.NEXT_PUBLIC_SELF_HOSTED !== "true";
Sentry.init({
dsn: process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN,
enabled: isHosted && !!(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN),
environment: process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
});
@@ -1,40 +0,0 @@
-- BankID identity linking table
-- Maps Supabase auth users to Swedish personnummer for BankID login.
-- Personnummer stored as SHA-256 hash (lookup) + AES-256-GCM encrypted (display).
create table public.bankid_identities (
id uuid primary key default uuid_generate_v4(),
user_id uuid references auth.users on delete cascade unique not null,
personal_number_hash text not null,
personal_number_enc bytea not null,
given_name text,
surname text,
linked_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.bankid_identities enable row level security;
-- Users can view their own BankID identity
create policy "bankid_identities_select" on public.bankid_identities
for select using (auth.uid() = user_id);
-- Users can link BankID to their own account
create policy "bankid_identities_insert" on public.bankid_identities
for insert with check (auth.uid() = user_id);
-- Users can unlink BankID from their account
create policy "bankid_identities_delete" on public.bankid_identities
for delete using (auth.uid() = user_id);
-- Fast lookup of returning BankID users by personnummer hash
create unique index idx_bankid_identities_pnr_hash
on public.bankid_identities (personal_number_hash);
create index idx_bankid_identities_user_id
on public.bankid_identities (user_id);
create trigger bankid_identities_updated_at
before update on public.bankid_identities
for each row execute function public.update_updated_at_column();
@@ -1,8 +0,0 @@
-- Add columns to customers that were applied directly to production but never captured in a migration.
-- This ensures staging/preview branches have the same schema.
ALTER TABLE public.customers
ADD COLUMN IF NOT EXISTS customer_type text NOT NULL DEFAULT 'individual',
ADD COLUMN IF NOT EXISTS address_line2 text,
ADD COLUMN IF NOT EXISTS vat_number_validated boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS default_payment_terms integer DEFAULT 30;
@@ -1,23 +0,0 @@
-- Add columns to company_settings that were applied directly to production but never captured in a migration.
-- This ensures staging/preview branches have the same schema.
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,
ADD COLUMN IF NOT EXISTS selected_sector text,
ADD COLUMN IF NOT EXISTS selected_modules jsonb DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS business_profile jsonb DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS employee_count integer,
ADD COLUMN IF NOT EXISTS annual_revenue_range text,
ADD COLUMN IF NOT EXISTS has_employees boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS uses_pos_system boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS sells_internationally boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS preliminary_tax_monthly numeric,
ADD COLUMN IF NOT EXISTS next_quote_number integer DEFAULT 1,
ADD COLUMN IF NOT EXISTS next_order_number integer DEFAULT 1,
ADD COLUMN IF NOT EXISTS quote_prefix text,
ADD COLUMN IF NOT EXISTS order_prefix text,
ADD COLUMN IF NOT EXISTS default_quote_validity_days integer DEFAULT 30,
ADD COLUMN IF NOT EXISTS swish_number text,
ADD COLUMN IF NOT EXISTS invoice_default_notes text;
@@ -1,539 +0,0 @@
-- Sync schema: add all columns and tables that were applied directly to production
-- but never captured in migration files. Uses IF NOT EXISTS throughout for idempotency.
-- =============================================================================
-- Missing columns on existing tables
-- =============================================================================
ALTER TABLE public.calendar_feeds
ADD COLUMN IF NOT EXISTS token_version integer DEFAULT 1;
ALTER TABLE public.cost_centers
ADD COLUMN IF NOT EXISTS description text,
ADD COLUMN IF NOT EXISTS manager_name text,
ADD COLUMN IF NOT EXISTS parent_id uuid,
ADD COLUMN IF NOT EXISTS sort_order integer DEFAULT 0;
ALTER TABLE public.invoice_inbox_items
ADD COLUMN IF NOT EXISTS raw_llm_response jsonb;
ALTER TABLE public.invoice_items
ADD COLUMN IF NOT EXISTS vat_amount numeric NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS vat_rate numeric NOT NULL DEFAULT 25;
ALTER TABLE public.invoices
ADD COLUMN IF NOT EXISTS bankgiro_number text,
ADD COLUMN IF NOT EXISTS is_recurring boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS ocr_number text,
ADD COLUMN IF NOT EXISTS payment_type text,
ADD COLUMN IF NOT EXISTS plusgiro_number text,
ADD COLUMN IF NOT EXISTS recurring_invoice_id uuid;
ALTER TABLE public.journal_entry_lines
ADD COLUMN IF NOT EXISTS cost_center_id uuid,
ADD COLUMN IF NOT EXISTS project_id uuid;
ALTER TABLE public.projects
ADD COLUMN IF NOT EXISTS budget_amount numeric DEFAULT 0,
ADD COLUMN IF NOT EXISTS customer_id uuid,
ADD COLUMN IF NOT EXISTS description text,
ADD COLUMN IF NOT EXISTS project_number text,
ADD COLUMN IF NOT EXISTS status text DEFAULT 'planning';
ALTER TABLE public.receipts
ADD COLUMN IF NOT EXISTS email_from text,
ADD COLUMN IF NOT EXISTS representation_business_connection text,
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'upload';
-- =============================================================================
-- Missing tables
-- =============================================================================
-- voucher_gap_explanations (BFNAR 2013:2 compliance)
CREATE TABLE IF NOT EXISTS public.voucher_gap_explanations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods ON DELETE CASCADE,
voucher_series text NOT NULL DEFAULT 'A',
gap_start integer NOT NULL,
gap_end integer NOT NULL,
explanation text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.voucher_gap_explanations ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "voucher_gap_explanations_select" ON public.voucher_gap_explanations
FOR SELECT USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "voucher_gap_explanations_insert" ON public.voucher_gap_explanations
FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "voucher_gap_explanations_update" ON public.voucher_gap_explanations
FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "voucher_gap_explanations_delete" ON public.voucher_gap_explanations
FOR DELETE USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DROP TRIGGER IF EXISTS voucher_gap_explanations_updated_at ON public.voucher_gap_explanations;
CREATE TRIGGER voucher_gap_explanations_updated_at
BEFORE UPDATE ON public.voucher_gap_explanations
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- automation_webhooks
CREATE TABLE IF NOT EXISTS public.automation_webhooks (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies ON DELETE CASCADE,
event_type text NOT NULL,
webhook_url text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.automation_webhooks ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "automation_webhooks_select" ON public.automation_webhooks
FOR SELECT USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "automation_webhooks_insert" ON public.automation_webhooks
FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "automation_webhooks_update" ON public.automation_webhooks
FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "automation_webhooks_delete" ON public.automation_webhooks
FOR DELETE USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DROP TRIGGER IF EXISTS automation_webhooks_updated_at ON public.automation_webhooks;
CREATE TRIGGER automation_webhooks_updated_at
BEFORE UPDATE ON public.automation_webhooks
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- bankid_identities
CREATE TABLE IF NOT EXISTS public.bankid_identities (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
personal_number_hash text NOT NULL,
personal_number_enc bytea NOT NULL,
given_name text,
surname text,
linked_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.bankid_identities ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "bankid_identities_select" ON public.bankid_identities
FOR SELECT USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "bankid_identities_insert" ON public.bankid_identities
FOR INSERT WITH CHECK (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "bankid_identities_update" ON public.bankid_identities
FOR UPDATE USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DROP TRIGGER IF EXISTS bankid_identities_updated_at ON public.bankid_identities;
CREATE TRIGGER bankid_identities_updated_at
BEFORE UPDATE ON public.bankid_identities
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- provider_connections
CREATE TABLE IF NOT EXISTS public.provider_connections (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
provider text NOT NULL,
status text NOT NULL DEFAULT 'pending',
provider_company_name text,
error_message text,
connected_at timestamptz,
last_synced_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.provider_connections ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "provider_connections_select" ON public.provider_connections
FOR SELECT USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "provider_connections_insert" ON public.provider_connections
FOR INSERT WITH CHECK (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "provider_connections_update" ON public.provider_connections
FOR UPDATE USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "provider_connections_delete" ON public.provider_connections
FOR DELETE USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DROP TRIGGER IF EXISTS provider_connections_updated_at ON public.provider_connections;
CREATE TRIGGER provider_connections_updated_at
BEFORE UPDATE ON public.provider_connections
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- provider_connection_tokens
CREATE TABLE IF NOT EXISTS public.provider_connection_tokens (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
connection_id uuid NOT NULL REFERENCES public.provider_connections ON DELETE CASCADE,
access_token text NOT NULL,
refresh_token text,
token_expires_at timestamptz,
provider_company_id text,
extra_data jsonb DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.provider_connection_tokens ENABLE ROW LEVEL SECURITY;
DROP TRIGGER IF EXISTS provider_connection_tokens_updated_at ON public.provider_connection_tokens;
CREATE TRIGGER provider_connection_tokens_updated_at
BEFORE UPDATE ON public.provider_connection_tokens
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- provider_oauth_states
CREATE TABLE IF NOT EXISTS public.provider_oauth_states (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
provider text NOT NULL,
csrf_token text NOT NULL,
connection_id uuid NOT NULL REFERENCES public.provider_connections ON DELETE CASCADE,
expires_at timestamptz NOT NULL DEFAULT (now() + interval '10 minutes'),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.provider_oauth_states ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "provider_oauth_states_select" ON public.provider_oauth_states
FOR SELECT USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "provider_oauth_states_insert" ON public.provider_oauth_states
FOR INSERT WITH CHECK (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "provider_oauth_states_delete" ON public.provider_oauth_states
FOR DELETE USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- provider_sync_data
CREATE TABLE IF NOT EXISTS public.provider_sync_data (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
connection_id uuid NOT NULL REFERENCES public.provider_connections ON DELETE CASCADE,
provider text NOT NULL,
resource_type text NOT NULL,
data jsonb NOT NULL DEFAULT '[]'::jsonb,
record_count integer NOT NULL DEFAULT 0,
synced_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.provider_sync_data ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "provider_sync_data_select" ON public.provider_sync_data
FOR SELECT USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "provider_sync_data_insert" ON public.provider_sync_data
FOR INSERT WITH CHECK (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "provider_sync_data_update" ON public.provider_sync_data
FOR UPDATE USING (auth.uid() = user_id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DROP TRIGGER IF EXISTS provider_sync_data_updated_at ON public.provider_sync_data;
CREATE TRIGGER provider_sync_data_updated_at
BEFORE UPDATE ON public.provider_sync_data
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- email_connections
CREATE TABLE IF NOT EXISTS public.email_connections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL REFERENCES public.companies ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
provider text NOT NULL DEFAULT 'gmail',
email_address text NOT NULL,
encrypted_token text NOT NULL,
last_sync_at timestamptz,
gmail_label_id text,
status text NOT NULL DEFAULT 'active',
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.email_connections ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "email_connections_select" ON public.email_connections
FOR SELECT USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "email_connections_insert" ON public.email_connections
FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "email_connections_update" ON public.email_connections
FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE POLICY "email_connections_delete" ON public.email_connections
FOR DELETE USING (company_id IN (SELECT user_company_ids()));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DROP TRIGGER IF EXISTS email_connections_updated_at ON public.email_connections;
CREATE TRIGGER email_connections_updated_at
BEFORE UPDATE ON public.email_connections
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- Missing functions
-- =============================================================================
-- commit_journal_entry — atomic voucher assignment (critical for bookkeeping)
CREATE OR REPLACE FUNCTION public.commit_journal_entry(p_company_id uuid, p_entry_id uuid)
RETURNS TABLE(voucher_number integer)
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
v_next integer;
v_fiscal_period_id uuid;
v_series text;
BEGIN
SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A')
INTO v_fiscal_period_id, v_series
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, auth.uid(), 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'
WHERE id = p_entry_id
AND company_id = p_company_id;
RETURN QUERY SELECT v_next;
END;
$function$;
-- release_voucher_range
CREATE OR REPLACE FUNCTION public.release_voucher_range(p_company_id uuid, p_fiscal_period_id uuid, p_series text, p_actual_last integer, p_reserved_highest integer)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
BEGIN
UPDATE public.voucher_sequences
SET last_number = p_actual_last,
updated_at = now()
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND voucher_series = p_series
AND last_number > p_actual_last
AND last_number <= p_reserved_highest;
END;
$function$;
-- create_invoice_with_items
CREATE OR REPLACE FUNCTION public.create_invoice_with_items(p_invoice jsonb, p_items jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_invoice_id uuid;
v_invoice_number integer;
v_result jsonb;
BEGIN
SELECT COALESCE(MAX(invoice_number::integer), 0) + 1
INTO v_invoice_number
FROM invoices
WHERE user_id = (p_invoice->>'user_id')::uuid;
INSERT INTO invoices (
user_id, customer_id, invoice_number, invoice_date, due_date,
status, currency, exchange_rate, exchange_rate_date,
subtotal, vat_amount, total,
subtotal_sek, vat_amount_sek, total_sek,
vat_treatment, vat_rate, moms_ruta,
your_reference, our_reference, notes,
reverse_charge_text
) VALUES (
(p_invoice->>'user_id')::uuid,
(p_invoice->>'customer_id')::uuid,
v_invoice_number::text,
(p_invoice->>'invoice_date')::date,
(p_invoice->>'due_date')::date,
COALESCE(p_invoice->>'status', 'draft'),
COALESCE(p_invoice->>'currency', 'SEK'),
(p_invoice->>'exchange_rate')::numeric,
(p_invoice->>'exchange_rate_date')::date,
(p_invoice->>'subtotal')::numeric,
(p_invoice->>'vat_amount')::numeric,
(p_invoice->>'total')::numeric,
(p_invoice->>'subtotal_sek')::numeric,
(p_invoice->>'vat_amount_sek')::numeric,
(p_invoice->>'total_sek')::numeric,
p_invoice->>'vat_treatment',
(p_invoice->>'vat_rate')::numeric,
p_invoice->>'moms_ruta',
p_invoice->>'your_reference',
p_invoice->>'our_reference',
p_invoice->>'notes',
p_invoice->>'reverse_charge_text'
) RETURNING id INTO v_invoice_id;
INSERT INTO invoice_items (invoice_id, sort_order, description, quantity, unit, unit_price, line_total)
SELECT
v_invoice_id,
(item->>'sort_order')::integer,
item->>'description',
(item->>'quantity')::numeric,
item->>'unit',
(item->>'unit_price')::numeric,
(item->>'line_total')::numeric
FROM jsonb_array_elements(p_items) AS item;
SELECT jsonb_build_object(
'id', i.id,
'invoice_number', i.invoice_number,
'invoice_date', i.invoice_date,
'due_date', i.due_date,
'status', i.status,
'currency', i.currency,
'exchange_rate', i.exchange_rate,
'subtotal', i.subtotal,
'vat_amount', i.vat_amount,
'total', i.total,
'subtotal_sek', i.subtotal_sek,
'vat_amount_sek', i.vat_amount_sek,
'total_sek', i.total_sek,
'vat_treatment', i.vat_treatment,
'vat_rate', i.vat_rate,
'moms_ruta', i.moms_ruta,
'your_reference', i.your_reference,
'our_reference', i.our_reference,
'notes', i.notes,
'reverse_charge_text', i.reverse_charge_text,
'customer', jsonb_build_object('id', c.id, 'name', c.name),
'items', (
SELECT jsonb_agg(jsonb_build_object(
'id', ii.id,
'sort_order', ii.sort_order,
'description', ii.description,
'quantity', ii.quantity,
'unit', ii.unit,
'unit_price', ii.unit_price,
'line_total', ii.line_total
) ORDER BY ii.sort_order)
FROM invoice_items ii WHERE ii.invoice_id = v_invoice_id
)
)
INTO v_result
FROM invoices i
LEFT JOIN customers c ON c.id = i.customer_id
WHERE i.id = v_invoice_id;
RETURN v_result;
END;
$function$;
-- seed_asset_categories
CREATE OR REPLACE FUNCTION public.seed_asset_categories(p_user_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
begin
if exists (select 1 from public.asset_categories where user_id = p_user_id) then
return;
end if;
insert into public.asset_categories (user_id, code, name, asset_account, depreciation_account, expense_account, default_useful_life_months, default_depreciation_method, is_system)
values
(p_user_id, 'BYGGNADER', 'Byggnader', '1110', '1119', '7820', 600, 'straight_line', true),
(p_user_id, 'MASKINER', 'Maskiner och tekniska anläggningar', '1210', '1219', '7831', 60, 'straight_line', true),
(p_user_id, 'INVENTARIER', 'Inventarier', '1220', '1229', '7832', 60, 'straight_line', true),
(p_user_id, 'FORDON', 'Fordon', '1240', '1249', '7834', 60, 'straight_line', true),
(p_user_id, 'DATORER', 'Datorer och IT-utrustning','1250', '1259', '7833', 36, 'straight_line', true),
(p_user_id, 'IMMATERIELLA', 'Immateriella tillgångar', '1010', '1019', '7810', 60, 'straight_line', true);
end;
$function$;
-- update_reconciliation_session_counts (trigger function)
CREATE OR REPLACE FUNCTION public.update_reconciliation_session_counts()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
begin
update public.bank_reconciliation_sessions
set
matched_count = (
select count(*) from public.bank_reconciliation_items
where session_id = coalesce(new.session_id, old.session_id)
and is_reconciled = true
),
unmatched_count = (
select count(*) from public.bank_reconciliation_items
where session_id = coalesce(new.session_id, old.session_id)
and is_reconciled = false
),
total_transactions = (
select count(*) from public.bank_reconciliation_items
where session_id = coalesce(new.session_id, old.session_id)
)
where id = coalesce(new.session_id, old.session_id);
return coalesce(new, old);
end;
$function$;
@@ -1,135 +0,0 @@
-- =============================================================================
-- Add missing DELETE RLS policies
-- =============================================================================
-- The multi-tenant migration (20260330130000) dropped all existing policies
-- but only recreated DELETE policies for company_members and api_keys.
-- This migration adds the missing DELETE policies for all tables that need them.
-- =============================================================================
-- Direct company_id tables
CREATE POLICY "customers_delete" ON public.customers
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "suppliers_delete" ON public.suppliers
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoices_delete" ON public.invoices
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_reminders_delete" ON public.invoice_reminders
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_payments_delete" ON public.invoice_payments
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoices_delete" ON public.supplier_invoices
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "supplier_invoice_payments_delete" ON public.supplier_invoice_payments
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "transactions_delete" ON public.transactions
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_connections_delete" ON public.bank_connections
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "bank_file_imports_delete" ON public.bank_file_imports
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "receipts_delete" ON public.receipts
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "company_settings_delete" ON public.company_settings
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chart_of_accounts_delete" ON public.chart_of_accounts
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "fiscal_periods_delete" ON public.fiscal_periods
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "journal_entries_delete" ON public.journal_entries
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "mapping_rules_delete" ON public.mapping_rules
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "categorization_templates_delete" ON public.categorization_templates
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "deadlines_delete" ON public.deadlines
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "cost_centers_delete" ON public.cost_centers
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "projects_delete" ON public.projects
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "calendar_feeds_delete" ON public.calendar_feeds
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "extension_data_delete" ON public.extension_data
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "skatteverket_tokens_delete" ON public.skatteverket_tokens
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "document_attachments_delete" ON public.document_attachments
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "invoice_inbox_items_delete" ON public.invoice_inbox_items
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_imports_delete" ON public.sie_imports
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "sie_account_mappings_delete" ON public.sie_account_mappings
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_sessions_delete" ON public.chat_sessions
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "chat_messages_delete" ON public.chat_messages
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
-- Conditional tables (may not exist in all environments)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'salary_payments') THEN
EXECUTE 'CREATE POLICY "salary_payments_delete" ON public.salary_payments FOR DELETE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'mileage_entries') THEN
EXECUTE 'CREATE POLICY "mileage_entries_delete" ON public.mileage_entries FOR DELETE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'account_balances') THEN
EXECUTE 'CREATE POLICY "account_balances_delete" ON public.account_balances FOR DELETE USING (company_id IN (SELECT public.user_company_ids()))';
END IF;
END $$;
-- Sub-tables using parent join (same pattern as their SELECT/INSERT/UPDATE policies)
CREATE POLICY "invoice_items_delete" ON public.invoice_items
FOR DELETE USING (
invoice_id IN (SELECT id FROM public.invoices WHERE company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "journal_entry_lines_delete" ON public.journal_entry_lines
FOR DELETE USING (
journal_entry_id IN (SELECT id FROM public.journal_entries WHERE company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "receipt_line_items_delete" ON public.receipt_line_items
FOR DELETE USING (
receipt_id IN (SELECT id FROM public.receipts WHERE company_id IN (SELECT public.user_company_ids()))
);
CREATE POLICY "supplier_invoice_items_delete" ON public.supplier_invoice_items
FOR DELETE USING (
supplier_invoice_id IN (SELECT id FROM public.supplier_invoices WHERE company_id IN (SELECT public.user_company_ids()))
);
@@ -1,49 +0,0 @@
-- Migration: Create 'sie-files' storage bucket for SIE file archival
-- The SIE import flow archives imported files to Supabase Storage for
-- BFL 7 kap 1-2§ retention compliance, but the bucket was never created.
-- Path convention: {company_id}/{import_id}.se
-- =============================================================================
-- 1. Create the 'sie-files' bucket (private, 10MB limit, text only)
-- =============================================================================
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'sie-files',
'sie-files',
false,
52428800, -- 50 MB, matches MAX_FILE_SIZE in the parse route
ARRAY['text/plain']
)
ON CONFLICT (id) DO NOTHING;
-- =============================================================================
-- 2. INSERT policy: Users can upload to companies they belong to
-- =============================================================================
CREATE POLICY "sie_files_insert"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'sie-files'
AND (storage.foldername(name))[1]::uuid IN (SELECT public.user_company_ids())
);
-- =============================================================================
-- 3. SELECT policy: Users can read files from companies they belong to
-- =============================================================================
CREATE POLICY "sie_files_select"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'sie-files'
AND (storage.foldername(name))[1]::uuid IN (SELECT public.user_company_ids())
);
-- =============================================================================
-- No UPDATE or DELETE policies — WORM compliance for BFL retention
-- Service role bypasses RLS for admin/cron access
-- =============================================================================
@@ -1,151 +0,0 @@
-- Migration: Silent teams for all users
--
-- Every user now gets a silent team at signup. This migration:
-- 1. Creates an ensure_user_team() RPC for idempotent team creation
-- 2. Backfills: creates teams for existing users who don't have one
-- 3. Assigns orphaned companies (team_id IS NULL) to their owner's team
-- 4. Deletes incomplete companies (mid-onboarding, no journal entries)
-- =============================================================================
-- 1. NEW RPC: ensure_user_team()
-- =============================================================================
-- Idempotently ensures the calling user has a team.
-- Returns the team_id (existing or newly created).
CREATE OR REPLACE FUNCTION public.ensure_user_team()
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
v_team_id uuid;
BEGIN
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
-- Check if user already has a team
SELECT team_id INTO v_team_id
FROM public.team_members
WHERE user_id = v_user_id
LIMIT 1;
IF v_team_id IS NOT NULL THEN
RETURN v_team_id;
END IF;
-- Create a new team (name doesn't matter — hidden from UI)
INSERT INTO public.teams (name, created_by)
VALUES ('Personal', v_user_id)
RETURNING id INTO v_team_id;
-- Add user as team owner
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (v_team_id, v_user_id, 'owner');
RETURN v_team_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.ensure_user_team() TO authenticated;
-- =============================================================================
-- 2. BACKFILL: Create teams for existing users without one
-- =============================================================================
-- Find all users who have company_members rows but no team_members rows.
-- Create a team for each and add them as owner.
DO $$
DECLARE
rec RECORD;
v_team_id uuid;
BEGIN
FOR rec IN
SELECT DISTINCT cm.user_id
FROM public.company_members cm
WHERE NOT EXISTS (
SELECT 1 FROM public.team_members tm WHERE tm.user_id = cm.user_id
)
LOOP
-- Create team
INSERT INTO public.teams (name, created_by)
VALUES ('Personal', rec.user_id)
RETURNING id INTO v_team_id;
-- Add as owner
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (v_team_id, rec.user_id, 'owner');
-- Assign all companies owned by this user to the new team
UPDATE public.companies
SET team_id = v_team_id
WHERE created_by = rec.user_id
AND team_id IS NULL;
END LOOP;
END;
$$;
-- =============================================================================
-- 3. Assign any remaining orphaned companies to their creator's team
-- =============================================================================
-- Edge case: companies where team_id IS NULL but the creator already has a team
-- (e.g., they were a team member but also had solo companies).
UPDATE public.companies c
SET team_id = (
SELECT tm.team_id
FROM public.team_members tm
WHERE tm.user_id = c.created_by
LIMIT 1
)
WHERE c.team_id IS NULL
AND EXISTS (
SELECT 1 FROM public.team_members tm WHERE tm.user_id = c.created_by
);
-- =============================================================================
-- 4. Delete incomplete companies (mid-onboarding cleanup)
-- =============================================================================
-- Only delete companies where:
-- - onboarding_complete is false or no settings row exists
-- - There are zero journal entries
-- - There are zero transactions
-- This is safe because no real bookkeeping data exists.
DO $$
DECLARE
rec RECORD;
v_je_count int;
v_tx_count int;
BEGIN
FOR rec IN
SELECT c.id AS company_id
FROM public.companies c
LEFT JOIN public.company_settings cs ON cs.company_id = c.id
WHERE (cs.onboarding_complete IS NULL OR cs.onboarding_complete = false)
LOOP
-- Check for journal entries
SELECT count(*) INTO v_je_count
FROM public.journal_entries
WHERE company_id = rec.company_id;
-- Check for transactions
SELECT count(*) INTO v_tx_count
FROM public.transactions
WHERE company_id = rec.company_id;
-- Only delete if truly empty
IF v_je_count = 0 AND v_tx_count = 0 THEN
-- Delete dependent rows first (order matters for FK constraints)
DELETE FROM public.company_settings WHERE company_id = rec.company_id;
DELETE FROM public.fiscal_periods WHERE company_id = rec.company_id;
DELETE FROM public.chart_of_accounts WHERE company_id = rec.company_id;
DELETE FROM public.company_members WHERE company_id = rec.company_id;
DELETE FROM public.companies WHERE id = rec.company_id;
END IF;
END LOOP;
END;
$$;
@@ -1,2 +0,0 @@
-- ML 17:24 p.7: leveransdatum when different from fakturadatum
ALTER TABLE public.invoices ADD COLUMN IF NOT EXISTS delivery_date date;
@@ -1,15 +0,0 @@
-- Add INTEGRITY_FAILURE to audit_log action CHECK constraint
-- The verify cron (app/api/documents/verify/cron) inserts INTEGRITY_FAILURE
-- but the original CHECK constraint in migration 014 did not include it,
-- causing all integrity failure logging to silently fail.
ALTER TABLE public.audit_log DROP CONSTRAINT audit_log_action_check;
ALTER TABLE public.audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (
'INSERT', 'UPDATE', 'DELETE',
'COMMIT', 'REVERSE', 'CORRECT',
'LOCK_PERIOD', 'CLOSE_PERIOD',
'DOCUMENT_DELETE_BLOCKED', 'RETENTION_BLOCK',
'SECURITY_EVENT',
'INTEGRITY_FAILURE'
));
@@ -1,5 +0,0 @@
-- Add trade_name column to company_settings
-- Allows companies to display a trade name (handelsnamn) on invoices
-- and other external-facing documents instead of the legal company name.
ALTER TABLE public.company_settings
ADD COLUMN trade_name text;
@@ -1,116 +0,0 @@
-- Document version chain: prev_version_hash column + create_document_version RPC
-- Fixes the non-functional document versioning (migration 023 was a placeholder).
-- The createNewVersion() in lib/core/documents/document-service.ts calls this RPC.
-- 1. Add prev_version_hash column for cryptographic version chain
ALTER TABLE public.document_attachments
ADD COLUMN IF NOT EXISTS prev_version_hash text;
-- 2. Atomic version creation RPC
-- Row-locks the current version, inserts a new version with hash chain,
-- and marks the old version as superseded — all in one transaction.
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid,
p_original_doc_id uuid,
p_storage_path text,
p_file_name text,
p_file_size_bytes bigint,
p_mime_type text,
p_sha256_hash text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
BEGIN
-- Lock the current version row to prevent concurrent versioning
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
-- Determine root document and next version number
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
-- Insert new version with hash chain link
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash -- cryptographic link to previous version
)
RETURNING id INTO v_new_id;
-- Mark old version as superseded
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
RETURN v_new_id;
END;
$$;
-- 3. Version chain validation function
-- Walks the version chain from newest to oldest and verifies each
-- prev_version_hash matches the prior version's sha256_hash.
CREATE OR REPLACE FUNCTION public.validate_version_chain(p_document_id uuid)
RETURNS TABLE(version integer, document_id uuid, hash_valid boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_root_id uuid;
BEGIN
-- Find root document
SELECT COALESCE(da.original_id, da.id) INTO v_root_id
FROM public.document_attachments da
WHERE da.id = p_document_id;
IF v_root_id IS NULL THEN
RAISE EXCEPTION 'Document % not found', p_document_id;
END IF;
-- Walk chain and verify hashes
RETURN QUERY
WITH chain AS (
SELECT
da.id AS doc_id,
da.version AS ver,
da.sha256_hash,
da.prev_version_hash,
LAG(da.sha256_hash) OVER (ORDER BY da.version) AS expected_prev_hash
FROM public.document_attachments da
WHERE da.id = v_root_id OR da.original_id = v_root_id
ORDER BY da.version
)
SELECT
chain.ver,
chain.doc_id,
CASE
WHEN chain.ver = 1 THEN chain.prev_version_hash IS NULL
ELSE chain.prev_version_hash IS NOT DISTINCT FROM chain.expected_prev_hash
END AS hash_valid
FROM chain
ORDER BY chain.ver;
END;
$$;
@@ -1,57 +0,0 @@
-- Enforce document metadata immutability for documents linked to committed entries
-- BFL 7 kap requires verifikation underlag to be immutable once committed.
-- Existing triggers only block DELETE — this blocks metadata UPDATE.
CREATE OR REPLACE FUNCTION public.enforce_document_metadata_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_status text;
BEGIN
-- Only enforce on documents already linked to a journal entry
IF OLD.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
SELECT status INTO v_entry_status
FROM public.journal_entries
WHERE id = OLD.journal_entry_id;
-- Only enforce for committed (posted/reversed) entries
IF v_entry_status IS NULL OR v_entry_status NOT IN ('posted', 'reversed') THEN
RETURN NEW;
END IF;
-- Block changes to immutable fields
-- Allowed: last_integrity_check_at (cron), updated_at (auto-trigger),
-- superseded_by_id (versioning), prev_version_hash (versioning),
-- journal_entry_id/journal_entry_line_id (linking)
IF NEW.file_name IS DISTINCT FROM OLD.file_name
OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
OR NEW.file_size_bytes IS DISTINCT FROM OLD.file_size_bytes
OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
OR NEW.sha256_hash IS DISTINCT FROM OLD.sha256_hash
OR NEW.upload_source IS DISTINCT FROM OLD.upload_source
OR NEW.digitization_date IS DISTINCT FROM OLD.digitization_date
OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
OR NEW.version IS DISTINCT FROM OLD.version
OR NEW.original_id IS DISTINCT FROM OLD.original_id
THEN
-- Log the blocked attempt
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked metadata modification of document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify metadata of document linked to a % journal entry (BFL 7 kap)', v_entry_status;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_document_metadata_immutability
BEFORE UPDATE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.enforce_document_metadata_immutability();
@@ -1,67 +0,0 @@
-- Audit all document deletions, including unlinked documents
-- Previously, block_document_deletion() only logged linked document deletion attempts.
-- Unlinked documents were deleted without any trace in the audit log.
CREATE OR REPLACE FUNCTION public.block_document_deletion()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_status text;
v_retention_expires date;
BEGIN
-- Always log document deletion attempts (linked and unlinked)
IF OLD.journal_entry_id IS NULL THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description, old_state)
VALUES (
OLD.user_id, OLD.company_id, 'DELETE', 'document_attachments', OLD.id,
'Deleted unlinked document "' || OLD.file_name || '"',
to_jsonb(OLD)
);
-- Allow deletion of unlinked documents
RETURN OLD;
END IF;
-- Check if linked to a committed journal entry
SELECT je.status INTO v_entry_status
FROM public.journal_entries je
WHERE je.id = OLD.journal_entry_id;
IF v_entry_status IN ('posted', 'reversed') THEN
-- Log the blocked attempt
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'DOCUMENT_DELETE_BLOCKED', 'document_attachments', OLD.id,
'Attempted deletion of document linked to ' || v_entry_status || ' journal entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot delete document linked to a % journal entry (Bokforingslagen)',
v_entry_status;
END IF;
-- Check retention window
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.journal_entries je
JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE je.id = OLD.journal_entry_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'RETENTION_BLOCK', 'document_attachments', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete document within 7-year retention period (expires %)',
v_retention_expires;
END IF;
-- Log deletion of linked-but-not-committed documents
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description, old_state)
VALUES (
OLD.user_id, OLD.company_id, 'DELETE', 'document_attachments', OLD.id,
'Deleted document "' || OLD.file_name || '" linked to draft entry ' || OLD.journal_entry_id,
to_jsonb(OLD)
);
RETURN OLD;
END;
$$;
@@ -1,35 +0,0 @@
-- Fix: add posted → cancelled transition for orphaned entry cleanup.
-- The live trigger is missing this transition due to migration 20260319000001
-- being edited after it was applied. engine.ts reverseEntry and storno-service
-- need posted → cancelled for CAS guard cleanup of orphaned concurrent reversals.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
-- No exemption for drafts: varaktighet applies from insertion.
-- Application code uses status='cancelled' instead of DELETE.
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
-- Draft can transition to draft (update fields), posted, or cancelled
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
-- Posted can transition to reversed (storno) or cancelled (orphaned cleanup)
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END; $$;
@@ -1,116 +0,0 @@
-- Fix delete_user_account: timeout + cascade blockers.
--
-- Problem 1: ALTER TABLE DDL requires ACCESS EXCLUSIVE locks. With the
-- authenticated role's 8s statement_timeout, any concurrent read causes
-- error 57014. Fixed via function-level SET statement_timeout = '60s'.
--
-- Problem 2: Several NO ACTION FKs block the CASCADE chain from
-- auth.users → companies → children:
-- audit_log.company_id → companies (NO ACTION)
-- fiscal_periods.previous_period_id → fiscal_periods (NO ACTION, self-ref)
-- fiscal_periods.closing_entry_id → journal_entries (NO ACTION)
-- fiscal_periods.opening_balance_entry_id → journal_entries (NO ACTION)
-- Fixed by explicitly clearing these before the CASCADE fires.
CREATE OR REPLACE FUNCTION public.delete_user_account(target_user_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET statement_timeout = '60s'
SET lock_timeout = '10s'
AS $$
DECLARE
v_company_ids uuid[];
BEGIN
-- Only allow users to delete their own account
IF auth.uid() IS DISTINCT FROM target_user_id THEN
RAISE EXCEPTION 'Can only delete your own account';
END IF;
-- Collect companies owned by this user (CASCADE will delete these)
SELECT array_agg(id) INTO v_company_ids
FROM public.companies
WHERE created_by = target_user_id;
-- Clear active_company_id to avoid FK conflicts during CASCADE
DELETE FROM public.user_preferences WHERE user_id = target_user_id;
-- Explicitly delete from extension_data (missing DELETE RLS policy
-- causes CASCADE from auth.users to fail even with ON DELETE CASCADE)
DELETE FROM public.extension_data WHERE user_id = target_user_id;
-- Disable BEFORE DELETE triggers that block deletion
ALTER TABLE audit_log DISABLE TRIGGER audit_log_no_delete;
ALTER TABLE payment_match_log DISABLE TRIGGER payment_match_log_no_delete;
ALTER TABLE document_attachments DISABLE TRIGGER block_document_deletion;
ALTER TABLE journal_entries DISABLE TRIGGER enforce_journal_entry_immutability;
ALTER TABLE journal_entries DISABLE TRIGGER enforce_retention_journal_entries;
ALTER TABLE journal_entry_lines DISABLE TRIGGER enforce_journal_entry_line_immutability;
-- Disable AFTER DELETE audit triggers (they INSERT into audit_log during
-- CASCADE, which would create orphaned rows after the user is gone)
ALTER TABLE api_keys DISABLE TRIGGER audit_api_keys;
ALTER TABLE chart_of_accounts DISABLE TRIGGER audit_chart_of_accounts;
ALTER TABLE company_settings DISABLE TRIGGER audit_company_settings;
ALTER TABLE document_attachments DISABLE TRIGGER audit_document_attachments;
ALTER TABLE extension_data DISABLE TRIGGER audit_extension_data;
ALTER TABLE fiscal_periods DISABLE TRIGGER audit_fiscal_periods;
ALTER TABLE journal_entries DISABLE TRIGGER audit_journal_entries;
ALTER TABLE supplier_invoices DISABLE TRIGGER audit_supplier_invoices;
-- Clear NO ACTION FK references that block the CASCADE chain
IF v_company_ids IS NOT NULL THEN
-- audit_log.company_id → companies (NO ACTION)
DELETE FROM public.audit_log
WHERE company_id = ANY(v_company_ids);
-- fiscal_periods self-ref and cross-refs to journal_entries (NO ACTION)
UPDATE public.fiscal_periods
SET previous_period_id = NULL,
closing_entry_id = NULL,
opening_balance_entry_id = NULL
WHERE company_id = ANY(v_company_ids);
END IF;
-- Delete from auth.users — ON DELETE CASCADE handles all public tables
DELETE FROM auth.users WHERE id = target_user_id;
-- Re-enable all triggers
ALTER TABLE audit_log ENABLE TRIGGER audit_log_no_delete;
ALTER TABLE payment_match_log ENABLE TRIGGER payment_match_log_no_delete;
ALTER TABLE document_attachments ENABLE TRIGGER block_document_deletion;
ALTER TABLE journal_entries ENABLE TRIGGER enforce_journal_entry_immutability;
ALTER TABLE journal_entries ENABLE TRIGGER enforce_retention_journal_entries;
ALTER TABLE journal_entry_lines ENABLE TRIGGER enforce_journal_entry_line_immutability;
ALTER TABLE api_keys ENABLE TRIGGER audit_api_keys;
ALTER TABLE chart_of_accounts ENABLE TRIGGER audit_chart_of_accounts;
ALTER TABLE company_settings ENABLE TRIGGER audit_company_settings;
ALTER TABLE document_attachments ENABLE TRIGGER audit_document_attachments;
ALTER TABLE extension_data ENABLE TRIGGER audit_extension_data;
ALTER TABLE fiscal_periods ENABLE TRIGGER audit_fiscal_periods;
ALTER TABLE journal_entries ENABLE TRIGGER audit_journal_entries;
ALTER TABLE supplier_invoices ENABLE TRIGGER audit_supplier_invoices;
EXCEPTION WHEN OTHERS THEN
-- PostgreSQL transactional DDL already rolls back the DISABLE TRIGGER
-- statements if the function aborts, but re-enable explicitly as a
-- defensive guard against sub-transaction edge cases so enforcement
-- triggers are never left disabled on the live tables.
BEGIN ALTER TABLE audit_log ENABLE TRIGGER audit_log_no_delete; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE payment_match_log ENABLE TRIGGER payment_match_log_no_delete; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE document_attachments ENABLE TRIGGER block_document_deletion; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entries ENABLE TRIGGER enforce_journal_entry_immutability; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entries ENABLE TRIGGER enforce_retention_journal_entries; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entry_lines ENABLE TRIGGER enforce_journal_entry_line_immutability; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE api_keys ENABLE TRIGGER audit_api_keys; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE chart_of_accounts ENABLE TRIGGER audit_chart_of_accounts; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE company_settings ENABLE TRIGGER audit_company_settings; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE document_attachments ENABLE TRIGGER audit_document_attachments; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE extension_data ENABLE TRIGGER audit_extension_data; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE fiscal_periods ENABLE TRIGGER audit_fiscal_periods; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entries ENABLE TRIGGER audit_journal_entries; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE supplier_invoices ENABLE TRIGGER audit_supplier_invoices; EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE;
END;
$$;
@@ -1,36 +0,0 @@
-- Allow the first fiscal period of a company to start on any day of the month,
-- per BFL 3 kap. (the first fiscal year starts on the company registration date).
-- Subsequent periods must still start on the 1st of a month.
-- Drop the unconditional CHECK constraint that enforces day-1 starts
ALTER TABLE public.fiscal_periods
DROP CONSTRAINT IF EXISTS fiscal_period_start_first_of_month;
-- Replace with a trigger that only enforces day-1 for non-first periods
CREATE OR REPLACE FUNCTION enforce_first_of_month_for_subsequent_periods()
RETURNS trigger AS $$
BEGIN
-- If this period starts on the 1st, no check needed
IF EXTRACT(DAY FROM NEW.period_start) = 1 THEN
RETURN NEW;
END IF;
-- Allow any start day only if this is the first fiscal period for the company
IF EXISTS (
SELECT 1 FROM public.fiscal_periods
WHERE company_id = NEW.company_id
AND id IS DISTINCT FROM NEW.id
) THEN
RAISE EXCEPTION 'Non-first fiscal period must start on the 1st of a month';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS enforce_period_start_day ON public.fiscal_periods;
CREATE TRIGGER enforce_period_start_day
BEFORE INSERT OR UPDATE ON public.fiscal_periods
FOR EACH ROW
EXECUTE FUNCTION enforce_first_of_month_for_subsequent_periods();
@@ -1,38 +0,0 @@
-- Filter archived companies out of user_company_ids() helper.
--
-- Soft-deleted (archived) companies must disappear from the user's UI while
-- the underlying bookkeeping data stays intact for BFL 7 kap. 2§ 7-year
-- retention. Routing this filter through user_company_ids() makes every
-- company-scoped RLS policy honor it automatically.
--
-- Also adds companies.archived_by (who archived it) and an index on
-- archived_at IS NULL to speed up the common "list my active companies"
-- picker query.
CREATE OR REPLACE FUNCTION public.user_company_ids()
RETURNS SETOF uuid
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT cm.company_id
FROM public.company_members cm
JOIN public.companies c ON c.id = cm.company_id
WHERE cm.user_id = auth.uid()
AND c.archived_at IS NULL;
$$;
GRANT EXECUTE ON FUNCTION public.user_company_ids() TO authenticated;
-- Track who archived a company. ON DELETE SET NULL so a future hard-purge
-- (7 years out) of the archiving user doesn't break the tombstone row.
ALTER TABLE public.companies
ADD COLUMN archived_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
-- Partial index: picker queries almost always filter to active companies.
CREATE INDEX companies_active_idx
ON public.companies (id)
WHERE archived_at IS NULL;
NOTIFY pgrst, 'reload schema';
@@ -1,13 +0,0 @@
-- Add tombstone columns to profiles for account anonymization.
--
-- When a user deletes their account we must keep the auth.users row alive
-- (otherwise ON DELETE CASCADE from companies.created_by would destroy
-- retained bookkeeping data). Instead we anonymize the profile row: strip
-- PII, stamp deleted_at + anonymized_at, and the UI falls back to
-- "Borttagen användare" wherever the profile is displayed.
--
-- email and full_name are already nullable per migration 20240101000001.
ALTER TABLE public.profiles
ADD COLUMN deleted_at timestamptz,
ADD COLUMN anonymized_at timestamptz;
@@ -1,68 +0,0 @@
-- Replace hard-delete with anonymization: retain auth.users as a tombstone.
--
-- Why: BFL 7 kap. 2§ requires räkenskapsinformation to be preserved for 7
-- years. companies.created_by references auth.users ON DELETE CASCADE, so
-- hard-deleting the user would wipe retained verifikationer. Instead we:
-- 1) verify zero owned, non-archived companies
-- 2) remove the user from all company/team memberships
-- 3) delete per-user operational state (prefs, api keys)
-- 4) anonymize the profile
-- The API route caller is responsible for banning the auth.users row via
-- the admin API afterwards (SQL can't reach supabase.auth.admin).
CREATE OR REPLACE FUNCTION public.anonymize_user_account(target_user_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
blocker_count int;
BEGIN
IF auth.uid() IS DISTINCT FROM target_user_id THEN
RAISE EXCEPTION 'Can only delete your own account';
END IF;
-- Precondition: zero owned, non-archived companies
SELECT count(*) INTO blocker_count
FROM public.company_members cm
JOIN public.companies c ON c.id = cm.company_id
WHERE cm.user_id = target_user_id
AND cm.role = 'owner'
AND c.archived_at IS NULL;
IF blocker_count > 0 THEN
RAISE EXCEPTION 'Cannot delete account: user still owns % active compan(y/ies)', blocker_count
USING ERRCODE = 'P0001';
END IF;
-- Remove memberships
DELETE FROM public.company_members WHERE user_id = target_user_id;
DELETE FROM public.team_members WHERE user_id = target_user_id;
-- Remove per-user operational state
DELETE FROM public.user_preferences WHERE user_id = target_user_id;
DELETE FROM public.api_keys WHERE user_id = target_user_id;
-- Anonymize profile tombstone
UPDATE public.profiles
SET email = NULL,
full_name = NULL,
avatar_url = NULL,
deleted_at = now(),
anonymized_at = now(),
updated_at = now()
WHERE id = target_user_id;
-- NOTE: auth.users row, companies.created_by FK, and audit_log.user_id
-- are intentionally left intact. They now reference a banned, anonymized
-- tombstone row that exists solely to keep BFL-retained data valid.
END;
$$;
GRANT EXECUTE ON FUNCTION public.anonymize_user_account(uuid) TO authenticated;
-- The old delete_user_account RPC hard-deleted auth.users, which cascaded
-- away retained verifikationer via companies.created_by. It is incompatible
-- with the retention model and must be removed.
DROP FUNCTION IF EXISTS public.delete_user_account(uuid);
@@ -1,664 +0,0 @@
-- =============================================================================
-- Active-company tenant isolation in RLS
-- =============================================================================
--
-- Before this migration, company-scoped RLS policies allowed access to any
-- company the user was a MEMBER of. This meant that a user with multiple
-- memberships could see cross-company data leak into dropdowns and lists
-- via direct browser-client queries (e.g. /invoices/new customer dropdown),
-- because RLS only checked membership, not which company was active.
--
-- This migration replaces `company_id IN (SELECT user_company_ids())` with
-- `company_id = current_active_company_id()` on every company-scoped policy.
-- The helper function reads `user_preferences.active_company_id` with a
-- fallback to the user's first non-archived membership. It is STABLE +
-- SECURITY DEFINER so the result is cached per query plan and it bypasses
-- RLS on the user_preferences + company_members tables it reads from.
--
-- Tables NOT updated (they stay on user_company_ids() because they must be
-- visible across all companies the user is a member of, or use a different
-- auth model entirely):
-- companies, company_members, user_preferences, profiles
-- teams, team_members, team_invitations, company_invitations
-- provider_consent_tokens, provider_otc (consent-id scoped via team_members)
-- bankid_identities (user-scoped, not company-scoped)
-- storage.objects/sie-files bucket policies (different auth surface)
--
-- =============================================================================
-- =============================================================================
-- 1. current_active_company_id() helper
-- =============================================================================
CREATE OR REPLACE FUNCTION public.current_active_company_id()
RETURNS uuid
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT COALESCE(
-- Active preference, validated against non-archived membership
(
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()
),
-- Fallback: first non-archived membership by created_at
(
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
)
);
$$;
GRANT EXECUTE ON FUNCTION public.current_active_company_id() TO authenticated;
COMMENT ON FUNCTION public.current_active_company_id() IS
'Returns the active company id for auth.uid(), reading user_preferences.active_company_id '
'with validation against non-archived company_members, and falling back to the user''s '
'first non-archived membership. Used by RLS policies to enforce single-active-company '
'tenant isolation. STABLE so the result is cached per query plan.';
-- =============================================================================
-- 2. Drop old policies on company-scoped data tables
-- =============================================================================
-- We enumerate every company-scoped data table that currently uses
-- user_company_ids() and drop all of its policies so we can recreate them
-- below with current_active_company_id().
DO $$
DECLARE
pol RECORD;
affected_tables TEXT[] := ARRAY[
'company_settings', 'chart_of_accounts', 'fiscal_periods',
'journal_entries', 'journal_entry_lines',
'account_balances', 'voucher_sequences',
'transactions', 'bank_connections', 'bank_file_imports',
'customers', 'invoices', 'invoice_items',
'invoice_reminders', 'invoice_payments',
'suppliers', 'supplier_invoices', 'supplier_invoice_items',
'supplier_invoice_payments',
'receipts', 'receipt_line_items',
'document_attachments', 'invoice_inbox_items',
'mapping_rules', 'categorization_templates',
'deadlines', 'cost_centers', 'projects',
'salary_payments', 'mileage_entries',
'sie_imports', 'sie_account_mappings',
'calendar_feeds', 'chat_sessions', 'chat_messages',
'ai_usage_tracking', 'extension_data', 'api_keys',
'skatteverket_tokens', 'pending_operations', 'payment_match_log',
'event_log', 'notification_log', 'audit_log',
'provider_consents', 'voucher_gap_explanations',
'automation_webhooks', 'email_connections'
];
tbl TEXT;
BEGIN
FOREACH tbl IN ARRAY affected_tables
LOOP
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = tbl
) THEN
FOR pol IN
SELECT policyname FROM pg_policies
WHERE schemaname = 'public' AND tablename = tbl
LOOP
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', pol.policyname, tbl);
END LOOP;
END IF;
END LOOP;
END $$;
-- =============================================================================
-- 3. Recreate policies using current_active_company_id()
-- =============================================================================
-- Same shape as the previous user_company_ids()-based policies, just
-- swapping the membership IN clause for an equality check against the
-- single active company.
-- company_settings
CREATE POLICY "company_settings_select" ON public.company_settings
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "company_settings_insert" ON public.company_settings
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "company_settings_update" ON public.company_settings
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "company_settings_delete" ON public.company_settings
FOR DELETE USING (company_id = public.current_active_company_id());
-- chart_of_accounts
CREATE POLICY "chart_of_accounts_select" ON public.chart_of_accounts
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "chart_of_accounts_insert" ON public.chart_of_accounts
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "chart_of_accounts_update" ON public.chart_of_accounts
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "chart_of_accounts_delete" ON public.chart_of_accounts
FOR DELETE USING (company_id = public.current_active_company_id());
-- fiscal_periods
CREATE POLICY "fiscal_periods_select" ON public.fiscal_periods
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "fiscal_periods_insert" ON public.fiscal_periods
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "fiscal_periods_update" ON public.fiscal_periods
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "fiscal_periods_delete" ON public.fiscal_periods
FOR DELETE USING (company_id = public.current_active_company_id());
-- journal_entries
CREATE POLICY "journal_entries_select" ON public.journal_entries
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "journal_entries_insert" ON public.journal_entries
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "journal_entries_update" ON public.journal_entries
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "journal_entries_delete" ON public.journal_entries
FOR DELETE USING (company_id = public.current_active_company_id());
-- journal_entry_lines (child: join to parent journal_entries)
CREATE POLICY "journal_entry_lines_select" ON public.journal_entry_lines
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id = public.current_active_company_id())
);
CREATE POLICY "journal_entry_lines_insert" ON public.journal_entry_lines
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id = public.current_active_company_id())
);
CREATE POLICY "journal_entry_lines_update" ON public.journal_entry_lines
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id = public.current_active_company_id())
);
CREATE POLICY "journal_entry_lines_delete" ON public.journal_entry_lines
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id = public.current_active_company_id())
);
-- account_balances (may not exist on fresh DBs)
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'account_balances'
) THEN
EXECUTE 'CREATE POLICY "account_balances_select" ON public.account_balances FOR SELECT USING (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "account_balances_insert" ON public.account_balances FOR INSERT WITH CHECK (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "account_balances_update" ON public.account_balances FOR UPDATE USING (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "account_balances_delete" ON public.account_balances FOR DELETE USING (company_id = public.current_active_company_id())';
END IF;
END $$;
-- voucher_sequences
CREATE POLICY "voucher_sequences_select" ON public.voucher_sequences
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
FOR UPDATE USING (company_id = public.current_active_company_id());
-- transactions
CREATE POLICY "transactions_select" ON public.transactions
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "transactions_insert" ON public.transactions
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "transactions_update" ON public.transactions
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "transactions_delete" ON public.transactions
FOR DELETE USING (company_id = public.current_active_company_id());
-- bank_connections
CREATE POLICY "bank_connections_select" ON public.bank_connections
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "bank_connections_insert" ON public.bank_connections
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "bank_connections_update" ON public.bank_connections
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "bank_connections_delete" ON public.bank_connections
FOR DELETE USING (company_id = public.current_active_company_id());
-- bank_file_imports
CREATE POLICY "bank_file_imports_select" ON public.bank_file_imports
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "bank_file_imports_insert" ON public.bank_file_imports
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "bank_file_imports_update" ON public.bank_file_imports
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "bank_file_imports_delete" ON public.bank_file_imports
FOR DELETE USING (company_id = public.current_active_company_id());
-- customers
CREATE POLICY "customers_select" ON public.customers
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "customers_insert" ON public.customers
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "customers_update" ON public.customers
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "customers_delete" ON public.customers
FOR DELETE USING (company_id = public.current_active_company_id());
-- invoices
CREATE POLICY "invoices_select" ON public.invoices
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "invoices_insert" ON public.invoices
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "invoices_update" ON public.invoices
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "invoices_delete" ON public.invoices
FOR DELETE USING (company_id = public.current_active_company_id());
-- invoice_items (child: join to parent invoices)
CREATE POLICY "invoice_items_select" ON public.invoice_items
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id = public.current_active_company_id())
);
CREATE POLICY "invoice_items_insert" ON public.invoice_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id = public.current_active_company_id())
);
CREATE POLICY "invoice_items_update" ON public.invoice_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id = public.current_active_company_id())
);
CREATE POLICY "invoice_items_delete" ON public.invoice_items
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id = public.current_active_company_id())
);
-- invoice_reminders
CREATE POLICY "invoice_reminders_select" ON public.invoice_reminders
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "invoice_reminders_insert" ON public.invoice_reminders
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "invoice_reminders_update" ON public.invoice_reminders
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "invoice_reminders_delete" ON public.invoice_reminders
FOR DELETE USING (company_id = public.current_active_company_id());
-- invoice_payments
CREATE POLICY "invoice_payments_select" ON public.invoice_payments
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "invoice_payments_insert" ON public.invoice_payments
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "invoice_payments_update" ON public.invoice_payments
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "invoice_payments_delete" ON public.invoice_payments
FOR DELETE USING (company_id = public.current_active_company_id());
-- suppliers
CREATE POLICY "suppliers_select" ON public.suppliers
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "suppliers_insert" ON public.suppliers
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "suppliers_update" ON public.suppliers
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "suppliers_delete" ON public.suppliers
FOR DELETE USING (company_id = public.current_active_company_id());
-- supplier_invoices
CREATE POLICY "supplier_invoices_select" ON public.supplier_invoices
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "supplier_invoices_insert" ON public.supplier_invoices
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "supplier_invoices_update" ON public.supplier_invoices
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "supplier_invoices_delete" ON public.supplier_invoices
FOR DELETE USING (company_id = public.current_active_company_id());
-- supplier_invoice_items (child: join to parent supplier_invoices)
CREATE POLICY "supplier_invoice_items_select" ON public.supplier_invoice_items
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id = public.current_active_company_id())
);
CREATE POLICY "supplier_invoice_items_insert" ON public.supplier_invoice_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id = public.current_active_company_id())
);
CREATE POLICY "supplier_invoice_items_update" ON public.supplier_invoice_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id = public.current_active_company_id())
);
CREATE POLICY "supplier_invoice_items_delete" ON public.supplier_invoice_items
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id = public.current_active_company_id())
);
-- supplier_invoice_payments
CREATE POLICY "supplier_invoice_payments_select" ON public.supplier_invoice_payments
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "supplier_invoice_payments_insert" ON public.supplier_invoice_payments
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "supplier_invoice_payments_update" ON public.supplier_invoice_payments
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "supplier_invoice_payments_delete" ON public.supplier_invoice_payments
FOR DELETE USING (company_id = public.current_active_company_id());
-- receipts
CREATE POLICY "receipts_select" ON public.receipts
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "receipts_insert" ON public.receipts
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "receipts_update" ON public.receipts
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "receipts_delete" ON public.receipts
FOR DELETE USING (company_id = public.current_active_company_id());
-- receipt_line_items (child: join to parent receipts)
CREATE POLICY "receipt_line_items_select" ON public.receipt_line_items
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id = public.current_active_company_id())
);
CREATE POLICY "receipt_line_items_insert" ON public.receipt_line_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id = public.current_active_company_id())
);
CREATE POLICY "receipt_line_items_update" ON public.receipt_line_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id = public.current_active_company_id())
);
CREATE POLICY "receipt_line_items_delete" ON public.receipt_line_items
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id = public.current_active_company_id())
);
-- document_attachments (DELETE is blocked by block_document_deletion trigger
-- but we still add a policy for completeness — the trigger runs after RLS)
CREATE POLICY "document_attachments_select" ON public.document_attachments
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "document_attachments_insert" ON public.document_attachments
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "document_attachments_update" ON public.document_attachments
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "document_attachments_delete" ON public.document_attachments
FOR DELETE USING (company_id = public.current_active_company_id());
-- invoice_inbox_items
CREATE POLICY "invoice_inbox_items_select" ON public.invoice_inbox_items
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "invoice_inbox_items_insert" ON public.invoice_inbox_items
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "invoice_inbox_items_update" ON public.invoice_inbox_items
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "invoice_inbox_items_delete" ON public.invoice_inbox_items
FOR DELETE USING (company_id = public.current_active_company_id());
-- mapping_rules (system rules have company_id IS NULL)
CREATE POLICY "mapping_rules_select" ON public.mapping_rules
FOR SELECT USING (company_id = public.current_active_company_id() OR company_id IS NULL);
CREATE POLICY "mapping_rules_insert" ON public.mapping_rules
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "mapping_rules_update" ON public.mapping_rules
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "mapping_rules_delete" ON public.mapping_rules
FOR DELETE USING (company_id = public.current_active_company_id());
-- categorization_templates
CREATE POLICY "categorization_templates_select" ON public.categorization_templates
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "categorization_templates_insert" ON public.categorization_templates
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "categorization_templates_update" ON public.categorization_templates
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "categorization_templates_delete" ON public.categorization_templates
FOR DELETE USING (company_id = public.current_active_company_id());
-- deadlines
CREATE POLICY "deadlines_select" ON public.deadlines
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "deadlines_insert" ON public.deadlines
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "deadlines_update" ON public.deadlines
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "deadlines_delete" ON public.deadlines
FOR DELETE USING (company_id = public.current_active_company_id());
-- cost_centers
CREATE POLICY "cost_centers_select" ON public.cost_centers
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "cost_centers_insert" ON public.cost_centers
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "cost_centers_update" ON public.cost_centers
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "cost_centers_delete" ON public.cost_centers
FOR DELETE USING (company_id = public.current_active_company_id());
-- projects
CREATE POLICY "projects_select" ON public.projects
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "projects_insert" ON public.projects
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "projects_update" ON public.projects
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "projects_delete" ON public.projects
FOR DELETE USING (company_id = public.current_active_company_id());
-- salary_payments (may not exist on fresh DBs)
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'salary_payments'
) THEN
EXECUTE 'CREATE POLICY "salary_payments_select" ON public.salary_payments FOR SELECT USING (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "salary_payments_insert" ON public.salary_payments FOR INSERT WITH CHECK (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "salary_payments_update" ON public.salary_payments FOR UPDATE USING (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "salary_payments_delete" ON public.salary_payments FOR DELETE USING (company_id = public.current_active_company_id())';
END IF;
END $$;
-- mileage_entries (may not exist on fresh DBs)
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'mileage_entries'
) THEN
EXECUTE 'CREATE POLICY "mileage_entries_select" ON public.mileage_entries FOR SELECT USING (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "mileage_entries_insert" ON public.mileage_entries FOR INSERT WITH CHECK (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "mileage_entries_update" ON public.mileage_entries FOR UPDATE USING (company_id = public.current_active_company_id())';
EXECUTE 'CREATE POLICY "mileage_entries_delete" ON public.mileage_entries FOR DELETE USING (company_id = public.current_active_company_id())';
END IF;
END $$;
-- sie_imports
CREATE POLICY "sie_imports_select" ON public.sie_imports
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "sie_imports_insert" ON public.sie_imports
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "sie_imports_update" ON public.sie_imports
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "sie_imports_delete" ON public.sie_imports
FOR DELETE USING (company_id = public.current_active_company_id());
-- sie_account_mappings
CREATE POLICY "sie_account_mappings_select" ON public.sie_account_mappings
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "sie_account_mappings_insert" ON public.sie_account_mappings
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "sie_account_mappings_update" ON public.sie_account_mappings
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "sie_account_mappings_delete" ON public.sie_account_mappings
FOR DELETE USING (company_id = public.current_active_company_id());
-- calendar_feeds
CREATE POLICY "calendar_feeds_select" ON public.calendar_feeds
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "calendar_feeds_insert" ON public.calendar_feeds
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "calendar_feeds_update" ON public.calendar_feeds
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "calendar_feeds_delete" ON public.calendar_feeds
FOR DELETE USING (company_id = public.current_active_company_id());
-- chat_sessions
CREATE POLICY "chat_sessions_select" ON public.chat_sessions
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "chat_sessions_insert" ON public.chat_sessions
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "chat_sessions_update" ON public.chat_sessions
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "chat_sessions_delete" ON public.chat_sessions
FOR DELETE USING (company_id = public.current_active_company_id());
-- chat_messages
CREATE POLICY "chat_messages_select" ON public.chat_messages
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "chat_messages_insert" ON public.chat_messages
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "chat_messages_update" ON public.chat_messages
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "chat_messages_delete" ON public.chat_messages
FOR DELETE USING (company_id = public.current_active_company_id());
-- ai_usage_tracking (SELECT + INSERT only — no UPDATE/DELETE policy)
CREATE POLICY "ai_usage_tracking_select" ON public.ai_usage_tracking
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "ai_usage_tracking_insert" ON public.ai_usage_tracking
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
-- extension_data
CREATE POLICY "extension_data_select" ON public.extension_data
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "extension_data_insert" ON public.extension_data
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "extension_data_update" ON public.extension_data
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "extension_data_delete" ON public.extension_data
FOR DELETE USING (company_id = public.current_active_company_id());
-- api_keys
CREATE POLICY "api_keys_select" ON public.api_keys
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "api_keys_insert" ON public.api_keys
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "api_keys_update" ON public.api_keys
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "api_keys_delete" ON public.api_keys
FOR DELETE USING (company_id = public.current_active_company_id());
-- skatteverket_tokens
CREATE POLICY "skatteverket_tokens_select" ON public.skatteverket_tokens
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "skatteverket_tokens_insert" ON public.skatteverket_tokens
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "skatteverket_tokens_update" ON public.skatteverket_tokens
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "skatteverket_tokens_delete" ON public.skatteverket_tokens
FOR DELETE USING (company_id = public.current_active_company_id());
-- pending_operations (SELECT + UPDATE only — inserts via service role)
CREATE POLICY "pending_operations_select" ON public.pending_operations
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "pending_operations_update" ON public.pending_operations
FOR UPDATE USING (company_id = public.current_active_company_id());
-- payment_match_log (nullable company_id)
CREATE POLICY "payment_match_log_select" ON public.payment_match_log
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "payment_match_log_insert" ON public.payment_match_log
FOR INSERT WITH CHECK (company_id = public.current_active_company_id() OR company_id IS NULL);
-- event_log (SELECT only for users — writes via handler in service role)
CREATE POLICY "event_log_select" ON public.event_log
FOR SELECT USING (company_id = public.current_active_company_id());
-- notification_log (nullable company_id for system-wide notifications)
CREATE POLICY "notification_log_select" ON public.notification_log
FOR SELECT USING (company_id = public.current_active_company_id() OR company_id IS NULL);
-- audit_log (SELECT only for users — writes via SECURITY DEFINER triggers)
CREATE POLICY "audit_log_select" ON public.audit_log
FOR SELECT USING (company_id = public.current_active_company_id());
-- provider_consents
CREATE POLICY "provider_consents_select" ON public.provider_consents
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "provider_consents_insert" ON public.provider_consents
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "provider_consents_update" ON public.provider_consents
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "provider_consents_delete" ON public.provider_consents
FOR DELETE USING (company_id = public.current_active_company_id());
-- voucher_gap_explanations (preserves the owner/admin team_members check)
CREATE POLICY "voucher_gap_explanations_select" ON public.voucher_gap_explanations
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "voucher_gap_explanations_insert" ON public.voucher_gap_explanations
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND EXISTS (
SELECT 1 FROM public.team_members tm
JOIN public.companies c ON c.team_id = tm.team_id
WHERE c.id = company_id
AND tm.user_id = auth.uid()
AND tm.role IN ('owner', 'admin')
)
);
CREATE POLICY "voucher_gap_explanations_update" ON public.voucher_gap_explanations
FOR UPDATE USING (
company_id = public.current_active_company_id()
AND EXISTS (
SELECT 1 FROM public.team_members tm
JOIN public.companies c ON c.team_id = tm.team_id
WHERE c.id = company_id
AND tm.user_id = auth.uid()
AND tm.role IN ('owner', 'admin')
)
);
-- automation_webhooks
CREATE POLICY "automation_webhooks_select" ON public.automation_webhooks
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "automation_webhooks_insert" ON public.automation_webhooks
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "automation_webhooks_update" ON public.automation_webhooks
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "automation_webhooks_delete" ON public.automation_webhooks
FOR DELETE USING (company_id = public.current_active_company_id());
-- email_connections
CREATE POLICY "email_connections_select" ON public.email_connections
FOR SELECT USING (company_id = public.current_active_company_id());
CREATE POLICY "email_connections_insert" ON public.email_connections
FOR INSERT WITH CHECK (company_id = public.current_active_company_id());
CREATE POLICY "email_connections_update" ON public.email_connections
FOR UPDATE USING (company_id = public.current_active_company_id());
CREATE POLICY "email_connections_delete" ON public.email_connections
FOR DELETE USING (company_id = public.current_active_company_id());
@@ -1,364 +0,0 @@
-- =============================================================================
-- Viewer role read-only enforcement
-- =============================================================================
--
-- Activates the `viewer` role in company_members. Until now the role existed
-- in the type system (CompanyRole) but was dormant — a viewer had the same
-- write capabilities as a member. This migration makes viewers truly
-- read-only at the database level.
--
-- Mechanism:
-- 1. New helper `public.current_user_can_write()` returns true only if
-- the authenticated user's role in the active company is NOT 'viewer'.
-- 2. Every INSERT / UPDATE / DELETE policy on company-scoped tables is
-- recreated with an added `AND public.current_user_can_write()` clause.
-- 3. SELECT policies are left untouched — viewers read everything.
--
-- After this migration, any write by a viewer is rejected with:
-- "new row violates row-level security policy" (server-side)
-- regardless of whether it originates from the UI, a browser console, or
-- an API key bound to a viewer's session.
-- =============================================================================
-- =============================================================================
-- 1. current_user_can_write() helper
-- =============================================================================
CREATE OR REPLACE FUNCTION public.current_user_can_write()
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
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'
);
$$;
GRANT EXECUTE ON FUNCTION public.current_user_can_write() TO authenticated;
COMMENT ON FUNCTION public.current_user_can_write() IS
'Returns true if auth.uid() has a non-viewer role (owner / admin / member) '
'in the current active company. Used by RLS INSERT/UPDATE/DELETE policies '
'to make the ''viewer'' role truly read-only. Viewers, non-members, and '
'unauthenticated callers all get false.';
-- =============================================================================
-- 2. Standard tables — recreate INSERT / UPDATE / DELETE policies
-- =============================================================================
-- Loop over all company-scoped tables that currently have the full set of
-- insert/update/delete policies. SELECT policies are left alone (viewers
-- must be able to read everything).
DO $$
DECLARE
t TEXT;
standard_tables TEXT[] := ARRAY[
'company_settings', 'chart_of_accounts', 'fiscal_periods', 'journal_entries',
'transactions', 'bank_connections', 'bank_file_imports',
'customers', 'invoices', 'invoice_reminders', 'invoice_payments',
'suppliers', 'supplier_invoices', 'supplier_invoice_payments',
'receipts', 'document_attachments', 'invoice_inbox_items',
'categorization_templates', 'deadlines', 'cost_centers', 'projects',
'sie_imports', 'sie_account_mappings', 'calendar_feeds',
'chat_sessions', 'chat_messages',
'extension_data', 'api_keys', 'skatteverket_tokens',
'provider_consents', 'automation_webhooks', 'email_connections'
];
BEGIN
FOREACH t IN ARRAY standard_tables
LOOP
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = t
) THEN
EXECUTE format('DROP POLICY IF EXISTS "%s_insert" ON public.%I', t, t);
EXECUTE format('DROP POLICY IF EXISTS "%s_update" ON public.%I', t, t);
EXECUTE format('DROP POLICY IF EXISTS "%s_delete" ON public.%I', t, t);
EXECUTE format(
'CREATE POLICY "%s_insert" ON public.%I '
'FOR INSERT WITH CHECK ('
'company_id = public.current_active_company_id() '
'AND public.current_user_can_write())',
t, t
);
EXECUTE format(
'CREATE POLICY "%s_update" ON public.%I '
'FOR UPDATE USING ('
'company_id = public.current_active_company_id() '
'AND public.current_user_can_write())',
t, t
);
EXECUTE format(
'CREATE POLICY "%s_delete" ON public.%I '
'FOR DELETE USING ('
'company_id = public.current_active_company_id() '
'AND public.current_user_can_write())',
t, t
);
END IF;
END LOOP;
END $$;
-- =============================================================================
-- 3. voucher_sequences (INSERT + UPDATE only, no DELETE policy)
-- =============================================================================
DROP POLICY IF EXISTS "voucher_sequences_insert" ON public.voucher_sequences;
DROP POLICY IF EXISTS "voucher_sequences_update" ON public.voucher_sequences;
CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
FOR UPDATE USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
-- =============================================================================
-- 4. mapping_rules (standard, but with OR company_id IS NULL on SELECT only)
-- =============================================================================
-- The SELECT policy keeps its OR company_id IS NULL exception for system
-- rules — viewers can still read them. Writes drop the NULL branch because
-- viewers never create rules (and non-viewers only create company-scoped
-- rules, not system rules).
DROP POLICY IF EXISTS "mapping_rules_insert" ON public.mapping_rules;
DROP POLICY IF EXISTS "mapping_rules_update" ON public.mapping_rules;
DROP POLICY IF EXISTS "mapping_rules_delete" ON public.mapping_rules;
CREATE POLICY "mapping_rules_insert" ON public.mapping_rules
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
CREATE POLICY "mapping_rules_update" ON public.mapping_rules
FOR UPDATE USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
CREATE POLICY "mapping_rules_delete" ON public.mapping_rules
FOR DELETE USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
-- =============================================================================
-- 5. ai_usage_tracking (INSERT only)
-- =============================================================================
DROP POLICY IF EXISTS "ai_usage_tracking_insert" ON public.ai_usage_tracking;
CREATE POLICY "ai_usage_tracking_insert" ON public.ai_usage_tracking
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
-- =============================================================================
-- 6. pending_operations (UPDATE only — inserts via service role)
-- =============================================================================
DROP POLICY IF EXISTS "pending_operations_update" ON public.pending_operations;
CREATE POLICY "pending_operations_update" ON public.pending_operations
FOR UPDATE USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
-- =============================================================================
-- 7. payment_match_log (INSERT only, nullable company_id)
-- =============================================================================
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 WITH CHECK (
(company_id = public.current_active_company_id() OR company_id IS NULL)
AND public.current_user_can_write()
);
-- =============================================================================
-- 8. Child tables — join to parent via EXISTS
-- =============================================================================
-- journal_entry_lines
DROP POLICY IF EXISTS "journal_entry_lines_insert" ON public.journal_entry_lines;
DROP POLICY IF EXISTS "journal_entry_lines_update" ON public.journal_entry_lines;
DROP POLICY IF EXISTS "journal_entry_lines_delete" ON public.journal_entry_lines;
CREATE POLICY "journal_entry_lines_insert" ON public.journal_entry_lines
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "journal_entry_lines_update" ON public.journal_entry_lines
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "journal_entry_lines_delete" ON public.journal_entry_lines
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.journal_entries je
WHERE je.id = journal_entry_lines.journal_entry_id
AND je.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
-- invoice_items
DROP POLICY IF EXISTS "invoice_items_insert" ON public.invoice_items;
DROP POLICY IF EXISTS "invoice_items_update" ON public.invoice_items;
DROP POLICY IF EXISTS "invoice_items_delete" ON public.invoice_items;
CREATE POLICY "invoice_items_insert" ON public.invoice_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "invoice_items_update" ON public.invoice_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "invoice_items_delete" ON public.invoice_items
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.invoices i
WHERE i.id = invoice_items.invoice_id
AND i.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
-- supplier_invoice_items
DROP POLICY IF EXISTS "supplier_invoice_items_insert" ON public.supplier_invoice_items;
DROP POLICY IF EXISTS "supplier_invoice_items_update" ON public.supplier_invoice_items;
DROP POLICY IF EXISTS "supplier_invoice_items_delete" ON public.supplier_invoice_items;
CREATE POLICY "supplier_invoice_items_insert" ON public.supplier_invoice_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "supplier_invoice_items_update" ON public.supplier_invoice_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "supplier_invoice_items_delete" ON public.supplier_invoice_items
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.supplier_invoices si
WHERE si.id = supplier_invoice_items.supplier_invoice_id
AND si.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
-- receipt_line_items
DROP POLICY IF EXISTS "receipt_line_items_insert" ON public.receipt_line_items;
DROP POLICY IF EXISTS "receipt_line_items_update" ON public.receipt_line_items;
DROP POLICY IF EXISTS "receipt_line_items_delete" ON public.receipt_line_items;
CREATE POLICY "receipt_line_items_insert" ON public.receipt_line_items
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "receipt_line_items_update" ON public.receipt_line_items
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
CREATE POLICY "receipt_line_items_delete" ON public.receipt_line_items
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.receipts r
WHERE r.id = receipt_line_items.receipt_id
AND r.company_id = public.current_active_company_id())
AND public.current_user_can_write()
);
-- =============================================================================
-- 9. voucher_gap_explanations (preserves owner/admin team_members check)
-- =============================================================================
-- The existing policy already blocks non-(owner|admin) via team_members, so
-- viewers are de facto blocked. We add current_user_can_write() as a
-- defensive second layer so a future refactor of team_members can't
-- accidentally re-open viewer writes.
DROP POLICY IF EXISTS "voucher_gap_explanations_insert" ON public.voucher_gap_explanations;
DROP POLICY IF EXISTS "voucher_gap_explanations_update" ON public.voucher_gap_explanations;
CREATE POLICY "voucher_gap_explanations_insert" ON public.voucher_gap_explanations
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
AND EXISTS (
SELECT 1 FROM public.team_members tm
JOIN public.companies c ON c.team_id = tm.team_id
WHERE c.id = company_id
AND tm.user_id = auth.uid()
AND tm.role IN ('owner', 'admin')
)
);
CREATE POLICY "voucher_gap_explanations_update" ON public.voucher_gap_explanations
FOR UPDATE USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
AND EXISTS (
SELECT 1 FROM public.team_members tm
JOIN public.companies c ON c.team_id = tm.team_id
WHERE c.id = company_id
AND tm.user_id = auth.uid()
AND tm.role IN ('owner', 'admin')
)
);
-- =============================================================================
-- 10. Conditional tables (may not exist on fresh DBs)
-- =============================================================================
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'account_balances'
) THEN
EXECUTE 'DROP POLICY IF EXISTS "account_balances_insert" ON public.account_balances';
EXECUTE 'DROP POLICY IF EXISTS "account_balances_update" ON public.account_balances';
EXECUTE 'DROP POLICY IF EXISTS "account_balances_delete" ON public.account_balances';
EXECUTE 'CREATE POLICY "account_balances_insert" ON public.account_balances FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
EXECUTE 'CREATE POLICY "account_balances_update" ON public.account_balances FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
EXECUTE 'CREATE POLICY "account_balances_delete" ON public.account_balances FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'salary_payments'
) THEN
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_insert" ON public.salary_payments';
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_update" ON public.salary_payments';
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_delete" ON public.salary_payments';
EXECUTE 'CREATE POLICY "salary_payments_insert" ON public.salary_payments FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
EXECUTE 'CREATE POLICY "salary_payments_update" ON public.salary_payments FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
EXECUTE 'CREATE POLICY "salary_payments_delete" ON public.salary_payments FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
END IF;
END $$;
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'mileage_entries'
) THEN
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_insert" ON public.mileage_entries';
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_update" ON public.mileage_entries';
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_delete" ON public.mileage_entries';
EXECUTE 'CREATE POLICY "mileage_entries_insert" ON public.mileage_entries FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
EXECUTE 'CREATE POLICY "mileage_entries_update" ON public.mileage_entries FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
EXECUTE 'CREATE POLICY "mileage_entries_delete" ON public.mileage_entries FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
END IF;
END $$;
@@ -1,11 +0,0 @@
-- Add default voucher series to company_settings
-- Allows companies to configure which series (A-Z) is pre-selected
-- when creating manual journal entries. Defaults to 'A'.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS default_voucher_series text NOT NULL DEFAULT 'A';
-- Enforce single uppercase letter
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_default_voucher_series_check
CHECK (default_voucher_series ~ '^[A-Z]$');
@@ -1,52 +0,0 @@
-- Generalized version of get_unlinked_1930_lines that accepts any bank account number.
-- This allows reconciliation against secondary bank accounts (e.g. 1931, 1932).
CREATE FUNCTION public.get_unlinked_bank_lines(
p_company_id UUID,
p_date_from DATE DEFAULT NULL,
p_date_to DATE DEFAULT NULL,
p_account_number TEXT DEFAULT '1930'
)
RETURNS TABLE (
line_id UUID,
journal_entry_id UUID,
debit_amount NUMERIC,
credit_amount NUMERIC,
line_description TEXT,
entry_date DATE,
voucher_number INT,
voucher_series TEXT,
entry_description TEXT,
source_type TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
jel.id AS line_id,
je.id AS journal_entry_id,
jel.debit_amount,
jel.credit_amount,
jel.line_description,
je.entry_date,
je.voucher_number,
je.voucher_series,
je.description AS entry_description,
je.source_type
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_number = p_account_number
AND je.company_id = p_company_id
AND je.status = 'posted'
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
AND NOT EXISTS (
SELECT 1
FROM public.transactions t
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
)
ORDER BY je.entry_date, je.voucher_number;
$$;
@@ -1,79 +0,0 @@
-- Allow completed SIE imports to be marked as 'replaced' when a user wants to
-- re-import corrected data for the same fiscal period.
--
-- Compliance: replaced imports and their cancelled journal entries remain in the
-- database as audit trail per BFL 5 kap 5§ (rättelse) and BFNAR 2013:2 kap 8
-- (behandlingshistorik). Nothing is deleted.
-- 1. Expand status CHECK to include 'replaced'
ALTER TABLE public.sie_imports
DROP CONSTRAINT IF EXISTS sie_imports_status_check;
ALTER TABLE public.sie_imports
ADD CONSTRAINT sie_imports_status_check
CHECK (status IN ('pending', 'mapped', 'completed', 'failed', 'replaced'));
-- 2. Add audit column for tracking when the import was replaced
ALTER TABLE public.sie_imports
ADD COLUMN IF NOT EXISTS replaced_at timestamptz;
-- 3. Convert UNIQUE (company_id, file_hash) to a partial unique index that
-- excludes replaced/failed imports. This allows re-importing the same file
-- after a previous import has been replaced.
ALTER TABLE public.sie_imports
DROP CONSTRAINT IF EXISTS sie_imports_company_id_file_hash_key;
CREATE UNIQUE INDEX IF NOT EXISTS sie_imports_company_id_file_hash_active_idx
ON public.sie_imports (company_id, file_hash)
WHERE status NOT IN ('replaced', 'failed');
-- 4. Atomic RPC to cancel entries and mark import as replaced in one transaction.
-- Prevents inconsistent state where entries are cancelled but import stays 'completed'.
CREATE OR REPLACE FUNCTION public.replace_sie_import(
p_company_id uuid,
p_import_id uuid
) RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_cancelled integer;
v_fiscal_period_id uuid;
v_opening_balance_entry_id uuid;
BEGIN
-- Look up the import record (caller must have verified status/permissions)
SELECT fiscal_period_id, opening_balance_entry_id
INTO v_fiscal_period_id, v_opening_balance_entry_id
FROM public.sie_imports
WHERE id = p_import_id AND company_id = p_company_id AND status = 'completed';
IF NOT FOUND THEN
RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
END IF;
-- Cancel all journal entries belonging to this import
UPDATE public.journal_entries
SET status = 'cancelled'
WHERE company_id = p_company_id
AND status = 'posted'
AND id IN (
-- Opening balance entry
SELECT v_opening_balance_entry_id WHERE v_opening_balance_entry_id IS NOT NULL
UNION ALL
-- Imported vouchers + migration adjustment
SELECT je.id FROM public.journal_entries je
WHERE je.company_id = p_company_id
AND je.fiscal_period_id = v_fiscal_period_id
AND je.source_type = 'import'
AND je.status = 'posted'
);
GET DIAGNOSTICS v_cancelled = ROW_COUNT;
-- Mark import as replaced
UPDATE public.sie_imports
SET status = 'replaced', replaced_at = now()
WHERE id = p_import_id AND company_id = p_company_id;
RETURN v_cancelled;
END;
$$;
@@ -1,4 +0,0 @@
-- Retroactive schema cache reload after recent ALTER TABLE migrations
-- (trade_name, delivery_date, pays_salaries, etc.) that did not include
-- NOTIFY pgrst. Ensures PostgREST picks up all new columns immediately.
NOTIFY pgrst, 'reload schema';
@@ -1,73 +0,0 @@
-- Add optional notes/comment field to journal entries.
-- Notes are internal metadata (not part of BFL verifikation content)
-- and remain editable even after posting, following the Fortnox model.
ALTER TABLE public.journal_entries ADD COLUMN notes text;
-- Update the immutability trigger to allow notes-only updates on posted entries.
-- BFL 5 kap 7§ defines verifikation content (date, description, amount, counterparty,
-- number, underlag refs). Notes are NOT in that list — they are internal metadata.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
-- Session variable bypass for controlled operations (e.g. delete_last_voucher RPC)
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
-- No exemption for drafts: varaktighet applies from insertion.
-- Application code uses status='cancelled' instead of DELETE.
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
-- Draft can transition to draft (update fields), posted, or cancelled
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
-- Posted entries: allow notes-only updates (internal metadata, not verifikation content)
-- BFL 5 kap 7§ mandates immutability for verifikation fields (description, date, amounts,
-- voucher number, etc.). Notes/comments are outside this scope per Fortnox precedent.
IF OLD.status = 'posted' AND NEW.status = 'posted' THEN
IF NEW.description = OLD.description
AND NEW.entry_date = OLD.entry_date
AND NEW.fiscal_period_id = OLD.fiscal_period_id
AND NEW.voucher_number = OLD.voucher_number
AND NEW.voucher_series = OLD.voucher_series
AND NEW.source_type = OLD.source_type
AND COALESCE(NEW.source_id::text, '') = COALESCE(OLD.source_id::text, '')
AND NEW.user_id = OLD.user_id
AND COALESCE(NEW.reversed_by_id::text, '') = COALESCE(OLD.reversed_by_id::text, '')
AND COALESCE(NEW.reverses_id::text, '') = COALESCE(OLD.reverses_id::text, '')
AND COALESCE(NEW.correction_of_id::text, '') = COALESCE(OLD.correction_of_id::text, '')
AND NEW.committed_at IS NOT DISTINCT FROM OLD.committed_at
THEN
RETURN NEW; -- Only notes and updated_at may differ
END IF;
END IF;
-- Posted can transition to reversed (storno) or cancelled (orphaned cleanup)
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
-- Reversed entries can transition back to posted (when their storno is deleted)
IF OLD.status = 'reversed' AND NEW.status = 'posted'
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END; $$;
NOTIFY pgrst, 'reload schema';
@@ -1,223 +0,0 @@
-- Delete last voucher per series (Fortnox model).
-- Allows deleting the highest-numbered posted voucher in a series,
-- maintaining an unbroken voucher number sequence per BFL 5 kap 7§.
--
-- Safeguards:
-- 1. Only the LAST voucher in its (company, fiscal_period, series) can be deleted
-- 2. Fiscal period must not be closed or locked
-- 3. No other entries may reference it (reverses_id, correction_of_id)
-- 4. Calling user must be company owner or admin
-- 5. Full audit trail via write_audit_log() trigger (BFNAR 2013:2 behandlingshistorik)
-- 1. Update line immutability trigger to respect session variable bypass
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v_status text;
BEGIN
-- Session variable bypass for controlled operations (delete_last_voucher RPC)
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
SELECT status INTO v_status FROM public.journal_entries
WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id);
-- Draft entries: all operations allowed
IF v_status = 'draft' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
-- Cancelled entries: only DELETE for cleanup
IF v_status = 'cancelled' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP;
END IF;
RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status;
END; $$;
-- 2. Update retention trigger to respect session variable bypass
CREATE OR REPLACE FUNCTION public.enforce_retention_journal_entries()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_retention_expires date;
BEGIN
-- Session variable bypass for controlled operations (delete_last_voucher RPC)
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN OLD;
END IF;
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.fiscal_periods fp
WHERE fp.id = OLD.fiscal_period_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
VALUES (OLD.user_id, 'RETENTION_BLOCK', 'journal_entries', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete journal entry within 7-year retention period (expires %)',
v_retention_expires;
END IF;
RETURN OLD;
END; $$;
-- 3. Create the delete_last_voucher RPC
CREATE OR REPLACE FUNCTION public.delete_last_voucher(
p_company_id uuid,
p_entry_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry record;
v_period record;
v_max_voucher integer;
v_ref_count integer;
v_caller_role text;
v_snapshot jsonb;
v_lines_snapshot jsonb;
BEGIN
-- 1. Verify calling user is company owner or admin
SELECT cm.role INTO v_caller_role
FROM company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid();
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'Only company owners and admins can delete vouchers';
END IF;
-- 2. Lock and fetch the target entry
SELECT * INTO v_entry
FROM journal_entries
WHERE id = p_entry_id
AND company_id = p_company_id
FOR UPDATE;
IF v_entry IS NULL THEN
RAISE EXCEPTION 'Journal entry not found';
END IF;
IF v_entry.status != 'posted' THEN
RAISE EXCEPTION 'Only posted entries can be deleted (current status: %)', v_entry.status;
END IF;
-- 3. Verify fiscal period is open
SELECT * INTO v_period
FROM fiscal_periods
WHERE id = v_entry.fiscal_period_id
FOR UPDATE;
IF v_period.is_closed THEN
RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period';
END IF;
IF v_period.locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period';
END IF;
-- 4. Lock voucher_sequences row to serialise against concurrent commit_journal_entry.
-- Without this, a concurrent commit could assign a new voucher number between
-- our MAX check and the DELETE, producing a gap (violating BFL 5 kap 7§).
PERFORM 1 FROM voucher_sequences
WHERE company_id = p_company_id
AND fiscal_period_id = v_entry.fiscal_period_id
AND voucher_series = v_entry.voucher_series
FOR UPDATE;
-- Verify it is the LAST voucher in its series
SELECT MAX(voucher_number) INTO v_max_voucher
FROM journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = v_entry.fiscal_period_id
AND voucher_series = v_entry.voucher_series
AND status NOT IN ('cancelled', 'draft');
IF v_entry.voucher_number != v_max_voucher THEN
RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %',
v_entry.voucher_series, v_entry.voucher_number, v_max_voucher;
END IF;
-- 5. Verify no other entries reference this one
SELECT COUNT(*) INTO v_ref_count
FROM journal_entries
WHERE company_id = p_company_id
AND status != 'cancelled'
AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id);
IF v_ref_count > 0 THEN
RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)',
v_ref_count;
END IF;
-- 6. Capture snapshot for audit trail (BFNAR 2013:2 behandlingshistorik)
-- The write_audit_log() AFTER trigger also captures old_state, but we
-- include lines here for complete traceability.
SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot
FROM journal_entry_lines l
WHERE l.journal_entry_id = p_entry_id;
v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb));
-- 7. If this entry is a storno (reverses another entry), restore the original
IF v_entry.reverses_id IS NOT NULL THEN
-- Use session variable to allow reversed → posted transition
PERFORM set_config('gnubok.allow_delete', 'true', true);
UPDATE journal_entries
SET status = 'posted', reversed_by_id = NULL
WHERE id = v_entry.reverses_id
AND company_id = p_company_id;
END IF;
-- 8. Enable session variable bypass for deletion triggers
PERFORM set_config('gnubok.allow_delete', 'true', true);
-- 9. Unlink document attachments (FK is RESTRICT, must clear before delete)
UPDATE document_attachments
SET journal_entry_id = NULL
WHERE journal_entry_id = p_entry_id;
-- 10. Delete the journal entry
-- journal_entry_lines: ON DELETE CASCADE (auto-deleted)
-- transactions: ON DELETE SET NULL (auto-nullified)
-- supplier_invoices: ON DELETE SET NULL (auto-nullified)
-- supplier_invoice_payments: ON DELETE SET NULL (auto-nullified)
-- invoice_payments: ON DELETE SET NULL (auto-nullified)
DELETE FROM journal_entries WHERE id = p_entry_id;
-- 11. Decrement voucher sequence
UPDATE voucher_sequences
SET last_number = GREATEST(last_number - 1, 0)
WHERE company_id = p_company_id
AND fiscal_period_id = v_entry.fiscal_period_id
AND voucher_series = v_entry.voucher_series;
-- 12. Insert explicit audit entry with full snapshot including lines
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
VALUES (
v_entry.user_id,
'DELETE',
'journal_entries',
p_entry_id,
auth.uid(),
v_snapshot,
'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number ||
' (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
);
RETURN jsonb_build_object(
'deleted', true,
'voucher_series', v_entry.voucher_series,
'voucher_number', v_entry.voucher_number
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,19 +0,0 @@
-- Efficient email existence check for invite flow.
-- Replaces the previous approach of listing all auth users (which only
-- returned the first page and broke for instances with >50 users).
CREATE OR REPLACE FUNCTION public.check_email_exists(email_to_check text)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT EXISTS (
SELECT 1 FROM auth.users WHERE lower(email) = lower(email_to_check)
);
$$;
-- Only callable by service role — prevents email enumeration via PostgREST.
-- Must revoke from PUBLIC first (PostgreSQL grants EXECUTE to PUBLIC by default),
-- then grant explicitly to service_role.
REVOKE EXECUTE ON FUNCTION public.check_email_exists(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.check_email_exists(text) TO service_role;
@@ -1,78 +0,0 @@
-- =============================================================================
-- Viewer role: allow bank transaction import and bank connection
-- =============================================================================
--
-- Viewers are read-only by default (enforced by `current_user_can_write()`).
-- This migration adds additive policies that let viewers:
-- 1. Import bank files (INSERT transactions + bank_file_imports)
-- 2. Connect banks via PSD2 (INSERT + UPDATE bank_connections)
--
-- No other write operation is opened — viewers still cannot categorize,
-- book, edit, or delete transactions, create invoices, etc.
--
-- RLS is OR-based: existing policies (which require `current_user_can_write()`)
-- remain unchanged. These new policies provide an alternative path for
-- viewers on just these tables.
-- =============================================================================
-- ─── Transactions ────────────────────────────────────────────────────────────
-- Viewer can INSERT (not UPDATE/DELETE) — raw uncategorized transactions only
CREATE POLICY "transactions_viewer_insert" ON public.transactions
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND 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'
)
);
-- ─── Bank file imports ───────────────────────────────────────────────────────
-- Viewer can INSERT (create import record) + UPDATE (status tracking)
CREATE POLICY "bank_file_imports_viewer_insert" ON public.bank_file_imports
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND 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'
)
);
CREATE POLICY "bank_file_imports_viewer_update" ON public.bank_file_imports
FOR UPDATE USING (
company_id = public.current_active_company_id()
AND 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'
)
);
-- ─── Bank connections ────────────────────────────────────────────────────────
-- Viewer can INSERT (initiate PSD2 connection) + UPDATE (status changes, sync)
-- No DELETE — disconnecting sets status='revoked' via UPDATE, not DELETE
CREATE POLICY "bank_connections_viewer_insert" ON public.bank_connections
FOR INSERT WITH CHECK (
company_id = public.current_active_company_id()
AND 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'
)
);
CREATE POLICY "bank_connections_viewer_update" ON public.bank_connections
FOR UPDATE USING (
company_id = public.current_active_company_id()
AND 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'
)
);
@@ -0,0 +1,979 @@
-- Schema sync: consolidates all changes applied to production after migration 97
-- (20260402120000_inbox_classification). This single migration reproduces the exact
-- production schema delta when applied on top of the first 97 base migrations.
--
-- Changes consolidated from 20 remote migrations (20260407091315 20260414191533).
-- Production database is the source of truth — no schema changes are introduced.
-- ============================================================================
-- 1. NEW TABLES
-- ============================================================================
-- 1a. bankid_identities
CREATE TABLE IF NOT EXISTS public.bankid_identities (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid REFERENCES auth.users ON DELETE CASCADE UNIQUE NOT NULL,
personal_number_hash text NOT NULL,
personal_number_enc bytea NOT NULL,
given_name text,
surname text,
linked_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.bankid_identities ENABLE ROW LEVEL SECURITY;
CREATE UNIQUE INDEX IF NOT EXISTS idx_bankid_identities_pnr_hash
ON public.bankid_identities (personal_number_hash);
CREATE INDEX IF NOT EXISTS idx_bankid_identities_user_id
ON public.bankid_identities (user_id);
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='bankid_identities' AND policyname='bankid_identities_select') THEN
CREATE POLICY bankid_identities_select ON public.bankid_identities FOR SELECT USING (auth.uid() = user_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='bankid_identities' AND policyname='bankid_identities_insert') THEN
CREATE POLICY bankid_identities_insert ON public.bankid_identities FOR INSERT WITH CHECK (auth.uid() = user_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='bankid_identities' AND policyname='bankid_identities_delete') THEN
CREATE POLICY bankid_identities_delete ON public.bankid_identities FOR DELETE USING (auth.uid() = user_id);
END IF;
END $$;
CREATE OR REPLACE TRIGGER bankid_identities_updated_at
BEFORE UPDATE ON public.bankid_identities
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- 1b. automation_webhooks
CREATE TABLE IF NOT EXISTS public.automation_webhooks (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies ON DELETE CASCADE,
event_type text NOT NULL,
webhook_url text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, event_type)
);
ALTER TABLE public.automation_webhooks ENABLE ROW LEVEL SECURITY;
CREATE INDEX IF NOT EXISTS idx_automation_webhooks_company_event
ON public.automation_webhooks (company_id, event_type) WHERE (active = true);
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='automation_webhooks' AND policyname='Members can view company webhooks') THEN
CREATE POLICY "Members can view company webhooks" ON public.automation_webhooks FOR SELECT
USING (company_id IN (SELECT company_id FROM company_members WHERE user_id = auth.uid()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='automation_webhooks' AND policyname='Members can insert company webhooks') THEN
CREATE POLICY "Members can insert company webhooks" ON public.automation_webhooks FOR INSERT
WITH CHECK (company_id IN (SELECT company_id FROM company_members WHERE user_id = auth.uid()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='automation_webhooks' AND policyname='Members can update company webhooks') THEN
CREATE POLICY "Members can update company webhooks" ON public.automation_webhooks FOR UPDATE
USING (company_id IN (SELECT company_id FROM company_members WHERE user_id = auth.uid()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='automation_webhooks' AND policyname='Members can delete company webhooks') THEN
CREATE POLICY "Members can delete company webhooks" ON public.automation_webhooks FOR DELETE
USING (company_id IN (SELECT company_id FROM company_members WHERE user_id = auth.uid()));
END IF;
END $$;
CREATE OR REPLACE TRIGGER set_updated_at
BEFORE UPDATE ON public.automation_webhooks
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- 1c. booking_template_library
CREATE TABLE IF NOT EXISTS public.booking_template_library (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid REFERENCES public.companies ON DELETE CASCADE,
team_id uuid REFERENCES public.teams ON DELETE CASCADE,
created_by uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
category text NOT NULL DEFAULT 'other'
CHECK (category IN ('eu_trade','tax_account','private_transfer','salary','representation','year_end','vat','financial','other')),
entity_type text NOT NULL DEFAULT 'all'
CHECK (entity_type IN ('all','enskild_firma','aktiebolag')),
lines jsonb NOT NULL DEFAULT '[]'::jsonb,
is_system boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (NOT is_system OR (company_id IS NULL AND team_id IS NULL)),
CHECK (team_id IS NULL OR company_id IS NULL),
CHECK (company_id IS NOT NULL OR team_id IS NOT NULL OR is_system)
);
ALTER TABLE public.booking_template_library ENABLE ROW LEVEL SECURITY;
CREATE INDEX IF NOT EXISTS idx_btl_active ON public.booking_template_library (is_active) WHERE (is_active = true);
CREATE INDEX IF NOT EXISTS idx_btl_category ON public.booking_template_library (category);
CREATE INDEX IF NOT EXISTS idx_btl_company ON public.booking_template_library (company_id) WHERE (company_id IS NOT NULL);
CREATE INDEX IF NOT EXISTS idx_btl_system ON public.booking_template_library (is_system) WHERE (is_system = true);
CREATE INDEX IF NOT EXISTS idx_btl_team ON public.booking_template_library (team_id) WHERE (team_id IS NOT NULL);
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='booking_template_library' AND policyname='btl_select') THEN
CREATE POLICY btl_select ON public.booking_template_library FOR SELECT
USING (is_system OR company_id IN (SELECT user_company_ids()) OR team_id IN (SELECT user_team_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='booking_template_library' AND policyname='btl_insert') THEN
CREATE POLICY btl_insert ON public.booking_template_library FOR INSERT
WITH CHECK (NOT is_system AND (company_id IN (SELECT user_company_ids()) OR (company_id IS NULL AND team_id IN (SELECT user_team_ids()))));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='booking_template_library' AND policyname='btl_update') THEN
CREATE POLICY btl_update ON public.booking_template_library FOR UPDATE
USING (NOT is_system AND (company_id IN (SELECT user_company_ids()) OR (company_id IS NULL AND team_id IN (SELECT user_team_ids()))));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='booking_template_library' AND policyname='btl_delete') THEN
CREATE POLICY btl_delete ON public.booking_template_library FOR DELETE
USING (NOT is_system AND (company_id IN (SELECT user_company_ids()) OR (company_id IS NULL AND team_id IN (SELECT user_team_ids()))));
END IF;
END $$;
CREATE OR REPLACE TRIGGER btl_updated_at
BEFORE UPDATE ON public.booking_template_library
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- ============================================================================
-- 2. ALTER EXISTING TABLES — new columns
-- ============================================================================
ALTER TABLE public.companies ADD COLUMN IF NOT EXISTS archived_by uuid;
ALTER TABLE public.invoices ADD COLUMN IF NOT EXISTS delivery_date date;
ALTER TABLE public.journal_entries ADD COLUMN IF NOT EXISTS notes text;
ALTER TABLE public.sie_imports ADD COLUMN IF NOT EXISTS replaced_at timestamptz;
ALTER TABLE public.document_attachments ADD COLUMN IF NOT EXISTS prev_version_hash text;
-- ============================================================================
-- 3. MODIFIED CHECK CONSTRAINTS
-- ============================================================================
-- sie_imports: add 'replaced' status
ALTER TABLE public.sie_imports DROP CONSTRAINT IF EXISTS sie_imports_status_check;
ALTER TABLE public.sie_imports ADD CONSTRAINT sie_imports_status_check
CHECK (status = ANY (ARRAY['pending','mapped','completed','failed','replaced']));
-- audit_log: add integrity/security action types
ALTER TABLE public.audit_log DROP CONSTRAINT IF EXISTS audit_log_action_check;
ALTER TABLE public.audit_log ADD CONSTRAINT audit_log_action_check
CHECK (action = ANY (ARRAY[
'INSERT','UPDATE','DELETE','COMMIT','REVERSE','CORRECT',
'LOCK_PERIOD','CLOSE_PERIOD','DOCUMENT_DELETE_BLOCKED',
'RETENTION_BLOCK','SECURITY_EVENT','INTEGRITY_FAILURE'
]));
-- ============================================================================
-- 4. FUNCTIONS — CREATE OR REPLACE
-- ============================================================================
-- 4a. user_company_ids — filters out archived companies
CREATE OR REPLACE FUNCTION public.user_company_ids()
RETURNS SETOF uuid
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT cm.company_id
FROM public.company_members cm
JOIN public.companies c ON c.id = cm.company_id
WHERE cm.user_id = auth.uid()
AND c.archived_at IS NULL;
$function$;
-- 4b. ensure_user_team — get-or-create personal team
CREATE OR REPLACE FUNCTION public.ensure_user_team()
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_user_id uuid;
v_team_id uuid;
BEGIN
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT team_id INTO v_team_id
FROM public.team_members
WHERE user_id = v_user_id
LIMIT 1;
IF v_team_id IS NOT NULL THEN
RETURN v_team_id;
END IF;
INSERT INTO public.teams (name, created_by)
VALUES ('Personal', v_user_id)
RETURNING id INTO v_team_id;
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (v_team_id, v_user_id, 'owner');
RETURN v_team_id;
END;
$function$;
-- 4c. enforce_journal_entry_immutability — supports gnubok.allow_delete + cancelled
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
IF OLD.status = 'posted' AND NEW.status = 'posted' THEN
IF NEW.description = OLD.description
AND NEW.entry_date = OLD.entry_date
AND NEW.fiscal_period_id = OLD.fiscal_period_id
AND NEW.voucher_number = OLD.voucher_number
AND NEW.voucher_series = OLD.voucher_series
AND NEW.source_type = OLD.source_type
AND COALESCE(NEW.source_id::text, '') = COALESCE(OLD.source_id::text, '')
AND NEW.user_id = OLD.user_id
AND COALESCE(NEW.reversed_by_id::text, '') = COALESCE(OLD.reversed_by_id::text, '')
AND COALESCE(NEW.reverses_id::text, '') = COALESCE(OLD.reverses_id::text, '')
AND COALESCE(NEW.correction_of_id::text, '') = COALESCE(OLD.correction_of_id::text, '')
AND NEW.committed_at IS NOT DISTINCT FROM OLD.committed_at
THEN
RETURN NEW;
END IF;
END IF;
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'reversed' AND NEW.status = 'posted'
AND current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END; $function$;
-- 4d. enforce_journal_entry_line_immutability — supports gnubok.allow_delete + cancelled
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE v_status text;
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
SELECT status INTO v_status FROM public.journal_entries
WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id);
IF v_status = 'draft' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
IF v_status = 'cancelled' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RAISE EXCEPTION 'Cannot % lines of a cancelled journal entry.', TG_OP;
END IF;
RAISE EXCEPTION 'Cannot % lines of a % journal entry.', TG_OP, v_status;
END; $function$;
-- 4e. enforce_retention_journal_entries — supports gnubok.allow_delete
CREATE OR REPLACE FUNCTION public.enforce_retention_journal_entries()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
v_retention_expires date;
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN OLD;
END IF;
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.fiscal_periods fp
WHERE fp.id = OLD.fiscal_period_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
VALUES (OLD.user_id, 'RETENTION_BLOCK', 'journal_entries', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete journal entry within 7-year retention period (expires %)',
v_retention_expires;
END IF;
RETURN OLD;
END; $function$;
-- 4f. enforce_document_metadata_immutability — new trigger function
CREATE OR REPLACE FUNCTION public.enforce_document_metadata_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_entry_status text;
BEGIN
IF OLD.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
SELECT status INTO v_entry_status
FROM public.journal_entries
WHERE id = OLD.journal_entry_id;
IF v_entry_status IS NULL OR v_entry_status NOT IN ('posted', 'reversed') THEN
RETURN NEW;
END IF;
IF NEW.file_name IS DISTINCT FROM OLD.file_name
OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
OR NEW.file_size_bytes IS DISTINCT FROM OLD.file_size_bytes
OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
OR NEW.sha256_hash IS DISTINCT FROM OLD.sha256_hash
OR NEW.upload_source IS DISTINCT FROM OLD.upload_source
OR NEW.digitization_date IS DISTINCT FROM OLD.digitization_date
OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
OR NEW.version IS DISTINCT FROM OLD.version
OR NEW.original_id IS DISTINCT FROM OLD.original_id
OR NEW.is_current_version IS DISTINCT FROM OLD.is_current_version
THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked metadata modification of document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify metadata of document linked to a % journal entry (BFL 7 kap)', v_entry_status;
END IF;
RETURN NEW;
END;
$function$;
-- 4g. create_document_version — document versioning with hash chain
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid,
p_original_doc_id uuid,
p_storage_path text,
p_file_name text,
p_file_size_bytes bigint,
p_mime_type text,
p_sha256_hash text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
BEGIN
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash
)
RETURNING id INTO v_new_id;
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
RETURN v_new_id;
END;
$function$;
-- 4h. validate_version_chain — validates document version hash chain
CREATE OR REPLACE FUNCTION public.validate_version_chain(p_document_id uuid)
RETURNS TABLE(version integer, document_id uuid, hash_valid boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_root_id uuid;
BEGIN
SELECT COALESCE(da.original_id, da.id) INTO v_root_id
FROM public.document_attachments da
WHERE da.id = p_document_id;
IF v_root_id IS NULL THEN
RAISE EXCEPTION 'Document % not found', p_document_id;
END IF;
RETURN QUERY
WITH chain AS (
SELECT
da.id AS doc_id,
da.version AS ver,
da.sha256_hash,
da.prev_version_hash,
LAG(da.sha256_hash) OVER (ORDER BY da.version) AS expected_prev_hash
FROM public.document_attachments da
WHERE da.id = v_root_id OR da.original_id = v_root_id
ORDER BY da.version
)
SELECT
chain.ver,
chain.doc_id,
CASE
WHEN chain.ver = 1 THEN chain.prev_version_hash IS NULL
ELSE chain.prev_version_hash IS NOT DISTINCT FROM chain.expected_prev_hash
END AS hash_valid
FROM chain
ORDER BY chain.ver;
END;
$function$;
-- 4i. write_audit_log — comprehensive audit logging
CREATE OR REPLACE FUNCTION public.write_audit_log()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
v_user_id uuid;
v_company_id uuid;
v_action text;
v_old_state jsonb;
v_new_state jsonb;
v_record_id uuid;
v_desc text;
BEGIN
IF TG_OP = 'DELETE' THEN
v_old_state := to_jsonb(OLD);
v_new_state := NULL;
v_record_id := OLD.id;
v_user_id := (v_old_state->>'user_id')::uuid;
v_company_id := (v_old_state->>'company_id')::uuid;
v_action := 'DELETE';
v_desc := 'Deleted ' || TG_TABLE_NAME || ' record';
ELSIF TG_OP = 'INSERT' THEN
v_old_state := NULL;
v_new_state := to_jsonb(NEW);
v_record_id := NEW.id;
v_user_id := (v_new_state->>'user_id')::uuid;
v_company_id := (v_new_state->>'company_id')::uuid;
v_action := 'INSERT';
v_desc := 'Created ' || TG_TABLE_NAME || ' record';
ELSIF TG_OP = 'UPDATE' THEN
v_old_state := to_jsonb(OLD);
v_new_state := to_jsonb(NEW);
v_record_id := COALESCE(NEW.id, OLD.id);
v_user_id := COALESCE((v_new_state->>'user_id')::uuid, (v_old_state->>'user_id')::uuid);
v_company_id := COALESCE((v_new_state->>'company_id')::uuid, (v_old_state->>'company_id')::uuid);
v_action := 'UPDATE';
v_desc := 'Updated ' || TG_TABLE_NAME || ' record';
IF TG_TABLE_NAME = 'journal_entries' THEN
IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
v_action := 'COMMIT';
v_desc := 'Committed journal entry ' || NEW.voucher_series || NEW.voucher_number;
ELSIF OLD.status = 'posted' AND NEW.status = 'reversed' THEN
v_action := 'REVERSE';
v_desc := 'Reversed journal entry ' || OLD.voucher_series || OLD.voucher_number;
END IF;
END IF;
IF TG_TABLE_NAME = 'fiscal_periods' THEN
IF (OLD.locked_at IS NULL AND NEW.locked_at IS NOT NULL) THEN
v_action := 'LOCK_PERIOD';
v_desc := 'Locked fiscal period "' || NEW.name || '"';
ELSIF (NOT OLD.is_closed AND NEW.is_closed) THEN
v_action := 'CLOSE_PERIOD';
v_desc := 'Closed fiscal period "' || NEW.name || '"';
END IF;
END IF;
END IF;
v_user_id := COALESCE(v_user_id, auth.uid());
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, new_state, description)
VALUES (v_user_id, v_company_id, v_action, TG_TABLE_NAME, v_record_id, v_user_id, v_old_state, v_new_state, v_desc);
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$function$;
-- 4j. delete_last_voucher — deletes only the last voucher in a series
CREATE OR REPLACE FUNCTION public.delete_last_voucher(p_company_id uuid, p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_entry record;
v_period record;
v_max_voucher integer;
v_ref_count integer;
v_caller_role text;
v_snapshot jsonb;
v_lines_snapshot jsonb;
BEGIN
SELECT cm.role INTO v_caller_role
FROM company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid();
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'Only company owners and admins can delete vouchers';
END IF;
SELECT * INTO v_entry
FROM journal_entries
WHERE id = p_entry_id
AND company_id = p_company_id
FOR UPDATE;
IF v_entry IS NULL THEN
RAISE EXCEPTION 'Journal entry not found';
END IF;
IF v_entry.status != 'posted' THEN
RAISE EXCEPTION 'Only posted entries can be deleted (current status: %)', v_entry.status;
END IF;
SELECT * INTO v_period
FROM fiscal_periods
WHERE id = v_entry.fiscal_period_id
FOR UPDATE;
IF v_period.is_closed THEN
RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period';
END IF;
IF v_period.locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period';
END IF;
PERFORM 1 FROM voucher_sequences
WHERE company_id = p_company_id
AND fiscal_period_id = v_entry.fiscal_period_id
AND voucher_series = v_entry.voucher_series
FOR UPDATE;
SELECT MAX(voucher_number) INTO v_max_voucher
FROM journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = v_entry.fiscal_period_id
AND voucher_series = v_entry.voucher_series
AND status NOT IN ('cancelled', 'draft');
IF v_entry.voucher_number != v_max_voucher THEN
RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %',
v_entry.voucher_series, v_entry.voucher_number, v_max_voucher;
END IF;
SELECT COUNT(*) INTO v_ref_count
FROM journal_entries
WHERE company_id = p_company_id
AND status != 'cancelled'
AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id);
IF v_ref_count > 0 THEN
RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)',
v_ref_count;
END IF;
SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot
FROM journal_entry_lines l
WHERE l.journal_entry_id = p_entry_id;
v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb));
IF v_entry.reverses_id IS NOT NULL THEN
PERFORM set_config('gnubok.allow_delete', 'true', true);
UPDATE journal_entries
SET status = 'posted', reversed_by_id = NULL
WHERE id = v_entry.reverses_id
AND company_id = p_company_id;
END IF;
PERFORM set_config('gnubok.allow_delete', 'true', true);
UPDATE document_attachments
SET journal_entry_id = NULL
WHERE journal_entry_id = p_entry_id;
DELETE FROM journal_entries WHERE id = p_entry_id;
UPDATE voucher_sequences
SET last_number = GREATEST(last_number - 1, 0)
WHERE company_id = p_company_id
AND fiscal_period_id = v_entry.fiscal_period_id
AND voucher_series = v_entry.voucher_series;
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
VALUES (
v_entry.user_id,
'DELETE',
'journal_entries',
p_entry_id,
auth.uid(),
v_snapshot,
'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number ||
' (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
);
RETURN jsonb_build_object(
'deleted', true,
'voucher_series', v_entry.voucher_series,
'voucher_number', v_entry.voucher_number
);
END;
$function$;
-- 4k. replace_sie_import — cancels entries from a SIE import
CREATE OR REPLACE FUNCTION public.replace_sie_import(p_company_id uuid, p_import_id uuid)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_cancelled integer;
v_fiscal_period_id uuid;
v_opening_balance_entry_id uuid;
BEGIN
SELECT fiscal_period_id, opening_balance_entry_id
INTO v_fiscal_period_id, v_opening_balance_entry_id
FROM public.sie_imports
WHERE id = p_import_id AND company_id = p_company_id AND status = 'completed';
IF NOT FOUND THEN
RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
END IF;
UPDATE public.journal_entries
SET status = 'cancelled'
WHERE company_id = p_company_id
AND status = 'posted'
AND id IN (
SELECT v_opening_balance_entry_id WHERE v_opening_balance_entry_id IS NOT NULL
UNION ALL
SELECT je.id FROM public.journal_entries je
WHERE je.company_id = p_company_id
AND je.fiscal_period_id = v_fiscal_period_id
AND je.source_type = 'import'
AND je.status = 'posted'
);
GET DIAGNOSTICS v_cancelled = ROW_COUNT;
UPDATE public.sie_imports
SET status = 'replaced', replaced_at = now()
WHERE id = p_import_id AND company_id = p_company_id;
RETURN v_cancelled;
END;
$function$;
-- 4l. get_unlinked_1930_lines — bank reconciliation helper
CREATE OR REPLACE FUNCTION public.get_unlinked_1930_lines(
p_company_id uuid,
p_date_from date DEFAULT NULL,
p_date_to date DEFAULT NULL
)
RETURNS TABLE(
line_id uuid, journal_entry_id uuid, debit_amount numeric,
credit_amount numeric, line_description text, entry_date date,
voucher_number integer, voucher_series text, entry_description text,
source_type text
)
LANGUAGE sql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
jel.id AS line_id,
je.id AS journal_entry_id,
jel.debit_amount,
jel.credit_amount,
jel.line_description,
je.entry_date,
je.voucher_number,
je.voucher_series,
je.description AS entry_description,
je.source_type
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_number = '1930'
AND je.company_id = p_company_id
AND je.status = 'posted'
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
AND NOT EXISTS (
SELECT 1
FROM public.transactions t
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
)
ORDER BY je.entry_date, je.voucher_number;
$function$;
-- 4m. delete_user_account — full account deletion with cascade blockers
CREATE OR REPLACE FUNCTION public.delete_user_account(target_user_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
SET statement_timeout TO '60s'
SET lock_timeout TO '10s'
AS $function$
DECLARE
v_company_ids uuid[];
BEGIN
IF auth.uid() IS DISTINCT FROM target_user_id THEN
RAISE EXCEPTION 'Can only delete your own account';
END IF;
SELECT array_agg(id) INTO v_company_ids
FROM public.companies
WHERE created_by = target_user_id;
DELETE FROM public.user_preferences WHERE user_id = target_user_id;
DELETE FROM public.extension_data WHERE user_id = target_user_id;
ALTER TABLE audit_log DISABLE TRIGGER audit_log_no_delete;
ALTER TABLE payment_match_log DISABLE TRIGGER payment_match_log_no_delete;
ALTER TABLE document_attachments DISABLE TRIGGER block_document_deletion;
ALTER TABLE journal_entries DISABLE TRIGGER enforce_journal_entry_immutability;
ALTER TABLE journal_entries DISABLE TRIGGER enforce_retention_journal_entries;
ALTER TABLE journal_entry_lines DISABLE TRIGGER enforce_journal_entry_line_immutability;
ALTER TABLE api_keys DISABLE TRIGGER audit_api_keys;
ALTER TABLE chart_of_accounts DISABLE TRIGGER audit_chart_of_accounts;
ALTER TABLE company_settings DISABLE TRIGGER audit_company_settings;
ALTER TABLE document_attachments DISABLE TRIGGER audit_document_attachments;
ALTER TABLE extension_data DISABLE TRIGGER audit_extension_data;
ALTER TABLE fiscal_periods DISABLE TRIGGER audit_fiscal_periods;
ALTER TABLE journal_entries DISABLE TRIGGER audit_journal_entries;
ALTER TABLE supplier_invoices DISABLE TRIGGER audit_supplier_invoices;
IF v_company_ids IS NOT NULL THEN
DELETE FROM public.audit_log
WHERE company_id = ANY(v_company_ids);
UPDATE public.fiscal_periods
SET previous_period_id = NULL,
closing_entry_id = NULL,
opening_balance_entry_id = NULL
WHERE company_id = ANY(v_company_ids);
END IF;
DELETE FROM auth.users WHERE id = target_user_id;
ALTER TABLE audit_log ENABLE TRIGGER audit_log_no_delete;
ALTER TABLE payment_match_log ENABLE TRIGGER payment_match_log_no_delete;
ALTER TABLE document_attachments ENABLE TRIGGER block_document_deletion;
ALTER TABLE journal_entries ENABLE TRIGGER enforce_journal_entry_immutability;
ALTER TABLE journal_entries ENABLE TRIGGER enforce_retention_journal_entries;
ALTER TABLE journal_entry_lines ENABLE TRIGGER enforce_journal_entry_line_immutability;
ALTER TABLE api_keys ENABLE TRIGGER audit_api_keys;
ALTER TABLE chart_of_accounts ENABLE TRIGGER audit_chart_of_accounts;
ALTER TABLE company_settings ENABLE TRIGGER audit_company_settings;
ALTER TABLE document_attachments ENABLE TRIGGER audit_document_attachments;
ALTER TABLE extension_data ENABLE TRIGGER audit_extension_data;
ALTER TABLE fiscal_periods ENABLE TRIGGER audit_fiscal_periods;
ALTER TABLE journal_entries ENABLE TRIGGER audit_journal_entries;
ALTER TABLE supplier_invoices ENABLE TRIGGER audit_supplier_invoices;
END;
$function$;
-- ============================================================================
-- 5. TRIGGERS on existing tables
-- ============================================================================
-- Document metadata immutability enforcement
DROP TRIGGER IF EXISTS enforce_document_metadata_immutability ON public.document_attachments;
CREATE TRIGGER enforce_document_metadata_immutability
BEFORE UPDATE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.enforce_document_metadata_immutability();
-- ============================================================================
-- 6. MISSING DELETE POLICIES on existing tables
-- ============================================================================
DO $$
DECLARE
r record;
BEGIN
-- Tables with a direct company_id column
FOR r IN
SELECT unnest(ARRAY[
'api_keys', 'bank_connections', 'bank_file_imports', 'calendar_feeds',
'categorization_templates', 'chart_of_accounts', 'company_settings',
'cost_centers', 'customers', 'deadlines', 'document_attachments',
'extension_data', 'fiscal_periods',
'invoice_inbox_items', 'invoice_payments',
'invoice_reminders', 'invoices', 'journal_entries',
'mapping_rules', 'projects',
'receipts',
'sie_account_mappings', 'sie_imports', 'skatteverket_tokens',
'supplier_invoice_payments',
'supplier_invoices', 'suppliers', 'transactions'
]) AS tbl
LOOP
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public' AND tablename = r.tbl AND cmd = 'DELETE'
) THEN
EXECUTE format(
'CREATE POLICY %I ON public.%I FOR DELETE USING (company_id IN (SELECT user_company_ids()))',
r.tbl || '_delete', r.tbl
);
END IF;
END LOOP;
-- Tables that join through a parent to reach company_id
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='invoice_items' AND cmd='DELETE') THEN
CREATE POLICY invoice_items_delete ON public.invoice_items FOR DELETE
USING (invoice_id IN (SELECT id FROM invoices WHERE company_id IN (SELECT user_company_ids())));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='journal_entry_lines' AND cmd='DELETE') THEN
CREATE POLICY journal_entry_lines_delete ON public.journal_entry_lines FOR DELETE
USING (journal_entry_id IN (SELECT id FROM journal_entries WHERE company_id IN (SELECT user_company_ids())));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='receipt_line_items' AND cmd='DELETE') THEN
CREATE POLICY receipt_line_items_delete ON public.receipt_line_items FOR DELETE
USING (receipt_id IN (SELECT id FROM receipts WHERE company_id IN (SELECT user_company_ids())));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='supplier_invoice_items' AND cmd='DELETE') THEN
CREATE POLICY supplier_invoice_items_delete ON public.supplier_invoice_items FOR DELETE
USING (supplier_invoice_id IN (SELECT id FROM supplier_invoices WHERE company_id IN (SELECT user_company_ids())));
END IF;
-- User-scoped tables (no company_id — use auth.uid())
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='extension_toggles' AND cmd='DELETE') THEN
CREATE POLICY extension_toggles_delete ON public.extension_toggles FOR DELETE
USING (auth.uid() = user_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='notification_settings' AND cmd='DELETE') THEN
CREATE POLICY notification_settings_delete ON public.notification_settings FOR DELETE
USING (auth.uid() = user_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='push_subscriptions' AND cmd='DELETE') THEN
CREATE POLICY push_subscriptions_delete ON public.push_subscriptions FOR DELETE
USING (auth.uid() = user_id);
END IF;
-- Tables that use other policy patterns
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='chat_sessions' AND cmd='DELETE') THEN
CREATE POLICY chat_sessions_delete ON public.chat_sessions FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='chat_messages' AND cmd='DELETE') THEN
CREATE POLICY chat_messages_delete ON public.chat_messages FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='company_members' AND cmd='DELETE') THEN
CREATE POLICY company_members_delete ON public.company_members FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='company_invitations' AND cmd='DELETE') THEN
CREATE POLICY company_invitations_delete ON public.company_invitations FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='team_invitations' AND cmd='DELETE') THEN
CREATE POLICY team_invitations_delete ON public.team_invitations FOR DELETE
USING (team_id IN (SELECT user_team_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='team_members' AND cmd='DELETE') THEN
CREATE POLICY team_members_delete ON public.team_members FOR DELETE
USING (team_id IN (SELECT user_team_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='provider_consents' AND cmd='DELETE') THEN
CREATE POLICY provider_consents_delete ON public.provider_consents FOR DELETE
USING (company_id IN (SELECT user_company_ids()));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='provider_consent_tokens' AND cmd='DELETE') THEN
CREATE POLICY provider_consent_tokens_delete ON public.provider_consent_tokens FOR DELETE
USING (consent_id IN (SELECT id FROM provider_consents WHERE company_id IN (SELECT user_company_ids())));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='provider_otc' AND cmd='DELETE') THEN
CREATE POLICY provider_otc_delete ON public.provider_otc FOR DELETE
USING (consent_id IN (SELECT id FROM provider_consents WHERE company_id IN (SELECT user_company_ids())));
END IF;
END $$;
-- ============================================================================
-- 7. NOTIFY PostgREST to reload schema
-- ============================================================================
NOTIFY pgrst, 'reload schema';