* 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
251 lines
8.2 KiB
TypeScript
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,
|
|
}),
|
|
)
|
|
})
|
|
})
|