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>
This commit is contained in:
Mattsson
2026-04-27 10:32:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ab63da8324
commit 1af977950b
70 changed files with 9735 additions and 451 deletions
+35
View File
@@ -0,0 +1,35 @@
/**
* Agent-inkorg feature flag.
*
* The AI bookkeeping agent isn't ready for general availability in production.
* This helper gates the whole feature — sidebar link, page, API routes, and
* orchestrator event handlers — behind either:
*
* 1. NODE_ENV === 'development' (local dev: always on)
* 2. NEXT_PUBLIC_AGENT_INBOX_ENABLED=true (opt-in for staging/prod QA)
*
* The escape hatch lets us flip the feature on for a specific Vercel
* deployment (staging) without a code change, and keeps prod deployments
* safely dark until we explicitly enable it.
*
* Mirrors the pattern used for Salary in components/dashboard/DashboardNav.tsx.
*/
import { NextResponse } from 'next/server'
export function isAgentInboxEnabled(): boolean {
if (process.env.NODE_ENV === 'development') return true
return process.env.NEXT_PUBLIC_AGENT_INBOX_ENABLED === 'true'
}
/**
* 404 early-return for API routes. Returns the response when disabled, null
* when enabled. Usage:
*
* const gate = gateAgentInbox()
* if (gate) return gate
*/
export function gateAgentInbox(): NextResponse | null {
if (isAgentInboxEnabled()) return null
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
+385
View File
@@ -0,0 +1,385 @@
/**
* AI agent orchestrator — event handler that wires the proposal lifecycle.
*
* Subscribes to:
* - inbox_item.classified → generate match proposal (receipts, ai_flow_enabled)
* - ai_proposal.accepted → chain match -> booking
* - transaction.categorized → skip pending proposals for that transaction's inbox item
*
* The generators themselves live in the ai-agent extension (Bedrock). When
* the extension is not loaded (prod, or feature off), the service's noop
* returns null and we issue a 'needs_manual' ai_request so the user still
* sees the item needs action — no silent failure.
*/
import { createClient as createServiceClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import type { EventPayload } from '@/lib/events/types'
import { getAIProposalService } from '@/lib/ai/proposal-service'
import {
insertProposal,
insertRequest,
skipPendingProposalsForSubject,
} from '@/lib/ai/proposals/persist'
import { createLogger } from '@/lib/logger'
import type {
InvoiceInboxItem,
Transaction,
CategorizationTemplate,
AIProposal,
} from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
AIRequestResult,
BookingProposalResult,
MatchProposalResult,
} from '@/lib/ai/proposal-service'
const log = createLogger('ai-orchestrator')
/**
* Service-role client for orchestrator writes.
* Mirrors inbox-smart-match — the handler runs server-side and needs to
* bypass RLS to write to ai_proposals, ai_requests, and read settings.
*/
function getServiceClient(): SupabaseClient {
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
}
// ── inbox_item.classified handler ────────────────────────────────────
async function handleInboxItemClassified(
payload: EventPayload<'inbox_item.classified'>
): Promise<void> {
const { inboxItem, documentType, correlationId, userId, companyId } = payload
// v1 scope: only receipts.
if (documentType !== 'receipt') return
const supabase = getServiceClient()
// Per-company gate.
const { data: settings } = await supabase
.from('company_settings')
.select('ai_flow_enabled')
.eq('company_id', companyId)
.maybeSingle()
if (!settings?.ai_flow_enabled) return
await generateMatchProposalFor(supabase, {
inboxItem,
correlationId,
userId,
companyId,
})
}
// ── ai_proposal.accepted handler (chain match -> booking) ───────────
async function handleProposalAccepted(
payload: EventPayload<'ai_proposal.accepted'>
): Promise<void> {
const { proposal, userId, companyId } = payload
if (proposal.step_type !== 'match') return
if (proposal.subject_type !== 'inbox_item') return
const supabase = getServiceClient()
// Load the inbox item + the matched transaction to feed the booking prompt.
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', proposal.subject_id)
.eq('company_id', companyId)
.maybeSingle()
if (!inboxItem || !(inboxItem as InvoiceInboxItem).matched_transaction_id) {
log.warn(`match accepted but no matched_transaction_id on inbox item ${proposal.subject_id}`)
return
}
const item = inboxItem as InvoiceInboxItem
// Defense in depth: don't chain to booking without a source document.
// reValidateMatch already blocks this at accept time, but a stale accepted
// proposal (from before the gate existed) could still reach here.
if (!item.document_id) {
log.warn(`refusing to chain booking for inbox item ${item.id} — no source document attached`)
return
}
const { data: tx } = await supabase
.from('transactions')
.select('*')
.eq('id', item.matched_transaction_id!)
.eq('company_id', companyId)
.maybeSingle()
if (!tx) {
log.warn(`match accepted but transaction ${item.matched_transaction_id} not found`)
return
}
// Existing counterparty templates to inform the booking prompt.
const { data: templates } = await supabase
.from('categorization_templates')
.select('*')
.eq('company_id', companyId)
.eq('is_active', true)
// Entity type for account routing.
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType: 'enskild_firma' | 'aktiebolag' =
(settings?.entity_type as 'enskild_firma' | 'aktiebolag') || 'enskild_firma'
await generateBookingProposalFor(supabase, {
inboxItem: item,
matchedTransaction: tx as Transaction,
existingTemplates: (templates || []) as CategorizationTemplate[],
entityType,
correlationId: item.correlation_id ?? undefined,
userId,
companyId,
})
}
// ── transaction.categorized handler (skip on manual takeover) ───────
async function handleTransactionCategorized(
payload: EventPayload<'transaction.categorized'>
): Promise<void> {
const { transaction, companyId } = payload
const supabase = getServiceClient()
// Find any inbox items matched to this transaction with pending proposals.
const { data: items } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('company_id', companyId)
.eq('matched_transaction_id', transaction.id)
if (!items || items.length === 0) return
for (const item of items) {
await skipPendingProposalsForSubject(supabase, 'inbox_item', item.id, 'user_went_manual')
}
}
// ── Generator dispatch ───────────────────────────────────────────────
interface GenerateMatchArgs {
inboxItem: InvoiceInboxItem
correlationId?: string
userId: string
companyId: string
}
async function generateMatchProposalFor(
supabase: SupabaseClient,
args: GenerateMatchArgs
): Promise<void> {
const { inboxItem, correlationId, userId, companyId } = args
const service = getAIProposalService()
const result = await service.generateMatchProposal({ inboxItem, userId, companyId })
if (result === null) {
// Service outage or no extension loaded → needs_manual ask.
await insertRequest(supabase, {
companyId,
subjectType: 'inbox_item',
subjectId: inboxItem.id,
requestType: 'needs_manual',
message: 'AI-agenten är inte tillgänglig just nu — hantera manuellt.',
correlationId,
})
return
}
if (result.kind === 'request') {
await insertRequest(supabase, {
companyId,
subjectType: 'inbox_item',
subjectId: inboxItem.id,
requestType: result.request.request_type,
message: result.request.message,
requiredFields: result.request.required_fields,
options: result.request.options as Record<string, unknown> | undefined,
model: result.provenance.model,
promptVersion: result.provenance.prompt_version,
correlationId,
})
return
}
const proposal = await persistMatchProposal(supabase, result, {
userId,
companyId,
subjectId: inboxItem.id,
correlationId,
})
// Emit for metrics / audit subscribers.
try {
await eventBus.emit({
type: 'ai_proposal.generated',
payload: { proposal, userId, companyId },
})
} catch { /* non-blocking */ }
}
interface GenerateBookingArgs {
inboxItem: InvoiceInboxItem
matchedTransaction: Transaction
existingTemplates: CategorizationTemplate[]
entityType: 'enskild_firma' | 'aktiebolag'
correlationId?: string
userId: string
companyId: string
}
async function generateBookingProposalFor(
supabase: SupabaseClient,
args: GenerateBookingArgs
): Promise<void> {
const { inboxItem, matchedTransaction, existingTemplates, entityType, correlationId, userId, companyId } = args
const service = getAIProposalService()
const result = await service.generateBookingProposal({
inboxItem,
matchedTransaction,
existingTemplates,
entityType,
userId,
companyId,
})
if (result === null) {
await insertRequest(supabase, {
companyId,
subjectType: 'inbox_item',
subjectId: inboxItem.id,
requestType: 'needs_manual',
message: 'AI-agenten är inte tillgänglig just nu — bokför manuellt.',
correlationId,
})
return
}
if (result.kind === 'request') {
await insertRequest(supabase, {
companyId,
subjectType: 'inbox_item',
subjectId: inboxItem.id,
requestType: result.request.request_type,
message: result.request.message,
requiredFields: result.request.required_fields,
options: result.request.options as Record<string, unknown> | undefined,
model: result.provenance.model,
promptVersion: result.provenance.prompt_version,
correlationId,
})
return
}
const proposal = await persistBookingProposal(supabase, result, {
userId,
companyId,
subjectId: inboxItem.id,
correlationId,
})
try {
await eventBus.emit({
type: 'ai_proposal.generated',
payload: { proposal, userId, companyId },
})
} catch { /* non-blocking */ }
}
// ── Persist helpers ──────────────────────────────────────────────────
interface PersistArgs {
userId: string
companyId: string
subjectId: string
correlationId?: string
}
async function persistMatchProposal(
supabase: SupabaseClient,
result: MatchProposalResult,
args: PersistArgs
): Promise<AIProposal> {
return insertProposal(supabase, {
companyId: args.companyId,
userId: args.userId,
subjectType: 'inbox_item',
subjectId: args.subjectId,
stepType: 'match',
proposalJson: result.proposal,
confidence: result.confidence,
reasoning: result.reasoning,
model: result.provenance.model,
promptVersion: result.provenance.prompt_version,
inputTokens: result.provenance.input_tokens,
outputTokens: result.provenance.output_tokens,
correlationId: args.correlationId,
})
}
async function persistBookingProposal(
supabase: SupabaseClient,
result: BookingProposalResult,
args: PersistArgs
): Promise<AIProposal> {
return insertProposal(supabase, {
companyId: args.companyId,
userId: args.userId,
subjectType: 'inbox_item',
subjectId: args.subjectId,
stepType: 'booking',
proposalJson: result.proposal,
confidence: result.confidence,
reasoning: result.reasoning,
model: result.provenance.model,
promptVersion: result.provenance.prompt_version,
inputTokens: result.provenance.input_tokens,
outputTokens: result.provenance.output_tokens,
correlationId: args.correlationId,
})
}
// ── Registration ─────────────────────────────────────────────────────
/**
* Register the AI orchestrator on the core event bus. Called from lib/init.ts
* alongside the other core handlers.
*/
export function registerAIProposalHandler(): () => void {
const unsubs: Array<() => void> = [
eventBus.on('inbox_item.classified', handleInboxItemClassified),
eventBus.on('ai_proposal.accepted', handleProposalAccepted),
eventBus.on('transaction.categorized', handleTransactionCategorized),
]
return () => {
unsubs.forEach((u) => u())
}
}
// Exports for direct use from API routes (e.g., /api/ai/backfill/receipts).
export { generateMatchProposalFor, generateBookingProposalFor }
// Also re-export the unused result types so TS keeps them imported.
export type { MatchProposalResult, BookingProposalResult, AIRequestResult }
+123
View File
@@ -0,0 +1,123 @@
/**
* AI Proposal Service Interface
*
* Core defines the contract. The `ai-agent` extension registers a real
* implementation backed by Bedrock. Without the extension, the noop service
* is used — every call returns `null` and the orchestrator degrades by
* issuing a `needs_manual` ai_request so the user sees the item and knows
* they need to process it manually.
*
* Mirrors the pattern in lib/email/service.ts.
*/
import type {
InvoiceInboxItem,
Transaction,
MatchProposalPayload,
BookingProposalPayload,
AIRequestType,
CategorizationTemplate,
PickTransactionOption,
} from '@/types'
// Shared fields any LLM call returns for audit.
export interface ProposalProvenance {
model: string
prompt_version: string
input_tokens: number
output_tokens: number
}
// When the AI produces a concrete suggestion.
export interface MatchProposalResult {
kind: 'proposal'
proposal: MatchProposalPayload
confidence: number
reasoning: string
provenance: ProposalProvenance
}
export interface BookingProposalResult {
kind: 'proposal'
proposal: BookingProposalPayload
confidence: number
reasoning: string
provenance: ProposalProvenance
}
// When the AI would rather ask the user than guess.
export interface AIRequestResult {
kind: 'request'
request: {
request_type: AIRequestType
message: string
required_fields?: Record<string, unknown>
options?: Record<string, unknown> | { candidates: PickTransactionOption[] }
}
provenance: Partial<ProposalProvenance>
}
// Context passed to each generator. Keeping the contract tight so extensions
// can't accidentally see more than they need.
export interface GenerateMatchContext {
inboxItem: InvoiceInboxItem
userId: string
companyId: string
}
export interface GenerateBookingContext {
inboxItem: InvoiceInboxItem
matchedTransaction: Transaction
existingTemplates: CategorizationTemplate[]
entityType: 'enskild_firma' | 'aktiebolag'
userId: string
companyId: string
}
export interface AIProposalService {
/** True when a real (non-noop) implementation is registered and ready. */
isEnabled(): boolean
/**
* Propose which bank transaction matches an incoming receipt.
* Returns null on service outage (orchestrator will issue needs_manual).
*/
generateMatchProposal(
ctx: GenerateMatchContext
): Promise<MatchProposalResult | AIRequestResult | null>
/**
* Propose how to book the matched transaction (accounts, VAT, lines).
* Returns null on service outage (orchestrator will issue needs_manual).
*/
generateBookingProposal(
ctx: GenerateBookingContext
): Promise<BookingProposalResult | AIRequestResult | null>
}
class NoopAIProposalService implements AIProposalService {
isEnabled(): boolean {
return false
}
async generateMatchProposal(): Promise<null> {
return null
}
async generateBookingProposal(): Promise<null> {
return null
}
}
let service: AIProposalService = new NoopAIProposalService()
export function getAIProposalService(): AIProposalService {
return service
}
export function registerAIProposalService(svc: AIProposalService): void {
service = svc
}
/** Reset to noop — for tests only. */
export function _resetAIProposalService(): void {
service = new NoopAIProposalService()
}
+296
View File
@@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock processing-history append BEFORE importing persist — the module
// grabs `createServiceClient` at import time, which needs env vars we
// don't care about here.
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue('evt-1'),
}))
import { insertProposal, insertRequest, skipPendingProposalsForSubject } from '../persist'
import type { MatchProposalPayload } from '@/types'
/**
* Build a scripted supabase mock where each chained operation is tracked
* so the test can inspect what was called. Each `.from(...)` returns a new
* chain; the `.update(...)` and `.insert(...)` calls capture payloads;
* the await resolves to a scripted result via the `results` queue.
*/
interface Call {
table: string
op: 'update' | 'insert' | 'select' | 'other'
payload?: unknown
filters: Array<{ key: string; value: unknown }>
}
function scriptedSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
const calls: Call[] = []
let resultIdx = 0
const makeChain = (table: string) => {
const current: Call = { table, op: 'other', filters: [] }
calls.push(current)
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = results[resultIdx++] ?? { data: null, error: null }
return (resolve: (v: unknown) => void) =>
resolve({ data: next.data ?? null, error: next.error ?? null })
}
return (...args: unknown[]) => {
if (prop === 'update') {
current.op = 'update'
current.payload = args[0]
} else if (prop === 'insert') {
current.op = 'insert'
current.payload = args[0]
} else if (prop === 'select') {
current.op = current.op === 'other' ? 'select' : current.op
} else if (prop === 'eq') {
current.filters.push({ key: String(args[0]), value: args[1] })
}
return chain
}
},
}
const chain = new Proxy({}, handler)
return chain
}
const client = {
from: vi.fn().mockImplementation((table: string) => makeChain(table)),
}
return { client, calls }
}
describe('insertProposal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('invalidates prior pending before inserting new', async () => {
const { client, calls } = scriptedSupabase([
// update (invalidate)
{ data: null },
// insert + select + single
{
data: {
id: 'proposal-new',
company_id: 'c1',
user_id: 'u1',
subject_type: 'inbox_item',
subject_id: 'inbox-1',
step_type: 'match',
status: 'pending',
version: 1,
proposal_json: {},
confidence: 0.9,
reasoning: 'x',
ai_request_id: null,
model: 'm',
prompt_version: 'v1',
input_token_count: 0,
output_token_count: 0,
edit_diff: null,
applied_entry_id: null,
invalidated_reason: null,
created_at: '2026-04-23T00:00:00Z',
accepted_at: null,
accepted_by_user_id: null,
rejected_at: null,
updated_at: '2026-04-23T00:00:00Z',
},
},
])
const payload: MatchProposalPayload = {
matched_transaction_id: 'tx-1',
alternatives: [],
top_confidence: 0.9,
}
const result = await insertProposal(
client as unknown as import('@supabase/supabase-js').SupabaseClient,
{
companyId: 'c1',
userId: 'u1',
subjectType: 'inbox_item',
subjectId: 'inbox-1',
stepType: 'match',
proposalJson: payload,
confidence: 0.9,
reasoning: 'x',
model: 'm',
promptVersion: 'v1',
inputTokens: 0,
outputTokens: 0,
}
)
expect(result.id).toBe('proposal-new')
// Expect two .from('ai_proposals') calls:
// 1. update → invalidate prior
// 2. insert → new row
const aiProposalCalls = calls.filter((c) => c.table === 'ai_proposals')
expect(aiProposalCalls).toHaveLength(2)
expect(aiProposalCalls[0].op).toBe('update')
expect(aiProposalCalls[0].payload).toMatchObject({
status: 'invalidated',
invalidated_reason: 'superseded_by_new_proposal',
})
expect(aiProposalCalls[1].op).toBe('insert')
expect(aiProposalCalls[1].payload).toMatchObject({
company_id: 'c1',
subject_id: 'inbox-1',
step_type: 'match',
status: 'pending',
})
})
it('throws when insert returns an error', async () => {
const { client } = scriptedSupabase([
{ data: null }, // update OK
{ data: null, error: { message: 'boom' } }, // insert fails
])
await expect(
insertProposal(
client as unknown as import('@supabase/supabase-js').SupabaseClient,
{
companyId: 'c1',
userId: 'u1',
subjectType: 'inbox_item',
subjectId: 'inbox-1',
stepType: 'match',
proposalJson: { matched_transaction_id: 'tx-1', alternatives: [], top_confidence: 0.9 },
confidence: 0.9,
reasoning: 'x',
model: 'm',
promptVersion: 'v1',
inputTokens: 0,
outputTokens: 0,
}
)
).rejects.toThrow(/Failed to insert ai_proposal/)
})
})
describe('insertRequest', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('updates existing open request with the same (subject, type) instead of inserting', async () => {
const { client, calls } = scriptedSupabase([
// existing lookup
{ data: { id: 'req-existing' } },
// update
{
data: {
id: 'req-existing',
company_id: 'c1',
subject_type: 'inbox_item',
subject_id: 'inbox-1',
request_type: 'needs_manual',
message: 'updated',
required_fields: null,
options: null,
status: 'open',
response_json: null,
resolved_at: null,
resolved_by_user_id: null,
model: null,
prompt_version: null,
created_at: '2026-04-23T00:00:00Z',
updated_at: '2026-04-23T00:00:00Z',
},
},
])
const result = await insertRequest(
client as unknown as import('@supabase/supabase-js').SupabaseClient,
{
companyId: 'c1',
subjectType: 'inbox_item',
subjectId: 'inbox-1',
requestType: 'needs_manual',
message: 'updated',
}
)
expect(result.id).toBe('req-existing')
const updateCall = calls.find((c) => c.table === 'ai_requests' && c.op === 'update')
expect(updateCall).toBeDefined()
expect(updateCall!.payload).toMatchObject({ message: 'updated' })
// No insert was performed (would have been a second ai_requests call with op=insert).
const insertCall = calls.find((c) => c.table === 'ai_requests' && c.op === 'insert')
expect(insertCall).toBeUndefined()
})
it('inserts a new request when none exists', async () => {
const { client, calls } = scriptedSupabase([
// existing lookup → none
{ data: null },
// insert
{
data: {
id: 'req-new',
company_id: 'c1',
subject_type: 'inbox_item',
subject_id: 'inbox-1',
request_type: 'reupload_document',
message: 'new ask',
required_fields: null,
options: null,
status: 'open',
response_json: null,
resolved_at: null,
resolved_by_user_id: null,
model: null,
prompt_version: null,
created_at: '2026-04-23T00:00:00Z',
updated_at: '2026-04-23T00:00:00Z',
},
},
])
const result = await insertRequest(
client as unknown as import('@supabase/supabase-js').SupabaseClient,
{
companyId: 'c1',
subjectType: 'inbox_item',
subjectId: 'inbox-1',
requestType: 'reupload_document',
message: 'new ask',
}
)
expect(result.id).toBe('req-new')
const insertCall = calls.find((c) => c.table === 'ai_requests' && c.op === 'insert')
expect(insertCall).toBeDefined()
})
})
describe('skipPendingProposalsForSubject', () => {
it('updates all pending proposals for the subject to skipped', async () => {
const { client, calls } = scriptedSupabase([{ data: null }])
await skipPendingProposalsForSubject(
client as unknown as import('@supabase/supabase-js').SupabaseClient,
'inbox_item',
'inbox-1',
'user_went_manual'
)
const call = calls.find((c) => c.table === 'ai_proposals')
expect(call?.op).toBe('update')
expect(call?.payload).toMatchObject({
status: 'skipped',
invalidated_reason: 'user_went_manual',
})
})
})
@@ -0,0 +1,422 @@
import { describe, it, expect, vi } from 'vitest'
import { reValidateProposal } from '../re-validate'
import type { AIProposal, BookingProposalPayload, MatchProposalPayload, InvoiceInboxItem } from '@/types'
// Minimal proposal factory.
function makeProposal(overrides: Partial<AIProposal> = {}): AIProposal {
return {
id: 'proposal-1',
company_id: 'company-1',
user_id: 'user-1',
subject_type: 'inbox_item',
subject_id: 'inbox-1',
step_type: 'match',
status: 'pending',
version: 1,
proposal_json: {
matched_transaction_id: 'tx-1',
alternatives: [],
top_confidence: 0.9,
} as MatchProposalPayload,
confidence: 0.9,
reasoning: 'test',
ai_request_id: null,
model: 'test',
prompt_version: 'test-v1',
input_token_count: 0,
output_token_count: 0,
edit_diff: null,
applied_entry_id: null,
invalidated_reason: null,
created_at: '2026-04-23T00:00:00Z',
accepted_at: null,
accepted_by_user_id: null,
rejected_at: null,
updated_at: '2026-04-23T00:00:00Z',
...overrides,
}
}
function makeInboxItem(overrides: Partial<InvoiceInboxItem> = {}): InvoiceInboxItem {
// Only the fields re-validate inspects need to be realistic.
return {
id: 'inbox-1',
company_id: 'company-1',
user_id: 'user-1',
status: 'ready',
source: 'upload',
document_id: 'doc-1',
document_type: 'receipt',
extracted_data: null,
confidence: null,
matched_supplier_id: null,
matched_transaction_id: null,
match_confidence: null,
match_method: null,
match_reasoning: null,
raw_llm_response: null,
email_from: null,
email_subject: null,
email_received_at: null,
email_body_text: null,
resend_email_id: null,
resend_attachment_id: null,
raw_email_payload: null,
correlation_id: null,
created_supplier_invoice_id: null,
error_message: null,
created_at: '2026-04-23T00:00:00Z',
updated_at: '2026-04-23T00:00:00Z',
...overrides,
} as unknown as InvoiceInboxItem
}
/**
* Build a scripted supabase mock where each `.from(table)` returns a chain
* whose terminal awaits resolve in FIFO order from the `results` queue.
*/
function scriptedSupabase(results: Array<{ data: unknown; error?: unknown }>) {
let i = 0
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = results[i++] ?? { data: null, error: null }
return (resolve: (v: unknown) => void) =>
resolve({ data: next.data ?? null, error: next.error ?? null })
}
return () => buildChain()
},
}
return new Proxy({}, handler)
}
return {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
} as unknown as import('@supabase/supabase-js').SupabaseClient
}
describe('reValidateProposal', () => {
it('inbox item missing → fails with inbox_item_missing', async () => {
const proposal = makeProposal()
const supabase = scriptedSupabase([{ data: null }])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('inbox_item_missing')
})
it('inbox item already confirmed → fails with inbox_item_already_booked', async () => {
const proposal = makeProposal()
const supabase = scriptedSupabase([
{ data: makeInboxItem({ status: 'confirmed' }) },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('inbox_item_already_booked')
})
it('match proposal → transaction missing → fails', async () => {
const proposal = makeProposal({ step_type: 'match' })
const supabase = scriptedSupabase([
{ data: makeInboxItem() },
{ data: null }, // transaction lookup → not found
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('transaction_missing')
})
it('match proposal → transaction already booked → fails', async () => {
const proposal = makeProposal({ step_type: 'match' })
const supabase = scriptedSupabase([
{ data: makeInboxItem() },
{ data: { id: 'tx-1', journal_entry_id: 'entry-1', company_id: 'company-1' } },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('transaction_already_booked')
})
it('match proposal → happy path → ok', async () => {
const proposal = makeProposal({ step_type: 'match' })
const supabase = scriptedSupabase([
{ data: makeInboxItem() },
{ data: { id: 'tx-1', journal_entry_id: null, company_id: 'company-1' } },
{ data: null }, // no other inbox item claims this transaction
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(true)
})
it('booking proposal → no matched_transaction_id → step_prerequisite_missing', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '5410', debit_amount: 100, credit_amount: 0, description: 'x' },
{ account_number: '1930', debit_amount: 0, credit_amount: 100, description: 'x' },
],
vat_treatment: 'exempt',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2026-04-23',
description: 'test',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([{ data: makeInboxItem({ matched_transaction_id: null }) }])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('step_prerequisite_missing')
})
it('booking proposal → period closed → period_missing_or_closed', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '5410', debit_amount: 100, credit_amount: 0, description: 'x' },
{ account_number: '1930', debit_amount: 0, credit_amount: 100, description: 'x' },
],
vat_treatment: 'exempt',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2026-04-23',
description: 'test',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([
{ data: makeInboxItem({ matched_transaction_id: 'tx-1' }) },
{ data: { id: 'tx-1', journal_entry_id: null } },
{ data: { id: 'period-1', is_closed: true, locked_at: null } },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('period_missing_or_closed')
})
it('booking proposal → grocery merchant + reduced_12 + 2026-04-15 → livsmedel_vat_rate_stale', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '4010', debit_amount: 80, credit_amount: 0, description: 'Matvaror ICA Maxi' },
{ account_number: '2641', debit_amount: 9.6, credit_amount: 0, description: 'Ingående moms 12%' },
{ account_number: '1930', debit_amount: 0, credit_amount: 89.6, description: 'ICA Maxi' },
],
vat_treatment: 'reduced_12',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2026-04-15',
description: 'ICA Maxi — matvaror',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'ICA Maxi Lindhagen' } }) },
{ data: { id: 'tx-1', journal_entry_id: null } },
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
{ data: [
{ account_number: '4010', is_active: true },
{ account_number: '2641', is_active: true },
{ account_number: '1930', is_active: true },
] },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('livsmedel_vat_rate_stale')
expect(result.details?.expected).toBe('reduced_6')
}
})
it('booking proposal → grocery merchant + reduced_6 + 2026-04-15 → ok', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '4010', debit_amount: 80, credit_amount: 0, description: 'Matvaror ICA' },
{ account_number: '2641', debit_amount: 4.8, credit_amount: 0, description: 'Ingående moms 6%' },
{ account_number: '1930', debit_amount: 0, credit_amount: 84.8, description: 'ICA' },
],
vat_treatment: 'reduced_6',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2026-04-15',
description: 'ICA Maxi — matvaror',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'ICA Maxi' } }) },
{ data: { id: 'tx-1', journal_entry_id: null } },
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
{ data: [
{ account_number: '4010', is_active: true },
{ account_number: '2641', is_active: true },
{ account_number: '1930', is_active: true },
] },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(true)
})
it('booking proposal → grocery merchant + reduced_6 + 2025-12-15 → livsmedel_vat_rate_stale', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '4010', debit_amount: 80, credit_amount: 0, description: 'Matvaror Coop' },
{ account_number: '2641', debit_amount: 4.8, credit_amount: 0, description: 'Ingående moms 6%' },
{ account_number: '1930', debit_amount: 0, credit_amount: 84.8, description: 'Coop' },
],
vat_treatment: 'reduced_6',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2025-12-15',
description: 'Coop — matvaror',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'Coop Konsum' } }) },
{ data: { id: 'tx-1', journal_entry_id: null } },
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
{ data: [
{ account_number: '4010', is_active: true },
{ account_number: '2641', is_active: true },
{ account_number: '1930', is_active: true },
] },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('livsmedel_vat_rate_stale')
expect(result.details?.expected).toBe('reduced_12')
}
})
it('booking proposal → restaurang + reduced_6 → livsmedel_vat_rate_stale', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '5810', debit_amount: 80, credit_amount: 0, description: 'Lunch på restaurang' },
{ account_number: '2641', debit_amount: 4.8, credit_amount: 0, description: 'Ingående moms 6%' },
{ account_number: '1930', debit_amount: 0, credit_amount: 84.8, description: 'Restaurang' },
],
vat_treatment: 'reduced_6',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2026-04-15',
description: 'Restaurang Frantzén — lunch',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'Restaurang Frantzén' } }) },
{ data: { id: 'tx-1', journal_entry_id: null } },
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
{ data: [
{ account_number: '5810', is_active: true },
{ account_number: '2641', is_active: true },
{ account_number: '1930', is_active: true },
] },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('livsmedel_vat_rate_stale')
expect(result.details?.signal).toBe('restaurang')
}
})
it('booking proposal → restaurang + reduced_12 → ok (servering stays at 12%)', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '5810', debit_amount: 80, credit_amount: 0, description: 'Lunch' },
{ account_number: '2641', debit_amount: 9.6, credit_amount: 0, description: 'Ingående moms 12%' },
{ account_number: '1930', debit_amount: 0, credit_amount: 89.6, description: 'Restaurang' },
],
vat_treatment: 'reduced_12',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2026-04-15',
description: 'Restaurang — lunch',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([
{ data: makeInboxItem({ matched_transaction_id: 'tx-1', extracted_data: { merchant_name: 'Restaurang Frantzén' } }) },
{ data: { id: 'tx-1', journal_entry_id: null } },
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
{ data: [
{ account_number: '5810', is_active: true },
{ account_number: '2641', is_active: true },
{ account_number: '1930', is_active: true },
] },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(true)
})
it('booking proposal → inactive account → account_missing_or_inactive', async () => {
const proposal = makeProposal({
step_type: 'booking',
proposal_json: {
lines: [
{ account_number: '5410', debit_amount: 100, credit_amount: 0, description: 'x' },
{ account_number: '1930', debit_amount: 0, credit_amount: 100, description: 'x' },
],
vat_treatment: 'exempt',
default_private: false,
counterparty_template_proposal: null,
fiscal_period_id: 'period-1',
entry_date: '2026-04-23',
description: 'test',
} as BookingProposalPayload,
})
const supabase = scriptedSupabase([
{ data: makeInboxItem({ matched_transaction_id: 'tx-1' }) },
{ data: { id: 'tx-1', journal_entry_id: null } },
{ data: { id: 'period-1', is_closed: false, locked_at: null } },
// Only 1930 is active; 5410 missing from results.
{ data: [{ account_number: '1930', is_active: true }] },
])
const result = await reValidateProposal(supabase, 'company-1', proposal)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('account_missing_or_inactive')
expect(result.details?.missing_accounts).toEqual(['5410'])
}
})
})
+188
View File
@@ -0,0 +1,188 @@
/**
* 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,
}
}
+264
View File
@@ -0,0 +1,264 @@
/**
* 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')
}
+346
View File
@@ -0,0 +1,346 @@
/**
* Re-validation at accept time.
*
* A pending proposal can become stale between generation and accept:
* * matched transaction gets deleted or already booked
* * fiscal period closed or locked
* * account deactivated in the chart
* * inbox item already linked to a journal entry via a manual path
*
* This module runs the relevant checks and returns a typed error the API
* route translates to a structured response the UI can act on (e.g.,
* "period closed — reopen it or change the entry date").
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
AIProposal,
BookingProposalPayload,
MatchProposalPayload,
InvoiceInboxItem,
} from '@/types'
export type ValidationFailureCode =
| 'inbox_item_missing'
| 'inbox_item_already_booked'
| 'transaction_missing'
| 'transaction_already_booked'
| 'transaction_already_matched_elsewhere'
| 'period_missing_or_closed'
| 'account_missing_or_inactive'
| 'receipt_file_missing'
| 'step_prerequisite_missing'
| 'livsmedel_vat_rate_stale'
export interface ValidationSuccess {
ok: true
inboxItem: InvoiceInboxItem
}
export interface ValidationFailure {
ok: false
code: ValidationFailureCode
message: string
details?: Record<string, unknown>
}
export type ValidationResult = ValidationSuccess | ValidationFailure
export async function reValidateProposal(
supabase: SupabaseClient,
companyId: string,
proposal: AIProposal
): Promise<ValidationResult> {
if (proposal.subject_type !== 'inbox_item') {
return {
ok: false,
code: 'step_prerequisite_missing',
message: 'Endast inkorgsobjekt stöds i denna version.',
}
}
// Common: the inbox item still exists.
const { data: inboxItem, error: inboxError } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', proposal.subject_id)
.eq('company_id', companyId)
.maybeSingle()
if (inboxError || !inboxItem) {
return {
ok: false,
code: 'inbox_item_missing',
message: 'Kvittot/fakturan finns inte längre.',
}
}
const item = inboxItem as InvoiceInboxItem
// If the document has already been booked via another path, skip.
if (item.status === 'confirmed') {
return {
ok: false,
code: 'inbox_item_already_booked',
message: 'Detta dokument är redan bokfört manuellt.',
}
}
if (proposal.step_type === 'match') {
return reValidateMatch(supabase, companyId, item, proposal.proposal_json as MatchProposalPayload)
}
if (proposal.step_type === 'booking') {
return reValidateBooking(supabase, companyId, item, proposal.proposal_json as BookingProposalPayload)
}
return {
ok: false,
code: 'step_prerequisite_missing',
message: `Okänt stegtyp: ${proposal.step_type}`,
}
}
async function reValidateMatch(
supabase: SupabaseClient,
companyId: string,
item: InvoiceInboxItem,
payload: MatchProposalPayload
): Promise<ValidationResult> {
// BFL 5 kap 7§: every verifikation requires an underlying source document.
// Block the match accept when no receipt file is attached so the user
// can't reach the booking step without proof. The UI shows an upload
// affordance in the receipt detail modal for this exact case.
if (!item.document_id) {
return {
ok: false,
code: 'receipt_file_missing',
message: 'Kvittobild krävs innan du kan koppla transaktionen. Ladda upp en bild av kvittot först.',
}
}
const txId = payload.matched_transaction_id
const { data: tx } = await supabase
.from('transactions')
.select('id, journal_entry_id, company_id')
.eq('id', txId)
.eq('company_id', companyId)
.maybeSingle()
if (!tx) {
return {
ok: false,
code: 'transaction_missing',
message: 'Den föreslagna transaktionen finns inte längre.',
}
}
if (tx.journal_entry_id) {
return {
ok: false,
code: 'transaction_already_booked',
message: 'Transaktionen är redan bokförd.',
}
}
// Another inbox item may have claimed this transaction via the existing
// smart-match partial unique index.
const { data: claimingInbox } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('matched_transaction_id', txId)
.eq('company_id', companyId)
.neq('id', item.id)
.maybeSingle()
if (claimingInbox) {
return {
ok: false,
code: 'transaction_already_matched_elsewhere',
message: 'Transaktionen är redan matchad till ett annat dokument.',
}
}
return { ok: true, inboxItem: item }
}
async function reValidateBooking(
supabase: SupabaseClient,
companyId: string,
item: InvoiceInboxItem,
payload: BookingProposalPayload
): Promise<ValidationResult> {
if (!item.matched_transaction_id) {
return {
ok: false,
code: 'step_prerequisite_missing',
message: 'Ingen matchande transaktion — stäng först matchningssteget.',
}
}
// The transaction still exists and is still unbooked.
const { data: tx } = await supabase
.from('transactions')
.select('id, journal_entry_id')
.eq('id', item.matched_transaction_id)
.eq('company_id', companyId)
.maybeSingle()
if (!tx) {
return {
ok: false,
code: 'transaction_missing',
message: 'Den matchade transaktionen finns inte längre.',
}
}
if (tx.journal_entry_id) {
return {
ok: false,
code: 'transaction_already_booked',
message: 'Transaktionen har redan bokförts.',
}
}
// Fiscal period is open.
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, is_closed, locked_at')
.eq('id', payload.fiscal_period_id)
.eq('company_id', companyId)
.maybeSingle()
if (!period || period.is_closed || period.locked_at) {
return {
ok: false,
code: 'period_missing_or_closed',
message: 'Räkenskapsåret är låst eller finns inte längre.',
}
}
// All accounts in the proposed lines are active in the chart.
const accountNumbers = [...new Set(payload.lines.map((l) => l.account_number))]
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, is_active')
.eq('company_id', companyId)
.in('account_number', accountNumbers)
const foundActive = new Set(
(accounts || []).filter((a) => a.is_active).map((a) => a.account_number)
)
const missing = accountNumbers.filter((n) => !foundActive.has(n))
if (missing.length > 0) {
return {
ok: false,
code: 'account_missing_or_inactive',
message: `Kontona saknas eller är inaktiva: ${missing.join(', ')}`,
details: { missing_accounts: missing },
}
}
const livsmedelMismatch = detectLivsmedelRateMismatch(item, payload)
if (livsmedelMismatch) {
return {
ok: false,
code: 'livsmedel_vat_rate_stale',
message: livsmedelMismatch.message,
details: livsmedelMismatch.details,
}
}
return { ok: true, inboxItem: item }
}
// Sweden's livsmedel VAT temporarily drops from 12% to 6% between
// 2026-04-01 and 2027-12-31 (Prop. 2025/26:55). Restaurang/servering stays
// at 12% throughout. This guard catches AI proposals where the rate label
// is stale relative to the entry date for clearly-grocery merchants. The
// prompt is the primary defence; this is the safety net for prompt drift.
const LIVSMEDEL_REDUCED_START = '2026-04-01'
const LIVSMEDEL_REDUCED_END = '2027-12-31'
const GROCERY_CHAIN_KEYWORDS = [
'ica maxi',
'ica kvantum',
'ica supermarket',
'ica nära',
'ica',
'coop',
'hemköp',
'willys',
'lidl',
'city gross',
'tempo',
'mathem',
'mat.se',
'matse',
'netto',
'matöppet',
]
const RESTAURANG_KEYWORDS = [
'restaurang',
'servering',
'pizzeria',
'bistro',
'lunchrestaurang',
'sushi',
'café',
'kafé',
'cafe',
]
function detectLivsmedelRateMismatch(
item: InvoiceInboxItem,
payload: BookingProposalPayload
): { message: string; details: Record<string, unknown> } | null {
const treatment = payload.vat_treatment
if (treatment !== 'reduced_12' && treatment !== 'reduced_6') return null
const haystack = [
payload.description ?? '',
...payload.lines.map((l) => l.description ?? ''),
JSON.stringify(item.extracted_data ?? {}),
]
.join(' ')
.toLowerCase()
const isGrocery = GROCERY_CHAIN_KEYWORDS.some((k) => haystack.includes(k))
const isRestaurang = RESTAURANG_KEYWORDS.some((k) => haystack.includes(k))
// If both signals fire, treat as ambiguous and let it through — the
// user will review on the inbox card anyway.
if (isGrocery === isRestaurang) return null
const date = payload.entry_date
const inReducedWindow = date >= LIVSMEDEL_REDUCED_START && date <= LIVSMEDEL_REDUCED_END
if (isGrocery && treatment === 'reduced_12' && inReducedWindow) {
return {
message:
'Momssatsen 12 % stämmer inte — livsmedel ska bokföras med 6 % moms från 1 april 2026 t.o.m. 31 december 2027. Justera förslaget eller bokför manuellt.',
details: { signal: 'grocery', treatment, entry_date: date, expected: 'reduced_6' },
}
}
if (isGrocery && treatment === 'reduced_6' && !inReducedWindow) {
return {
message:
'Momssatsen 6 % gäller endast för livsmedel mellan 1 april 2026 och 31 december 2027. Övriga datum ska bokföras med 12 %.',
details: { signal: 'grocery', treatment, entry_date: date, expected: 'reduced_12' },
}
}
if (isRestaurang && treatment === 'reduced_6') {
return {
message:
'Restaurang- och serveringstjänster har 12 % moms (omfattas inte av livsmedelssänkningen). Justera förslaget eller bokför manuellt.',
details: { signal: 'restaurang', treatment, entry_date: date, expected: 'reduced_12' },
}
}
return null
}
+86
View File
@@ -394,6 +394,8 @@ export const UpdateSettingsSchema = z.object({
invoice_show_plusgiro: z.boolean().optional(),
invoice_late_fee_text: z.string().nullable().optional(),
invoice_credit_terms_text: z.string().nullable().optional(),
// AI agent flow
ai_flow_enabled: z.boolean().optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
@@ -768,3 +770,87 @@ export const CreateSalaryLineItemSchema = z.object({
})
export const UpdateSalaryLineItemSchema = CreateSalaryLineItemSchema.partial().omit({ salary_run_employee_id: true })
// ============================================================
// AI agent flow schemas
// ============================================================
const BookingProposalLineSchema = z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount,
credit_amount: nonNegativeAmount,
description: z.string().min(1).max(500),
})
const BookingProposalCounterpartyTemplateSchema = z.object({
counterparty_name: z.string().min(1).max(200),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: VatTreatmentSchema.nullable(),
category: TransactionCategorySchema.nullable(),
})
// Edit payload: the user's edited version of a booking proposal. Used in
// the /accept endpoint when the user adjusted accounts/VAT before approving.
export const EditBookingProposalSchema = z.object({
lines: z.array(BookingProposalLineSchema).min(2),
vat_treatment: VatTreatmentSchema.nullable(),
default_private: z.boolean(),
counterparty_template_proposal: BookingProposalCounterpartyTemplateSchema.nullable(),
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1).max(500),
})
// For match proposals, editing just means picking a different transaction.
export const EditMatchProposalSchema = z.object({
matched_transaction_id: uuid,
})
export const AcceptProposalSchema = z.object({
version: z.number().int().nonnegative(),
edits: z.union([EditBookingProposalSchema, EditMatchProposalSchema]).optional(),
})
// Change the matched transaction on a pending match proposal without
// accepting it. Source tells us whether the user picked one of the AI's
// own alternatives, an AI-regenerated suggestion, or a manually-chosen
// transaction — kept on edit_diff for learning signal.
export const ChangeMatchProposalSchema = z.object({
version: z.number().int().nonnegative(),
matched_transaction_id: uuid,
source: z.enum(['user_alternative', 'user_manual', 'ai_regenerated']),
})
export const RejectProposalSchema = z.object({
version: z.number().int().nonnegative(),
reason: z.string().max(500).optional(),
})
export const BatchAcceptSchema = z.object({
proposal_ids: z.array(uuid).min(1).max(50),
})
export const ResolveRequestSchema = z.object({
response: z.record(z.string(), z.unknown()).optional(),
})
export const StartBackfillSchema = z.object({}).strict()
export const RememberLearningSchema = z.object({
proposal_id: uuid,
counterparty_name: z.string().min(1).max(200),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: VatTreatmentSchema.nullable(),
category: TransactionCategorySchema.nullable(),
})
export const ListProposalsQuerySchema = z.object({
status: z
.enum(['pending', 'accepted', 'rejected', 'skipped', 'invalidated'])
.optional(),
step_type: z.enum(['match', 'booking']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
})
@@ -80,6 +80,11 @@ const SOURCE_PRIORITY: Record<CategorizationTemplateSource, number> = {
auto_learned: 1,
sie_import: 2,
user_approved: 3,
// AI-corrected templates carry explicit user validation (they edited the
// AI's proposal, then confirmed "remember this"), so rank equal to
// user_approved. Fresh incoming AI corrections still win over older
// templates of the same rank (>= in resolveSource).
ai_corrected: 3,
}
export function resolveSource(
+2
View File
@@ -223,6 +223,8 @@ export async function createDraftEntry(
source_id: input.source_id || null,
notes: input.notes || null,
status: 'draft',
created_via: input.created_via || 'manual',
source_proposal_id: input.source_proposal_id || null,
})
.select()
.single()
+7
View File
@@ -10,6 +10,8 @@ import type {
ReconciliationMethod,
InvoiceInboxItem,
SupplierInvoice,
AIProposal,
AIRequest,
} from '@/types'
// ============================================================
@@ -86,6 +88,11 @@ export type CoreEvent =
// Company & account lifecycle
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
// AI agent flow (receipts v1)
| { type: 'ai_proposal.generated'; payload: { proposal: AIProposal; userId: string; companyId: string } }
| { type: 'ai_proposal.accepted'; payload: { proposal: AIProposal; appliedEntry: JournalEntry | null; userId: string; companyId: string } }
| { type: 'ai_proposal.rejected'; payload: { proposal: AIProposal; userId: string; companyId: string } }
| { type: 'ai_request.created'; payload: { request: AIRequest; userId: string; companyId: string } }
// ============================================================
// Helper Types
+3 -3
View File
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
expect(SECTORS.length).toBe(1)
})
it('should have 11 total extensions', () => {
expect(getAllExtensions().length).toBe(11)
it('should have 12 total extensions', () => {
expect(getAllExtensions().length).toBe(12)
})
it('should have unique slugs within each sector', () => {
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('general')
expect(extensions.length).toBe(11)
expect(extensions.length).toBe(12)
})
it('all extensions have required fields', () => {
@@ -7,4 +7,6 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'tic',
'mcp-server',
'cloud-backup',
'invoice-inbox',
'ai-agent',
])
@@ -6,6 +6,8 @@ import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
import { ticExtension } from '@/extensions/general/tic'
import { mcpServerExtension } from '@/extensions/general/mcp-server'
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { aiAgentExtension } from '@/extensions/general/ai-agent'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
@@ -14,4 +16,6 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
ticExtension,
mcpServerExtension,
cloudBackupExtension,
invoiceInboxExtension,
aiAgentExtension,
]
@@ -80,5 +80,38 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"hasOwnData": true,
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google."
},
{
"slug": "invoice-inbox",
"name": "Dokumentinkorg",
"sector": "general",
"category": "import",
"icon": "Inbox",
"dataPattern": "both",
"description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton",
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt, klassificeras med AI (leverantör, belopp, moms) och matchas mot transaktioner. Kräver AWS Bedrock och Resend.",
"readsCoreTables": [
"document_attachments",
"suppliers",
"transactions"
],
"hasOwnData": true
},
{
"slug": "ai-agent",
"name": "AI-agent (beta)",
"sector": "general",
"category": "operations",
"icon": "Sparkles",
"dataPattern": "core",
"description": "Autonom bokföring — AI föreslår match + bokföring, du godkänner.",
"longDescription": "När ett kvitto kommer in föreslår AI-agenten först vilken banktransaktion som matchar, sedan hur det ska bokföras. Du granskar och godkänner varje steg — inget bokförs automatiskt. Om AI:n inte kan producera ett förslag (oläslig bild, ingen matchande transaktion, osäker moms) frågar den dig specifikt vad som behövs.",
"readsCoreTables": [
"invoice_inbox_items",
"transactions",
"ai_proposals",
"ai_requests",
"processing_history"
]
},
],
}
@@ -8,4 +8,5 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
}
+3
View File
@@ -3,6 +3,8 @@ import { setContextFactory } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler'
import { registerAIProposalHandler } from '@/lib/ai/orchestrator'
import { isAgentInboxEnabled } from '@/lib/ai/feature-flag'
import { createLogger } from '@/lib/logger'
const log = createLogger('init')
@@ -75,6 +77,7 @@ export function ensureInitialized(): void {
setContextFactory(createExtensionContext)
registerSupplierInvoiceHandler()
registerEventLogHandler()
if (isAgentInboxEnabled()) registerAIProposalHandler()
loadExtensions()
initialized = true
@@ -414,6 +414,8 @@ describe('ingestTransactions', () => {
enqueue({ data: [], error: null })
// Transaction 1: insert OK
enqueue({ data: inserted1, error: null })
// AI flow flag lookup (lazy, fires once on first auto-categorize branch)
enqueue({ data: { ai_flow_enabled: false }, error: null })
// Transaction 2: insert OK
enqueue({ data: inserted2, error: null })
+25 -1
View File
@@ -120,6 +120,28 @@ export async function ingestTransactions(
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
// AI agent gate: when the company has opted into the agent flow, every
// uncategorized transaction becomes a review proposal — no silent auto-book.
// Matching/suggestion still runs (it only sets potential_*_id fields), but
// the mapping-rule auto-categorize branch below is disabled. Fetched lazily
// the first time the auto-categorize branch is about to run, and cached for
// the rest of the batch so we don't hit the DB per-transaction.
let aiFlowEnabledCache: boolean | null = null
const isAiFlowEnabled = async (): Promise<boolean> => {
if (aiFlowEnabledCache !== null) return aiFlowEnabledCache
try {
const { data: aiSettings } = await supabase
.from('company_settings')
.select('ai_flow_enabled')
.eq('company_id', companyId)
.maybeSingle()
aiFlowEnabledCache = Boolean(aiSettings?.ai_flow_enabled)
} catch {
aiFlowEnabledCache = false
}
return aiFlowEnabledCache
}
// When rawInsertOnly is set (viewer imports), skip pre-fetching GL lines,
// supplier invoices, and exchange rates — they are not used.
let glLinePool: UnlinkedGLLine[] = []
@@ -372,7 +394,9 @@ export async function ingestTransactions(
// Skipped when SIE-imported entries overlap the sync range — prevents
// double-booking. Reconciliation (step 2.5) still links transactions to
// existing GL lines; only the "create new journal entry" path is suppressed.
if (!options?.skipAutoCategorization) {
// Also skipped when the company has opted into the AI agent flow — every
// uncategorized transaction must become a proposal, not a silent post.
if (!options?.skipAutoCategorization && !(await isAiFlowEnabled())) {
try {
const mappingResult = await evaluateMappingRules(
supabase,