Files
accounted/lib/ai/proposals/__tests__/re-validate.test.ts
T
MattssonandClaude Opus 4.7 1af977950b Ai/full autonomous flow (#359)
* Refactor bookkeeping error handling and introduce new error classes

- Introduced new error classes for better error categorization:
  - JournalEntryNotBalancedError
  - FiscalPeriodNotFoundError
  - EntryDateOutsideFiscalPeriodError
  - JournalEntryNotFoundError
  - CannotReverseNonPostedError
  - CannotCorrectNonPostedError
  - EntryAlreadyReversedError
  - CurrencyRevaluationAlreadyExistsError
  - InvalidMappingResultError
  - BookkeepingDatabaseError

- Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones.
- Enhanced error response handling in get-error-message.ts to provide localized messages for new error types.
- Added unit tests for new error classes and error handling functions to ensure correctness and coverage.

* feat(ai): implement AI proposal application and persistence

- Add apply.ts to handle the application of AI proposals, including match and booking steps.
- Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints.
- Create re-validate.ts for validating proposals before acceptance, checking for stale conditions.
- Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes.
- Enhance journal_entries with AI provenance tracking, linking entries to AI proposals.
- Update categorization_templates to distinguish AI-corrected templates.
- Add company settings for toggling AI flow and managing backfill processes.
- Extend processing_history to include AI-related events for better tracking.

* feat: add uncategorized transactions API and UI for transaction selection

- Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options.
- Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals.
- Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality.
- Added TransactionDetailDialog for viewing transaction details with links to the transaction list.
- Introduced receipt quality assessment logic to evaluate extracted receipt data.
- Implemented feature flagging for the AI bookkeeping agent to control availability in different environments.

* feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis

- Added ManualExtractDialog component for user input when AI fails to extract receipt data.
- Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities.
- Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date.
- Updated package.json to include @aws-sdk/client-textract dependency.

* fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard

Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel
VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering
carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate
labels for grocery-chain merchants relative to the entry date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:32:15 +02:00

423 lines
16 KiB
TypeScript

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'])
}
})
})