* Refactor bookkeeping error handling and introduce new error classes - Introduced new error classes for better error categorization: - JournalEntryNotBalancedError - FiscalPeriodNotFoundError - EntryDateOutsideFiscalPeriodError - JournalEntryNotFoundError - CannotReverseNonPostedError - CannotCorrectNonPostedError - EntryAlreadyReversedError - CurrencyRevaluationAlreadyExistsError - InvalidMappingResultError - BookkeepingDatabaseError - Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones. - Enhanced error response handling in get-error-message.ts to provide localized messages for new error types. - Added unit tests for new error classes and error handling functions to ensure correctness and coverage. * feat(ai): implement AI proposal application and persistence - Add apply.ts to handle the application of AI proposals, including match and booking steps. - Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints. - Create re-validate.ts for validating proposals before acceptance, checking for stale conditions. - Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes. - Enhance journal_entries with AI provenance tracking, linking entries to AI proposals. - Update categorization_templates to distinguish AI-corrected templates. - Add company settings for toggling AI flow and managing backfill processes. - Extend processing_history to include AI-related events for better tracking. * feat: add uncategorized transactions API and UI for transaction selection - Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options. - Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals. - Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality. - Added TransactionDetailDialog for viewing transaction details with links to the transaction list. - Introduced receipt quality assessment logic to evaluate extracted receipt data. - Implemented feature flagging for the AI bookkeeping agent to control availability in different environments. * feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis - Added ManualExtractDialog component for user input when AI fails to extract receipt data. - Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities. - Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date. - Updated package.json to include @aws-sdk/client-textract dependency. * fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate labels for grocery-chain merchants relative to the entry date. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
297 lines
8.8 KiB
TypeScript
297 lines
8.8 KiB
TypeScript
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',
|
|
})
|
|
})
|
|
})
|