From 0521c385d2e6d90a2ccac859ac56c6ca0de2d65d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:13:51 +0200 Subject: [PATCH] feat(transactions): underlag status badges + attach dialog; auto-expire stale pending ops (#712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transactions): per-row underlag status + attach-document dialog - New "Matcha mot underlag" dialog on /transactions (inbox pick or fresh upload), the tx→doc mirror of the Documents view's matcher - Per-row Underlag/Underlag saknas badges on booked history rows, driven by computeJeUnderlagStatus — same posted-only, exemption-aware scope as the worklist count so badge and count never disagree - attach-document route + commit dispatcher now propagate the doc onto the verifikation when the tx is already booked (BFL 5 kap 6 §), with a 409 guard for docs consumed by a different verifikation, idempotent re-attach (no same-value rewrite under period lock), and an honest 409 when the period-lock trigger blocks the propagation - Booking-dialog doc links also pin the doc to the transaction row (first linked doc wins) via the link route's new transaction_id param messages/{sv,en}.json also carries the strings for the pending-ops expiry UI that lands in the next commit. Co-Authored-By: Claude Fable 5 * feat(pending-operations): auto-expire stale staged operations after 30 days - New daily cron (02:30 UTC, vercel.json + both docker crontabs) flips >30-day-old pending ops to rejected with the dispatcher's { auto_rejected: true, reason: 'expired' } result_data shape — rows are never deleted, the table is the audit trail - /pending renders an "Utgick automatiskt" badge + detail line for these, orders terminal tabs by resolved_at so a fresh expiry sweep isn't buried, and adds a first-time-reviewer explainer - Origin labels spell out where a proposal came from (AI chat, MCP key, API, cron) instead of the raw actor_label - agent_chat actor type added to PendingOperationActorType/AuditLogEntry (DB CHECK already widened in 20260519090000) and to the agent filter - ApprovalCard notes that ignoring a proposal is safe Co-Authored-By: Claude Fable 5 * docs(mcp): surface the client telemetry marker in connect instructions Tag the connector URLs shown in ApiKeysPanel, the connect-claude doc and the gnubok-mcp README with ?client= (claude-connector / claude-code) and GNUBOK_CLIENT=claude-desktop for the npm bridge. Telemetry-only — the server already reads the param/header; this just lets us measure which Claude surface connected. The claude mcp add copy blocks quote the URL: an unquoted ? in the query string trips zsh globbing ("no matches found"). Co-Authored-By: Claude Fable 5 * review: fix stale-closure badge flip + zod-validate link route body (PR #712) - handleDocumentAttached read journal_entry_id off the render-time transactions snapshot; if the list changed while the attach dialog was open the optimistic badge flip was silently skipped. Read it off the dialog's own subject (attachDocTx) instead. - POST /api/documents/[id]/link now validates the body against the new LinkDocumentSchema (uuid-strict, all four fields) instead of a bare presence check on journal_entry_id — same canonical VALIDATION_ERROR envelope. Test fixtures switched to real UUIDs accordingly. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/pending/page.tsx | 71 +++++- app/(dashboard)/transactions/page.tsx | 138 ++++++++++- .../[id]/link/__tests__/route.test.ts | 78 +++++- app/api/documents/[id]/link/route.ts | 43 +++- .../expire/cron/__tests__/route.test.ts | 141 +++++++++++ .../pending-operations/expire/cron/route.ts | 62 +++++ app/api/pending-operations/route.ts | 8 +- .../attach-document/__tests__/route.test.ts | 115 ++++++++- .../[id]/attach-document/route.ts | 77 +++++- components/agent/ApprovalCard.tsx | 53 +++-- components/settings/ApiKeysPanel.tsx | 15 +- .../TransactionAttachDocumentDialog.tsx | 223 ++++++++++++++++++ .../TransactionAttachmentIndicator.tsx | 126 ++++++++-- .../transactions/TransactionBookingDialog.tsx | 33 ++- .../transactions/TransactionHistoryList.tsx | 46 +++- .../transactions/TransactionInboxCard.tsx | 28 ++- docker/crontab.hosted | 1 + docker/crontab.self-hosted | 1 + lib/api/schemas.ts | 7 + lib/docs/content/connect-claude.ts | 6 +- .../__tests__/executors.test.ts | 71 ++++++ lib/pending-operations/commit.ts | 33 ++- .../__tests__/underlag-status.test.ts | 66 ++++++ lib/transactions/underlag-status.ts | 43 ++++ messages/en.json | 32 ++- messages/sv.json | 32 ++- packages/gnubok-mcp/README.md | 2 +- types/index.ts | 6 +- vercel.json | 4 + 29 files changed, 1457 insertions(+), 104 deletions(-) create mode 100644 app/api/pending-operations/expire/cron/__tests__/route.test.ts create mode 100644 app/api/pending-operations/expire/cron/route.ts create mode 100644 components/transactions/TransactionAttachDocumentDialog.tsx create mode 100644 lib/transactions/__tests__/underlag-status.test.ts create mode 100644 lib/transactions/underlag-status.ts diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 71d5b82f..5d8cbc58 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -170,6 +170,44 @@ const REJECTION_CATEGORY_LABELS: Record) => string, +): string | null { + switch (op.actor_type) { + case 'agent_chat': + return t('origin_agent_chat') + // The claude.ai MCP connector mints a gnubok_sk_ key, so MCP traffic + // arrives as actor_type='api_key' with the key name as actor_label — + // keep the label so users with several integrations can tell which one + // staged the op. 'mcp_oauth' is declared but currently unreachable. + case 'mcp_oauth': + case 'api_key': + return op.actor_label + ? t('origin_mcp', { label: op.actor_label }) + : t('origin_api') + case 'cron': + return t('origin_cron') + default: + return null + } +} + +/** + * Rows the expiry cron auto-rejected (app/api/pending-operations/expire/cron). + * Strict on reason === 'expired' so commit-time auto-rejects (404/409, where + * reason is the error text) do NOT read as "expired". + */ +function isAutoExpired(op: PendingOperation): boolean { + const rd = op.result_data as { auto_rejected?: boolean; reason?: string } | null + return op.status === 'rejected' && rd?.auto_rejected === true && rd?.reason === 'expired' +} + function formatRelativeTime(dateStr: string): string { const now = new Date() const date = new Date(dateStr) @@ -734,7 +772,12 @@ export default function PendingOperationsPage() { } switch (sourceFilter) { case 'agent': - return op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron' + return ( + op.actor_type === 'api_key' || + op.actor_type === 'mcp_oauth' || + op.actor_type === 'cron' || + op.actor_type === 'agent_chat' + ) case 'high_risk': return op.risk_level === 'high' case 'all': @@ -890,6 +933,14 @@ export default function PendingOperationsPage() { + {/* First-time reviewers haven't necessarily used the AI chat that staged + these — say what the buttons do and that ignoring a proposal is safe. */} + {activeTab === 'pending' && ( +

+ {t('explainer')} {t('auto_expiry_note')} +

+ )} + {showBulkControls && bulkEligible.length > 0 && ( @@ -1066,7 +1117,7 @@ export default function PendingOperationsPage() { - {/* The actor label doubles as the deep-link into the + {/* The origin line doubles as the deep-link into the originating conversation — no separate strip needed. */} {conversationId ? ( e.stopPropagation()} > - {op.actor_label || op.actor_type} + {originLabel(op, t) ?? op.actor_label ?? op.actor_type} ) : ( - op.actor_label || op.actor_type + originLabel(op, t) ?? op.actor_label ?? op.actor_type )} @@ -1089,6 +1140,11 @@ export default function PendingOperationsPage() { {t('badge_high_risk')} )} + {isAutoExpired(op) && ( + + {t('badge_auto_expired')} + + )} {showHighRiskWarning && (

@@ -1102,6 +1158,13 @@ export default function PendingOperationsPage() { {op.rejection_reason ? ` — "${op.rejection_reason}"` : ''}

)} + {/* rejection_category is always NULL on auto-expired rows, so + this never collides with the manual-rejection line above. */} + {isAutoExpired(op) && ( +

+ {t('auto_expired_detail')} +

+ )} ) }) diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 4a9361c4..13c58c01 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -39,6 +39,7 @@ import SupplierInvoicePicker from '@/components/transactions/SupplierInvoicePick import MatchAllocationDialog from '@/components/transactions/MatchAllocationDialog' import BulkBookDialog from '@/components/transactions/BulkBookDialog' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' +import TransactionAttachDocumentDialog from '@/components/transactions/TransactionAttachDocumentDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog' @@ -59,6 +60,7 @@ import { formatCurrency, formatDate } from '@/lib/utils' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { isImportedTransaction } from '@/lib/transactions/origin' +import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status' type InvoiceWithCustomer = Invoice & { customer?: Customer } type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier } @@ -118,6 +120,18 @@ export default function TransactionsPage() { const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) const [bookingDialogTemplate, setBookingDialogTemplate] = useState(null) + // Attach-underlag dialog (tx→doc mirror of the Documents view's matcher) + const [attachDocTx, setAttachDocTx] = useState(null) + // Underlag status per booked journal_entry_id — drives the per-row + // "Underlag"/"Underlag saknas" badges in history view. + const [jeUnderlagStatus, setJeUnderlagStatus] = useState>({}) + // JE ids already requested (in-flight or done) so the enrichment effect + // never refetches on unrelated transactions-state changes. + const requestedJeIdsRef = useRef<{ companyId: string | null; ids: Set }>({ + companyId: null, + ids: new Set(), + }) + // Template picker dialog const [templatePickerOpen, setTemplatePickerOpen] = useState(false) const [templatePickerTransaction, setTemplatePickerTransaction] = useState(null) @@ -416,6 +430,83 @@ export default function TransactionsPage() { setIsLoadingMore(false) } + // Underlag-status enrichment for booked rows. Three RLS-scoped reads per + // 150-id chunk (PostgREST .in() URL-length convention, see + // lib/worklist/categories.ts): the JEs' source types, which JEs have a + // current-version document, and which are exempted via + // journal_entry_no_doc_required. Incremental — only fetches JE ids not yet + // requested, so loadMoreTransactions pages are covered without refetching. + // Soft-fails to "no badges" on error. + useEffect(() => { + if (!company) return + if (requestedJeIdsRef.current.companyId !== company.id) { + requestedJeIdsRef.current = { companyId: company.id, ids: new Set() } + setJeUnderlagStatus({}) + } + const requested = requestedJeIdsRef.current.ids + const newIds = Array.from( + new Set( + transactions + .map((tx) => tx.journal_entry_id) + .filter((id): id is string => !!id && !requested.has(id)), + ), + ) + if (newIds.length === 0) return + newIds.forEach((id) => requested.add(id)) + + const companyId = company.id + ;(async () => { + const IN_CLAUSE_CHUNK = 150 + const merged: Record = {} + for (let i = 0; i < newIds.length; i += IN_CLAUSE_CHUNK) { + const chunk = newIds.slice(i, i + IN_CLAUSE_CHUNK) + const [entriesRes, docsRes, exemptRes] = await Promise.all([ + supabase + .from('journal_entries') + .select('id, source_type') + // Same posted-only scope as countVerifikatMissingDocument: + // reversed/corrected entries fall out of the result set and the + // row renders no badge — a storno'd verifikation must never grow + // an "Underlag saknas" attach affordance. + .eq('status', 'posted') + .in('id', chunk) + .eq('company_id', companyId), + supabase + .from('document_attachments') + .select('journal_entry_id') + .in('journal_entry_id', chunk) + .eq('company_id', companyId) + .eq('is_current_version', true), + supabase + .from('journal_entry_no_doc_required') + .select('journal_entry_id') + .in('journal_entry_id', chunk) + .eq('company_id', companyId), + ]) + // Soft-fail: keep the chunks that already succeeded. + if (entriesRes.error || docsRes.error || exemptRes.error) break + const jeIdsWithDocs = new Set( + (docsRes.data ?? []).map((d) => d.journal_entry_id as string), + ) + const exemptIds = new Set( + (exemptRes.data ?? []).map((e) => e.journal_entry_id as string), + ) + Object.assign( + merged, + computeJeUnderlagStatus(entriesRes.data ?? [], jeIdsWithDocs, exemptIds), + ) + } + // The merge is an idempotent keyed write, so it stays valid across + // unrelated transactions-state changes (booking a row, deletes, + // load-more) — only a company switch invalidates it. No cleanup-based + // cancellation: that would orphan ids already marked as requested. + if (requestedJeIdsRef.current.companyId === companyId && Object.keys(merged).length > 0) { + setJeUnderlagStatus((prev) => ({ ...prev, ...merged })) + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transactions, company]) + async function fetchCategorySuggestions(txIds: string[]) { if (txIds.length === 0) return try { @@ -1435,13 +1526,24 @@ export default function TransactionsPage() { } } - function handleTransactionBooked(transactionId: string, journalEntryId: string) { + function handleTransactionBooked( + transactionId: string, + journalEntryId: string, + attachedDocumentId?: string | null, + ) { setExitingIds((prev) => new Set(prev).add(transactionId)) setTimeout(() => { setTransactions((prev) => prev.map((t) => t.id === transactionId - ? { ...t, is_business: true, journal_entry_id: journalEntryId } + ? { + ...t, + is_business: true, + journal_entry_id: journalEntryId, + // Existing pin wins — the link route only pins when the tx + // had none (document_id IS NULL guard). + document_id: t.document_id ?? attachedDocumentId ?? null, + } : t ) ) @@ -1457,6 +1559,26 @@ export default function TransactionsPage() { toast({ title: 'Bokförd' }) } + function openAttachDocumentDialog(transaction: TransactionWithInvoice) { + setAttachDocTx(transaction) + } + + function handleDocumentAttached(transactionId: string, documentId: string) { + setTransactions((prev) => + prev.map((t) => (t.id === transactionId ? { ...t, document_id: documentId } : t)) + ) + // Booked row: the attach route propagated the doc onto the verifikation, + // so flip the JE status optimistically too. Read the JE id off the + // dialog's own subject (attachDocTx), not the transactions snapshot — + // the list may have changed (load-more, booking) while the dialog was + // open, and a stale find() would silently skip the badge flip. + const jeId = + attachDocTx?.id === transactionId ? attachDocTx.journal_entry_id : null + if (jeId) { + setJeUnderlagStatus((prev) => ({ ...prev, [jeId]: 'has' })) + } + } + // Batch mode handlers function toggleBatchSelect(id: string) { setSelectedIds((prev) => { @@ -1885,6 +2007,7 @@ export default function TransactionsPage() { onOpenMatchInvoicePicker={openInvoiceMatchPicker} onOpenSplitMatch={openSplitMatchDialog} onOpenMatchVoucher={openMatchVoucherDialog} + onOpenAttachDocument={openAttachDocumentDialog} onOpenCategoryDialog={openCategoryDialog} onDelete={handleDeleteTransaction} onEditTitle={openEditTitleDialog} @@ -1909,8 +2032,10 @@ export default function TransactionsPage() { transactions={transactions} skvRows={skvRows} searchTerm={searchTerm} + jeUnderlagStatus={jeUnderlagStatus} onOpenMatchDialog={openMatchDialog} onOpenCategoryDialog={openCategoryDialog} + onOpenAttachDocument={openAttachDocumentDialog} onDelete={handleDeleteTransaction} onSkvBokfor={handleSkvBokfor} onSkvMatch={r => setSkvMatchTarget(r)} @@ -2026,6 +2151,15 @@ export default function TransactionsPage() { onBooked={handleTransactionBooked} /> + { + if (!o) setAttachDocTx(null) + }} + transaction={attachDocTx} + onAttached={handleDocumentAttached} + /> + diff --git a/app/api/documents/[id]/link/__tests__/route.test.ts b/app/api/documents/[id]/link/__tests__/route.test.ts index 993c1234..c49ef829 100644 --- a/app/api/documents/[id]/link/__tests__/route.test.ts +++ b/app/api/documents/[id]/link/__tests__/route.test.ts @@ -30,6 +30,14 @@ import { NextResponse } from 'next/server' const mockUser = { id: 'user-1', email: 'test@test.se' } +// Body fields are uuid-validated (LinkDocumentSchema) — request fixtures must +// be real UUIDs. The enqueue mock rows keep their short ids; the queued mock +// doesn't correlate request input with mocked output. +const JE_ID = '11111111-1111-4111-8111-111111111111' +const OTHER_JE_ID = '22222222-2222-4222-8222-222222222222' +const INBOX_ID = '33333333-3333-4333-8333-333333333333' +const TX_ID = '44444444-4444-4444-8444-444444444444' + beforeEach(() => { vi.clearAllMocks() reset() @@ -49,7 +57,7 @@ function makeReq(body: unknown) { describe('POST /api/documents/[id]/link', () => { it('returns 401 when not authenticated', async () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) - const res = await POST(makeReq({ journal_entry_id: 'je-1' }), createMockRouteParams({ id: 'doc-1' })) + const res = await POST(makeReq({ journal_entry_id: JE_ID }), createMockRouteParams({ id: 'doc-1' })) const { status } = await parseJsonResponse(res) expect(status).toBe(401) }) @@ -62,7 +70,7 @@ describe('POST /api/documents/[id]/link', () => { { status: 403 }, ), }) - const res = await POST(makeReq({ journal_entry_id: 'je-1' }), createMockRouteParams({ id: 'doc-1' })) + const res = await POST(makeReq({ journal_entry_id: JE_ID }), createMockRouteParams({ id: 'doc-1' })) const { status } = await parseJsonResponse(res) expect(status).toBe(403) }) @@ -73,13 +81,24 @@ describe('POST /api/documents/[id]/link', () => { expect(body.error.code).toBe('VALIDATION_ERROR') }) + it('rejects a non-uuid journal_entry_id', async () => { + const res = await POST( + makeReq({ journal_entry_id: 'not-a-uuid' }), + createMockRouteParams({ id: 'doc-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(400) + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(mockSupabase.from).not.toHaveBeenCalled() + }) + it('links the document and stamps the inbox item when inbox_item_id is given', async () => { enqueue({ data: { id: 'je-1' } }) // journal entry company check enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update enqueue({ data: null }) // inbox stamp update const res = await POST( - makeReq({ journal_entry_id: 'je-1', inbox_item_id: 'inbox-1' }), + makeReq({ journal_entry_id: JE_ID, inbox_item_id: INBOX_ID }), createMockRouteParams({ id: 'doc-1' }), ) const { status, body } = await parseJsonResponse<{ data: { id: string } }>(res) @@ -95,7 +114,7 @@ describe('POST /api/documents/[id]/link', () => { enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update const res = await POST( - makeReq({ journal_entry_id: 'je-1' }), + makeReq({ journal_entry_id: JE_ID }), createMockRouteParams({ id: 'doc-1' }), ) const { status } = await parseJsonResponse(res) @@ -104,6 +123,51 @@ describe('POST /api/documents/[id]/link', () => { expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_inbox_items') }) + it('pins the document to the transaction when transaction_id is given', async () => { + enqueue({ data: { id: 'je-1' } }) // journal entry company check + enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update + enqueue({ data: null }) // transaction pin update + + const res = await POST( + makeReq({ journal_entry_id: JE_ID, transaction_id: TX_ID }), + createMockRouteParams({ id: 'doc-1' }), + ) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(mockSupabase.from).toHaveBeenCalledWith('transactions') + }) + + it('does not touch transactions when no transaction_id is given', async () => { + enqueue({ data: { id: 'je-1' } }) // journal entry company check + enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update + + const res = await POST( + makeReq({ journal_entry_id: JE_ID }), + createMockRouteParams({ id: 'doc-1' }), + ) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions') + }) + + it('tolerates a failing transaction pin — the JE link is the primary effect', async () => { + enqueue({ data: { id: 'je-1' } }) // journal entry company check + enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1', file_name: 'x.pdf' } }) // link update + enqueue({ data: null, error: { message: 'rls denied' } }) // transaction pin fails + + const res = await POST( + makeReq({ journal_entry_id: JE_ID, transaction_id: TX_ID }), + createMockRouteParams({ id: 'doc-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(res) + + // The pin is row-level UX; its failure must not fail the (compliant) link. + expect(status).toBe(200) + expect(body.data.id).toBe('doc-1') + }) + it('maps a period-lock trigger error to PERIOD_LOCKED', async () => { enqueue({ data: { id: 'je-1' } }) // journal entry company check enqueue({ @@ -111,7 +175,7 @@ describe('POST /api/documents/[id]/link', () => { error: { message: 'new row violates ... locked/closed fiscal period' }, }) const res = await POST( - makeReq({ journal_entry_id: 'je-1', inbox_item_id: 'inbox-1' }), + makeReq({ journal_entry_id: JE_ID, inbox_item_id: INBOX_ID }), createMockRouteParams({ id: 'doc-1' }), ) const { body } = await parseJsonResponse<{ error: { code: string } }>(res) @@ -127,7 +191,7 @@ describe('POST /api/documents/[id]/link', () => { error: { message: 'document already linked to another entry' }, }) const res = await POST( - makeReq({ journal_entry_id: 'je-1' }), + makeReq({ journal_entry_id: JE_ID }), createMockRouteParams({ id: 'doc-1' }), ) const { body } = await parseJsonResponse<{ error: { code: string } }>(res) @@ -139,7 +203,7 @@ describe('POST /api/documents/[id]/link', () => { // bogus or belongs to another tenant. The document must never be updated. enqueue({ data: null }) // journal entry company check → no match const res = await POST( - makeReq({ journal_entry_id: 'je-other-company' }), + makeReq({ journal_entry_id: OTHER_JE_ID }), createMockRouteParams({ id: 'doc-1' }), ) const { body } = await parseJsonResponse<{ error: { code: string } }>(res) diff --git a/app/api/documents/[id]/link/route.ts b/app/api/documents/[id]/link/route.ts index 33f8edfb..73c61b84 100644 --- a/app/api/documents/[id]/link/route.ts +++ b/app/api/documents/[id]/link/route.ts @@ -3,13 +3,14 @@ import { ensureInitialized } from '@/lib/init' import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { LinkDocumentSchema } from '@/lib/api/schemas' ensureInitialized() /** * POST /api/documents/[id]/link — link a document to a journal entry. * - * Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string } + * Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string, transaction_id?: string } * * When `inbox_item_id` is supplied (the "choose from inbox" flow), the inbox * item is stamped with the verifikat id after a successful link so it drops out @@ -18,6 +19,14 @@ ensureInitialized() * write and happens first; the inbox stamp is operational housekeeping, so a * stamp failure is logged but does not fail the request (the doc is correctly * attached and the DB immutability trigger still blocks any double-link). + * + * When `transaction_id` is supplied (booking-flow callers that link underlag + * right after booking a bank transaction), the doc is also pinned to the + * transaction row (transactions.document_id) so the /transactions list shows + * the underlag indicator. Only set when the tx has no pin yet — first linked + * doc wins, and an existing räkenskapsinformation pin is never swapped (which + * would trip the immutability trigger). Same best-effort posture as the inbox + * stamp: a failure is logged but does not fail the request. */ export const POST = withRouteContext( 'document.link', @@ -26,14 +35,19 @@ export const POST = withRouteContext( const { supabase, companyId, log, requestId } = ctx const opLog = log.child({ documentId: id }) - const body = await request.json().catch(() => ({})) - - if (!body.journal_entry_id) { + const parsed = LinkDocumentSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { return errorResponseFromCode('VALIDATION_ERROR', opLog, { requestId, - details: { field: 'journal_entry_id', reason: 'required' }, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + reason: i.message, + })), + }, }) } + const body = parsed.data try { const document = await linkToJournalEntry( @@ -72,6 +86,25 @@ export const POST = withRouteContext( } } + if (body.transaction_id) { + const { error: pinError } = await supabase + .from('transactions') + .update({ document_id: id }) + .eq('id', body.transaction_id) + .eq('company_id', companyId!) + // Never swap an existing pin: keeps "first linked doc wins" semantics + // for multi-doc bookings and avoids the BFL immutability trigger. + .is('document_id', null) + if (pinError) { + // Non-fatal — the verifikat ↔ underlag link already succeeded; the + // pin is row-level UX on the /transactions list. + opLog.warn('transaction pin after link failed', { + transactionId: body.transaction_id, + reason: pinError.message, + }) + } + } + return NextResponse.json({ data: document }) } catch (err) { opLog.error('document link failed', err as Error, { diff --git a/app/api/pending-operations/expire/cron/__tests__/route.test.ts b/app/api/pending-operations/expire/cron/__tests__/route.test.ts new file mode 100644 index 00000000..a66ddc67 --- /dev/null +++ b/app/api/pending-operations/expire/cron/__tests__/route.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for the pending_operations expiry cron: stale (>30 days) pending + * staged operations are auto-rejected with the commit dispatcher's + * result_data shape ({ auto_rejected: true, reason: 'expired' }) so the + * /pending UI can render them as "Utgick automatiskt". + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' + +vi.mock('@/lib/auth/cron', () => ({ + verifyCronSecret: vi.fn(() => null), +})) + +interface FilterCall { + method: string + args: unknown[] +} + +interface UpdateCapture { + payload: Record | null + filters: FilterCall[] +} + +const updateCalls: UpdateCapture[] = [] +let updateResults: Array<{ data: unknown; error: unknown }> = [] + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: vi.fn(() => ({ + from: vi.fn(() => { + const capture: UpdateCapture = { payload: null, filters: [] } + updateCalls.push(capture) + const result = updateResults.shift() ?? { data: [], error: null } + const chain: Record = {} + chain.update = vi.fn((payload: Record) => { + capture.payload = payload + return chain + }) + chain.eq = vi.fn((...args: unknown[]) => { + capture.filters.push({ method: 'eq', args }) + return chain + }) + chain.lt = vi.fn((...args: unknown[]) => { + capture.filters.push({ method: 'lt', args }) + return chain + }) + chain.select = vi.fn((...args: unknown[]) => { + capture.filters.push({ method: 'select', args }) + return chain + }) + // Thenable — awaiting the builder resolves the queued result. + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve) + return chain + }), + })), +})) + +import { GET } from '../route' +import { verifyCronSecret } from '@/lib/auth/cron' +import { createServiceClient } from '@/lib/supabase/server' + +function cronRequest(): Request { + return new Request('http://localhost:3000/api/pending-operations/expire/cron') +} + +function daysAgo(iso: string): number { + return (Date.now() - new Date(iso).getTime()) / 86_400_000 +} + +beforeEach(() => { + vi.clearAllMocks() + updateCalls.length = 0 + updateResults = [] +}) + +describe('GET /api/pending-operations/expire/cron', () => { + it('flips stale pending rows to rejected with the auto-expired marker', async () => { + updateResults = [ + { data: [{ id: 'op-1', company_id: 'c-1' }, { id: 'op-2', company_id: 'c-2' }], error: null }, + ] + + const response = await GET(cronRequest()) + const json = await response.json() + + expect(json.success).toBe(true) + expect(json.expired).toBe(2) + expect(daysAgo(json.cutoff)).toBeCloseTo(30, 0) + + expect(updateCalls).toHaveLength(1) + const call = updateCalls[0] + + // The update payload: terminal rejected status + the exact result_data + // shape the commit dispatcher uses for its own auto-rejects, with the + // strict 'expired' reason the UI badge keys on. rejection_category and + // rejection_reason must NOT be set — those carry user-feedback semantics. + expect(call.payload).toBeTruthy() + expect(call.payload!.status).toBe('rejected') + expect(Number.isNaN(new Date(call.payload!.resolved_at as string).getTime())).toBe(false) + expect(call.payload!.result_data).toEqual({ auto_rejected: true, reason: 'expired' }) + expect(call.payload).not.toHaveProperty('rejection_category') + expect(call.payload).not.toHaveProperty('rejection_reason') + + // CAS on status='pending' (skips concurrently-claimed 'committing' rows) + // + the 30-day created_at cutoff. + const eq = call.filters.find((f) => f.method === 'eq')! + expect(eq.args).toEqual(['status', 'pending']) + const lt = call.filters.find((f) => f.method === 'lt')! + expect(lt.args[0]).toBe('created_at') + expect(daysAgo(lt.args[1] as string)).toBeCloseTo(30, 0) + // .select() must be chained — without it PostgREST returns no rows and + // the endpoint would permanently report expired: 0. + expect(call.filters.some((f) => f.method === 'select')).toBe(true) + }) + + it('reports zero when no rows are stale', async () => { + updateResults = [{ data: [], error: null }] + + const response = await GET(cronRequest()) + const json = await response.json() + + expect(json).toEqual({ success: true, expired: 0, cutoff: expect.any(String) }) + }) + + it('returns 401 without touching the database when cron auth fails', async () => { + vi.mocked(verifyCronSecret).mockReturnValueOnce( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + ) + + const response = await GET(cronRequest()) + + expect(response.status).toBe(401) + expect(vi.mocked(createServiceClient)).not.toHaveBeenCalled() + }) + + it('returns an error envelope when the update fails', async () => { + updateResults = [{ data: null, error: { message: 'boom', code: 'XX000' } }] + + const response = await GET(cronRequest()) + + expect(response.status).toBeGreaterThanOrEqual(500) + }) +}) diff --git a/app/api/pending-operations/expire/cron/route.ts b/app/api/pending-operations/expire/cron/route.ts new file mode 100644 index 00000000..c7532a88 --- /dev/null +++ b/app/api/pending-operations/expire/cron/route.ts @@ -0,0 +1,62 @@ +import { createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { withCronContext } from '@/lib/api/with-cron-context' +import { errorResponse } from '@/lib/errors/get-structured-error' + +/** + * GET /api/pending-operations/expire/cron — daily 02:30 UTC. + * + * Auto-rejects staged operations that have sat at status='pending' for more + * than 30 days. AI agents stage operations for human review; when the chat + * session is abandoned the proposal would otherwise linger in the worklist + * forever, asking the user to Godkänn/Avvisa something whose context they no + * longer remember. A 30-day-old proposal has lost its context regardless of + * risk level, so the sweep applies uniformly. + * + * Rows are flipped to 'rejected' (never deleted — the table is the audit + * trail) with the same result_data shape the commit dispatcher uses for its + * own auto-rejects (lib/pending-operations/commit.ts). The strict + * reason: 'expired' marker is what the /pending UI keys its + * "Utgick automatiskt" badge on. rejection_category/rejection_reason stay + * NULL — those carry user feedback semantics, and an expiry is not feedback. + * + * If you change EXPIRY_DAYS, update the user-facing copy that states the + * window: pending.auto_expiry_note + pending.auto_expired_detail in + * messages/{sv,en}.json and the static note in components/agent/ApprovalCard.tsx. + */ +const EXPIRY_DAYS = 30 + +export const GET = withCronContext('cron.pending_operations_expire', async (_request, ctx) => { + const supabase = createServiceClient() + + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - EXPIRY_DAYS) + + // CAS on status='pending': rows a concurrent commit has claimed (status + // 'committing') or already resolved are skipped; the status-immutability + // trigger never fires because OLD.status is always 'pending' here. + // result_data is NULL on pending rows, so plain assignment is the merge. + const { data, error } = await supabase + .from('pending_operations') + .update({ + status: 'rejected', + resolved_at: new Date().toISOString(), + result_data: { auto_rejected: true, reason: 'expired' }, + }) + .eq('status', 'pending') + .lt('created_at', cutoff.toISOString()) + .select('id, company_id') + + if (error) { + ctx.log.error('pending operations expiry failed', error) + return errorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + const expired = data?.length ?? 0 + ctx.log.info('pending operations expiry summary', { + expired, + cutoff: cutoff.toISOString(), + }) + + return NextResponse.json({ success: true, expired, cutoff: cutoff.toISOString() }) +}) diff --git a/app/api/pending-operations/route.ts b/app/api/pending-operations/route.ts index 6a250242..01aef487 100644 --- a/app/api/pending-operations/route.ts +++ b/app/api/pending-operations/route.ts @@ -23,12 +23,18 @@ export async function GET(request: Request) { if (!result.success) return result.response const { status, limit, offset } = result.data + // Terminal tabs (Godkända/Avvisade) order by when the op was RESOLVED, not + // created — auto-expired ops are ≥30 days old by construction, so a + // created_at ordering would bury a fresh expiry sweep below a month of + // newer rejections and the "Utgick automatiskt" context would never be seen. + const orderColumn = status === 'pending' ? 'created_at' : 'resolved_at' + const { data, error, count } = await supabase .from('pending_operations') .select('*', { count: 'exact' }) .eq('company_id', companyId) .eq('status', status) - .order('created_at', { ascending: false }) + .order(orderColumn, { ascending: false, nullsFirst: false }) .range(offset, offset + limit - 1) if (error) { diff --git a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts index 1bff87ea..2254bdf6 100644 --- a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts +++ b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts @@ -80,26 +80,121 @@ describe('POST /api/transactions/[id]/attach-document', () => { }) 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 }) // transactions update + enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: null }) // inbox-link best-effort 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) + const { status, body } = await parseJsonResponse<{ data: { transaction_id: string; document_id: string; journal_entry_id: string | null } }>(res) expect(status).toBe(200) expect(body.data.transaction_id).toBe('tx-1') expect(body.data.document_id).toBe('11111111-1111-4111-8111-111111111111') + expect(body.data.journal_entry_id).toBeNull() + // Unbooked tx — document_attachments is only read (doc fetch), never + // written: no journal entry to propagate to. + const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0]) + expect(fromCalls.filter((t) => t === 'document_attachments')).toHaveLength(1) + }) + + it('propagates the link onto the verifikation when the transaction is booked', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING) + enqueue({ data: null, error: null }) // inbox-link best-effort update + enqueue({ data: null, error: null }) // document_attachments propagation + const res = await POST( + makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { journal_entry_id: string } }>(res) + expect(status).toBe(200) + expect(body.data.journal_entry_id).toBe('je-1') + // doc fetch + propagation write + const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0]) + expect(fromCalls.filter((t) => t === 'document_attachments')).toHaveLength(2) + }) + + it('skips propagation when the doc already points at the same verifikation (idempotent re-attach)', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING) + enqueue({ data: null, error: null }) // inbox-link best-effort update + const res = await POST( + makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status } = await parseJsonResponse(res) + expect(status).toBe(200) + // No propagation write — only the doc fetch touched document_attachments. + const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0]) + expect(fromCalls.filter((t) => t === 'document_attachments')).toHaveLength(1) + }) + + it('returns 409 when the document already belongs to a different verifikation', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, 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<{ error: string }>(res) + expect(status).toBe(409) + expect(body.error).toContain('annan verifikation') + }) + + it('returns 409 when the verifikation period is locked during propagation', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING) + enqueue({ data: null, error: null }) // inbox-link best-effort update + enqueue({ data: null, error: { message: 'cannot link document in a locked/closed fiscal period' } }) // propagation blocked + const res = await POST( + makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(409) + expect(body.error).toContain('låst') + }) + + it('returns 500 with the idempotent-retry message when propagation fails', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING) + enqueue({ data: null, error: null }) // inbox-link best-effort update + enqueue({ data: null, error: { message: 'boom' } }) // propagation fails + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const res = await POST( + makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(500) + expect(body.error).toContain('idempotent') + spy.mockRestore() + }) + + it('returns 404 when the update matches no row (concurrent delete)', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: null, error: null }) // transactions update returns no row + const res = await POST( + makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status } = await parseJsonResponse(res) + expect(status).toBe(404) }) it('attempts to update invoice_inbox_items.matched_transaction_id after successful attach', async () => { // The side effect lets the inbox UI flip an item from "needs action" to // "Kopplad till transaktion" without an extra round-trip. - enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch - enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch - enqueue({ data: null, error: null }) // transactions update + enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: null }) // inbox-link update await POST( @@ -112,9 +207,9 @@ describe('POST /api/transactions/[id]/attach-document', () => { }) it('tolerates a failing inbox-link update — the document attach is the primary effect', async () => { - enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch - enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch - enqueue({ data: null, error: null }) // transactions update + enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch + enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch + enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING) enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/app/api/transactions/[id]/attach-document/route.ts b/app/api/transactions/[id]/attach-document/route.ts index 1bd75d51..64f892e9 100644 --- a/app/api/transactions/[id]/attach-document/route.ts +++ b/app/api/transactions/[id]/attach-document/route.ts @@ -16,6 +16,8 @@ ensureInitialized() * (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. + * If the transaction is ALREADY booked, the propagation happens here instead + * (mirroring commitAttachDocumentToTransaction in lib/pending-operations/commit.ts). * * Idempotent — overwrites any existing link. */ @@ -40,7 +42,7 @@ export async function POST( const { data: transaction, error: txError } = await supabase .from('transactions') - .select('id, document_id') + .select('id, document_id, journal_entry_id') .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() @@ -53,7 +55,7 @@ export async function POST( const { data: document, error: docError } = await supabase .from('document_attachments') - .select('id') + .select('id, journal_entry_id') .eq('id', document_id) .eq('company_id', companyId) .maybeSingle() @@ -62,11 +64,29 @@ export async function POST( return NextResponse.json({ error: 'Document not found' }, { status: 404 }) } - const { error: updateError } = await supabase + // A document that already serves as underlag for a DIFFERENT verifikation + // cannot be pinned here — propagating would either corrupt that link or be + // blocked by the document-metadata immutability trigger. Same verifikation + // is fine (idempotent re-attach; propagation below becomes a no-op). + const docJournalEntryId = (document.journal_entry_id as string | null) ?? null + if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) { + return NextResponse.json( + { error: 'Underlaget är redan kopplat till en annan verifikation.' }, + { status: 409 }, + ) + } + + // Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we + // propagate against reflects any concurrent categorize that committed before + // our UPDATE acquired the row lock. Mirrors commitAttachDocumentToTransaction + // in lib/pending-operations/commit.ts so REST and MCP attaches converge. + const { data: postUpdate, error: updateError } = await supabase .from('transactions') .update({ document_id }) .eq('id', transactionId) .eq('company_id', companyId) + .select('journal_entry_id') + .maybeSingle() if (updateError) { const errMsg = (updateError as { message?: string }).message ?? '' @@ -82,6 +102,9 @@ export async function POST( console.error('[attach-document] Failed to attach:', updateError) return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 }) } + if (!postUpdate) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } // If this document came from an invoice_inbox_items row, mark that row // as matched so the inbox UI can show it as "Kopplad" + link back to the @@ -101,6 +124,46 @@ export async function POST( console.error('[attach-document] Failed to link inbox item:', inboxLinkErr) } + // If the transaction is already booked, propagate the link onto the + // verifikation immediately (BFL 5 kap 6 § — the verifikation must reference + // its underlag). Skipped when the doc already points at this verifikation + // (idempotent re-attach). Mirrors commitAttachDocumentToTransaction. + const journalEntryId = (postUpdate.journal_entry_id as string | null) ?? null + if (journalEntryId && docJournalEntryId !== journalEntryId) { + const { error: linkErr } = await supabase + .from('document_attachments') + .update({ journal_entry_id: journalEntryId }) + .eq('id', document_id) + .eq('company_id', companyId) + if (linkErr) { + // The enforce_period_lock trigger blocks journal_entry_id writes when + // the target entry sits in a closed/locked period. + const linkMsg = (linkErr as { message?: string }).message ?? '' + if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) { + // Honest about the partial write: the pin on the transaction (and the + // inbox back-link) persisted; only the verifikat link was blocked. + return NextResponse.json( + { + error: + 'Bilagan kopplades till transaktionen men verifikationens period är låst — den kunde inte länkas till verifikationen.', + }, + { status: 409 }, + ) + } + // Surface the propagation failure rather than logging-and-continuing — + // a "succeeded" attach that left document_attachments.journal_entry_id + // null would be a silent compliance gap. A retry is idempotent. + console.error('[attach-document] Failed to propagate to journal entry:', linkErr) + return NextResponse.json( + { + 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 §): record swaps where a non-null doc // was replaced. Best-effort — a logging failure must not roll back the // (compliant) attach. @@ -116,6 +179,7 @@ export async function POST( transaction_id: transactionId, previous_document_id: previousDocumentId, new_document_id: document_id, + journal_entry_id: journalEntryId, }, actor: { type: 'user', id: user.id }, occurredAt: new Date(), @@ -126,7 +190,12 @@ export async function POST( } return NextResponse.json({ - data: { transaction_id: transactionId, document_id, previous_document_id: previousDocumentId }, + data: { + transaction_id: transactionId, + document_id, + previous_document_id: previousDocumentId, + journal_entry_id: journalEntryId, + }, }) } diff --git a/components/agent/ApprovalCard.tsx b/components/agent/ApprovalCard.tsx index f1b45ac3..c74e53fb 100644 --- a/components/agent/ApprovalCard.tsx +++ b/components/agent/ApprovalCard.tsx @@ -396,29 +396,36 @@ export default function ApprovalCard({ ) : ( -
- - -
+ <> +
+ + +
+ {/* Keep in sync with EXPIRY_DAYS in + app/api/pending-operations/expire/cron/route.ts. */} +

+ Om du inte gör något utgår förslaget automatiskt efter 30 dagar — inget bokförs. +

+ )} ) diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index a2805162..3063ff88 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -360,9 +360,12 @@ export function ApiKeysPanel() { }) } - const mcpUrl = typeof window !== 'undefined' + const mcpBase = typeof window !== 'undefined' ? `${window.location.origin}/api/extensions/ext/mcp-server/mcp` : '/api/extensions/ext/mcp-server/mcp' + // Telemetry-only distribution-channel marker (server reads the `client` query + // param; never used for auth). Lets us measure which Claude surface connected. + const mcpUrl = (client: string) => `${mcpBase}?client=${client}` return (
@@ -465,7 +468,7 @@ export function ApiKeysPanel() { path: (chunks) => {chunks}, })}

- +
@@ -473,7 +476,8 @@ export function ApiKeysPanel() {

{t('terminal_runs_browser_login')}

- + {/* URL is quoted — unquoted `?` in the query string trips zsh globbing. */} +
@@ -500,7 +504,8 @@ export function ApiKeysPanel() { "command": "npx", "args": ["gnubok-mcp"], "env": { - "GNUBOK_API_KEY": "gnubok_sk_..." + "GNUBOK_API_KEY": "gnubok_sk_...", + "GNUBOK_CLIENT": "claude-desktop" } } } @@ -513,7 +518,7 @@ export function ApiKeysPanel() { {t('terminal_with_api_key')}

diff --git a/components/transactions/TransactionAttachDocumentDialog.tsx b/components/transactions/TransactionAttachDocumentDialog.tsx new file mode 100644 index 00000000..bfafd9cb --- /dev/null +++ b/components/transactions/TransactionAttachDocumentDialog.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { formatCurrency, formatDate } from '@/lib/utils' +import { ArrowUpRight, ArrowDownRight, FileText, Inbox, Loader2, X } from 'lucide-react' +import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' +import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' +import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker' +import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker' +import type { TransactionWithInvoice } from './transaction-types' + +interface TransactionAttachDocumentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + transaction: TransactionWithInvoice | null + onAttached: (transactionId: string, documentId: string) => void +} + +/** + * Standalone "Matcha mot underlag" dialog for the /transactions view — the + * mirror of the Documents view's TransactionMatchPicker (doc → tx direction). + * Pick an unconsumed inbox document or upload a new file, then pin it to the + * transaction via POST /api/transactions/[id]/attach-document. The pin is + * single-valued (transactions.document_id); for booked rows the route + * propagates the link onto the verifikation immediately. + */ +export default function TransactionAttachDocumentDialog({ + open, + onOpenChange, + transaction, + onAttached, +}: TransactionAttachDocumentDialogProps) { + const t = useTranslations('tx_attach_dialog') + const { toast } = useToast() + const [uploadedFiles, setUploadedFiles] = useState([]) + const [pickedDoc, setPickedDoc] = useState(null) + const [inboxPickerOpen, setInboxPickerOpen] = useState(false) + const [isAttaching, setIsAttaching] = useState(false) + + if (!transaction) return null + + const isIncome = transaction.amount > 0 + + // Single selection — transactions.document_id pins exactly one doc, so an + // inbox pick replaces any upload and vice versa. + const selectedDocumentId = + pickedDoc?.document_id ?? + uploadedFiles.find((f) => f.status === 'uploaded' && f.id)?.id ?? + null + + const reset = () => { + setUploadedFiles([]) + setPickedDoc(null) + setInboxPickerOpen(false) + } + + const handleAttach = async () => { + if (!selectedDocumentId || isAttaching) return + setIsAttaching(true) + try { + const res = await fetch(`/api/transactions/${transaction.id}/attach-document`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document_id: selectedDocumentId }), + }) + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { error?: unknown } + // The route returns Swedish domain messages (immutability, locked + // period) as a plain string — surface them verbatim. + toast({ + title: t('error_toast'), + description: typeof json.error === 'string' ? json.error : undefined, + variant: 'destructive', + }) + return + } + toast({ title: t('success_toast') }) + // Same event AgentChat and the booking dialog dispatch — flips the inbox + // card's indicator optimistically without a refetch. + window.dispatchEvent( + new CustomEvent('Accounted:transaction-document-linked', { + detail: { transaction_id: transaction.id, document_id: selectedDocumentId }, + }), + ) + onAttached(transaction.id, selectedDocumentId) + reset() + onOpenChange(false) + } catch { + // Network-level failure — fetch rejected before a response existed. + toast({ title: t('error_toast'), variant: 'destructive' }) + } finally { + setIsAttaching(false) + } + } + + return ( + { + if (!o) reset() + onOpenChange(o) + }} + > + + + {t('title')} + {t('description')} + + + {/* Transaction summary — same block as TransactionBookingDialog */} +
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+
+ + {transaction.document_id && ( +

{t('already_attached_hint')}

+ )} + +
+ { + setUploadedFiles(files) + if (files.length > 0) setPickedDoc(null) + }} + maxFiles={1} + compact + disabled={isAttaching} + /> + {pickedDoc && ( +
+ + + {pickedDoc.supplier_name ?? pickedDoc.file_name} + + {pickedDoc.amount != null && ( + + {formatCurrency(pickedDoc.amount, pickedDoc.currency ?? 'SEK')} + + )} + +
+ )} + +
+ + + + + + + setInboxPickerOpen(false)} + onSelect={(doc) => { + setPickedDoc(doc) + setUploadedFiles([]) + }} + /> +
+
+ ) +} diff --git a/components/transactions/TransactionAttachmentIndicator.tsx b/components/transactions/TransactionAttachmentIndicator.tsx index f8d6470b..b02ba5ca 100644 --- a/components/transactions/TransactionAttachmentIndicator.tsx +++ b/components/transactions/TransactionAttachmentIndicator.tsx @@ -1,36 +1,64 @@ 'use client' import { useState } from 'react' +import { useTranslations } from 'next-intl' +import Link from 'next/link' import { Paperclip, Loader2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' import { cn } from '@/lib/utils' import { useToast } from '@/components/ui/use-toast' interface Props { documentId: string | null | undefined + /** The booked tx's journal entry — link target when the underlag lives only + * at verifikat level (multi-doc entries, booking-dialog uploads). */ + journalEntryId?: string | null + /** Underlag exists on the journal entry even though no doc is pinned to the + * transaction row (computeJeUnderlagStatus === 'has'). */ + hasJeDoc?: boolean + /** Booked, requires underlag, has none (computeJeUnderlagStatus === 'missing'). */ + missing?: boolean + /** Opens the attach dialog from the negative state. Omit for viewers — + * the badge then renders non-interactive. */ + onAttach?: () => void className?: string } +const badgeClass = 'h-4 gap-1 px-1.5 py-0 text-[10px] font-normal' +// Enlarges the hit area beyond the 16px badge without shifting layout. +const hitAreaClass = 'shrink-0 p-1 -m-1' + /** - * Compact "this transaction has an attached document" indicator. - * Clicking fetches a signed download URL and opens the document in a new tab — - * lets the user verify the attached receipt without first having to book the - * transaction (which is when the doc gets linked to a journal entry). + * Per-row underlag status for the /transactions lists. + * + * - Pinned doc (transactions.document_id): clickable badge that fetches a + * signed URL and opens the document in a new tab. + * - Verifikat-level doc only: same badge, links to the verifikat page (which + * lists all attachments — handles multi-doc without a per-row fetch). + * - Missing on a booked row: discreet outline badge that doubles as the + * attach affordance when onAttach is provided. */ -export function TransactionAttachmentIndicator({ documentId, className }: Props) { +export function TransactionAttachmentIndicator({ + documentId, + journalEntryId, + hasJeDoc, + missing, + onAttach, + className, +}: Props) { + const t = useTranslations('tx_underlag') const { toast } = useToast() const [isLoading, setIsLoading] = useState(false) - if (!documentId) return null - const handleOpen = async (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() - if (isLoading) return + if (isLoading || !documentId) return setIsLoading(true) try { const res = await fetch(`/api/documents/${documentId}`) if (!res.ok) { - toast({ title: 'Kunde inte hämta underlaget', variant: 'destructive' }) + toast({ title: t('open_failed'), variant: 'destructive' }) return } const { data } = await res.json() @@ -42,22 +70,68 @@ export function TransactionAttachmentIndicator({ documentId, className }: Props) } } - return ( - + ) + } + + if (hasJeDoc && journalEntryId) { + return ( + e.stopPropagation()} + className={cn(hitAreaClass, className)} + > + + + {t('attached_label')} + + + ) + } + + if (missing) { + const badge = ( + - )} - - ) + {t('missing_label')} + + ) + if (!onAttach) return {badge} + return ( + + ) + } + + return null } diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index cf7b1a4b..4a4b88ca 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -23,7 +23,11 @@ interface TransactionBookingDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null - onBooked: (transactionId: string, journalEntryId: string) => void + onBooked: ( + transactionId: string, + journalEntryId: string, + attachedDocumentId?: string | null, + ) => void preselectedTemplate?: BookingTemplateLibrary | null } @@ -116,16 +120,23 @@ export default function TransactionBookingDialog({ // files, and existing inbox documents picked via InboxDocumentPicker. For // picked docs, inbox_item_id stamps the inbox item as consumed so it drops // out of the active inbox — see app/api/documents/[id]/link/route.ts. + // transaction_id additionally pins the doc to the transaction row so the + // /transactions list shows the underlag indicator (first linked doc wins). const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) let linkFailCount = 0 + let firstLinkedDocId: string | null = null for (const file of filesToLink) { try { const res = await fetch(`/api/documents/${file.id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ journal_entry_id: journalEntryId }), + body: JSON.stringify({ + journal_entry_id: journalEntryId, + transaction_id: transactionId, + }), }) if (!res.ok) linkFailCount++ + else firstLinkedDocId ??= file.id ?? null } catch { linkFailCount++ } @@ -138,9 +149,11 @@ export default function TransactionBookingDialog({ body: JSON.stringify({ journal_entry_id: journalEntryId, inbox_item_id: doc.inbox_item_id, + transaction_id: transactionId, }), }) if (!res.ok) linkFailCount++ + else firstLinkedDocId ??= doc.document_id } catch { linkFailCount++ } @@ -153,10 +166,24 @@ export default function TransactionBookingDialog({ }) } + // The server pins only when the tx has no document_id yet (first linked + // doc wins) — mirror that here so the optimistic state never claims a + // pin the server refused to swap. + const pinnedDocId = transaction.document_id ? null : firstLinkedDocId + if (pinnedDocId) { + // Same event AgentChat dispatches after uploads — flips the inbox card's + // paperclip optimistically without a refetch. + window.dispatchEvent( + new CustomEvent('Accounted:transaction-document-linked', { + detail: { transaction_id: transactionId, document_id: pinnedDocId }, + }), + ) + } + setUploadedFiles([]) setPickedInboxDocs([]) setShowUploadZone(false) - onBooked(transactionId, journalEntryId) + onBooked(transactionId, journalEntryId, pinnedDocId) } const attachedCount = diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx index e00b8281..2458105d 100644 --- a/components/transactions/TransactionHistoryList.tsx +++ b/components/transactions/TransactionHistoryList.tsx @@ -38,11 +38,13 @@ import { FileText, Loader2, MoreHorizontal, + Paperclip, Trash2, } from 'lucide-react' import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator' import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance' import { useCanWrite } from '@/lib/hooks/use-can-write' +import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status' import type { TransactionWithInvoice, HistoryFilter } from './transaction-types' import type { SkattekontoTransactionWithSuggestion, @@ -59,8 +61,13 @@ interface TransactionHistoryListProps { transactions: TransactionWithInvoice[] skvRows?: SkattekontoTransactionWithSuggestion[] searchTerm?: string + /** Underlag status per journal_entry_id (computeJeUnderlagStatus) — drives + * the per-row "Underlag"/"Underlag saknas" badges on booked rows. */ + jeUnderlagStatus?: Record onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void + /** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */ + onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void onSkvBokfor?: (row: StoredSkattekontoTransaction) => void onSkvMatch?: (row: StoredSkattekontoTransaction) => void @@ -73,8 +80,10 @@ export default function TransactionHistoryList({ transactions, skvRows = [], searchTerm = '', + jeUnderlagStatus, onOpenMatchDialog, onOpenCategoryDialog, + onOpenAttachDocument, onDelete, onSkvBokfor, onSkvMatch, @@ -177,8 +186,10 @@ export default function TransactionHistoryList({ ) : ( @@ -213,13 +224,17 @@ export default function TransactionHistoryList({ function BankHistoryRow({ transaction, + jeUnderlagStatus, onOpenMatchDialog, onOpenCategoryDialog, + onOpenAttachDocument, onDelete, }: { transaction: TransactionWithInvoice + jeUnderlagStatus?: Record onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void + onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void onDelete?: (id: string) => void }) { const t = useTranslations('tx_history') @@ -237,6 +252,15 @@ function BankHistoryRow({ const hasInvoiceMatch = !isLinkedToInvoice && !!transaction.potential_invoice && !isBooked + // Underlag status — see computeJeUnderlagStatus. Unknown/not-yet-loaded JE + // renders neither badge (no false "saknas" flash while the enrichment loads). + const jeStatus = transaction.journal_entry_id + ? jeUnderlagStatus?.[transaction.journal_entry_id] + : undefined + const hasJeDoc = jeStatus === 'has' + const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing' + const showAttachItem = canWrite && !!onOpenAttachDocument + // Primary status badge — pick the most informative one. const statusBadge = (() => { if (isBooked) { @@ -319,7 +343,7 @@ function BankHistoryRow({ )} - {(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite)) && ( + {(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite) || showAttachItem) && (