diff --git a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts new file mode 100644 index 00000000..95a1cb14 --- /dev/null +++ b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/transactions/[id]/attach-document/route.ts b/app/api/transactions/[id]/attach-document/route.ts new file mode 100644 index 00000000..b7b5a02a --- /dev/null +++ b/app/api/transactions/[id]/attach-document/route.ts @@ -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 } }) +} diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 5c75fef1..ee1029e5 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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, }) } diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 4130a50b..401abc1d 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -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) { handleDelete(selected.id)} + onAttach={() => setAttachOpen(true)} isDeleting={isDeleting} /> ) : ( @@ -419,10 +429,181 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { + + {selected && ( + { + setAttachOpen(false) + await fetchItems() + toast({ title: 'Bilaga kopplad till transaktion' }) + }} + /> + )} ) } +// ── 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 +}) { + const { toast } = useToast() + const [transactions, setTransactions] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [attachingId, setAttachingId] = useState(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 ( + + + + Koppla bilaga till transaktion + + {targetAmount != null + ? `Belopp på fakturan: ${formatCurrency(targetAmount, pickCurrency(item))}. Listan är sorterad efter beloppsmatch.` + : 'Välj en transaktion att koppla bilagan till.'} + + +
+ {isLoading ? ( +
+ Laddar transaktioner… +
+ ) : transactions.length === 0 ? ( +

+ Inga okategoriserade transaktioner hittades. +

+ ) : ( +
    + {transactions.map((tx) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ) +} + +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({ ) : ( - - - + + + + )}