* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects We never sent auth_method to Enable Banking, so it fell back to the ASPSP's visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate* PSUs the redirect flow does not support Mobile BankID, so authorization failed right after the user approved in the BankID app. Mobile BankID at Handelsbanken is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses when requested explicitly. Resolve the bank's preferred auth method before /auth: query the ASPSP's auth_methods and pick the DECOUPLED (Mobile BankID) method when present, otherwise leave auth_method unset so banks that already work are untouched. The method name is read dynamically per psu_type, so it is robust across sandbox/production naming. - api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods field name (was available_auth_methods, never populated), add getPreferredAuthMethod(), thread optional authMethod through startAuthorization - index: resolve authMethod in /connect and pass it on both fresh + reconnect - tests: cover method selection and request-body shaping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(invoice-inbox): clean up bulk-selection toolbar UI Redesign the selection toolbar shown when inbox items are checked: one solid primary "Bokför valda" button with outlined secondary actions ("Fråga assistenten", "Ta bort") and a plain selection count. Removes the redundant "Avmarkera" button (users uncheck the still-visible box), fixes label clipping, and gives the toolbar more breathing room. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(entitlements): bypass paywall in local development Add isPaywallBypassed() so all gated capabilities are testable locally without a subscription. Fires only on NODE_ENV=development (npm run dev) or an explicit DISABLE_PAYWALL=true escape hatch — production builds run under NODE_ENV=production and the entitlement suite runs under 'test', so both keep exercising the real gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transactions): implement categorize core for bank transaction categorization - Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations. - Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing. - Implemented fiscal period validation and duplicate booking detection. - Enhanced logging and error handling for transaction categorization. feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata - Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken. - Outputs metadata for business and personal PSU types, including default authentication methods. fix(migrations): increase statement timeout for SIE bulk delete operations - Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports. feat(migrations): add bulk book inbox items to pending operations - Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`. - Supports bulk booking of matched inbox items against bank transactions. test(pg): add tests for replace_period_opening_balance_link RPC - Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow. - Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries. * fix(sie-export): update journal entries and lines handling in SIE export tests * fix(migrations): resolve version collision on 20260629160000 The SIE bulk-delete statement_timeout migration shared version 20260629160000 with journal_entries_list_series_filter (merged from main via #798/#823), causing a schema_migrations_pkey duplicate key error on apply. Rename the branch's migration to 20260629160100. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compliance): resolve compliance-swarm + review findings - opening-balance/correct: compensating rollback for the non-atomic storno+rebook so a mid-sequence failure never leaves two posted OB entries (ASVS V2.3); durable audit event on every failure path (V16); reference the original verifikationsnummer in the corrected entry per BFL 5 kap 5§; document that requireWrite already enforces write-role + membership (V8.2.1 was a false positive) - reports sources routes: validate the cursor date component as ISO (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2), applied to both the VAT-declaration and trial-balance routes - AgentSessionList: await the rename PATCH, revert the optimistic title and toast on failure (ASVS V4.5) - bank booking: exclude same-batch siblings from the booking-time duplicate guard so bulk-booking distinct same-(date,amount) transactions no longer false-positives; pre-existing duplicate detection is preserved - BulkBookInboxDialog: drop the unsafe currency-based reverse_charge default, add an omvänd skattskyldighet advisory, and type VAT options to the backend VatTreatment union - OpeningBalanceRowEditor: hold onChange in a ref (synced in effect, not during render) so an unstable callback can't cause a render loop Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
import { defineAgentIntent } from './types'
|
|
import { SONNET_MODEL, THINKING_BUDGET_STANDARD } from '@/lib/agent/composer/client'
|
|
import { renderAgentGroundRules } from './shared-rules'
|
|
|
|
// verifikation.draft — "Fråga om denna verifikation" on the journal entry
|
|
// creation/draft surfaces (Bokföring → "Skapa med assistent", the Ny
|
|
// verifikat-dialog, and a draft verifikat's own page).
|
|
//
|
|
// Helps the user construct a balanced verifikation end to end: read the
|
|
// underlag (kvitto/faktura) the user often can't see themselves and pull the
|
|
// figures from it, pick the right BAS accounts, handle VAT splits, and detect
|
|
// when a transaction should instead be matched to an invoice or supplier
|
|
// invoice (rather than booked from scratch). Reads any in-progress draft state
|
|
// + linked underlag passed via intent_args.
|
|
|
|
interface VerifikationDraftArgs {
|
|
// Optional id when the user is editing an existing draft. null for /new.
|
|
journal_entry_id?: string | null
|
|
// Optional starter description from the form, so the agent can suggest
|
|
// counterparty templates without round-tripping.
|
|
description?: string | null
|
|
}
|
|
|
|
interface CapturedVerifikationDraft {
|
|
entry: {
|
|
id: string
|
|
entry_date: string | null
|
|
description: string | null
|
|
status: string | null
|
|
} | null
|
|
current_lines: {
|
|
account_number: string | null
|
|
debit_amount: number | null
|
|
credit_amount: number | null
|
|
description: string | null
|
|
}[]
|
|
period_status: {
|
|
period_id: string | null
|
|
status: string | null
|
|
lock_date: string | null
|
|
} | null
|
|
description_hint: string | null
|
|
// Underlag already linked to the entry (when editing a draft). Flattened
|
|
// from document_attachments.extracted_data the same way
|
|
// transaction.categorization does, so the agent can read the figures
|
|
// without a round-trip. Empty for a brand-new verifikation — there the
|
|
// agent discovers underlag via gnubok_list_inbox_items.
|
|
underlag: {
|
|
document_id: string | null
|
|
file_name: string | null
|
|
merchant_name: string | null
|
|
receipt_date: string | null
|
|
total_amount: number | null
|
|
vat_amount: number | null
|
|
currency: string | null
|
|
raw_extraction: Record<string, unknown> | null
|
|
}[]
|
|
}
|
|
|
|
export const verifikationDraft = defineAgentIntent<
|
|
VerifikationDraftArgs,
|
|
CapturedVerifikationDraft
|
|
>({
|
|
id: 'verifikation.draft',
|
|
buttonLabel: 'Fråga om denna verifikation',
|
|
sheetTitle: 'Hjälp med verifikation',
|
|
|
|
atoms: {
|
|
mode: 'declarative',
|
|
horizontal: ['swedish-accounting-compliance', 'swedish-vat'],
|
|
includeCompanyVertical: true,
|
|
includeCompanyModifiers: true,
|
|
},
|
|
|
|
tools: [
|
|
'gnubok_get_trial_balance',
|
|
'gnubok_query_journal',
|
|
'gnubok_create_voucher',
|
|
// Underlag reading — the ground rules (shared-rules.ts) already instruct
|
|
// the agent to look in the inbox and read the underlag before proposing a
|
|
// booking; these are the tools that make those instructions callable.
|
|
'gnubok_get_document_content',
|
|
'gnubok_list_inbox_items',
|
|
'gnubok_list_unmatched_documents',
|
|
'gnubok_get_inbox_item',
|
|
'gnubok_load_skill',
|
|
'gnubok_search_tools',
|
|
'gnubok_remember_fact',
|
|
'gnubok_forget_fact',
|
|
],
|
|
|
|
model: SONNET_MODEL,
|
|
|
|
// Work out the entry (accounts, VAT, balance) in the thinking channel, so the
|
|
// visible reply lands once — after the voucher is staged — instead of an
|
|
// analysis before the tool call and a near-identical answer after it. The
|
|
// always-on prompt promises "resonemang sker i tankekanalen"; without this
|
|
// that channel doesn't exist and the reasoning spills into the visible reply.
|
|
thinking: { budgetTokens: THINKING_BUDGET_STANDARD },
|
|
|
|
capture: async ({ journal_entry_id, description }, { supabase, companyId }) => {
|
|
let entry: CapturedVerifikationDraft['entry'] = null
|
|
let lines: CapturedVerifikationDraft['current_lines'] = []
|
|
let periodStatus: CapturedVerifikationDraft['period_status'] = null
|
|
const underlag: CapturedVerifikationDraft['underlag'] = []
|
|
|
|
if (journal_entry_id) {
|
|
const { data: e } = await supabase
|
|
.from('journal_entries')
|
|
.select('id, entry_date, description, status')
|
|
.eq('id', journal_entry_id)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (e) {
|
|
entry = {
|
|
id: (e as { id: string }).id,
|
|
entry_date: ((e as { entry_date?: string | null }).entry_date) ?? null,
|
|
description: ((e as { description?: string | null }).description) ?? null,
|
|
status: ((e as { status?: string | null }).status) ?? null,
|
|
}
|
|
const { data: rows } = await supabase
|
|
.from('journal_entry_lines')
|
|
.select('account_number, debit_amount, credit_amount, description')
|
|
.eq('journal_entry_id', journal_entry_id)
|
|
.order('id', { ascending: true })
|
|
lines = (rows ?? []) as CapturedVerifikationDraft['current_lines']
|
|
const entryDate = entry?.entry_date ?? null
|
|
if (entryDate) {
|
|
const { data: period } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id, status, locked_through')
|
|
.eq('company_id', companyId)
|
|
.lte('period_start', entryDate)
|
|
.gte('period_end', entryDate)
|
|
.maybeSingle()
|
|
if (period) {
|
|
periodStatus = {
|
|
period_id: (period as { id: string }).id,
|
|
status: ((period as { status?: string | null }).status) ?? null,
|
|
lock_date: ((period as { locked_through?: string | null }).locked_through) ?? null,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Underlag already linked to this draft — surface the extracted fields
|
|
// so the agent suggests accounts from what's on the kvitto without
|
|
// re-asking. Mirrors transaction.categorization's document_attachments
|
|
// read (same table, same extracted_data shape).
|
|
const { data: docs } = await supabase
|
|
.from('document_attachments')
|
|
.select('id, file_name, extracted_data')
|
|
.eq('journal_entry_id', journal_entry_id)
|
|
.eq('company_id', companyId)
|
|
.eq('is_current_version', true)
|
|
for (const d of (docs ?? []) as {
|
|
id: string
|
|
file_name: string | null
|
|
extracted_data: Record<string, unknown> | null
|
|
}[]) {
|
|
const ex = d.extracted_data ?? null
|
|
const supplier = (ex?.supplier as { name?: string | null } | undefined) ?? null
|
|
const invoice = (ex?.invoice as { invoiceDate?: string | null; currency?: string | null } | undefined) ?? null
|
|
const totals = (ex?.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
|
|
underlag.push({
|
|
document_id: d.id,
|
|
file_name: d.file_name,
|
|
merchant_name: supplier?.name ?? null,
|
|
receipt_date: invoice?.invoiceDate ?? null,
|
|
total_amount: totals?.total ?? null,
|
|
vat_amount: totals?.vatAmount ?? null,
|
|
currency: invoice?.currency ?? null,
|
|
raw_extraction: ex,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
entry,
|
|
current_lines: lines,
|
|
period_status: periodStatus,
|
|
description_hint: description ?? null,
|
|
underlag,
|
|
}
|
|
},
|
|
|
|
promptTemplate: ({ captured, profileSummary }) => {
|
|
const lines: string[] = []
|
|
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
|
|
|
|
lines.push('Användaren skapar eller redigerar en verifikation.')
|
|
if (captured.entry) {
|
|
lines.push(
|
|
`Verifikation: ${captured.entry.id} (${captured.entry.entry_date ?? '?'}, status ${captured.entry.status ?? '?'})`,
|
|
)
|
|
if (captured.entry.description) lines.push(`Beskrivning: ${captured.entry.description}`)
|
|
} else if (captured.description_hint) {
|
|
lines.push(`Användarens beskrivning än så länge: "${captured.description_hint}"`)
|
|
} else {
|
|
lines.push('Ny verifikation, inga rader än.')
|
|
}
|
|
lines.push('')
|
|
lines.push(renderAgentGroundRules())
|
|
lines.push('')
|
|
|
|
if (captured.current_lines.length > 0) {
|
|
lines.push('')
|
|
lines.push('Befintliga rader:')
|
|
let debits = 0
|
|
let credits = 0
|
|
for (const r of captured.current_lines) {
|
|
const d = r.debit_amount ?? 0
|
|
const c = r.credit_amount ?? 0
|
|
debits += d
|
|
credits += c
|
|
const dStr = d > 0 ? d.toLocaleString('sv-SE') : ''
|
|
const cStr = c > 0 ? c.toLocaleString('sv-SE') : ''
|
|
lines.push(` ${r.account_number ?? '????'} ${dStr.padStart(12)} ${cStr.padStart(12)} ${r.description ?? ''}`)
|
|
}
|
|
lines.push(` SUMMA ${debits.toLocaleString('sv-SE').padStart(12)} ${credits.toLocaleString('sv-SE').padStart(12)}`)
|
|
if (Math.abs(debits - credits) > 0.005) {
|
|
lines.push(` ⚠ Diff: ${(debits - credits).toLocaleString('sv-SE')} — debet ≠ kredit`)
|
|
}
|
|
}
|
|
|
|
if (captured.underlag.length > 0) {
|
|
lines.push('')
|
|
lines.push(`UNDERLAG kopplat till verifikationen: ${captured.underlag.length} st. Extraherade fält:`)
|
|
for (const u of captured.underlag) {
|
|
const parts: string[] = []
|
|
if (u.document_id) parts.push(`document_id=${u.document_id}`)
|
|
if (u.merchant_name) parts.push(`leverantör=${u.merchant_name}`)
|
|
if (u.receipt_date) parts.push(`datum=${u.receipt_date}`)
|
|
if (u.total_amount != null) {
|
|
parts.push(`total=${u.total_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
|
|
}
|
|
if (u.vat_amount != null) {
|
|
parts.push(`moms=${u.vat_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
|
|
}
|
|
lines.push(
|
|
` • ${parts.join(', ') || `${u.file_name ?? 'underlag'} (ingen extraherad data — läs med gnubok_get_document_content)`}`,
|
|
)
|
|
}
|
|
lines.push('')
|
|
lines.push('Extraktionen ovan är det vi REDAN VET — fråga inte om leverantör/belopp som står där. Räcker den inte (t.ex. saknar momsbelopp), läs underlaget med gnubok_get_document_content(document_id=…).')
|
|
}
|
|
|
|
if (captured.period_status) {
|
|
lines.push('')
|
|
lines.push(
|
|
`Period: ${captured.period_status.period_id ?? '?'} (status ${captured.period_status.status ?? '?'}${
|
|
captured.period_status.lock_date ? `, låst t.o.m. ${captured.period_status.lock_date}` : ''
|
|
})`,
|
|
)
|
|
if (captured.period_status.status === 'locked' || captured.period_status.status === 'closed') {
|
|
lines.push('PERIODEN ÄR LÅST — ett utkast kan inte bokföras här. Vägled användaren att ändra verifikationsdatumet till en öppen period (utkast redigeras fritt), eller att låsa upp perioden under Bokföring → Räkenskapsår om datumet måste stå kvar.')
|
|
}
|
|
}
|
|
lines.push('')
|
|
lines.push('Arbetssätt:')
|
|
lines.push('1. UNDERLAG FÖRST. Saknas underlaget i sammanhanget ovan: leta i Dokumentinkorgen med gnubok_list_inbox_items (och gnubok_list_unmatched_documents). Läs det relevanta underlaget med gnubok_get_inbox_item / gnubok_get_document_content och dra fram datum, belopp, moms och motpart INNAN du föreslår konton. Användaren ser ofta inte underlagets innehåll själv — det är just det du hjälper till med.')
|
|
lines.push('2. Föreslå rätt BAS-konton utifrån underlaget och beskrivningen. Syns en motpart — kolla historiken med gnubok_query_journal({ text: "<motpartens namn>", limit: 5 }) och följ tidigare mönster.')
|
|
lines.push('3. Säkerställ att debet = kredit. Förklara varje rad kort (i kategori-/kontonamn, inte kontonummer).')
|
|
lines.push('4. Är detta egentligen en kund-/leverantörsfaktura eller en bankrad? Be användaren matcha den istället — direktbokning skapar dubbletter.')
|
|
lines.push('5. Skapa verifikationen:')
|
|
lines.push(' • NY verifikation (inget utkast visas ovan): staga via gnubok_create_voucher när allt stämmer. Ligger underlaget i Dokumentinkorgen — skicka med inbox_item_id så kvittot kopplas till verifikationen automatiskt vid godkännande.')
|
|
lines.push(' • BEFINTLIGT utkast (visas ovan): föreslå konton/moms och kontrollera balansen så att användaren kan färdigställa utkastet i formuläret. Staga INTE en ny verifikation för ett utkast som redan finns — det skapar en dubblett.')
|
|
lines.push('')
|
|
lines.push('Svara på svenska, kort och konkret.')
|
|
return lines.join('\n')
|
|
},
|
|
})
|