@kirjapro/mcp-server 0.8.2 → 0.8.5
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 +58 -3
- package/dist/index.js +144 -142
- package/package.json +1 -1
- package/server.json +1 -1
- package/tool-fingerprints.json +4 -4
- 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/templates.js
DELETED
|
@@ -1,636 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { ACCOUNTS } from "../accounts.js";
|
|
3
|
-
import { getSupabase, getCompanyId } from "../auth.js";
|
|
4
|
-
import { toResult, toErrorContent, sanitizeDbError, roundCents, resolveOpenFiscalYear, withIdempotency, IDEMPOTENCY_KEY_SCHEMA, ISO_DATE_SCHEMA, READ_ONLY_ANNOTATIONS, WRITE_ANNOTATIONS, } from "../utils.js";
|
|
5
|
-
// ---- Pure functions (exported for testing) ----
|
|
6
|
-
/**
|
|
7
|
-
* Split a gross EUR amount into net + VAT using residual method.
|
|
8
|
-
* vat = gross - net ensures no rounding drift.
|
|
9
|
-
*/
|
|
10
|
-
export function splitVat(gross, vatRate) {
|
|
11
|
-
if (vatRate === 0)
|
|
12
|
-
return { net: roundCents(gross), vat: 0 };
|
|
13
|
-
const net = roundCents(gross / (1 + vatRate / 100));
|
|
14
|
-
const vat = roundCents(gross - net);
|
|
15
|
-
return { net, vat };
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Apply account overrides to entries (e.g. swap "2320" → "2330").
|
|
19
|
-
*/
|
|
20
|
-
function applyOverrides(entries, overrides) {
|
|
21
|
-
if (!overrides)
|
|
22
|
-
return entries;
|
|
23
|
-
return entries.map((e) => ({
|
|
24
|
-
...e,
|
|
25
|
-
accountNumber: overrides[e.accountNumber] ?? e.accountNumber,
|
|
26
|
-
}));
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Validate that each accountOverrides pair (source → target) satisfies:
|
|
30
|
-
* 1. source exists in this company's chart,
|
|
31
|
-
* 2. target exists and is active, and
|
|
32
|
-
* 3. source.account_type === target.account_type.
|
|
33
|
-
*
|
|
34
|
-
* Prevents a caller from redirecting e.g. salary expense (`ELAKEKULUT`,
|
|
35
|
-
* expense) into an owner-withdrawal account (equity) or VAT receivable
|
|
36
|
-
* (asset) into a revenue account.
|
|
37
|
-
*/
|
|
38
|
-
export async function validateAccountOverrides(overrides) {
|
|
39
|
-
const entries = Object.entries(overrides);
|
|
40
|
-
if (entries.length === 0)
|
|
41
|
-
return { ok: true };
|
|
42
|
-
const supabase = getSupabase();
|
|
43
|
-
const companyId = getCompanyId();
|
|
44
|
-
const needed = [...new Set(entries.flatMap(([from, to]) => [from, to]))];
|
|
45
|
-
const { data: accounts, error } = await supabase
|
|
46
|
-
.from("accounts")
|
|
47
|
-
.select("account_number, account_type, is_active")
|
|
48
|
-
.eq("company_id", companyId)
|
|
49
|
-
.in("account_number", needed);
|
|
50
|
-
if (error) {
|
|
51
|
-
return { error: sanitizeDbError("Failed to validate accountOverrides", error) };
|
|
52
|
-
}
|
|
53
|
-
const byNumber = new Map((accounts || []).map((a) => [a.account_number, a]));
|
|
54
|
-
for (const [from, to] of entries) {
|
|
55
|
-
const src = byNumber.get(from);
|
|
56
|
-
const tgt = byNumber.get(to);
|
|
57
|
-
if (!src) {
|
|
58
|
-
return {
|
|
59
|
-
error: `accountOverrides: source "${from}" not found in your chart of accounts.`,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
if (!tgt) {
|
|
63
|
-
return {
|
|
64
|
-
error: `accountOverrides: target "${to}" not found in your chart of accounts.`,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
if (!tgt.is_active) {
|
|
68
|
-
return {
|
|
69
|
-
error: `accountOverrides: target "${to}" is inactive.`,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
if (src.account_type !== tgt.account_type) {
|
|
73
|
-
return {
|
|
74
|
-
error: `accountOverrides: cannot map "${from}" (${src.account_type}) to ` +
|
|
75
|
-
`"${to}" (${tgt.account_type}). Override target must match source account type.`,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return { ok: true };
|
|
80
|
-
}
|
|
81
|
-
// ---- Template definitions ----
|
|
82
|
-
function saleCashTemplate(revenueAccount, vatRate) {
|
|
83
|
-
return (amount, opts) => {
|
|
84
|
-
const { net, vat } = splitVat(amount, vatRate);
|
|
85
|
-
const entries = [
|
|
86
|
-
{
|
|
87
|
-
accountNumber: ACCOUNTS.KASSA,
|
|
88
|
-
debitAmount: roundCents(amount),
|
|
89
|
-
creditAmount: 0,
|
|
90
|
-
},
|
|
91
|
-
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net },
|
|
92
|
-
];
|
|
93
|
-
if (vat > 0) {
|
|
94
|
-
entries.push({
|
|
95
|
-
accountNumber: ACCOUNTS.ALV_VELKA,
|
|
96
|
-
debitAmount: 0,
|
|
97
|
-
creditAmount: vat,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
return applyOverrides(entries, opts?.accountOverrides);
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
function saleBankTemplate(revenueAccount, vatRate) {
|
|
104
|
-
return (amount, opts) => {
|
|
105
|
-
const { net, vat } = splitVat(amount, vatRate);
|
|
106
|
-
const entries = [
|
|
107
|
-
{
|
|
108
|
-
accountNumber: ACCOUNTS.PANKKITILI,
|
|
109
|
-
debitAmount: roundCents(amount),
|
|
110
|
-
creditAmount: 0,
|
|
111
|
-
},
|
|
112
|
-
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net },
|
|
113
|
-
];
|
|
114
|
-
if (vat > 0) {
|
|
115
|
-
entries.push({
|
|
116
|
-
accountNumber: ACCOUNTS.ALV_VELKA,
|
|
117
|
-
debitAmount: 0,
|
|
118
|
-
creditAmount: vat,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
return applyOverrides(entries, opts?.accountOverrides);
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
const TEMPLATES = [
|
|
125
|
-
// Cash sales
|
|
126
|
-
{
|
|
127
|
-
id: "cash_sale_25",
|
|
128
|
-
description: "Käteismyynti ALV 25,5%",
|
|
129
|
-
vatRate: 25.5,
|
|
130
|
-
expand: saleCashTemplate(ACCOUNTS.MYYNTI_25_5, 25.5),
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
id: "cash_sale_14",
|
|
134
|
-
description: "Käteismyynti ALV 13,5%",
|
|
135
|
-
vatRate: 13.5,
|
|
136
|
-
expand: saleCashTemplate(ACCOUNTS.MYYNTI_13_5, 13.5),
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
id: "cash_sale_10",
|
|
140
|
-
description: "Käteismyynti ALV 10%",
|
|
141
|
-
vatRate: 10,
|
|
142
|
-
expand: saleCashTemplate(ACCOUNTS.MYYNTI_10, 10),
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
id: "cash_sale_0",
|
|
146
|
-
description: "Käteismyynti ALV 0%",
|
|
147
|
-
vatRate: 0,
|
|
148
|
-
expand: saleCashTemplate(ACCOUNTS.MYYNTI_0, 0),
|
|
149
|
-
},
|
|
150
|
-
// Bank sales
|
|
151
|
-
{
|
|
152
|
-
id: "bank_sale_25",
|
|
153
|
-
description: "Pankkimyynti ALV 25,5%",
|
|
154
|
-
vatRate: 25.5,
|
|
155
|
-
expand: saleBankTemplate(ACCOUNTS.MYYNTI_25_5, 25.5),
|
|
156
|
-
},
|
|
157
|
-
{
|
|
158
|
-
id: "bank_sale_14",
|
|
159
|
-
description: "Pankkimyynti ALV 13,5%",
|
|
160
|
-
vatRate: 13.5,
|
|
161
|
-
expand: saleBankTemplate(ACCOUNTS.MYYNTI_13_5, 13.5),
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
id: "bank_sale_10",
|
|
165
|
-
description: "Pankkimyynti ALV 10%",
|
|
166
|
-
vatRate: 10,
|
|
167
|
-
expand: saleBankTemplate(ACCOUNTS.MYYNTI_10, 10),
|
|
168
|
-
},
|
|
169
|
-
// Invoice sale
|
|
170
|
-
{
|
|
171
|
-
id: "invoice_sale_25",
|
|
172
|
-
description: "Laskumyynti ALV 25,5% (myyntisaamiset)",
|
|
173
|
-
vatRate: 25.5,
|
|
174
|
-
expand: (amount, opts) => {
|
|
175
|
-
const { net, vat } = splitVat(amount, 25.5);
|
|
176
|
-
return applyOverrides([
|
|
177
|
-
{
|
|
178
|
-
accountNumber: ACCOUNTS.MYYNTISAAMISET,
|
|
179
|
-
debitAmount: roundCents(amount),
|
|
180
|
-
creditAmount: 0,
|
|
181
|
-
},
|
|
182
|
-
{ accountNumber: ACCOUNTS.MYYNTI_25_5, debitAmount: 0, creditAmount: net },
|
|
183
|
-
{ accountNumber: ACCOUNTS.ALV_VELKA, debitAmount: 0, creditAmount: vat },
|
|
184
|
-
], opts?.accountOverrides);
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
// Purchase expenses
|
|
188
|
-
{
|
|
189
|
-
id: "purchase_expense",
|
|
190
|
-
description: "Osto ALV 25,5% (pankkitililtä)",
|
|
191
|
-
vatRate: 25.5,
|
|
192
|
-
expand: (amount, opts) => {
|
|
193
|
-
const { net, vat } = splitVat(amount, 25.5);
|
|
194
|
-
return applyOverrides([
|
|
195
|
-
{ accountNumber: ACCOUNTS.OSTOT, debitAmount: net, creditAmount: 0 },
|
|
196
|
-
{ accountNumber: ACCOUNTS.ALV_SAAMISET, debitAmount: vat, creditAmount: 0 },
|
|
197
|
-
{
|
|
198
|
-
accountNumber: ACCOUNTS.PANKKITILI,
|
|
199
|
-
debitAmount: 0,
|
|
200
|
-
creditAmount: roundCents(amount),
|
|
201
|
-
},
|
|
202
|
-
], opts?.accountOverrides);
|
|
203
|
-
},
|
|
204
|
-
},
|
|
205
|
-
{
|
|
206
|
-
id: "service_expense",
|
|
207
|
-
description: "Ulkopuolinen palvelu ALV 25,5%",
|
|
208
|
-
vatRate: 25.5,
|
|
209
|
-
expand: (amount, opts) => {
|
|
210
|
-
const { net, vat } = splitVat(amount, 25.5);
|
|
211
|
-
return applyOverrides([
|
|
212
|
-
{ accountNumber: ACCOUNTS.OSTOT_PALVELUT, debitAmount: net, creditAmount: 0 },
|
|
213
|
-
{ accountNumber: ACCOUNTS.ALV_SAAMISET, debitAmount: vat, creditAmount: 0 },
|
|
214
|
-
{
|
|
215
|
-
accountNumber: ACCOUNTS.PANKKITILI,
|
|
216
|
-
debitAmount: 0,
|
|
217
|
-
creditAmount: roundCents(amount),
|
|
218
|
-
},
|
|
219
|
-
], opts?.accountOverrides);
|
|
220
|
-
},
|
|
221
|
-
},
|
|
222
|
-
// Rent (no VAT)
|
|
223
|
-
{
|
|
224
|
-
id: "rent_payment",
|
|
225
|
-
description: "Vuokranmaksu (pankkitililtä)",
|
|
226
|
-
vatRate: null,
|
|
227
|
-
expand: (amount, opts) => applyOverrides([
|
|
228
|
-
{
|
|
229
|
-
accountNumber: ACCOUNTS.TOIMITILAVUOKRAT,
|
|
230
|
-
debitAmount: roundCents(amount),
|
|
231
|
-
creditAmount: 0,
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
accountNumber: ACCOUNTS.PANKKITILI,
|
|
235
|
-
debitAmount: 0,
|
|
236
|
-
creditAmount: roundCents(amount),
|
|
237
|
-
},
|
|
238
|
-
], opts?.accountOverrides),
|
|
239
|
-
},
|
|
240
|
-
// Bank transfer
|
|
241
|
-
{
|
|
242
|
-
id: "bank_transfer",
|
|
243
|
-
description: "Tilisiirto kassasta pankkitilille",
|
|
244
|
-
vatRate: null,
|
|
245
|
-
expand: (amount, opts) => applyOverrides([
|
|
246
|
-
{
|
|
247
|
-
accountNumber: ACCOUNTS.PANKKITILI,
|
|
248
|
-
debitAmount: roundCents(amount),
|
|
249
|
-
creditAmount: 0,
|
|
250
|
-
},
|
|
251
|
-
{
|
|
252
|
-
accountNumber: ACCOUNTS.KASSA,
|
|
253
|
-
debitAmount: 0,
|
|
254
|
-
creditAmount: roundCents(amount),
|
|
255
|
-
},
|
|
256
|
-
], opts?.accountOverrides),
|
|
257
|
-
},
|
|
258
|
-
];
|
|
259
|
-
const TEMPLATE_MAP = new Map(TEMPLATES.map((t) => [t.id, t]));
|
|
260
|
-
/**
|
|
261
|
-
* Expand a template into journal entries for a given gross amount.
|
|
262
|
-
* Returns an error string if the template is unknown.
|
|
263
|
-
*/
|
|
264
|
-
export function expandTemplate(templateId, amount, opts) {
|
|
265
|
-
const template = TEMPLATE_MAP.get(templateId);
|
|
266
|
-
if (!template) {
|
|
267
|
-
return {
|
|
268
|
-
error: `Unknown template "${templateId}". Use kirjapro_list_templates to see available templates.`,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
if (amount <= 0) {
|
|
272
|
-
return { error: "Amount must be positive." };
|
|
273
|
-
}
|
|
274
|
-
const entries = template.expand(amount, opts);
|
|
275
|
-
return { entries };
|
|
276
|
-
}
|
|
277
|
-
// ---- Account resolution + fiscal year validation (same pattern as transactions.ts) ----
|
|
278
|
-
/**
|
|
279
|
-
* Validate the fiscal year (exists, open, date in range) for a given
|
|
280
|
-
* transaction date. Shared by `resolveAndCreate` and the atomic booking
|
|
281
|
-
* wrappers in `bookings.ts` so they get the same preflight checks before
|
|
282
|
-
* entering an advisory-locked RPC.
|
|
283
|
-
*/
|
|
284
|
-
export async function validateFiscalYearForDate(fiscalYearId, transactionDate) {
|
|
285
|
-
const supabase = getSupabase();
|
|
286
|
-
const companyId = getCompanyId();
|
|
287
|
-
const { data: fy, error: fyError } = await supabase
|
|
288
|
-
.from("fiscal_years")
|
|
289
|
-
.select("id, is_closed, start_date, end_date")
|
|
290
|
-
.eq("id", fiscalYearId)
|
|
291
|
-
.eq("company_id", companyId)
|
|
292
|
-
.single();
|
|
293
|
-
if (fyError || !fy) {
|
|
294
|
-
return toErrorContent(`Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard or read kirjapro://fiscal-years resource.`);
|
|
295
|
-
}
|
|
296
|
-
if (fy.is_closed) {
|
|
297
|
-
return toErrorContent(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`);
|
|
298
|
-
}
|
|
299
|
-
if (transactionDate < fy.start_date || transactionDate > fy.end_date) {
|
|
300
|
-
return toErrorContent(`Transaction date ${transactionDate} outside fiscal year (${fy.start_date} to ${fy.end_date}).`);
|
|
301
|
-
}
|
|
302
|
-
return { start_date: fy.start_date, end_date: fy.end_date };
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Resolve a set of account numbers to `{account_number -> id}` within the
|
|
306
|
-
* current company. Returns `toErrorContent` on lookup failure or missing
|
|
307
|
-
* accounts.
|
|
308
|
-
*/
|
|
309
|
-
export async function resolveAccountNumbers(accountNumbers) {
|
|
310
|
-
const supabase = getSupabase();
|
|
311
|
-
const companyId = getCompanyId();
|
|
312
|
-
const unique = [...new Set(accountNumbers)];
|
|
313
|
-
const { data: accounts, error } = await supabase
|
|
314
|
-
.from("accounts")
|
|
315
|
-
.select("id, account_number")
|
|
316
|
-
.eq("company_id", companyId)
|
|
317
|
-
.in("account_number", unique);
|
|
318
|
-
if (error) {
|
|
319
|
-
return toErrorContent(sanitizeDbError("Failed to resolve accounts", error));
|
|
320
|
-
}
|
|
321
|
-
const map = new Map((accounts || []).map((a) => [a.account_number, a.id]));
|
|
322
|
-
const missing = unique.filter((n) => !map.has(n));
|
|
323
|
-
if (missing.length > 0) {
|
|
324
|
-
return toErrorContent(`Account numbers not found: ${missing.join(", ")}. Use kirjapro_list_accounts to see available accounts.`);
|
|
325
|
-
}
|
|
326
|
-
return map;
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Build DB-shaped journal entries from `TemplateEntry[]` using an
|
|
330
|
-
* account-number -> id map. Caller is responsible for balance validation.
|
|
331
|
-
*/
|
|
332
|
-
export function toJournalEntries(entries, accountMap) {
|
|
333
|
-
return entries.map((e) => ({
|
|
334
|
-
account_id: accountMap.get(e.accountNumber),
|
|
335
|
-
debit_amount: e.debitAmount,
|
|
336
|
-
credit_amount: e.creditAmount,
|
|
337
|
-
}));
|
|
338
|
-
}
|
|
339
|
-
export async function resolveAndCreate(params) {
|
|
340
|
-
const supabase = getSupabase();
|
|
341
|
-
const companyId = getCompanyId();
|
|
342
|
-
let fiscalYearId = params.fiscalYearId;
|
|
343
|
-
if (!fiscalYearId) {
|
|
344
|
-
const resolved = await resolveOpenFiscalYear(supabase, companyId);
|
|
345
|
-
if ("isError" in resolved)
|
|
346
|
-
return resolved;
|
|
347
|
-
fiscalYearId = resolved.id;
|
|
348
|
-
}
|
|
349
|
-
const fyResult = await validateFiscalYearForDate(fiscalYearId, params.transactionDate);
|
|
350
|
-
if ("isError" in fyResult)
|
|
351
|
-
return fyResult;
|
|
352
|
-
const accountResult = await resolveAccountNumbers(params.entries.map((e) => e.accountNumber));
|
|
353
|
-
if ("isError" in accountResult)
|
|
354
|
-
return accountResult;
|
|
355
|
-
// Validate balance
|
|
356
|
-
const totalDebit = roundCents(params.entries.reduce((s, e) => s + e.debitAmount, 0));
|
|
357
|
-
const totalCredit = roundCents(params.entries.reduce((s, e) => s + e.creditAmount, 0));
|
|
358
|
-
if (Math.abs(totalDebit - totalCredit) >= 0.01) {
|
|
359
|
-
return toErrorContent(`Template produced unbalanced entries: debits ${totalDebit.toFixed(2)} != credits ${totalCredit.toFixed(2)}.`);
|
|
360
|
-
}
|
|
361
|
-
const journalEntries = toJournalEntries(params.entries, accountResult);
|
|
362
|
-
const { data: transactionId, error: txError } = await supabase.rpc("create_transaction_with_entries", {
|
|
363
|
-
p_company_id: companyId,
|
|
364
|
-
p_fiscal_year_id: fiscalYearId,
|
|
365
|
-
p_transaction_date: params.transactionDate,
|
|
366
|
-
p_description: params.description,
|
|
367
|
-
p_document_number: params.documentNumber || "",
|
|
368
|
-
p_entries: journalEntries,
|
|
369
|
-
});
|
|
370
|
-
if (txError || !transactionId) {
|
|
371
|
-
return toErrorContent(txError ? sanitizeDbError("Failed to create transaction", txError) : "Failed to create transaction: no ID returned.");
|
|
372
|
-
}
|
|
373
|
-
return { id: transactionId };
|
|
374
|
-
}
|
|
375
|
-
// Account reference removed — use kirjapro_list_accounts or kirjapro://accounts/chart resource for lookups.
|
|
376
|
-
// ---- Tool registration ----
|
|
377
|
-
export function registerTemplateTools(server) {
|
|
378
|
-
// ---- READ: List Templates ----
|
|
379
|
-
server.registerTool("kirjapro_list_templates", {
|
|
380
|
-
description: 'List available transaction templates. Templates handle double-entry balancing and VAT splitting automatically.',
|
|
381
|
-
inputSchema: {},
|
|
382
|
-
outputSchema: {
|
|
383
|
-
templates: z.array(z.object({
|
|
384
|
-
id: z.string(),
|
|
385
|
-
description: z.string(),
|
|
386
|
-
vatRate: z.number().nullable(),
|
|
387
|
-
})),
|
|
388
|
-
},
|
|
389
|
-
annotations: READ_ONLY_ANNOTATIONS,
|
|
390
|
-
}, async () => {
|
|
391
|
-
const list = TEMPLATES.map((t) => ({
|
|
392
|
-
id: t.id,
|
|
393
|
-
description: t.description,
|
|
394
|
-
vatRate: t.vatRate,
|
|
395
|
-
}));
|
|
396
|
-
return toResult({ templates: list });
|
|
397
|
-
});
|
|
398
|
-
// ---- WRITE: Create from Template ----
|
|
399
|
-
server.registerTool("kirjapro_create_from_template", {
|
|
400
|
-
description: 'Create a transaction from a template. Use kirjapro_list_templates for available templates. accountOverrides swaps default accounts (e.g. {"2320": "2330"}).',
|
|
401
|
-
inputSchema: {
|
|
402
|
-
template: z
|
|
403
|
-
.string()
|
|
404
|
-
.describe('Template ID (e.g. "cash_sale_25", "purchase_expense")'),
|
|
405
|
-
fiscalYearId: z
|
|
406
|
-
.string()
|
|
407
|
-
.uuid()
|
|
408
|
-
.optional()
|
|
409
|
-
.describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
410
|
-
transactionDate: ISO_DATE_SCHEMA
|
|
411
|
-
.describe("Transaction date (YYYY-MM-DD)"),
|
|
412
|
-
amount: z
|
|
413
|
-
.number()
|
|
414
|
-
.finite()
|
|
415
|
-
.positive()
|
|
416
|
-
.max(1_000_000_000)
|
|
417
|
-
.describe("Gross amount in EUR"),
|
|
418
|
-
description: z
|
|
419
|
-
.string()
|
|
420
|
-
.min(1)
|
|
421
|
-
.max(500)
|
|
422
|
-
.describe("Transaction description"),
|
|
423
|
-
documentNumber: z.string().optional().describe("Document/receipt number"),
|
|
424
|
-
accountOverrides: z
|
|
425
|
-
.record(z.string(), z.string())
|
|
426
|
-
.optional()
|
|
427
|
-
.describe('Account number swaps, e.g. {"2320": "2330"}'),
|
|
428
|
-
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
429
|
-
},
|
|
430
|
-
annotations: WRITE_ANNOTATIONS,
|
|
431
|
-
}, async (params) => {
|
|
432
|
-
const supabase = getSupabase();
|
|
433
|
-
const companyId = getCompanyId();
|
|
434
|
-
return withIdempotency(supabase, companyId, "kirjapro_create_from_template", params.idempotencyKey, params, async () => {
|
|
435
|
-
if (params.accountOverrides) {
|
|
436
|
-
const ov = await validateAccountOverrides(params.accountOverrides);
|
|
437
|
-
if ("error" in ov)
|
|
438
|
-
return toErrorContent(ov.error);
|
|
439
|
-
}
|
|
440
|
-
const result = expandTemplate(params.template, params.amount, {
|
|
441
|
-
accountOverrides: params.accountOverrides,
|
|
442
|
-
});
|
|
443
|
-
if ("error" in result) {
|
|
444
|
-
return toErrorContent(result.error);
|
|
445
|
-
}
|
|
446
|
-
const createResult = await resolveAndCreate({
|
|
447
|
-
fiscalYearId: params.fiscalYearId,
|
|
448
|
-
transactionDate: params.transactionDate,
|
|
449
|
-
description: params.description,
|
|
450
|
-
documentNumber: params.documentNumber,
|
|
451
|
-
entries: result.entries,
|
|
452
|
-
});
|
|
453
|
-
if ("isError" in createResult) {
|
|
454
|
-
return createResult;
|
|
455
|
-
}
|
|
456
|
-
return toResult({
|
|
457
|
-
message: "Transaction created from template",
|
|
458
|
-
template: params.template,
|
|
459
|
-
transaction_id: createResult.id,
|
|
460
|
-
entries: result.entries,
|
|
461
|
-
});
|
|
462
|
-
});
|
|
463
|
-
});
|
|
464
|
-
// ---- WRITE: Batch Create from Template ----
|
|
465
|
-
server.registerTool("kirjapro_create_from_template_batch", {
|
|
466
|
-
description: 'Create multiple transactions from templates in one call (up to 50). Default mode "atomic" rolls back ALL on any failure; "best_effort" collects per-row errors.',
|
|
467
|
-
inputSchema: {
|
|
468
|
-
fiscalYearId: z
|
|
469
|
-
.string()
|
|
470
|
-
.uuid()
|
|
471
|
-
.optional()
|
|
472
|
-
.describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
473
|
-
mode: z
|
|
474
|
-
.enum(["atomic", "best_effort"])
|
|
475
|
-
.default("atomic")
|
|
476
|
-
.describe('Batch mode. "atomic" (default): all-or-nothing, rolls back on any row failure. "best_effort": commits what it can, returns per-row errors.'),
|
|
477
|
-
transactions: z
|
|
478
|
-
.array(z.object({
|
|
479
|
-
template: z.string().describe("Template ID"),
|
|
480
|
-
transactionDate: ISO_DATE_SCHEMA
|
|
481
|
-
.describe("Date (YYYY-MM-DD)"),
|
|
482
|
-
amount: z
|
|
483
|
-
.number()
|
|
484
|
-
.finite()
|
|
485
|
-
.positive()
|
|
486
|
-
.max(1_000_000_000)
|
|
487
|
-
.describe("Gross amount in EUR"),
|
|
488
|
-
description: z.string().min(1).max(500),
|
|
489
|
-
documentNumber: z.string().optional(),
|
|
490
|
-
accountOverrides: z.record(z.string(), z.string()).optional(),
|
|
491
|
-
}))
|
|
492
|
-
.min(1)
|
|
493
|
-
.max(50)
|
|
494
|
-
.describe("Array of template transactions to create (max 50)"),
|
|
495
|
-
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
496
|
-
},
|
|
497
|
-
annotations: WRITE_ANNOTATIONS,
|
|
498
|
-
}, async (params) => {
|
|
499
|
-
const supabase = getSupabase();
|
|
500
|
-
const companyId = getCompanyId();
|
|
501
|
-
return withIdempotency(supabase, companyId, "kirjapro_create_from_template_batch", params.idempotencyKey, params, async () => {
|
|
502
|
-
let fiscalYearId = params.fiscalYearId;
|
|
503
|
-
if (!fiscalYearId) {
|
|
504
|
-
const resolved = await resolveOpenFiscalYear(supabase, companyId);
|
|
505
|
-
if ("isError" in resolved)
|
|
506
|
-
return resolved;
|
|
507
|
-
fiscalYearId = resolved.id;
|
|
508
|
-
}
|
|
509
|
-
const { data: fy, error: fyError } = await supabase
|
|
510
|
-
.from("fiscal_years")
|
|
511
|
-
.select("id, is_closed, start_date, end_date")
|
|
512
|
-
.eq("id", fiscalYearId)
|
|
513
|
-
.eq("company_id", companyId)
|
|
514
|
-
.single();
|
|
515
|
-
if (fyError || !fy) {
|
|
516
|
-
return toErrorContent(`Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard or read kirjapro://fiscal-years resource.`);
|
|
517
|
-
}
|
|
518
|
-
if (fy.is_closed) {
|
|
519
|
-
return toErrorContent(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`);
|
|
520
|
-
}
|
|
521
|
-
const mergedOverrides = {};
|
|
522
|
-
for (const tx of params.transactions) {
|
|
523
|
-
if (!tx.accountOverrides)
|
|
524
|
-
continue;
|
|
525
|
-
for (const [from, to] of Object.entries(tx.accountOverrides)) {
|
|
526
|
-
const prior = mergedOverrides[from];
|
|
527
|
-
if (prior && prior !== to) {
|
|
528
|
-
return toErrorContent(`accountOverrides conflict across batch: "${from}" is mapped to both ` +
|
|
529
|
-
`"${prior}" and "${to}". Use a single target per source within one batch.`);
|
|
530
|
-
}
|
|
531
|
-
mergedOverrides[from] = to;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
if (Object.keys(mergedOverrides).length > 0) {
|
|
535
|
-
const ov = await validateAccountOverrides(mergedOverrides);
|
|
536
|
-
if ("error" in ov)
|
|
537
|
-
return toErrorContent(ov.error);
|
|
538
|
-
}
|
|
539
|
-
// Phase 1: expand templates + collect account numbers
|
|
540
|
-
const expanded = [];
|
|
541
|
-
const preFailed = [];
|
|
542
|
-
for (let i = 0; i < params.transactions.length; i++) {
|
|
543
|
-
const tx = params.transactions[i];
|
|
544
|
-
const result = expandTemplate(tx.template, tx.amount, {
|
|
545
|
-
accountOverrides: tx.accountOverrides,
|
|
546
|
-
});
|
|
547
|
-
if ("error" in result) {
|
|
548
|
-
preFailed.push({ index: i, error: result.error });
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
if (tx.transactionDate < fy.start_date ||
|
|
552
|
-
tx.transactionDate > fy.end_date) {
|
|
553
|
-
preFailed.push({
|
|
554
|
-
index: i,
|
|
555
|
-
error: `Date ${tx.transactionDate} outside fiscal year range (${fy.start_date} to ${fy.end_date}).`,
|
|
556
|
-
});
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
|
-
const totalDebit = roundCents(result.entries.reduce((s, e) => s + e.debitAmount, 0));
|
|
560
|
-
const totalCredit = roundCents(result.entries.reduce((s, e) => s + e.creditAmount, 0));
|
|
561
|
-
if (Math.abs(totalDebit - totalCredit) >= 0.01) {
|
|
562
|
-
preFailed.push({
|
|
563
|
-
index: i,
|
|
564
|
-
error: `Template produced unbalanced entries: debits ${totalDebit.toFixed(2)} != credits ${totalCredit.toFixed(2)}.`,
|
|
565
|
-
});
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
expanded.push({
|
|
569
|
-
index: i,
|
|
570
|
-
transactionDate: tx.transactionDate,
|
|
571
|
-
description: tx.description,
|
|
572
|
-
documentNumber: tx.documentNumber || "",
|
|
573
|
-
entries: result.entries,
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
if (params.mode === "atomic" && preFailed.length > 0) {
|
|
577
|
-
return toErrorContent(`Atomic batch aborted: ${preFailed.length} transaction(s) failed validation. ` +
|
|
578
|
-
`Fix all errors or use mode "best_effort" for partial commits.\n` +
|
|
579
|
-
preFailed.map((f) => ` [${f.index}] ${f.error}`).join("\n"));
|
|
580
|
-
}
|
|
581
|
-
if (expanded.length === 0) {
|
|
582
|
-
return toResult({
|
|
583
|
-
mode: params.mode,
|
|
584
|
-
created_count: 0,
|
|
585
|
-
failed_count: preFailed.length,
|
|
586
|
-
created: [],
|
|
587
|
-
failed: preFailed,
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
// Phase 2: resolve all account numbers in one batch
|
|
591
|
-
const allAccountNumbers = [
|
|
592
|
-
...new Set(expanded.flatMap((e) => e.entries.map((en) => en.accountNumber))),
|
|
593
|
-
];
|
|
594
|
-
const accountResult = await resolveAccountNumbers(allAccountNumbers);
|
|
595
|
-
if ("isError" in accountResult)
|
|
596
|
-
return accountResult;
|
|
597
|
-
const accountMap = accountResult;
|
|
598
|
-
// Phase 3: build RPC payload
|
|
599
|
-
const rpcTransactions = expanded.map((e) => ({
|
|
600
|
-
transaction_date: e.transactionDate,
|
|
601
|
-
description: e.description,
|
|
602
|
-
document_number: e.documentNumber,
|
|
603
|
-
entries: toJournalEntries(e.entries, accountMap),
|
|
604
|
-
}));
|
|
605
|
-
const { data: rpcResult, error: rpcError } = await supabase.rpc("create_transactions_batch_atomic", {
|
|
606
|
-
p_company_id: companyId,
|
|
607
|
-
p_fiscal_year_id: fiscalYearId,
|
|
608
|
-
p_transactions: rpcTransactions,
|
|
609
|
-
p_mode: params.mode,
|
|
610
|
-
});
|
|
611
|
-
if (rpcError) {
|
|
612
|
-
return toErrorContent(sanitizeDbError("Batch create failed", rpcError));
|
|
613
|
-
}
|
|
614
|
-
const result = rpcResult;
|
|
615
|
-
const created = result.created.map((c) => ({
|
|
616
|
-
id: c.id,
|
|
617
|
-
description: c.description,
|
|
618
|
-
date: c.date,
|
|
619
|
-
index: expanded[c.index].index,
|
|
620
|
-
}));
|
|
621
|
-
const rpcFailed = result.failed.map((f) => ({
|
|
622
|
-
index: expanded[f.index].index,
|
|
623
|
-
error: sanitizeDbError("Failed to create transaction", {
|
|
624
|
-
message: f.error,
|
|
625
|
-
}),
|
|
626
|
-
}));
|
|
627
|
-
return toResult({
|
|
628
|
-
mode: params.mode,
|
|
629
|
-
created_count: created.length,
|
|
630
|
-
failed_count: preFailed.length + rpcFailed.length,
|
|
631
|
-
created,
|
|
632
|
-
failed: [...preFailed, ...rpcFailed],
|
|
633
|
-
});
|
|
634
|
-
});
|
|
635
|
-
});
|
|
636
|
-
}
|