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:
co-authored by
Claude Fable 5
parent
e978136210
commit
0521c385d2
@@ -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)
|
||||
|
||||
@@ -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() })
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user