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
@@ -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,
},
})
}