@kirjapro/mcp-server 0.5.10 → 0.6.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 +32 -3
- package/README.md +2 -2
- package/dist/accounts.d.ts +40 -0
- package/dist/accounts.js +45 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +245 -0
- package/dist/compliance/rates.d.ts +53 -0
- package/dist/compliance/rates.js +57 -0
- package/dist/database.types.d.ts +6525 -0
- package/dist/database.types.js +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +58 -52
- package/dist/lib/incomeStatementSynthesis.d.ts +47 -0
- package/dist/lib/incomeStatementSynthesis.js +140 -0
- package/dist/resources/company.d.ts +2 -0
- package/dist/resources/company.js +75 -0
- package/dist/resources/compliance.d.ts +2 -0
- package/dist/resources/compliance.js +338 -0
- package/dist/resources/index.d.ts +2 -0
- package/dist/resources/index.js +10 -0
- package/dist/resources/schema.d.ts +2 -0
- package/dist/resources/schema.js +63 -0
- package/dist/resources/security.d.ts +2 -0
- package/dist/resources/security.js +33 -0
- package/dist/schemas.d.ts +12 -0
- package/dist/schemas.js +12 -0
- package/dist/tools/accounts.d.ts +2 -0
- package/dist/tools/accounts.js +48 -0
- package/dist/tools/banking.d.ts +2 -0
- package/dist/tools/banking.js +196 -0
- package/dist/tools/bookings.d.ts +2 -0
- package/dist/tools/bookings.js +294 -0
- package/dist/tools/customers.d.ts +2 -0
- package/dist/tools/customers.js +320 -0
- package/dist/tools/financialNotes.d.ts +2 -0
- package/dist/tools/financialNotes.js +255 -0
- package/dist/tools/fingerprints.d.ts +6 -0
- package/dist/tools/fingerprints.js +28 -0
- package/dist/tools/fiscal-years.d.ts +2 -0
- package/dist/tools/fiscal-years.js +35 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +105 -0
- package/dist/tools/invoices.d.ts +2 -0
- package/dist/tools/invoices.js +489 -0
- package/dist/tools/offers.d.ts +2 -0
- package/dist/tools/offers.js +443 -0
- package/dist/tools/received-invoices.d.ts +2 -0
- package/dist/tools/received-invoices.js +134 -0
- package/dist/tools/recurring-invoices.d.ts +2 -0
- package/dist/tools/recurring-invoices.js +348 -0
- package/dist/tools/reports.d.ts +2 -0
- package/dist/tools/reports.js +869 -0
- package/dist/tools/templates.d.ts +89 -0
- package/dist/tools/templates.js +636 -0
- package/dist/tools/transactions.d.ts +2 -0
- package/dist/tools/transactions.js +597 -0
- package/dist/tools/validation.d.ts +26 -0
- package/dist/tools/validation.js +127 -0
- package/dist/types.d.ts +388 -0
- package/dist/types.js +34 -0
- package/dist/utils.d.ts +276 -0
- package/dist/utils.js +637 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +3 -2
- package/server.json +5 -1
- package/tool-fingerprints.json +1 -1
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getSupabase, getCompanyId } from "../auth.js";
|
|
3
|
+
import { clampPagination, isUuid, paginate, toErrorContent, toResult, sanitizeDbError, roundCents, escapeLike, resolveOpenFiscalYear, withIdempotency, IDEMPOTENCY_KEY_SCHEMA, ISO_DATE_SCHEMA, READ_ONLY_ANNOTATIONS, WRITE_ANNOTATIONS, } from "../utils.js";
|
|
4
|
+
import { PAGINATED_OUTPUT_SHAPE } from "../schemas.js";
|
|
5
|
+
const TRANSACTION_DETAIL_OUTPUT_SHAPE = {
|
|
6
|
+
id: z.string(),
|
|
7
|
+
transaction_date: z.string(),
|
|
8
|
+
description: z.string().nullable(),
|
|
9
|
+
document_number: z.string().nullable(),
|
|
10
|
+
journal_entries: z.array(z.record(z.string(), z.unknown())),
|
|
11
|
+
total_debit: z.number(),
|
|
12
|
+
total_credit: z.number(),
|
|
13
|
+
balanced: z.boolean(),
|
|
14
|
+
};
|
|
15
|
+
const LIST_TRANSACTIONS_ROW_SHAPE = {
|
|
16
|
+
id: z.string().uuid(),
|
|
17
|
+
transaction_date: z.string(),
|
|
18
|
+
description: z.string().nullable(),
|
|
19
|
+
document_number: z.string().nullable(),
|
|
20
|
+
entries: z.array(z.object({
|
|
21
|
+
account: z.string(),
|
|
22
|
+
debit: z.number(),
|
|
23
|
+
credit: z.number(),
|
|
24
|
+
})),
|
|
25
|
+
total_debit: z.number(),
|
|
26
|
+
total_credit: z.number(),
|
|
27
|
+
};
|
|
28
|
+
export function registerTransactionTools(server) {
|
|
29
|
+
// ---- READ: List Transactions ----
|
|
30
|
+
server.registerTool("kirjapro_list_transactions", {
|
|
31
|
+
description: "List accounting transactions (tositteet) with their journal entries for a fiscal year. Returns transaction date, description, and debit/credit entries with account details.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
fiscalYearId: z
|
|
34
|
+
.string()
|
|
35
|
+
.uuid()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
38
|
+
limit: z
|
|
39
|
+
.number()
|
|
40
|
+
.min(1)
|
|
41
|
+
.max(200)
|
|
42
|
+
.default(50)
|
|
43
|
+
.describe("Max results (default 50)"),
|
|
44
|
+
offset: z.number().min(0).default(0).describe("Skip first N results"),
|
|
45
|
+
},
|
|
46
|
+
outputSchema: {
|
|
47
|
+
results: z.array(z.object(LIST_TRANSACTIONS_ROW_SHAPE).passthrough()),
|
|
48
|
+
total_count: z.number(),
|
|
49
|
+
has_more: z.boolean(),
|
|
50
|
+
next_offset: z.number(),
|
|
51
|
+
},
|
|
52
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
53
|
+
}, async (params) => {
|
|
54
|
+
const supabase = getSupabase();
|
|
55
|
+
const companyId = getCompanyId();
|
|
56
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
57
|
+
let fiscalYearId = params.fiscalYearId;
|
|
58
|
+
if (!fiscalYearId) {
|
|
59
|
+
const resolved = await resolveOpenFiscalYear(supabase, companyId);
|
|
60
|
+
if ("isError" in resolved)
|
|
61
|
+
return resolved;
|
|
62
|
+
fiscalYearId = resolved.id;
|
|
63
|
+
}
|
|
64
|
+
const { data, count, error } = await supabase
|
|
65
|
+
.from("transactions")
|
|
66
|
+
.select(`
|
|
67
|
+
id, transaction_date, description, document_number, created_at,
|
|
68
|
+
journal_entries (
|
|
69
|
+
id, account_id, debit_amount, credit_amount,
|
|
70
|
+
accounts ( account_number, name )
|
|
71
|
+
)
|
|
72
|
+
`, { count: "exact" })
|
|
73
|
+
.eq("company_id", companyId)
|
|
74
|
+
.eq("fiscal_year_id", fiscalYearId)
|
|
75
|
+
.order("transaction_date", { ascending: false })
|
|
76
|
+
.order("id", { ascending: false })
|
|
77
|
+
.range(offset, offset + limit - 1);
|
|
78
|
+
if (error) {
|
|
79
|
+
return toErrorContent(sanitizeDbError("Failed to list transactions", error));
|
|
80
|
+
}
|
|
81
|
+
const transactions = (data || []).map((t) => ({
|
|
82
|
+
id: t.id,
|
|
83
|
+
transaction_date: t.transaction_date,
|
|
84
|
+
description: t.description,
|
|
85
|
+
document_number: t.document_number,
|
|
86
|
+
entries: (t.journal_entries || []).map((e) => ({
|
|
87
|
+
account: `${e.accounts?.account_number} ${e.accounts?.name}`,
|
|
88
|
+
debit: e.debit_amount || 0,
|
|
89
|
+
credit: e.credit_amount || 0,
|
|
90
|
+
})),
|
|
91
|
+
total_debit: roundCents((t.journal_entries || []).reduce((s, e) => s + (e.debit_amount || 0), 0)),
|
|
92
|
+
total_credit: roundCents((t.journal_entries || []).reduce((s, e) => s + (e.credit_amount || 0), 0)),
|
|
93
|
+
}));
|
|
94
|
+
return toResult(paginate(transactions, count || 0, offset, limit));
|
|
95
|
+
});
|
|
96
|
+
// ---- READ: Search Transactions ----
|
|
97
|
+
server.registerTool("kirjapro_search_transactions", {
|
|
98
|
+
description: "Search transactions by description, date range, or amount range. Useful for finding specific entries.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
query: z.string().optional().describe("Text search in description"),
|
|
101
|
+
dateFrom: ISO_DATE_SCHEMA
|
|
102
|
+
.optional()
|
|
103
|
+
.describe("Start date (YYYY-MM-DD)"),
|
|
104
|
+
dateTo: ISO_DATE_SCHEMA
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("End date (YYYY-MM-DD)"),
|
|
107
|
+
minAmount: z
|
|
108
|
+
.number()
|
|
109
|
+
.min(0)
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Minimum total debit/credit amount"),
|
|
112
|
+
maxAmount: z
|
|
113
|
+
.number()
|
|
114
|
+
.min(0)
|
|
115
|
+
.optional()
|
|
116
|
+
.describe("Maximum total debit/credit amount"),
|
|
117
|
+
limit: z
|
|
118
|
+
.number()
|
|
119
|
+
.min(1)
|
|
120
|
+
.max(200)
|
|
121
|
+
.default(50)
|
|
122
|
+
.describe("Max results (default 50)"),
|
|
123
|
+
offset: z.number().min(0).default(0).describe("Skip first N results"),
|
|
124
|
+
},
|
|
125
|
+
outputSchema: PAGINATED_OUTPUT_SHAPE,
|
|
126
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
127
|
+
}, async (params) => {
|
|
128
|
+
const supabase = getSupabase();
|
|
129
|
+
const companyId = getCompanyId();
|
|
130
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
131
|
+
let query = supabase
|
|
132
|
+
.from("transactions")
|
|
133
|
+
.select(`
|
|
134
|
+
id, transaction_date, description, document_number, created_at,
|
|
135
|
+
journal_entries (
|
|
136
|
+
id, debit_amount, credit_amount,
|
|
137
|
+
accounts ( account_number, name )
|
|
138
|
+
)
|
|
139
|
+
`, { count: "exact" })
|
|
140
|
+
.eq("company_id", companyId)
|
|
141
|
+
.order("transaction_date", { ascending: false })
|
|
142
|
+
.order("id", { ascending: false });
|
|
143
|
+
if (params.query) {
|
|
144
|
+
query = query.ilike("description", `%${escapeLike(params.query)}%`);
|
|
145
|
+
}
|
|
146
|
+
if (params.dateFrom) {
|
|
147
|
+
query = query.gte("transaction_date", params.dateFrom);
|
|
148
|
+
}
|
|
149
|
+
if (params.dateTo) {
|
|
150
|
+
query = query.lte("transaction_date", params.dateTo);
|
|
151
|
+
}
|
|
152
|
+
const hasAmountFilter = params.minAmount !== undefined || params.maxAmount !== undefined;
|
|
153
|
+
// When amount filters are active, we must fetch a larger window because
|
|
154
|
+
// PostgREST can't filter on aggregated journal entry totals server-side.
|
|
155
|
+
// We cap at 1000 rows to prevent unbounded memory usage.
|
|
156
|
+
// Future improvement: create an RPC that aggregates and filters in Postgres.
|
|
157
|
+
const AMOUNT_FILTER_CAP = 1000;
|
|
158
|
+
if (hasAmountFilter) {
|
|
159
|
+
query = query.range(0, AMOUNT_FILTER_CAP - 1);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
query = query.range(offset, offset + limit - 1);
|
|
163
|
+
}
|
|
164
|
+
const { data, count, error } = await query;
|
|
165
|
+
if (error) {
|
|
166
|
+
return toErrorContent("Failed to search transactions. Check your filters and try again.");
|
|
167
|
+
}
|
|
168
|
+
let results = (data || []).map((t) => {
|
|
169
|
+
const totalDebit = roundCents((t.journal_entries || []).reduce((s, e) => s + (e.debit_amount || 0), 0));
|
|
170
|
+
const totalCredit = roundCents((t.journal_entries || []).reduce((s, e) => s + (e.credit_amount || 0), 0));
|
|
171
|
+
return {
|
|
172
|
+
id: t.id,
|
|
173
|
+
transaction_date: t.transaction_date,
|
|
174
|
+
description: t.description,
|
|
175
|
+
document_number: t.document_number,
|
|
176
|
+
entries: (t.journal_entries || []).map((e) => ({
|
|
177
|
+
account: `${e.accounts?.account_number} ${e.accounts?.name}`,
|
|
178
|
+
debit: e.debit_amount || 0,
|
|
179
|
+
credit: e.credit_amount || 0,
|
|
180
|
+
})),
|
|
181
|
+
total_debit: totalDebit,
|
|
182
|
+
total_credit: totalCredit,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
if (hasAmountFilter) {
|
|
186
|
+
// Client-side amount filtering (PostgREST can't filter on aggregated values).
|
|
187
|
+
if (params.minAmount !== undefined) {
|
|
188
|
+
results = results.filter((t) => t.total_debit >= params.minAmount);
|
|
189
|
+
}
|
|
190
|
+
if (params.maxAmount !== undefined) {
|
|
191
|
+
results = results.filter((t) => t.total_debit <= params.maxAmount);
|
|
192
|
+
}
|
|
193
|
+
const filteredTotal = results.length;
|
|
194
|
+
const paginatedResults = results.slice(offset, offset + limit);
|
|
195
|
+
const cappedWarning = (count || 0) >= AMOUNT_FILTER_CAP
|
|
196
|
+
? " (results capped at 1000 rows — narrow your date range for complete results)"
|
|
197
|
+
: "";
|
|
198
|
+
return toResult({
|
|
199
|
+
...paginate(paginatedResults, filteredTotal, offset, limit),
|
|
200
|
+
amount_filter_note: `Filtered client-side from ${count || 0} transactions${cappedWarning}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return toResult(paginate(results, count || 0, offset, limit));
|
|
204
|
+
});
|
|
205
|
+
// ---- WRITE: Create Transaction ----
|
|
206
|
+
server.registerTool("kirjapro_create_transaction", {
|
|
207
|
+
description: 'Create a transaction (tosite) with journal entries. Entries must balance (debits = credits). Accepts account numbers resolved to UUIDs server-side. Prefer kirjapro_create_from_template for standard patterns.',
|
|
208
|
+
inputSchema: {
|
|
209
|
+
fiscalYearId: z
|
|
210
|
+
.string()
|
|
211
|
+
.uuid()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
214
|
+
transactionDate: ISO_DATE_SCHEMA
|
|
215
|
+
.describe("Transaction date (YYYY-MM-DD)"),
|
|
216
|
+
description: z
|
|
217
|
+
.string()
|
|
218
|
+
.min(1)
|
|
219
|
+
.max(500)
|
|
220
|
+
.describe("Transaction description"),
|
|
221
|
+
documentNumber: z.string().optional().describe("Document/receipt number"),
|
|
222
|
+
entries: z
|
|
223
|
+
.array(z.object({
|
|
224
|
+
accountNumber: z
|
|
225
|
+
.string()
|
|
226
|
+
.describe('Account number (e.g. "4000") or UUID'),
|
|
227
|
+
debitAmount: z
|
|
228
|
+
.number()
|
|
229
|
+
.finite()
|
|
230
|
+
.min(0)
|
|
231
|
+
.max(1_000_000_000)
|
|
232
|
+
.default(0)
|
|
233
|
+
.describe("Debit amount in EUR"),
|
|
234
|
+
creditAmount: z
|
|
235
|
+
.number()
|
|
236
|
+
.finite()
|
|
237
|
+
.min(0)
|
|
238
|
+
.max(1_000_000_000)
|
|
239
|
+
.default(0)
|
|
240
|
+
.describe("Credit amount in EUR"),
|
|
241
|
+
}))
|
|
242
|
+
.min(2)
|
|
243
|
+
.describe("Journal entries (min 2, must balance)"),
|
|
244
|
+
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
245
|
+
},
|
|
246
|
+
annotations: WRITE_ANNOTATIONS,
|
|
247
|
+
}, async (params) => {
|
|
248
|
+
const supabase = getSupabase();
|
|
249
|
+
const companyId = getCompanyId();
|
|
250
|
+
return withIdempotency(supabase, companyId, "kirjapro_create_transaction", params.idempotencyKey, params, async () => {
|
|
251
|
+
let fiscalYearId = params.fiscalYearId;
|
|
252
|
+
if (!fiscalYearId) {
|
|
253
|
+
const resolved = await resolveOpenFiscalYear(supabase, companyId);
|
|
254
|
+
if ("isError" in resolved)
|
|
255
|
+
return resolved;
|
|
256
|
+
fiscalYearId = resolved.id;
|
|
257
|
+
}
|
|
258
|
+
// Verify fiscal year is open
|
|
259
|
+
const { data: fy, error: fyError } = await supabase
|
|
260
|
+
.from("fiscal_years")
|
|
261
|
+
.select("id, is_closed, start_date, end_date")
|
|
262
|
+
.eq("id", fiscalYearId)
|
|
263
|
+
.eq("company_id", companyId)
|
|
264
|
+
.single();
|
|
265
|
+
if (fyError || !fy) {
|
|
266
|
+
return toErrorContent(`Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard with a valid fiscal year ID, or read the kirjapro://fiscal-years resource to list available fiscal years.`);
|
|
267
|
+
}
|
|
268
|
+
if (fy.is_closed) {
|
|
269
|
+
return toErrorContent(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}). ` +
|
|
270
|
+
`Use an open fiscal year instead.`);
|
|
271
|
+
}
|
|
272
|
+
// Validate date is within fiscal year
|
|
273
|
+
if (params.transactionDate < fy.start_date ||
|
|
274
|
+
params.transactionDate > fy.end_date) {
|
|
275
|
+
return toErrorContent(`Transaction date ${params.transactionDate} is outside fiscal year range ` +
|
|
276
|
+
`(${fy.start_date} to ${fy.end_date}).`);
|
|
277
|
+
}
|
|
278
|
+
// Resolve account numbers or UUIDs to UUIDs
|
|
279
|
+
const accountIdentifiers = params.entries.map((e) => e.accountNumber);
|
|
280
|
+
const uuidIdentifiers = [
|
|
281
|
+
...new Set(accountIdentifiers.filter((identifier) => isUuid(identifier))),
|
|
282
|
+
];
|
|
283
|
+
const numberIdentifiers = [
|
|
284
|
+
...new Set(accountIdentifiers.filter((identifier) => !isUuid(identifier))),
|
|
285
|
+
];
|
|
286
|
+
const [accountsByNumber, accountsById] = await Promise.all([
|
|
287
|
+
numberIdentifiers.length > 0
|
|
288
|
+
? supabase
|
|
289
|
+
.from("accounts")
|
|
290
|
+
.select("id, account_number")
|
|
291
|
+
.eq("company_id", companyId)
|
|
292
|
+
.in("account_number", numberIdentifiers)
|
|
293
|
+
: Promise.resolve({ data: [], error: null }),
|
|
294
|
+
uuidIdentifiers.length > 0
|
|
295
|
+
? supabase
|
|
296
|
+
.from("accounts")
|
|
297
|
+
.select("id, account_number")
|
|
298
|
+
.eq("company_id", companyId)
|
|
299
|
+
.in("id", uuidIdentifiers)
|
|
300
|
+
: Promise.resolve({ data: [], error: null }),
|
|
301
|
+
]);
|
|
302
|
+
const accError = accountsByNumber.error || accountsById.error;
|
|
303
|
+
if (accError) {
|
|
304
|
+
return toErrorContent(sanitizeDbError("Failed to resolve accounts", accError));
|
|
305
|
+
}
|
|
306
|
+
const accounts = [
|
|
307
|
+
...(accountsByNumber.data || []),
|
|
308
|
+
...(accountsById.data || []),
|
|
309
|
+
];
|
|
310
|
+
const accountMap = new Map();
|
|
311
|
+
for (const account of accounts) {
|
|
312
|
+
accountMap.set(account.account_number, account.id);
|
|
313
|
+
accountMap.set(account.id, account.id);
|
|
314
|
+
}
|
|
315
|
+
const missingAccounts = accountIdentifiers.filter((identifier) => !accountMap.has(identifier));
|
|
316
|
+
if (missingAccounts.length > 0) {
|
|
317
|
+
return toErrorContent(`Accounts not found: ${missingAccounts.join(", ")}. ` +
|
|
318
|
+
`Use kirjapro_list_accounts to see available accounts.`);
|
|
319
|
+
}
|
|
320
|
+
// Validate balance
|
|
321
|
+
const totalDebit = roundCents(params.entries.reduce((s, e) => s + e.debitAmount, 0));
|
|
322
|
+
const totalCredit = roundCents(params.entries.reduce((s, e) => s + e.creditAmount, 0));
|
|
323
|
+
if (totalDebit !== totalCredit) {
|
|
324
|
+
return toErrorContent(`Transaction is unbalanced: total debits (${totalDebit.toFixed(2)}) != total credits (${totalCredit.toFixed(2)}). ` +
|
|
325
|
+
`Difference: ${Math.abs(totalDebit - totalCredit).toFixed(2)} EUR. ` +
|
|
326
|
+
`Add a ${totalDebit > totalCredit ? "credit" : "debit"} entry of ${Math.abs(totalDebit - totalCredit).toFixed(2)} to balance.`);
|
|
327
|
+
}
|
|
328
|
+
const journalEntries = params.entries.map((e) => ({
|
|
329
|
+
account_id: accountMap.get(e.accountNumber),
|
|
330
|
+
debit_amount: e.debitAmount,
|
|
331
|
+
credit_amount: e.creditAmount,
|
|
332
|
+
}));
|
|
333
|
+
const { data: transactionId, error: txError } = await supabase.rpc("create_transaction_with_entries", {
|
|
334
|
+
p_company_id: companyId,
|
|
335
|
+
p_fiscal_year_id: fiscalYearId,
|
|
336
|
+
p_transaction_date: params.transactionDate,
|
|
337
|
+
p_description: params.description,
|
|
338
|
+
p_document_number: params.documentNumber || "",
|
|
339
|
+
p_entries: journalEntries,
|
|
340
|
+
});
|
|
341
|
+
if (txError || !transactionId) {
|
|
342
|
+
return toErrorContent(txError ? sanitizeDbError("Failed to create transaction", txError) : "Failed to create transaction: no ID returned.");
|
|
343
|
+
}
|
|
344
|
+
// Fetch complete transaction with entries
|
|
345
|
+
const { data: complete } = await supabase
|
|
346
|
+
.from("transactions")
|
|
347
|
+
.select(`
|
|
348
|
+
id, transaction_date, description, document_number, created_at,
|
|
349
|
+
journal_entries (
|
|
350
|
+
id, debit_amount, credit_amount,
|
|
351
|
+
accounts ( account_number, name )
|
|
352
|
+
)
|
|
353
|
+
`)
|
|
354
|
+
.eq("id", transactionId)
|
|
355
|
+
.single();
|
|
356
|
+
return toResult({
|
|
357
|
+
message: "Transaction created successfully",
|
|
358
|
+
transaction: complete,
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
// ---- WRITE: Create Transactions Batch ----
|
|
363
|
+
server.registerTool("kirjapro_create_transactions_batch", {
|
|
364
|
+
description: 'Create multiple transactions in one call (up to 50). Default mode "atomic" rolls back ALL on any failure; "best_effort" collects per-row errors. Prefer kirjapro_create_from_template_batch for standard patterns.',
|
|
365
|
+
inputSchema: {
|
|
366
|
+
fiscalYearId: z
|
|
367
|
+
.string()
|
|
368
|
+
.uuid()
|
|
369
|
+
.optional()
|
|
370
|
+
.describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
371
|
+
mode: z
|
|
372
|
+
.enum(["atomic", "best_effort"])
|
|
373
|
+
.default("atomic")
|
|
374
|
+
.describe('Batch mode. "atomic" (default): all-or-nothing, rolls back on any row failure. "best_effort": commits what it can, returns per-row errors.'),
|
|
375
|
+
transactions: z
|
|
376
|
+
.array(z.object({
|
|
377
|
+
transactionDate: ISO_DATE_SCHEMA
|
|
378
|
+
.describe("Date (YYYY-MM-DD)"),
|
|
379
|
+
description: z.string().min(1).max(500),
|
|
380
|
+
documentNumber: z.string().optional(),
|
|
381
|
+
entries: z
|
|
382
|
+
.array(z.object({
|
|
383
|
+
accountNumber: z
|
|
384
|
+
.string()
|
|
385
|
+
.describe('Account number (e.g. "4000")'),
|
|
386
|
+
debitAmount: z
|
|
387
|
+
.number()
|
|
388
|
+
.finite()
|
|
389
|
+
.min(0)
|
|
390
|
+
.max(1_000_000_000)
|
|
391
|
+
.default(0),
|
|
392
|
+
creditAmount: z
|
|
393
|
+
.number()
|
|
394
|
+
.finite()
|
|
395
|
+
.min(0)
|
|
396
|
+
.max(1_000_000_000)
|
|
397
|
+
.default(0),
|
|
398
|
+
}))
|
|
399
|
+
.min(2),
|
|
400
|
+
}))
|
|
401
|
+
.min(1)
|
|
402
|
+
.max(50)
|
|
403
|
+
.describe("Array of transactions to create (max 50)"),
|
|
404
|
+
idempotencyKey: IDEMPOTENCY_KEY_SCHEMA,
|
|
405
|
+
},
|
|
406
|
+
annotations: WRITE_ANNOTATIONS,
|
|
407
|
+
}, async (params) => {
|
|
408
|
+
const supabase = getSupabase();
|
|
409
|
+
const companyId = getCompanyId();
|
|
410
|
+
return withIdempotency(supabase, companyId, "kirjapro_create_transactions_batch", params.idempotencyKey, params, async () => {
|
|
411
|
+
let fiscalYearId = params.fiscalYearId;
|
|
412
|
+
if (!fiscalYearId) {
|
|
413
|
+
const resolved = await resolveOpenFiscalYear(supabase, companyId);
|
|
414
|
+
if ("isError" in resolved)
|
|
415
|
+
return resolved;
|
|
416
|
+
fiscalYearId = resolved.id;
|
|
417
|
+
}
|
|
418
|
+
const { data: fy, error: fyError } = await supabase
|
|
419
|
+
.from("fiscal_years")
|
|
420
|
+
.select("id, is_closed, start_date, end_date")
|
|
421
|
+
.eq("id", fiscalYearId)
|
|
422
|
+
.eq("company_id", companyId)
|
|
423
|
+
.single();
|
|
424
|
+
if (fyError || !fy) {
|
|
425
|
+
return toErrorContent(`Fiscal year ${fiscalYearId} not found.`);
|
|
426
|
+
}
|
|
427
|
+
if (fy.is_closed) {
|
|
428
|
+
return toErrorContent(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`);
|
|
429
|
+
}
|
|
430
|
+
// Resolve account numbers -> UUIDs
|
|
431
|
+
const allAccountNumbers = [
|
|
432
|
+
...new Set(params.transactions.flatMap((t) => t.entries.map((e) => e.accountNumber))),
|
|
433
|
+
];
|
|
434
|
+
const uuidIdentifiers = allAccountNumbers.filter((id) => isUuid(id));
|
|
435
|
+
const numberIdentifiers = allAccountNumbers.filter((id) => !isUuid(id));
|
|
436
|
+
const [accountsByNumber, accountsById] = await Promise.all([
|
|
437
|
+
numberIdentifiers.length > 0
|
|
438
|
+
? supabase
|
|
439
|
+
.from("accounts")
|
|
440
|
+
.select("id, account_number")
|
|
441
|
+
.eq("company_id", companyId)
|
|
442
|
+
.in("account_number", numberIdentifiers)
|
|
443
|
+
: Promise.resolve({ data: [], error: null }),
|
|
444
|
+
uuidIdentifiers.length > 0
|
|
445
|
+
? supabase
|
|
446
|
+
.from("accounts")
|
|
447
|
+
.select("id, account_number")
|
|
448
|
+
.eq("company_id", companyId)
|
|
449
|
+
.in("id", uuidIdentifiers)
|
|
450
|
+
: Promise.resolve({ data: [], error: null }),
|
|
451
|
+
]);
|
|
452
|
+
const accError = accountsByNumber.error || accountsById.error;
|
|
453
|
+
if (accError) {
|
|
454
|
+
return toErrorContent(sanitizeDbError("Failed to resolve accounts", accError));
|
|
455
|
+
}
|
|
456
|
+
const accounts = [
|
|
457
|
+
...(accountsByNumber.data || []),
|
|
458
|
+
...(accountsById.data || []),
|
|
459
|
+
];
|
|
460
|
+
const accountMap = new Map();
|
|
461
|
+
for (const account of accounts) {
|
|
462
|
+
accountMap.set(account.account_number, account.id);
|
|
463
|
+
accountMap.set(account.id, account.id);
|
|
464
|
+
}
|
|
465
|
+
// Pre-validate: resolve entries, check balance and accounts
|
|
466
|
+
const preValidated = [];
|
|
467
|
+
const preFailed = [];
|
|
468
|
+
for (let i = 0; i < params.transactions.length; i++) {
|
|
469
|
+
const tx = params.transactions[i];
|
|
470
|
+
if (tx.transactionDate < fy.start_date ||
|
|
471
|
+
tx.transactionDate > fy.end_date) {
|
|
472
|
+
preFailed.push({
|
|
473
|
+
index: i,
|
|
474
|
+
error: `Date ${tx.transactionDate} outside fiscal year range.`,
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const missing = tx.entries.filter((e) => !accountMap.has(e.accountNumber));
|
|
479
|
+
if (missing.length > 0) {
|
|
480
|
+
preFailed.push({
|
|
481
|
+
index: i,
|
|
482
|
+
error: `Unknown accounts: ${missing.map((m) => m.accountNumber).join(", ")}`,
|
|
483
|
+
});
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const totalDebit = roundCents(tx.entries.reduce((s, e) => s + e.debitAmount, 0));
|
|
487
|
+
const totalCredit = roundCents(tx.entries.reduce((s, e) => s + e.creditAmount, 0));
|
|
488
|
+
if (totalDebit !== totalCredit) {
|
|
489
|
+
preFailed.push({
|
|
490
|
+
index: i,
|
|
491
|
+
error: `Unbalanced: debits ${totalDebit} != credits ${totalCredit}`,
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
preValidated.push({
|
|
496
|
+
index: i,
|
|
497
|
+
transaction_date: tx.transactionDate,
|
|
498
|
+
description: tx.description,
|
|
499
|
+
document_number: tx.documentNumber || "",
|
|
500
|
+
entries: tx.entries.map((e) => ({
|
|
501
|
+
account_id: accountMap.get(e.accountNumber),
|
|
502
|
+
debit_amount: e.debitAmount,
|
|
503
|
+
credit_amount: e.creditAmount,
|
|
504
|
+
})),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
// In atomic mode, abort immediately if any row failed pre-validation
|
|
508
|
+
if (params.mode === "atomic" && preFailed.length > 0) {
|
|
509
|
+
return toErrorContent(`Atomic batch aborted: ${preFailed.length} transaction(s) failed validation. ` +
|
|
510
|
+
`Fix all errors or use mode "best_effort" for partial commits.\n` +
|
|
511
|
+
preFailed.map((f) => ` [${f.index}] ${f.error}`).join("\n"));
|
|
512
|
+
}
|
|
513
|
+
if (preValidated.length === 0) {
|
|
514
|
+
return toResult({
|
|
515
|
+
mode: params.mode,
|
|
516
|
+
created_count: 0,
|
|
517
|
+
failed_count: preFailed.length,
|
|
518
|
+
created: [],
|
|
519
|
+
failed: preFailed,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// Build JSONB array for the RPC (strip the index field)
|
|
523
|
+
const rpcTransactions = preValidated.map((v) => ({
|
|
524
|
+
transaction_date: v.transaction_date,
|
|
525
|
+
description: v.description,
|
|
526
|
+
document_number: v.document_number,
|
|
527
|
+
entries: v.entries,
|
|
528
|
+
}));
|
|
529
|
+
const { data: rpcResult, error: rpcError } = await supabase.rpc("create_transactions_batch_atomic", {
|
|
530
|
+
p_company_id: companyId,
|
|
531
|
+
p_fiscal_year_id: fiscalYearId,
|
|
532
|
+
p_transactions: rpcTransactions,
|
|
533
|
+
p_mode: params.mode,
|
|
534
|
+
});
|
|
535
|
+
if (rpcError) {
|
|
536
|
+
return toErrorContent(sanitizeDbError("Batch create failed", rpcError));
|
|
537
|
+
}
|
|
538
|
+
const result = rpcResult;
|
|
539
|
+
// Remap RPC indices back to original input indices
|
|
540
|
+
const created = result.created.map((c) => ({
|
|
541
|
+
id: c.id,
|
|
542
|
+
description: c.description,
|
|
543
|
+
date: c.date,
|
|
544
|
+
index: preValidated[c.index].index,
|
|
545
|
+
}));
|
|
546
|
+
const rpcFailed = result.failed.map((f) => ({
|
|
547
|
+
index: preValidated[f.index].index,
|
|
548
|
+
error: sanitizeDbError("Failed to create transaction", {
|
|
549
|
+
message: f.error,
|
|
550
|
+
}),
|
|
551
|
+
}));
|
|
552
|
+
return toResult({
|
|
553
|
+
mode: params.mode,
|
|
554
|
+
created_count: created.length,
|
|
555
|
+
failed_count: preFailed.length + rpcFailed.length,
|
|
556
|
+
created,
|
|
557
|
+
failed: [...preFailed, ...rpcFailed],
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
// ---- READ: Get Transaction Detail ----
|
|
562
|
+
server.registerTool("kirjapro_get_transaction", {
|
|
563
|
+
description: "Get a single transaction with all journal entries and account details. Shows total debits and credits.",
|
|
564
|
+
inputSchema: {
|
|
565
|
+
transactionId: z.string().uuid().describe("Transaction UUID"),
|
|
566
|
+
},
|
|
567
|
+
outputSchema: TRANSACTION_DETAIL_OUTPUT_SHAPE,
|
|
568
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
569
|
+
}, async (params) => {
|
|
570
|
+
const supabase = getSupabase();
|
|
571
|
+
const companyId = getCompanyId();
|
|
572
|
+
const { data: transaction, error } = await supabase
|
|
573
|
+
.from("transactions")
|
|
574
|
+
.select(`
|
|
575
|
+
id, transaction_date, description, document_number, created_at,
|
|
576
|
+
journal_entries (
|
|
577
|
+
id, account_id, debit_amount, credit_amount, description,
|
|
578
|
+
accounts ( account_number, name, account_type )
|
|
579
|
+
)
|
|
580
|
+
`)
|
|
581
|
+
.eq("id", params.transactionId)
|
|
582
|
+
.eq("company_id", companyId)
|
|
583
|
+
.single();
|
|
584
|
+
if (error || !transaction) {
|
|
585
|
+
return toErrorContent(`Transaction ${params.transactionId} not found. Use kirjapro_list_transactions to find transaction IDs.`);
|
|
586
|
+
}
|
|
587
|
+
const entries = transaction.journal_entries || [];
|
|
588
|
+
const totalDebit = roundCents(entries.reduce((s, e) => s + (e.debit_amount || 0), 0));
|
|
589
|
+
const totalCredit = roundCents(entries.reduce((s, e) => s + (e.credit_amount || 0), 0));
|
|
590
|
+
return toResult({
|
|
591
|
+
...transaction,
|
|
592
|
+
total_debit: totalDebit,
|
|
593
|
+
total_credit: totalCredit,
|
|
594
|
+
balanced: Math.abs(totalDebit - totalCredit) < 0.01,
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validate Finnish Y-tunnus (business ID).
|
|
4
|
+
* Format: NNNNNNN-C, weights [7, 9, 10, 5, 8, 4, 2].
|
|
5
|
+
*/
|
|
6
|
+
declare function validateFinnishBusinessId(input: string): {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
error?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Validate Finnish IBAN.
|
|
12
|
+
* Format: FI + 16 digits, mod-97 check.
|
|
13
|
+
*/
|
|
14
|
+
declare function validateFinnishIBAN(input: string): {
|
|
15
|
+
valid: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Generate Finnish reference number (viitenumero) with 7-3-1 check digit.
|
|
20
|
+
*/
|
|
21
|
+
declare function generateReferenceNumber(baseNumber: string): {
|
|
22
|
+
reference: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function registerValidationTools(server: McpServer): void;
|
|
26
|
+
export { validateFinnishBusinessId, validateFinnishIBAN, generateReferenceNumber };
|