Files
accounted/app/api/transactions/bulk-book/__tests__/route.test.ts
T
MattssonandClaude Fable 5 8d56219c31 fix(inbox): booked items no longer strand in Att gora as matched-forever (#1547)
* fix(inbox): booked items no longer strand in Att gora as matched-forever

A matched inbox item only left the active inbox when
created_journal_entry_id was stamped, and only categorizeTransactionCore
stamped it. Booking the matched transaction through any other path (the
/book dialog route, bulk-book, link-to-existing-voucher) or matching a
receipt to an already-booked transaction (receipt hunt approvals,
attach-document, match-transaction) left the item "linked" forever,
pointing at a transaction that had already left the transactions work
list. Todays hunt fix (#1524) turned this July-old gap into a visible
flood of stuck items.

Two-part fix, because stamps alone cannot cover the reported case:
created_journal_entry_id is UNIQUE (20260515090000), so on a bulk-book
samlingsverifikat only one of N matched items can ever carry it.

Write side: lib/transactions/inbox-underlag.ts is the shared
implementation all paths now call. It links matched items' documents to
the anchoring verifikat (BFL 5 kap 6-7 kap: underlag on the
verifikation) and stamps created_journal_entry_id best-effort (CAS on
null, unique_violation tolerated). Wired into categorize-core (replacing
its inline block), /book, bulk-book, linkTransactionToJournalEntry, both
attach paths (REST + pending-operation), and the inbox match-transaction
handler. The attach paths and the doc-conflict guard also resolve
bulk-booked transactions through transaction_voucher_links, which they
previously treated as unbooked.

Read side: GET /items (and /items/:id) enrich matched-but-unstamped
items with matched_transaction_journal_entry_id, and the workspace
derives "booked" from it. This is what clears the stuck rows already in
prod without a status backfill, and what covers the N-1 samlingsverifikat
items the UNIQUE constraint refuses to stamp. Bulk-book selection
filters exclude such items so "Bokfor valda" no longer offers 409 fodder.

scripts/backfill-inbox-booked-underlag.ts (dry-run by default) repairs
the historical document->verifikat links the old paths never made.

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

* fix(inbox): stamp only settled underlag, and give the backfill behandlingshistorik

Both from the Swedish accounting compliance review.

The consumed-stamp is now conditional on the underlag actually
referencing a verifikat: stamping over a failed document link hid the
item from the .is('created_journal_entry_id', null) query forever,
leaving a posted verifikation without its underlag reference
(BFL 5 kap 6-7 kap) and nothing left to surface or repair it. A failed
link now leaves the item unstamped so re-runs and the backfill can
finish the job; a document preserved on another verifikat still counts
as settled.

The backfill script now appends an InboxUnderlagBackfilled event per
repaired transaction to processing_history (BFNAR 2013:2 kap 8): a mass
repair touching underlag-to-verifikat linkage leaves a changelog trail
distinguishing it from the original booking action.

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

* refactor(inbox): backfill writes behandlingshistorik through the shared appender

From the Swedish accounting compliance review round 2: a hand-rolled
processing_history insert in the backfill script could drift from the
shared row shape and skip the PII validation. appendProcessingHistory
now delegates to appendProcessingHistoryWithClient, which takes a
caller-supplied service-role client, so standalone scripts write
behandlingshistorik through the exact same code path as the app
(BFNAR 2013:2 kap 8: one reconcilable change log across writers).

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

* fix(inbox): leave the item unstamped when its document belongs to another verifikat

Swedish accounting review round 3: refusing to steal the document was
right, but stamping the item consumed anyway hid the fact that the
transaction's own verifikat ended up with no underlag reference from it
(BFL 5 kap 6-7 kap). The anchored-elsewhere case now leaves
created_journal_entry_id null so the mismatch keeps surfacing for
reconciliation, same posture as a failed link.

Also documents in the backfill script header why its writes cannot land
in locked periods: linkToJournalEntry's UPDATE is guarded by the
enforce_period_lock DB trigger, which fires for service-role writes too.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 00:49:20 +02:00

434 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
}))
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 }),
}))
// applyTemplate is the pure ratio/VAT expander used by the route. The
// route test stubs it so we don't need a real template object.
vi.mock('@/lib/bookkeeping/template-library', () => ({
applyTemplate: vi.fn(),
}))
// Stubbed so the queued supabase mock stays aligned with the route's own
// queries; the helper's behaviour is covered in
// lib/transactions/__tests__/inbox-underlag.test.ts.
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
}))
import { POST } from '../route'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
const TX1 = '11111111-1111-4111-8111-111111111111'
const TX2 = '22222222-2222-4222-8222-222222222222'
const TPL = '33333333-3333-4333-8333-333333333333'
const JE = '44444444-4444-4444-8444-444444444444'
describe('POST /api/transactions/bulk-book', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 400 when neither template_id nor existing_journal_entry_id is set', async () => {
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: { tx_ids: [TX1] },
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('returns 400 when both template_id and existing_journal_entry_id are set', async () => {
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1],
template_id: TPL,
existing_journal_entry_id: JE,
mode: 'one_line_per_tx',
entry_description: 'Test',
},
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('link path passes through to RPC and returns the success envelope', async () => {
// Currency gate tx fetch (hoisted: runs on every path).
enqueue({
data: [
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' },
],
error: null,
})
// RPC returns the link-existing happy path.
enqueue({
data: {
ok: true,
mode: 'link_existing',
journal_entry_id: JE,
voucher_series: 'A',
voucher_number: 12,
linked_tx_count: 2,
tx_sum: 300,
},
error: null,
})
// Event re-fetch (empty is fine for the test).
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
existing_journal_entry_id: JE,
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { mode: string; journal_entry_id: string; linked_tx_count: number }
}>(response)
expect(status).toBe(200)
expect(body.data.mode).toBe('link_existing')
expect(body.data.journal_entry_id).toBe(JE)
expect(body.data.linked_tx_count).toBe(2)
// Every booked tx gets its matched inbox items completed against the
// samlingsverifikat (underlag link + consumed stamp).
expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith(
expect.anything(),
'company-1',
TX1,
JE,
)
expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith(
expect.anything(),
'company-1',
TX2,
JE,
)
})
it('create-new path fetches template, expands per mode, and calls RPC', async () => {
// Tx fetch (hoisted for the currency gate): 2 incomes totalling 300.
enqueue({
data: [
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' },
],
error: null,
})
// Template fetch.
enqueue({
data: {
id: TPL,
name: 'Försäljning 25%',
lines: [
{ account: '1930', label: 'Bank', side: 'debit', type: 'settlement' },
{ account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 0.8 },
{ account: '2611', label: 'Utg moms 25%', side: 'credit', type: 'vat', vat_rate: 0.25 },
],
is_active: true,
},
error: null,
})
// applyTemplate stub: return a balanced 3-line set per call.
vi.mocked(applyTemplate).mockImplementation((_lines, total) => [
{ account_number: '1930', debit_amount: String(total), credit_amount: '', line_description: 'Bank' },
{ account_number: '3001', debit_amount: '', credit_amount: String(total * 0.8), line_description: 'Försäljning' },
{ account_number: '2611', debit_amount: '', credit_amount: String(total * 0.2), line_description: 'Utg moms 25%' },
])
// Account dimension rules pre-check (PR10) — none configured.
enqueue({ data: [], error: null })
// RPC returns happy path.
enqueue({
data: {
ok: true,
mode: 'create_new',
journal_entry_id: JE,
voucher_series: 'A',
voucher_number: 13,
linked_tx_count: 2,
tx_sum: 300,
},
error: null,
})
// Event re-fetch.
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
template_id: TPL,
mode: 'one_line_per_tx',
entry_description: 'Samlingsverifikation 2026-06-05',
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: { mode: string; journal_entry_id: string }
}>(response)
expect(status).toBe(200)
expect(body.data.mode).toBe('create_new')
expect(body.data.journal_entry_id).toBe(JE)
// Template expansion was invoked once per tx in one_line_per_tx mode.
expect(vi.mocked(applyTemplate)).toHaveBeenCalledTimes(2)
})
it('maps RPC structured failure code to errorResponseFromCode', async () => {
// Currency gate tx fetch.
enqueue({
data: [
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' },
{ id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-06' },
],
error: null,
})
enqueue({
data: { ok: false, code: 'BULK_BOOK_DATE_MISMATCH', details: { expected: '2026-06-05', got: '2026-06-06' } },
error: null,
})
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BULK_BOOK_DATE_MISMATCH')
})
})
/**
* Mixed-currency guard (BFL 4 kap 6 §: one redovisningsvaluta). A
* samlingsverifikation spanning SEK and EUR has no representable single
* belopp, so all three request shapes must refuse it with the SAME code the
* MCP twin uses. Before this, only the template branch checked; manual_lines
* and existing_journal_entry_id went straight to the RPC, which summed
* 100 EUR + 100 SEK into the scalar 200.
*/
describe('POST /api/transactions/bulk-book: mixed-currency guard', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const MIXED_TXS = [
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
{ id: TX2, amount: 100, currency: 'EUR', description: 'Stripe', date: '2026-06-05' },
]
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('refuses a mixed-currency selection on the template branch', async () => {
enqueue({ data: MIXED_TXS, error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
template_id: TPL,
mode: 'sum_per_account',
entry_description: 'Samlingsverifikation',
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { currencies?: string[] } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY')
})
it('refuses a mixed-currency selection on the manual_lines branch', async () => {
enqueue({ data: MIXED_TXS, error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
entry_description: 'Samlingsverifikation',
manual_lines: [
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY')
// Refused before the RPC: the chart-of-accounts lookup never ran either.
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('refuses a mixed-currency selection on the existing_journal_entry_id branch', async () => {
enqueue({ data: MIXED_TXS, error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('refuses a homogeneous non-SEK selection with BULK_BOOK_FOREIGN_CURRENCY', async () => {
// Same currency throughout, but not kronor: the RPC would write the
// foreign magnitudes into the always-SEK debit/credit columns, so the
// route refuses before the RPC just like the mixed-currency case.
enqueue({
data: [
{ id: TX1, amount: 100, currency: 'EUR', description: 'Stripe 1', date: '2026-06-05' },
{ id: TX2, amount: 200, currency: 'EUR', description: 'Stripe 2', date: '2026-06-05' },
],
error: null,
})
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
entry_description: 'Samlingsverifikation',
manual_lines: [
{ account_number: '1930', debit_amount: 300, credit_amount: 0, currency: 'EUR' },
{ account_number: '3001', debit_amount: 0, credit_amount: 300, currency: 'EUR' },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { currency?: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BULK_BOOK_FOREIGN_CURRENCY')
expect(body.error.details?.currency).toBe('EUR')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('books a single-currency manual_lines selection', async () => {
// Tx fetch: both SEK.
enqueue({
data: [
{ id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
{ id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
],
error: null,
})
// chart_of_accounts allowlist.
enqueue({
data: [{ account_number: '1930' }, { account_number: '3001' }],
error: null,
})
// Dimension rules: none configured.
enqueue({ data: [], error: null })
// RPC happy path.
enqueue({
data: {
ok: true,
mode: 'create_new',
journal_entry_id: JE,
voucher_series: 'A',
voucher_number: 14,
linked_tx_count: 2,
tx_sum: 200,
},
error: null,
})
// Event re-fetch.
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
entry_description: 'Samlingsverifikation',
manual_lines: [
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { mode: string } }>(response)
expect(status).toBe(200)
expect(body.data.mode).toBe('create_new')
})
it('treats NULL currency as SEK and still books', async () => {
enqueue({
data: [
{ id: TX1, amount: 100, currency: null, description: 'Legacy row', date: '2026-06-05' },
{ id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' },
],
error: null,
})
enqueue({ data: [{ account_number: '1930' }, { account_number: '3001' }], error: null })
enqueue({ data: [], error: null })
enqueue({
data: {
ok: true,
mode: 'create_new',
journal_entry_id: JE,
voucher_series: 'A',
voucher_number: 15,
linked_tx_count: 2,
tx_sum: 200,
},
error: null,
})
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/bulk-book', {
method: 'POST',
body: {
tx_ids: [TX1, TX2],
entry_description: 'Samlingsverifikation',
manual_lines: [
{ account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
],
},
})
const response = await POST(request)
expect(response.status).toBe(200)
})
})