* 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>
265 lines
7.9 KiB
TypeScript
265 lines
7.9 KiB
TypeScript
/**
|
|
* Persistence helpers for ai_proposals and ai_requests.
|
|
*
|
|
* - Inserts new proposals, invalidating any prior pending proposal for the
|
|
* same (subject, step) first to keep the partial unique index happy.
|
|
* - Inserts new ai_requests with the same idempotency on (subject, request_type).
|
|
* - Appends processing_history audit events so the timeline on the inbox
|
|
* item tells the full story: DocumentIngested -> DocumentClassified ->
|
|
* AIProposalGenerated -> AIProposalAccepted -> JournalEntryPosted.
|
|
*
|
|
* All writes use the caller's Supabase client — service role for orchestrator
|
|
* context (RLS bypassed), user client for API route context (RLS enforced).
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type {
|
|
AIProposal,
|
|
AIProposalStepType,
|
|
AIRequest,
|
|
AIRequestType,
|
|
AISubjectType,
|
|
InvoiceInboxItem,
|
|
MatchProposalPayload,
|
|
BookingProposalPayload,
|
|
} from '@/types'
|
|
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('ai-proposals/persist')
|
|
|
|
// ── Proposal insert ──────────────────────────────────────────────────
|
|
|
|
export interface InsertProposalInput {
|
|
companyId: string
|
|
userId: string
|
|
subjectType: AISubjectType
|
|
subjectId: string
|
|
stepType: AIProposalStepType
|
|
proposalJson: MatchProposalPayload | BookingProposalPayload
|
|
confidence: number
|
|
reasoning: string
|
|
model: string
|
|
promptVersion: string
|
|
inputTokens: number
|
|
outputTokens: number
|
|
aiRequestId?: string | null
|
|
correlationId?: string
|
|
}
|
|
|
|
/**
|
|
* Insert a new pending proposal. Invalidates any prior pending proposal for
|
|
* the same (subject, step) first so the partial unique index accepts the
|
|
* new row and the audit trail reflects the replacement.
|
|
*/
|
|
export async function insertProposal(
|
|
supabase: SupabaseClient,
|
|
input: InsertProposalInput
|
|
): Promise<AIProposal> {
|
|
// 1. Invalidate any prior pending proposal for this (subject, step).
|
|
await supabase
|
|
.from('ai_proposals')
|
|
.update({
|
|
status: 'invalidated',
|
|
invalidated_reason: 'superseded_by_new_proposal',
|
|
})
|
|
.eq('subject_type', input.subjectType)
|
|
.eq('subject_id', input.subjectId)
|
|
.eq('step_type', input.stepType)
|
|
.eq('status', 'pending')
|
|
|
|
// 2. Insert the new proposal.
|
|
const { data, error } = await supabase
|
|
.from('ai_proposals')
|
|
.insert({
|
|
company_id: input.companyId,
|
|
user_id: input.userId,
|
|
subject_type: input.subjectType,
|
|
subject_id: input.subjectId,
|
|
step_type: input.stepType,
|
|
status: 'pending',
|
|
proposal_json: input.proposalJson,
|
|
confidence: input.confidence,
|
|
reasoning: input.reasoning,
|
|
model: input.model,
|
|
prompt_version: input.promptVersion,
|
|
input_token_count: input.inputTokens,
|
|
output_token_count: input.outputTokens,
|
|
ai_request_id: input.aiRequestId ?? null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error || !data) {
|
|
throw new Error(`Failed to insert ai_proposal: ${error?.message}`)
|
|
}
|
|
|
|
const proposal = data as AIProposal
|
|
|
|
// 3. Audit: AIProposalGenerated
|
|
if (input.correlationId) {
|
|
try {
|
|
await appendProcessingHistory({
|
|
companyId: input.companyId,
|
|
correlationId: input.correlationId,
|
|
aggregateType: 'AIProposal',
|
|
aggregateId: proposal.id,
|
|
eventType: 'AIProposalGenerated',
|
|
payload: {
|
|
proposal_id: proposal.id,
|
|
subject_type: input.subjectType,
|
|
subject_id: input.subjectId,
|
|
step_type: input.stepType,
|
|
confidence: input.confidence,
|
|
model: input.model,
|
|
prompt_version: input.promptVersion,
|
|
input_tokens: input.inputTokens,
|
|
output_tokens: input.outputTokens,
|
|
},
|
|
actor: { type: 'llm', id: 'ai-agent' },
|
|
occurredAt: new Date(),
|
|
})
|
|
} catch (err) {
|
|
log.error('Failed to append AIProposalGenerated:', err)
|
|
}
|
|
}
|
|
|
|
return proposal
|
|
}
|
|
|
|
// ── Request insert ───────────────────────────────────────────────────
|
|
|
|
export interface InsertRequestInput {
|
|
companyId: string
|
|
subjectType: AISubjectType
|
|
subjectId: string
|
|
requestType: AIRequestType
|
|
message: string
|
|
requiredFields?: Record<string, unknown>
|
|
options?: Record<string, unknown>
|
|
model?: string
|
|
promptVersion?: string
|
|
correlationId?: string
|
|
}
|
|
|
|
export async function insertRequest(
|
|
supabase: SupabaseClient,
|
|
input: InsertRequestInput
|
|
): Promise<AIRequest> {
|
|
// Idempotency: if an open request of the same (subject, request_type) exists,
|
|
// update it in place rather than erroring on the partial unique index.
|
|
const { data: existing } = await supabase
|
|
.from('ai_requests')
|
|
.select('id')
|
|
.eq('subject_type', input.subjectType)
|
|
.eq('subject_id', input.subjectId)
|
|
.eq('request_type', input.requestType)
|
|
.eq('status', 'open')
|
|
.maybeSingle()
|
|
|
|
if (existing) {
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('ai_requests')
|
|
.update({
|
|
message: input.message,
|
|
required_fields: input.requiredFields ?? null,
|
|
options: input.options ?? null,
|
|
model: input.model ?? null,
|
|
prompt_version: input.promptVersion ?? null,
|
|
})
|
|
.eq('id', existing.id)
|
|
.select()
|
|
.single()
|
|
|
|
if (updateError || !updated) {
|
|
throw new Error(`Failed to update ai_request: ${updateError?.message}`)
|
|
}
|
|
return updated as AIRequest
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('ai_requests')
|
|
.insert({
|
|
company_id: input.companyId,
|
|
subject_type: input.subjectType,
|
|
subject_id: input.subjectId,
|
|
request_type: input.requestType,
|
|
message: input.message,
|
|
required_fields: input.requiredFields ?? null,
|
|
options: input.options ?? null,
|
|
model: input.model ?? null,
|
|
prompt_version: input.promptVersion ?? null,
|
|
status: 'open',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error || !data) {
|
|
throw new Error(`Failed to insert ai_request: ${error?.message}`)
|
|
}
|
|
|
|
const request = data as AIRequest
|
|
|
|
if (input.correlationId) {
|
|
try {
|
|
await appendProcessingHistory({
|
|
companyId: input.companyId,
|
|
correlationId: input.correlationId,
|
|
aggregateType: 'AIRequest',
|
|
aggregateId: request.id,
|
|
eventType: 'AIRequestCreated',
|
|
payload: {
|
|
request_id: request.id,
|
|
subject_type: input.subjectType,
|
|
subject_id: input.subjectId,
|
|
request_type: input.requestType,
|
|
},
|
|
actor: { type: 'llm', id: 'ai-agent' },
|
|
occurredAt: new Date(),
|
|
})
|
|
} catch (err) {
|
|
log.error('Failed to append AIRequestCreated:', err)
|
|
}
|
|
}
|
|
|
|
return request
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
export async function fetchInboxItem(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
inboxItemId: string
|
|
): Promise<InvoiceInboxItem | null> {
|
|
const { data } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*')
|
|
.eq('id', inboxItemId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
return data as InvoiceInboxItem | null
|
|
}
|
|
|
|
/**
|
|
* Mark all pending proposals for an inbox item as skipped. Used when the
|
|
* user bypassed the AI flow and took a manual action (categorize,
|
|
* match-invoice, match-supplier-invoice) on the linked transaction.
|
|
*/
|
|
export async function skipPendingProposalsForSubject(
|
|
supabase: SupabaseClient,
|
|
subjectType: AISubjectType,
|
|
subjectId: string,
|
|
reason: string
|
|
): Promise<void> {
|
|
await supabase
|
|
.from('ai_proposals')
|
|
.update({
|
|
status: 'skipped',
|
|
invalidated_reason: reason,
|
|
})
|
|
.eq('subject_type', subjectType)
|
|
.eq('subject_id', subjectId)
|
|
.eq('status', 'pending')
|
|
}
|