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
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
composeInboxAddress,
|
||||
} from './lib/inbox-provisioning'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
|
||||
import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
@@ -321,6 +324,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
id, status, source, created_at, extracted_data,
|
||||
matched_supplier_id, document_id, email_from, email_subject,
|
||||
email_received_at, error_message, created_supplier_invoice_id,
|
||||
matched_transaction_id, created_journal_entry_id,
|
||||
resend_email_id
|
||||
`)
|
||||
.eq('company_id', ctx.companyId)
|
||||
@@ -1027,7 +1031,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
const { data: item } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, created_supplier_invoice_id')
|
||||
.select('id, created_supplier_invoice_id, created_journal_entry_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
@@ -1039,6 +1043,12 @@ export const invoiceInboxExtension: Extension = {
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (item.created_journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Posten är bokförd och kan inte tas bort.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
@@ -1253,6 +1263,172 @@ export const invoiceInboxExtension: Extension = {
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// ── Book inbox item directly as a manual journal entry ─
|
||||
// For kontantmetoden users (and ad-hoc receipts) — bypasses the
|
||||
// supplier-invoice flow entirely. Optionally links to a bank
|
||||
// transaction; otherwise produces a standalone verifikation
|
||||
// (e.g. private outlay, cash receipt). The source document is
|
||||
// attached to the new entry per BFL 5 kap. 6§.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/book-direct',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
let body: z.infer<typeof BookInboxItemDirectlySchema>
|
||||
try {
|
||||
const json = await request.json()
|
||||
body = BookInboxItemDirectlySchema.parse(json)
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: item, error: fetchError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, status, created_supplier_invoice_id, created_journal_entry_id, matched_transaction_id, correlation_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
// Surface the real DB error instead of masking as 404. Common cause:
|
||||
// the migration adding `created_journal_entry_id` hasn't been
|
||||
// applied to this database (e.g. local dev DB lagging staging).
|
||||
console.error('[invoice-inbox/book-direct] Item lookup failed:', fetchError)
|
||||
return NextResponse.json(
|
||||
{ error: `Kunde inte slå upp posten: ${fetchError.message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
if (!item) {
|
||||
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||||
}
|
||||
if (item.created_supplier_invoice_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Posten är redan kopplad till en leverantörsfaktura.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (item.created_journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Posten är redan bokförd.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// If a transaction is provided, validate it before booking.
|
||||
let transaction: { id: string; journal_entry_id: string | null } | null = null
|
||||
if (body.transaction_id) {
|
||||
const { data: tx, error: txError } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', body.transaction_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
if (txError || !tx) {
|
||||
return NextResponse.json({ error: 'Transaktion hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (tx.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaktionen är redan bokförd' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
transaction = tx
|
||||
}
|
||||
|
||||
// Create the journal entry via the engine. Source-tracks back to
|
||||
// the inbox item so the audit trail is preserved even when no
|
||||
// transaction is involved.
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await createJournalEntry(ctx.supabase, ctx.companyId, ctx.userId, {
|
||||
fiscal_period_id: body.fiscal_period_id,
|
||||
entry_date: body.entry_date,
|
||||
description: body.description,
|
||||
source_type: transaction ? 'bank_transaction' : 'inbox_item',
|
||||
source_id: transaction ? transaction.id : item.id,
|
||||
notes: body.notes,
|
||||
lines: body.lines,
|
||||
})
|
||||
} catch (err) {
|
||||
const typed = bookkeepingErrorResponse(err)
|
||||
if (typed) return typed
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Kunde inte skapa verifikation' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Link the source document to the new entry. Best-effort — the
|
||||
// entry itself is already posted; surfacing the failure shouldn't
|
||||
// roll it back, but log so support can re-link manually.
|
||||
if (item.document_id) {
|
||||
try {
|
||||
await linkToJournalEntry(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
item.document_id,
|
||||
journalEntry.id
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/book-direct] Document link failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// If transaction-linked, mark the transaction as booked.
|
||||
if (transaction) {
|
||||
const { error: txUpdateError } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntry.id,
|
||||
is_business: true,
|
||||
category: 'uncategorized',
|
||||
})
|
||||
.eq('id', transaction.id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
if (txUpdateError) {
|
||||
console.error('[invoice-inbox/book-direct] Transaction link failed:', txUpdateError)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the inbox item as resolved by writing the FK. The status
|
||||
// column is intentionally left at 'received' — terminal state is
|
||||
// encoded via created_journal_entry_id / matched_transaction_id
|
||||
// (see migration 20260504180000_invoice_inbox_remove_ai_columns).
|
||||
const { error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
created_journal_entry_id: journalEntry.id,
|
||||
matched_transaction_id: transaction?.id ?? null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// The engine already emits journal_entry.committed — no need to
|
||||
// re-emit. Transaction categorization is implicit: the entry is
|
||||
// already source-linked to the transaction via source_type.
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
journal_entry: journalEntry,
|
||||
inbox_item_id: id,
|
||||
transaction_id: transaction?.id ?? null,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user