* 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>
189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
/**
|
|
* Apply path — what happens when a user accepts a pending proposal.
|
|
*
|
|
* - step='match': sets matched_transaction_id on the inbox item using the
|
|
* same columns as the existing smart-matcher (match_method, match_confidence,
|
|
* match_reasoning) so downstream consumers don't need to know whether the
|
|
* match came from AI or the deterministic matcher.
|
|
*
|
|
* - step='booking': creates a draft journal entry via the engine with
|
|
* created_via='ai_proposed' + source_proposal_id, then commits, then
|
|
* links the document. Mirrors the categorize API route's CAS guards.
|
|
*
|
|
* Re-validation MUST have already passed (call reValidateProposal() first).
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type {
|
|
AIProposal,
|
|
BookingProposalPayload,
|
|
CreateJournalEntryInput,
|
|
InvoiceInboxItem,
|
|
JournalEntry,
|
|
MatchProposalPayload,
|
|
} from '@/types'
|
|
import {
|
|
createDraftEntry,
|
|
commitEntry,
|
|
} from '@/lib/bookkeeping/engine'
|
|
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('ai-proposals/apply')
|
|
|
|
export interface ApplyMatchOutcome {
|
|
kind: 'match_applied'
|
|
inboxItemId: string
|
|
matchedTransactionId: string
|
|
}
|
|
|
|
export interface ApplyBookingOutcome {
|
|
kind: 'booking_applied'
|
|
inboxItemId: string
|
|
journalEntry: JournalEntry
|
|
}
|
|
|
|
export type ApplyOutcome = ApplyMatchOutcome | ApplyBookingOutcome
|
|
|
|
/**
|
|
* Apply a re-validated proposal. Writes the proposal's changes to the
|
|
* domain tables (inbox_item, journal_entries, document_attachments).
|
|
*
|
|
* Callers should:
|
|
* 1. Run reValidateProposal() first.
|
|
* 2. Use this function's return value to update the proposal row
|
|
* (status='accepted', applied_entry_id).
|
|
*/
|
|
export async function applyProposal(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
proposal: AIProposal,
|
|
inboxItem: InvoiceInboxItem,
|
|
editedPayload?: MatchProposalPayload | BookingProposalPayload
|
|
): Promise<ApplyOutcome> {
|
|
const payload = editedPayload ?? proposal.proposal_json
|
|
|
|
if (proposal.step_type === 'match') {
|
|
return applyMatch(supabase, inboxItem, payload as MatchProposalPayload, proposal)
|
|
}
|
|
|
|
if (proposal.step_type === 'booking') {
|
|
return applyBooking(supabase, companyId, userId, inboxItem, payload as BookingProposalPayload, proposal)
|
|
}
|
|
|
|
throw new Error(`Unknown step_type: ${proposal.step_type}`)
|
|
}
|
|
|
|
async function applyMatch(
|
|
supabase: SupabaseClient,
|
|
inboxItem: InvoiceInboxItem,
|
|
payload: MatchProposalPayload,
|
|
proposal: AIProposal
|
|
): Promise<ApplyMatchOutcome> {
|
|
const { error } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
matched_transaction_id: payload.matched_transaction_id,
|
|
match_method: 'llm',
|
|
match_confidence: proposal.confidence,
|
|
match_reasoning: proposal.reasoning,
|
|
})
|
|
.eq('id', inboxItem.id)
|
|
|
|
if (error) {
|
|
// 23505 = the existing smart-match partial unique index (the same
|
|
// transaction was claimed by another inbox item since re-validation).
|
|
throw new Error(`Failed to apply match: ${error.message}`)
|
|
}
|
|
|
|
return {
|
|
kind: 'match_applied',
|
|
inboxItemId: inboxItem.id,
|
|
matchedTransactionId: payload.matched_transaction_id,
|
|
}
|
|
}
|
|
|
|
async function applyBooking(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
inboxItem: InvoiceInboxItem,
|
|
payload: BookingProposalPayload,
|
|
proposal: AIProposal
|
|
): Promise<ApplyBookingOutcome> {
|
|
// 1. Draft the entry with provenance.
|
|
const input: CreateJournalEntryInput = {
|
|
fiscal_period_id: payload.fiscal_period_id,
|
|
entry_date: payload.entry_date,
|
|
description: payload.description,
|
|
source_type: 'bank_transaction',
|
|
source_id: inboxItem.matched_transaction_id!,
|
|
lines: payload.lines.map((l) => ({
|
|
account_number: l.account_number,
|
|
debit_amount: l.debit_amount,
|
|
credit_amount: l.credit_amount,
|
|
line_description: l.description,
|
|
})),
|
|
created_via: 'ai_proposed',
|
|
source_proposal_id: proposal.id,
|
|
}
|
|
|
|
const draft = await createDraftEntry(supabase, companyId, userId, input)
|
|
|
|
let entry: JournalEntry
|
|
try {
|
|
entry = await commitEntry(supabase, companyId, userId, draft.id)
|
|
} catch (commitError) {
|
|
// Mirror the safety net from createJournalEntry — cancel the orphan draft.
|
|
try {
|
|
await supabase
|
|
.from('journal_entries')
|
|
.update({ status: 'cancelled' })
|
|
.eq('id', draft.id)
|
|
.eq('status', 'draft')
|
|
} catch {
|
|
// Swallow — surface the original commit error
|
|
}
|
|
throw commitError
|
|
}
|
|
|
|
// 2. Link the document to the entry (mirror the categorize route pattern).
|
|
if (inboxItem.document_id) {
|
|
try {
|
|
await linkToJournalEntry(supabase, companyId, inboxItem.document_id, entry.id)
|
|
} catch (err) {
|
|
log.error('Failed to link document to entry (entry stays posted):', err)
|
|
// The entry is already posted; re-linking can be retried from the UI.
|
|
}
|
|
}
|
|
|
|
// 3. Link the transaction to the entry (same CAS as categorize route).
|
|
if (inboxItem.matched_transaction_id) {
|
|
const { error: txError } = await supabase
|
|
.from('transactions')
|
|
.update({
|
|
journal_entry_id: entry.id,
|
|
is_business: true,
|
|
})
|
|
.eq('id', inboxItem.matched_transaction_id)
|
|
.is('journal_entry_id', null)
|
|
|
|
if (txError) {
|
|
log.error('Failed to link transaction to entry:', txError)
|
|
}
|
|
}
|
|
|
|
// 4. Mark the inbox item confirmed.
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ status: 'confirmed' })
|
|
.eq('id', inboxItem.id)
|
|
|
|
return {
|
|
kind: 'booking_applied',
|
|
inboxItemId: inboxItem.id,
|
|
journalEntry: entry,
|
|
}
|
|
}
|