From cd376e1cad805184ed91b9bbeca258a1e21d138a Mon Sep 17 00:00:00 2001
From: Mattsson <111893710+mattssonn@users.noreply.github.com>
Date: Mon, 13 Apr 2026 19:43:28 +0200
Subject: [PATCH] feat: implement viewer role permissions for bank transaction
imports and connections (#234)
---
app/api/import/bank-file/execute/route.ts | 18 +++--
components/import/BankFileConfirmStep.tsx | 11 +--
extensions/general/enable-banking/index.ts | 24 ++++--
extensions/general/enable-banking/lib/sync.ts | 8 +-
lib/auth/__tests__/require-write.test.ts | 59 +++++++++++++-
lib/auth/require-write.ts | 52 +++++++++++++
lib/transactions/__tests__/ingest.test.ts | 58 ++++++++++++++
lib/transactions/ingest.ts | 36 +++++----
...3150000_viewer_bank_import_permissions.sql | 78 +++++++++++++++++++
types/index.ts | 3 +
10 files changed, 305 insertions(+), 42 deletions(-)
create mode 100644 supabase/migrations/20260413150000_viewer_bank_import_permissions.sql
diff --git a/app/api/import/bank-file/execute/route.ts b/app/api/import/bank-file/execute/route.ts
index 9bdf49f5..4df7cfdc 100644
--- a/app/api/import/bank-file/execute/route.ts
+++ b/app/api/import/bank-file/execute/route.ts
@@ -4,8 +4,8 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { generateExternalId } from '@/lib/import/bank-file/parser'
-import { requireCompanyId } from '@/lib/company/context'
-import { requireWritePermission } from '@/lib/auth/require-write'
+import type { IngestOptions } from '@/types'
+import { getCompanyRole } from '@/lib/auth/require-write'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
@@ -36,10 +36,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const writeCheck = await requireWritePermission(supabase, user.id)
- if (!writeCheck.ok) return writeCheck.response
-
- const companyId = await requireCompanyId(supabase, user.id)
+ const roleCheck = await getCompanyRole(supabase, user.id)
+ if (!roleCheck.ok) return roleCheck.response
+ const { role, companyId } = roleCheck
const body: ExecuteRequest = await request.json()
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true, settlement_account } = body
@@ -84,8 +83,11 @@ export async function POST(request: Request) {
import_source: format === 'camt053' ? 'camt053' : `csv_${format}`,
}))
- // Run ingestion pipeline
- const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, settlement_account ? { settlementAccount: settlement_account } : undefined)
+ // Run ingestion pipeline — viewers get rawInsertOnly (no categorization, no matching)
+ const ingestOptions: IngestOptions = {}
+ if (settlement_account) ingestOptions.settlementAccount = settlement_account
+ if (role === 'viewer') ingestOptions.rawInsertOnly = true
+ const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, ingestOptions)
// Update import record with results
await supabase
diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx
index 2baab10b..e8466e76 100644
--- a/components/import/BankFileConfirmStep.tsx
+++ b/components/import/BankFileConfirmStep.tsx
@@ -13,11 +13,9 @@ import {
FileText,
Link2,
Calendar,
- Lock,
Landmark,
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
-import { useCanWrite } from '@/lib/hooks/use-can-write'
import { createClient } from '@/lib/supabase/client'
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
@@ -39,7 +37,6 @@ export default function BankFileConfirmStep({
onBack,
isLoading,
}: BankFileConfirmStepProps) {
- const { canWrite } = useCanWrite()
const { transactions, stats, date_from, date_to } = parseResult
const refsCount = transactions.filter((t) => t.reference).length
@@ -188,19 +185,13 @@ export default function BankFileConfirmStep({
auto_categorize: false,
settlement_account: selectedAccount !== '1930' ? selectedAccount : undefined,
})}
- disabled={isLoading || !canWrite}
- title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
+ disabled={isLoading}
>
{isLoading ? (
<>
Importerar...
>
- ) : !canWrite ? (
- <>
-
- Importera {stats.parsed_rows} transaktioner
- >
) : (
<>
diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts
index 30c1ed27..aea7eba4 100644
--- a/extensions/general/enable-banking/index.ts
+++ b/extensions/general/enable-banking/index.ts
@@ -234,6 +234,7 @@ export const enableBankingExtension: Extension = {
// Use ctx.services.ingestTransactions when available
const ingestFn = ctx?.services.ingestTransactions
+ const companyId = ctx?.companyId ?? user.id
// Detect SIE overlap — skip auto-categorization if the sync range
// overlaps with a completed SIE import to prevent double-booking.
@@ -241,15 +242,25 @@ export const enableBankingExtension: Extension = {
const { data: sieOverlap } = await supabase
.from('sie_imports')
.select('id')
- .eq('company_id', ctx?.companyId ?? user.id)
+ .eq('company_id', companyId)
.eq('status', 'completed')
.gte('fiscal_year_end', fromDate)
.limit(1)
.maybeSingle()
- const syncOptions = sieOverlap
- ? { skipAutoCategorization: true }
- : undefined
+ // Check if user is a viewer — viewers get rawInsertOnly (no categorization)
+ const { data: membership } = await supabase
+ .from('company_members')
+ .select('role')
+ .eq('company_id', companyId)
+ .eq('user_id', user.id)
+ .maybeSingle()
+ const isViewer = membership?.role === 'viewer'
+
+ const syncOptions = {
+ ...(sieOverlap ? { skipAutoCategorization: true } : {}),
+ ...(isViewer ? { rawInsertOnly: true } : {}),
+ }
if (sieOverlap) {
log.info('SIE import overlap detected — suppressing auto-categorization', {
@@ -258,8 +269,6 @@ export const enableBankingExtension: Extension = {
toDate,
})
}
-
- const companyId = ctx?.companyId ?? user.id
const results = await Promise.all(
accounts.map(account => syncAccountTransactions(
supabase,
@@ -281,7 +290,8 @@ export const enableBankingExtension: Extension = {
// The greedy algorithm considers all candidates globally (highest-
// confidence first) and catches matches the inline per-transaction
// pass may have missed due to processing order.
- if (sieOverlap && totalImported > 0) {
+ // Skip for viewers — reconciliation updates transactions which viewers cannot do.
+ if (sieOverlap && totalImported > 0 && !isViewer) {
try {
const reconResult = await runReconciliation(supabase, ctx?.companyId ?? user.id, {
dateFrom: fromDate,
diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts
index e970fdce..1f91e51d 100644
--- a/extensions/general/enable-banking/lib/sync.ts
+++ b/extensions/general/enable-banking/lib/sync.ts
@@ -17,6 +17,8 @@ export type IngestFn = (
export interface SyncOptions {
/** Skip auto-categorization during ingestion (e.g. SIE overlap) */
skipAutoCategorization?: boolean
+ /** Only INSERT + dedup, no matching/categorization (viewer imports) */
+ rawInsertOnly?: boolean
}
export interface SyncResult {
@@ -84,9 +86,9 @@ export async function syncAccountTransactions(
import_source: 'enable_banking',
}))
- const ingestOptions: IngestOptions | undefined = syncOptions?.skipAutoCategorization
- ? { skipAutoCategorization: true }
- : undefined
+ const ingestOptions: IngestOptions = {}
+ if (syncOptions?.skipAutoCategorization) ingestOptions.skipAutoCategorization = true
+ if (syncOptions?.rawInsertOnly) ingestOptions.rawInsertOnly = true
const ingestResult = await ingest(supabase, companyId, userId, rawTransactions, ingestOptions)
console.log('[enable-banking] Ingest result', {
diff --git a/lib/auth/__tests__/require-write.test.ts b/lib/auth/__tests__/require-write.test.ts
index 4e1aea12..3adfc3e7 100644
--- a/lib/auth/__tests__/require-write.test.ts
+++ b/lib/auth/__tests__/require-write.test.ts
@@ -5,7 +5,7 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn(),
}))
-import { requireWritePermission } from '../require-write'
+import { requireWritePermission, getCompanyRole } from '../require-write'
import { getActiveCompanyId } from '@/lib/company/context'
describe('requireWritePermission', () => {
@@ -79,3 +79,60 @@ describe('requireWritePermission', () => {
}
})
})
+
+describe('getCompanyRole', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns role and companyId for owner', async () => {
+ const { supabase, mockResult } = createMockSupabase()
+ vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
+ mockResult({ data: { role: 'owner' } })
+
+ const result = await getCompanyRole(supabase, 'user-1')
+ expect(result.ok).toBe(true)
+ if (result.ok) {
+ expect(result.role).toBe('owner')
+ expect(result.companyId).toBe('company-1')
+ }
+ })
+
+ it('returns role for viewer (does not block)', async () => {
+ const { supabase, mockResult } = createMockSupabase()
+ vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
+ mockResult({ data: { role: 'viewer' } })
+
+ const result = await getCompanyRole(supabase, 'user-1')
+ expect(result.ok).toBe(true)
+ if (result.ok) {
+ expect(result.role).toBe('viewer')
+ expect(result.companyId).toBe('company-1')
+ }
+ })
+
+ it('returns 403 when user has no membership', async () => {
+ const { supabase, mockResult } = createMockSupabase()
+ vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
+ mockResult({ data: null })
+
+ const result = await getCompanyRole(supabase, 'user-1')
+ expect(result.ok).toBe(false)
+ if (!result.ok) {
+ expect(result.response.status).toBe(403)
+ }
+ })
+
+ it('returns 403 when there is no active company', async () => {
+ const { supabase } = createMockSupabase()
+ vi.mocked(getActiveCompanyId).mockResolvedValue(null)
+
+ const result = await getCompanyRole(supabase, 'user-1')
+ expect(result.ok).toBe(false)
+ if (!result.ok) {
+ expect(result.response.status).toBe(403)
+ const body = await result.response.json()
+ expect(body.error).toContain('aktivt företag')
+ }
+ })
+})
diff --git a/lib/auth/require-write.ts b/lib/auth/require-write.ts
index 6d305b12..b352da32 100644
--- a/lib/auth/require-write.ts
+++ b/lib/auth/require-write.ts
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getActiveCompanyId } from '@/lib/company/context'
+import type { CompanyRole } from '@/types'
/**
* Write-permission guard for API routes.
@@ -61,3 +62,54 @@ export async function requireWritePermission(
return { ok: true }
}
+
+/**
+ * Resolves the caller's role in the active company without blocking viewers.
+ *
+ * Unlike `requireWritePermission()` (which returns 403 for viewers), this
+ * returns the actual role so the caller can make conditional decisions —
+ * e.g. allowing viewers to import raw bank transactions but nothing else.
+ *
+ * Use `requireWritePermission()` for routes that are fully off-limits to
+ * viewers. Use `getCompanyRole()` only when the route needs viewer-
+ * conditional behavior.
+ */
+export type CompanyRoleResult =
+ | { ok: true; role: CompanyRole; companyId: string }
+ | { ok: false; response: NextResponse }
+
+export async function getCompanyRole(
+ supabase: SupabaseClient,
+ userId: string,
+): Promise {
+ const companyId = await getActiveCompanyId(supabase, userId)
+
+ if (!companyId) {
+ return {
+ ok: false,
+ response: NextResponse.json(
+ { error: 'Inget aktivt företag.' },
+ { status: 403 },
+ ),
+ }
+ }
+
+ const { data: membership } = await supabase
+ .from('company_members')
+ .select('role')
+ .eq('company_id', companyId)
+ .eq('user_id', userId)
+ .maybeSingle()
+
+ if (!membership) {
+ return {
+ ok: false,
+ response: NextResponse.json(
+ { error: 'Du har ingen roll i detta företag.' },
+ { status: 403 },
+ ),
+ }
+ }
+
+ return { ok: true, role: membership.role as CompanyRole, companyId }
+}
diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts
index e1f532ce..2c893e61 100644
--- a/lib/transactions/__tests__/ingest.test.ts
+++ b/lib/transactions/__tests__/ingest.test.ts
@@ -701,6 +701,64 @@ describe('ingestTransactions', () => {
expect(result.errors).toBe(0)
})
+ // -----------------------------------------------------------------------
+ // rawInsertOnly: skips reconciliation, matching, and auto-categorization
+ // -----------------------------------------------------------------------
+ it('skips reconciliation, matching, and categorization when rawInsertOnly is set', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+ const raw = makeRaw({ amount: 5000, description: 'Payment received' })
+ const inserted = makeTransaction({
+ id: 'tx-raw',
+ amount: 5000,
+ external_id: raw.external_id,
+ })
+
+ // Booked transaction map query
+ enqueue({ data: [], error: null })
+ // Unbooked bank-synced transaction map query
+ enqueue({ data: [], error: null })
+ // No supplier invoices fetch (skipped by rawInsertOnly)
+ // Batch external_id dedup query (no matches)
+ enqueue({ data: [], error: null })
+ // Insert returns the new transaction
+ enqueue({ data: inserted, error: null })
+
+ const result = await ingestTransactions(
+ supabase as never, COMPANY_ID, USER_ID, [raw],
+ { rawInsertOnly: true }
+ )
+
+ expect(result.imported).toBe(1)
+ expect(result.reconciled).toBe(0)
+ expect(result.auto_categorized).toBe(0)
+ expect(result.auto_matched_invoices).toBe(0)
+ // Should NOT have attempted any post-insert operations
+ expect(mockFetchUnlinkedGLLines).not.toHaveBeenCalled()
+ expect(mockTryReconcileTransaction).not.toHaveBeenCalled()
+ expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
+ expect(mockEvaluateMappingRules).not.toHaveBeenCalled()
+ })
+
+ it('still deduplicates when rawInsertOnly is set', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+ const raw = makeRaw({ external_id: 'ext-dup-raw' })
+
+ // Booked transaction map query
+ enqueue({ data: [], error: null })
+ // Unbooked bank-synced transaction map query
+ enqueue({ data: [], error: null })
+ // Batch external_id dedup query — already exists
+ enqueue({ data: [{ external_id: 'ext-dup-raw' }], error: null })
+
+ const result = await ingestTransactions(
+ supabase as never, COMPANY_ID, USER_ID, [raw],
+ { rawInsertOnly: true }
+ )
+
+ expect(result.duplicates).toBe(1)
+ expect(result.imported).toBe(0)
+ })
+
// -----------------------------------------------------------------------
// Content-based dedup: cross-source duplicate detection
// -----------------------------------------------------------------------
diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts
index 4276ddfc..e2ea92e9 100644
--- a/lib/transactions/ingest.ts
+++ b/lib/transactions/ingest.ts
@@ -119,8 +119,14 @@ export async function ingestTransactions(
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
- // Pre-fetch unlinked GL lines for reconciliation (non-critical)
+ // When rawInsertOnly is set (viewer imports), skip pre-fetching GL lines,
+ // supplier invoices, and exchange rates — they are not used.
let glLinePool: UnlinkedGLLine[] = []
+ let unpaidSupplierInvoices: SupplierInvoice[] = []
+ let exchangeRates = new Map()
+
+ if (!options?.rawInsertOnly) {
+ // Pre-fetch unlinked GL lines for reconciliation (non-critical)
try {
glLinePool = await fetchUnlinkedGLLines(supabase, companyId, undefined, undefined, options?.settlementAccount)
} catch {
@@ -128,7 +134,6 @@ export async function ingestTransactions(
}
// Pre-fetch unpaid supplier invoices for expense matching (non-critical)
- let unpaidSupplierInvoices: SupplierInvoice[] = []
try {
const { data } = await supabase
.from('supplier_invoices')
@@ -141,20 +146,22 @@ export async function ingestTransactions(
} catch {
// Non-critical — supplier invoice matching will be skipped
}
+ }
// Pre-fetch exchange rates for non-SEK currencies (non-critical)
- let exchangeRates = new Map()
- try {
- const uniqueCurrencies = [...new Set(
- rawTransactions
- .map(t => t.currency)
- .filter((c): c is Currency => c != null && c !== 'SEK')
- )]
- if (uniqueCurrencies.length > 0) {
- exchangeRates = await fetchMultipleRates(uniqueCurrencies)
+ if (!options?.rawInsertOnly) {
+ try {
+ const uniqueCurrencies = [...new Set(
+ rawTransactions
+ .map(t => t.currency)
+ .filter((c): c is Currency => c != null && c !== 'SEK')
+ )]
+ if (uniqueCurrencies.length > 0) {
+ exchangeRates = await fetchMultipleRates(uniqueCurrencies)
+ }
+ } catch {
+ // Non-critical — amount_sek fields will stay null
}
- } catch {
- // Non-critical — amount_sek fields will stay null
}
// Pre-fetch existing external_ids in batches for dedup (avoids N+1 queries)
@@ -244,6 +251,9 @@ export async function ingestTransactions(
result.imported++
result.transaction_ids.push(newTransaction.id)
+ // rawInsertOnly: skip reconciliation, invoice matching, and auto-categorization
+ if (options?.rawInsertOnly) continue
+
// 2.5. Try reconciliation against pre-fetched unlinked GL lines
if (glLinePool.length > 0) {
try {
diff --git a/supabase/migrations/20260413150000_viewer_bank_import_permissions.sql b/supabase/migrations/20260413150000_viewer_bank_import_permissions.sql
new file mode 100644
index 00000000..2fc699e6
--- /dev/null
+++ b/supabase/migrations/20260413150000_viewer_bank_import_permissions.sql
@@ -0,0 +1,78 @@
+-- =============================================================================
+-- 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'
+ )
+ );
diff --git a/types/index.ts b/types/index.ts
index 05dfaf4f..806c1ed7 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -2197,6 +2197,9 @@ export interface IngestOptions {
/** Override the default settlement account (1930) for bank transactions.
* Used when importing to a secondary bank account (e.g., 1931). */
settlementAccount?: string
+ /** Only INSERT transactions + dedup. Skip reconciliation, invoice matching,
+ * supplier matching, and auto-categorization. For viewer imports. */
+ rawInsertOnly?: boolean
}
/** Result of the transaction ingestion pipeline */