Files
accounted/app/api/transactions/[id]/book/__tests__/route.test.ts
T
MattssonandClaude Opus 4.8 9ed0b9515a Fix/invoice booking vat fixes (#778)
* feat(invoices): add Plusgiro input to bank details settings

Plusgiro was already persisted, validated by the API schema, rendered on
the invoice PDF and toggleable via "Visa plusgiro" — but the settings UI
had no field to enter the number, so plusgiro-only users could not fill
it in. Add the input next to Bankgiro with Luhn validation and hyphen
formatting, include it in the save payload (normalised on save so raw
digits still match the dashed schema format), and add sv/en strings.

Adds validatePlusgiroNumber/formatPlusgiroNumber helpers + tests.

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

* fix(invoices): respect non-VAT-registered seller in PDF preview + portal tooltips

Two user-reported bugs:

- PDF preview (/api/invoices/preview-pdf) ignored company.vat_registered and
  fell back to the customer-driven 25% rate, so a non-momsregistrerad seller
  saw VAT in the review step even though the created invoice books none. Mirror
  the server-side write gate (build-invoice-write.ts): force 0% when
  vat_registered is false (delivery notes excepted).

- InfoTooltip rendered TooltipContent without a Portal, so tooltips were
  clipped by the scrollable DialogContent (overflow-y-auto) in the send-invoice
  journal-entry review. Wrap in TooltipPrimitive.Portal.

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

* fix(transactions): book library mall from its literal lines, not a lossy fallback

Booking a bank transaction with a user-created booking-template (mall) via the
convertible "QuickReview" fast path reduced the template to a single category +
one account_override, silently discarding the chosen debit/credit. A
kundinbetalning mall (D 1930 / K 1510) booked as a generic cost (D 6991 / K 1930),
or with a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the
direction inferred from the business/settlement line tags, so visually-identical
templates produced different verifikationer.

Route every library template through the journal-entry editor (applyTemplate ->
/book), which posts the literal lines, regardless of convertibility. Add
regression tests locking the contract.

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

* fix(bookkeeping): make the booking-time duplicate guard bypassable

TRANSACTION_BOOK_POSSIBLE_DUPLICATE told users they could "book anyway" but
the UI dead-ended on a toast with no way to do so. Add a shared
DuplicateBookingDialog that surfaces the already-booked sibling and lets the
user review it or book anyway (force bound to the reviewed candidate, which
the server re-detects so a stale id cannot wave the guard away).

- Wire the dialog into the /transactions categorize flow and the manual
  booking dialog (JournalEntryForm -> /api/transactions/[id]/book)
- Bind the override to expected_duplicate_transaction_id OR
  expected_duplicate_journal_entry_id so ledger-only vouchers (paid invoice,
  salary run) can be confirmed too
- Extend the guard to the pending-operations commit path and the MCP server
- Tests for book/categorize routes, detection, and the commit guard

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

* fix(bookkeeping): log duplicate-guard bypass to behandlingshistorik in the agent commit path

The web /book and /categorize routes append a durable
BankTransactionDuplicateDismissed event when a user books over a detected
possible double-booking. The agent commit path (commitCategorizeTransaction,
commitMarkInvoicePaid) skipped the guard silently on allow_duplicate=true,
leaving no behandlingshistorik — an auditor could not reconstruct why the
duplicate was allowed (BFNAR 2013:2 kap 8).

When allow_duplicate=true, re-detect the candidate and append the dismissal
event (BankTransactionDuplicateDismissed for the bank-line path,
InvoiceDuplicatePaymentDismissed for mark-paid). Best-effort — a logging
failure never blocks a legitimate booking. Payloads stay PII-safe (ids,
amounts, dates only — no customer or merchant name).

Also fix the misleading DuplicateBookingDialog JSDoc: the retry binds
expected_duplicate_journal_entry_id, not candidate.transaction_id, so the
systemdokumentation matches the actual control (BFL 7 kap).

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

* test(mcp-server): stub booking-duplicate guard in receipt-matcher categorize tests

The gnubok_categorize_transaction tool runs the booking-time duplicate guard
before staging; its detection queries consumed the queued supabase mock
results, so the staging assertions saw a thrown duplicate error instead of a
staged op. Mock detectBookingDuplicate to "no duplicate" since these tests
don't exercise that path.

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

* refactor(transactions): use roundOre for duplicate-guard öre rounding

Replace naive Math.round(x*100)/100 with roundOre() from @/lib/money in the
booking-time duplicate guard (detection lib, commit executor, MCP categorize
tool), satisfying the no-new-antipatterns ratchet guard.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:35 +02:00

390 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
makeTransaction,
makeJournalEntry,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
}))
// Booking-time duplicate guard — mocked so route tests exercise the WIRING
// (warn / force / mismatch); the detection query itself is unit-tested in
// lib/transactions/__tests__/booking-duplicate-detection.test.ts.
const mockDetectDup = vi.fn()
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args),
}))
// Behandlingshistorik append — mocked so we can assert the dismissal is
// persisted without reaching the service-role client.
const mockAppendProcessingHistory = vi.fn()
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: (...args: unknown[]) => mockAppendProcessingHistory(...args),
}))
import { POST } from '../route'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const SIBLING_UUID = '660e8400-e29b-41d4-a716-446655440111'
const OTHER_UUID = '770e8400-e29b-41d4-a716-446655440222'
const VOUCHER_JE_UUID = '880e8400-e29b-41d4-a716-446655440333'
describe('POST /api/transactions/[id]/book', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const validBody = {
fiscal_period_id: VALID_UUID,
entry_date: '2025-01-15',
description: 'Test booking',
lines: [
{ account_number: '6200', debit_amount: 500, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 500 },
],
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
// No booking-duplicate by default; guard tests override per-case.
mockDetectDup.mockResolvedValue(null)
mockAppendProcessingHistory.mockResolvedValue('evt-1')
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when missing required fields', async () => {
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { fiscal_period_id: VALID_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Validation failed')
})
it('returns 404 when transaction not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/transactions/tx-999/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toBe('Transaction not found')
})
it('returns 409 when transaction already has a journal entry', async () => {
const tx = makeTransaction({
id: 'tx-1',
journal_entry_id: 'je-existing',
})
enqueue({ data: tx, error: null })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toBe('Transaction already has a journal entry')
})
it('returns 400 when journal entry creation fails (engine error)', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockRejectedValue(new Error('Entry is not balanced'))
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Entry is not balanced')
})
it('creates journal entry and links to transaction (happy path)', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
journal_entry_id: null,
})
const je = makeJournalEntry({ id: 'je-new' })
// Fetch transaction
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockResolvedValue(je)
// Update transaction
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string
data: { id: string }
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-new')
expect(body.data.id).toBe('je-new')
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', {
fiscal_period_id: VALID_UUID,
entry_date: '2025-01-15',
description: 'Test booking',
source_type: 'bank_transaction',
source_id: 'tx-1',
lines: validBody.lines,
})
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'transaction.categorized' })
)
})
it('returns 500 when transaction update fails', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null })
mockCreateJournalEntry.mockResolvedValue(je)
// Update fails
enqueue({ data: null, error: { message: 'Update failed' } })
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: validBody,
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('Failed to update transaction')
})
// ── Booking-time duplicate guard ──────────────────────────────────────
it('returns 409 duplicate warning when a booked sibling shares date+amount', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
enqueue({ data: tx, error: null }) // fetch
mockDetectDup.mockResolvedValue({
transaction_id: SIBLING_UUID,
journal_entry_id: 'je-existing',
voucher_label: 'A142',
entry_date: '2025-01-15',
description: 'redan bokförd',
amount: -500,
})
const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', body: validBody })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { candidate: { transaction_id: string; voucher_label: string } } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_BOOK_POSSIBLE_DUPLICATE')
expect(body.error.details.candidate.transaction_id).toBe(SIBLING_UUID)
expect(body.error.details.candidate.voucher_label).toBe('A142')
// Critically: no verifikat is created when a duplicate is flagged.
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
// Blocking a duplicate is not a dismissal — nothing is logged.
expect(mockAppendProcessingHistory).not.toHaveBeenCalled()
})
it('books when force=true and the expected sibling still matches', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // update
mockDetectDup.mockResolvedValue({
transaction_id: SIBLING_UUID,
journal_entry_id: 'je-existing',
voucher_label: 'A142',
entry_date: '2025-01-15',
description: null,
amount: -500,
})
mockCreateJournalEntry.mockResolvedValue(je)
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { ...validBody, force: true, expected_duplicate_transaction_id: SIBLING_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-new')
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
// The dismissal is recorded to behandlingshistorik (BFNAR 2013:2 kap 8).
expect(mockAppendProcessingHistory).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'BankTransactionDuplicateDismissed',
aggregateType: 'BankTransaction',
aggregateId: 'tx-1',
actor: { type: 'user', id: 'user-1' },
payload: expect.objectContaining({
transaction_id: 'tx-1',
dismissed_transaction_id: SIBLING_UUID,
}),
}),
)
})
it('returns 409 when a ledger-only voucher (no sibling transaction) already books this movement', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 98565, journal_entry_id: null })
enqueue({ data: tx, error: null }) // fetch
// A voucher-keyed candidate has no transaction_id — it's bound by je id.
mockDetectDup.mockResolvedValue({
transaction_id: null,
journal_entry_id: VOUCHER_JE_UUID,
voucher_label: 'A2',
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
amount: 98565,
})
const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', body: validBody })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { candidate: { transaction_id: string | null; journal_entry_id: string } } }
}>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_BOOK_POSSIBLE_DUPLICATE')
expect(body.error.details.candidate.transaction_id).toBeNull()
expect(body.error.details.candidate.journal_entry_id).toBe(VOUCHER_JE_UUID)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('books a voucher-keyed duplicate when force=true binds the expected journal_entry_id', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 98565, journal_entry_id: null })
const je = makeJournalEntry({ id: 'je-new' })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // update
mockDetectDup.mockResolvedValue({
transaction_id: null,
journal_entry_id: VOUCHER_JE_UUID,
voucher_label: 'A2',
entry_date: '2026-03-30',
description: null,
amount: 98565,
})
mockCreateJournalEntry.mockResolvedValue(je)
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { ...validBody, force: true, expected_duplicate_journal_entry_id: VOUCHER_JE_UUID },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
expect(mockAppendProcessingHistory).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'BankTransactionDuplicateDismissed',
payload: expect.objectContaining({
dismissed_transaction_id: null,
dismissed_journal_entry_id: VOUCHER_JE_UUID,
}),
}),
)
})
it('rejects force=true when the expected sibling no longer matches the detected one', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
enqueue({ data: tx, error: null }) // fetch
mockDetectDup.mockResolvedValue({
transaction_id: SIBLING_UUID, // server detects this one…
journal_entry_id: 'je-existing',
voucher_label: 'A142',
entry_date: '2025-01-15',
description: null,
amount: -500,
})
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { ...validBody, force: true, expected_duplicate_transaction_id: OTHER_UUID }, // …caller claims another
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when force=true is sent without expected_duplicate_transaction_id', async () => {
const request = createMockRequest('/api/transactions/tx-1/book', {
method: 'POST',
body: { ...validBody, force: true },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
})
})