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 */