@kirjapro/mcp-server 0.6.3 → 0.8.0
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.
- package/CHANGELOG.md +115 -0
- package/README.md +73 -9
- package/dist/index.js +1472 -657
- package/package.json +10 -4
- package/server.json +6 -2
- package/tool-fingerprints.json +55 -51
- package/dist/accounts.d.ts +0 -40
- package/dist/accounts.js +0 -45
- package/dist/auth.d.ts +0 -17
- package/dist/auth.js +0 -245
- package/dist/compliance/rates.d.ts +0 -53
- package/dist/compliance/rates.js +0 -57
- package/dist/database.types.d.ts +0 -6525
- package/dist/database.types.js +0 -20
- package/dist/index.d.ts +0 -2
- package/dist/lib/incomeStatementSynthesis.d.ts +0 -47
- package/dist/lib/incomeStatementSynthesis.js +0 -140
- package/dist/resources/company.d.ts +0 -2
- package/dist/resources/company.js +0 -75
- package/dist/resources/compliance.d.ts +0 -2
- package/dist/resources/compliance.js +0 -338
- package/dist/resources/index.d.ts +0 -2
- package/dist/resources/index.js +0 -10
- package/dist/resources/schema.d.ts +0 -2
- package/dist/resources/schema.js +0 -63
- package/dist/resources/security.d.ts +0 -2
- package/dist/resources/security.js +0 -33
- package/dist/schemas.d.ts +0 -12
- package/dist/schemas.js +0 -12
- package/dist/tools/accounts.d.ts +0 -2
- package/dist/tools/accounts.js +0 -48
- package/dist/tools/banking.d.ts +0 -2
- package/dist/tools/banking.js +0 -196
- package/dist/tools/bookings.d.ts +0 -2
- package/dist/tools/bookings.js +0 -294
- package/dist/tools/customers.d.ts +0 -2
- package/dist/tools/customers.js +0 -320
- package/dist/tools/financialNotes.d.ts +0 -2
- package/dist/tools/financialNotes.js +0 -255
- package/dist/tools/fingerprints.d.ts +0 -6
- package/dist/tools/fingerprints.js +0 -28
- package/dist/tools/fiscal-years.d.ts +0 -2
- package/dist/tools/fiscal-years.js +0 -35
- package/dist/tools/index.d.ts +0 -9
- package/dist/tools/index.js +0 -105
- package/dist/tools/invoices.d.ts +0 -2
- package/dist/tools/invoices.js +0 -489
- package/dist/tools/offers.d.ts +0 -2
- package/dist/tools/offers.js +0 -443
- package/dist/tools/received-invoices.d.ts +0 -2
- package/dist/tools/received-invoices.js +0 -134
- package/dist/tools/recurring-invoices.d.ts +0 -2
- package/dist/tools/recurring-invoices.js +0 -348
- package/dist/tools/reports.d.ts +0 -2
- package/dist/tools/reports.js +0 -869
- package/dist/tools/templates.d.ts +0 -89
- package/dist/tools/templates.js +0 -636
- package/dist/tools/transactions.d.ts +0 -2
- package/dist/tools/transactions.js +0 -597
- package/dist/tools/validation.d.ts +0 -26
- package/dist/tools/validation.js +0 -127
- package/dist/types.d.ts +0 -388
- package/dist/types.js +0 -34
- package/dist/utils.d.ts +0 -276
- package/dist/utils.js +0 -637
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -3
package/dist/tools/reports.js
DELETED
|
@@ -1,869 +0,0 @@
|
|
|
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
|
-
}
|