Files
accounted/extensions/general/invoice-inbox/__tests__/book-direct-route.test.ts
T
Mattsson b0890c7c79 Add/docs skv mcp (#494)
* feat: add "book directly" functionality for invoice inbox items

- Extend JournalEntrySourceTypeSchema to include 'inbox_item'.
- Introduce BookInboxItemDirectlySchema for direct journal entry creation.
- Update InvoiceInboxItem type to include matched_transaction_id and created_journal_entry_id.
- Implement BookDirectlyDialog component for user interaction.
- Create API route for booking directly from inbox items with appropriate validations.
- Add SQL migration to support new journal entry references in the invoice inbox items table.
- Implement tests for the new booking functionality and ensure proper error handling.

* fix(invoice-inbox): update status handling for resolved inbox items

* feat: enforce unique journal entry constraint for invoice inbox items
2026-05-15 00:49:59 +02:00

251 lines
8.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
makeInvoiceInboxItem,
} from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
const createJournalEntryMock = vi.fn()
const linkToJournalEntryMock = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => createJournalEntryMock(...args),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => linkToJournalEntryMock(...args),
}))
function findRoute(method: string, path: string) {
return invoiceInboxExtension.apiRoutes!.find(
(r) => r.method === method && r.path === path
)!
}
function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'invoice-inbox',
supabase: supabase as ExtensionContext['supabase'],
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn() },
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
services: {},
...overrides,
} as ExtensionContext
}
const PERIOD_UUID = '00000000-0000-4000-8000-000000000010'
const TX_UUID = '00000000-0000-4000-8000-000000000020'
const VALID_BODY = {
fiscal_period_id: PERIOD_UUID,
entry_date: '2026-05-14',
description: 'Kvitto från Spotify',
lines: [
{ account_number: '6540', debit_amount: 79.2, credit_amount: 0 },
{ account_number: '2641', debit_amount: 19.8, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 99 },
],
}
describe('POST /items/:id/book-direct', () => {
const route = findRoute('POST', '/items/:id/book-direct')
beforeEach(() => {
createJournalEntryMock.mockReset()
linkToJournalEntryMock.mockReset()
createJournalEntryMock.mockResolvedValue({
id: 'je-1',
voucher_series: 'A',
voucher_number: 42,
})
linkToJournalEntryMock.mockResolvedValue({ id: 'doc-1' })
})
it('returns 401 when no context', async () => {
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: VALID_BODY,
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, undefined)
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when body is invalid (unbalanced is checked by engine; here we check zod-level)', async () => {
const { supabase } = createQueuedMockSupabase()
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: { fiscal_period_id: 'not-a-uuid' },
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 when inbox item not found', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: VALID_BODY,
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
it('returns 409 when item already linked to a supplier invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: makeInvoiceInboxItem({
created_supplier_invoice_id: 'si-1',
}),
})
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: VALID_BODY,
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status } = await parseJsonResponse(res)
expect(status).toBe(409)
})
it('returns 409 when item already has a journal entry', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: makeInvoiceInboxItem({
created_journal_entry_id: 'je-existing',
}),
})
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: VALID_BODY,
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status } = await parseJsonResponse(res)
expect(status).toBe(409)
})
it('books a standalone entry and links the document', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// 1. fetch inbox item
enqueue({ data: makeInvoiceInboxItem({ document_id: 'doc-1' }) })
// 2. update inbox item (status=confirmed, created_journal_entry_id)
enqueue({ data: null })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: VALID_BODY,
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(body).toMatchObject({
data: { journal_entry: { id: 'je-1' }, transaction_id: null },
})
expect(createJournalEntryMock).toHaveBeenCalledTimes(1)
expect(createJournalEntryMock).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
source_type: 'inbox_item',
fiscal_period_id: PERIOD_UUID,
}),
)
expect(linkToJournalEntryMock).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'doc-1',
'je-1',
)
})
it('returns 404 when transaction_id is provided but not found', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({}) })
enqueue({ data: null }) // transaction lookup
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: { ...VALID_BODY, transaction_id: TX_UUID },
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
expect(createJournalEntryMock).not.toHaveBeenCalled()
})
it('returns 409 when transaction is already booked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({}) })
enqueue({ data: { id: TX_UUID, journal_entry_id: 'je-old' } })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: { ...VALID_BODY, transaction_id: TX_UUID },
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status } = await parseJsonResponse(res)
expect(status).toBe(409)
expect(createJournalEntryMock).not.toHaveBeenCalled()
})
it('books with transaction link: source_type=bank_transaction, source_id=transaction.id', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ document_id: 'doc-1' }) })
enqueue({ data: { id: TX_UUID, journal_entry_id: null } })
enqueue({ data: null }) // transaction update
enqueue({ data: null }) // inbox item update
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/book-direct', {
method: 'POST',
body: { ...VALID_BODY, transaction_id: TX_UUID },
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(body).toMatchObject({
data: { transaction_id: TX_UUID },
})
expect(createJournalEntryMock).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
source_type: 'bank_transaction',
source_id: TX_UUID,
}),
)
})
})