feat: event log, pending operations, and MCP staging (#135)

* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

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

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

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

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-26 10:07:36 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4d2152946a
commit f3ae3cd361
23 changed files with 2744 additions and 465 deletions
@@ -226,15 +226,15 @@ describe('MCP Receipt Matcher', () => {
// ── gnubok_categorize_transaction still works after refactor ──
describe('gnubok_categorize_transaction (refactored)', () => {
it('returns result without transaction field', async () => {
describe('gnubok_categorize_transaction (staging)', () => {
it('always stages the operation directly', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
enqueueMany([
{ data: tx, error: null }, // fetch transaction
{ data: tx, error: null }, // fetch transaction (preview)
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
{ data: null, error: null }, // fiscal_periods upsert
{ data: null, error: null }, // transaction update
{ data: tx, error: null }, // fetch transaction for title
{ data: { id: 'op-1' }, error: null }, // insert into pending_operations
])
const res = await handleMcpRequest(
@@ -246,10 +246,11 @@ describe('MCP Receipt Matcher', () => {
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.success).toBe(true)
expect(parsed.journal_entry_created).toBe(true)
expect(parsed.category).toBe('expense_office')
expect(parsed.transaction).toBeUndefined()
expect(parsed.staged).toBe(true)
expect(parsed.operation_id).toBe('op-1')
expect(parsed.message).toContain('staged')
expect(parsed.preview).toBeDefined()
expect(parsed.preview.debit_account).toBeDefined()
})
})
@@ -273,8 +274,8 @@ describe('MCP Receipt Matcher', () => {
enqueueMany([
{ data: tx, error: null },
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
{ data: null, error: null },
{ data: null, error: null },
{ data: tx, error: null }, // fetch transaction for title
{ data: { id: 'op-1' }, error: null }, // insert into pending_operations
])
const res = await handleMcpRequest(
+193 -446
View File
@@ -95,6 +95,38 @@ const VALID_VAT_TREATMENTS = [
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
] as const
// ── Pending operations staging ───────────────────────────────
async function stagePendingOperation(
supabase: SupabaseClient,
userId: string,
operationType: string,
title: string,
params: Record<string, unknown>,
previewData: Record<string, unknown>
): Promise<{ staged: true; operation_id: string; message: string; preview: Record<string, unknown> }> {
const { data, error } = await supabase
.from('pending_operations')
.insert({
user_id: userId,
operation_type: operationType,
title,
params,
preview_data: previewData,
})
.select('id')
.single()
if (error) throw new Error(`Failed to stage operation: ${error.message}`)
return {
staged: true,
operation_id: data.id,
message: 'Operation staged for review. Open the gnubok web app to approve or reject it.',
preview: previewData,
}
}
// ── Shared categorization logic ──────────────────────────────
async function categorizeTransactionCore(
@@ -102,18 +134,22 @@ async function categorizeTransactionCore(
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
userId: string,
supabase: SupabaseClient
supabase: SupabaseClient,
confirm: boolean = false
): Promise<{
success: boolean
journal_entry_created: boolean
journal_entry_id: string | null
journal_entry_error: string | null
preview?: boolean
success?: boolean
journal_entry_created?: boolean
journal_entry_id?: string | null
journal_entry_error?: string | null
category: string
debit_account: string
credit_account: string
amount: number
currency: string
transaction: Transaction
vat_lines?: Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>
message?: string
transaction?: Transaction
}> {
// Validate category
if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) {
@@ -182,6 +218,25 @@ async function categorizeTransactionCore(
)
}
// Preview mode: return what would happen without executing
if (!confirm) {
return {
preview: true,
category,
debit_account: mappingResult.debit_account,
credit_account: mappingResult.credit_account,
amount: Math.abs(transaction.amount),
currency: transaction.currency,
vat_lines: mappingResult.vat_lines.map(v => ({
account_number: v.account_number,
debit_amount: v.debit_amount,
credit_amount: v.credit_amount,
description: v.description,
})),
message: 'Preview only — no changes made. Call again with confirm: true to create the journal entry.',
}
}
// Ensure fiscal period exists
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
const txDate = new Date(transaction.date)
@@ -357,16 +412,16 @@ const tools: McpTool[] = [
{
name: 'gnubok_categorize_transaction',
description:
'Categorize a bank transaction and create the corresponding double-entry journal entry. ' +
'This books the transaction in the accounting ledger using Swedish BAS accounts.\n\n' +
'Categorize a bank transaction and stage the journal entry for user approval.\n\n' +
'This tool stages the operation — the user reviews and approves it in the gnubok web app. ' +
'The journal entry is NOT created until the user approves.\n\n' +
'Args:\n' +
' - transaction_id (string, required): UUID of the transaction from gnubok_list_uncategorized_transactions\n' +
' - category (string, required): One of: ' + VALID_CATEGORIES.join(', ') + '\n' +
' - vat_treatment (string, optional): One of: ' + VALID_VAT_TREATMENTS.join(', ') + '. ' +
'Defaults to standard_25 for business expenses.\n\n' +
'Returns JSON:\n' +
' { success: boolean, journal_entry_created: boolean, journal_entry_id?: string,\n' +
' category: string, debit_account: string, credit_account: string }\n\n' +
' { staged: true, operation_id, message, preview: { debit_account, credit_account, amount, vat_lines } }\n\n' +
'Examples:\n' +
' - "Book that as office supplies, 25% VAT" → category="expense_office"\n' +
' - "Mark as private" → category="private" (no journal entry created for private)\n' +
@@ -402,16 +457,51 @@ const tools: McpTool[] = [
openWorldHint: false,
},
async execute(args, userId, supabase) {
// Compute the preview (accounts, amounts, VAT lines)
const result = await categorizeTransactionCore(
args.transaction_id as string,
args.category as TransactionCategory,
args.vat_treatment as VatTreatment | undefined,
userId,
supabase
supabase,
false // preview mode — execution happens via web UI commit
)
// If already has a journal entry, pass through as-is
if (result.success && result.journal_entry_created === false) {
const { transaction: _tx, ...publicResult } = result
return publicResult
}
// Fetch transaction description for the title
const { data: tx } = await supabase
.from('transactions')
.select('description, merchant_name, amount, currency')
.eq('id', args.transaction_id as string)
.eq('user_id', userId)
.single()
const txDesc = tx
? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}`
: String(args.transaction_id)
// Stage for user approval
return stagePendingOperation(supabase, userId, 'categorize_transaction',
`Kategorisera: ${txDesc}`,
{
transaction_id: args.transaction_id,
category: args.category,
vat_treatment: args.vat_treatment || null,
},
{
debit_account: result.debit_account,
credit_account: result.credit_account,
amount: result.amount,
currency: result.currency,
vat_lines: result.vat_lines || [],
category: result.category,
}
)
// Strip internal transaction field from public response
const { transaction: _tx, ...publicResult } = result
return publicResult
},
},
@@ -501,7 +591,9 @@ const tools: McpTool[] = [
{
name: 'gnubok_create_customer',
description:
'Create a new customer. Required for invoice creation.\n\n' +
'Stage a new customer for user approval. Required before creating invoices.\n\n' +
'The customer is NOT created immediately — it is staged for the user to review ' +
'and approve in the gnubok web app.\n\n' +
'Args:\n' +
' - name (string, required): Customer/company name\n' +
' - customer_type (string, required): individual, swedish_business, eu_business, non_eu_business\n' +
@@ -513,7 +605,7 @@ const tools: McpTool[] = [
' - postal_code (string, optional)\n' +
' - city (string, optional)\n' +
' - country (string, optional): Defaults to Sweden\n\n' +
'Returns JSON: the created customer object with id.\n\n' +
'Returns JSON: { staged: true, operation_id, message, preview }\n\n' +
'Examples:\n' +
' - "Add Acme AB" → name="Acme AB", customer_type="swedish_business"\n' +
' - "Add a German client" → customer_type="eu_business", country="Germany"',
@@ -552,27 +644,24 @@ const tools: McpTool[] = [
throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business')
}
const { data, error } = await supabase
.from('customers')
.insert({
user_id: userId,
name: name.trim(),
customer_type: customerType,
email: (args.email as string) || null,
org_number: (args.org_number as string) || null,
vat_number: (args.vat_number as string) || null,
default_payment_terms: Number(args.payment_terms) || 30,
address_line1: (args.address as string) || null,
postal_code: (args.postal_code as string) || null,
city: (args.city as string) || null,
country: (args.country as string) || 'Sweden',
})
.select()
.single()
const params = {
name: name.trim(),
customer_type: customerType,
email: (args.email as string) || null,
org_number: (args.org_number as string) || null,
vat_number: (args.vat_number as string) || null,
payment_terms: Number(args.payment_terms) || 30,
address: (args.address as string) || null,
postal_code: (args.postal_code as string) || null,
city: (args.city as string) || null,
country: (args.country as string) || 'Sweden',
}
if (error) throw new Error(`Failed to create customer: ${error.message}`)
return { customer: data }
return stagePendingOperation(supabase, userId, 'create_customer',
`Ny kund: ${params.name}`,
params,
params // params ARE the preview for customers
)
},
},
@@ -650,7 +739,9 @@ const tools: McpTool[] = [
{
name: 'gnubok_create_invoice',
description:
'Create a new invoice for a customer. Automatically calculates VAT based on customer type.\n\n' +
'Stage a new invoice for user approval. Validates inputs and calculates VAT preview.\n\n' +
'The invoice is NOT created immediately — it is staged for the user to review ' +
'and approve in the gnubok web app. The invoice number is assigned at approval time.\n\n' +
'Args:\n' +
' - customer_id (string, required): UUID from gnubok_list_customers\n' +
' - items (array, required): Line items, each with:\n' +
@@ -665,7 +756,7 @@ const tools: McpTool[] = [
' - our_reference (string, optional)\n' +
' - your_reference (string, optional)\n' +
' - notes (string, optional): Notes printed on invoice\n\n' +
'Returns JSON: the created invoice with id, invoice_number, total, vat_amount.\n\n' +
'Returns JSON: { staged: true, operation_id, message, preview }\n\n' +
'Examples:\n' +
' - "Invoice Acme for 15000 kr consulting" → items=[{description:"Konsulttjänster",quantity:1,unit:"st",unit_price:15000}]\n' +
' - "Invoice 10 hours at 1500/h" → items=[{description:"Konsulttjänster",quantity:10,unit:"tim",unit_price:1500}]',
@@ -760,27 +851,6 @@ const tools: McpTool[] = [
}
const total = subtotal + vatAmount
// Mixed-rate detection
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
// Currency exchange (Riksbanken)
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (currency !== 'SEK') {
const rateData = await fetchExchangeRate(currency)
if (rateData) {
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
}
// Due date from payment terms if not provided
let dueDate = args.due_date as string | undefined
if (!dueDate) {
@@ -789,94 +859,27 @@ const tools: McpTool[] = [
dueDate = d.toISOString().split('T')[0]
}
// Generate invoice number via DB RPC (sequential, same as web UI)
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: userId,
})
const invoiceNumber = baseNumber as string
// Create invoice
const { data: invoice, error: insertError } = await supabase
.from('invoices')
.insert({
user_id: userId,
// Stage for user approval instead of creating directly
return stagePendingOperation(supabase, userId, 'create_invoice',
`Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`,
{
customer_id: customerId,
invoice_number: invoiceNumber,
items,
invoice_date: invoiceDate,
due_date: dueDate,
status: 'draft',
currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
vat_treatment: vatRules.treatment,
vat_rate: uniqueRates.size > 1 ? null : (uniqueRates.values().next().value ?? vatRules.rate),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
document_type: 'invoice',
our_reference: (args.our_reference as string) || null,
your_reference: (args.your_reference as string) || null,
notes: (args.notes as string) || null,
})
.select()
.single()
if (insertError || !invoice) {
throw new Error(`Failed to create invoice: ${insertError?.message || 'Unknown error'}`)
}
// Insert items
const invoiceItems = items.map((item, idx) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
sort_order: idx,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
}
})
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(invoiceItems)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
throw new Error(`Failed to create invoice items: ${itemsError.message}`)
}
// Emit event (triggers journal entry creation via event handler)
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId },
})
}
return {
invoice: {
id: invoice.id,
invoice_number: invoiceNumber,
status: 'draft',
},
{
customer_name: customer.name,
customer_type: customer.customer_type,
items: items.map(item => ({
...item,
line_total: item.quantity * item.unit_price,
vat_rate: item.vat_rate ?? vatRules.rate,
})),
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round(total * 100) / 100,
@@ -884,11 +887,8 @@ const tools: McpTool[] = [
vat_treatment: vatRules.treatment,
invoice_date: invoiceDate,
due_date: dueDate,
item_count: invoiceItems.length,
...(exchangeRate ? { exchange_rate: exchangeRate, total_sek: totalSek } : {}),
},
note: 'Invoice created as draft. Use the web UI to send it.',
}
}
)
},
},
@@ -1353,7 +1353,7 @@ const tools: McpTool[] = [
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
@@ -1363,49 +1363,19 @@ const tools: McpTool[] = [
throw new Error('Invoice can only be marked as paid when status is "sent" or "overdue"')
}
const now = new Date().toISOString()
const paymentDate = (args.payment_date as string) || now.split('T')[0]
const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0]
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (isRealInvoice) {
if (accountingMethod === 'accrual') {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
return stagePendingOperation(supabase, userId, 'mark_invoice_paid',
`Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`,
{ invoice_id: invoiceId, payment_date: paymentDate },
{
invoice_number: invoice.invoice_number,
customer_name: invoice.customer?.name,
total: invoice.total,
currency: invoice.currency,
payment_date: paymentDate,
}
}
const { error: updateError } = await supabase
.from('invoices')
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
.eq('id', invoiceId)
.eq('user_id', userId)
if (updateError) throw new Error('Failed to update invoice status')
return {
success: true,
status: 'paid',
paid_at: now,
paid_amount: invoice.total,
journal_entry_id: journalEntryId,
}
)
},
},
@@ -1450,7 +1420,7 @@ const tools: McpTool[] = [
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
@@ -1460,112 +1430,17 @@ const tools: McpTool[] = [
const customer = invoice.customer as Customer
if (!customer.email) throw new Error('Customer has no email address. Update customer details first.')
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', userId)
.single()
if (companyError || !company) throw new Error('Company settings missing')
const items = (invoice.items as InvoiceItem[]).sort(
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
)
// If credit note, fetch original invoice number
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: orig } = await supabase
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.single()
if (orig) originalInvoiceNumber = orig.invoice_number
}
// Generate PDF
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
})
)
// Determine filename
const isCreditNote = !!invoice.credited_invoice_id
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) filename = `kreditfaktura-${invoice.invoice_number}.pdf`
else if (docType === 'proforma') filename = `proformafaktura-${invoice.invoice_number}.pdf`
else if (docType === 'delivery_note') filename = `foljesedel-${invoice.invoice_number}.pdf`
else filename = `faktura-${invoice.invoice_number}.pdf`
// Get user email for CC
const { data: { user: authUser } } = await supabase.auth.admin.getUserById(userId)
const ccAddress = company.email || authUser?.email
// Send email
const emailData = { invoice: invoice as Invoice, customer, company: company as CompanySettings }
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
})
if (!result.success) throw new Error(`Failed to send email: ${result.error}`)
// Update status to sent
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId)
// Create journal entry (non-blocking)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let createdJournalEntryId: string | undefined
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice, (company as CompanySettings).entity_type
)
if (je) {
createdJournalEntryId = je.id
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
}
} catch {
// Non-blocking
return stagePendingOperation(supabase, userId, 'send_invoice',
`Skicka: ${invoice.invoice_number} till ${customer.email}`,
{ invoice_id: invoiceId },
{
invoice_number: invoice.invoice_number,
customer_name: customer.name,
customer_email: customer.email,
total: invoice.total,
currency: invoice.currency,
}
}
// Store PDF as document (non-blocking)
if (isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, userId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
}, {
upload_source: 'system',
journal_entry_id: createdJournalEntryId,
})
} catch {
// Non-blocking
}
}
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId } })
return {
success: true,
message: `Invoice ${invoice.invoice_number} sent to ${customer.email}`,
messageId: result.messageId,
}
)
},
},
@@ -1601,7 +1476,7 @@ const tools: McpTool[] = [
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
@@ -1609,40 +1484,16 @@ const tools: McpTool[] = [
if (invoiceError || !invoice) throw new Error('Invoice not found')
if (invoice.status !== 'draft') throw new Error('Only draft invoices can be marked as sent')
const { error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', invoiceId)
.eq('user_id', userId)
if (updateError) throw new Error('Failed to update invoice status')
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.single()
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
invoice.customer?.name
)
if (je) {
journalEntryId = je.id
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
}
} catch {
// Non-blocking
return stagePendingOperation(supabase, userId, 'mark_invoice_sent',
`Markera skickad: ${invoice.invoice_number} ${invoice.customer?.name || ''}`,
{ invoice_id: invoiceId },
{
invoice_number: invoice.invoice_number,
customer_name: invoice.customer?.name,
total: invoice.total,
currency: invoice.currency,
}
}
return { success: true, status: 'sent', journal_entry_id: journalEntryId }
)
},
},
@@ -2126,10 +1977,10 @@ const tools: McpTool[] = [
const invoiceId = args.invoice_id as string
if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required')
// Fetch transaction
// Validate both exist and are matchable
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('*')
.select('id, description, merchant_name, amount, currency, invoice_id')
.eq('id', transactionId)
.eq('user_id', userId)
.single()
@@ -2138,10 +1989,9 @@ const tools: McpTool[] = [
if (transaction.amount <= 0) throw new Error('Only income transactions (amount > 0) can be matched to invoices')
if (transaction.invoice_id) throw new Error('Transaction is already linked to an invoice')
// Fetch invoice
const { data: invoice, error: invError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
@@ -2151,124 +2001,21 @@ const tools: McpTool[] = [
throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)')
}
// Storno conflicting journal entry if exists
if (transaction.journal_entry_id) {
await reverseEntry(supabase, userId, transaction.journal_entry_id)
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
}
const txDesc = transaction.merchant_name || transaction.description || transactionId
const now = new Date().toISOString()
const paidAmount = transaction.amount
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Create journal entry (method-aware)
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
)
journalEntryId = je?.id ?? null
return stagePendingOperation(supabase, userId, 'match_transaction_invoice',
`Matcha: ${txDesc} → ${invoice.invoice_number}`,
{ transaction_id: transactionId, invoice_id: invoiceId },
{
transaction_description: txDesc,
transaction_amount: transaction.amount,
transaction_currency: transaction.currency,
invoice_number: invoice.invoice_number,
invoice_total: invoice.total,
invoice_currency: invoice.currency,
customer_name: (invoice.customer as Record<string, unknown>)?.name as string,
}
} catch (err) {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// Optimistic lock update on invoice
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
.eq('id', invoiceId)
.in('status', ['sent', 'overdue', 'partially_paid'])
.select('id')
if (updateInvError) throw new Error('Failed to update invoice status')
if (!updatedRows || updatedRows.length === 0) {
throw new Error('Invoice has already been fully paid or is no longer matchable')
}
// Record payment
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
const { error: paymentError } = await supabase
.from('invoice_payments')
.insert({
user_id: userId,
invoice_id: invoiceId,
payment_date: transaction.date,
amount: paidAmount,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: paymentNotes,
})
if (paymentError) {
if (paymentError.code === '23505') throw new Error('This transaction is already matched to this invoice')
throw new Error('Failed to record invoice payment')
}
// Update transaction
const { error: updateTxError } = await supabase
.from('transactions')
.update({
invoice_id: invoiceId,
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services',
})
.eq('id', transactionId)
if (updateTxError) throw new Error('Failed to link transaction to invoice')
try {
eventBus.emit({
type: 'invoice.match_confirmed',
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId },
})
} catch {
// Non-critical
}
return {
success: true,
invoice_status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
}
)
},
},