* fix(bookkeeping): settle unbound transactions on the company's single enabled cash account A transaction with no cash_account_id booked its bank leg on the hardcoded 1930 from the standard templates and category mappings even when the company's only bank account is e.g. 1920 (PlusGiro), while the booking dialogs previewed the right account via the client-side resolveAccount fallback. resolveSettlementAccount now mirrors that fallback: with a NULL cash_account_id it lists the company's enabled cash accounts and, when EXACTLY ONE matches the transaction's currency, settles there; zero or several candidates keep the 1930 fallback. The explicit-cash_account_id branch (including its throw-on-error path, issue #842) is byte-identical. Transaction currency is threaded into the categorize, batch-categorize, pending-operation edit, MCP staging, and invoice-inbox call sites; other callers get the SEK default. Forward-only: historical wrong verifikat are corrected only via the existing storno runbook (docs/SETTLEMENT_ACCOUNT_REMEDIATION.md). Fixes #1722 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF * test: align duplicate-guard mock queue with combined pre-FY and settlement-fallback lookups The merge of main (PR #1828) into this branch combined two changes that each add one query to the categorize commit flow; the strictly ordered queued mock in the allow_duplicate test needed the cash_accounts listing entry inserted between the period lookup and the pre-FY clamp lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
createMockRequest,
|
|
createMockRouteParams,
|
|
parseJsonResponse,
|
|
createQueuedMockSupabase,
|
|
} from '@/tests/helpers'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
|
|
// The route runs through the real withRouteContext wrapper: mock its auth,
|
|
// company-resolution and write-permission dependencies (getActiveCompanyId,
|
|
// not requireCompanyId, is what the wrapper calls) and inject the queued
|
|
// Supabase mock via requireAuth so the route's own queries stay in sequence.
|
|
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 requireWritePermissionMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
|
|
|
const mappingMock = vi.fn()
|
|
const accountMappingMock = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
|
|
buildMappingResultFromCategory: (...args: unknown[]) => mappingMock(...args),
|
|
getCategoryAccountMapping: (...args: unknown[]) => accountMappingMock(...args),
|
|
}))
|
|
|
|
const buildLinesMock = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
|
buildTransactionEntryLines: (...args: unknown[]) => buildLinesMock(...args),
|
|
}))
|
|
|
|
import { PATCH } from '../route'
|
|
|
|
const mockUser = { id: 'user-1' }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
|
requireWritePermissionMock.mockResolvedValue({ ok: true })
|
|
mappingMock.mockReturnValue({
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
vat_lines: [],
|
|
})
|
|
accountMappingMock.mockReturnValue({
|
|
debitAccount: '5410',
|
|
creditAccount: '1930',
|
|
vatTreatment: 'standard_25',
|
|
vatDebitAccount: '2641',
|
|
vatCreditAccount: null,
|
|
})
|
|
buildLinesMock.mockReturnValue([])
|
|
})
|
|
|
|
describe('PATCH /api/pending-operations/[id]', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
const { NextResponse } = await import('next/server')
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: mockSupabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('blocks viewers', async () => {
|
|
const { NextResponse } = await import('next/server')
|
|
requireWritePermissionMock.mockResolvedValue({
|
|
ok: false,
|
|
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
|
|
})
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(403)
|
|
})
|
|
|
|
it('rejects empty body', async () => {
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', { method: 'PATCH', body: {} }),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 404 when op not visible', async () => {
|
|
enqueue({ data: null })
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-x', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-x' }),
|
|
)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('returns 409 when op is no longer pending', async () => {
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'committed',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other' },
|
|
preview_data: {},
|
|
title: '',
|
|
},
|
|
})
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(409)
|
|
})
|
|
|
|
it('returns 400 when operation_type is not editable', async () => {
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'create_invoice',
|
|
status: 'pending',
|
|
params: {},
|
|
preview_data: {},
|
|
title: '',
|
|
},
|
|
})
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('re-derives accounts and returns updated preview on category change', async () => {
|
|
// Sequence:
|
|
// 1. pending_operations lookup
|
|
// 2. transactions lookup
|
|
// 3. company_settings lookup
|
|
// 4. pending_operations update → returns updated row
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: {
|
|
transaction_id: 'tx-1',
|
|
category: 'expense_other',
|
|
vat_treatment: null,
|
|
},
|
|
preview_data: { debit_account: '6990', credit_account: '1930', amount: 500 },
|
|
title: 'Kategorisera: X',
|
|
},
|
|
})
|
|
enqueue({
|
|
data: {
|
|
id: 'tx-1',
|
|
company_id: 'company-1',
|
|
amount: -500,
|
|
currency: 'SEK',
|
|
date: '2026-05-10',
|
|
},
|
|
})
|
|
enqueue({ data: { entity_type: 'aktiebolag' } })
|
|
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
params: { transaction_id: 'tx-1', category: 'expense_software', vat_treatment: null },
|
|
preview_data: {
|
|
debit_account: '5410',
|
|
credit_account: '1930',
|
|
amount: 500,
|
|
currency: 'SEK',
|
|
vat_lines: [],
|
|
category: 'expense_software',
|
|
},
|
|
title: 'Kategorisera: X',
|
|
status: 'pending',
|
|
},
|
|
})
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{
|
|
data: { preview_data: { category: string; debit_account: string } }
|
|
}>(res)
|
|
expect(status).toBe(200)
|
|
expect(body.data.preview_data.category).toBe('expense_software')
|
|
expect(body.data.preview_data.debit_account).toBe('5410')
|
|
expect(mappingMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('re-derives the full journal lines from the new mapping (stale-preview guard)', async () => {
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other', vat_treatment: null },
|
|
preview_data: {
|
|
debit_account: '6990',
|
|
credit_account: '1930',
|
|
amount: 500,
|
|
// Stale lines from staging — must be replaced, not spread through.
|
|
lines: [{ account_number: '6990', debit_amount: 400, credit_amount: 0 }],
|
|
},
|
|
title: 'Kategorisera: X',
|
|
},
|
|
})
|
|
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } })
|
|
enqueue({ data: { entity_type: 'aktiebolag' } })
|
|
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
|
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
|
|
|
const mapping = {
|
|
debit_account: '5420',
|
|
credit_account: '1930',
|
|
vat_lines: [
|
|
{ account_number: '2641', debit_amount: 100, credit_amount: 0, description: 'Ingående moms 25%' },
|
|
],
|
|
}
|
|
mappingMock.mockReturnValue(mapping)
|
|
buildLinesMock.mockReturnValue([
|
|
{ account_number: '2641', debit_amount: 100, credit_amount: 0, line_description: 'Ingående moms 25%' },
|
|
{ account_number: '5420', debit_amount: 400, credit_amount: 0, line_description: 'Kostnad' },
|
|
{ account_number: '1930', debit_amount: 0, credit_amount: 500, line_description: 'X' },
|
|
])
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(buildLinesMock).toHaveBeenCalledTimes(1)
|
|
expect(buildLinesMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 'tx-1' }),
|
|
mapping,
|
|
)
|
|
})
|
|
|
|
it('re-derives edited previews against the linked cash account', async () => {
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other', vat_treatment: null },
|
|
preview_data: { debit_account: '6990', credit_account: '1930', amount: 500 },
|
|
title: 'Kategorisera: X',
|
|
},
|
|
})
|
|
enqueue({
|
|
data: {
|
|
id: 'tx-1',
|
|
company_id: 'company-1',
|
|
amount: -500,
|
|
currency: 'SEK',
|
|
cash_account_id: 'cash-1',
|
|
},
|
|
})
|
|
enqueue({ data: { entity_type: 'aktiebolag' } })
|
|
enqueue({ data: { ledger_account: '1931' } })
|
|
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_software' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(buildLinesMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 'tx-1', cash_account_id: 'cash-1' }),
|
|
expect.objectContaining({ debit_account: '5410', credit_account: '1931' }),
|
|
)
|
|
})
|
|
|
|
it('preserves a staged vat_amount override when the new treatment still carries VAT', async () => {
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: {
|
|
transaction_id: 'tx-1',
|
|
category: 'expense_representation',
|
|
vat_treatment: 'reduced_12',
|
|
vat_amount: 42.43,
|
|
},
|
|
preview_data: {},
|
|
title: '',
|
|
},
|
|
})
|
|
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
|
|
enqueue({ data: { entity_type: 'enskild_firma' } })
|
|
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
|
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_office' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(200)
|
|
// 6th arg = vat_amount override, carried over from the staged params
|
|
// (vat_treatment persists too, only the category changed)
|
|
expect(mappingMock).toHaveBeenCalledWith(
|
|
'expense_office', expect.anything(), true, 'enskild_firma', 'reduced_12', 42.43,
|
|
)
|
|
})
|
|
|
|
it('drops a stale vat_amount override when the new treatment is VAT-less', async () => {
|
|
accountMappingMock.mockReturnValueOnce({
|
|
debitAccount: '6570',
|
|
creditAccount: '1930',
|
|
vatTreatment: null,
|
|
vatDebitAccount: null,
|
|
vatCreditAccount: null,
|
|
})
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: {
|
|
transaction_id: 'tx-1',
|
|
category: 'expense_representation',
|
|
vat_treatment: 'reduced_12',
|
|
vat_amount: 42.43,
|
|
},
|
|
preview_data: {},
|
|
title: '',
|
|
},
|
|
})
|
|
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
|
|
enqueue({ data: { entity_type: 'enskild_firma' } })
|
|
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
|
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'expense_bank_fees', vat_treatment: null },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(mappingMock).toHaveBeenCalledWith(
|
|
'expense_bank_fees', expect.anything(), true, 'enskild_firma', undefined, null,
|
|
)
|
|
})
|
|
|
|
it('returns 400 when vat_amount is explicitly set on a VAT-less treatment', async () => {
|
|
accountMappingMock.mockReturnValueOnce({
|
|
debitAccount: '5410',
|
|
creditAccount: '1930',
|
|
vatTreatment: null,
|
|
vatDebitAccount: null,
|
|
vatCreditAccount: null,
|
|
})
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other' },
|
|
preview_data: {},
|
|
title: '',
|
|
},
|
|
})
|
|
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } })
|
|
enqueue({ data: { entity_type: 'enskild_firma' } })
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { vat_treatment: 'exempt', vat_amount: 50 },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(400)
|
|
expect(mappingMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 400 when the mapping builder rejects the vat_amount', async () => {
|
|
mappingMock.mockImplementationOnce(() => {
|
|
throw new Error('vat_amount 100 exceeds the maximum possible Swedish VAT on 415.8')
|
|
})
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: { transaction_id: 'tx-1', category: 'expense_representation', vat_treatment: 'reduced_12' },
|
|
preview_data: {},
|
|
title: '',
|
|
},
|
|
})
|
|
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
|
|
enqueue({ data: { entity_type: 'enskild_firma' } })
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { vat_amount: 100 },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(400)
|
|
expect(body.error).toBe('Något gick fel. Försök igen.')
|
|
})
|
|
|
|
it('returns 400 when mapping yields no accounts', async () => {
|
|
enqueue({
|
|
data: {
|
|
id: 'op-1',
|
|
company_id: 'company-1',
|
|
operation_type: 'categorize_transaction',
|
|
status: 'pending',
|
|
params: { transaction_id: 'tx-1', category: 'expense_other' },
|
|
preview_data: {},
|
|
title: '',
|
|
},
|
|
})
|
|
enqueue({ data: { id: 'tx-1', amount: -100, currency: 'SEK' } })
|
|
enqueue({ data: { entity_type: 'enskild_firma' } })
|
|
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
|
mappingMock.mockReturnValueOnce({
|
|
debit_account: null,
|
|
credit_account: null,
|
|
vat_lines: [],
|
|
})
|
|
|
|
const res = await PATCH(
|
|
createMockRequest('/api/pending-operations/op-1', {
|
|
method: 'PATCH',
|
|
body: { category: 'private' },
|
|
}),
|
|
createMockRouteParams({ id: 'op-1' }),
|
|
)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
})
|