Files
accounted/extensions/general/ai-chat/chatbot/tools.ts
T

586 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { tool } from '@langchain/core/tools'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Extract name from a Supabase join result (could be object or array).
*/
function extractName(joined: unknown): string | null {
if (!joined) return null
if (Array.isArray(joined)) {
return joined[0]?.name ?? null
}
if (typeof joined === 'object' && 'name' in joined) {
return (joined as { name: string }).name
}
return null
}
/**
* Resolve the current fiscal period for a user. Falls back to latest period.
*/
async function resolveCurrentPeriod(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId?: string
): Promise<{ id: string; start: string; end: string } | null> {
if (fiscalPeriodId) {
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
}
// Default: latest open period, or just the latest period
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end, is_closed')
.eq('user_id', userId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
return null
}
/**
* Create all 10 accounting tools bound to a specific Supabase client and user.
*/
export function createAccountingTools(supabase: SupabaseClient, userId: string) {
const getInvoices = tool(
async ({ status, customer_name, date_from, date_to, limit }) => {
let query = supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, paid_amount, currency, vat_amount, customer:customers(name)')
.eq('user_id', userId)
.order('invoice_date', { ascending: false })
.limit(limit)
if (status) query = query.eq('status', status)
if (customer_name) query = query.ilike('customers.name', `%${customer_name}%`)
if (date_from) query = query.gte('invoice_date', date_from)
if (date_to) query = query.lte('invoice_date', date_to)
const { data, error, count } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
const { data: invoices, error: fetchError } = await query
if (fetchError) return `Fel vid hämtning av fakturor: ${fetchError.message}`
if (!invoices || invoices.length === 0) return 'Inga fakturor hittades.'
const result = invoices.map((inv) => ({
invoice_number: inv.invoice_number,
date: inv.invoice_date,
due_date: inv.due_date,
status: inv.status,
total: inv.total,
paid: inv.paid_amount || 0,
currency: inv.currency || 'SEK',
vat: inv.vat_amount || 0,
customer: extractName(inv.customer) || 'Okänd',
}))
const summary: Record<string, unknown> = { invoices: result }
if (count && count > limit) {
summary.note = `Visar ${result.length} av totalt ${count} fakturor.`
}
return JSON.stringify(summary)
},
{
name: 'get_invoices',
description: 'Hämtar användarens försäljningsfakturor (kundfakturor). Kan filtrera på status, kundnamn och datumintervall.',
schema: z.object({
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på fakturastatus'),
customer_name: z.string().optional().describe('Sök på kundnamn (delmatchning)'),
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
limit: z.number().max(20).default(10).describe('Max antal fakturor att returnera'),
}),
}
)
const getSupplierInvoices = tool(
async ({ status, supplier_name, overdue_only, limit }) => {
let query = supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, remaining_amount, currency, vat_amount, supplier:suppliers(name)')
.eq('user_id', userId)
.order('invoice_date', { ascending: false })
.limit(limit)
if (status) query = query.eq('status', status)
if (overdue_only) query = query.eq('status', 'overdue')
if (supplier_name) query = query.ilike('suppliers.name', `%${supplier_name}%`)
const { data: invoices, error } = await query
if (error) return `Fel vid hämtning av leverantörsfakturor: ${error.message}`
if (!invoices || invoices.length === 0) return 'Inga leverantörsfakturor hittades.'
const result = invoices.map((inv) => ({
number: inv.supplier_invoice_number,
date: inv.invoice_date,
due_date: inv.due_date,
status: inv.status,
total: inv.total,
remaining: inv.remaining_amount || 0,
currency: inv.currency || 'SEK',
vat: inv.vat_amount || 0,
supplier: extractName(inv.supplier) || 'Okänd',
}))
return JSON.stringify({ supplier_invoices: result })
},
{
name: 'get_supplier_invoices',
description: 'Hämtar användarens leverantörsfakturor (inköpsfakturor). Kan filtrera på status, leverantörsnamn och förfallodag.',
schema: z.object({
status: z.enum(['registered', 'approved', 'partially_paid', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på status'),
supplier_name: z.string().optional().describe('Sök på leverantörsnamn (delmatchning)'),
overdue_only: z.boolean().optional().describe('Visa bara förfallna fakturor'),
limit: z.number().max(20).default(10).describe('Max antal fakturor'),
}),
}
)
const getAccountBalances = tool(
async ({ account_numbers, account_class, fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateTrialBalance } = await import('@/lib/reports/trial-balance')
const { rows } = await generateTrialBalance(supabase, userId, period.id)
let filtered = rows
if (account_numbers && account_numbers.length > 0) {
filtered = rows.filter((r) => account_numbers.includes(r.account_number))
} else if (account_class) {
filtered = rows.filter((r) => r.account_class === account_class)
}
if (filtered.length === 0) return 'Inga konton med saldo hittades.'
const result = filtered.map((r) => ({
account: r.account_number,
name: r.account_name,
debit: r.closing_debit,
credit: r.closing_credit,
balance: r.closing_debit - r.closing_credit,
}))
return JSON.stringify({
period: `${period.start} ${period.end}`,
accounts: result,
total_debit: Math.round(result.reduce((s, r) => s + r.debit, 0) * 100) / 100,
total_credit: Math.round(result.reduce((s, r) => s + r.credit, 0) * 100) / 100,
})
},
{
name: 'get_account_balances',
description: 'Hämtar saldon för BAS-konton. Kan filtrera på kontonummer eller kontoklass (1=tillgångar, 2=skulder, 3=intäkter, 4-7=kostnader, 8=finansiella).',
schema: z.object({
account_numbers: z.array(z.string()).optional().describe('Specifika kontonummer att hämta'),
account_class: z.number().min(1).max(8).optional().describe('Kontoklass 1-8'),
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getTransactions = tool(
async ({ uncategorized_only, description, date_from, date_to, limit }) => {
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, category, is_business, merchant_name, journal_entry_id')
.eq('user_id', userId)
.order('date', { ascending: false })
.limit(limit)
if (uncategorized_only) query = query.is('journal_entry_id', null)
if (description) query = query.ilike('description', `%${description}%`)
if (date_from) query = query.gte('date', date_from)
if (date_to) query = query.lte('date', date_to)
const { data: transactions, error } = await query
if (error) return `Fel vid hämtning av transaktioner: ${error.message}`
if (!transactions || transactions.length === 0) return 'Inga transaktioner hittades.'
const result = transactions.map((tx) => ({
date: tx.date,
description: tx.description,
amount: tx.amount,
currency: tx.currency || 'SEK',
category: tx.category,
is_business: tx.is_business,
merchant: tx.merchant_name,
booked: !!tx.journal_entry_id,
}))
return JSON.stringify({ transactions: result })
},
{
name: 'get_transactions',
description: 'Hämtar användarens banktransaktioner. Kan filtrera på obokförda, beskrivning (textsökning) och datumintervall.',
schema: z.object({
uncategorized_only: z.boolean().optional().describe('Visa bara obokförda transaktioner'),
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
limit: z.number().max(20).default(10).describe('Max antal transaktioner'),
}),
}
)
const getJournalEntries = tool(
async ({ limit, fiscal_period_id, account_number, description }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
let query = supabase
.from('journal_entries')
.select('id, voucher_number, entry_date, description, status, source_type')
.eq('user_id', userId)
.eq('status', 'posted')
.order('voucher_number', { ascending: false })
.limit(limit)
if (period) query = query.eq('fiscal_period_id', period.id)
if (description) query = query.ilike('description', `%${description}%`)
const { data: entries, error } = await query
if (error) return `Fel vid hämtning av verifikationer: ${error.message}`
if (!entries || entries.length === 0) return 'Inga verifikationer hittades.'
// Fetch lines for these entries
const entryIds = entries.map((e) => e.id)
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount, credit_amount, line_description')
.in('journal_entry_id', entryIds)
// If filtering by account, only include entries with matching lines
let filteredEntries = entries
if (account_number && lines) {
const matchingEntryIds = new Set(
lines.filter((l) => l.account_number === account_number).map((l) => l.journal_entry_id)
)
filteredEntries = entries.filter((e) => matchingEntryIds.has(e.id))
}
const linesByEntry = new Map<string, typeof lines>()
for (const line of lines || []) {
const group = linesByEntry.get(line.journal_entry_id) || []
group.push(line)
linesByEntry.set(line.journal_entry_id, group)
}
const result = filteredEntries.map((e) => ({
voucher: e.voucher_number,
date: e.entry_date,
description: e.description,
source: e.source_type,
lines: (linesByEntry.get(e.id) || []).map((l) => ({
account: l.account_number,
debit: l.debit_amount,
credit: l.credit_amount,
text: l.line_description,
})),
}))
return JSON.stringify({ journal_entries: result })
},
{
name: 'get_journal_entries',
description: 'Hämtar bokförda verifikationer med konteringsrader. Kan filtrera på kontonummer, beskrivning och räkenskapsperiod.',
schema: z.object({
limit: z.number().max(20).default(10).describe('Max antal verifikationer'),
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID'),
account_number: z.string().optional().describe('Filtrera på kontonummer i rader'),
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
}),
}
)
const getIncomeStatement = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
const report = await generateIncomeStatement(supabase, userId, period.id)
const sections = [
...report.revenue_sections.map((s) => ({
category: 'Intäkter',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.expense_sections.map((s) => ({
category: 'Kostnader',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.financial_sections.map((s) => ({
category: 'Finansiella poster',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
]
return JSON.stringify({
period: `${period.start} ${period.end}`,
total_revenue: report.total_revenue,
total_expenses: report.total_expenses,
total_financial: report.total_financial,
net_result: report.net_result,
sections,
})
},
{
name: 'get_income_statement',
description: 'Hämtar resultaträkning med intäkter, kostnader och årets resultat. Visar alla kontona grupperade i sektioner.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getBalanceSheet = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateBalanceSheet } = await import('@/lib/reports/balance-sheet')
const report = await generateBalanceSheet(supabase, userId, period.id)
const sections = [
...report.asset_sections.map((s) => ({
category: 'Tillgångar',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.equity_liability_sections.map((s) => ({
category: 'Eget kapital & skulder',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
]
return JSON.stringify({
period: `${period.start} ${period.end}`,
total_assets: report.total_assets,
total_equity_liabilities: report.total_equity_liabilities,
balanced: Math.abs(report.total_assets - report.total_equity_liabilities) < 0.01,
sections,
})
},
{
name: 'get_balance_sheet',
description: 'Hämtar balansräkning med tillgångar, eget kapital och skulder.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getVatSummary = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
// Get company settings for moms period type
const { data: settings } = await supabase
.from('company_settings')
.select('moms_period')
.eq('user_id', userId)
.single()
const periodType = settings?.moms_period || 'quarterly'
const startDate = new Date(period.start)
const year = startDate.getFullYear()
let periodNum = 1
if (periodType === 'monthly') {
periodNum = startDate.getMonth() + 1
} else if (periodType === 'quarterly') {
periodNum = Math.ceil((startDate.getMonth() + 1) / 3)
}
const { calculateVatDeclaration, getVatDeclarationSummary } = await import('@/lib/reports/vat-declaration')
const declaration = await calculateVatDeclaration(supabase, userId, periodType, year, periodNum)
const summary = getVatDeclarationSummary(declaration)
return JSON.stringify({
period: `${period.start} ${period.end}`,
output_vat_25: declaration.rutor.ruta05,
output_vat_12: declaration.rutor.ruta06,
output_vat_6: declaration.rutor.ruta07,
total_output_vat: summary.totalOutputVat,
input_vat: summary.totalInputVat,
vat_to_pay: summary.vatToPay,
is_refund: summary.isRefund,
revenue_basis_25: declaration.rutor.ruta10,
revenue_basis_12: declaration.rutor.ruta11,
revenue_basis_6: declaration.rutor.ruta12,
invoice_count: declaration.invoiceCount,
transaction_count: declaration.transactionCount,
})
},
{
name: 'get_vat_summary',
description: 'Hämtar momssammanställning med utgående moms, ingående moms och moms att betala/återfå.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getCompanyOverview = tool(
async () => {
const { data: settings } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', userId)
.single()
if (!settings) return 'Inga företagsinställningar hittades.'
// Get quick KPIs
const period = await resolveCurrentPeriod(supabase, userId)
const [
{ count: invoiceCount },
{ count: unpaidCount },
{ count: txCount },
{ count: unbookedCount },
] = await Promise.all([
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId),
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId).in('status', ['sent', 'overdue']),
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId),
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId).is('journal_entry_id', null),
])
let netResult: number | null = null
if (period) {
try {
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
const report = await generateIncomeStatement(supabase, userId, period.id)
netResult = report.net_result
} catch {
// Non-critical
}
}
return JSON.stringify({
company: {
name: settings.company_name,
entity_type: settings.entity_type,
org_number: settings.org_number,
vat_registered: settings.vat_registered,
accounting_method: settings.accounting_method,
moms_period: settings.moms_period,
},
kpis: {
total_invoices: invoiceCount || 0,
unpaid_invoices: unpaidCount || 0,
total_transactions: txCount || 0,
unbooked_transactions: unbookedCount || 0,
...(netResult !== null ? { net_result: netResult } : {}),
...(period ? { current_period: `${period.start} ${period.end}` } : {}),
},
})
},
{
name: 'get_company_overview',
description: 'Hämtar företagsinformation och nyckeltal (KPIs): antal fakturor, obetalda fakturor, transaktioner, obokförda transaktioner, årets resultat.',
schema: z.object({}),
}
)
const getAgingReport = tool(
async ({ type, limit }) => {
if (type === 'receivable') {
const { generateARLedger } = await import('@/lib/reports/ar-ledger')
const report = await generateARLedger(supabase, userId)
if (report.entries.length === 0) return 'Inga utestående kundfordringar.'
const entries = report.entries.slice(0, limit).map((e) => ({
name: e.customer_name,
current: e.current,
'1_30': e.days_1_30,
'31_60': e.days_31_60,
'61_90': e.days_61_90,
'90_plus': e.days_90_plus,
total: e.total_outstanding,
}))
return JSON.stringify({
type: 'receivable',
total_outstanding: report.total_outstanding,
total_current: report.total_current,
total_overdue: report.total_overdue,
unpaid_count: report.unpaid_count,
entries,
})
} else {
const { generateSupplierLedger } = await import('@/lib/reports/supplier-ledger')
const report = await generateSupplierLedger(supabase, userId)
if (report.entries.length === 0) return 'Inga utestående leverantörsskulder.'
const entries = report.entries.slice(0, limit).map((e) => ({
name: e.supplier_name,
current: e.current,
'1_30': e.days_1_30,
'31_60': e.days_31_60,
'61_90': e.days_61_90,
'90_plus': e.days_90_plus,
total: e.total_outstanding,
}))
return JSON.stringify({
type: 'payable',
total_outstanding: report.total_outstanding,
total_current: report.total_current,
total_overdue: report.total_overdue,
unpaid_count: report.unpaid_count,
entries,
})
}
},
{
name: 'get_aging_report',
description: 'Hämtar åldersanalys för kundfordringar (receivable) eller leverantörsskulder (payable). Visar utestående belopp uppdelat i ålderskategorier.',
schema: z.object({
type: z.enum(['receivable', 'payable']).describe("'receivable' för kundfordringar, 'payable' för leverantörsskulder"),
limit: z.number().max(20).default(10).describe('Max antal poster'),
}),
}
)
return [
getInvoices,
getSupplierInvoices,
getAccountBalances,
getTransactions,
getJournalEntries,
getIncomeStatement,
getBalanceSheet,
getVatSummary,
getCompanyOverview,
getAgingReport,
]
}