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
|
||||
}
|
||||
@@ -1033,12 +1033,20 @@ describe('UpdateSettingsSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects aktiebolag with kontantmetoden (BFNAR 2006:1)', () => {
|
||||
it('allows aktiebolag with kontantmetoden (BFL 5 kap. 2 §)', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
entity_type: 'aktiebolag',
|
||||
accounting_method: 'cash',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('allows aktiebolag with faktureringsmetoden', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
entity_type: 'aktiebolag',
|
||||
accounting_method: 'accrual',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('allows enskild firma with kontantmetoden', () => {
|
||||
|
||||
@@ -411,18 +411,6 @@ export const UpdateSettingsSchema = z.object({
|
||||
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
|
||||
path: ['fiscal_year_start_month'],
|
||||
}
|
||||
).refine(
|
||||
(data) => {
|
||||
// BFNAR 2006:1: Aktiebolag must use accrual accounting (faktureringsmetoden)
|
||||
if (data.entity_type === 'aktiebolag' && data.accounting_method !== undefined) {
|
||||
return data.accounting_method === 'accrual'
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Aktiebolag måste använda faktureringsmetoden (BFNAR 2006:1)',
|
||||
path: ['accounting_method'],
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -223,8 +223,6 @@ 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()
|
||||
|
||||
@@ -260,7 +260,7 @@ describe('createCompanyFromTicRole', () => {
|
||||
expect(settings.entity_type).toBe('enskild_firma')
|
||||
expect(settings.vat_registered).toBe(false)
|
||||
expect(settings.moms_period).toBeNull()
|
||||
// EF entities default to cash per K1/BFNAR 2013:2; AB must use accrual (K2/K3).
|
||||
// Default for EF is kontantmetoden (BFL 5 kap. 2 §); AB defaults to accrual but may switch.
|
||||
expect(settings.accounting_method).toBe('cash')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -303,11 +303,9 @@ export async function createCompanyFromTicRole(params: {
|
||||
// under SFL.
|
||||
const momsPeriod = vatRegistered ? 'quarterly' : null
|
||||
|
||||
// EF ≤3 MSEK may use kontantmetoden under K1/BFNAR 2013:2; above that
|
||||
// threshold, BFNAR 2017:3 requires bokföringsmässiga grunder. We default
|
||||
// to cash because the vast majority of EF users are small; users above
|
||||
// the threshold can switch in /settings/bookkeeping. Aktiebolag must use
|
||||
// accrual under K2/K3.
|
||||
// Default by entity_type: EF → kontantmetoden, AB → faktureringsmetoden.
|
||||
// Both forms may use either method under BFL 5 kap. 2 § when annual net
|
||||
// turnover is normally ≤ 3 MSEK; users can change in /settings/bookkeeping.
|
||||
const accountingMethod = entityType === 'enskild_firma' ? 'cash' : 'accrual'
|
||||
|
||||
const settings: Record<string, unknown> = {
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { matchDocumentToTransactions } from '../document-matcher'
|
||||
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
|
||||
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
|
||||
describe('matchDocumentToTransactions', () => {
|
||||
const mockSupabase = {} as never // Not used when candidateTransactions is provided
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('supplier_invoice matching', () => {
|
||||
const baseExtraction: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: 'Telia AB',
|
||||
orgNumber: '556103-4249',
|
||||
vatNumber: 'SE556103424901',
|
||||
address: 'Stockholm',
|
||||
bankgiro: '5820-5093',
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: 'INV-2024-001',
|
||||
invoiceDate: '2024-06-10',
|
||||
dueDate: '2024-06-20',
|
||||
paymentReference: '73401284756',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.95,
|
||||
}
|
||||
|
||||
it('returns null for government_letter type', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'government_letter',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no extracted_data', async () => {
|
||||
const item = makeInvoiceInboxItem({ extracted_data: null })
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no candidate transactions', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 1: matches by payment reference with 0.98 confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
expect(result!.transactionId).toBe(tx.id)
|
||||
})
|
||||
|
||||
it('pass 1: matches with whitespace/dash-normalized references', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '734 012 847 56',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 2: matches by exact amount + bankgiro with 0.92 confidence', async () => {
|
||||
const extractionNoRef = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoRef as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING 58205093',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.92)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 3: matches by exact amount + date proximity with 0.85 confidence', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-22',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.85)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: matches with lower confidence at 6–14 days', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-28', // 8 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.75)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: does not match if date is >14 days away', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-07-06', // 16 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 4: matches by fuzzy amount + supplier name with 0.70 confidence', async () => {
|
||||
const extractionMinimal = {
|
||||
...baseExtraction,
|
||||
invoice: {
|
||||
...baseExtraction.invoice,
|
||||
paymentReference: null,
|
||||
dueDate: null,
|
||||
invoiceDate: null,
|
||||
},
|
||||
supplier: {
|
||||
...baseExtraction.supplier,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
name: 'Telia Sverige',
|
||||
},
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionMinimal as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'telia faktura april',
|
||||
date: '2024-06-15',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
expect(result!.method).toBe('amount_merchant')
|
||||
})
|
||||
|
||||
it('prefers higher confidence matches', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
|
||||
const txWithRef = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
const txWithAmount = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [txWithAmount, txWithRef])
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.transactionId).toBe(txWithRef.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receipt matching', () => {
|
||||
const receiptExtraction: ReceiptExtractionResult = {
|
||||
merchant: {
|
||||
name: 'ICA Maxi',
|
||||
orgNumber: null,
|
||||
vatNumber: null,
|
||||
isForeign: false,
|
||||
},
|
||||
receipt: {
|
||||
date: '2024-06-15',
|
||||
time: '14:30',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
|
||||
flags: {
|
||||
isRestaurant: false,
|
||||
isSystembolaget: false,
|
||||
isForeignMerchant: false,
|
||||
},
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
it('matches receipt to transaction with high confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
description: 'ICA MAXI STOCKHOLM',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('receipt_match')
|
||||
expect(result!.confidence).toBeGreaterThanOrEqual(0.60)
|
||||
})
|
||||
|
||||
it('returns null when amount is too different', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -500,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when date is too far away', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-25', // 10 days after
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('skips transactions with existing receipt_id', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
receipt_id: 'existing-receipt',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when total is 0 or null', async () => {
|
||||
const zeroExtraction = {
|
||||
...receiptExtraction,
|
||||
totals: { ...receiptExtraction.totals, total: 0 },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: zeroExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({ amount: -299, date: '2024-06-15' })
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Batch Document Matching
|
||||
*
|
||||
* Orchestrates matching multiple inbox items to transactions in a single sweep.
|
||||
* Fetches all unbooked transactions once, then runs per-item matching with
|
||||
* greedy assignment to prevent double-matching.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction } from '@/types'
|
||||
import { matchDocumentToTransactions, type DocumentMatchResult } from './document-matcher'
|
||||
|
||||
export interface BatchMatchResult {
|
||||
matched: number
|
||||
total: number
|
||||
matches: Array<{ inboxItemId: string; result: DocumentMatchResult }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a matching sweep for all ready unmatched inbox items.
|
||||
*
|
||||
* 1. Fetches all ready/processing inbox items without a matched_transaction_id
|
||||
* 2. Fetches all unbooked expense transactions
|
||||
* 3. Runs matching per item, greedily assigning (highest confidence first)
|
||||
* 4. Persists matches back to inbox items
|
||||
*/
|
||||
export async function runDocumentMatchingSweep(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
inboxItemIds?: string[]
|
||||
): Promise<BatchMatchResult> {
|
||||
// 1. Fetch unmatched inbox items
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('matched_transaction_id', null)
|
||||
.in('status', ['ready', 'processing'])
|
||||
|
||||
if (inboxItemIds && inboxItemIds.length > 0) {
|
||||
query = query.in('id', inboxItemIds)
|
||||
}
|
||||
|
||||
const { data: inboxItems, error: itemsError } = await query
|
||||
|
||||
if (itemsError || !inboxItems || inboxItems.length === 0) {
|
||||
console.log(`[batch-match] No unmatched inbox items found`)
|
||||
return { matched: 0, total: 0, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Starting sweep: ${inboxItems.length} unmatched inbox items`)
|
||||
|
||||
// 2. Fetch all unbooked expense transactions (broad window: last 90 days)
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', ninetyDaysAgo.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError || !transactions || transactions.length === 0) {
|
||||
console.log(`[batch-match] No candidate transactions found (last 90 days)`)
|
||||
return { matched: 0, total: inboxItems.length, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] ${transactions.length} candidate transactions (last 90 days)`)
|
||||
|
||||
// 3. Run matching for each item and collect results
|
||||
const pendingMatches: Array<{
|
||||
inboxItemId: string
|
||||
result: DocumentMatchResult
|
||||
}> = []
|
||||
|
||||
for (const item of inboxItems as InvoiceInboxItem[]) {
|
||||
const result = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
companyId,
|
||||
item,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
if (result) {
|
||||
pendingMatches.push({ inboxItemId: item.id, result })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Greedy assignment: sort by confidence desc, assign each transaction at most once
|
||||
pendingMatches.sort((a, b) => b.result.confidence - a.result.confidence)
|
||||
|
||||
const assignedTransactionIds = new Set<string>()
|
||||
const finalMatches: typeof pendingMatches = []
|
||||
|
||||
for (const match of pendingMatches) {
|
||||
if (assignedTransactionIds.has(match.result.transactionId)) {
|
||||
console.log(`[batch-match] Skipped item=${match.inboxItemId} → tx=${match.result.transactionId} (already assigned to higher-confidence match)`)
|
||||
continue // Transaction already assigned to a higher-confidence match
|
||||
}
|
||||
assignedTransactionIds.add(match.result.transactionId)
|
||||
finalMatches.push(match)
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Sweep complete: ${finalMatches.length}/${inboxItems.length} items matched, ${pendingMatches.length - finalMatches.length} skipped (greedy dedup)`)
|
||||
|
||||
// 5. Persist matches
|
||||
for (const { inboxItemId, result } of finalMatches) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: result.transactionId,
|
||||
match_confidence: result.confidence,
|
||||
match_method: result.method,
|
||||
})
|
||||
.eq('id', inboxItemId)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
return {
|
||||
matched: finalMatches.length,
|
||||
total: inboxItems.length,
|
||||
matches: finalMatches,
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* Document-to-Transaction Matcher
|
||||
*
|
||||
* Pure matching logic that works from extracted data already stored on inbox items.
|
||||
* Zero AI or extension dependencies — works entirely from structured data.
|
||||
*
|
||||
* Matching passes by document type:
|
||||
*
|
||||
* Supplier invoices:
|
||||
* 1. Payment reference exact match → 0.98
|
||||
* 2. Exact amount + bankgiro → 0.92
|
||||
* 3. Exact amount + date ±5 days → 0.85
|
||||
* 4. Fuzzy amount + supplier name → 0.70
|
||||
*
|
||||
* Receipts:
|
||||
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
import {
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
} from './core-receipt-matcher'
|
||||
|
||||
export type DocumentMatchMethod =
|
||||
| 'payment_reference'
|
||||
| 'amount_date'
|
||||
| 'amount_merchant'
|
||||
| 'receipt_match'
|
||||
|
||||
export interface DocumentMatchResult {
|
||||
transactionId: string
|
||||
confidence: number
|
||||
method: DocumentMatchMethod
|
||||
matchReasons: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a single inbox item to the best candidate transaction.
|
||||
*
|
||||
* If `candidateTransactions` is not provided, fetches unbooked expense
|
||||
* transactions within ±7 days of the document date.
|
||||
*/
|
||||
export async function matchDocumentToTransactions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
inboxItem: InvoiceInboxItem,
|
||||
candidateTransactions?: Transaction[]
|
||||
): Promise<DocumentMatchResult | null> {
|
||||
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
|
||||
|
||||
// Only match supplier invoices and receipts
|
||||
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
|
||||
console.log(`${tag} — skipped (unsupported document type)`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!inboxItem.extracted_data) {
|
||||
console.log(`${tag} — skipped (no extracted_data)`)
|
||||
return null
|
||||
}
|
||||
|
||||
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, companyId, inboxItem))
|
||||
|
||||
console.log(`${tag} — ${transactions.length} candidate transactions`)
|
||||
|
||||
if (transactions.length === 0) {
|
||||
console.log(`${tag} — no candidates, aborting`)
|
||||
return null
|
||||
}
|
||||
|
||||
let result: DocumentMatchResult | null = null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
result = matchSupplierInvoiceDocument(inboxItem, transactions)
|
||||
} else if (inboxItem.document_type === 'receipt') {
|
||||
result = matchReceiptDocument(inboxItem, transactions)
|
||||
}
|
||||
|
||||
if (result) {
|
||||
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
|
||||
} else {
|
||||
console.log(`${tag} — no match found`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch unbooked expense transactions within ±7 days of the document date.
|
||||
*/
|
||||
async function fetchCandidateTransactions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
inboxItem: InvoiceInboxItem
|
||||
): Promise<Transaction[]> {
|
||||
const docDate = getDocumentDate(inboxItem)
|
||||
if (!docDate) return []
|
||||
|
||||
const startDate = new Date(docDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(docDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error || !data) return []
|
||||
return data as Transaction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most relevant date from an inbox item's extracted data.
|
||||
*/
|
||||
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
|
||||
const data = inboxItem.extracted_data as Record<string, unknown> | null
|
||||
if (!data) return null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
const extraction = data as unknown as InvoiceExtractionResult
|
||||
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
|
||||
}
|
||||
|
||||
if (inboxItem.document_type === 'receipt') {
|
||||
const extraction = data as unknown as ReceiptExtractionResult
|
||||
return extraction.receipt?.date ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
|
||||
*/
|
||||
function matchSupplierInvoiceDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const invoiceTotal = extraction.totals?.total
|
||||
if (invoiceTotal == null || invoiceTotal === 0) {
|
||||
console.log(`${tag} — no invoice total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
const paymentRef = extraction.invoice?.paymentReference
|
||||
const bankgiro = extraction.supplier?.bankgiro
|
||||
const plusgiro = extraction.supplier?.plusgiro
|
||||
const supplierName = extraction.supplier?.name
|
||||
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
|
||||
|
||||
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDesc = (tx.description || '').toLowerCase()
|
||||
const txRef = tx.reference || ''
|
||||
|
||||
// Pass 1: Payment reference exact match → 0.98
|
||||
if (paymentRef && txRef) {
|
||||
const normTxRef = txRef.replace(/\D/g, '')
|
||||
const normPayRef = paymentRef.replace(/\D/g, '')
|
||||
if (normTxRef && normPayRef && normTxRef === normPayRef) {
|
||||
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.98,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Betalningsreferens matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
|
||||
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
|
||||
if (amountMatch) {
|
||||
const bgNorm = bankgiro?.replace(/\D/g, '')
|
||||
const pgNorm = plusgiro?.replace(/\D/g, '')
|
||||
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
|
||||
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
|
||||
|
||||
if (hasBgMatch || hasPgMatch) {
|
||||
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.92,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
|
||||
// Invoices are often paid early or a few days late, so we use a 14-day window.
|
||||
if (amountMatch && dueDate) {
|
||||
const txDate = new Date(tx.date)
|
||||
const docDate = new Date(dueDate)
|
||||
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 14) {
|
||||
// Higher confidence for close dates, lower for wider window
|
||||
const confidence = diffDays <= 5 ? 0.85 : 0.75
|
||||
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'amount_date',
|
||||
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
|
||||
if (fuzzyAmountMatch && supplierName) {
|
||||
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
|
||||
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
|
||||
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
|
||||
|
||||
if (nameInDesc) {
|
||||
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.70,
|
||||
method: 'amount_merchant',
|
||||
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a receipt inbox item to transactions using weighted scoring.
|
||||
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
|
||||
*/
|
||||
function matchReceiptDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const receiptTotal = extraction.totals?.total
|
||||
const receiptDate = extraction.receipt?.date
|
||||
const merchantName = extraction.merchant?.name
|
||||
|
||||
if (receiptTotal == null || receiptTotal === 0) {
|
||||
console.log(`${tag} — no receipt total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (tx.receipt_id) continue // Skip already matched
|
||||
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDate = new Date(tx.date)
|
||||
|
||||
// Calculate date variance
|
||||
const dateVariance = receiptDate
|
||||
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 3 // Default to tolerance boundary if no date
|
||||
|
||||
if (dateVariance > 3) continue
|
||||
|
||||
// Calculate amount variance
|
||||
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
|
||||
if (amountVariance > 0.05) continue // Skip if >5% off
|
||||
|
||||
// Calculate merchant similarity
|
||||
const txMerchant = tx.merchant_name || tx.description || ''
|
||||
const merchantSimilarity = merchantName
|
||||
? calculateMerchantSimilarity(merchantName, txMerchant)
|
||||
: 0
|
||||
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
merchantSimilarity
|
||||
)
|
||||
|
||||
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
|
||||
|
||||
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
|
||||
bestMatch = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'receipt_match',
|
||||
matchReasons,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
ReconciliationMethod,
|
||||
InvoiceInboxItem,
|
||||
SupplierInvoice,
|
||||
AIProposal,
|
||||
AIRequest,
|
||||
} from '@/types'
|
||||
|
||||
// ============================================================
|
||||
@@ -78,8 +76,6 @@ export type CoreEvent =
|
||||
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
|
||||
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
|
||||
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
||||
// Generic inbox classification (fires for all document_types after classify)
|
||||
| { type: 'inbox_item.classified'; payload: { inboxItem: InvoiceInboxItem; documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'; confidence: number | null; correlationId: string; userId: string; companyId: string } }
|
||||
// Salary
|
||||
| { type: 'salary_run.created'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
||||
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
|
||||
@@ -94,11 +90,6 @@ 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 13 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(13)
|
||||
it('should have 11 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(11)
|
||||
})
|
||||
|
||||
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(13)
|
||||
expect(extensions.length).toBe(11)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
@@ -8,4 +8,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'mcp-server',
|
||||
'cloud-backup',
|
||||
'skatteverket',
|
||||
'invoice-inbox',
|
||||
])
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ticExtension } from '@/extensions/general/tic'
|
||||
import { mcpServerExtension } from '@/extensions/general/mcp-server'
|
||||
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
|
||||
import { skatteverketExtension } from '@/extensions/general/skatteverket'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
enableBankingExtension,
|
||||
@@ -16,4 +17,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
mcpServerExtension,
|
||||
cloudBackupExtension,
|
||||
skatteverketExtension,
|
||||
invoiceInboxExtension,
|
||||
]
|
||||
|
||||
@@ -90,5 +90,20 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
|
||||
"longDescription": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från gnubok. Spara utkast, validera, lås och signera — utan att lämna appen."
|
||||
},
|
||||
{
|
||||
"slug": "invoice-inbox",
|
||||
"name": "Dokumentinkorg",
|
||||
"sector": "general",
|
||||
"category": "import",
|
||||
"icon": "Inbox",
|
||||
"dataPattern": "both",
|
||||
"description": "Vidarebefordra leverantörsfakturor till en unik adress – dokumenten landar här med extraherade fält",
|
||||
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning.",
|
||||
"readsCoreTables": [
|
||||
"document_attachments",
|
||||
"suppliers"
|
||||
],
|
||||
"hasOwnData": true
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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,8 +3,6 @@ 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')
|
||||
@@ -77,7 +75,6 @@ export function ensureInitialized(): void {
|
||||
setContextFactory(createExtensionContext)
|
||||
registerSupplierInvoiceHandler()
|
||||
registerEventLogHandler()
|
||||
if (isAgentInboxEnabled()) registerAIProposalHandler()
|
||||
loadExtensions()
|
||||
|
||||
initialized = true
|
||||
|
||||
@@ -404,8 +404,6 @@ 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 })
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { isAutoBookEnabled } from '@/lib/ai/feature-flag'
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
@@ -119,28 +118,6 @@ 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 supplier
|
||||
// invoices and exchange rates — they are not used.
|
||||
let unpaidSupplierInvoices: SupplierInvoice[] = []
|
||||
@@ -363,12 +340,11 @@ export async function ingestTransactions(
|
||||
}
|
||||
|
||||
// 4. Evaluate mapping rules for auto-categorization
|
||||
// Production-disabled: auto-booking only runs in local dev (isAutoBookEnabled).
|
||||
// Production-disabled: auto-booking only runs in local dev (and tests).
|
||||
// Users must explicitly book each transaction on the deployed app.
|
||||
// Also skipped when the company has opted into the AI agent flow (proposals)
|
||||
// and when SIE-imported entries overlap the sync range (prevents double-book).
|
||||
// Reconciliation (step 2.5) still links transactions to existing GL lines.
|
||||
if (isAutoBookEnabled() && !options?.skipAutoCategorization && !(await isAiFlowEnabled())) {
|
||||
const autoBookEnabled = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
||||
if (autoBookEnabled && !options?.skipAutoCategorization) {
|
||||
try {
|
||||
const mappingResult = await evaluateMappingRules(
|
||||
supabase,
|
||||
|
||||
Reference in New Issue
Block a user