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