feat: implement viewer role permissions for bank transaction imports and connections (#234)
This commit is contained in:
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user