feat: implement viewer role permissions for bank transaction imports and connections (#234)

This commit is contained in:
Mattsson
2026-04-13 19:43:28 +02:00
committed by GitHub
parent 9753f18533
commit cd376e1cad
10 changed files with 305 additions and 42 deletions
+10 -8
View File
@@ -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
+1 -10
View File
@@ -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 ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importerar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Importera {stats.parsed_rows} transaktioner
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
+17 -7
View File
@@ -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,
@@ -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', {
+58 -1
View File
@@ -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')
}
})
})
+52
View File
@@ -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<CompanyRoleResult> {
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 }
}
+58
View File
@@ -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
// -----------------------------------------------------------------------
+23 -13
View File
@@ -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<Currency, ExchangeRate>()
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<Currency, ExchangeRate>()
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 {
@@ -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'
)
);
+3
View File
@@ -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 */