Files
accounted/lib/ai/proposals/persist.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

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')
}