One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
459 lines
19 KiB
TypeScript
459 lines
19 KiB
TypeScript
import type { McpResource } from './types'
|
|
import { ACTION_NEEDED_THRESHOLD_DAYS } from '@/lib/deadlines/status-engine'
|
|
import {
|
|
fetchUnlinkedDocuments,
|
|
UNLINKED_DOCUMENT_SCAN_CAP,
|
|
} from '@/lib/documents/unlinked-documents'
|
|
import { countReconciliationDue } from '@/lib/worklist/categories'
|
|
import { fetchJunctionLinkedTxIds } from '@/lib/reconciliation/bank-reconciliation'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
|
|
type Severity = 'critical' | 'warning' | 'info'
|
|
|
|
interface AttentionCategory {
|
|
key: string
|
|
label_sv: string
|
|
severity: Severity
|
|
count: number
|
|
samples: Array<Record<string, unknown>>
|
|
next?: {
|
|
description: string
|
|
tool?: string
|
|
args?: Record<string, unknown>
|
|
resource?: string
|
|
}
|
|
}
|
|
|
|
const SAMPLE_LIMIT = 5
|
|
|
|
function daysBetween(fromIso: string, toIso: string): number {
|
|
const ms = new Date(toIso).getTime() - new Date(fromIso).getTime()
|
|
return Math.round(ms / 86_400_000)
|
|
}
|
|
|
|
export const attentionResource: McpResource = {
|
|
uri: 'Accounted://attention',
|
|
name: 'What Needs Attention',
|
|
description:
|
|
'One-shot summary of outstanding work for the active company: unbooked transactions, overdue invoices, pending approvals, documents linked to no verifikat, voucher gaps, upcoming deadlines, bank consent expiry, and period-lock alerts. Each category includes a count, up to 5 sample rows, and a suggested next tool call. Use this at session start to orient before chaining read tools.',
|
|
mimeType: 'application/json',
|
|
read: async ({ supabase, companyId }) => {
|
|
const now = new Date()
|
|
const today = now.toISOString().slice(0, 10)
|
|
const horizonDate = new Date(now.getTime() + ACTION_NEEDED_THRESHOLD_DAYS * 86_400_000)
|
|
const horizon = horizonDate.toISOString().slice(0, 10)
|
|
|
|
const [
|
|
unbookedIds,
|
|
unbookedSamples,
|
|
overdueRows,
|
|
pendingSupplierHead,
|
|
pendingSupplierSamples,
|
|
pendingOpsHead,
|
|
pendingOpsSamples,
|
|
unmatchedReceiptsHead,
|
|
unmatchedReceiptsSamples,
|
|
voucherSeriesRows,
|
|
deadlineRows,
|
|
bankConnRows,
|
|
activePeriodRow,
|
|
companySettingsRow,
|
|
unlinkedDocuments,
|
|
] = await Promise.all([
|
|
// Ids, not a head count: journal_entry_id IS NULL is only the first of
|
|
// the "booked" anchors (lib/transactions/is-booked.ts). Rows bulk-booked
|
|
// into a samlingsverifikat or split over several verifikat (1:N) are
|
|
// anchored through transaction_voucher_links and are subtracted below.
|
|
fetchAllRows<{ id: string }>(({ from, to }) =>
|
|
supabase
|
|
.from('transactions')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.is('journal_entry_id', null)
|
|
.eq('is_business', true)
|
|
.order('id')
|
|
.range(from, to),
|
|
).catch(() => [] as Array<{ id: string }>),
|
|
supabase
|
|
.from('transactions')
|
|
.select('id, date, amount, currency, description, merchant_name')
|
|
.eq('company_id', companyId)
|
|
.is('journal_entry_id', null)
|
|
.eq('is_business', true)
|
|
.order('date', { ascending: true })
|
|
.limit(SAMPLE_LIMIT * 4),
|
|
supabase
|
|
.from('invoices')
|
|
.select('id, invoice_number, customer_id, due_date, total, currency, status')
|
|
.eq('company_id', companyId)
|
|
.in('status', ['sent', 'overdue'])
|
|
.lt('due_date', today)
|
|
.order('due_date', { ascending: true })
|
|
.limit(100),
|
|
supabase
|
|
.from('supplier_invoices')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'registered'),
|
|
supabase
|
|
.from('supplier_invoices')
|
|
.select('id, supplier_invoice_number, supplier_id, total, currency, due_date')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'registered')
|
|
.order('due_date', { ascending: true })
|
|
.limit(SAMPLE_LIMIT),
|
|
supabase
|
|
.from('pending_operations')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'pending'),
|
|
supabase
|
|
.from('pending_operations')
|
|
.select('id, operation_type, title, risk_level, actor_label, created_at')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'pending')
|
|
.order('created_at', { ascending: false })
|
|
.limit(SAMPLE_LIMIT),
|
|
supabase
|
|
.from('receipts')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'confirmed')
|
|
.is('matched_transaction_id', null),
|
|
supabase
|
|
.from('receipts')
|
|
.select('id, receipt_date, total_amount, currency, merchant_name')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'confirmed')
|
|
.is('matched_transaction_id', null)
|
|
.order('receipt_date', { ascending: false, nullsFirst: false })
|
|
.limit(SAMPLE_LIMIT),
|
|
supabase
|
|
.from('voucher_sequences')
|
|
.select('voucher_series, fiscal_period_id')
|
|
.eq('company_id', companyId),
|
|
supabase
|
|
.from('deadlines')
|
|
.select('id, title, due_date, deadline_type, tax_deadline_type, status')
|
|
.eq('company_id', companyId)
|
|
.eq('is_completed', false)
|
|
.lte('due_date', horizon)
|
|
.order('due_date', { ascending: true })
|
|
.limit(20),
|
|
supabase
|
|
.from('bank_connections')
|
|
.select('id, bank_name, status, consent_expires')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'active')
|
|
.not('consent_expires', 'is', null),
|
|
supabase
|
|
.from('fiscal_periods')
|
|
.select('id, name, period_start, period_end, locked_at, is_closed')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', today)
|
|
.gte('period_end', today)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from('company_settings')
|
|
.select('bookkeeping_locked_through, auto_lock_period_days')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle(),
|
|
fetchUnlinkedDocuments(supabase, companyId),
|
|
])
|
|
|
|
const categories: AttentionCategory[] = []
|
|
|
|
// ── Unbooked business transactions ──────────────────────────────
|
|
const pointerUnbookedIds = unbookedIds.map((row) => row.id)
|
|
const junctionLinked =
|
|
pointerUnbookedIds.length > 0
|
|
? await fetchJunctionLinkedTxIds(supabase, companyId, pointerUnbookedIds)
|
|
: new Set<string>()
|
|
const unbookedCount = pointerUnbookedIds.filter((id) => !junctionLinked.has(id)).length
|
|
const samples = (unbookedSamples.data ?? [])
|
|
.filter((row) => !junctionLinked.has(row.id as string))
|
|
.slice(0, SAMPLE_LIMIT)
|
|
if (unbookedCount > 0) {
|
|
const oldest = samples[0]
|
|
const oldestAgeDays = oldest?.date ? daysBetween(oldest.date, today) : 0
|
|
categories.push({
|
|
key: 'unbooked_transactions',
|
|
label_sv: 'Obokförda affärstransaktioner',
|
|
severity: oldestAgeDays > 30 ? 'critical' : 'warning',
|
|
count: unbookedCount,
|
|
samples,
|
|
next: {
|
|
description: 'Kategorisera den äldsta obokförda transaktionen.',
|
|
tool: 'gnubok_categorize_transaction',
|
|
args: oldest ? { transaction_id: oldest.id } : undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Overdue invoices ────────────────────────────────────────────
|
|
const overdueAll = overdueRows.data ?? []
|
|
if (overdueAll.length > 0) {
|
|
const maxOverdueDays = overdueAll.reduce((max, inv) => {
|
|
const days = inv.due_date ? daysBetween(inv.due_date, today) : 0
|
|
return Math.max(max, days)
|
|
}, 0)
|
|
categories.push({
|
|
key: 'overdue_invoices',
|
|
label_sv: 'Förfallna fakturor',
|
|
severity: maxOverdueDays > 30 ? 'critical' : 'warning',
|
|
count: overdueAll.length,
|
|
samples: overdueAll.slice(0, SAMPLE_LIMIT),
|
|
next: {
|
|
description: 'Granska förfallna fakturor och skicka påminnelser.',
|
|
resource: 'Accounted://recent-activity?limit=20',
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Pending supplier invoices (status='registered') ─────────────
|
|
const pendingSupplierCount = pendingSupplierHead.count ?? 0
|
|
if (pendingSupplierCount > 0) {
|
|
const oldestRegistered = pendingSupplierSamples.data?.[0]
|
|
categories.push({
|
|
key: 'pending_supplier_invoices',
|
|
label_sv: 'Leverantörsfakturor som väntar på godkännande',
|
|
severity: 'warning',
|
|
count: pendingSupplierCount,
|
|
samples: pendingSupplierSamples.data ?? [],
|
|
next: {
|
|
description: 'Godkänn äldsta registrerade leverantörsfakturan.',
|
|
tool: 'gnubok_approve_supplier_invoice',
|
|
args: oldestRegistered ? { supplier_invoice_id: oldestRegistered.id } : undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Pending operations awaiting approval ────────────────────────
|
|
const pendingOpsCount = pendingOpsHead.count ?? 0
|
|
if (pendingOpsCount > 0) {
|
|
const ops = pendingOpsSamples.data ?? []
|
|
const hasHighRisk = ops.some((o) => o.risk_level === 'high')
|
|
categories.push({
|
|
key: 'pending_operations',
|
|
label_sv: 'Operationer som väntar på godkännande',
|
|
severity: hasHighRisk ? 'critical' : 'warning',
|
|
count: pendingOpsCount,
|
|
samples: ops,
|
|
next: {
|
|
description:
|
|
'Visa kön för användaren. När användaren godkänner en specifik operation_id i chatten, anropa gnubok_approve_pending_operation direkt: /pending är ett alternativ, inte ett krav.',
|
|
tool: 'gnubok_list_pending_operations',
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Unmatched receipts ──────────────────────────────────────────
|
|
const unmatchedReceiptsCount = unmatchedReceiptsHead.count ?? 0
|
|
if (unmatchedReceiptsCount > 0) {
|
|
const samples = unmatchedReceiptsSamples.data ?? []
|
|
const oldest = samples[samples.length - 1]
|
|
categories.push({
|
|
key: 'unmatched_receipts',
|
|
label_sv: 'Kvitton utan matchad transaktion',
|
|
severity: 'warning',
|
|
count: unmatchedReceiptsCount,
|
|
samples,
|
|
next: {
|
|
description: 'Försök matcha kvitto mot bankhändelse.',
|
|
tool: 'gnubok_receipt_matcher',
|
|
args: oldest ? { receipt_id: oldest.id } : undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Documents attached to nothing ──────────────────────────────
|
|
//
|
|
// Underlag-shaped files only: the same query without a mime allow-list
|
|
// returns 11 309 archived PSD2 bank-API responses on production, which are
|
|
// unlinked by design and must never be presented as work. See
|
|
// lib/documents/unlinked-documents.ts.
|
|
if (unlinkedDocuments.count > 0) {
|
|
const oldest = unlinkedDocuments.documents[unlinkedDocuments.documents.length - 1]
|
|
categories.push({
|
|
key: 'unlinked_documents',
|
|
label_sv: 'Dokument utan koppling till verifikat eller transaktion',
|
|
severity: 'warning',
|
|
count: unlinkedDocuments.count,
|
|
samples: unlinkedDocuments.documents.slice(0, SAMPLE_LIMIT),
|
|
next: {
|
|
// Two legitimate destinations, and the tool pointer can only name
|
|
// one. A document that arrived after the fact is linked to the
|
|
// posted verifikat; one whose affärshändelse was never booked
|
|
// belongs to a new verifikat as its underlag (BFL 5 kap. 6 §), which
|
|
// is what gnubok_link_document_to_voucher's own description says to
|
|
// prefer. The prose carries the choice, the pointer carries the
|
|
// common case, and journal_entry_id is the agent's to resolve.
|
|
description: unlinkedDocuments.capped
|
|
? `Koppla dokumentet till rätt verifikat, eller bokför affärshändelsen med dokumentet som underlag om den inte är bokförd än. Minst ${unlinkedDocuments.count} dokument saknar koppling (avsökningen stannade vid ${UNLINKED_DOCUMENT_SCAN_CAP} kandidater).`
|
|
: 'Koppla dokumentet till rätt verifikat, eller bokför affärshändelsen med dokumentet som underlag om den inte är bokförd än.',
|
|
tool: 'gnubok_link_document_to_voucher',
|
|
args: oldest ? { document_id: oldest.id } : undefined,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Voucher gaps without explanations ──────────────────────────
|
|
const seriesRows = (voucherSeriesRows.data ?? []) as Array<{ voucher_series: string; fiscal_period_id: string }>
|
|
const allGaps: Array<{ series: string; gap_start: number; gap_end: number; fiscal_period_id: string }> = []
|
|
for (const row of seriesRows) {
|
|
const { data: gaps } = await supabase.rpc('detect_voucher_gaps', {
|
|
p_company_id: companyId,
|
|
p_fiscal_period_id: row.fiscal_period_id,
|
|
p_series: row.voucher_series,
|
|
})
|
|
if (gaps && Array.isArray(gaps)) {
|
|
for (const g of gaps as Array<{ gap_start: number; gap_end: number }>) {
|
|
allGaps.push({
|
|
series: row.voucher_series,
|
|
gap_start: g.gap_start,
|
|
gap_end: g.gap_end,
|
|
fiscal_period_id: row.fiscal_period_id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
if (allGaps.length > 0) {
|
|
const { data: explanations } = await supabase
|
|
.from('voucher_gap_explanations')
|
|
.select('voucher_series, gap_start, gap_end, fiscal_period_id')
|
|
.eq('company_id', companyId)
|
|
const explainedKeys = new Set(
|
|
(explanations ?? []).map(
|
|
(e) => `${e.fiscal_period_id}:${e.voucher_series}:${e.gap_start}:${e.gap_end}`
|
|
)
|
|
)
|
|
const unexplained = allGaps.filter(
|
|
(g) => !explainedKeys.has(`${g.fiscal_period_id}:${g.series}:${g.gap_start}:${g.gap_end}`)
|
|
)
|
|
if (unexplained.length > 0) {
|
|
const first = unexplained[0]
|
|
categories.push({
|
|
key: 'voucher_gaps_unexplained',
|
|
label_sv: 'Verifikationshål utan förklaring (BFNAR 2013:2)',
|
|
severity: 'critical',
|
|
count: unexplained.length,
|
|
samples: unexplained.slice(0, SAMPLE_LIMIT),
|
|
next: {
|
|
description: 'Dokumentera hålet i verifikationsserien.',
|
|
tool: 'gnubok_explain_voucher_gap',
|
|
args: first
|
|
? {
|
|
fiscal_period_id: first.fiscal_period_id,
|
|
voucher_series: first.series,
|
|
gap_start: first.gap_start,
|
|
gap_end: first.gap_end,
|
|
}
|
|
: undefined,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Deadlines upcoming (within 14 days) ─────────────────────────
|
|
const deadlines = deadlineRows.data ?? []
|
|
if (deadlines.length > 0) {
|
|
const anyOverdue = deadlines.some((d) => d.due_date && d.due_date < today)
|
|
categories.push({
|
|
key: 'deadlines_upcoming',
|
|
label_sv: 'Deadlines inom 14 dagar',
|
|
severity: anyOverdue ? 'critical' : 'warning',
|
|
count: deadlines.length,
|
|
samples: deadlines.slice(0, SAMPLE_LIMIT),
|
|
next: {
|
|
description: 'Granska kommande deadlines i /deadlines.',
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Bank consent expiring ───────────────────────────────────────
|
|
const bankConns = bankConnRows.data ?? []
|
|
const expiring = bankConns
|
|
.map((c) => {
|
|
const daysLeft = c.consent_expires ? daysBetween(today, c.consent_expires) : null
|
|
return { ...c, days_left: daysLeft }
|
|
})
|
|
.filter((c) => c.days_left != null && c.days_left <= ACTION_NEEDED_THRESHOLD_DAYS)
|
|
if (expiring.length > 0) {
|
|
const anyExpired = expiring.some((c) => (c.days_left ?? 0) <= 0)
|
|
categories.push({
|
|
key: 'bank_consent_expiring',
|
|
label_sv: 'Bankanslutningar med samtycke som löper ut',
|
|
severity: anyExpired ? 'critical' : 'warning',
|
|
count: expiring.length,
|
|
samples: expiring.slice(0, SAMPLE_LIMIT).map((c) => ({
|
|
id: c.id,
|
|
bank_name: c.bank_name,
|
|
consent_expires: c.consent_expires,
|
|
days_left: c.days_left,
|
|
})),
|
|
next: {
|
|
description: 'Be användaren förnya bank-samtycket innan det löper ut.',
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Accounts not signed off through the previous month end ──────
|
|
// Cheap by construction (lib/worklist countReconciliationDue: no bridge
|
|
// computation) and zero until the company has signed anything off.
|
|
const reconciliationDue = await countReconciliationDue(supabase, companyId, now)
|
|
if (reconciliationDue > 0) {
|
|
categories.push({
|
|
key: 'reconciliation_due',
|
|
label_sv: 'Konton som inte är avstämda t.o.m. förra månadsskiftet',
|
|
severity: 'warning',
|
|
count: reconciliationDue,
|
|
samples: [],
|
|
next: {
|
|
description:
|
|
'Läs Accounted://reconciliation/summary för bryggan per konto; koppla föreslagna par, bokför det som saknas och signera med gnubok_reconcile_signoff när oförklarat är 0.',
|
|
resource: 'Accounted://reconciliation/summary',
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Period lock approaching ─────────────────────────────────────
|
|
const lockDate = companySettingsRow.data?.bookkeeping_locked_through ?? null
|
|
if (lockDate && activePeriodRow.data) {
|
|
const daysUntilLock = daysBetween(today, lockDate)
|
|
if (daysUntilLock >= 0 && daysUntilLock <= ACTION_NEEDED_THRESHOLD_DAYS) {
|
|
categories.push({
|
|
key: 'period_lock_approaching',
|
|
label_sv: 'Bokföringslås närmar sig',
|
|
severity: 'info',
|
|
count: 1,
|
|
samples: [
|
|
{
|
|
lock_date: lockDate,
|
|
days_until: daysUntilLock,
|
|
active_period_id: activePeriodRow.data.id,
|
|
},
|
|
],
|
|
next: {
|
|
description: 'Slutför obokfört arbete innan lock_date.',
|
|
resource: 'Accounted://period/active',
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Summary tally ───────────────────────────────────────────────
|
|
const summary = {
|
|
total_items: categories.reduce((sum, c) => sum + c.count, 0),
|
|
critical: categories.filter((c) => c.severity === 'critical').length,
|
|
warning: categories.filter((c) => c.severity === 'warning').length,
|
|
info: categories.filter((c) => c.severity === 'info').length,
|
|
}
|
|
|
|
return {
|
|
generated_at: now.toISOString(),
|
|
summary,
|
|
categories,
|
|
}
|
|
},
|
|
}
|