@kirjapro/mcp-server 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/accounts.d.ts +40 -0
  3. package/dist/accounts.js +45 -0
  4. package/dist/auth.d.ts +17 -0
  5. package/dist/auth.js +245 -0
  6. package/dist/compliance/rates.d.ts +53 -0
  7. package/dist/compliance/rates.js +57 -0
  8. package/dist/database.types.d.ts +6525 -0
  9. package/dist/database.types.js +20 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +7441 -2613
  12. package/dist/lib/incomeStatementSynthesis.d.ts +47 -0
  13. package/dist/lib/incomeStatementSynthesis.js +140 -0
  14. package/dist/resources/company.d.ts +2 -0
  15. package/dist/resources/company.js +75 -0
  16. package/dist/resources/compliance.d.ts +2 -0
  17. package/dist/resources/compliance.js +338 -0
  18. package/dist/resources/index.d.ts +2 -0
  19. package/dist/resources/index.js +10 -0
  20. package/dist/resources/schema.d.ts +2 -0
  21. package/dist/resources/schema.js +63 -0
  22. package/dist/resources/security.d.ts +2 -0
  23. package/dist/resources/security.js +33 -0
  24. package/dist/schemas.d.ts +12 -0
  25. package/dist/schemas.js +12 -0
  26. package/dist/tools/accounts.d.ts +2 -0
  27. package/dist/tools/accounts.js +48 -0
  28. package/dist/tools/banking.d.ts +2 -0
  29. package/dist/tools/banking.js +196 -0
  30. package/dist/tools/bookings.d.ts +2 -0
  31. package/dist/tools/bookings.js +294 -0
  32. package/dist/tools/customers.d.ts +2 -0
  33. package/dist/tools/customers.js +320 -0
  34. package/dist/tools/financialNotes.d.ts +2 -0
  35. package/dist/tools/financialNotes.js +255 -0
  36. package/dist/tools/fingerprints.d.ts +6 -0
  37. package/dist/tools/fingerprints.js +28 -0
  38. package/dist/tools/fiscal-years.d.ts +2 -0
  39. package/dist/tools/fiscal-years.js +35 -0
  40. package/dist/tools/index.d.ts +9 -0
  41. package/dist/tools/index.js +105 -0
  42. package/dist/tools/invoices.d.ts +2 -0
  43. package/dist/tools/invoices.js +489 -0
  44. package/dist/tools/offers.d.ts +2 -0
  45. package/dist/tools/offers.js +443 -0
  46. package/dist/tools/received-invoices.d.ts +2 -0
  47. package/dist/tools/received-invoices.js +134 -0
  48. package/dist/tools/recurring-invoices.d.ts +2 -0
  49. package/dist/tools/recurring-invoices.js +348 -0
  50. package/dist/tools/reports.d.ts +2 -0
  51. package/dist/tools/reports.js +869 -0
  52. package/dist/tools/templates.d.ts +89 -0
  53. package/dist/tools/templates.js +636 -0
  54. package/dist/tools/transactions.d.ts +2 -0
  55. package/dist/tools/transactions.js +597 -0
  56. package/dist/tools/validation.d.ts +26 -0
  57. package/dist/tools/validation.js +127 -0
  58. package/dist/types.d.ts +388 -0
  59. package/dist/types.js +34 -0
  60. package/dist/utils.d.ts +276 -0
  61. package/dist/utils.js +637 -0
  62. package/dist/version.d.ts +1 -0
  63. package/dist/version.js +3 -0
  64. package/package.json +1 -1
  65. package/server.json +1 -1
  66. package/tool-fingerprints.json +14 -14
@@ -0,0 +1,869 @@
1
+ import { z } from 'zod';
2
+ import { getSupabase, getCompanyId } from '../auth.js';
3
+ import { synthesizeIncomeStatementLines } from '../lib/incomeStatementSynthesis.js';
4
+ import { toErrorContent, toResult, sanitizeDbError, roundCents, formatCurrency, helsinkiDateString, resolveOpenFiscalYear, tagUntrusted, ISO_DATE_SCHEMA, READ_ONLY_ANNOTATIONS, } from '../utils.js';
5
+ /**
6
+ * Default income statement template id, resolved lazily on first call.
7
+ * Templates are seed-static (no admin UI), so a process-wide cache is safe;
8
+ * worst case after a redeploy we hit the DB once. Post-DT-039 the database
9
+ * holds a single `kululajikohtainen` row (KPA 1:1 § cost-of-sales) — the
10
+ * lookup matches by name only. Mirrors the web hook
11
+ * `useIncomeStatementTemplates.ts` which dropped its `is_default` filter for
12
+ * the same wave-sibling-doctrine-parity reason: if a future opt-in alt
13
+ * template flips the default flag, both surfaces continue to resolve.
14
+ */
15
+ let cachedDefaultTemplateId = null;
16
+ async function resolveDefaultIncomeStatementTemplate(supabase) {
17
+ if (cachedDefaultTemplateId)
18
+ return cachedDefaultTemplateId;
19
+ const { data, error } = await supabase
20
+ .from('income_statement_templates')
21
+ .select('id')
22
+ .eq('name', 'kululajikohtainen')
23
+ .maybeSingle();
24
+ if (error || !data) {
25
+ throw new Error('Default income statement template not found');
26
+ }
27
+ cachedDefaultTemplateId = data.id;
28
+ return data.id;
29
+ }
30
+ const INVOICE_STAT_BUCKET = z.object({
31
+ count: z.number(),
32
+ total: z.number(),
33
+ });
34
+ const DASHBOARD_OUTPUT_SHAPE = {
35
+ financial_summary: z
36
+ .object({
37
+ revenue: z.number(),
38
+ revenue_formatted: z.string(),
39
+ expenses: z.number(),
40
+ expenses_formatted: z.string(),
41
+ profit: z.number(),
42
+ profit_formatted: z.string(),
43
+ transaction_count: z.number(),
44
+ warning: z.string().optional(),
45
+ })
46
+ .passthrough(),
47
+ invoice_stats: z.object({
48
+ draft: INVOICE_STAT_BUCKET,
49
+ sent: INVOICE_STAT_BUCKET,
50
+ partial: INVOICE_STAT_BUCKET,
51
+ paid: INVOICE_STAT_BUCKET,
52
+ overdue: INVOICE_STAT_BUCKET,
53
+ cancelled: INVOICE_STAT_BUCKET,
54
+ no_due_date: INVOICE_STAT_BUCKET,
55
+ }),
56
+ bank_summary: z
57
+ .object({
58
+ accounts: z.array(z.object({
59
+ name: z.string(),
60
+ iban: z.string().nullable(),
61
+ balance: z.number(),
62
+ })),
63
+ total_balance: z.number(),
64
+ total_balance_formatted: z.string(),
65
+ })
66
+ .passthrough(),
67
+ };
68
+ /**
69
+ * Income statement shape (post-RPC migration, 2026-04-27).
70
+ *
71
+ * Sourced from `get_income_statement` RPC (range-based aggregation with the
72
+ * 5-digit-subaccount fix) plus client-side `synthesizeIncomeStatementLines`
73
+ * for the calculated subtotals (LIIKEVOITTO, RAHOITUS_TOTAL, ...).
74
+ *
75
+ * BREAKING vs the pre-RPC shape: dropped `revenue.accounts[]` and
76
+ * `expenses.accounts[]` per-account arrays. No live consumer in
77
+ * `src/components/chat/` or the AI system prompt referenced those fields;
78
+ * they were a leftover from the inline-join implementation. The remaining
79
+ * `revenue.total` / `expenses.total` and the new `subtotals` map cover
80
+ * every documented use case.
81
+ *
82
+ * `subtotals` uses `.partial().catchall(z.number())` so the structuredContent
83
+ * stays typed as `{[k: string]: unknown}` (skill `mcp-structured-content-
84
+ * index-signature`) while tolerating custom-template line_codes that aren't
85
+ * in the canonical kululajikohtainen list.
86
+ */
87
+ const INCOME_STATEMENT_OUTPUT_SHAPE = {
88
+ period: z.object({ from: z.string(), to: z.string() }),
89
+ revenue: z.object({
90
+ total: z.number(),
91
+ total_formatted: z.string(),
92
+ }),
93
+ expenses: z.object({
94
+ total: z.number(),
95
+ total_formatted: z.string(),
96
+ }),
97
+ net_profit: z.number(),
98
+ net_profit_formatted: z.string().optional(),
99
+ subtotals: z
100
+ .object({
101
+ LIIKEVAIHTO: z.number(),
102
+ MATERIAALIT_TOTAL: z.number(),
103
+ HENKILOSTO_TOTAL: z.number(),
104
+ POISTOT_TOTAL: z.number(),
105
+ MUUT_KULUT: z.number(),
106
+ LIIKEVOITTO: z.number(),
107
+ RAHOITUS_TOTAL: z.number(),
108
+ VOITTO_ENNEN_SIIRTOJA: z.number(),
109
+ SIIRROT_TOTAL: z.number(),
110
+ TULOVEROT: z.number(),
111
+ TILIKAUDEN_TULOS: z.number(),
112
+ })
113
+ .partial()
114
+ .catchall(z.number()),
115
+ warning: z.string().optional(),
116
+ };
117
+ const ALV_SUMMARY_OUTPUT_SHAPE = {
118
+ period: z.object({ from: z.string(), to: z.string() }),
119
+ output_vat: z.unknown().optional(),
120
+ input_vat: z.unknown().optional(),
121
+ net_vat_payable: z.number().optional(),
122
+ net_vat_payable_formatted: z.string().optional(),
123
+ omavero_fields: z.unknown().optional(),
124
+ warning: z.string().optional(),
125
+ message: z.string().optional(),
126
+ };
127
+ const BALANCE_SHEET_ROW_SHAPE = z
128
+ .object({
129
+ account_number: z.string(),
130
+ name: z.string(),
131
+ account_type: z.string(),
132
+ balance: z.number(),
133
+ })
134
+ .passthrough();
135
+ const BALANCE_SHEET_OUTPUT_SHAPE = {
136
+ report_date: z.string(),
137
+ assets: z.object({
138
+ accounts: z.array(BALANCE_SHEET_ROW_SHAPE),
139
+ total: z.number(),
140
+ total_formatted: z.string(),
141
+ }),
142
+ liabilities: z.object({
143
+ accounts: z.array(BALANCE_SHEET_ROW_SHAPE),
144
+ total: z.number(),
145
+ total_formatted: z.string(),
146
+ }),
147
+ equity: z.object({
148
+ accounts: z.array(BALANCE_SHEET_ROW_SHAPE),
149
+ total: z.number(),
150
+ total_formatted: z.string(),
151
+ synthetic_tilikauden_tulos: z.number().optional(),
152
+ synthetic_note: z.string().optional(),
153
+ }),
154
+ total_liabilities_and_equity: z.number(),
155
+ balanced: z.boolean(),
156
+ warning: z.string().optional(),
157
+ };
158
+ const AGING_BUCKET_SHAPE = z.object({
159
+ label: z.string(),
160
+ count: z.number(),
161
+ total: z.number(),
162
+ total_formatted: z.string(),
163
+ invoices: z.array(z
164
+ .object({
165
+ invoice_number: z.string(),
166
+ customer: z.string().nullable().optional(),
167
+ total: z.number().nullable(),
168
+ due_date: z.string().nullable(),
169
+ days_overdue: z.number(),
170
+ })
171
+ .passthrough()),
172
+ });
173
+ const AR_AGING_OUTPUT_SHAPE = {
174
+ buckets: z.array(AGING_BUCKET_SHAPE),
175
+ no_due_date: z.object({
176
+ count: z.number(),
177
+ total: z.number(),
178
+ invoices: z.array(z.record(z.string(), z.unknown())),
179
+ }).passthrough(),
180
+ total_receivables: z.number(),
181
+ total_receivables_formatted: z.string(),
182
+ days_sales_outstanding: z.number(),
183
+ };
184
+ const GENERAL_LEDGER_OUTPUT_SHAPE = {
185
+ account: z.object({
186
+ account_number: z.string(),
187
+ name: z.string(),
188
+ account_type: z.string(),
189
+ balance_type: z.enum(['debit-normal', 'credit-normal']),
190
+ }),
191
+ entries: z.array(z
192
+ .object({
193
+ date: z.string(),
194
+ description: z.string().nullable(),
195
+ document_number: z.string().nullable(),
196
+ transaction_id: z.string().uuid(),
197
+ debit: z.number(),
198
+ credit: z.number(),
199
+ running_balance: z.number(),
200
+ })
201
+ .passthrough()),
202
+ entry_count: z.number(),
203
+ opening_balance: z.number(),
204
+ closing_balance: z.number(),
205
+ closing_balance_formatted: z.string(),
206
+ capped: z.boolean(),
207
+ capped_note: z.string().nullable(),
208
+ };
209
+ export function registerReportTools(server) {
210
+ // ---- Dashboard Stats ----
211
+ server.registerTool('kirjapro_get_dashboard', {
212
+ description: 'Get dashboard overview: revenue, expenses, profit, transaction count, invoice stats, bank balances, and AR aging for a fiscal year. Returns a comprehensive financial snapshot. If fiscalYearId is omitted, the single open fiscal year is used; specify it explicitly when multiple are open.',
213
+ inputSchema: {
214
+ fiscalYearId: z
215
+ .string()
216
+ .uuid()
217
+ .optional()
218
+ .describe('Fiscal year UUID (auto-resolved to the open fiscal year if omitted)'),
219
+ },
220
+ outputSchema: DASHBOARD_OUTPUT_SHAPE,
221
+ annotations: READ_ONLY_ANNOTATIONS,
222
+ }, async (params) => {
223
+ const supabase = getSupabase();
224
+ const companyId = getCompanyId();
225
+ let fiscalYearId;
226
+ if (params.fiscalYearId) {
227
+ fiscalYearId = params.fiscalYearId;
228
+ }
229
+ else {
230
+ const resolved = await resolveOpenFiscalYear(supabase, companyId);
231
+ if ('isError' in resolved)
232
+ return resolved;
233
+ fiscalYearId = resolved.id;
234
+ }
235
+ // Fetch fiscal year dates for scoping
236
+ const { data: fiscalYear, error: fyError } = await supabase
237
+ .from('fiscal_years')
238
+ .select('start_date, end_date')
239
+ .eq('id', fiscalYearId)
240
+ .eq('company_id', companyId)
241
+ .single();
242
+ if (fyError || !fiscalYear) {
243
+ return toErrorContent(`Fiscal year ${fiscalYearId} not found. Use kirjapro_list_fiscal_years to find available fiscal years.`);
244
+ }
245
+ // Parallel queries for performance
246
+ const JOURNAL_ENTRY_CAP = 50_000;
247
+ const [entriesResult, txCountResult, invoiceResult, bankResult] = await Promise.all([
248
+ // Revenue & expense totals from journal entries (capped to prevent OOM on large datasets)
249
+ supabase
250
+ .from('journal_entries')
251
+ .select(`
252
+ debit_amount, credit_amount,
253
+ account:accounts!inner(account_type),
254
+ transaction:transactions!inner(fiscal_year_id, company_id)
255
+ `, { count: 'exact' })
256
+ .eq('transaction.fiscal_year_id', fiscalYearId)
257
+ .eq('transaction.company_id', companyId)
258
+ .limit(JOURNAL_ENTRY_CAP),
259
+ // Transaction count
260
+ supabase
261
+ .from('transactions')
262
+ .select('*', { count: 'exact', head: true })
263
+ .eq('company_id', companyId)
264
+ .eq('fiscal_year_id', fiscalYearId),
265
+ // Invoice stats scoped to fiscal year date range
266
+ supabase
267
+ .from('invoices')
268
+ .select('id, status, total, due_date')
269
+ .eq('company_id', companyId)
270
+ .gte('invoice_date', fiscalYear.start_date)
271
+ .lte('invoice_date', fiscalYear.end_date),
272
+ // Bank balances
273
+ supabase
274
+ .from('bank_accounts')
275
+ .select('account_name, iban, current_balance')
276
+ .eq('company_id', companyId)
277
+ .eq('is_active', true),
278
+ ]);
279
+ // Calculate revenue and expenses
280
+ let revenue = 0;
281
+ let expenses = 0;
282
+ for (const entry of entriesResult.data || []) {
283
+ const accountType = entry.account?.account_type;
284
+ if (accountType === 'revenue') {
285
+ revenue += (entry.credit_amount || 0) - (entry.debit_amount || 0);
286
+ }
287
+ else if (accountType === 'expense') {
288
+ expenses += (entry.debit_amount || 0) - (entry.credit_amount || 0);
289
+ }
290
+ }
291
+ // Invoice stats — use Helsinki-local "today" so period attribution near
292
+ // midnight-UTC doesn't silently shift invoices into the wrong day.
293
+ const now = helsinkiDateString();
294
+ const invoices = invoiceResult.data || [];
295
+ const invoiceStats = {
296
+ draft: { count: 0, total: 0 },
297
+ sent: { count: 0, total: 0 },
298
+ partial: { count: 0, total: 0 },
299
+ paid: { count: 0, total: 0 },
300
+ overdue: { count: 0, total: 0 },
301
+ cancelled: { count: 0, total: 0 },
302
+ no_due_date: { count: 0, total: 0 },
303
+ };
304
+ for (const inv of invoices) {
305
+ // Null/empty due_date can't be compared against "today" safely.
306
+ // Surface these in their own bucket instead of silently leaving them
307
+ // as `sent` (which hides stuck invoices from the agent).
308
+ const hasDueDate = typeof inv.due_date === 'string' && inv.due_date.length > 0;
309
+ let status;
310
+ if (inv.status === 'sent' && !hasDueDate) {
311
+ status = 'no_due_date';
312
+ }
313
+ else if (inv.status === 'sent' && hasDueDate && inv.due_date < now) {
314
+ status = 'overdue';
315
+ }
316
+ else if (inv.status in invoiceStats) {
317
+ status = inv.status;
318
+ }
319
+ else {
320
+ continue;
321
+ }
322
+ invoiceStats[status].count++;
323
+ invoiceStats[status].total += inv.total || 0;
324
+ }
325
+ // Bank balances
326
+ const bankAccounts = (bankResult.data || []).map((b) => ({
327
+ name: b.account_name || 'Unnamed',
328
+ iban: b.iban,
329
+ balance: b.current_balance || 0,
330
+ }));
331
+ const totalBankBalance = bankAccounts.reduce((s, b) => s + b.balance, 0);
332
+ const entriesCapped = (entriesResult.count || 0) >= JOURNAL_ENTRY_CAP;
333
+ return toResult({
334
+ financial_summary: {
335
+ revenue: roundCents(revenue),
336
+ revenue_formatted: formatCurrency(revenue),
337
+ expenses: roundCents(expenses),
338
+ expenses_formatted: formatCurrency(expenses),
339
+ profit: roundCents(revenue - expenses),
340
+ profit_formatted: formatCurrency(revenue - expenses),
341
+ transaction_count: txCountResult.count || 0,
342
+ ...(entriesCapped && { warning: `Results capped at ${JOURNAL_ENTRY_CAP} journal entries. Totals may be approximate.` }),
343
+ },
344
+ invoice_stats: invoiceStats,
345
+ bank_summary: {
346
+ accounts: bankAccounts,
347
+ total_balance: roundCents(totalBankBalance),
348
+ total_balance_formatted: formatCurrency(totalBankBalance),
349
+ },
350
+ });
351
+ });
352
+ // ---- Income Statement ----
353
+ server.registerTool('kirjapro_get_income_statement', {
354
+ description: 'Generate income statement (tuloslaskelma) for a date range. Returns revenue / expense totals plus a `subtotals` map of every line_code in the default cost-of-sales template (kululajikohtainen) — including calculated subtotals such as LIIKEVOITTO, RAHOITUS_TOTAL, TILIKAUDEN_TULOS. fiscalYearId is accepted for backward compat but ignored: the underlying RPC scopes by company + date range only.',
355
+ inputSchema: {
356
+ fiscalYearId: z
357
+ .string()
358
+ .uuid()
359
+ .optional()
360
+ .describe('Fiscal year UUID (accepted for backward compat; ignored by the RPC which scopes by date range)'),
361
+ dateFrom: ISO_DATE_SCHEMA
362
+ .describe('Period start date (YYYY-MM-DD)'),
363
+ dateTo: ISO_DATE_SCHEMA
364
+ .describe('Period end date (YYYY-MM-DD)'),
365
+ },
366
+ outputSchema: INCOME_STATEMENT_OUTPUT_SHAPE,
367
+ annotations: READ_ONLY_ANNOTATIONS,
368
+ }, async (params) => {
369
+ const supabase = getSupabase();
370
+ const companyId = getCompanyId();
371
+ // fiscalYearId is intentionally not used here. The RPC aggregates by
372
+ // (company_id, transaction_date BETWEEN p_date_from AND p_date_to),
373
+ // which is the same scope the web report uses. Keeping the param in
374
+ // the input schema preserves backward compat with existing AI prompts.
375
+ let templateId;
376
+ try {
377
+ templateId = await resolveDefaultIncomeStatementTemplate(supabase);
378
+ }
379
+ catch (err) {
380
+ return toErrorContent(err instanceof Error ? err.message : 'Default income statement template not found');
381
+ }
382
+ // Fetch leaf-line amounts via the RPC — uses account_number_range_value()
383
+ // (rpad-to-5) to include 5-digit subaccounts within 4-digit parent ranges.
384
+ const { data: rpcRows, error } = await supabase.rpc('get_income_statement', {
385
+ p_company_id: companyId,
386
+ p_template_id: templateId,
387
+ p_date_from: params.dateFrom,
388
+ p_date_to: params.dateTo,
389
+ });
390
+ if (error) {
391
+ return toErrorContent(sanitizeDbError('Failed to generate income statement', error));
392
+ }
393
+ // Fetch the template's line_types so we can run the same synthesis the
394
+ // web hook runs. Order MUST follow display_order — calculated subtotals
395
+ // depend on prior-line amounts being in the map first.
396
+ const { data: lineTypes, error: lineTypesError } = await supabase
397
+ .from('income_statement_line_types')
398
+ .select('*')
399
+ .eq('template_id', templateId)
400
+ .order('display_order', { ascending: true });
401
+ if (lineTypesError || !lineTypes) {
402
+ return toErrorContent(sanitizeDbError('Failed to load income statement template lines', lineTypesError));
403
+ }
404
+ // RPC returns rows ONLY for leaf (range-based) line_codes; calculated
405
+ // subtotals (LIIKEVOITTO, ...) are synthesized below.
406
+ const currentByLine = new Map();
407
+ for (const row of rpcRows ?? []) {
408
+ currentByLine.set(row.line_code, Number(row.current_amount));
409
+ }
410
+ const { lines } = synthesizeIncomeStatementLines(lineTypes, currentByLine, new Map());
411
+ // Build `subtotals` from every materialized line in the synthesis result
412
+ // (both leaf rows and calculated subtotals), rounded for cents safety.
413
+ const subtotals = {};
414
+ for (const line of lines) {
415
+ subtotals[line.lineCode] = roundCents(line.currentAmount);
416
+ }
417
+ // Roll up legacy revenue/expenses totals so existing prompt phrasing
418
+ // ("revenue total", "expense total") still works. After Wave 0
419
+ // realignment (2026-04-27, plan: tuloslaskelma-rationalization-plan.md)
420
+ // every leaf row carries sign_convention='positive' and the RPC returns
421
+ // amounts in their natural sign — revenue rows positive, expense rows
422
+ // negative. Bucket by line_type ('credit'=revenue / 'debit'=expense)
423
+ // because signConvention is no longer a discriminator.
424
+ let totalRevenue = 0;
425
+ let totalExpenses = 0;
426
+ for (const line of lines) {
427
+ if (line.isCalculated)
428
+ continue;
429
+ if (line.lineType === 'debit') {
430
+ totalExpenses += -line.currentAmount;
431
+ }
432
+ else if (line.lineType === 'credit') {
433
+ totalRevenue += line.currentAmount;
434
+ }
435
+ }
436
+ // Prefer the synthesized bottom-line; fall back to liikevoitto, then to
437
+ // raw revenue-expenses for templates that don't define TILIKAUDEN_TULOS.
438
+ // Use key-presence checks (not truthiness) — a dormant or break-even
439
+ // year legitimately yields TILIKAUDEN_TULOS = 0, and `||` would skip it
440
+ // and report an inflated net_profit from the next fallback.
441
+ const netProfit = 'TILIKAUDEN_TULOS' in subtotals
442
+ ? subtotals.TILIKAUDEN_TULOS
443
+ : 'LIIKEVOITTO' in subtotals
444
+ ? subtotals.LIIKEVOITTO
445
+ : totalRevenue - totalExpenses;
446
+ return toResult({
447
+ period: { from: params.dateFrom, to: params.dateTo },
448
+ revenue: {
449
+ total: roundCents(totalRevenue),
450
+ total_formatted: formatCurrency(totalRevenue),
451
+ },
452
+ expenses: {
453
+ total: roundCents(totalExpenses),
454
+ total_formatted: formatCurrency(totalExpenses),
455
+ },
456
+ net_profit: roundCents(netProfit),
457
+ net_profit_formatted: formatCurrency(netProfit),
458
+ subtotals,
459
+ });
460
+ });
461
+ // ---- Balance Sheet ----
462
+ server.registerTool('kirjapro_get_balance_sheet', {
463
+ description: 'Generate balance sheet (tase) as of a specific date. Shows assets, liabilities, and equity with account-level detail. If fiscalYearId is omitted, the single open fiscal year is used.',
464
+ inputSchema: {
465
+ fiscalYearId: z
466
+ .string()
467
+ .uuid()
468
+ .optional()
469
+ .describe('Fiscal year UUID (auto-resolved to the open fiscal year if omitted)'),
470
+ reportDate: ISO_DATE_SCHEMA
471
+ .describe('Report date (YYYY-MM-DD)'),
472
+ },
473
+ outputSchema: BALANCE_SHEET_OUTPUT_SHAPE,
474
+ annotations: READ_ONLY_ANNOTATIONS,
475
+ }, async (params) => {
476
+ const supabase = getSupabase();
477
+ const companyId = getCompanyId();
478
+ let fiscalYearId;
479
+ if (params.fiscalYearId) {
480
+ fiscalYearId = params.fiscalYearId;
481
+ }
482
+ else {
483
+ const resolved = await resolveOpenFiscalYear(supabase, companyId);
484
+ if ('isError' in resolved)
485
+ return resolved;
486
+ fiscalYearId = resolved.id;
487
+ }
488
+ const BS_ENTRY_CAP = 50_000;
489
+ // Pull rev/expense entries alongside balance-sheet entries so we can
490
+ // synthesise mid-year tilikauden tulos (DT-043) for open fiscal years
491
+ // where account 3600 has no closing-entry balance yet. Without this,
492
+ // `balanced` reports false for every open-FY company, contradicting
493
+ // the web Tase (DT-041 + DT-043) and giving AI agents a false signal.
494
+ const { data: entries, count: bsEntryCount, error } = await supabase
495
+ .from('journal_entries')
496
+ .select(`
497
+ debit_amount, credit_amount,
498
+ account:accounts!inner(id, account_number, name, account_type),
499
+ transaction:transactions!inner(fiscal_year_id, transaction_date, company_id)
500
+ `, { count: 'exact' })
501
+ .eq('transaction.fiscal_year_id', fiscalYearId)
502
+ .eq('transaction.company_id', companyId)
503
+ .lte('transaction.transaction_date', params.reportDate)
504
+ .limit(BS_ENTRY_CAP);
505
+ if (error) {
506
+ return toErrorContent(sanitizeDbError("Failed to generate balance sheet", error));
507
+ }
508
+ // Aggregate by account. Keep rev/expense d-c sums for the synth path
509
+ // (DT-043) — they are NOT pushed onto assets/liabilities/equity arrays.
510
+ const accountTotals = new Map();
511
+ let synthRevExpDMinusC = 0;
512
+ let account3600Balance = 0;
513
+ for (const entry of entries || []) {
514
+ const acc = entry.account;
515
+ if (!acc)
516
+ continue;
517
+ const debit = entry.debit_amount || 0;
518
+ const credit = entry.credit_amount || 0;
519
+ if (acc.account_type === 'revenue' || acc.account_type === 'expense') {
520
+ synthRevExpDMinusC += debit - credit;
521
+ continue;
522
+ }
523
+ const balanceTypes = ['asset', 'liability', 'equity'];
524
+ if (!balanceTypes.includes(acc.account_type))
525
+ continue;
526
+ if (acc.account_number === '3600') {
527
+ account3600Balance += credit - debit;
528
+ }
529
+ const key = acc.account_number;
530
+ const existing = accountTotals.get(key) || {
531
+ account_number: acc.account_number,
532
+ name: acc.name,
533
+ account_type: acc.account_type,
534
+ balance: 0,
535
+ };
536
+ if (acc.account_type === 'asset') {
537
+ existing.balance += debit - credit;
538
+ }
539
+ else {
540
+ existing.balance += credit - debit;
541
+ }
542
+ accountTotals.set(key, existing);
543
+ }
544
+ const assets = [];
545
+ const liabilities = [];
546
+ const equity = [];
547
+ let totalAssets = 0;
548
+ let totalLiabilities = 0;
549
+ let totalEquity = 0;
550
+ for (const [, acc] of [...accountTotals].sort((a, b) => a[0].localeCompare(b[0]))) {
551
+ acc.balance = roundCents(acc.balance);
552
+ switch (acc.account_type) {
553
+ case 'asset':
554
+ assets.push(acc);
555
+ totalAssets += acc.balance;
556
+ break;
557
+ case 'liability':
558
+ liabilities.push(acc);
559
+ totalLiabilities += acc.balance;
560
+ break;
561
+ case 'equity':
562
+ equity.push(acc);
563
+ totalEquity += acc.balance;
564
+ break;
565
+ }
566
+ }
567
+ const bsCapped = (bsEntryCount || 0) >= BS_ENTRY_CAP;
568
+ // DT-043: synthetic mid-year tilikauden tulos. When account 3600
569
+ // hasn't been posted to (open FY, closing entry deferred), surface
570
+ // the implied profit so totals balance. Skip when 3600 already has
571
+ // a balance (closed FY: closing entry done, would double-count).
572
+ // Sign: synthRevExpDMinusC = -profit under d-c convention; flip to
573
+ // +profit for display.
574
+ const syntheticTilikaudenTulos = account3600Balance === 0 ? roundCents(-synthRevExpDMinusC) : 0;
575
+ const totalEquityWithSynth = totalEquity + syntheticTilikaudenTulos;
576
+ return toResult({
577
+ report_date: params.reportDate,
578
+ assets: {
579
+ accounts: assets,
580
+ total: roundCents(totalAssets),
581
+ total_formatted: formatCurrency(totalAssets),
582
+ },
583
+ liabilities: {
584
+ accounts: liabilities,
585
+ total: roundCents(totalLiabilities),
586
+ total_formatted: formatCurrency(totalLiabilities),
587
+ },
588
+ equity: {
589
+ accounts: equity,
590
+ total: roundCents(totalEquityWithSynth),
591
+ total_formatted: formatCurrency(totalEquityWithSynth),
592
+ synthetic_tilikauden_tulos: syntheticTilikaudenTulos,
593
+ ...(syntheticTilikaudenTulos !== 0 && {
594
+ synthetic_note: 'Tilikauden tulos (kesken) — kirjattu mukaan oma pääoma -summaan, mutta EI jakokelpoinen ennen tilinpäätöksen vahvistamista (OYL 13:5§).',
595
+ }),
596
+ },
597
+ total_liabilities_and_equity: roundCents(totalLiabilities + totalEquityWithSynth),
598
+ balanced: Math.abs(totalAssets - (totalLiabilities + totalEquityWithSynth)) < 0.01,
599
+ ...(bsCapped && { warning: `Results capped at ${BS_ENTRY_CAP} journal entries. Totals may be approximate.` }),
600
+ });
601
+ });
602
+ // ---- AR Aging ----
603
+ server.registerTool('kirjapro_get_ar_aging', {
604
+ description: 'Get accounts receivable aging report. Shows unpaid invoices grouped by days overdue: 0-30, 31-60, 61-90, 90+ days. Includes Days Sales Outstanding (DSO).',
605
+ inputSchema: {},
606
+ outputSchema: AR_AGING_OUTPUT_SHAPE,
607
+ annotations: READ_ONLY_ANNOTATIONS,
608
+ }, async () => {
609
+ const supabase = getSupabase();
610
+ const companyId = getCompanyId();
611
+ const { data: invoices, error } = await supabase
612
+ .from('invoices')
613
+ .select('id, invoice_number, total, due_date, invoice_date, customers(name)')
614
+ .eq('company_id', companyId)
615
+ .in('status', ['sent', 'overdue']);
616
+ if (error) {
617
+ return toErrorContent(sanitizeDbError("Failed to get AR aging", error));
618
+ }
619
+ // Use Helsinki-local "today" so bucket assignment near midnight-UTC
620
+ // is consistent with get_dashboard's overdue detection (same helper).
621
+ const todayStr = helsinkiDateString();
622
+ const todayMs = Date.parse(todayStr);
623
+ const buckets = [
624
+ { label: '0-30 days', min: 0, max: 30, count: 0, total: 0, invoices: [] },
625
+ { label: '31-60 days', min: 31, max: 60, count: 0, total: 0, invoices: [] },
626
+ { label: '61-90 days', min: 61, max: 90, count: 0, total: 0, invoices: [] },
627
+ { label: '90+ days', min: 91, max: Infinity, count: 0, total: 0, invoices: [] },
628
+ ];
629
+ let totalReceivables = 0;
630
+ let weightedDays = 0;
631
+ const noDueDate = [];
632
+ for (const inv of invoices || []) {
633
+ // invoices.due_date is nullable in DB. `new Date(null)` yields NaN, and
634
+ // the old bucket.find(...)! returned undefined + crashed on count++.
635
+ // Surface null-due-date rows in a separate field so the agent can
636
+ // prompt the user to set a due date, rather than silently hiding them.
637
+ if (!inv.due_date) {
638
+ noDueDate.push({
639
+ invoice_number: inv.invoice_number,
640
+ customer: tagUntrusted(inv.customers?.name),
641
+ total: inv.total || 0,
642
+ });
643
+ totalReceivables += inv.total || 0;
644
+ continue;
645
+ }
646
+ const dueMs = Date.parse(inv.due_date);
647
+ if (Number.isNaN(dueMs)) {
648
+ noDueDate.push({
649
+ invoice_number: inv.invoice_number,
650
+ customer: tagUntrusted(inv.customers?.name),
651
+ total: inv.total || 0,
652
+ });
653
+ totalReceivables += inv.total || 0;
654
+ continue;
655
+ }
656
+ const daysOverdue = Math.max(0, Math.floor((todayMs - dueMs) / 86400000));
657
+ const bucket = buckets.find((b) => daysOverdue >= b.min && daysOverdue <= b.max);
658
+ if (!bucket)
659
+ continue;
660
+ bucket.count++;
661
+ bucket.total += inv.total || 0;
662
+ bucket.invoices.push({
663
+ invoice_number: inv.invoice_number,
664
+ customer: tagUntrusted(inv.customers?.name),
665
+ total: inv.total,
666
+ due_date: inv.due_date,
667
+ days_overdue: daysOverdue,
668
+ });
669
+ totalReceivables += inv.total || 0;
670
+ weightedDays += daysOverdue * (inv.total || 0);
671
+ }
672
+ // DSO = weighted average days
673
+ const dso = totalReceivables > 0 ? Math.round(weightedDays / totalReceivables) : 0;
674
+ return toResult({
675
+ buckets: buckets.map((b) => ({
676
+ label: b.label,
677
+ count: b.count,
678
+ total: roundCents(b.total),
679
+ total_formatted: formatCurrency(b.total),
680
+ invoices: b.invoices,
681
+ })),
682
+ no_due_date: {
683
+ count: noDueDate.length,
684
+ total: roundCents(noDueDate.reduce((s, i) => s + (i.total || 0), 0)),
685
+ invoices: noDueDate,
686
+ },
687
+ total_receivables: roundCents(totalReceivables),
688
+ total_receivables_formatted: formatCurrency(totalReceivables),
689
+ days_sales_outstanding: dso,
690
+ });
691
+ });
692
+ // ---- General Ledger ----
693
+ server.registerTool('kirjapro_get_general_ledger', {
694
+ description: 'Get general ledger (pääkirja) for a single account within a fiscal year. Shows all journal entries with running balance. Assets and expenses use debit-normal balance; liabilities, equity, and revenue use credit-normal. If fiscalYearId is omitted, the single open fiscal year is used.',
695
+ inputSchema: {
696
+ accountNumber: z.string().describe('Account number (e.g. "3000")'),
697
+ fiscalYearId: z
698
+ .string()
699
+ .uuid()
700
+ .optional()
701
+ .describe('Fiscal year UUID (auto-resolved to the open fiscal year if omitted)'),
702
+ dateFrom: ISO_DATE_SCHEMA
703
+ .optional()
704
+ .describe('Start date filter (YYYY-MM-DD)'),
705
+ dateTo: ISO_DATE_SCHEMA
706
+ .optional()
707
+ .describe('End date filter (YYYY-MM-DD)'),
708
+ },
709
+ outputSchema: GENERAL_LEDGER_OUTPUT_SHAPE,
710
+ annotations: READ_ONLY_ANNOTATIONS,
711
+ }, async (params) => {
712
+ const supabase = getSupabase();
713
+ const companyId = getCompanyId();
714
+ let fiscalYearId;
715
+ if (params.fiscalYearId) {
716
+ fiscalYearId = params.fiscalYearId;
717
+ }
718
+ else {
719
+ const resolved = await resolveOpenFiscalYear(supabase, companyId);
720
+ if ('isError' in resolved)
721
+ return resolved;
722
+ fiscalYearId = resolved.id;
723
+ }
724
+ // Resolve account number to get account details
725
+ const { data: account, error: accError } = await supabase
726
+ .from('accounts')
727
+ .select('id, account_number, name, account_type')
728
+ .eq('company_id', companyId)
729
+ .eq('account_number', params.accountNumber)
730
+ .single();
731
+ if (accError || !account) {
732
+ return toErrorContent(`Account "${params.accountNumber}" not found. Use kirjapro_list_accounts to see available accounts.`);
733
+ }
734
+ // Fetch journal entries for this account in the fiscal year. We fetch
735
+ // LEDGER_FETCH_LIMIT + 1 rows so we can detect truncation and surface
736
+ // a `capped: true` signal to the agent — otherwise a busy account
737
+ // silently returns a partial ledger and the closing balance is wrong.
738
+ const LEDGER_FETCH_LIMIT = 5000;
739
+ let query = supabase
740
+ .from('journal_entries')
741
+ .select(`
742
+ debit_amount, credit_amount,
743
+ transaction:transactions!inner(id, transaction_date, description, document_number, fiscal_year_id, company_id)
744
+ `)
745
+ .eq('account_id', account.id)
746
+ .eq('transaction.fiscal_year_id', fiscalYearId)
747
+ .eq('transaction.company_id', companyId)
748
+ .order('transaction(transaction_date)', { ascending: true })
749
+ .order('id', { ascending: true })
750
+ .range(0, LEDGER_FETCH_LIMIT);
751
+ if (params.dateFrom) {
752
+ query = query.gte('transaction.transaction_date', params.dateFrom);
753
+ }
754
+ if (params.dateTo) {
755
+ query = query.lte('transaction.transaction_date', params.dateTo);
756
+ }
757
+ const { data: entries, error } = await query;
758
+ if (error) {
759
+ return toErrorContent(sanitizeDbError("Failed to get general ledger", error));
760
+ }
761
+ // Determine balance direction: debit-normal for assets/expenses, credit-normal for others
762
+ const debitNormal = ['asset', 'expense'];
763
+ const isDebitNormal = debitNormal.includes(account.account_type);
764
+ // When dateFrom is applied, compute opening balance from entries
765
+ // before that date so the running balance starts correctly.
766
+ let openingBalance = 0;
767
+ if (params.dateFrom) {
768
+ const { data: priorEntries, error: priorErr } = await supabase
769
+ .from('journal_entries')
770
+ .select(`
771
+ debit_amount, credit_amount,
772
+ transaction:transactions!inner(fiscal_year_id, company_id, transaction_date)
773
+ `)
774
+ .eq('account_id', account.id)
775
+ .eq('transaction.fiscal_year_id', fiscalYearId)
776
+ .eq('transaction.company_id', companyId)
777
+ .lt('transaction.transaction_date', params.dateFrom);
778
+ if (!priorErr && priorEntries) {
779
+ for (const e of priorEntries) {
780
+ const d = e.debit_amount || 0;
781
+ const c = e.credit_amount || 0;
782
+ openingBalance += isDebitNormal ? (d - c) : (c - d);
783
+ }
784
+ openingBalance = roundCents(openingBalance);
785
+ }
786
+ }
787
+ // Detect truncation and drop the probe row BEFORE computing running
788
+ // balance — otherwise the probe row's debit/credit would leak into the
789
+ // closing balance and break the invariant `entry_count == entries.length`.
790
+ const fetched = entries || [];
791
+ const capped = fetched.length > LEDGER_FETCH_LIMIT;
792
+ const consumed = capped ? fetched.slice(0, LEDGER_FETCH_LIMIT) : fetched;
793
+ // Build ledger with running balance
794
+ let runningBalance = openingBalance;
795
+ const ledgerEntries = [];
796
+ for (const entry of consumed) {
797
+ const tx = entry.transaction;
798
+ const debit = entry.debit_amount || 0;
799
+ const credit = entry.credit_amount || 0;
800
+ if (isDebitNormal) {
801
+ runningBalance += debit - credit;
802
+ }
803
+ else {
804
+ runningBalance += credit - debit;
805
+ }
806
+ ledgerEntries.push({
807
+ date: tx.transaction_date,
808
+ description: tx.description,
809
+ document_number: tx.document_number,
810
+ transaction_id: tx.id,
811
+ debit: roundCents(debit),
812
+ credit: roundCents(credit),
813
+ running_balance: roundCents(runningBalance),
814
+ });
815
+ }
816
+ return toResult({
817
+ account: {
818
+ account_number: account.account_number,
819
+ name: account.name,
820
+ account_type: account.account_type,
821
+ balance_type: isDebitNormal ? 'debit-normal' : 'credit-normal',
822
+ },
823
+ entries: ledgerEntries,
824
+ entry_count: ledgerEntries.length,
825
+ opening_balance: openingBalance,
826
+ closing_balance: roundCents(runningBalance),
827
+ closing_balance_formatted: formatCurrency(runningBalance),
828
+ capped,
829
+ capped_note: capped
830
+ ? `Result truncated at ${LEDGER_FETCH_LIMIT} entries. Closing balance is computed from fetched rows only and may not reflect the full fiscal-year total. Narrow the date range with dateFrom/dateTo to see the remainder.`
831
+ : null,
832
+ });
833
+ });
834
+ // ---- ALV Summary ----
835
+ server.registerTool('kirjapro_get_alv_summary', {
836
+ description: 'Get VAT (ALV) summary for a date range. Returns output/input VAT breakdown by rate and OmaVero field codes (301-320). For filing deadlines, read kirjapro://compliance/deadlines.',
837
+ inputSchema: {
838
+ dateFrom: ISO_DATE_SCHEMA
839
+ .describe('Period start date (YYYY-MM-DD)'),
840
+ dateTo: ISO_DATE_SCHEMA
841
+ .describe('Period end date (YYYY-MM-DD)'),
842
+ },
843
+ outputSchema: ALV_SUMMARY_OUTPUT_SHAPE,
844
+ annotations: READ_ONLY_ANNOTATIONS,
845
+ }, async (params) => {
846
+ const supabase = getSupabase();
847
+ const companyId = getCompanyId();
848
+ const { data, error } = await supabase.rpc('get_alv_summary', {
849
+ p_company_id: companyId,
850
+ p_date_from: params.dateFrom,
851
+ p_date_to: params.dateTo,
852
+ });
853
+ if (error) {
854
+ return toErrorContent(sanitizeDbError("Failed to get ALV summary", error));
855
+ }
856
+ const result = data;
857
+ if (!result) {
858
+ return toResult({
859
+ period: { from: params.dateFrom, to: params.dateTo },
860
+ message: 'No VAT data found for this period.',
861
+ });
862
+ }
863
+ return toResult({
864
+ ...result,
865
+ period: { from: params.dateFrom, to: params.dateTo },
866
+ net_vat_payable_formatted: formatCurrency(result.net_vat_payable || 0),
867
+ });
868
+ });
869
+ }