Files
accounted/extensions/general/ai-agent/lib/generate-booking.ts
T
MattssonandClaude Opus 4.7 1af977950b Ai/full autonomous flow (#359)
* Refactor bookkeeping error handling and introduce new error classes

- Introduced new error classes for better error categorization:
  - JournalEntryNotBalancedError
  - FiscalPeriodNotFoundError
  - EntryDateOutsideFiscalPeriodError
  - JournalEntryNotFoundError
  - CannotReverseNonPostedError
  - CannotCorrectNonPostedError
  - EntryAlreadyReversedError
  - CurrencyRevaluationAlreadyExistsError
  - InvalidMappingResultError
  - BookkeepingDatabaseError

- Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones.
- Enhanced error response handling in get-error-message.ts to provide localized messages for new error types.
- Added unit tests for new error classes and error handling functions to ensure correctness and coverage.

* feat(ai): implement AI proposal application and persistence

- Add apply.ts to handle the application of AI proposals, including match and booking steps.
- Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints.
- Create re-validate.ts for validating proposals before acceptance, checking for stale conditions.
- Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes.
- Enhance journal_entries with AI provenance tracking, linking entries to AI proposals.
- Update categorization_templates to distinguish AI-corrected templates.
- Add company settings for toggling AI flow and managing backfill processes.
- Extend processing_history to include AI-related events for better tracking.

* feat: add uncategorized transactions API and UI for transaction selection

- Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options.
- Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals.
- Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality.
- Added TransactionDetailDialog for viewing transaction details with links to the transaction list.
- Introduced receipt quality assessment logic to evaluate extracted receipt data.
- Implemented feature flagging for the AI bookkeeping agent to control availability in different environments.

* feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis

- Added ManualExtractDialog component for user input when AI fails to extract receipt data.
- Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities.
- Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date.
- Updated package.json to include @aws-sdk/client-textract dependency.

* fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard

Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel
VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering
carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate
labels for grocery-chain merchants relative to the entry date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:32:15 +02:00

329 lines
11 KiB
TypeScript

/**
* Booking proposal generator for the ai-agent extension.
*
* Takes a matched receipt + transaction and returns a balanced journal-entry
* proposal in the BookingProposalPayload shape. Uses existing counterparty
* templates as seeds in the prompt so recurring merchants converge fast.
*
* Returns an AIRequestResult when the LLM chooses to clarify (e.g.,
* can't tell business vs private), or null on outage.
*
* Also verifies the proposed lines balance (sum debits = sum credits); when
* the LLM returns unbalanced lines the result is degraded to a clarify ask
* rather than being silently wrong.
*/
import {
ConverseCommand,
type ContentBlock,
type Message,
} from '@aws-sdk/client-bedrock-runtime'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { createClient as createServiceClient } from '@supabase/supabase-js'
import type {
AIRequestResult,
BookingProposalResult,
GenerateBookingContext,
} from '@/lib/ai/proposal-service'
import type {
BookingProposalLine,
BookingProposalCounterpartyTemplate,
BookingProposalPayload,
VatTreatment,
} from '@/types'
import { getBedrockClient, getModelId, getMaxTokens } from './bedrock-client'
import {
BOOKING_PROMPT_VERSION,
BOOKING_SYSTEM_PROMPT,
BOOKING_TOOL_CONFIG,
} from './prompts/booking-prompt'
export async function generateBookingForExtension(
ctx: GenerateBookingContext
): Promise<BookingProposalResult | AIRequestResult | null> {
const serviceClient = createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
// Resolve fiscal period for the transaction date — a prerequisite for any booking.
const fiscalPeriodId = await findFiscalPeriod(
serviceClient,
ctx.companyId,
ctx.matchedTransaction.date
)
if (!fiscalPeriodId) {
return {
kind: 'request',
request: {
request_type: 'needs_manual',
message:
'Ingen öppen räkenskapsperiod täcker transaktionens datum. Skapa perioden eller bokför manuellt.',
},
provenance: { prompt_version: BOOKING_PROMPT_VERSION },
}
}
// Brief the LLM with receipt + transaction + relevant templates.
const extracted = ctx.inboxItem.extracted_data as Record<string, unknown> | null
const relevantTemplates = ctx.existingTemplates
.filter((t) => t.is_active)
.slice(0, 20)
.map((t) => ({
counterparty: t.counterparty_name,
debit: t.debit_account,
credit: t.credit_account,
vat_treatment: t.vat_treatment,
category: t.category,
source: t.source,
occurrences: t.occurrence_count,
}))
const userPrompt = `Kvittodata (extraherad):
${JSON.stringify(extracted, null, 2)}
Matchad banktransaktion:
${JSON.stringify(
{
id: ctx.matchedTransaction.id,
date: ctx.matchedTransaction.date,
description: ctx.matchedTransaction.description,
amount: ctx.matchedTransaction.amount,
amount_sek: ctx.matchedTransaction.amount_sek,
currency: ctx.matchedTransaction.currency,
merchant_name: ctx.matchedTransaction.merchant_name,
},
null,
2
)}
Företagstyp: ${ctx.entityType}
Befintliga motpartsmallar (upp till 20):
${JSON.stringify(relevantTemplates, null, 2)}
Föreslå ett balanserat verifikat. Transaktionens belopp är bruttobeloppet som betalas från 1930.`
const messages: Message[] = [{ role: 'user', content: [{ text: userPrompt }] }]
let response
try {
response = await getBedrockClient().send(
new ConverseCommand({
modelId: getModelId(),
messages,
system: [{ text: BOOKING_SYSTEM_PROMPT }],
toolConfig: BOOKING_TOOL_CONFIG,
inferenceConfig: { maxTokens: getMaxTokens(), temperature: 0 },
})
)
} catch (err) {
console.error('[ai-agent/booking] Bedrock call failed:', err)
return null
}
const usage = {
input_tokens: response.usage?.inputTokens ?? 0,
output_tokens: response.usage?.outputTokens ?? 0,
}
const toolUse = response.output?.message?.content?.find(
(b): b is ContentBlock.ToolUseMember => 'toolUse' in b && b.toolUse !== undefined
)
if (!toolUse?.toolUse?.input) return null
const raw = toolUse.toolUse.input as Record<string, unknown>
const action = raw.action === 'clarify_business_private' ? 'clarify_business_private' : 'propose'
const confidence = clampConfidence(Number(raw.confidence))
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
if (action === 'clarify_business_private') {
return {
kind: 'request',
request: {
request_type: 'clarify_business_private',
message:
typeof raw.clarify_message === 'string' && raw.clarify_message.trim().length > 0
? raw.clarify_message.trim()
: 'Är detta en affärsutgift eller privat?',
required_fields: { is_business: 'boolean' },
},
provenance: {
model: getModelId(),
prompt_version: BOOKING_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
const proposalRaw = raw.proposal as Record<string, unknown> | null | undefined
if (!proposalRaw) {
return null
}
const rawLines = extractLines(proposalRaw.lines)
const vatTreatment = extractVatTreatment(proposalRaw.vat_treatment)
const defaultPrivate = Boolean(proposalRaw.default_private)
const counterpartyTpl = extractCounterpartyTemplate(proposalRaw.counterparty_template_proposal)
// Claude often returns lines that are off by a cent or two due to the way
// it does 25% VAT math on awkward totals (e.g. 183,30 split as net 146,64
// + VAT 36,66 — fine — but sometimes 146,64 + 36,67 from rounding up).
// Repair those silently; the journal engine can't post unbalanced entries
// anyway, and the human-facing answer (same accounts, same rate) is identical.
const { lines, repaired } = repairRounding(rawLines)
if (!linesBalanced(lines)) {
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
console.warn('[ai-agent/generate-booking] unbalanced proposal', {
totalDebit, totalCredit, diff: totalDebit - totalCredit, lines,
})
return {
kind: 'request',
request: {
request_type: 'needs_manual',
message:
`AI:n producerade ett obalanserat verifikat (debet ${totalDebit.toFixed(2)} vs kredit ${totalCredit.toFixed(2)}). Bokför manuellt eller försök igen via Bearbeta befintliga.`,
},
provenance: {
model: getModelId(),
prompt_version: BOOKING_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
if (repaired) {
console.log('[ai-agent/generate-booking] auto-repaired rounding on booking lines')
}
const payload: BookingProposalPayload = {
lines,
vat_treatment: vatTreatment,
default_private: defaultPrivate,
counterparty_template_proposal: counterpartyTpl,
fiscal_period_id: fiscalPeriodId,
entry_date: ctx.matchedTransaction.date,
description: buildDescription(ctx),
}
return {
kind: 'proposal',
proposal: payload,
confidence,
reasoning,
provenance: {
model: getModelId(),
prompt_version: BOOKING_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
function clampConfidence(raw: number): number {
if (!isFinite(raw)) return 0
return Math.min(1, Math.max(0, raw / 100))
}
function extractLines(raw: unknown): BookingProposalLine[] {
if (!Array.isArray(raw)) return []
return raw
.map((item) => item as Record<string, unknown>)
.filter((item) => typeof item.account_number === 'string')
.map((item) => ({
account_number: String(item.account_number),
debit_amount: Number(item.debit_amount) || 0,
credit_amount: Number(item.credit_amount) || 0,
description: typeof item.description === 'string' ? item.description : '',
}))
}
function extractVatTreatment(raw: unknown): VatTreatment | null {
const allowed: VatTreatment[] = [
'standard_25',
'reduced_12',
'reduced_6',
'reverse_charge',
'export',
'exempt',
]
if (typeof raw !== 'string') return null
return (allowed as string[]).includes(raw) ? (raw as VatTreatment) : null
}
function extractCounterpartyTemplate(
raw: unknown
): BookingProposalCounterpartyTemplate | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
if (
typeof r.counterparty_name !== 'string' ||
typeof r.debit_account !== 'string' ||
typeof r.credit_account !== 'string'
) {
return null
}
return {
counterparty_name: r.counterparty_name,
debit_account: r.debit_account,
credit_account: r.credit_account,
vat_treatment: extractVatTreatment(r.vat_treatment),
category:
typeof r.category === 'string' && r.category.length > 0
? (r.category as BookingProposalCounterpartyTemplate['category'])
: null,
}
}
function linesBalanced(lines: BookingProposalLine[]): boolean {
if (lines.length < 2) return false
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
return Math.abs(totalDebit - totalCredit) < 0.005 && totalDebit > 0
}
// Adjust sub-5-öre discrepancies silently by nudging the largest debit
// line. Only repairs imbalances up to 0.05 kr — anything larger is treated
// as a real error (Claude got confused, not just a rounding quirk) and
// bubbles up via the existing needs_manual fallback.
function repairRounding(lines: BookingProposalLine[]): { lines: BookingProposalLine[]; repaired: boolean } {
if (lines.length < 2) return { lines, repaired: false }
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const diff = totalDebit - totalCredit
const absDiff = Math.abs(diff)
if (absDiff < 0.005) return { lines, repaired: false }
if (absDiff > 0.05) return { lines, repaired: false }
// Pick the single biggest debit line to absorb the adjustment — usually
// the expense account, not the VAT line. Subtract if debit is over,
// add if debit is under. Round to öre precision.
const withIndex = lines.map((l, idx) => ({ l, idx }))
const biggestDebit = withIndex
.filter((x) => x.l.debit_amount > 0)
.sort((a, b) => b.l.debit_amount - a.l.debit_amount)[0]
if (!biggestDebit) return { lines, repaired: false }
const adjusted = [...lines]
const current = adjusted[biggestDebit.idx]
adjusted[biggestDebit.idx] = {
...current,
debit_amount: Math.round((current.debit_amount - diff) * 100) / 100,
}
return { lines: adjusted, repaired: true }
}
function buildDescription(ctx: GenerateBookingContext): string {
const merchant =
ctx.matchedTransaction.merchant_name ||
ctx.matchedTransaction.description ||
'Okänd handlare'
return `AI-förslag: ${merchant}`
}