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
+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 {