feat(invoice-inbox): pin documents to bank transactions + MCP tools (#397)
* feat(invoice-inbox): pin documents to bank transactions + MCP tools Adds a first-class flow for attaching unmatched inbox documents to bank transactions, separate from the existing supplier-invoice convert path: - new transactions.document_id FK → document_attachments (ON DELETE SET NULL) - POST/DELETE /api/transactions/[id]/attach-document - categorize route propagates the link to journal_entry_id on commit - three new MCP tools: gnubok_list_unmatched_documents, gnubok_get_document_content (5-min signed URL), gnubok_attach_document_to_transaction (staged via pending_operations) - InvoiceInboxWorkspace gains a "Koppla till transaktion" picker dialog ranked by amount-match, plus a "Bilaga" badge in SwipeCategorizationView - regex extraction unchanged; supplier-invoice convert flow unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 review findings - categorize: destructure { error } from the document-link update so Supabase-level failures are logged instead of silently dropped (BFL 5 kap 6 § receipt-on-verifikation contract). - list_unmatched_documents: emit next_cursor whenever the inbox query may have more rows, not only when the post-filter slice was full; switch to composite (created_at, id) cursor to avoid same-second collisions. - DELETE /attach-document: return 404 when the tx isn't in the company; return 409 when the linked document already has journal_entry_id set (räkenskapsinformation immutability). - risk tier: attach_document_to_transaction medium (was low) — link becomes part of verifikation underlag once categorize propagates it. - pg-real test: stop reusing $2 across uuid + text-concat contexts (Postgres couldn't deduce the parameter type). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrations): break duplicate version 20260505120000 (Supabase Preview) Two migrations on main share filename version 20260505120000: - 20260505120000_api_keys_refresh_token.sql (PR #392) - 20260505120000_drop_agent_auto_commit.sql (PR #394) The schema_migrations primary key is (version), so any fresh DB doing `supabase db push` over both files conflicts on the second insert. This is why every PR with a migration since #394 has had Supabase Preview either fail or skip. Renaming _drop_agent_auto_commit to 20260505190027 — that matches the timestamp recorded in prod schema_migrations from when apply_migration was called for it, so future `db push` against prod sees the file as already- applied (no re-run). The migration body is fully idempotent (IF EXISTS on every drop) so a re-run would be a no-op anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-2 compliance review Two BFL gaps the compliance bot flagged on the round-1 fix commit: 1. commitAttachDocumentToTransaction silently broke verifikation→underlag if the transaction was categorized between staging and approval. Now reads transactions.journal_entry_id at commit time and, if non-null, also writes document_attachments.journal_entry_id in the same commit so BFL 5 kap 6 § is satisfied regardless of order. 2. Application-layer DELETE check was racy (SELECT then UPDATE) and the FK ON DELETE SET NULL path could null transactions.document_id even for a document that is räkenskapsinformation. Added a BEFORE UPDATE OF document_id trigger on transactions that raises check_violation when the previously-attached document has document_attachments.journal_entry_id set. The app-layer guard stays for friendly Swedish messaging; the trigger is the DB-level safety net. pg-real test extended to cover both directions of the trigger (block detach + block swap) and the happy-path detach when there's no JE link yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-3 compliance review Four findings from the round-2 update of the compliance bot. The first three are genuine compliance gaps; the fourth (preview metadata distinguishing pre- vs post-categorization overwrites) is a UX nicety left for follow-up. 1. transactions.document_id FK switched from ON DELETE SET NULL to RESTRICT (migration 20260506100000). Removes the "trigger ordering" concern: a doc that's pinned to any tx now cannot be deleted at all without explicit detach first. Belt-and-braces with block_document_deletion. 2. commitAttachDocumentToTransaction now does: - pre-check that mirrors the DELETE route's 409 when the existing pinned doc is räkenskapsinformation, so the same Swedish message is returned in both paths; - UPDATE…RETURNING journal_entry_id so the propagation decision uses the post-update state, closing the read-then-write race with concurrent categorize. Either ordering of attach-then-categorize or categorize-then-attach now lands at the same correct final state. 3. Both DELETE /attach-document and the MCP commit path catch the trigger's check_violation (SQLSTATE 23514) and translate to 409 with the Swedish underlag message. The trigger remains the DB-level safety net; the app layer is responsible only for friendly UX. pg-real test rewritten for ON DELETE RESTRICT (blocks deletion of pinned doc; detach-then-delete works). Unit coverage added for commitAttach: 404, two distinct 409 paths (pre-check + trigger-translation), happy-path uncategorized, and propagation when tx was categorized between staging and commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-4 compliance review Three of five round-3 findings actioned: 1. commitAttachDocumentToTransaction: surface propagation failure rather than logging-and-continuing. If document_attachments.journal_entry_id can't be set after the transaction has been categorized, the op fails (status 500) with a Swedish message instructing retry. Retry is idempotent — same document_id on the tx, same propagate target. 2. Replace check_violation (23514) matching with a stable "BFL_DOCUMENT_IMMUTABILITY:" message prefix. The trigger now uses default P0001 + tagged message; both the route handler and the executor match on the prefix instead of the generic SQLSTATE. Future unrelated CHECK constraints on transactions can no longer accidentally surface as the räkenskapsinformation message. 3. gnubok_list_unmatched_documents now returns invoice currency alongside amount so an agent can FX-normalise before comparing to transactions.amount. Description updated to make the requirement explicit. Mirrored in the UI: AttachToTransactionDialog ranks same-currency rows by amount distance and pushes cross-currency rows to the bottom of the list. Skipped: - Two-migration window for FK action change is acknowledged as resolved by the bot; deploy-atomicity is an ops concern, not code. - Period-lock check in attach/detach: realistic compliance concern is already covered by the existing immutability trigger (post-categorize) and by the engine's period-lock enforcement (categorize itself). A dedicated period check on pre-categorize attach would only guard against pinning a doc to a tx in a closed period — defensible defense-in-depth, but no active BFL violation. Left for a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): address PR #397 round-5 compliance review Three of four findings actioned. The fourth (block_document_deletion verification) is already covered by 20240101000017_enforcement_triggers.sql which raises when document_attachments.journal_entry_id IS NOT NULL on a posted/reversed entry — confirmed via grep, no code change needed. 1. categorize/route.ts: propagation no longer fires-and-forgets. If document_attachments.journal_entry_id can't be set after the JE has been committed, the response now carries a document_link_warning field with a Swedish retry message. The JE is already committed so we can't roll back, but the client can no longer mistake a partial attach for a clean categorize. 2. Rättelse audit trail (BFL 5 kap 5 §): both the REST POST handler and the MCP commit executor now append a TransactionDocumentReplaced event to processing_history whenever a non-null document_id is overwritten, with previous_document_id and new_document_id in the payload. Best-effort — logging failure must not roll back the (compliant) attach. The previous doc id is also returned in the response so callers see what was displaced. 3. MCP staging preview now exposes the existing doc's identity (existing_document_id, existing_document_file_name) plus an explicit existing_document_is_rakenskapsinformation flag, so a human approver sees "replaces X.pdf with Y.pdf" rather than just a will_overwrite_existing boolean. Mirrors BFL 5 kap 5 § informed-rättelse intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): close trigger SELECT race (PR #397 round-6) The enforce_transactions_document_immutability trigger SELECTed document_attachments.journal_entry_id without a row lock. A concurrent UPDATE setting journal_entry_id on that row could commit between the trigger's SELECT and its RAISE, letting a detach slip through against a document that just became räkenskapsinformation. Add FOR SHARE to the SELECT inside the trigger. A concurrent journal_entry_id write blocks on our share lock until our transaction commits, so either we observe the propagation and raise, or we run first and the propagation observes our committed detach (which is fine because journal_entry_id was still null at that point). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): bidirectional immutability + richer staging preview (PR #397 round-7) Two of six round-6 findings actioned. The other four are recurring architectural recommendations (atomic audit-log writes, background reconciliation jobs, migration consolidation, anti-join via materialized view) that are properly scoped as follow-up work. 1. document_attachments side of the immutability link (BFL 5 kap 6 § works in both directions). New trigger enforce_document_journal_entry_immutability blocks UPDATE OF journal_entry_id when going from non-null to NULL or to a different uuid. The original null→uuid path (initial propagation in the categorize / commitAttach flows) still works. Migration 20260506130000. 2. gnubok_attach_document_to_transaction staging preview now joins on invoice_inbox_items.extracted_data and surfaces vendor/amount/currency/ invoice_date alongside the existing doc filename/mime metadata. Gives the human approver the same hints the agent saw before choosing the attachment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
2c03dac981
commit
94f15b9c6c
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
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 }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST, DELETE } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') {
|
||||
return new Request('http://localhost/api/transactions/tx-1/attach-document', {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: method === 'POST' ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await POST(makeReq({ document_id: 'doc-1' }), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when document_id missing', async () => {
|
||||
const res = await POST(makeReq({}), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not in company', async () => {
|
||||
enqueue({ data: null, error: null }) // tx fetch
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
expect(body).toEqual({ error: 'Transaction not found' })
|
||||
})
|
||||
|
||||
it('returns 404 when document not in company', async () => {
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: null, error: null }) // doc fetch
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
expect(body).toEqual({ error: 'Document not found' })
|
||||
})
|
||||
|
||||
it('attaches when both rows exist', async () => {
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // update
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { transaction_id: string; document_id: string } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.transaction_id).toBe('tx-1')
|
||||
expect(body.data.document_id).toBe('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/transactions/[id]/attach-document', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not in company', async () => {
|
||||
enqueue({ data: null, error: null }) // tx fetch
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(res)
|
||||
expect(status).toBe(404)
|
||||
expect(body).toEqual({ error: 'Transaction not found' })
|
||||
})
|
||||
|
||||
it('returns 409 when document is already on a journal entry', async () => {
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // doc fetch
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('verifikation')
|
||||
})
|
||||
|
||||
it('clears document_id when no journal entry link', async () => {
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // update
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { document_id: string | null } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.document_id).toBeNull()
|
||||
})
|
||||
|
||||
it('clears document_id when no doc was attached', async () => {
|
||||
enqueue({ data: { id: 'tx-1', document_id: null }, error: null }) // tx fetch
|
||||
enqueue({ data: null, error: null }) // update
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { AttachDocumentSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/attach-document
|
||||
*
|
||||
* Pin an unmatched document_attachments row to a bank transaction. Lets users
|
||||
* (or AI agents via MCP) bind a forwarded/uploaded invoice or receipt before
|
||||
* the transaction is categorized. When the transaction is later categorized,
|
||||
* the categorize route propagates the link to document_attachments.journal_entry_id.
|
||||
*
|
||||
* Idempotent — overwrites any existing link.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: transactionId } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, AttachDocumentSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { document_id } = validation.data
|
||||
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, document_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (txError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const previousDocumentId = (transaction.document_id as string | null) ?? null
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id')
|
||||
.eq('id', document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (docError || !document) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ document_id })
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
const errMsg = (updateError as { message?: string }).message ?? ''
|
||||
if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
|
||||
},
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
console.error('[attach-document] Failed to attach:', updateError)
|
||||
return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc
|
||||
// was replaced. Best-effort — a logging failure must not roll back the
|
||||
// (compliant) attach.
|
||||
if (previousDocumentId && previousDocumentId !== document_id) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId,
|
||||
correlationId: transactionId,
|
||||
aggregateType: 'BankTransaction',
|
||||
aggregateId: transactionId,
|
||||
eventType: 'TransactionDocumentReplaced',
|
||||
payload: {
|
||||
transaction_id: transactionId,
|
||||
previous_document_id: previousDocumentId,
|
||||
new_document_id: document_id,
|
||||
},
|
||||
actor: { type: 'user', id: user.id },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (logErr) {
|
||||
console.error('[attach-document] Failed to append rättelse event:', logErr)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: { transaction_id: transactionId, document_id, previous_document_id: previousDocumentId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/transactions/[id]/attach-document
|
||||
*
|
||||
* Detach a document from a transaction.
|
||||
*
|
||||
* Blocked once the document has propagated into a journal entry (BFL 5 kap 6 §
|
||||
* räkenskapsinformation immutability) — at that point the doc is the
|
||||
* verifikation's underlag and can only be undone by reversing the entry.
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: transactionId } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data: tx, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, document_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError || !tx) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (tx.document_id) {
|
||||
const { data: doc } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('journal_entry_id')
|
||||
.eq('id', tx.document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (doc?.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.',
|
||||
},
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ document_id: null })
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
// The enforce_transactions_document_immutability trigger raises a
|
||||
// P0001 exception with a stable BFL_DOCUMENT_IMMUTABILITY: prefix when the
|
||||
// previously-attached doc has already become räkenskapsinformation.
|
||||
// Match on the prefix (not on the generic SQLSTATE) so unrelated future
|
||||
// exceptions don't get translated into the Swedish underlag message.
|
||||
const errMsg = (updateError as { message?: string }).message ?? ''
|
||||
if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.',
|
||||
},
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
console.error('[attach-document] Failed to detach:', updateError)
|
||||
return NextResponse.json({ error: 'Failed to detach document' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { transaction_id: transactionId, document_id: null } })
|
||||
}
|
||||
@@ -283,6 +283,7 @@ export async function POST(
|
||||
let journalEntryCreated = false
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
let documentLinkWarning: string | null = null
|
||||
|
||||
try {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
@@ -382,6 +383,29 @@ export async function POST(
|
||||
} catch (inboxErr) {
|
||||
console.error('[categorize] Failed to update inbox item:', inboxErr)
|
||||
}
|
||||
} else if (journalEntryId && transaction.document_id) {
|
||||
// Document was pinned to the transaction (via /attach-document or MCP) before
|
||||
// categorization. Propagate the link to the journal entry so receipt-on-verifikation
|
||||
// (BFL 5 kap 6 §) is satisfied. The journal entry has already been committed at
|
||||
// this point, so we can't roll it back; instead surface a warning in the response
|
||||
// so the UI can prompt the user to retry the link. Supabase JS returns { error }
|
||||
// rather than throwing — destructure and surface it, never swallow silently.
|
||||
try {
|
||||
const { error: linkErr } = await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', transaction.document_id)
|
||||
.eq('company_id', companyId)
|
||||
if (linkErr) {
|
||||
console.error('[categorize] Failed to link transaction document:', linkErr)
|
||||
documentLinkWarning =
|
||||
'Verifikationen skapades men bilagan kunde inte länkas till den. Försök länka om bilagan manuellt.'
|
||||
}
|
||||
} catch (docErr) {
|
||||
console.error('[categorize] Failed to link transaction document:', docErr)
|
||||
documentLinkWarning =
|
||||
'Verifikationen skapades men bilagan kunde inte länkas till den. Försök länka om bilagan manuellt.'
|
||||
}
|
||||
}
|
||||
|
||||
// Update the transaction (CAS guard: only set journal_entry_id if still null)
|
||||
@@ -451,6 +475,7 @@ export async function POST(
|
||||
journal_entry_created: journalEntryCreated,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
document_link_warning: documentLinkWarning,
|
||||
category: finalCategory,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,11 +18,19 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Link2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,6 +113,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isRotating, setIsRotating] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [attachOpen, setAttachOpen] = useState(false)
|
||||
|
||||
// ── Data loading ───────────────────────────────────────────
|
||||
|
||||
@@ -409,6 +418,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
<FieldsRail
|
||||
item={selected}
|
||||
onDelete={() => handleDelete(selected.id)}
|
||||
onAttach={() => setAttachOpen(true)}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
) : (
|
||||
@@ -419,10 +429,181 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<AttachToTransactionDialog
|
||||
open={attachOpen}
|
||||
onOpenChange={setAttachOpen}
|
||||
item={selected}
|
||||
onAttached={async () => {
|
||||
setAttachOpen(false)
|
||||
await fetchItems()
|
||||
toast({ title: 'Bilaga kopplad till transaktion' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Attach-to-transaction dialog ─────────────────────────────
|
||||
|
||||
interface PickerTransaction {
|
||||
id: string
|
||||
date: string
|
||||
description: string
|
||||
amount: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
function AttachToTransactionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
onAttached,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
item: InboxItem
|
||||
onAttached: () => void | Promise<void>
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [transactions, setTransactions] = useState<PickerTransaction[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [attachingId, setAttachingId] = useState<string | null>(null)
|
||||
|
||||
const targetAmount = pickAmount(item)
|
||||
const targetCurrency = pickCurrency(item)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
setIsLoading(true)
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/transactions?unmatched=true')
|
||||
const json = await res.json()
|
||||
if (cancelled) return
|
||||
const rows: PickerTransaction[] = (Array.isArray(json.data) ? json.data : [])
|
||||
.map((t: PickerTransaction) => ({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
currency: t.currency || 'SEK',
|
||||
}))
|
||||
setTransactions(rankByAmount(rows, targetAmount, targetCurrency))
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/attach] fetch failed:', err)
|
||||
toast({ title: 'Kunde inte ladda transaktioner', variant: 'destructive' })
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [open, targetAmount, targetCurrency, toast])
|
||||
|
||||
const handleAttach = async (tx: PickerTransaction) => {
|
||||
if (!item.document_id) return
|
||||
setAttachingId(tx.id)
|
||||
try {
|
||||
const res = await fetch(`/api/transactions/${tx.id}/attach-document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document_id: item.document_id }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}))
|
||||
toast({ title: json.error || 'Kunde inte koppla bilaga', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
await onAttached()
|
||||
} finally {
|
||||
setAttachingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Koppla bilaga till transaktion</DialogTitle>
|
||||
<DialogDescription>
|
||||
{targetAmount != null
|
||||
? `Belopp på fakturan: ${formatCurrency(targetAmount, pickCurrency(item))}. Listan är sorterad efter beloppsmatch.`
|
||||
: 'Välj en transaktion att koppla bilagan till.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Laddar transaktioner…
|
||||
</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
Inga okategoriserade transaktioner hittades.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{transactions.map((tx) => (
|
||||
<li key={tx.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between gap-4 px-1 py-3 text-left hover:bg-accent/40 disabled:opacity-50"
|
||||
onClick={() => handleAttach(tx)}
|
||||
disabled={attachingId !== null}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{tx.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{tx.date}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm tabular-nums whitespace-nowrap',
|
||||
targetAmount != null
|
||||
&& tx.currency === targetCurrency
|
||||
&& Math.abs(Math.abs(tx.amount) - Math.abs(targetAmount)) < 0.01
|
||||
? 'font-semibold'
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(tx.amount, tx.currency)}
|
||||
</span>
|
||||
{attachingId === tx.id && <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function rankByAmount(
|
||||
rows: PickerTransaction[],
|
||||
target: number | null,
|
||||
targetCurrency: string,
|
||||
): PickerTransaction[] {
|
||||
if (target == null) return rows
|
||||
const t = Math.abs(target)
|
||||
// Same-currency rows rank by amount distance. Cross-currency rows go to
|
||||
// the bottom — comparing a EUR invoice's amount to a SEK transaction's
|
||||
// amount numerically would be misleading and could cause a wrong attachment
|
||||
// (which then becomes verifikation underlag, BFL 5 kap 6 §). The user can
|
||||
// still manually pick a cross-currency match by scrolling down.
|
||||
return [...rows].sort((a, b) => {
|
||||
const aMatch = a.currency === targetCurrency
|
||||
const bMatch = b.currency === targetCurrency
|
||||
if (aMatch !== bMatch) return aMatch ? -1 : 1
|
||||
if (!aMatch) return 0
|
||||
const da = Math.abs(Math.abs(a.amount) - t)
|
||||
const db = Math.abs(Math.abs(b.amount) - t)
|
||||
return da - db
|
||||
})
|
||||
}
|
||||
|
||||
// ── List row ─────────────────────────────────────────────────
|
||||
|
||||
function InboxRow({
|
||||
@@ -567,10 +748,12 @@ function EmptyPreview({
|
||||
function FieldsRail({
|
||||
item,
|
||||
onDelete,
|
||||
onAttach,
|
||||
isDeleting,
|
||||
}: {
|
||||
item: InboxItem
|
||||
onDelete: () => void
|
||||
onAttach: () => void
|
||||
isDeleting: boolean
|
||||
}) {
|
||||
const data = item.extracted_data
|
||||
@@ -636,11 +819,24 @@ function FieldsRail({
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
|
||||
<Button variant="default" size="sm" className="w-full">
|
||||
Skapa leverantörsfaktura
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onAttach}
|
||||
disabled={!item.document_id}
|
||||
title={!item.document_id ? 'Ingen bilaga att koppla' : undefined}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Koppla till transaktion
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Skapa leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -655,6 +655,12 @@ export default function SwipeCategorizationView({
|
||||
Kvitto
|
||||
</Badge>
|
||||
)}
|
||||
{currentTransaction.document_id && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
Bilaga
|
||||
</Badge>
|
||||
)}
|
||||
<Badge>{currentTransaction.currency}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2680,6 +2680,340 @@ export const tools: McpTool[] = [
|
||||
return data
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_unmatched_documents',
|
||||
description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. The amount is in the invoice currency — FX-normalise before comparing to transactions.amount.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 20, max 50)' },
|
||||
cursor: { type: 'string', description: 'Composite "<created_at>__<inbox_item_id>" from previous page (exclusive). Pass next_cursor verbatim.' },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
next_cursor: { type: 'string', description: 'Pass as cursor on next call. Absent = no more pages.' },
|
||||
},
|
||||
required: ['items', 'count'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
|
||||
const cursor = typeof args.cursor === 'string' ? args.cursor : null
|
||||
|
||||
// Composite cursor: "<created_at>__<id>". Falls back to plain timestamp
|
||||
// for backward compat with older callers.
|
||||
let cursorTs: string | null = null
|
||||
let cursorId: string | null = null
|
||||
if (cursor) {
|
||||
const sep = cursor.indexOf('__')
|
||||
if (sep === -1) {
|
||||
cursorTs = cursor
|
||||
} else {
|
||||
cursorTs = cursor.slice(0, sep)
|
||||
cursorId = cursor.slice(sep + 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull recent inbox items with a document, no supplier invoice yet, then
|
||||
// filter out those whose document is already pinned to a transaction.
|
||||
// Two-step query because PostgREST doesn't expose anti-joins.
|
||||
const fetchSize = limit * 2
|
||||
let inboxQuery = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, source, email_from, email_subject, email_received_at, extracted_data, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.not('document_id', 'is', null)
|
||||
.is('created_supplier_invoice_id', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.order('id', { ascending: false })
|
||||
.limit(fetchSize)
|
||||
|
||||
if (cursorTs && cursorId) {
|
||||
// (created_at, id) < (cursorTs, cursorId) — keyset pagination
|
||||
inboxQuery = inboxQuery.or(
|
||||
`created_at.lt.${cursorTs},and(created_at.eq.${cursorTs},id.lt.${cursorId})`
|
||||
)
|
||||
} else if (cursorTs) {
|
||||
inboxQuery = inboxQuery.lt('created_at', cursorTs)
|
||||
}
|
||||
|
||||
const { data: inboxRows, error: inboxError } = await inboxQuery
|
||||
if (inboxError) throw new Error(`Database error: ${inboxError.message}`)
|
||||
if (!inboxRows || inboxRows.length === 0) {
|
||||
return { items: [], count: 0 }
|
||||
}
|
||||
|
||||
const docIds = inboxRows.map((r) => r.document_id).filter((d): d is string => d != null)
|
||||
const { data: txMatches, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('document_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('document_id', docIds)
|
||||
|
||||
if (txError) throw new Error(`Database error: ${txError.message}`)
|
||||
const matchedDocIds = new Set((txMatches || []).map((t) => t.document_id))
|
||||
|
||||
const unmatched = inboxRows
|
||||
.filter((r) => r.document_id && !matchedDocIds.has(r.document_id))
|
||||
.slice(0, limit)
|
||||
.map((item) => {
|
||||
const extracted = item.extracted_data as Record<string, unknown> | null
|
||||
let vendorName: string | null = null
|
||||
let orgNumber: string | null = null
|
||||
let amount: number | null = null
|
||||
let currency: string | null = null
|
||||
let invoiceDate: string | null = null
|
||||
let paymentReference: string | null = null
|
||||
|
||||
if (extracted) {
|
||||
const supplier = extracted.supplier as Record<string, unknown> | undefined
|
||||
const invoice = extracted.invoice as Record<string, unknown> | undefined
|
||||
const totals = extracted.totals as Record<string, unknown> | undefined
|
||||
vendorName = (supplier?.name as string) || null
|
||||
orgNumber = (supplier?.orgNumber as string) || null
|
||||
amount = (totals?.total as number) || null
|
||||
// Surface currency alongside amount so the agent doesn't compare a
|
||||
// non-SEK invoice numerically to a SEK transaction. transactions.amount
|
||||
// is in transactions.currency; if these don't match, the agent must
|
||||
// FX-normalise before ranking matches. Defaulting to null when absent
|
||||
// (rather than 'SEK') makes the missing-currency case explicit.
|
||||
currency = (invoice?.currency as string) || null
|
||||
invoiceDate = (invoice?.invoiceDate as string) || null
|
||||
paymentReference = (invoice?.paymentReference as string) || null
|
||||
}
|
||||
|
||||
return {
|
||||
inbox_item_id: item.id,
|
||||
document_id: item.document_id,
|
||||
source: item.source,
|
||||
created_at: item.created_at,
|
||||
email_from: item.email_from,
|
||||
email_subject: item.email_subject,
|
||||
email_received_at: item.email_received_at,
|
||||
vendor_name: vendorName,
|
||||
org_number: orgNumber,
|
||||
amount,
|
||||
currency,
|
||||
invoice_date: invoiceDate,
|
||||
payment_reference: paymentReference,
|
||||
}
|
||||
})
|
||||
|
||||
// Pagination contract: emit next_cursor whenever the caller might be
|
||||
// missing rows. Two cases:
|
||||
// (a) slice was full → cursor on last returned item (next page picks up
|
||||
// any leftover unmatched rows we filtered past);
|
||||
// (b) slice was short but inbox query returned a full batch → cursor on
|
||||
// last inspected row (more unmatched may exist deeper in the inbox).
|
||||
// Only suppress the cursor when we exhausted the inbox stream entirely.
|
||||
let nextCursor: string | null = null
|
||||
if (unmatched.length === limit) {
|
||||
const last = unmatched[unmatched.length - 1]
|
||||
nextCursor = `${last.created_at}__${last.inbox_item_id}`
|
||||
} else if (inboxRows.length === fetchSize) {
|
||||
const last = inboxRows[inboxRows.length - 1]
|
||||
nextCursor = `${last.created_at}__${last.id}`
|
||||
}
|
||||
|
||||
return {
|
||||
items: unmatched,
|
||||
count: unmatched.length,
|
||||
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_document_content',
|
||||
description: 'Get a 5-minute signed download URL for a document so the agent can read its contents (e.g. with vision). Use after gnubok_list_unmatched_documents to inspect a specific PDF before deciding which transaction it matches.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: { type: 'string', description: 'UUID of the document_attachments row' },
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: { type: 'string' },
|
||||
file_name: { type: 'string' },
|
||||
mime_type: { type: 'string' },
|
||||
size_bytes: { type: 'number' },
|
||||
signed_url: { type: 'string' },
|
||||
expires_at: { type: 'string' },
|
||||
},
|
||||
required: ['document_id', 'file_name', 'signed_url', 'expires_at'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const documentId = args.document_id as string
|
||||
if (!documentId) throw new Error('document_id is required')
|
||||
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, file_name, mime_type, file_size_bytes, storage_path')
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (docError) throw new Error(`Database error: ${docError.message}`)
|
||||
if (!doc) throw new Error('Document not found')
|
||||
|
||||
const ttlSeconds = 300
|
||||
const { data: signed, error: signError } = await supabase.storage
|
||||
.from('documents')
|
||||
.createSignedUrl(doc.storage_path, ttlSeconds)
|
||||
|
||||
if (signError || !signed) {
|
||||
throw new Error(`Failed to create signed URL: ${signError?.message ?? 'unknown error'}`)
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString()
|
||||
|
||||
return {
|
||||
document_id: doc.id,
|
||||
file_name: doc.file_name,
|
||||
mime_type: doc.mime_type,
|
||||
size_bytes: doc.file_size_bytes,
|
||||
signed_url: signed.signedUrl,
|
||||
expires_at: expiresAt,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_attach_document_to_transaction',
|
||||
description: 'Stage attaching a document to a bank transaction. The document is pinned to the tx; when the tx is later categorized the link propagates to the journal entry. Stages for approval.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
transaction_id: { type: 'string', description: 'UUID of the bank transaction' },
|
||||
document_id: { type: 'string', description: 'UUID of the document_attachments row' },
|
||||
idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' },
|
||||
dry_run: { type: 'boolean', description: 'Preview without staging' },
|
||||
},
|
||||
required: ['transaction_id', 'document_id'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const transactionId = args.transaction_id as string
|
||||
const documentId = args.document_id as string
|
||||
if (!transactionId) throw new Error('transaction_id is required')
|
||||
if (!documentId) throw new Error('document_id is required')
|
||||
|
||||
const { data: tx, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, description, merchant_name, amount, currency, date, document_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (txError || !tx) throw new Error('Transaction not found')
|
||||
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, file_name, mime_type')
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (docError || !doc) throw new Error('Document not found')
|
||||
|
||||
// If the tx already has a different doc pinned, fetch its identity so the
|
||||
// human approver sees "replaces X.pdf with Y.pdf" rather than just a flag.
|
||||
// Required by BFL 5 kap 5 § rättelse (the approver must know what's being
|
||||
// displaced before authorising the change).
|
||||
type ExistingDoc = { id: string; file_name: string; journal_entry_id: string | null }
|
||||
let existingDoc: ExistingDoc | null = null
|
||||
if (tx.document_id && tx.document_id !== documentId) {
|
||||
const { data: prev } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, file_name, journal_entry_id')
|
||||
.eq('id', tx.document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (prev) {
|
||||
existingDoc = prev as unknown as ExistingDoc
|
||||
}
|
||||
}
|
||||
|
||||
// Pull the matching invoice_inbox_items extracted_data so the approver
|
||||
// sees vendor/amount/currency/date — the same hints the agent had when
|
||||
// choosing this attachment. Mirrors the BFL 5 kap 6 § informed-rättelse
|
||||
// intent: the human authorising the link should see what's on the doc.
|
||||
let docVendorName: string | null = null
|
||||
let docAmount: number | null = null
|
||||
let docCurrency: string | null = null
|
||||
let docInvoiceDate: string | null = null
|
||||
const { data: inbox } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('extracted_data')
|
||||
.eq('document_id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (inbox?.extracted_data) {
|
||||
const ext = inbox.extracted_data as Record<string, unknown>
|
||||
const supplier = ext.supplier as Record<string, unknown> | undefined
|
||||
const invoice = ext.invoice as Record<string, unknown> | undefined
|
||||
const totals = ext.totals as Record<string, unknown> | undefined
|
||||
docVendorName = (supplier?.name as string) || null
|
||||
docAmount = (totals?.total as number) || null
|
||||
docCurrency = (invoice?.currency as string) || null
|
||||
docInvoiceDate = (invoice?.invoiceDate as string) || null
|
||||
}
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'attach_document_to_transaction',
|
||||
`Koppla bilaga: ${doc.file_name} → ${tx.merchant_name || tx.description || transactionId}`,
|
||||
{ transaction_id: transactionId, document_id: documentId },
|
||||
{
|
||||
transaction_description: tx.merchant_name || tx.description,
|
||||
transaction_amount: tx.amount,
|
||||
transaction_currency: tx.currency,
|
||||
transaction_date: tx.date,
|
||||
document_file_name: doc.file_name,
|
||||
document_mime_type: doc.mime_type,
|
||||
document_vendor_name: docVendorName,
|
||||
document_amount: docAmount,
|
||||
document_currency: docCurrency,
|
||||
document_invoice_date: docInvoiceDate,
|
||||
will_overwrite_existing: existingDoc != null,
|
||||
existing_document_id: existingDoc?.id ?? null,
|
||||
existing_document_file_name: existingDoc?.file_name ?? null,
|
||||
existing_document_is_rakenskapsinformation: existingDoc?.journal_entry_id != null,
|
||||
},
|
||||
actor,
|
||||
undefined,
|
||||
{
|
||||
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
|
||||
dryRun: args.dry_run === true,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
// ── Payroll (Lönehantering) ──────────────────────────────────
|
||||
{
|
||||
name: 'gnubok_list_employees',
|
||||
|
||||
@@ -875,3 +875,7 @@ export const ListProposalsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
export const AttachDocumentSchema = z.object({
|
||||
document_id: uuid,
|
||||
})
|
||||
|
||||
@@ -83,6 +83,9 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
gnubok_get_inbox_item: 'transactions:read',
|
||||
gnubok_list_unmatched_documents: 'transactions:read',
|
||||
gnubok_get_document_content: 'transactions:read',
|
||||
gnubok_attach_document_to_transaction: 'transactions:write',
|
||||
// Payroll
|
||||
gnubok_list_employees: 'payroll:read',
|
||||
gnubok_get_salary_run: 'payroll:read',
|
||||
|
||||
@@ -353,3 +353,104 @@ describe('commitPendingOperation: credit_invoice', () => {
|
||||
expect(result.http_status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── attach_document_to_transaction ─────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: attach_document_to_transaction', () => {
|
||||
const baseOp: Partial<PendingOperation> = {
|
||||
operation_type: 'attach_document_to_transaction',
|
||||
params: { transaction_id: 'tx-1', document_id: 'doc-1' },
|
||||
}
|
||||
|
||||
it('auto-rejects 404 when transaction is not in the company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // tx fetch — not found
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(404)
|
||||
})
|
||||
|
||||
it('auto-rejects 409 when existing pinned doc is räkenskapsinformation', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-old', journal_entry_id: null }, error: null })
|
||||
enqueue({ data: { journal_entry_id: 'je-99' }, error: null }) // existing doc fetch — locked
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
})
|
||||
|
||||
it('translates BFL_DOCUMENT_IMMUTABILITY trigger error into auto-reject 409', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({
|
||||
data: null,
|
||||
error: {
|
||||
code: 'P0001',
|
||||
message: 'BFL_DOCUMENT_IMMUTABILITY: cannot detach or swap document …',
|
||||
},
|
||||
})
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
})
|
||||
|
||||
it('happy path uncategorized: attaches without propagation', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('committed')
|
||||
})
|
||||
|
||||
it('propagates to journal entry when tx was categorized between staging and commit', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // UPDATE returning post-state
|
||||
enqueue({ data: null, error: null }) // doc propagation update
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('committed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -856,6 +856,145 @@ async function commitUncategorizeTransaction(
|
||||
return { data: { transaction_id: txId, reversed_journal_entry_id: journalEntryId } }
|
||||
}
|
||||
|
||||
async function commitAttachDocumentToTransaction(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
const txId = params.transaction_id as string
|
||||
const documentId = params.document_id as string
|
||||
if (!txId || !documentId) {
|
||||
return { error: 'transaction_id and document_id are required', status: 400 }
|
||||
}
|
||||
|
||||
const { data: tx, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, document_id, journal_entry_id')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (txError || !tx) return { error: 'Transaction not found', status: 404 }
|
||||
|
||||
const previousDocumentId = (tx.document_id as string | null) ?? null
|
||||
|
||||
// Pre-check: if the tx already has a doc and that doc is räkenskapsinformation,
|
||||
// mirror the DELETE-route 409 instead of letting the DB trigger raise a
|
||||
// raw check_violation. Same compliance message in both places.
|
||||
if (tx.document_id && tx.document_id !== documentId) {
|
||||
const { data: existing } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('journal_entry_id')
|
||||
.eq('id', tx.document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (existing?.journal_entry_id) {
|
||||
return {
|
||||
error:
|
||||
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id')
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (docError || !doc) return { error: 'Document not found', status: 404 }
|
||||
|
||||
// Race-free read of journal_entry_id: use UPDATE ... RETURNING so the value
|
||||
// we propagate against reflects any concurrent categorize that committed
|
||||
// before our UPDATE acquired the row lock. Reading the post-update state
|
||||
// (rather than the pre-staging state) is what makes the
|
||||
// attach-then-categorize and categorize-then-attach orderings produce the
|
||||
// same final state — both end with document_attachments.journal_entry_id
|
||||
// set to the tx's journal_entry_id. (BFL 5 kap 6 § verifikation underlag.)
|
||||
const { data: postUpdate, error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ document_id: documentId })
|
||||
.eq('id', txId)
|
||||
.eq('company_id', companyId)
|
||||
.select('journal_entry_id')
|
||||
.maybeSingle()
|
||||
|
||||
if (updateError) {
|
||||
// The DB-level immutability trigger raises P0001 with a stable
|
||||
// BFL_DOCUMENT_IMMUTABILITY: prefix when the previous doc is already
|
||||
// räkenskapsinformation. Match on the prefix (not the generic SQLSTATE)
|
||||
// so unrelated future exceptions don't get translated.
|
||||
const errMsg = (updateError as { message?: string }).message ?? ''
|
||||
if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) {
|
||||
return {
|
||||
error:
|
||||
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
return { error: 'Failed to attach document', status: 500 }
|
||||
}
|
||||
if (!postUpdate) return { error: 'Transaction not found', status: 404 }
|
||||
|
||||
const journalEntryId = postUpdate.journal_entry_id as string | null
|
||||
if (journalEntryId) {
|
||||
const { error: linkErr } = await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
if (linkErr) {
|
||||
// Surface the propagation failure rather than logging-and-continuing.
|
||||
// BFL 5 kap 6 § requires the verifikation to reference its underlag, so
|
||||
// a "succeeded" attach that left document_attachments.journal_entry_id
|
||||
// null would be a silent compliance gap. Failing here marks the op
|
||||
// failed; a retry is idempotent (same documentId on tx, same propagate
|
||||
// target) and will replay the document_attachments UPDATE.
|
||||
console.error('[commitAttach] Failed to propagate to journal entry:', linkErr)
|
||||
return {
|
||||
error:
|
||||
'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen — operationen är idempotent.',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rättelse audit trail (BFL 5 kap 5 §): if we replaced a non-null doc, log
|
||||
// the swap to processing_history so the original is traceable. Best-effort —
|
||||
// a logging failure must not roll back the (compliant) attach.
|
||||
if (previousDocumentId && previousDocumentId !== documentId) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId,
|
||||
correlationId: txId,
|
||||
aggregateType: 'BankTransaction',
|
||||
aggregateId: txId,
|
||||
eventType: 'TransactionDocumentReplaced',
|
||||
payload: {
|
||||
transaction_id: txId,
|
||||
previous_document_id: previousDocumentId,
|
||||
new_document_id: documentId,
|
||||
journal_entry_id: journalEntryId,
|
||||
},
|
||||
actor: { type: 'user', id: userId },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (logErr) {
|
||||
console.error('[commitAttach] Failed to append rättelse event:', logErr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
transaction_id: txId,
|
||||
document_id: documentId,
|
||||
previous_document_id: previousDocumentId,
|
||||
journal_entry_id: journalEntryId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function commitRunYearEnd(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -1485,6 +1624,9 @@ export async function commitPendingOperation(
|
||||
case 'uncategorize_transaction':
|
||||
result = await commitUncategorizeTransaction(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'attach_document_to_transaction':
|
||||
result = await commitAttachDocumentToTransaction(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'run_year_end':
|
||||
result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
@@ -26,6 +26,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
categorize_transaction: 'medium',
|
||||
match_transaction_invoice: 'medium',
|
||||
create_invoice: 'medium', // creates as draft; sending is a separate op
|
||||
// Pinning a doc to a tx is reversible while pre-categorization, but the link
|
||||
// becomes part of the verifikation underlag (BFL 5 kap 6 §) once categorize
|
||||
// propagates it. A wrong attachment requires a rättelse, so require human
|
||||
// approval rather than auto-commit.
|
||||
attach_document_to_transaction: 'medium',
|
||||
|
||||
// ── High: irreversible, compliance-critical, or external side-effects
|
||||
send_invoice: 'high', // emails the customer
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Direct attachment of an unmatched document to a bank transaction.
|
||||
-- Lets users (and AI agents via MCP) pin a forwarded/uploaded document to a
|
||||
-- specific tx before categorization. The categorize route propagates this
|
||||
-- to document_attachments.journal_entry_id when a journal entry is created.
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
ADD COLUMN IF NOT EXISTS document_id uuid
|
||||
REFERENCES public.document_attachments(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_document_id
|
||||
ON public.transactions (document_id)
|
||||
WHERE document_id IS NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,50 @@
|
||||
-- BFL 5 kap 6 § / 7 kap 1 §: once a document has propagated to a journal
|
||||
-- entry (i.e. become räkenskapsinformation underlag), the link from the
|
||||
-- transaction to that document must not be silently broken. The application-
|
||||
-- layer guard in /api/transactions/[id]/attach-document gives the user a
|
||||
-- friendly Swedish error; this trigger is the DB-level safety net that also
|
||||
-- catches direct SQL writes, races, and the FK SET NULL path.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_transactions_document_immutability()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
old_doc_je_id uuid;
|
||||
BEGIN
|
||||
-- Only act when document_id changes.
|
||||
IF NEW.document_id IS NOT DISTINCT FROM OLD.document_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- No previously-attached document, nothing to protect.
|
||||
IF OLD.document_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- If the previously-attached document is already räkenskapsinformation
|
||||
-- (linked to a journal entry), block the change. Storno the journal entry
|
||||
-- first if you genuinely need to swap or detach.
|
||||
SELECT journal_entry_id
|
||||
INTO old_doc_je_id
|
||||
FROM public.document_attachments
|
||||
WHERE id = OLD.document_id;
|
||||
|
||||
IF old_doc_je_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'Cannot detach or swap document % from transaction %: document is linked to journal entry % and is räkenskapsinformation per BFL 5 kap 6 §. Reverse the journal entry first.',
|
||||
OLD.document_id, OLD.id, old_doc_je_id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS enforce_transactions_document_immutability ON public.transactions;
|
||||
CREATE TRIGGER enforce_transactions_document_immutability
|
||||
BEFORE UPDATE OF document_id ON public.transactions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.enforce_transactions_document_immutability();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Tighten transactions.document_id FK from ON DELETE SET NULL to RESTRICT.
|
||||
--
|
||||
-- The original definition (20260505140000) used SET NULL with the assumption
|
||||
-- that block_document_deletion would catch any deletion of a propagated
|
||||
-- document before the FK cascade fired. The compliance review flagged this as
|
||||
-- fragile: trigger ordering could let a SET NULL slip through and silently
|
||||
-- break the verifikation→underlag link (BFL 5 kap 6 §, 7 kap 1 §).
|
||||
--
|
||||
-- RESTRICT means a document with any pinned transaction cannot be deleted at
|
||||
-- all — the user must explicitly detach first (which goes through the
|
||||
-- application-layer + DB-level immutability guards). Belt-and-braces with
|
||||
-- block_document_deletion.
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
DROP CONSTRAINT IF EXISTS transactions_document_id_fkey;
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
ADD CONSTRAINT transactions_document_id_fkey
|
||||
FOREIGN KEY (document_id) REFERENCES public.document_attachments(id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Make the immutability trigger's exception distinguishable from generic
|
||||
-- check_violation (23514) failures. Compliance review noted that matching on
|
||||
-- 23514 alone could mistakenly translate an unrelated future CHECK constraint
|
||||
-- into the räkenskapsinformation message. Switch to RAISE EXCEPTION's default
|
||||
-- SQLSTATE (P0001 / raise_exception) and prefix the message with a stable
|
||||
-- application-defined tag the route handlers can match on.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_transactions_document_immutability()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
old_doc_je_id uuid;
|
||||
BEGIN
|
||||
IF NEW.document_id IS NOT DISTINCT FROM OLD.document_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.document_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT journal_entry_id
|
||||
INTO old_doc_je_id
|
||||
FROM public.document_attachments
|
||||
WHERE id = OLD.document_id;
|
||||
|
||||
IF old_doc_je_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'BFL_DOCUMENT_IMMUTABILITY: cannot detach or swap document % from transaction %: document is linked to journal entry % (BFL 5 kap 6 §).',
|
||||
OLD.document_id, OLD.id, old_doc_je_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Close the race in enforce_transactions_document_immutability:
|
||||
-- the original trigger SELECT'd document_attachments.journal_entry_id without
|
||||
-- a row lock, so a concurrent UPDATE on that row (e.g. the categorize
|
||||
-- propagation setting journal_entry_id) could commit between the trigger's
|
||||
-- SELECT and its RAISE, letting a detach through.
|
||||
--
|
||||
-- Acquire FOR SHARE on the document row inside the trigger so the trigger
|
||||
-- either sees the post-UPDATE state and raises, or runs first and the
|
||||
-- concurrent UPDATE waits on the share lock until our transaction completes.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_transactions_document_immutability()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
old_doc_je_id uuid;
|
||||
BEGIN
|
||||
IF NEW.document_id IS NOT DISTINCT FROM OLD.document_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.document_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- FOR SHARE: a concurrent UPDATE that wants to set journal_entry_id on the
|
||||
-- same row will block until our transaction commits. Either we observe the
|
||||
-- categorize propagation already-committed (and raise), or we hold the
|
||||
-- share lock and the propagation observes our committed detach (which is
|
||||
-- fine because at that point document.journal_entry_id was still null when
|
||||
-- our transaction began).
|
||||
SELECT journal_entry_id
|
||||
INTO old_doc_je_id
|
||||
FROM public.document_attachments
|
||||
WHERE id = OLD.document_id
|
||||
FOR SHARE;
|
||||
|
||||
IF old_doc_je_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'BFL_DOCUMENT_IMMUTABILITY: cannot detach or swap document % from transaction %: document is linked to journal entry % (BFL 5 kap 6 §).',
|
||||
OLD.document_id, OLD.id, old_doc_je_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- BFL 7 kap räkenskapsinformation immutability is bidirectional: once a
|
||||
-- document_attachments row's journal_entry_id has been set (the doc is now
|
||||
-- the underlag for a verifikation), nothing should be able to clear it back
|
||||
-- to NULL. The transactions-side immutability trigger added in 20260506090000
|
||||
-- prevents transactions.document_id from being detached, but a direct UPDATE
|
||||
-- on document_attachments could still null out journal_entry_id.
|
||||
--
|
||||
-- Add a parallel trigger on document_attachments that blocks UPDATE that
|
||||
-- would null out a non-null journal_entry_id. Keeping this distinct from
|
||||
-- existing triggers so block_document_deletion (DELETE-side) and this
|
||||
-- (UPDATE-side) are independent guards.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_document_journal_entry_immutability()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Only act when journal_entry_id changes.
|
||||
IF NEW.journal_entry_id IS NOT DISTINCT FROM OLD.journal_entry_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Allow first-time set (NULL → uuid).
|
||||
IF OLD.journal_entry_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Block clearing or swapping once set: BFL 5 kap 6 § requires the
|
||||
-- verifikation→underlag link to be durable.
|
||||
IF NEW.journal_entry_id IS NULL OR NEW.journal_entry_id <> OLD.journal_entry_id THEN
|
||||
RAISE EXCEPTION
|
||||
'BFL_DOCUMENT_IMMUTABILITY: cannot clear or change journal_entry_id on document % once set (BFL 5 kap 6 §). Reverse the journal entry first.',
|
||||
OLD.id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS enforce_document_journal_entry_immutability ON public.document_attachments;
|
||||
CREATE TRIGGER enforce_document_journal_entry_immutability
|
||||
BEFORE UPDATE OF journal_entry_id ON public.document_attachments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.enforce_document_journal_entry_immutability();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -190,6 +190,7 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
|
||||
merchant_name: 'ICA Maxi',
|
||||
reconciliation_method: null,
|
||||
receipt_id: null,
|
||||
document_id: null,
|
||||
import_source: null,
|
||||
reference: null,
|
||||
notes: null,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Smoke for transactions.document_id (added 20260505140000, hardened in
|
||||
* 20260506090000 and 20260506100000).
|
||||
*
|
||||
* Locks in:
|
||||
* - FK exists and points at document_attachments(id).
|
||||
* - ON DELETE RESTRICT: deleting a doc that is pinned to any tx is blocked.
|
||||
* - enforce_transactions_document_immutability trigger blocks UPDATE on
|
||||
* transactions.document_id when the previously-pinned document has
|
||||
* propagated to a journal entry (BFL 5 kap 6 §).
|
||||
*/
|
||||
|
||||
async function insertDocument(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const storagePath = `documents/${params.companyId}/test.pdf`
|
||||
const sha256 = randomUUID().replace(/-/g, '').padEnd(64, '0')
|
||||
await getPool().query(
|
||||
`INSERT INTO public.document_attachments
|
||||
(id, user_id, company_id, file_name, mime_type, file_size_bytes,
|
||||
storage_path, sha256_hash, upload_source)
|
||||
VALUES ($1, $2, $3, 'test.pdf', 'application/pdf', 1024,
|
||||
$4, $5, 'file_upload')`,
|
||||
[id, params.userId, params.companyId, storagePath, sha256],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertTransaction(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
documentId?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.transactions
|
||||
(id, user_id, company_id, date, description, amount, currency, document_id)
|
||||
VALUES ($1, $2, $3, '2026-05-01', 'Test tx', -1000, 'SEK', $4)`,
|
||||
[id, params.userId, params.companyId, params.documentId ?? null],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertJournalEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status)
|
||||
VALUES ($1, $2, $3, $4, 1, 'A', '2026-05-01', 'Test', 'manual', 'draft')`,
|
||||
[id, params.userId, params.companyId, params.fiscalPeriodId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('transactions.document_id.pg', () => {
|
||||
it('attaches a document to a transaction and reads it back', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const docId = await insertDocument({ userId, companyId })
|
||||
const txId = await insertTransaction({ userId, companyId, documentId: docId })
|
||||
|
||||
const res = await getPool().query<{ document_id: string | null }>(
|
||||
`SELECT document_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(res.rows[0]!.document_id).toBe(docId)
|
||||
})
|
||||
|
||||
it('ON DELETE RESTRICT: blocks deletion of a doc still pinned to a tx', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const docId = await insertDocument({ userId, companyId })
|
||||
await insertTransaction({ userId, companyId, documentId: docId })
|
||||
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.document_attachments WHERE id = $1`, [docId]),
|
||||
).rejects.toThrow(/violates foreign key constraint|still referenced/)
|
||||
})
|
||||
|
||||
it('detaching first then deleting the doc succeeds', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const docId = await insertDocument({ userId, companyId })
|
||||
const txId = await insertTransaction({ userId, companyId, documentId: docId })
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET document_id = NULL WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
await getPool().query(`DELETE FROM public.document_attachments WHERE id = $1`, [docId])
|
||||
|
||||
const res = await getPool().query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text as count FROM public.document_attachments WHERE id = $1`,
|
||||
[docId],
|
||||
)
|
||||
expect(res.rows[0]!.count).toBe('0')
|
||||
})
|
||||
|
||||
it('blocks UPDATE that detaches a document already linked to a journal entry', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const docId = await insertDocument({ userId, companyId })
|
||||
const txId = await insertTransaction({ userId, companyId, documentId: docId })
|
||||
const jeId = await insertJournalEntry({ userId, companyId, fiscalPeriodId })
|
||||
|
||||
// Simulate the categorize propagation: doc is now räkenskapsinformation.
|
||||
await getPool().query(
|
||||
`UPDATE public.document_attachments SET journal_entry_id = $1 WHERE id = $2`,
|
||||
[jeId, docId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.transactions SET document_id = NULL WHERE id = $1`,
|
||||
[txId],
|
||||
),
|
||||
).rejects.toThrow(/BFL_DOCUMENT_IMMUTABILITY/)
|
||||
|
||||
// And blocks swapping to a different document.
|
||||
const otherDocId = await insertDocument({ userId, companyId })
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.transactions SET document_id = $1 WHERE id = $2`,
|
||||
[otherDocId, txId],
|
||||
),
|
||||
).rejects.toThrow(/BFL_DOCUMENT_IMMUTABILITY/)
|
||||
})
|
||||
|
||||
it('allows detach when the document is not yet on a journal entry', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const docId = await insertDocument({ userId, companyId })
|
||||
const txId = await insertTransaction({ userId, companyId, documentId: docId })
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET document_id = NULL WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
|
||||
const res = await getPool().query<{ document_id: string | null }>(
|
||||
`SELECT document_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(res.rows[0]!.document_id).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -352,6 +352,10 @@ export interface Transaction {
|
||||
// Receipt link
|
||||
receipt_id: string | null
|
||||
|
||||
// Inbox/upload document pinned to this transaction (pre-categorization).
|
||||
// Propagates to document_attachments.journal_entry_id on categorize.
|
||||
document_id: string | null
|
||||
|
||||
// Reconciliation
|
||||
reconciliation_method: ReconciliationMethod | null
|
||||
|
||||
@@ -1317,6 +1321,8 @@ export type PendingOperationType =
|
||||
| 'explain_voucher_gap'
|
||||
// Stream 1 Phase 1: transaction reversal
|
||||
| 'uncategorize_transaction'
|
||||
// Document inbox: pin doc to bank transaction
|
||||
| 'attach_document_to_transaction'
|
||||
// Stream 1 Phase 1: supplier invoice lifecycle
|
||||
| 'approve_supplier_invoice'
|
||||
| 'credit_supplier_invoice'
|
||||
|
||||
Reference in New Issue
Block a user