Files
accounted/lib/agent/intents/invoice-draft.ts
T
Jakob Wennberg 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

249 lines
9.9 KiB
TypeScript

import { defineAgentIntent } from './types'
import { SONNET_MODEL, THINKING_BUDGET_STANDARD } from '@/lib/agent/composer/client'
import { renderAgentGroundRules } from './shared-rules'
// invoice.draft: "Fråga om denna faktura" from the invoice form.
//
// Declarative atom mode: loads VAT + invoice compliance + e-invoicing
// upfront, plus the company's vertical + modifier atoms. The agent helps
// with VAT treatment (25/12/6 % or reverse charge), payment terms,
// kreditfaktura mechanics, OCR/Bankgiro on the invoice, and EU-customer
// edge cases.
//
// The user does the actual drafting in the form; this intent advises.
// gnubok_create_invoice / send_invoice are NOT in the tool list because
// the form already submits to those endpoints: the agent shouldn't race
// the form.
//
// Plan ref: dev_docs/specialized-agent-plan.md §8 (V1 intent #2).
interface InvoiceDraftArgs {
// null when the user opened the agent before picking a customer.
customer_id?: string | null
// Set when editing an existing draft (route /invoices/[id]). null for new.
invoice_id?: string | null
}
interface CapturedInvoiceDraft {
customer: {
id: string
name: string | null
customer_type: string | null
country: string | null
vat_number: string | null
vat_number_validated: boolean | null
org_number: string | null
} | null
recent_invoices: {
invoice_number: string | null
invoice_date: string | null
status: string | null
total: number | null
currency: string | null
}[]
invoice: {
id: string
invoice_number: string | null
status: string | null
total: number | null
currency: string | null
} | null
// Compact subset of company_settings relevant to invoice drafting.
company_invoice_context: {
moms_period: string | null
vat_registered: boolean | null
accounting_method: string | null
invoice_default_days: number | null
} | null
}
export const invoiceDraft = defineAgentIntent<InvoiceDraftArgs, CapturedInvoiceDraft>({
id: 'invoice.draft',
buttonLabel: 'Fråga om denna faktura',
sheetTitle: 'Hjälp med faktura',
atoms: {
mode: 'declarative',
horizontal: ['swedish-vat', 'swedish-invoice-compliance', 'swedish-e-invoicing'],
includeCompanyVertical: true,
includeCompanyModifiers: true,
},
tools: [
'gnubok_list_customers',
'gnubok_create_customer',
'gnubok_load_skill',
'gnubok_search_tools',
'gnubok_remember_fact',
'gnubok_forget_fact',
],
model: SONNET_MODEL,
// Draft the invoice lines + VAT in the thinking channel, so the visible reply
// is one short confirmation after staging rather than a play-by-play that
// repeats once before the tool call and once after it.
thinking: { budgetTokens: THINKING_BUDGET_STANDARD },
capture: async ({ customer_id, invoice_id }, { supabase, companyId }) => {
// Resolve the effective customer_id. When the FAB lands here from
// /invoices/[id] it only knows invoice_id: read customer_id off the
// invoice row so the customer section of the prompt isn't empty.
type InvoiceRow = {
id: string
invoice_number?: string | null
status?: string | null
total?: number | null
currency?: string | null
customer_id?: string | null
}
let invoice: InvoiceRow | null = null
if (invoice_id) {
const { data } = await supabase
.from('invoices')
.select('id, invoice_number, status, total, currency, customer_id')
.eq('id', invoice_id)
.eq('company_id', companyId)
.maybeSingle()
invoice = (data as InvoiceRow | null) ?? null
}
const effectiveCustomerId = customer_id ?? invoice?.customer_id ?? null
const [{ data: customer }, { data: recent }, { data: settings }] = await Promise.all([
effectiveCustomerId
? supabase
.from('customers')
.select('id, name, customer_type, country, vat_number, vat_number_validated, org_number')
.eq('id', effectiveCustomerId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
effectiveCustomerId
? supabase
.from('invoices')
.select('invoice_number, invoice_date, status, total, currency')
.eq('customer_id', effectiveCustomerId)
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
.limit(5)
: Promise.resolve({ data: [] }),
supabase
.from('company_settings')
.select('moms_period, vat_registered, accounting_method, invoice_default_days')
.eq('company_id', companyId)
.maybeSingle(),
])
return {
customer: customer
? {
id: (customer as { id: string }).id,
name: ((customer as { name?: string | null }).name) ?? null,
customer_type: ((customer as { customer_type?: string | null }).customer_type) ?? null,
country: ((customer as { country?: string | null }).country) ?? null,
vat_number: ((customer as { vat_number?: string | null }).vat_number) ?? null,
vat_number_validated:
((customer as { vat_number_validated?: boolean | null }).vat_number_validated) ?? null,
org_number: ((customer as { org_number?: string | null }).org_number) ?? null,
}
: null,
recent_invoices: ((recent ?? []) as {
invoice_number: string | null
invoice_date: string | null
status: string | null
total: number | null
currency: string | null
}[]).map((r) => ({
invoice_number: r.invoice_number,
invoice_date: r.invoice_date,
status: r.status,
total: r.total,
currency: r.currency,
})),
invoice: invoice
? {
id: (invoice as { id: string }).id,
invoice_number: ((invoice as { invoice_number?: string | null }).invoice_number) ?? null,
status: ((invoice as { status?: string | null }).status) ?? null,
total: ((invoice as { total?: number | null }).total) ?? null,
currency: ((invoice as { currency?: string | null }).currency) ?? null,
}
: null,
company_invoice_context: settings
? {
moms_period: (settings as { moms_period?: string | null }).moms_period ?? null,
vat_registered: (settings as { vat_registered?: boolean | null }).vat_registered ?? null,
accounting_method:
(settings as { accounting_method?: string | null }).accounting_method ?? null,
invoice_default_days:
(settings as { invoice_default_days?: number | null }).invoice_default_days ?? null,
}
: null,
}
},
promptTemplate: ({ captured, profileSummary }) => {
const lines: string[] = []
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
lines.push('Användaren håller på att skriva en faktura. Hjälp dem komma rätt.')
lines.push('')
lines.push(renderAgentGroundRules())
lines.push('')
if (captured.customer) {
const c = captured.customer
lines.push('KUND (vald):')
lines.push(`- Namn: ${c.name ?? '(saknas)'}`)
lines.push(`- Typ: ${c.customer_type ?? '(saknas)'}`)
lines.push(`- Land: ${c.country ?? 'SE'}`)
if (c.vat_number) {
lines.push(
`- VAT-nummer: ${c.vat_number}${c.vat_number_validated ? ' (validerat via VIES)' : ' (ej validerat)'}`,
)
}
if (c.org_number) lines.push(`- Org.nr: ${c.org_number}`)
lines.push('')
if (captured.recent_invoices.length > 0) {
lines.push('Senaste fakturor till denna kund:')
for (const r of captured.recent_invoices) {
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('')
}
} else {
lines.push('Ingen kund vald ännu. Be användaren välja eller skapa en kund först om de behöver hjälp med momsbehandling: momskod beror på kundens land och typ.')
lines.push('')
}
if (captured.company_invoice_context) {
const s = captured.company_invoice_context
const known: string[] = []
if (s.moms_period) known.push(`Momsperiod: ${s.moms_period}`)
if (s.vat_registered != null) known.push(`Momsregistrerad: ${s.vat_registered ? 'ja' : 'nej'}`)
if (s.accounting_method) known.push(`Bokföringsmetod: ${s.accounting_method}`)
if (s.invoice_default_days != null) known.push(`Standardbetalningsvillkor: ${s.invoice_default_days} dagar`)
if (known.length > 0) {
lines.push('KÄNDA FAKTA (fråga inte om dessa):')
for (const k of known) lines.push(`- ${k}`)
lines.push('')
}
}
lines.push('Arbetssätt: hämta information via verktygsanrop FÖRST (tyst: statusraderna visar att du söker, och ditt resonemang sker i tankekanalen), föreslå sedan. Skriv din förklaring EN gång efteråt, inte i flera block runt anropen.')
lines.push('- Hjälp användaren välja rätt momsbehandling baserat på kundens land + typ + VAT-validering:')
lines.push(' · SE-kund: 25/12/6 % beroende på vara/tjänst.')
lines.push(' · EU näringsidkare med validerat VAT-nr: omvänd skattskyldighet (reverse charge) på tjänster.')
lines.push(' · EU privatperson: SE-moms (eller OSS-tröskel om varor).')
lines.push(' · Utanför EU: export, 0 %.')
lines.push('- Föreslå betalningsvillkor, OCR/Bankgiro-uppgifter, eventuell ROT/RUT, EU-text på fakturan vid reverse charge.')
lines.push('- Du SKAPAR INTE fakturan. Användaren gör det själv i formuläret. Du rådger.')
lines.push('- Om kunden saknar VAT-nummer men är EU-näringsidkare, säg till: VIES-validering krävs för reverse charge.')
lines.push('')
lines.push('Svara på svenska och var direkt: ditt första svar är det första användaren ser.')
return lines.join('\n')
},
})