Supp/invoice bfl errors (#390)
* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma * Remove AI subsystem and related code - Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`. - Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`. - Cleaned up schemas related to AI flows in `lib/api/schemas.ts`. - Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`. - Eliminated AI event types from `lib/events/types.ts`. - Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`. - Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration. - Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks. - Updated helper functions in `tests/helpers.ts` to remove AI-related settings. - Removed AI-related types and interfaces from `types/index.ts`. - Added migration script to drop AI-related tables and settings from the database. * fix(migrations): ensure foreign key constraint is dropped before removing AI tables * feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning - Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier. - Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs. - Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API. - Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions. - Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`. * feat(invoice-inbox): remove AI-specific columns and tighten status enum * fix(skattekonto): remove manual entry creation reference from transaction input * fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Agent-inkorg feature flag.
|
||||
*
|
||||
* The AI bookkeeping agent isn't ready for general availability. It is
|
||||
* strictly local-dev only — sidebar link, page, API routes, and orchestrator
|
||||
* event handlers all return 404 / are hidden on any deployed (Vercel) build.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export function isAgentInboxEnabled(): boolean {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-booking of bank transactions during ingest.
|
||||
*
|
||||
* Mapping-rule-driven creation of journal entries on import is a future
|
||||
* feature. It must NEVER run on the deployed Vercel production build —
|
||||
* users have to explicitly book each transaction. Allowed only in local
|
||||
* dev (and in the test environment so the auto-book pipeline stays under
|
||||
* test coverage). No env-var escape hatch.
|
||||
*/
|
||||
export function isAutoBookEnabled(): boolean {
|
||||
return process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
* 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 }
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,422 +0,0 @@
|
||||
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'])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user