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:
@@ -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 | |
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 @@
|
||||
{"extensions": ["enable-banking", "ai-categorization", "email"]}
|
||||
{"extensions": ["enable-banking", "email", "arcim-migration", "tic", "mcp-server"]}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Generated
+76
-2261
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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,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';
|
||||
Reference in New Issue
Block a user