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:
co-authored by
Claude Opus 4.7
parent
ab63da8324
commit
1af977950b
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 })
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user