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:
Mattsson
2026-05-15 00:49:59 +02:00
committed by GitHub
parent 523a8650cc
commit b0890c7c79
9 changed files with 1271 additions and 10 deletions
@@ -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,
}),
)
})
})
+178 -2
View File
@@ -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,
},
})
},
},
],
}