Files
accounted/lib/agent/intents/supplier-invoice-review.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

340 lines
15 KiB
TypeScript

import { defineAgentIntent } from './types'
import { OPUS_MODEL, THINKING_BUDGET_DEEP } from '@/lib/agent/composer/client'
import { renderAgentGroundRules } from './shared-rules'
// supplier_invoice.review: "Fråga din assistent" from a supplier invoice
// detail page. Helps the user verify a supplier invoice before attestering
// it: BAS account, VAT treatment (reverse charge byggtjänster?), anomaly
// detection vs. prior invoices from the same supplier, and missing-field
// checks against ML 17 kap 24§.
//
// Declarative atom mode: loads VAT + invoice compliance + accounting
// compliance upfront, plus the company's vertical + modifier atoms. AP
// flows benefit most from these: invoices from EU suppliers trigger
// reverse charge logic, bygg suppliers trigger omvänd skattskyldighet.
//
// Default model: Opus (per plan §8 V1 #5: heavy intent). The reasoning
// chain is non-trivial:
// 1. Read inbox-extracted fields (supplier, total, VAT, line items)
// 2. Compare to supplier history: anomalies?
// 3. Detect reverse charge cases (EU, bygg)
// 4. Verify ML 17 kap 24§ mandatory fields are present
// 5. Propose BAS account + VAT code
//
// Plan ref: dev_docs/specialized-agent-plan.md §8 (V1 intent #5).
interface SupplierInvoiceReviewArgs {
supplier_invoice_id: string
}
interface CapturedSupplierInvoiceReview {
invoice: {
id: string
arrival_number: number | null
supplier_invoice_number: string | null
invoice_date: string | null
due_date: string | null
status: string | null
currency: string | null
subtotal: number | null
vat_amount: number | null
total: number | null
vat_treatment: string | null
reverse_charge: boolean | null
payment_reference: string | null
is_credit_note: boolean | null
document_id: string | null
} | null
supplier: {
id: string
name: string | null
org_number: string | null
vat_number: string | null
country: string | null
} | null
items: {
description: string | null
quantity: number | null
unit_price: number | null
line_total: number | null
vat_rate: number | null
account_number: string | null
}[]
recent_invoices_from_supplier: {
invoice_number: string | null
invoice_date: string | null
total: number | null
currency: string | null
status: string | null
}[]
// Linked inbox / document extraction so the agent doesn't re-ask for what
// the AI has already extracted.
inbox_extraction: Record<string, unknown> | null
document_extraction: Record<string, unknown> | null
}
export const supplierInvoiceReview = defineAgentIntent<
SupplierInvoiceReviewArgs,
CapturedSupplierInvoiceReview
>({
id: 'supplier_invoice.review',
buttonLabel: 'Granska med assistent',
sheetTitle: 'Granska leverantörsfaktura',
atoms: {
mode: 'declarative',
horizontal: ['swedish-vat', 'swedish-invoice-compliance', 'swedish-accounting-compliance'],
includeCompanyVertical: true,
includeCompanyModifiers: true,
},
tools: [
'gnubok_get_supplier_ledger',
'gnubok_query_journal',
'gnubok_get_document_content',
'gnubok_approve_supplier_invoice',
'gnubok_credit_supplier_invoice',
'gnubok_load_skill',
'gnubok_search_tools',
'gnubok_remember_fact',
'gnubok_forget_fact',
],
// Opus per plan §8: anomaly detection + multi-source synthesis benefits
// from deeper reasoning than Sonnet's strength on selection tasks.
model: OPUS_MODEL,
// Reason about underlag, VAT treatment and anomalies in the thinking channel
// so the visible reply is a single conclusion after the booking is staged:
// not a pre-tool analysis echoed again post-tool. Matches the always-on
// prompt's promise that reasoning happens in the (separately shown) tankekanal.
thinking: { budgetTokens: THINKING_BUDGET_DEEP },
capture: async ({ supplier_invoice_id }, { supabase, companyId }) => {
const { data: invoice } = await supabase
.from('supplier_invoices')
.select(
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, vat_treatment, reverse_charge, payment_reference, is_credit_note, document_id',
)
.eq('id', supplier_invoice_id)
.eq('company_id', companyId)
.maybeSingle()
if (!invoice) {
return {
invoice: null,
supplier: null,
items: [],
recent_invoices_from_supplier: [],
inbox_extraction: null,
document_extraction: null,
}
}
const supplierId = (invoice as { supplier_id: string }).supplier_id
const documentId = (invoice as { document_id: string | null }).document_id
const [
{ data: supplier },
{ data: items },
{ data: recent },
{ data: inboxRow },
{ data: docRow },
] = await Promise.all([
supplierId
? supabase
.from('suppliers')
.select('id, name, org_number, vat_number, country')
.eq('id', supplierId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
supabase
.from('supplier_invoice_items')
.select('description, quantity, unit_price, line_total, vat_rate, account_number')
.eq('supplier_invoice_id', supplier_invoice_id),
supplierId
? supabase
.from('supplier_invoices')
.select('supplier_invoice_number, invoice_date, total, currency, status')
.eq('supplier_id', supplierId)
.eq('company_id', companyId)
.neq('id', supplier_invoice_id)
.order('invoice_date', { ascending: false })
.limit(5)
: Promise.resolve({ data: [] }),
documentId
? supabase
.from('invoice_inbox_items')
.select('extracted_data')
.eq('document_id', documentId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
documentId
? supabase
.from('document_attachments')
.select('extracted_data')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
])
return {
invoice: {
id: (invoice as { id: string }).id,
arrival_number: ((invoice as { arrival_number?: number }).arrival_number) ?? null,
supplier_invoice_number:
((invoice as { supplier_invoice_number?: string | null }).supplier_invoice_number) ?? null,
invoice_date: ((invoice as { invoice_date?: string | null }).invoice_date) ?? null,
due_date: ((invoice as { due_date?: string | null }).due_date) ?? null,
status: ((invoice as { status?: string | null }).status) ?? null,
currency: ((invoice as { currency?: string | null }).currency) ?? null,
subtotal: ((invoice as { subtotal?: number | null }).subtotal) ?? null,
vat_amount: ((invoice as { vat_amount?: number | null }).vat_amount) ?? null,
total: ((invoice as { total?: number | null }).total) ?? null,
vat_treatment: ((invoice as { vat_treatment?: string | null }).vat_treatment) ?? null,
reverse_charge: ((invoice as { reverse_charge?: boolean | null }).reverse_charge) ?? null,
payment_reference:
((invoice as { payment_reference?: string | null }).payment_reference) ?? null,
is_credit_note: ((invoice as { is_credit_note?: boolean | null }).is_credit_note) ?? null,
document_id: documentId,
},
supplier: supplier
? {
id: (supplier as { id: string }).id,
name: ((supplier as { name?: string | null }).name) ?? null,
org_number: ((supplier as { org_number?: string | null }).org_number) ?? null,
vat_number: ((supplier as { vat_number?: string | null }).vat_number) ?? null,
country: ((supplier as { country?: string | null }).country) ?? null,
}
: null,
items: ((items ?? []) as {
description: string | null
quantity: number | null
unit_price: number | null
line_total: number | null
vat_rate: number | null
account_number: string | null
}[]).map((i) => ({
description: i.description,
quantity: i.quantity,
unit_price: i.unit_price,
line_total: i.line_total,
vat_rate: i.vat_rate,
account_number: i.account_number,
})),
recent_invoices_from_supplier: ((recent ?? []) as {
supplier_invoice_number: string | null
invoice_date: string | null
total: number | null
currency: string | null
status: string | null
}[]).map((r) => ({
invoice_number: r.supplier_invoice_number,
invoice_date: r.invoice_date,
total: r.total,
currency: r.currency,
status: r.status,
})),
inbox_extraction: (inboxRow as { extracted_data?: Record<string, unknown> | null } | null)?.extracted_data ?? null,
document_extraction: (docRow as { extracted_data?: Record<string, unknown> | null } | null)?.extracted_data ?? null,
}
},
promptTemplate: ({ captured, profileSummary }) => {
if (!captured.invoice) {
return [
'Användaren öppnade hjälpfönstret från en leverantörsfaktura, men fakturan kunde inte hittas.',
'Be om mer information och försök hjälpa på generell nivå.',
].join(' ')
}
const inv = captured.invoice
const lines: string[] = []
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
lines.push(renderAgentGroundRules())
lines.push('')
lines.push('Granska denna leverantörsfaktura innan attestering:')
lines.push(`- Ankomst #${inv.arrival_number ?? '?'} / Fakturanummer ${inv.supplier_invoice_number ?? '?'}`)
if (captured.supplier) {
const s = captured.supplier
const supplierLine: string[] = []
if (s.name) supplierLine.push(`Leverantör: ${s.name}`)
if (s.country && s.country !== 'SE') supplierLine.push(`(${s.country})`)
if (s.org_number) supplierLine.push(`org.nr ${s.org_number}`)
if (s.vat_number) supplierLine.push(`VAT ${s.vat_number}`)
lines.push(`- ${supplierLine.join(' ')}`)
}
lines.push(`- Status: ${inv.status ?? '?'} | Datum: ${inv.invoice_date ?? '?'} | Förfaller: ${inv.due_date ?? '?'}`)
lines.push(
`- Belopp: ${inv.total != null ? `${inv.total.toLocaleString('sv-SE')} ${inv.currency ?? 'SEK'}` : '?'} (moms ${inv.vat_amount != null ? `${inv.vat_amount.toLocaleString('sv-SE')} ${inv.currency ?? 'SEK'}` : '?'})`,
)
lines.push(
`- Momskod: ${inv.vat_treatment ?? '?'}${inv.reverse_charge ? ' (omvänd skattskyldighet flaggad)' : ''}`,
)
if (inv.payment_reference) lines.push(`- OCR / referens: ${inv.payment_reference}`)
if (inv.is_credit_note) lines.push('- DETTA ÄR EN KREDITFAKTURA')
lines.push('')
if (captured.items.length > 0) {
lines.push('Rader:')
for (const it of captured.items.slice(0, 20)) {
const total = it.line_total != null ? `${it.line_total.toLocaleString('sv-SE')}` : '?'
const vat = it.vat_rate != null ? `${it.vat_rate}%` : '?'
const acc = it.account_number ? ` → ${it.account_number}` : ' → (ingen kontering)'
lines.push(` • ${it.description ?? '(beskrivning saknas)'}: ${total} ${inv.currency ?? 'SEK'} (${vat})${acc}`)
}
lines.push('')
}
if (captured.recent_invoices_from_supplier.length > 0) {
lines.push('Tidigare fakturor från samma leverantör (för anomalikontroll):')
for (const r of captured.recent_invoices_from_supplier) {
const amt = r.total != null ? `${r.total.toLocaleString('sv-SE')} ${r.currency ?? 'SEK'}` : '?'
lines.push(` • ${r.invoice_number ?? '?'} (${r.invoice_date ?? '?'}, ${r.status ?? '?'}): ${amt}`)
}
lines.push('')
}
const ex = captured.inbox_extraction ?? captured.document_extraction
if (ex) {
lines.push('KÄNDA FAKTA från AI-extraktion av underlaget: fråga INTE om dessa:')
const supplier = (ex.supplier as { name?: string | null; orgNumber?: string | null; vatNumber?: string | null } | undefined) ?? null
const totals = (ex.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
const breakdown = (ex.vatBreakdown as { rate: number; base: number; amount: number }[] | undefined) ?? []
if (supplier?.name) lines.push(`- Leverantör (PDF): ${supplier.name}`)
if (supplier?.orgNumber) lines.push(`- Org.nr (PDF): ${supplier.orgNumber}`)
if (supplier?.vatNumber) lines.push(`- VAT-nr (PDF): ${supplier.vatNumber}`)
if (totals?.total != null) lines.push(`- Total (PDF): ${totals.total.toLocaleString('sv-SE')}`)
if (totals?.vatAmount != null) lines.push(`- Moms (PDF): ${totals.vatAmount.toLocaleString('sv-SE')}`)
if (breakdown.length > 0) {
lines.push(`- Momsuppdelning: ${breakdown.map((b) => `${b.rate}%: ${b.amount.toLocaleString('sv-SE')}`).join('; ')}`)
}
lines.push('')
} else if (inv.document_id) {
lines.push('Underlag är bifogat men inte AI-extraherat. Använd gnubok_get_document_content för att läsa PDF/bilden om du behöver fler signaler.')
lines.push('')
} else {
lines.push('Inget underlag bifogat. Be användaren ladda upp fakturan om något är otydligt.')
lines.push('')
}
lines.push('Arbetssätt: granska, peka på risker, föreslå.')
lines.push('1. Kontrollera momsbehandling: passar den med leverantörens land + VAT-status?')
lines.push(' - SE-leverantör: 25/12/6 % beroende på vara/tjänst.')
lines.push(' - EU näringsidkare: omvänd skattskyldighet (2614/2645).')
lines.push(' - Bygg i Sverige: omvänd skattskyldighet enligt ML 1 kap 2 § 1 st 4b.')
lines.push(' - Tredje land: import-moms via Tullverket eller deklareras via momsdeklaration ruta 60-62.')
lines.push('2. Avvikelse mot tidigare fakturor från samma leverantör? Beloppen i ungefär samma härad?')
lines.push('3. Saknas obligatoriska fält (ML 17 kap 24§): fakturanummer, datum, org.nr, moms-belopp, VAT-id vid reverse charge?')
lines.push('4. Föreslå rätt BAS-konto för varje rad (eller för hela fakturan om bara en summarad finns). Följ leverantörens historik: gnubok_query_journal({ text: "<leverantörens namn>", source_type: "supplier_invoice", limit: 5 }): när du väljer konto.')
lines.push('5. Om du är säker, staga attestering via gnubok_approve_supplier_invoice. Annars: peka på vad som ska klargöras innan attestering.')
lines.push('')
lines.push('Svara på svenska, var direkt och konkret. Ditt första svar är det första användaren ser.')
return lines.join('\n')
},
})