Fix/attributes config (#926)

* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-08 09:54:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 63e05c4eec
commit abe9ac9d8c
204 changed files with 9841 additions and 6362 deletions
@@ -0,0 +1,131 @@
/**
* Tests for POST /api/reconciliation/bank/link.
*
* Exercises the route through the real withRouteContext wrapper, mocking only
* its auth/company/write dependencies plus the manualLink service. Covers:
* 401, 403 viewer, validation (400), service failure (400), and happy path.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const manualLinkMock = vi.fn()
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
manualLink: (...args: unknown[]) => manualLinkMock(...args),
}))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const TX_ID = '11111111-1111-4111-8111-111111111111'
const JE_ID = '22222222-2222-4222-8222-222222222222'
describe('POST /api/reconciliation/bank/link', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
manualLinkMock.mockResolvedValue({ success: true })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: { transaction_id: TX_ID, journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(401)
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: { transaction_id: TX_ID, journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(403)
})
it('rejects a non-uuid transaction_id with 400', async () => {
const request = createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: { transaction_id: 'not-a-uuid', journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
expect(manualLinkMock).not.toHaveBeenCalled()
})
it('surfaces a manualLink failure as 400 with the service error', async () => {
manualLinkMock.mockResolvedValue({
success: false,
error: 'Transaktionen är redan kopplad till en verifikation.',
})
const request = createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: { transaction_id: TX_ID, journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Transaktionen är redan kopplad till en verifikation.')
})
it('links the transaction, defaulting the account to 1930', async () => {
const request = createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: { transaction_id: TX_ID, journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(manualLinkMock).toHaveBeenCalledWith(
supabase,
'company-1',
TX_ID,
JE_ID,
'user-1',
'1930',
)
})
})
+22 -32
View File
@@ -1,43 +1,33 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankLinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext(
'reconciliation.bank.link',
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, BankLinkSchema)
if (!validation.success) return validation.response
const { transaction_id, journal_entry_id, account_number } = validation.data
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await manualLink(
supabase,
companyId,
transaction_id,
journal_entry_id,
user.id,
account_number ?? '1930',
)
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankLinkSchema)
if (!validation.success) return validation.response
const { transaction_id, journal_entry_id, account_number } = validation.data
const result = await manualLink(
supabase,
companyId,
transaction_id,
journal_entry_id,
user.id,
account_number ?? '1930',
)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ data: { success: true } })
}
return NextResponse.json({ data: { success: true } })
},
{ requireWrite: true },
)
@@ -0,0 +1,119 @@
/**
* Tests for POST /api/reconciliation/bank/mark-opening-balance.
*
* Exercises the route through the real withRouteContext wrapper, mocking only
* its auth/company/write dependencies and injecting a queued Supabase mock via
* requireAuth. Covers: 401, 403 viewer, validation (400), the RPC error
* translation to Swedish, and the happy path.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const JE_ID = '33333333-3333-4333-8333-333333333333'
describe('POST /api/reconciliation/bank/mark-opening-balance', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', {
method: 'POST',
body: { journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(401)
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', {
method: 'POST',
body: { journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(403)
})
it('rejects a non-uuid journal_entry_id with 400', async () => {
const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', {
method: 'POST',
body: { journal_entry_id: 'nope' },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
})
it('translates a locked-period RPC error to Swedish', async () => {
enqueue({ error: { message: 'Cannot modify entries in a locked fiscal period' } })
const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', {
method: 'POST',
body: { journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Perioden är låst. Lås upp perioden innan du ändrar verifikationen.')
})
it('marks the entry as opening balance via the RPC', async () => {
enqueue({ data: { updated: true } })
const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', {
method: 'POST',
body: { journal_entry_id: JE_ID },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ data: { updated: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.updated).toBe(true)
expect(supabase.rpc).toHaveBeenCalledWith('mark_entry_as_opening_balance', {
p_company_id: 'company-1',
p_entry_id: JE_ID,
})
})
})
@@ -1,10 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { MarkOpeningBalanceSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -15,48 +13,40 @@ ensureInitialized()
* enforces owner/admin role, the manual/import precondition, a bank-line check,
* and the period lock. We only translate its errors to Swedish here.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext(
'reconciliation.bank.mark_opening_balance',
async (request, { supabase, companyId }) => {
const validation = await validateBody(request, MarkOpeningBalanceSchema)
if (!validation.success) return validation.response
const { journal_entry_id } = validation.data
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase.rpc('mark_entry_as_opening_balance', {
p_company_id: companyId,
p_entry_id: journal_entry_id,
})
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, MarkOpeningBalanceSchema)
if (!validation.success) return validation.response
const { journal_entry_id } = validation.data
const { data, error } = await supabase.rpc('mark_entry_as_opening_balance', {
p_company_id: companyId,
p_entry_id: journal_entry_id,
})
if (error) {
const raw = error.message || ''
let message = 'Kunde inte markera verifikationen som ingående balans.'
if (/owners and admins/i.test(raw)) {
message = 'Endast ägare och administratörer kan markera en ingående balans.'
} else if (/not found/i.test(raw)) {
message = 'Verifikationen kunde inte hittas.'
} else if (/manual\/import/i.test(raw)) {
message = 'Bara manuellt eller importerat bokförda verifikationer kan markeras som ingående balans.'
} else if (/posted entries/i.test(raw)) {
message = 'Bara bokförda verifikationer kan markeras som ingående balans.'
} else if (/bank\/cash account/i.test(raw)) {
message = 'Verifikationen saknar rad på ett bankkonto (19xx) och kan inte vara en ingående balans.'
} else if (/closed fiscal period/i.test(raw)) {
message = 'Perioden är stängd. Öppna perioden innan du ändrar verifikationen.'
} else if (/locked fiscal period/i.test(raw)) {
message = 'Perioden är låst. Lås upp perioden innan du ändrar verifikationen.'
if (error) {
const raw = error.message || ''
let message = 'Kunde inte markera verifikationen som ingående balans.'
if (/owners and admins/i.test(raw)) {
message = 'Endast ägare och administratörer kan markera en ingående balans.'
} else if (/not found/i.test(raw)) {
message = 'Verifikationen kunde inte hittas.'
} else if (/manual\/import/i.test(raw)) {
message = 'Bara manuellt eller importerat bokförda verifikationer kan markeras som ingående balans.'
} else if (/posted entries/i.test(raw)) {
message = 'Bara bokförda verifikationer kan markeras som ingående balans.'
} else if (/bank\/cash account/i.test(raw)) {
message = 'Verifikationen saknar rad på ett bankkonto (19xx) och kan inte vara en ingående balans.'
} else if (/closed fiscal period/i.test(raw)) {
message = 'Perioden är stängd. Öppna perioden innan du ändrar verifikationen.'
} else if (/locked fiscal period/i.test(raw)) {
message = 'Perioden är låst. Lås upp perioden innan du ändrar verifikationen.'
}
return NextResponse.json({ error: message }, { status: 400 })
}
return NextResponse.json({ error: message }, { status: 400 })
}
return NextResponse.json({ data })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
@@ -0,0 +1,140 @@
/**
* Tests for POST /api/reconciliation/bank/run.
*
* Exercises the route through the real withRouteContext wrapper, mocking only
* its auth/company/write dependencies plus the runReconciliation service.
* Covers: 401, 403 viewer, unknown cash account (400), and the happy path.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const runReconciliationMock = vi.fn()
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
runReconciliation: (...args: unknown[]) => runReconciliationMock(...args),
}))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
describe('POST /api/reconciliation/bank/run', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
runReconciliationMock.mockResolvedValue({ matches: [], applied: 0, errors: [] })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { dry_run: true },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(401)
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { dry_run: true },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(403)
})
it('rejects a non-default account with no cash_accounts row', async () => {
// cash_accounts lookup finds nothing for 1932.
enqueue({ data: null })
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { account_number: '1932', dry_run: true },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Okänt kassakonto för det här företaget')
expect(runReconciliationMock).not.toHaveBeenCalled()
})
it('runs reconciliation on the default 1930 account even without a cash_accounts row', async () => {
// cash_accounts lookup: no row, but '1930' is exempt.
enqueue({ data: null })
runReconciliationMock.mockResolvedValue({
matches: [
{
transaction: { id: 't-1', date: '2024-06-15', description: 'Betalning', amount: 1250 },
glLine: {
journal_entry_id: 'je-1',
voucher_number: 12,
voucher_series: 'A',
entry_date: '2024-06-15',
entry_description: 'Kundfaktura',
},
method: 'exact',
confidence: 1,
},
],
applied: 1,
errors: [],
})
const request = createMockRequest('/api/reconciliation/bank/run', {
method: 'POST',
body: { date_from: '2024-06-01', date_to: '2024-06-30' },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{
data: { matches: { transaction_id: string }[]; applied: number; dry_run: boolean }
}>(response)
expect(status).toBe(200)
expect(body.data.applied).toBe(1)
expect(body.data.dry_run).toBe(false)
expect(body.data.matches[0].transaction_id).toBe('t-1')
expect(runReconciliationMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
expect.objectContaining({ accountNumber: '1930', currency: 'SEK', dryRun: false }),
)
})
})
+65 -75
View File
@@ -1,88 +1,78 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { RunReconciliationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext(
'reconciliation.bank.run',
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accountNumber = account_number ?? '1930'
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
// Defense-in-depth: reject a non-default account the company hasn't
// registered as a cash account. The default '1930' is exempt: when no
// cash_accounts row exists it falls back to currency-only scoping
// (cashAccountId undefined), so a company reconciling its primary SEK account
// without a row behaves exactly as before this feature. Matches the status
// endpoint, which is likewise lenient for '1930'.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
const companyId = await requireCompanyId(supabase, user.id)
if (!cashAccount && accountNumber !== '1930') {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data
const accountNumber = account_number ?? '1930'
// Defense-in-depth: reject a non-default account the company hasn't
// registered as a cash account. The default '1930' is exempt: when no
// cash_accounts row exists it falls back to currency-only scoping
// (cashAccountId undefined), so a company reconciling its primary SEK account
// without a row behaves exactly as before this feature. Matches the status
// endpoint, which is likewise lenient for '1930'.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount && accountNumber !== '1930') {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const result = await runReconciliation(supabase, companyId, user.id, {
dateFrom: date_from,
dateTo: date_to,
accountNumber,
currency,
cashAccountId: cashAccount?.id as string | undefined,
// Only the primary account claims unassigned (NULL cash_account_id) rows:
// a secondary same-currency account must scope strictly to its own id.
includeUnassigned: Boolean(cashAccount?.is_primary),
dryRun: dry_run ?? false,
applyOnly: selected_matches?.map((m) => ({
transactionId: m.transaction_id,
journalEntryId: m.journal_entry_id,
})),
})
return NextResponse.json({
data: {
matches: result.matches.map((m) => ({
transaction_id: m.transaction.id,
transaction_date: m.transaction.date,
transaction_description: m.transaction.description,
transaction_amount: m.transaction.amount,
journal_entry_id: m.glLine.journal_entry_id,
voucher_number: m.glLine.voucher_number,
voucher_series: m.glLine.voucher_series,
entry_date: m.glLine.entry_date,
entry_description: m.glLine.entry_description,
method: m.method,
confidence: m.confidence,
const result = await runReconciliation(supabase, companyId, user.id, {
dateFrom: date_from,
dateTo: date_to,
accountNumber,
currency,
cashAccountId: cashAccount?.id as string | undefined,
// Only the primary account claims unassigned (NULL cash_account_id) rows:
// a secondary same-currency account must scope strictly to its own id.
includeUnassigned: Boolean(cashAccount?.is_primary),
dryRun: dry_run ?? false,
applyOnly: selected_matches?.map((m) => ({
transactionId: m.transaction_id,
journalEntryId: m.journal_entry_id,
})),
applied: result.applied,
errors: result.errors,
dry_run: dry_run ?? false,
},
})
}
})
return NextResponse.json({
data: {
matches: result.matches.map((m) => ({
transaction_id: m.transaction.id,
transaction_date: m.transaction.date,
transaction_description: m.transaction.description,
transaction_amount: m.transaction.amount,
journal_entry_id: m.glLine.journal_entry_id,
voucher_number: m.glLine.voucher_number,
voucher_series: m.glLine.voucher_series,
entry_date: m.glLine.entry_date,
entry_description: m.glLine.entry_description,
method: m.method,
confidence: m.confidence,
})),
applied: result.applied,
errors: result.errors,
dry_run: dry_run ?? false,
},
})
},
{ requireWrite: true },
)
+41 -48
View File
@@ -1,57 +1,50 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const GET = withRouteContext(
'reconciliation.bank.status',
async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const accountNumber = searchParams.get('account_number') || '1930'
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Look up the cash account so we can pair the bank account with the right
// currency. Comparing EUR GL movements against SEK transactions silently
// produces nonsense.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
const companyId = await requireCompanyId(supabase, user.id)
if (!cashAccount && accountNumber !== '1930') {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const accountNumber = searchParams.get('account_number') || '1930'
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const cashAccountId = cashAccount?.id as string | undefined
// Only the primary account claims unassigned (NULL cash_account_id) rows.
// A secondary same-currency account (e.g. a 1931 savings account) must not, or
// 1930's unassigned rows inflate its bank total and show a bogus difference.
const includeUnassigned = Boolean(cashAccount?.is_primary)
// Look up the cash account so we can pair the bank account with the right
// currency. Comparing EUR GL movements against SEK transactions silently
// produces nonsense.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount && accountNumber !== '1930') {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
const status = await getReconciliationStatus(
supabase,
companyId,
dateFrom,
dateTo,
accountNumber,
currency,
cashAccountId,
includeUnassigned,
)
}
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const cashAccountId = cashAccount?.id as string | undefined
// Only the primary account claims unassigned (NULL cash_account_id) rows.
// A secondary same-currency account (e.g. a 1931 savings account) must not, or
// 1930's unassigned rows inflate its bank total and show a bogus difference.
const includeUnassigned = Boolean(cashAccount?.is_primary)
const status = await getReconciliationStatus(
supabase,
companyId,
dateFrom,
dateTo,
accountNumber,
currency,
cashAccountId,
includeUnassigned,
)
return NextResponse.json({ data: status })
}
return NextResponse.json({ data: status })
},
)
@@ -0,0 +1,122 @@
/**
* Tests for POST /api/reconciliation/bank/unlink.
*
* Exercises the route through the real withRouteContext wrapper, mocking only
* its auth/company/write dependencies plus the unlinkReconciliation service.
* Covers: 401, 403 viewer, validation (400), service failure (400), and the
* happy path.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
const unlinkMock = vi.fn()
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
unlinkReconciliation: (...args: unknown[]) => unlinkMock(...args),
}))
import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const TX_ID = '44444444-4444-4444-8444-444444444444'
describe('POST /api/reconciliation/bank/unlink', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
unlinkMock.mockResolvedValue({ success: true })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/reconciliation/bank/unlink', {
method: 'POST',
body: { transaction_id: TX_ID },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(401)
})
it('returns 403 for a viewer', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/reconciliation/bank/unlink', {
method: 'POST',
body: { transaction_id: TX_ID },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(403)
})
it('rejects a non-uuid transaction_id with 400', async () => {
const request = createMockRequest('/api/reconciliation/bank/unlink', {
method: 'POST',
body: { transaction_id: 'not-a-uuid' },
})
const response = await POST(request, emptyParams)
expect(response.status).toBe(400)
expect(unlinkMock).not.toHaveBeenCalled()
})
it('surfaces an unlink failure as 400 with the service error', async () => {
unlinkMock.mockResolvedValue({
success: false,
error: 'Cannot unlink a categorization-created entry. Use storno to reverse it instead.',
})
const request = createMockRequest('/api/reconciliation/bank/unlink', {
method: 'POST',
body: { transaction_id: TX_ID },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('storno')
})
it('unlinks the transaction', async () => {
const request = createMockRequest('/api/reconciliation/bank/unlink', {
method: 'POST',
body: { transaction_id: TX_ID },
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(unlinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_ID, 'user-1')
})
})
+15 -25
View File
@@ -1,33 +1,23 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankUnlinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const POST = withRouteContext(
'reconciliation.bank.unlink',
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, BankUnlinkSchema)
if (!validation.success) return validation.response
const { transaction_id } = validation.data
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id)
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankUnlinkSchema)
if (!validation.success) return validation.response
const { transaction_id } = validation.data
const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ data: { success: true } })
}
return NextResponse.json({ data: { success: true } })
},
{ requireWrite: true },
)
@@ -1,93 +1,86 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
export const GET = withRouteContext(
'reconciliation.bank.unmatched_entries',
async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const accountNumber = searchParams.get('account_number') || '1930'
// Optional: when set, rank the returned candidates for this specific bank
// transaction (used by the Transactions-page "Matcha mot befintlig
// verifikation" dialog). Ranking happens server-side on purpose:
// lib/reconciliation/bank-reconciliation pulls in server-only deps (event
// bus, match-log) and must never reach the client bundle.
const transactionId = searchParams.get('transaction_id') || undefined
// When true, also return vouchers already matched to a bank transaction (each
// carries linked_transaction_count) so the user can attach a second/third
// transaction to the same verifikat, the N:1 "lägga på flera" case. Default
// false keeps the list to unmatched candidates only.
const includeMatched = searchParams.get('include_matched') === 'true'
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const accountNumber = searchParams.get('account_number') || '1930'
// Optional: when set, rank the returned candidates for this specific bank
// transaction (used by the Transactions-page "Matcha mot befintlig
// verifikation" dialog). Ranking happens server-side on purpose:
// lib/reconciliation/bank-reconciliation pulls in server-only deps (event
// bus, match-log) and must never reach the client bundle.
const transactionId = searchParams.get('transaction_id') || undefined
// When true, also return vouchers already matched to a bank transaction (each
// carries linked_transaction_count) so the user can attach a second/third
// transaction to the same verifikat, the N:1 "lägga på flera" case. Default
// false keeps the list to unmatched candidates only.
const includeMatched = searchParams.get('include_matched') === 'true'
// Defense-in-depth: only allow account numbers that the company has actually
// registered as a cash account. Without this, a curious caller could probe
// arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly
// including '1930': the cash_accounts backfill seeds 1930 for every company
// that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new
// companies on first connection.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!cashAccount) {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched)
if (transactionId) {
// company-scoped fetch (defense-in-depth). A malformed/foreign id yields no
// row → we fall through to the unranked list rather than erroring.
const { data: tx } = await supabase
.from('transactions')
.select('id, amount, date, currency, reference')
.eq('id', transactionId)
// Defense-in-depth: only allow account numbers that the company has actually
// registered as a cash account. Without this, a curious caller could probe
// arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly
// including '1930': the cash_accounts backfill seeds 1930 for every company
// that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new
// companies on first connection.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
if (!tx) {
// transaction_id was supplied but doesn't resolve to a row in the
// caller's company: the ranking context is invalid. Return no candidates
// rather than silently falling back to the full unranked list, so a
// fabricated or foreign id can never yield a broader result set.
return NextResponse.json({ data: [] })
if (!cashAccount) {
return NextResponse.json(
{ error: 'Okänt kassakonto för det här företaget' },
{ status: 400 },
)
}
const txCurrency = (tx.currency as string | null) ?? 'SEK'
const txDate = tx.date as string
const ranked = lines
.map((line) => {
// Score each line in isolation; confidence 0 means "no auto-match
// rule fired": the line still appears so the user can pick it
// manually (e.g. a salary or Fortnox voucher with a tweaked date).
const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency)
return { ...line, confidence: match?.confidence ?? 0 }
})
.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence
const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime())
const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime())
return da - db
})
return NextResponse.json({ data: ranked })
}
const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched)
return NextResponse.json({ data: lines })
}
if (transactionId) {
// company-scoped fetch (defense-in-depth). A malformed/foreign id yields no
// row → we fall through to the unranked list rather than erroring.
const { data: tx } = await supabase
.from('transactions')
.select('id, amount, date, currency, reference')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
if (!tx) {
// transaction_id was supplied but doesn't resolve to a row in the
// caller's company: the ranking context is invalid. Return no candidates
// rather than silently falling back to the full unranked list, so a
// fabricated or foreign id can never yield a broader result set.
return NextResponse.json({ data: [] })
}
const txCurrency = (tx.currency as string | null) ?? 'SEK'
const txDate = tx.date as string
const ranked = lines
.map((line) => {
// Score each line in isolation; confidence 0 means "no auto-match
// rule fired": the line still appears so the user can pick it
// manually (e.g. a salary or Fortnox voucher with a tweaked date).
const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency)
return { ...line, confidence: match?.confidence ?? 0 }
})
.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence
const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime())
const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime())
return da - db
})
return NextResponse.json({ data: ranked })
}
return NextResponse.json({ data: lines })
},
)