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:
Mattsson
2026-05-05 09:53:37 +02:00
committed by GitHub
parent f3fd4c0822
commit fa7d4075cf
108 changed files with 1491 additions and 13234 deletions
-38
View File
@@ -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 })
}
-385
View File
@@ -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 }
-123
View File
@@ -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()
}
-296
View File
@@ -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'])
}
})
})
-188
View File
@@ -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,
}
}
-264
View File
@@ -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')
}
-346
View File
@@ -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
}