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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -396,29 +396,36 @@ export default function ApprovalCard({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCommit}
|
||||
disabled={isBusy || !canCommit}
|
||||
className="flex-1"
|
||||
>
|
||||
{state === 'committing' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Godkänn'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRejectForm(true)}
|
||||
disabled={isBusy}
|
||||
className="flex-1"
|
||||
>
|
||||
Avslå
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCommit}
|
||||
disabled={isBusy || !canCommit}
|
||||
className="flex-1"
|
||||
>
|
||||
{state === 'committing' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Godkänn'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRejectForm(true)}
|
||||
disabled={isBusy}
|
||||
className="flex-1"
|
||||
>
|
||||
Avslå
|
||||
</Button>
|
||||
</div>
|
||||
{/* Keep in sync with EXPIRY_DAYS in
|
||||
app/api/pending-operations/expire/cron/route.ts. */}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Om du inte gör något utgår förslaget automatiskt efter 30 dagar — inget bokförs.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -360,9 +360,12 @@ export function ApiKeysPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
const mcpUrl = typeof window !== 'undefined'
|
||||
const mcpBase = typeof window !== 'undefined'
|
||||
? `${window.location.origin}/api/extensions/ext/mcp-server/mcp`
|
||||
: '/api/extensions/ext/mcp-server/mcp'
|
||||
// Telemetry-only distribution-channel marker (server reads the `client` query
|
||||
// param; never used for auth). Lets us measure which Claude surface connected.
|
||||
const mcpUrl = (client: string) => `${mcpBase}?client=${client}`
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -465,7 +468,7 @@ export function ApiKeysPanel() {
|
||||
path: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</p>
|
||||
<CopyBlock text={mcpUrl} copyAriaLabel={t('copy_aria')} />
|
||||
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -473,7 +476,8 @@ export function ApiKeysPanel() {
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t('terminal_runs_browser_login')}
|
||||
</p>
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http ${mcpUrl}`} copyAriaLabel={t('copy_aria')} />
|
||||
{/* URL is quoted — unquoted `?` in the query string trips zsh globbing. */}
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http "${mcpUrl('claude-code')}"`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
@@ -500,7 +504,8 @@ export function ApiKeysPanel() {
|
||||
"command": "npx",
|
||||
"args": ["gnubok-mcp"],
|
||||
"env": {
|
||||
"GNUBOK_API_KEY": "gnubok_sk_..."
|
||||
"GNUBOK_API_KEY": "gnubok_sk_...",
|
||||
"GNUBOK_CLIENT": "claude-desktop"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,7 +518,7 @@ export function ApiKeysPanel() {
|
||||
{t('terminal_with_api_key')}
|
||||
</p>
|
||||
<CopyBlock text={`claude mcp add ${connectorName} --transport http \\
|
||||
--url ${mcpUrl} \\
|
||||
--url "${mcpUrl('claude-code')}" \\
|
||||
--header "Authorization: Bearer gnubok_sk_..."`} copyAriaLabel={t('copy_aria')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Inbox, Loader2, X } from 'lucide-react'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPicker'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
|
||||
interface TransactionAttachDocumentDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onAttached: (transactionId: string, documentId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone "Matcha mot underlag" dialog for the /transactions view — the
|
||||
* mirror of the Documents view's TransactionMatchPicker (doc → tx direction).
|
||||
* Pick an unconsumed inbox document or upload a new file, then pin it to the
|
||||
* transaction via POST /api/transactions/[id]/attach-document. The pin is
|
||||
* single-valued (transactions.document_id); for booked rows the route
|
||||
* propagates the link onto the verifikation immediately.
|
||||
*/
|
||||
export default function TransactionAttachDocumentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onAttached,
|
||||
}: TransactionAttachDocumentDialogProps) {
|
||||
const t = useTranslations('tx_attach_dialog')
|
||||
const { toast } = useToast()
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [pickedDoc, setPickedDoc] = useState<AvailableInboxDoc | null>(null)
|
||||
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
|
||||
const [isAttaching, setIsAttaching] = useState(false)
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
|
||||
// Single selection — transactions.document_id pins exactly one doc, so an
|
||||
// inbox pick replaces any upload and vice versa.
|
||||
const selectedDocumentId =
|
||||
pickedDoc?.document_id ??
|
||||
uploadedFiles.find((f) => f.status === 'uploaded' && f.id)?.id ??
|
||||
null
|
||||
|
||||
const reset = () => {
|
||||
setUploadedFiles([])
|
||||
setPickedDoc(null)
|
||||
setInboxPickerOpen(false)
|
||||
}
|
||||
|
||||
const handleAttach = async () => {
|
||||
if (!selectedDocumentId || isAttaching) return
|
||||
setIsAttaching(true)
|
||||
try {
|
||||
const res = await fetch(`/api/transactions/${transaction.id}/attach-document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ document_id: selectedDocumentId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = (await res.json().catch(() => ({}))) as { error?: unknown }
|
||||
// The route returns Swedish domain messages (immutability, locked
|
||||
// period) as a plain string — surface them verbatim.
|
||||
toast({
|
||||
title: t('error_toast'),
|
||||
description: typeof json.error === 'string' ? json.error : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('success_toast') })
|
||||
// Same event AgentChat and the booking dialog dispatch — flips the inbox
|
||||
// card's indicator optimistically without a refetch.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('Accounted:transaction-document-linked', {
|
||||
detail: { transaction_id: transaction.id, document_id: selectedDocumentId },
|
||||
}),
|
||||
)
|
||||
onAttached(transaction.id, selectedDocumentId)
|
||||
reset()
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// Network-level failure — fetch rejected before a response existed.
|
||||
toast({ title: t('error_toast'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsAttaching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset()
|
||||
onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Transaction summary — same block as TransactionBookingDialog */}
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isIncome
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{transaction.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{transaction.document_id && (
|
||||
<p className="text-xs text-muted-foreground">{t('already_attached_hint')}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={(files) => {
|
||||
setUploadedFiles(files)
|
||||
if (files.length > 0) setPickedDoc(null)
|
||||
}}
|
||||
maxFiles={1}
|
||||
compact
|
||||
disabled={isAttaching}
|
||||
/>
|
||||
{pickedDoc && (
|
||||
<div className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50">
|
||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{pickedDoc.supplier_name ?? pickedDoc.file_name}
|
||||
</span>
|
||||
{pickedDoc.amount != null && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
|
||||
{formatCurrency(pickedDoc.amount, pickedDoc.currency ?? 'SEK')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
aria-label={t('selected_remove')}
|
||||
onClick={() => setPickedDoc(null)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={isAttaching}
|
||||
onClick={() => setInboxPickerOpen(true)}
|
||||
>
|
||||
<Inbox className="h-4 w-4 mr-2" />
|
||||
{t('pick_existing')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={isAttaching} onClick={() => onOpenChange(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button disabled={!selectedDocumentId || isAttaching} onClick={handleAttach}>
|
||||
{isAttaching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('attaching')}
|
||||
</>
|
||||
) : (
|
||||
t('confirm')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<InboxDocumentPicker
|
||||
open={inboxPickerOpen}
|
||||
onClose={() => setInboxPickerOpen(false)}
|
||||
onSelect={(doc) => {
|
||||
setPickedDoc(doc)
|
||||
setUploadedFiles([])
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import { Paperclip, Loader2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface Props {
|
||||
documentId: string | null | undefined
|
||||
/** The booked tx's journal entry — link target when the underlag lives only
|
||||
* at verifikat level (multi-doc entries, booking-dialog uploads). */
|
||||
journalEntryId?: string | null
|
||||
/** Underlag exists on the journal entry even though no doc is pinned to the
|
||||
* transaction row (computeJeUnderlagStatus === 'has'). */
|
||||
hasJeDoc?: boolean
|
||||
/** Booked, requires underlag, has none (computeJeUnderlagStatus === 'missing'). */
|
||||
missing?: boolean
|
||||
/** Opens the attach dialog from the negative state. Omit for viewers —
|
||||
* the badge then renders non-interactive. */
|
||||
onAttach?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const badgeClass = 'h-4 gap-1 px-1.5 py-0 text-[10px] font-normal'
|
||||
// Enlarges the hit area beyond the 16px badge without shifting layout.
|
||||
const hitAreaClass = 'shrink-0 p-1 -m-1'
|
||||
|
||||
/**
|
||||
* Compact "this transaction has an attached document" indicator.
|
||||
* Clicking fetches a signed download URL and opens the document in a new tab —
|
||||
* lets the user verify the attached receipt without first having to book the
|
||||
* transaction (which is when the doc gets linked to a journal entry).
|
||||
* Per-row underlag status for the /transactions lists.
|
||||
*
|
||||
* - Pinned doc (transactions.document_id): clickable badge that fetches a
|
||||
* signed URL and opens the document in a new tab.
|
||||
* - Verifikat-level doc only: same badge, links to the verifikat page (which
|
||||
* lists all attachments — handles multi-doc without a per-row fetch).
|
||||
* - Missing on a booked row: discreet outline badge that doubles as the
|
||||
* attach affordance when onAttach is provided.
|
||||
*/
|
||||
export function TransactionAttachmentIndicator({ documentId, className }: Props) {
|
||||
export function TransactionAttachmentIndicator({
|
||||
documentId,
|
||||
journalEntryId,
|
||||
hasJeDoc,
|
||||
missing,
|
||||
onAttach,
|
||||
className,
|
||||
}: Props) {
|
||||
const t = useTranslations('tx_underlag')
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
if (!documentId) return null
|
||||
|
||||
const handleOpen = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (isLoading) return
|
||||
if (isLoading || !documentId) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}`)
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte hämta underlaget', variant: 'destructive' })
|
||||
toast({ title: t('open_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
@@ -42,22 +70,68 @@ export function TransactionAttachmentIndicator({ documentId, className }: Props)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
title="Underlag bifogat — klicka för att öppna"
|
||||
aria-label="Öppna bifogat underlag"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors shrink-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
if (documentId) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
title={t('attached_title')}
|
||||
aria-label={t('attached_aria')}
|
||||
className={cn(hitAreaClass, className)}
|
||||
>
|
||||
<Badge variant="secondary" className={badgeClass}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="h-3 w-3" />
|
||||
)}
|
||||
{t('attached_label')}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (hasJeDoc && journalEntryId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/bookkeeping/${journalEntryId}`}
|
||||
title={t('attached_title')}
|
||||
aria-label={t('attached_aria')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(hitAreaClass, className)}
|
||||
>
|
||||
<Badge variant="secondary" className={badgeClass}>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{t('attached_label')}
|
||||
</Badge>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
const badge = (
|
||||
<Badge variant="outline" className={cn(badgeClass, 'text-muted-foreground')}>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
{t('missing_label')}
|
||||
</Badge>
|
||||
)
|
||||
if (!onAttach) return <span className={cn('shrink-0', className)}>{badge}</span>
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onAttach()
|
||||
}}
|
||||
title={t('missing_title')}
|
||||
aria-label={t('missing_title')}
|
||||
className={cn(hitAreaClass, className)}
|
||||
>
|
||||
{badge}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ interface TransactionBookingDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onBooked: (transactionId: string, journalEntryId: string) => void
|
||||
onBooked: (
|
||||
transactionId: string,
|
||||
journalEntryId: string,
|
||||
attachedDocumentId?: string | null,
|
||||
) => void
|
||||
preselectedTemplate?: BookingTemplateLibrary | null
|
||||
}
|
||||
|
||||
@@ -116,16 +120,23 @@ export default function TransactionBookingDialog({
|
||||
// files, and existing inbox documents picked via InboxDocumentPicker. For
|
||||
// picked docs, inbox_item_id stamps the inbox item as consumed so it drops
|
||||
// out of the active inbox — see app/api/documents/[id]/link/route.ts.
|
||||
// transaction_id additionally pins the doc to the transaction row so the
|
||||
// /transactions list shows the underlag indicator (first linked doc wins).
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
let linkFailCount = 0
|
||||
let firstLinkedDocId: string | null = null
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
body: JSON.stringify({
|
||||
journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) linkFailCount++
|
||||
else firstLinkedDocId ??= file.id ?? null
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
@@ -138,9 +149,11 @@ export default function TransactionBookingDialog({
|
||||
body: JSON.stringify({
|
||||
journal_entry_id: journalEntryId,
|
||||
inbox_item_id: doc.inbox_item_id,
|
||||
transaction_id: transactionId,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) linkFailCount++
|
||||
else firstLinkedDocId ??= doc.document_id
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
@@ -153,10 +166,24 @@ export default function TransactionBookingDialog({
|
||||
})
|
||||
}
|
||||
|
||||
// The server pins only when the tx has no document_id yet (first linked
|
||||
// doc wins) — mirror that here so the optimistic state never claims a
|
||||
// pin the server refused to swap.
|
||||
const pinnedDocId = transaction.document_id ? null : firstLinkedDocId
|
||||
if (pinnedDocId) {
|
||||
// Same event AgentChat dispatches after uploads — flips the inbox card's
|
||||
// paperclip optimistically without a refetch.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('Accounted:transaction-document-linked', {
|
||||
detail: { transaction_id: transactionId, document_id: pinnedDocId },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
setShowUploadZone(false)
|
||||
onBooked(transactionId, journalEntryId)
|
||||
onBooked(transactionId, journalEntryId, pinnedDocId)
|
||||
}
|
||||
|
||||
const attachedCount =
|
||||
|
||||
@@ -38,11 +38,13 @@ import {
|
||||
FileText,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status'
|
||||
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
|
||||
import type {
|
||||
SkattekontoTransactionWithSuggestion,
|
||||
@@ -59,8 +61,13 @@ interface TransactionHistoryListProps {
|
||||
transactions: TransactionWithInvoice[]
|
||||
skvRows?: SkattekontoTransactionWithSuggestion[]
|
||||
searchTerm?: string
|
||||
/** Underlag status per journal_entry_id (computeJeUnderlagStatus) — drives
|
||||
* the per-row "Underlag"/"Underlag saknas" badges on booked rows. */
|
||||
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
onSkvBokfor?: (row: StoredSkattekontoTransaction) => void
|
||||
onSkvMatch?: (row: StoredSkattekontoTransaction) => void
|
||||
@@ -73,8 +80,10 @@ export default function TransactionHistoryList({
|
||||
transactions,
|
||||
skvRows = [],
|
||||
searchTerm = '',
|
||||
jeUnderlagStatus,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onDelete,
|
||||
onSkvBokfor,
|
||||
onSkvMatch,
|
||||
@@ -177,8 +186,10 @@ export default function TransactionHistoryList({
|
||||
<BankHistoryRow
|
||||
key={`bank-${item.data.id}`}
|
||||
transaction={item.data}
|
||||
jeUnderlagStatus={jeUnderlagStatus}
|
||||
onOpenMatchDialog={onOpenMatchDialog}
|
||||
onOpenCategoryDialog={onOpenCategoryDialog}
|
||||
onOpenAttachDocument={onOpenAttachDocument}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
@@ -213,13 +224,17 @@ export default function TransactionHistoryList({
|
||||
|
||||
function BankHistoryRow({
|
||||
transaction,
|
||||
jeUnderlagStatus,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onDelete,
|
||||
}: {
|
||||
transaction: TransactionWithInvoice
|
||||
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
const t = useTranslations('tx_history')
|
||||
@@ -237,6 +252,15 @@ function BankHistoryRow({
|
||||
const hasInvoiceMatch =
|
||||
!isLinkedToInvoice && !!transaction.potential_invoice && !isBooked
|
||||
|
||||
// Underlag status — see computeJeUnderlagStatus. Unknown/not-yet-loaded JE
|
||||
// renders neither badge (no false "saknas" flash while the enrichment loads).
|
||||
const jeStatus = transaction.journal_entry_id
|
||||
? jeUnderlagStatus?.[transaction.journal_entry_id]
|
||||
: undefined
|
||||
const hasJeDoc = jeStatus === 'has'
|
||||
const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing'
|
||||
const showAttachItem = canWrite && !!onOpenAttachDocument
|
||||
|
||||
// Primary status badge — pick the most informative one.
|
||||
const statusBadge = (() => {
|
||||
if (isBooked) {
|
||||
@@ -319,7 +343,7 @@ function BankHistoryRow({
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite)) && (
|
||||
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite) || showAttachItem) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -340,6 +364,14 @@ function BankHistoryRow({
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Attach underlag — available on both booked rows (the route
|
||||
propagates the doc onto the verifikation) and unbooked. */}
|
||||
{showAttachItem && (
|
||||
<DropdownMenuItem onSelect={() => onOpenAttachDocument!(transaction)}>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
{t('attach_document')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isBooked && canWrite && transaction.journal_entry_id && (
|
||||
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
|
||||
{({ open, isLoading }) => (
|
||||
@@ -351,7 +383,7 @@ function BankHistoryRow({
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<>
|
||||
{hasInvoiceMatch && <DropdownMenuSeparator />}
|
||||
{(hasInvoiceMatch || showAttachItem) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete(transaction.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
@@ -369,7 +401,15 @@ function BankHistoryRow({
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<DataListPrimary>{transaction.description}</DataListPrimary>
|
||||
<TransactionAttachmentIndicator documentId={transaction.document_id} />
|
||||
<TransactionAttachmentIndicator
|
||||
documentId={transaction.document_id}
|
||||
journalEntryId={transaction.journal_entry_id}
|
||||
hasJeDoc={hasJeDoc}
|
||||
missing={missingUnderlag}
|
||||
onAttach={
|
||||
showAttachItem ? () => onOpenAttachDocument!(transaction) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DataListMeta>
|
||||
<span className="tabular-nums">{formatDate(transaction.date)}</span>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Link2,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Split,
|
||||
Trash2,
|
||||
@@ -43,6 +44,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
|
||||
// upload functional but drop the "AI:n läser dokumentet" promise.
|
||||
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
|
||||
interface TransactionInboxCardProps {
|
||||
@@ -66,6 +68,9 @@ interface TransactionInboxCardProps {
|
||||
/** Open the existing-verifikat matcher — link the bank tx to an already-booked
|
||||
* voucher (salary, Fortnox import, manual entry) with no new bokföring. */
|
||||
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the attach-underlag dialog — pin an inbox document or a fresh upload
|
||||
* to the transaction (the tx→doc mirror of the Documents view's matcher). */
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
/** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */
|
||||
@@ -84,6 +89,7 @@ export default function TransactionInboxCard({
|
||||
onOpenMatchInvoicePicker,
|
||||
onOpenSplitMatch,
|
||||
onOpenMatchVoucher,
|
||||
onOpenAttachDocument,
|
||||
onOpenCategoryDialog,
|
||||
onDelete,
|
||||
onEditTitle,
|
||||
@@ -91,6 +97,9 @@ export default function TransactionInboxCard({
|
||||
onAnimationComplete,
|
||||
}: TransactionInboxCardProps) {
|
||||
const t = useTranslations('tx_inbox_card')
|
||||
// Attaching underlag is a write — hide the affordance from viewers so they
|
||||
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
|
||||
const { canWrite } = useCanWrite()
|
||||
const isProcessing = processingId === transaction.id
|
||||
const isDisabled = processingId !== null && processingId !== transaction.id
|
||||
const isIncome = transaction.amount > 0
|
||||
@@ -224,10 +233,14 @@ export default function TransactionInboxCard({
|
||||
// invoice match was auto-detected: the user may want to point the bank line at
|
||||
// an existing salary/Fortnox/manual voucher instead of confirming a payment.
|
||||
const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher
|
||||
// "Matcha mot underlag" — pin an inbox doc / fresh upload to the tx. The
|
||||
// tx→doc mirror of the Documents view's "Matcha mot transaktion".
|
||||
const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument
|
||||
const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch
|
||||
const showEditItem = isTitleEditable && !!onEditTitle
|
||||
const showDeleteItem = canDelete && !!onDelete
|
||||
const showOverflowMenu = showMatchVoucherItem || showSplitItem || showEditItem || showDeleteItem
|
||||
const showOverflowMenu =
|
||||
showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showDeleteItem
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -344,6 +357,17 @@ export default function TransactionInboxCard({
|
||||
{t('match_voucher_btn')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showAttachDocumentItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenAttachDocument!(transaction)
|
||||
}}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
{t('attach_document_btn')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSplitItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -368,7 +392,7 @@ export default function TransactionInboxCard({
|
||||
)}
|
||||
{showDeleteItem && (
|
||||
<>
|
||||
{(showMatchVoucherItem || showSplitItem || showEditItem) && <DropdownMenuSeparator />}
|
||||
{(showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
|
||||
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
|
||||
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
|
||||
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
|
||||
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
|
||||
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
|
||||
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
|
||||
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
|
||||
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
|
||||
|
||||
@@ -1730,6 +1730,13 @@ export const AttachDocumentSchema = z.object({
|
||||
document_id: uuid,
|
||||
})
|
||||
|
||||
export const LinkDocumentSchema = z.object({
|
||||
journal_entry_id: uuid,
|
||||
journal_entry_line_id: uuid.optional(),
|
||||
inbox_item_id: uuid.optional(),
|
||||
transaction_id: uuid.optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Shift-premium rules (OB-tillägg och övertid)
|
||||
// ============================================================
|
||||
|
||||
@@ -17,8 +17,9 @@ Best for most users. No API key to manage — you authorise Accounted the same w
|
||||
1. In **claude.ai** (Settings → Connectors) or **Claude Desktop** (Settings → Connectors → Add custom connector), choose **Add custom connector**.
|
||||
2. Paste the connector URL:
|
||||
\`\`\`
|
||||
https://app.gnubok.se/api/extensions/ext/mcp-server/mcp
|
||||
https://app.gnubok.se/api/extensions/ext/mcp-server/mcp?client=claude-connector
|
||||
\`\`\`
|
||||
_(The "?client=claude-connector" suffix is telemetry-only — it lets Accounted see you connected via claude.ai/Desktop. It changes nothing about behaviour or scopes; drop the query string if you prefer.)_
|
||||
3. Claude opens the Accounted OAuth 2.1 consent screen. Sign in and pick the company you want Claude to act on.
|
||||
4. On the consent screen you grant **read-only scopes by default** (list invoices, read reports, compute VAT). Write scopes (create invoice, categorise, book vouchers, run year-end) are **listed separately and must be ticked explicitly** — leave them unchecked for a read-only review session.
|
||||
5. Approve. Claude now lists the Accounted tools and you can start asking questions.
|
||||
@@ -38,7 +39,8 @@ Best for Claude Desktop on a machine where you'd rather use a long-lived API key
|
||||
"command": "npx",
|
||||
"args": ["gnubok-mcp"],
|
||||
"env": {
|
||||
"GNUBOK_API_KEY": "gnubok_sk_test_..."
|
||||
"GNUBOK_API_KEY": "gnubok_sk_test_...",
|
||||
"GNUBOK_CLIENT": "claude-desktop"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,4 +694,75 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
|
||||
const tablesTouched = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0])
|
||||
expect(tablesTouched).toContain('invoice_inbox_items')
|
||||
})
|
||||
|
||||
it('auto-rejects 409 when the document already belongs to a different verifikation', async () => {
|
||||
// Without this guard the attach would pin a consumed doc (undetachable
|
||||
// per the transactions immutability trigger) and the later propagation
|
||||
// would corrupt or be blocked by the doc-metadata immutability trigger.
|
||||
// Mirrors the REST route's guard.
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: 'je-1' }, error: null })
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
})
|
||||
|
||||
it('skips the propagation write on an idempotent re-attach (doc already on the same verifikation)', async () => {
|
||||
// The period-lock trigger raises on ANY journal_entry_id write — even a
|
||||
// same-value rewrite — so an unconditional re-run would fail an otherwise
|
||||
// idempotent re-attach once the period locks.
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-1', journal_entry_id: 'je-1' }, error: null })
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch — same JE
|
||||
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // tx UPDATE returning
|
||||
enqueue({ data: null, error: null }) // invoice_inbox_items link
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('committed')
|
||||
// document_attachments touched once (the doc fetch) — no propagation write.
|
||||
const tablesTouched = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0])
|
||||
expect(tablesTouched.filter((t) => t === 'document_attachments')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('maps a period-lock propagation failure to auto-reject 409', async () => {
|
||||
// A retry could never succeed until the period is unlocked, so the
|
||||
// generic 500 "försök igen" would be a false promise. Mirrors the REST
|
||||
// route's mapping.
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // tx UPDATE returning — booked meanwhile
|
||||
enqueue({ data: null, error: null }) // invoice_inbox_items link
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { message: 'cannot link document in a locked/closed fiscal period' },
|
||||
}) // propagation blocked by enforce_period_lock
|
||||
enqueue({ data: null, error: null }) // dispatcher reject update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1453,12 +1453,25 @@ async function commitAttachDocumentToTransaction(
|
||||
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (docError || !doc) return { error: 'Document not found', status: 404 }
|
||||
|
||||
// A document that already serves as underlag for a DIFFERENT verifikation
|
||||
// cannot be pinned here — propagating would either corrupt that link or be
|
||||
// blocked by the document-metadata immutability trigger. Same verifikation
|
||||
// is fine (idempotent re-attach; propagation below becomes a no-op).
|
||||
// Mirrors the REST route in app/api/transactions/[id]/attach-document.
|
||||
const docJournalEntryId = (doc.journal_entry_id as string | null) ?? null
|
||||
if (docJournalEntryId && docJournalEntryId !== tx.journal_entry_id) {
|
||||
return {
|
||||
error: 'Underlaget är redan kopplat till en annan verifikation.',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
|
||||
// Race-free read of journal_entry_id: use UPDATE ... RETURNING so the value
|
||||
// we propagate against reflects any concurrent categorize that committed
|
||||
// before our UPDATE acquired the row lock. Reading the post-update state
|
||||
@@ -1511,13 +1524,29 @@ async function commitAttachDocumentToTransaction(
|
||||
}
|
||||
|
||||
const journalEntryId = postUpdate.journal_entry_id as string | null
|
||||
if (journalEntryId) {
|
||||
// Skip when the doc already points at this verifikation: the period-lock
|
||||
// trigger raises on ANY journal_entry_id write (even a same-value rewrite),
|
||||
// so an unconditional re-run would 500 an otherwise idempotent re-attach
|
||||
// once the period locks.
|
||||
if (journalEntryId && docJournalEntryId !== journalEntryId) {
|
||||
const { error: linkErr } = await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
if (linkErr) {
|
||||
// The enforce_period_lock trigger blocks journal_entry_id writes when
|
||||
// the target entry sits in a closed/locked period. Map to 409 — the
|
||||
// dispatcher auto-rejects it, and a retry could never succeed until the
|
||||
// period is unlocked, so "försök igen" would be a false promise.
|
||||
const linkMsg = (linkErr as { message?: string }).message ?? ''
|
||||
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) {
|
||||
return {
|
||||
error:
|
||||
'Bilagan kopplades till transaktionen men verifikationens period är låst — den kunde inte länkas till verifikationen.',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
// Surface the propagation failure rather than logging-and-continuing.
|
||||
// BFL 5 kap 6 § requires the verifikation to reference its underlag, so
|
||||
// a "succeeded" attach that left document_attachments.journal_entry_id
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeJeUnderlagStatus } from '../underlag-status'
|
||||
|
||||
describe('computeJeUnderlagStatus', () => {
|
||||
it('marks entries with a current-version document as has', () => {
|
||||
const result = computeJeUnderlagStatus(
|
||||
[{ id: 'je-1', source_type: 'bank_transaction' }],
|
||||
new Set(['je-1']),
|
||||
new Set(),
|
||||
)
|
||||
expect(result['je-1']).toBe('has')
|
||||
})
|
||||
|
||||
it('marks doc-requiring entries without documents as missing', () => {
|
||||
const result = computeJeUnderlagStatus(
|
||||
[
|
||||
{ id: 'je-1', source_type: 'bank_transaction' },
|
||||
{ id: 'je-2', source_type: 'manual' },
|
||||
{ id: 'je-3', source_type: 'import' },
|
||||
],
|
||||
new Set(),
|
||||
new Set(),
|
||||
)
|
||||
expect(result).toEqual({ 'je-1': 'missing', 'je-2': 'missing', 'je-3': 'missing' })
|
||||
})
|
||||
|
||||
it('respects journal_entry_no_doc_required exemptions', () => {
|
||||
const result = computeJeUnderlagStatus(
|
||||
[{ id: 'je-1', source_type: 'manual' }],
|
||||
new Set(),
|
||||
new Set(['je-1']),
|
||||
)
|
||||
expect(result['je-1']).toBe('none')
|
||||
})
|
||||
|
||||
it('never flags system-generated source types (exempt by omission)', () => {
|
||||
const result = computeJeUnderlagStatus(
|
||||
[
|
||||
{ id: 'je-1', source_type: 'vat_settlement' },
|
||||
{ id: 'je-2', source_type: 'invoice_payment' },
|
||||
{ id: 'je-3', source_type: 'year_end' },
|
||||
],
|
||||
new Set(),
|
||||
new Set(),
|
||||
)
|
||||
expect(result).toEqual({ 'je-1': 'none', 'je-2': 'none', 'je-3': 'none' })
|
||||
})
|
||||
|
||||
it('treats null source_type as no statement', () => {
|
||||
const result = computeJeUnderlagStatus(
|
||||
[{ id: 'je-1', source_type: null }],
|
||||
new Set(),
|
||||
new Set(),
|
||||
)
|
||||
expect(result['je-1']).toBe('none')
|
||||
})
|
||||
|
||||
it('has wins over missing when both a doc and a needs-doc source type are present', () => {
|
||||
const result = computeJeUnderlagStatus(
|
||||
[{ id: 'je-1', source_type: 'manual' }],
|
||||
new Set(['je-1']),
|
||||
new Set(['je-1']),
|
||||
)
|
||||
expect(result['je-1']).toBe('has')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
|
||||
|
||||
/**
|
||||
* Row-level underlag status for a booked transaction's journal entry.
|
||||
*
|
||||
* - 'has' — the verifikation has at least one current-version document
|
||||
* - 'missing' — the verifikation's source type requires underlag (BFL 5 kap
|
||||
* 7§), has none, and is not exempted via journal_entry_no_doc_required
|
||||
* - 'none' — no statement either way (system-generated source types,
|
||||
* exempted entries) — render no badge
|
||||
*
|
||||
* Mirrors countVerifikatMissingDocument in lib/worklist/categories.ts at row
|
||||
* granularity so the per-row "Underlag saknas" badge and the worklist count
|
||||
* never disagree on what counts as missing. Contract: callers must pass only
|
||||
* POSTED entries (filter journal_entries on status = 'posted', like the
|
||||
* worklist does) — reversed/corrected entries must render no badge at all,
|
||||
* never 'missing'.
|
||||
*/
|
||||
export type JeUnderlagStatus = 'has' | 'missing' | 'none'
|
||||
|
||||
const NEEDS_DOC = new Set<string>(NEEDS_DOC_SOURCE_TYPES)
|
||||
|
||||
export function computeJeUnderlagStatus(
|
||||
entries: Array<{ id: string; source_type: string | null }>,
|
||||
jeIdsWithDocs: ReadonlySet<string>,
|
||||
exemptJeIds: ReadonlySet<string>,
|
||||
): Record<string, JeUnderlagStatus> {
|
||||
const result: Record<string, JeUnderlagStatus> = {}
|
||||
for (const entry of entries) {
|
||||
if (jeIdsWithDocs.has(entry.id)) {
|
||||
result[entry.id] = 'has'
|
||||
} else if (
|
||||
entry.source_type != null &&
|
||||
NEEDS_DOC.has(entry.source_type) &&
|
||||
!exemptJeIds.has(entry.id)
|
||||
) {
|
||||
result[entry.id] = 'missing'
|
||||
} else {
|
||||
result[entry.id] = 'none'
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
+31
-1
@@ -265,7 +265,15 @@
|
||||
"type_mark_invoice_paid": "Paid invoice",
|
||||
"type_send_invoice": "Send invoice",
|
||||
"type_mark_invoice_sent": "Mark as sent",
|
||||
"type_match_transaction_invoice": "Invoice match"
|
||||
"type_match_transaction_invoice": "Invoice match",
|
||||
"origin_agent_chat": "Suggested by the AI assistant via chat",
|
||||
"origin_mcp": "Suggested by an AI assistant via {label}",
|
||||
"origin_api": "Suggested via API integration",
|
||||
"origin_cron": "Created by a scheduled job",
|
||||
"explainer": "Approve executes the bookkeeping operation. Reject just discards the proposal — nothing is booked.",
|
||||
"auto_expiry_note": "Unhandled proposals expire automatically after 30 days — doing nothing is safe.",
|
||||
"badge_auto_expired": "Expired automatically",
|
||||
"auto_expired_detail": "Expired automatically after 30 days without action. Nothing was booked."
|
||||
},
|
||||
"deadlines": {
|
||||
"title": "Deadlines",
|
||||
@@ -1667,6 +1675,7 @@
|
||||
"match_supplier_invoice_btn": "Match supplier invoice {number}",
|
||||
"choose_template_btn": "Choose template...",
|
||||
"match_voucher_btn": "Match to existing voucher",
|
||||
"attach_document_btn": "Match to document",
|
||||
"more_actions_aria": "More actions",
|
||||
"delete_aria": "Delete transaction",
|
||||
"edit_title_aria": "Edit title",
|
||||
@@ -1724,6 +1733,26 @@
|
||||
"doc_link_failed_description": "{count} file(s) could not be linked to the journal entry. Try again from the bookkeeping page.",
|
||||
"bank_line_description": "Business account"
|
||||
},
|
||||
"tx_attach_dialog": {
|
||||
"title": "Match to document",
|
||||
"description": "Attach a receipt or invoice to the transaction — pick from the inbox or upload a new file.",
|
||||
"pick_existing": "Choose existing document",
|
||||
"selected_remove": "Remove selected document",
|
||||
"already_attached_hint": "This transaction already has a document. Attaching a new one replaces it.",
|
||||
"confirm": "Attach document",
|
||||
"attaching": "Attaching…",
|
||||
"success_toast": "Document attached",
|
||||
"error_toast": "Could not attach the document",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"tx_underlag": {
|
||||
"attached_label": "Document",
|
||||
"attached_title": "Supporting document attached — click to open",
|
||||
"attached_aria": "Open attached document",
|
||||
"missing_label": "Missing document",
|
||||
"missing_title": "The voucher has no supporting document — click to attach",
|
||||
"open_failed": "Could not fetch the document"
|
||||
},
|
||||
"tx_batch_selector": {
|
||||
"title_processing": "Posting {done}/{total}...",
|
||||
"title_default": "Post {count} transactions",
|
||||
@@ -2007,6 +2036,7 @@
|
||||
"create_correction": "Create correction entry",
|
||||
"not_posted": "Not posted",
|
||||
"possible_match_invoice": "Possible match: Invoice {number}",
|
||||
"attach_document": "Match to document",
|
||||
"book": "Post",
|
||||
"delete": "Delete",
|
||||
"delete_aria": "Delete transaction",
|
||||
|
||||
+31
-1
@@ -265,7 +265,15 @@
|
||||
"type_mark_invoice_paid": "Betald faktura",
|
||||
"type_send_invoice": "Skicka faktura",
|
||||
"type_mark_invoice_sent": "Markera skickad",
|
||||
"type_match_transaction_invoice": "Fakturamatchning"
|
||||
"type_match_transaction_invoice": "Fakturamatchning",
|
||||
"origin_agent_chat": "Föreslaget av AI-assistenten via chatt",
|
||||
"origin_mcp": "Föreslaget av AI-assistent via {label}",
|
||||
"origin_api": "Föreslaget via API-integration",
|
||||
"origin_cron": "Skapat av automatiskt jobb",
|
||||
"explainer": "Godkänn utför bokföringen. Avvisa kastar bara förslaget — inget bokförs.",
|
||||
"auto_expiry_note": "Förslag som inte hanteras utgår automatiskt efter 30 dagar — att inte göra något är säkert.",
|
||||
"badge_auto_expired": "Utgick automatiskt",
|
||||
"auto_expired_detail": "Utgick automatiskt efter 30 dagar utan åtgärd. Inget bokfördes."
|
||||
},
|
||||
"deadlines": {
|
||||
"title": "Deadlines",
|
||||
@@ -1667,6 +1675,7 @@
|
||||
"match_supplier_invoice_btn": "Matcha Leverantörsfaktura {number}",
|
||||
"choose_template_btn": "Välj mall...",
|
||||
"match_voucher_btn": "Matcha mot befintlig verifikation",
|
||||
"attach_document_btn": "Matcha mot underlag",
|
||||
"more_actions_aria": "Fler åtgärder",
|
||||
"delete_aria": "Ta bort transaktion",
|
||||
"edit_title_aria": "Ändra titel",
|
||||
@@ -1724,6 +1733,26 @@
|
||||
"doc_link_failed_description": "{count} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.",
|
||||
"bank_line_description": "Företagskonto"
|
||||
},
|
||||
"tx_attach_dialog": {
|
||||
"title": "Matcha mot underlag",
|
||||
"description": "Koppla ett kvitto eller en faktura till transaktionen — välj från inkorgen eller ladda upp en ny fil.",
|
||||
"pick_existing": "Välj befintligt underlag",
|
||||
"selected_remove": "Ta bort valt underlag",
|
||||
"already_attached_hint": "Transaktionen har redan ett underlag. Kopplar du ett nytt ersätts det befintliga.",
|
||||
"confirm": "Koppla underlag",
|
||||
"attaching": "Kopplar…",
|
||||
"success_toast": "Underlag kopplat",
|
||||
"error_toast": "Kunde inte koppla underlaget",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"tx_underlag": {
|
||||
"attached_label": "Underlag",
|
||||
"attached_title": "Underlag bifogat — klicka för att öppna",
|
||||
"attached_aria": "Öppna bifogat underlag",
|
||||
"missing_label": "Underlag saknas",
|
||||
"missing_title": "Verifikationen saknar underlag — klicka för att bifoga",
|
||||
"open_failed": "Kunde inte hämta underlaget"
|
||||
},
|
||||
"tx_batch_selector": {
|
||||
"title_processing": "Bokför {done}/{total}...",
|
||||
"title_default": "Bokför {count} transaktioner",
|
||||
@@ -2007,6 +2036,7 @@
|
||||
"create_correction": "Skapa ändringsverifikation",
|
||||
"not_posted": "Ej bokförd",
|
||||
"possible_match_invoice": "Möjlig match: Faktura {number}",
|
||||
"attach_document": "Matcha mot underlag",
|
||||
"book": "Bokför",
|
||||
"delete": "Ta bort",
|
||||
"delete_aria": "Ta bort transaktion",
|
||||
|
||||
@@ -46,7 +46,7 @@ Restart Claude Desktop. The Accounted tools appear in the client and you can sta
|
||||
|
||||
## Alternative: claude.ai connector (no API key)
|
||||
|
||||
If you use **claude.ai** or Claude Desktop's custom-connector flow, you can skip this bridge entirely and add Accounted as an OAuth 2.1 custom connector instead — paste the connector URL `https://app.gnubok.se/api/extensions/ext/mcp-server/mcp` and authorise on the Accounted consent screen (read-only scopes by default; write scopes are ticked explicitly).
|
||||
If you use **claude.ai** or Claude Desktop's custom-connector flow, you can skip this bridge entirely and add Accounted as an OAuth 2.1 custom connector instead — paste the connector URL `https://app.gnubok.se/api/extensions/ext/mcp-server/mcp?client=claude-connector` and authorise on the Accounted consent screen (read-only scopes by default; write scopes are ticked explicitly).
|
||||
|
||||
## Docs
|
||||
|
||||
|
||||
+4
-2
@@ -1687,7 +1687,9 @@ export type PendingOperationType =
|
||||
| 'submit_agi'
|
||||
export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected'
|
||||
|
||||
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
||||
// 'agent_chat' = the in-app AI chat (DB CHECK widened in migration
|
||||
// 20260519090000_actor_type_agent_chat).
|
||||
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'agent_chat'
|
||||
export type PendingOperationRiskLevel = 'low' | 'medium' | 'high'
|
||||
|
||||
export interface PendingOperationAgentMetadata {
|
||||
@@ -2535,7 +2537,7 @@ export interface AuditLogEntry {
|
||||
table_name: string | null
|
||||
record_id: string | null
|
||||
actor_id: string | null
|
||||
actor_type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'system' | null
|
||||
actor_type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'agent_chat' | 'system' | null
|
||||
actor_label: string | null
|
||||
old_state: Record<string, unknown> | null
|
||||
new_state: Record<string, unknown> | null
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"path": "/api/idempotency/cleanup/cron",
|
||||
"schedule": "30 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/pending-operations/expire/cron",
|
||||
"schedule": "30 2 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/skatteverket/skattekonto/sync/cron",
|
||||
"schedule": "0 4 * * *"
|
||||
|
||||
Reference in New Issue
Block a user