feat(transactions): underlag status badges + attach dialog; auto-expire stale pending ops (#712)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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=<surface> (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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-11 11:13:51 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e978136210
commit 0521c385d2
29 changed files with 1457 additions and 104 deletions
+67 -4
View File
@@ -170,6 +170,44 @@ const REJECTION_CATEGORY_LABELS: Record<PendingOperationRejectionCategory, strin
other: 'Annat',
}
/**
* Human origin line for a staged operation. Many reviewers never used the AI
* chat themselves (a colleague or consultant did), so the raw actor_label is
* not enough context — spell out where the proposal came from.
*/
function originLabel(
op: PendingOperation,
t: (key: string, values?: Record<string, string>) => 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() {
</DropdownMenu>
</div>
{/* 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' && (
<p className="text-xs text-muted-foreground">
{t('explainer')} {t('auto_expiry_note')}
</p>
)}
<DataList>
{showBulkControls && bulkEligible.length > 0 && (
<DataListHeader>
@@ -1066,7 +1117,7 @@ export default function PendingOperationsPage() {
<DataListMetaSeparator />
<span className="inline-flex items-center gap-1">
<Bot className="h-3 w-3" />
{/* 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 ? (
<a
@@ -1074,10 +1125,10 @@ export default function PendingOperationsPage() {
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{op.actor_label || op.actor_type}
{originLabel(op, t) ?? op.actor_label ?? op.actor_type}
</a>
) : (
op.actor_label || op.actor_type
originLabel(op, t) ?? op.actor_label ?? op.actor_type
)}
</span>
</>
@@ -1089,6 +1140,11 @@ export default function PendingOperationsPage() {
{t('badge_high_risk')}
</Badge>
)}
{isAutoExpired(op) && (
<Badge variant="secondary" className="ml-1 h-4 px-1.5 py-0 text-[10px]">
{t('badge_auto_expired')}
</Badge>
)}
</DataListMeta>
{showHighRiskWarning && (
<p className="mt-1 flex items-start gap-1 text-xs text-destructive">
@@ -1102,6 +1158,13 @@ export default function PendingOperationsPage() {
{op.rejection_reason ? ` — "${op.rejection_reason}"` : ''}
</p>
)}
{/* rejection_category is always NULL on auto-expired rows, so
this never collides with the manual-rejection line above. */}
{isAutoExpired(op) && (
<p className="mt-1 text-xs text-muted-foreground">
{t('auto_expired_detail')}
</p>
)}
</DataListRow>
)
})
+136 -2
View File
@@ -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<TransactionWithInvoice | null>(null)
const [bookingDialogTemplate, setBookingDialogTemplate] = useState<BookingTemplateLibrary | null>(null)
// Attach-underlag dialog (tx→doc mirror of the Documents view's matcher)
const [attachDocTx, setAttachDocTx] = useState<TransactionWithInvoice | null>(null)
// Underlag status per booked journal_entry_id — drives the per-row
// "Underlag"/"Underlag saknas" badges in history view.
const [jeUnderlagStatus, setJeUnderlagStatus] = useState<Record<string, JeUnderlagStatus>>({})
// 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<string> }>({
companyId: null,
ids: new Set(),
})
// Template picker dialog
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [templatePickerTransaction, setTemplatePickerTransaction] = useState<TransactionWithInvoice | null>(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<string, JeUnderlagStatus> = {}
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}
/>
<TransactionAttachDocumentDialog
open={attachDocTx !== null}
onOpenChange={(o) => {
if (!o) setAttachDocTx(null)
}}
transaction={attachDocTx}
onAttached={handleDocumentAttached}
/>
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
@@ -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)
+38 -5
View File
@@ -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, {
@@ -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<string, unknown> | 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<string, unknown> = {}
chain.update = vi.fn((payload: Record<string, unknown>) => {
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)
})
})
@@ -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() })
})
+7 -1
View File
@@ -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) {
@@ -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(() => {})
@@ -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,
},
})
}
+30 -23
View File
@@ -396,29 +396,36 @@ export default function ApprovalCard({
</div>
</div>
) : (
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCommit}
disabled={isBusy || !canCommit}
className="flex-1"
>
{state === 'committing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Godkänn'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRejectForm(true)}
disabled={isBusy}
className="flex-1"
>
Avslå
</Button>
</div>
<>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCommit}
disabled={isBusy || !canCommit}
className="flex-1"
>
{state === 'committing' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Godkänn'
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRejectForm(true)}
disabled={isBusy}
className="flex-1"
>
Avslå
</Button>
</div>
{/* Keep in sync with EXPIRY_DAYS in
app/api/pending-operations/expire/cron/route.ts. */}
<p className="text-[11px] text-muted-foreground">
Om du inte gör något utgår förslaget automatiskt efter 30 dagar — inget bokförs.
</p>
</>
)}
</div>
)
+10 -5
View File
@@ -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 (
<div className="space-y-6">
@@ -465,7 +468,7 @@ export function ApiKeysPanel() {
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl} copyAriaLabel={t('copy_aria')} />
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div>
<div>
@@ -473,7 +476,8 @@ export function ApiKeysPanel() {
<p className="text-xs text-muted-foreground mb-2">
{t('terminal_runs_browser_login')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} copyAriaLabel={t('copy_aria')} />
{/* URL is quoted — unquoted `?` in the query string trips zsh globbing. */}
<CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
</div>
<div className="border-t pt-4">
@@ -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')}
</p>
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
--url ${mcpUrl} \\
--url "${mcpUrl('claude-code')}" \\
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
</div>
</div>
@@ -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<UploadedFile[]>([])
const [pickedDoc, setPickedDoc] = useState<AvailableInboxDoc | null>(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 (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{/* Transaction summary — same block as TransactionBookingDialog */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
{transaction.document_id && (
<p className="text-xs text-muted-foreground">{t('already_attached_hint')}</p>
)}
<div className="space-y-2">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={(files) => {
setUploadedFiles(files)
if (files.length > 0) setPickedDoc(null)
}}
maxFiles={1}
compact
disabled={isAttaching}
/>
{pickedDoc && (
<div className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">
{pickedDoc.supplier_name ?? pickedDoc.file_name}
</span>
{pickedDoc.amount != null && (
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{formatCurrency(pickedDoc.amount, pickedDoc.currency ?? 'SEK')}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
aria-label={t('selected_remove')}
onClick={() => setPickedDoc(null)}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={isAttaching}
onClick={() => setInboxPickerOpen(true)}
>
<Inbox className="h-4 w-4 mr-2" />
{t('pick_existing')}
</Button>
</div>
<DialogFooter>
<Button variant="outline" disabled={isAttaching} onClick={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button disabled={!selectedDocumentId || isAttaching} onClick={handleAttach}>
{isAttaching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('attaching')}
</>
) : (
t('confirm')
)}
</Button>
</DialogFooter>
<InboxDocumentPicker
open={inboxPickerOpen}
onClose={() => setInboxPickerOpen(false)}
onSelect={(doc) => {
setPickedDoc(doc)
setUploadedFiles([])
}}
/>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<button
type="button"
onClick={handleOpen}
title="Underlag bifogat — klicka för att öppna"
aria-label="Öppna bifogat underlag"
className={cn(
'inline-flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors shrink-0',
className
)}
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
if (documentId) {
return (
<button
type="button"
onClick={handleOpen}
title={t('attached_title')}
aria-label={t('attached_aria')}
className={cn(hitAreaClass, className)}
>
<Badge variant="secondary" className={badgeClass}>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Paperclip className="h-3 w-3" />
)}
{t('attached_label')}
</Badge>
</button>
)
}
if (hasJeDoc && journalEntryId) {
return (
<Link
href={`/bookkeeping/${journalEntryId}`}
title={t('attached_title')}
aria-label={t('attached_aria')}
onClick={(e) => e.stopPropagation()}
className={cn(hitAreaClass, className)}
>
<Badge variant="secondary" className={badgeClass}>
<Paperclip className="h-3 w-3" />
{t('attached_label')}
</Badge>
</Link>
)
}
if (missing) {
const badge = (
<Badge variant="outline" className={cn(badgeClass, 'text-muted-foreground')}>
<Paperclip className="h-3 w-3" />
)}
</button>
)
{t('missing_label')}
</Badge>
)
if (!onAttach) return <span className={cn('shrink-0', className)}>{badge}</span>
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onAttach()
}}
title={t('missing_title')}
aria-label={t('missing_title')}
className={cn(hitAreaClass, className)}
>
{badge}
</button>
)
}
return null
}
@@ -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 =
@@ -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<string, JeUnderlagStatus>
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({
<BankHistoryRow
key={`bank-${item.data.id}`}
transaction={item.data}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={onOpenMatchDialog}
onOpenCategoryDialog={onOpenCategoryDialog}
onOpenAttachDocument={onOpenAttachDocument}
onDelete={onDelete}
/>
) : (
@@ -213,13 +224,17 @@ export default function TransactionHistoryList({
function BankHistoryRow({
transaction,
jeUnderlagStatus,
onOpenMatchDialog,
onOpenCategoryDialog,
onOpenAttachDocument,
onDelete,
}: {
transaction: TransactionWithInvoice
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
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({
</Link>
</Button>
)}
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite)) && (
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite) || showAttachItem) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -340,6 +364,14 @@ function BankHistoryRow({
})}
</DropdownMenuItem>
)}
{/* Attach underlag — available on both booked rows (the route
propagates the doc onto the verifikation) and unbooked. */}
{showAttachItem && (
<DropdownMenuItem onSelect={() => onOpenAttachDocument!(transaction)}>
<Paperclip className="h-3.5 w-3.5" />
{t('attach_document')}
</DropdownMenuItem>
)}
{isBooked && canWrite && transaction.journal_entry_id && (
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
{({ open, isLoading }) => (
@@ -351,7 +383,7 @@ function BankHistoryRow({
)}
{canDelete && onDelete && (
<>
{hasInvoiceMatch && <DropdownMenuSeparator />}
{(hasInvoiceMatch || showAttachItem) && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={() => onDelete(transaction.id)}
className="text-destructive focus:text-destructive"
@@ -369,7 +401,15 @@ function BankHistoryRow({
>
<div className="flex items-center gap-1.5 min-w-0">
<DataListPrimary>{transaction.description}</DataListPrimary>
<TransactionAttachmentIndicator documentId={transaction.document_id} />
<TransactionAttachmentIndicator
documentId={transaction.document_id}
journalEntryId={transaction.journal_entry_id}
hasJeDoc={hasJeDoc}
missing={missingUnderlag}
onAttach={
showAttachItem ? () => onOpenAttachDocument!(transaction) : undefined
}
/>
</div>
<DataListMeta>
<span className="tabular-nums">{formatDate(transaction.date)}</span>
@@ -25,6 +25,7 @@ import {
Link2,
Loader2,
MoreHorizontal,
Paperclip,
Pencil,
Split,
Trash2,
@@ -43,6 +44,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
// upload functional but drop the "AI:n läser dokumentet" promise.
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
interface TransactionInboxCardProps {
@@ -66,6 +68,9 @@ interface TransactionInboxCardProps {
/** Open the existing-verifikat matcher — link the bank tx to an already-booked
* voucher (salary, Fortnox import, manual entry) with no new bokföring. */
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
/** Open the attach-underlag dialog — pin an inbox document or a fresh upload
* to the transaction (the tx→doc mirror of the Documents view's matcher). */
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
/** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */
@@ -84,6 +89,7 @@ export default function TransactionInboxCard({
onOpenMatchInvoicePicker,
onOpenSplitMatch,
onOpenMatchVoucher,
onOpenAttachDocument,
onOpenCategoryDialog,
onDelete,
onEditTitle,
@@ -91,6 +97,9 @@ export default function TransactionInboxCard({
onAnimationComplete,
}: TransactionInboxCardProps) {
const t = useTranslations('tx_inbox_card')
// Attaching underlag is a write — hide the affordance from viewers so they
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
const { canWrite } = useCanWrite()
const isProcessing = processingId === transaction.id
const isDisabled = processingId !== null && processingId !== transaction.id
const isIncome = transaction.amount > 0
@@ -224,10 +233,14 @@ export default function TransactionInboxCard({
// invoice match was auto-detected: the user may want to point the bank line at
// an existing salary/Fortnox/manual voucher instead of confirming a payment.
const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher
// "Matcha mot underlag" — pin an inbox doc / fresh upload to the tx. The
// tx→doc mirror of the Documents view's "Matcha mot transaktion".
const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument
const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch
const showEditItem = isTitleEditable && !!onEditTitle
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu = showMatchVoucherItem || showSplitItem || showEditItem || showDeleteItem
const showOverflowMenu =
showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showDeleteItem
return (
<motion.div
@@ -344,6 +357,17 @@ export default function TransactionInboxCard({
{t('match_voucher_btn')}
</DropdownMenuItem>
)}
{showAttachDocumentItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenAttachDocument!(transaction)
}}
>
<Paperclip className="h-4 w-4" />
{t('attach_document_btn')}
</DropdownMenuItem>
)}
{showSplitItem && (
<DropdownMenuItem
onClick={(e) => {
@@ -368,7 +392,7 @@ export default function TransactionInboxCard({
)}
{showDeleteItem && (
<>
{(showMatchVoucherItem || showSplitItem || showEditItem) && <DropdownMenuSeparator />}
{(showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
+1
View File
@@ -3,5 +3,6 @@
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
+1
View File
@@ -3,5 +3,6 @@
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
+7
View File
@@ -1730,6 +1730,13 @@ export const AttachDocumentSchema = z.object({
document_id: uuid,
})
export const LinkDocumentSchema = z.object({
journal_entry_id: uuid,
journal_entry_line_id: uuid.optional(),
inbox_item_id: uuid.optional(),
transaction_id: uuid.optional(),
})
// ============================================================
// Shift-premium rules (OB-tillägg och övertid)
// ============================================================
+4 -2
View File
@@ -17,8 +17,9 @@ Best for most users. No API key to manage — you authorise Accounted the same w
1. In **claude.ai** (Settings → Connectors) or **Claude Desktop** (Settings → Connectors → Add custom connector), choose **Add custom connector**.
2. Paste the connector URL:
\`\`\`
https://app.gnubok.se/api/extensions/ext/mcp-server/mcp
https://app.gnubok.se/api/extensions/ext/mcp-server/mcp?client=claude-connector
\`\`\`
_(The "?client=claude-connector" suffix is telemetry-only — it lets Accounted see you connected via claude.ai/Desktop. It changes nothing about behaviour or scopes; drop the query string if you prefer.)_
3. Claude opens the Accounted OAuth 2.1 consent screen. Sign in and pick the company you want Claude to act on.
4. On the consent screen you grant **read-only scopes by default** (list invoices, read reports, compute VAT). Write scopes (create invoice, categorise, book vouchers, run year-end) are **listed separately and must be ticked explicitly** — leave them unchecked for a read-only review session.
5. Approve. Claude now lists the Accounted tools and you can start asking questions.
@@ -38,7 +39,8 @@ Best for Claude Desktop on a machine where you'd rather use a long-lived API key
"command": "npx",
"args": ["gnubok-mcp"],
"env": {
"GNUBOK_API_KEY": "gnubok_sk_test_..."
"GNUBOK_API_KEY": "gnubok_sk_test_...",
"GNUBOK_CLIENT": "claude-desktop"
}
}
}
@@ -694,4 +694,75 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
const tablesTouched = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0])
expect(tablesTouched).toContain('invoice_inbox_items')
})
it('auto-rejects 409 when the document already belongs to a different verifikation', async () => {
// Without this guard the attach would pin a consumed doc (undetachable
// per the transactions immutability trigger) and the later propagation
// would corrupt or be blocked by the doc-metadata immutability trigger.
// Mirrors the REST route's guard.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: 'je-1' }, error: null })
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
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('skips the propagation write on an idempotent re-attach (doc already on the same verifikation)', async () => {
// The period-lock trigger raises on ANY journal_entry_id write — even a
// same-value rewrite — so an unconditional re-run would fail an otherwise
// idempotent re-attach once the period locks.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { id: 'tx-1', document_id: 'doc-1', journal_entry_id: 'je-1' }, error: null })
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch — same JE
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // tx UPDATE returning
enqueue({ data: null, error: null }) // invoice_inbox_items link
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')
// document_attachments touched once (the doc fetch) — no propagation write.
const tablesTouched = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0])
expect(tablesTouched.filter((t) => t === 'document_attachments')).toHaveLength(1)
})
it('maps a period-lock propagation failure to auto-reject 409', async () => {
// A retry could never succeed until the period is unlocked, so the
// generic 500 "försök igen" would be a false promise. Mirrors the REST
// route's mapping.
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', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // tx UPDATE returning — booked meanwhile
enqueue({ data: null, error: null }) // invoice_inbox_items link
enqueue({
data: null,
error: { message: 'cannot link document in a locked/closed fiscal period' },
}) // propagation blocked by enforce_period_lock
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)
})
})
+31 -2
View File
@@ -1453,12 +1453,25 @@ async function commitAttachDocumentToTransaction(
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id')
.select('id, journal_entry_id')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
if (docError || !doc) return { error: 'Document not found', status: 404 }
// 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).
// Mirrors the REST route in app/api/transactions/[id]/attach-document.
const docJournalEntryId = (doc.journal_entry_id as string | null) ?? null
if (docJournalEntryId && docJournalEntryId !== tx.journal_entry_id) {
return {
error: 'Underlaget är redan kopplat till en annan verifikation.',
status: 409,
}
}
// 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
@@ -1511,13 +1524,29 @@ async function commitAttachDocumentToTransaction(
}
const journalEntryId = postUpdate.journal_entry_id as string | null
if (journalEntryId) {
// Skip when the doc already points at this verifikation: the period-lock
// trigger raises on ANY journal_entry_id write (even a same-value rewrite),
// so an unconditional re-run would 500 an otherwise idempotent re-attach
// once the period locks.
if (journalEntryId && docJournalEntryId !== journalEntryId) {
const { error: linkErr } = await supabase
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', documentId)
.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. Map to 409 — the
// dispatcher auto-rejects it, and a retry could never succeed until the
// period is unlocked, so "försök igen" would be a false promise.
const linkMsg = (linkErr as { message?: string }).message ?? ''
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) {
return {
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.
// BFL 5 kap 6 § requires the verifikation to reference its underlag, so
// a "succeeded" attach that left document_attachments.journal_entry_id
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { computeJeUnderlagStatus } from '../underlag-status'
describe('computeJeUnderlagStatus', () => {
it('marks entries with a current-version document as has', () => {
const result = computeJeUnderlagStatus(
[{ id: 'je-1', source_type: 'bank_transaction' }],
new Set(['je-1']),
new Set(),
)
expect(result['je-1']).toBe('has')
})
it('marks doc-requiring entries without documents as missing', () => {
const result = computeJeUnderlagStatus(
[
{ id: 'je-1', source_type: 'bank_transaction' },
{ id: 'je-2', source_type: 'manual' },
{ id: 'je-3', source_type: 'import' },
],
new Set(),
new Set(),
)
expect(result).toEqual({ 'je-1': 'missing', 'je-2': 'missing', 'je-3': 'missing' })
})
it('respects journal_entry_no_doc_required exemptions', () => {
const result = computeJeUnderlagStatus(
[{ id: 'je-1', source_type: 'manual' }],
new Set(),
new Set(['je-1']),
)
expect(result['je-1']).toBe('none')
})
it('never flags system-generated source types (exempt by omission)', () => {
const result = computeJeUnderlagStatus(
[
{ id: 'je-1', source_type: 'vat_settlement' },
{ id: 'je-2', source_type: 'invoice_payment' },
{ id: 'je-3', source_type: 'year_end' },
],
new Set(),
new Set(),
)
expect(result).toEqual({ 'je-1': 'none', 'je-2': 'none', 'je-3': 'none' })
})
it('treats null source_type as no statement', () => {
const result = computeJeUnderlagStatus(
[{ id: 'je-1', source_type: null }],
new Set(),
new Set(),
)
expect(result['je-1']).toBe('none')
})
it('has wins over missing when both a doc and a needs-doc source type are present', () => {
const result = computeJeUnderlagStatus(
[{ id: 'je-1', source_type: 'manual' }],
new Set(['je-1']),
new Set(['je-1']),
)
expect(result['je-1']).toBe('has')
})
})
+43
View File
@@ -0,0 +1,43 @@
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
/**
* Row-level underlag status for a booked transaction's journal entry.
*
* - 'has' — the verifikation has at least one current-version document
* - 'missing' — the verifikation's source type requires underlag (BFL 5 kap
* 7§), has none, and is not exempted via journal_entry_no_doc_required
* - 'none' — no statement either way (system-generated source types,
* exempted entries) — render no badge
*
* Mirrors countVerifikatMissingDocument in lib/worklist/categories.ts at row
* granularity so the per-row "Underlag saknas" badge and the worklist count
* never disagree on what counts as missing. Contract: callers must pass only
* POSTED entries (filter journal_entries on status = 'posted', like the
* worklist does) — reversed/corrected entries must render no badge at all,
* never 'missing'.
*/
export type JeUnderlagStatus = 'has' | 'missing' | 'none'
const NEEDS_DOC = new Set<string>(NEEDS_DOC_SOURCE_TYPES)
export function computeJeUnderlagStatus(
entries: Array<{ id: string; source_type: string | null }>,
jeIdsWithDocs: ReadonlySet<string>,
exemptJeIds: ReadonlySet<string>,
): Record<string, JeUnderlagStatus> {
const result: Record<string, JeUnderlagStatus> = {}
for (const entry of entries) {
if (jeIdsWithDocs.has(entry.id)) {
result[entry.id] = 'has'
} else if (
entry.source_type != null &&
NEEDS_DOC.has(entry.source_type) &&
!exemptJeIds.has(entry.id)
) {
result[entry.id] = 'missing'
} else {
result[entry.id] = 'none'
}
}
return result
}
+31 -1
View File
@@ -265,7 +265,15 @@
"type_mark_invoice_paid": "Paid invoice",
"type_send_invoice": "Send invoice",
"type_mark_invoice_sent": "Mark as sent",
"type_match_transaction_invoice": "Invoice match"
"type_match_transaction_invoice": "Invoice match",
"origin_agent_chat": "Suggested by the AI assistant via chat",
"origin_mcp": "Suggested by an AI assistant via {label}",
"origin_api": "Suggested via API integration",
"origin_cron": "Created by a scheduled job",
"explainer": "Approve executes the bookkeeping operation. Reject just discards the proposal — nothing is booked.",
"auto_expiry_note": "Unhandled proposals expire automatically after 30 days — doing nothing is safe.",
"badge_auto_expired": "Expired automatically",
"auto_expired_detail": "Expired automatically after 30 days without action. Nothing was booked."
},
"deadlines": {
"title": "Deadlines",
@@ -1667,6 +1675,7 @@
"match_supplier_invoice_btn": "Match supplier invoice {number}",
"choose_template_btn": "Choose template...",
"match_voucher_btn": "Match to existing voucher",
"attach_document_btn": "Match to document",
"more_actions_aria": "More actions",
"delete_aria": "Delete transaction",
"edit_title_aria": "Edit title",
@@ -1724,6 +1733,26 @@
"doc_link_failed_description": "{count} file(s) could not be linked to the journal entry. Try again from the bookkeeping page.",
"bank_line_description": "Business account"
},
"tx_attach_dialog": {
"title": "Match to document",
"description": "Attach a receipt or invoice to the transaction — pick from the inbox or upload a new file.",
"pick_existing": "Choose existing document",
"selected_remove": "Remove selected document",
"already_attached_hint": "This transaction already has a document. Attaching a new one replaces it.",
"confirm": "Attach document",
"attaching": "Attaching…",
"success_toast": "Document attached",
"error_toast": "Could not attach the document",
"cancel": "Cancel"
},
"tx_underlag": {
"attached_label": "Document",
"attached_title": "Supporting document attached — click to open",
"attached_aria": "Open attached document",
"missing_label": "Missing document",
"missing_title": "The voucher has no supporting document — click to attach",
"open_failed": "Could not fetch the document"
},
"tx_batch_selector": {
"title_processing": "Posting {done}/{total}...",
"title_default": "Post {count} transactions",
@@ -2007,6 +2036,7 @@
"create_correction": "Create correction entry",
"not_posted": "Not posted",
"possible_match_invoice": "Possible match: Invoice {number}",
"attach_document": "Match to document",
"book": "Post",
"delete": "Delete",
"delete_aria": "Delete transaction",
+31 -1
View File
@@ -265,7 +265,15 @@
"type_mark_invoice_paid": "Betald faktura",
"type_send_invoice": "Skicka faktura",
"type_mark_invoice_sent": "Markera skickad",
"type_match_transaction_invoice": "Fakturamatchning"
"type_match_transaction_invoice": "Fakturamatchning",
"origin_agent_chat": "Föreslaget av AI-assistenten via chatt",
"origin_mcp": "Föreslaget av AI-assistent via {label}",
"origin_api": "Föreslaget via API-integration",
"origin_cron": "Skapat av automatiskt jobb",
"explainer": "Godkänn utför bokföringen. Avvisa kastar bara förslaget — inget bokförs.",
"auto_expiry_note": "Förslag som inte hanteras utgår automatiskt efter 30 dagar — att inte göra något är säkert.",
"badge_auto_expired": "Utgick automatiskt",
"auto_expired_detail": "Utgick automatiskt efter 30 dagar utan åtgärd. Inget bokfördes."
},
"deadlines": {
"title": "Deadlines",
@@ -1667,6 +1675,7 @@
"match_supplier_invoice_btn": "Matcha Leverantörsfaktura {number}",
"choose_template_btn": "Välj mall...",
"match_voucher_btn": "Matcha mot befintlig verifikation",
"attach_document_btn": "Matcha mot underlag",
"more_actions_aria": "Fler åtgärder",
"delete_aria": "Ta bort transaktion",
"edit_title_aria": "Ändra titel",
@@ -1724,6 +1733,26 @@
"doc_link_failed_description": "{count} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.",
"bank_line_description": "Företagskonto"
},
"tx_attach_dialog": {
"title": "Matcha mot underlag",
"description": "Koppla ett kvitto eller en faktura till transaktionen — välj från inkorgen eller ladda upp en ny fil.",
"pick_existing": "Välj befintligt underlag",
"selected_remove": "Ta bort valt underlag",
"already_attached_hint": "Transaktionen har redan ett underlag. Kopplar du ett nytt ersätts det befintliga.",
"confirm": "Koppla underlag",
"attaching": "Kopplar…",
"success_toast": "Underlag kopplat",
"error_toast": "Kunde inte koppla underlaget",
"cancel": "Avbryt"
},
"tx_underlag": {
"attached_label": "Underlag",
"attached_title": "Underlag bifogat — klicka för att öppna",
"attached_aria": "Öppna bifogat underlag",
"missing_label": "Underlag saknas",
"missing_title": "Verifikationen saknar underlag — klicka för att bifoga",
"open_failed": "Kunde inte hämta underlaget"
},
"tx_batch_selector": {
"title_processing": "Bokför {done}/{total}...",
"title_default": "Bokför {count} transaktioner",
@@ -2007,6 +2036,7 @@
"create_correction": "Skapa ändringsverifikation",
"not_posted": "Ej bokförd",
"possible_match_invoice": "Möjlig match: Faktura {number}",
"attach_document": "Matcha mot underlag",
"book": "Bokför",
"delete": "Ta bort",
"delete_aria": "Ta bort transaktion",
+1 -1
View File
@@ -46,7 +46,7 @@ Restart Claude Desktop. The Accounted tools appear in the client and you can sta
## Alternative: claude.ai connector (no API key)
If you use **claude.ai** or Claude Desktop's custom-connector flow, you can skip this bridge entirely and add Accounted as an OAuth 2.1 custom connector instead — paste the connector URL `https://app.gnubok.se/api/extensions/ext/mcp-server/mcp` and authorise on the Accounted consent screen (read-only scopes by default; write scopes are ticked explicitly).
If you use **claude.ai** or Claude Desktop's custom-connector flow, you can skip this bridge entirely and add Accounted as an OAuth 2.1 custom connector instead — paste the connector URL `https://app.gnubok.se/api/extensions/ext/mcp-server/mcp?client=claude-connector` and authorise on the Accounted consent screen (read-only scopes by default; write scopes are ticked explicitly).
## Docs
+4 -2
View File
@@ -1687,7 +1687,9 @@ export type PendingOperationType =
| 'submit_agi'
export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected'
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'
// 'agent_chat' = the in-app AI chat (DB CHECK widened in migration
// 20260519090000_actor_type_agent_chat).
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'agent_chat'
export type PendingOperationRiskLevel = 'low' | 'medium' | 'high'
export interface PendingOperationAgentMetadata {
@@ -2535,7 +2537,7 @@ export interface AuditLogEntry {
table_name: string | null
record_id: string | null
actor_id: string | null
actor_type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'system' | null
actor_type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'agent_chat' | 'system' | null
actor_label: string | null
old_state: Record<string, unknown> | null
new_state: Record<string, unknown> | null
+4
View File
@@ -32,6 +32,10 @@
"path": "/api/idempotency/cleanup/cron",
"schedule": "30 * * * *"
},
{
"path": "/api/pending-operations/expire/cron",
"schedule": "30 2 * * *"
},
{
"path": "/api/extensions/skatteverket/skattekonto/sync/cron",
"schedule": "0 4 * * *"