@kirjapro/mcp-server 0.2.0 → 0.3.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/README.md +2 -0
- package/dist/index.js +986 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
MCP server for Finnish accounting. Exposes Kirjapro bookkeeping, invoicing, and reporting tools to Claude Code, Claude Desktop, and any MCP client.
|
|
4
4
|
|
|
5
|
+
> **Full documentation:** [docs/mcp-server.md](../docs/mcp-server.md) — complete tool reference with parameters, usage examples, error guide, and multi-client setup.
|
|
6
|
+
|
|
5
7
|
## Requirements
|
|
6
8
|
|
|
7
9
|
- Node.js 20+
|
package/dist/index.js
CHANGED
|
@@ -20891,6 +20891,7 @@ function scheduleTokenRefresh(apiKey, expiresIn) {
|
|
|
20891
20891
|
console.error("[kirjapro-mcp] Refreshing API key token...");
|
|
20892
20892
|
const auth = await authenticateWithApiKey(apiKey);
|
|
20893
20893
|
supabase = createClientFromAuth(auth);
|
|
20894
|
+
companyId = auth.company_id;
|
|
20894
20895
|
console.error("[kirjapro-mcp] Token refreshed successfully");
|
|
20895
20896
|
} catch (error2) {
|
|
20896
20897
|
console.error("[kirjapro-mcp] Token refresh failed:", error2.message);
|
|
@@ -20925,7 +20926,8 @@ async function initLegacyAuth() {
|
|
|
20925
20926
|
if (!kirjaproCompanyId) {
|
|
20926
20927
|
throw new Error("KIRJAPRO_COMPANY_ID environment variable is required.");
|
|
20927
20928
|
}
|
|
20928
|
-
|
|
20929
|
+
const anonKey = process.env.SUPABASE_ANON_KEY || supabaseAccessToken;
|
|
20930
|
+
supabase = createClient(supabaseUrl, anonKey, {
|
|
20929
20931
|
auth: {
|
|
20930
20932
|
persistSession: false,
|
|
20931
20933
|
autoRefreshToken: false
|
|
@@ -21018,7 +21020,7 @@ function toTextContent(data) {
|
|
|
21018
21020
|
content: [
|
|
21019
21021
|
{
|
|
21020
21022
|
type: "text",
|
|
21021
|
-
text: JSON.stringify(data
|
|
21023
|
+
text: JSON.stringify(data)
|
|
21022
21024
|
}
|
|
21023
21025
|
]
|
|
21024
21026
|
};
|
|
@@ -21035,6 +21037,23 @@ function roundCents(amount) {
|
|
|
21035
21037
|
function escapeLike(input) {
|
|
21036
21038
|
return input.replace(/[%_\\]/g, "\\$&");
|
|
21037
21039
|
}
|
|
21040
|
+
async function resolveOpenFiscalYear(supabase2, companyId2) {
|
|
21041
|
+
const { data, error: error2 } = await supabase2.from("fiscal_years").select("id").eq("company_id", companyId2).eq("is_closed", false);
|
|
21042
|
+
if (error2) {
|
|
21043
|
+
return toErrorContent(`Failed to query fiscal years: ${error2.message}`);
|
|
21044
|
+
}
|
|
21045
|
+
if (!data || data.length === 0) {
|
|
21046
|
+
return toErrorContent(
|
|
21047
|
+
"No open fiscal year found. Create one or specify fiscalYearId explicitly."
|
|
21048
|
+
);
|
|
21049
|
+
}
|
|
21050
|
+
if (data.length > 1) {
|
|
21051
|
+
return toErrorContent(
|
|
21052
|
+
`Multiple open fiscal years found (${data.length}). Specify fiscalYearId explicitly. Read kirjapro://fiscal-years to list them.`
|
|
21053
|
+
);
|
|
21054
|
+
}
|
|
21055
|
+
return { id: data[0].id };
|
|
21056
|
+
}
|
|
21038
21057
|
|
|
21039
21058
|
// src/tools/accounts.ts
|
|
21040
21059
|
function registerAccountTools(server2) {
|
|
@@ -21046,13 +21065,15 @@ function registerAccountTools(server2) {
|
|
|
21046
21065
|
offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
|
|
21047
21066
|
accountType: external_exports.enum(["asset", "liability", "equity", "revenue", "expense"]).optional().describe("Filter by account type"),
|
|
21048
21067
|
search: external_exports.string().optional().describe("Search by account name or number"),
|
|
21049
|
-
activeOnly: external_exports.boolean().default(true).describe("Only show active accounts")
|
|
21068
|
+
activeOnly: external_exports.boolean().default(true).describe("Only show active accounts"),
|
|
21069
|
+
includeIds: external_exports.boolean().default(false).describe("Include UUID id and parent_id fields (rarely needed)")
|
|
21050
21070
|
},
|
|
21051
21071
|
async (params) => {
|
|
21052
21072
|
const supabase2 = getSupabase();
|
|
21053
21073
|
const companyId2 = getCompanyId();
|
|
21054
21074
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21055
|
-
|
|
21075
|
+
const selectCols = params.includeIds ? "id, account_number, name, account_type, parent_id, is_active" : "account_number, name, account_type";
|
|
21076
|
+
let query = supabase2.from("accounts").select(selectCols, { count: "exact" }).eq("company_id", companyId2).order("account_number");
|
|
21056
21077
|
if (params.activeOnly) {
|
|
21057
21078
|
query = query.eq("is_active", true);
|
|
21058
21079
|
}
|
|
@@ -21079,7 +21100,7 @@ function registerTransactionTools(server2) {
|
|
|
21079
21100
|
"kirjapro_list_transactions",
|
|
21080
21101
|
"List accounting transactions (tositteet) with their journal entries for a fiscal year. Returns transaction date, description, and debit/credit entries with account details.",
|
|
21081
21102
|
{
|
|
21082
|
-
fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID
|
|
21103
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21083
21104
|
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
21084
21105
|
offset: external_exports.number().min(0).default(0).describe("Skip first N results")
|
|
21085
21106
|
},
|
|
@@ -21087,18 +21108,32 @@ function registerTransactionTools(server2) {
|
|
|
21087
21108
|
const supabase2 = getSupabase();
|
|
21088
21109
|
const companyId2 = getCompanyId();
|
|
21089
21110
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21111
|
+
let fiscalYearId = params.fiscalYearId;
|
|
21112
|
+
if (!fiscalYearId) {
|
|
21113
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
21114
|
+
if ("isError" in resolved) return resolved;
|
|
21115
|
+
fiscalYearId = resolved.id;
|
|
21116
|
+
}
|
|
21090
21117
|
const { data, count, error: error2 } = await supabase2.from("transactions").select(`
|
|
21091
21118
|
id, transaction_date, description, document_number, created_at,
|
|
21092
21119
|
journal_entries (
|
|
21093
21120
|
id, account_id, debit_amount, credit_amount,
|
|
21094
21121
|
accounts ( account_number, name )
|
|
21095
21122
|
)
|
|
21096
|
-
`, { count: "exact" }).eq("company_id", companyId2).eq("fiscal_year_id",
|
|
21123
|
+
`, { count: "exact" }).eq("company_id", companyId2).eq("fiscal_year_id", fiscalYearId).order("transaction_date", { ascending: false }).range(offset, offset + limit - 1);
|
|
21097
21124
|
if (error2) {
|
|
21098
21125
|
return toErrorContent(`Failed to list transactions: ${error2.message}`);
|
|
21099
21126
|
}
|
|
21100
21127
|
const transactions = (data || []).map((t) => ({
|
|
21101
|
-
|
|
21128
|
+
id: t.id,
|
|
21129
|
+
transaction_date: t.transaction_date,
|
|
21130
|
+
description: t.description,
|
|
21131
|
+
document_number: t.document_number,
|
|
21132
|
+
entries: (t.journal_entries || []).map((e) => ({
|
|
21133
|
+
account: `${e.accounts?.account_number} ${e.accounts?.name}`,
|
|
21134
|
+
debit: e.debit_amount || 0,
|
|
21135
|
+
credit: e.credit_amount || 0
|
|
21136
|
+
})),
|
|
21102
21137
|
total_debit: roundCents(
|
|
21103
21138
|
(t.journal_entries || []).reduce((s, e) => s + (e.debit_amount || 0), 0)
|
|
21104
21139
|
),
|
|
@@ -21153,7 +21188,19 @@ function registerTransactionTools(server2) {
|
|
|
21153
21188
|
const totalCredit = roundCents(
|
|
21154
21189
|
(t.journal_entries || []).reduce((s, e) => s + (e.credit_amount || 0), 0)
|
|
21155
21190
|
);
|
|
21156
|
-
return {
|
|
21191
|
+
return {
|
|
21192
|
+
id: t.id,
|
|
21193
|
+
transaction_date: t.transaction_date,
|
|
21194
|
+
description: t.description,
|
|
21195
|
+
document_number: t.document_number,
|
|
21196
|
+
entries: (t.journal_entries || []).map((e) => ({
|
|
21197
|
+
account: `${e.accounts?.account_number} ${e.accounts?.name}`,
|
|
21198
|
+
debit: e.debit_amount || 0,
|
|
21199
|
+
credit: e.credit_amount || 0
|
|
21200
|
+
})),
|
|
21201
|
+
total_debit: totalDebit,
|
|
21202
|
+
total_credit: totalCredit
|
|
21203
|
+
};
|
|
21157
21204
|
});
|
|
21158
21205
|
if (params.minAmount !== void 0) {
|
|
21159
21206
|
results = results.filter((t) => t.total_debit >= params.minAmount);
|
|
@@ -21172,9 +21219,16 @@ function registerTransactionTools(server2) {
|
|
|
21172
21219
|
);
|
|
21173
21220
|
server2.tool(
|
|
21174
21221
|
"kirjapro_create_transaction",
|
|
21175
|
-
|
|
21222
|
+
`Create a new accounting transaction (tosite) with journal entries. Entries must balance (total debits = total credits). Accepts account numbers (e.g. "4000") which are resolved to UUIDs server-side.
|
|
21223
|
+
Common accounts (YSE 2017):
|
|
21224
|
+
2310 Kassa | 2320 Pankkitili | 2110 Myyntisaamiset | 2140 ALV-saamiset
|
|
21225
|
+
4030 Ostovelat | 4050 ALV-velka | 4060 Ennakonpid\xE4tysvelka | 4080 TyEL-velka
|
|
21226
|
+
5010 Myynti 25,5% | 5020 Myynti 13,5% | 5030 Myynti 10% | 5040 Myynti 0%
|
|
21227
|
+
6010 Ostot | 6030 Ulkopuoliset palvelut | 7010 Palkat | 7020 El\xE4kekulut
|
|
21228
|
+
8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut
|
|
21229
|
+
Prefer kirjapro_create_from_template for standard patterns.`,
|
|
21176
21230
|
{
|
|
21177
|
-
fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID"),
|
|
21231
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21178
21232
|
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
21179
21233
|
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
21180
21234
|
documentNumber: external_exports.string().optional().describe("Document/receipt number"),
|
|
@@ -21187,10 +21241,16 @@ function registerTransactionTools(server2) {
|
|
|
21187
21241
|
async (params) => {
|
|
21188
21242
|
const supabase2 = getSupabase();
|
|
21189
21243
|
const companyId2 = getCompanyId();
|
|
21190
|
-
|
|
21244
|
+
let fiscalYearId = params.fiscalYearId;
|
|
21245
|
+
if (!fiscalYearId) {
|
|
21246
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
21247
|
+
if ("isError" in resolved) return resolved;
|
|
21248
|
+
fiscalYearId = resolved.id;
|
|
21249
|
+
}
|
|
21250
|
+
const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
21191
21251
|
if (fyError || !fy) {
|
|
21192
21252
|
return toErrorContent(
|
|
21193
|
-
`Fiscal year ${
|
|
21253
|
+
`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.`
|
|
21194
21254
|
);
|
|
21195
21255
|
}
|
|
21196
21256
|
if (fy.is_closed) {
|
|
@@ -21224,7 +21284,7 @@ function registerTransactionTools(server2) {
|
|
|
21224
21284
|
}
|
|
21225
21285
|
const { data: transaction, error: txError } = await supabase2.from("transactions").insert({
|
|
21226
21286
|
company_id: companyId2,
|
|
21227
|
-
fiscal_year_id:
|
|
21287
|
+
fiscal_year_id: fiscalYearId,
|
|
21228
21288
|
transaction_date: params.transactionDate,
|
|
21229
21289
|
description: params.description,
|
|
21230
21290
|
document_number: params.documentNumber || null
|
|
@@ -21257,9 +21317,16 @@ function registerTransactionTools(server2) {
|
|
|
21257
21317
|
);
|
|
21258
21318
|
server2.tool(
|
|
21259
21319
|
"kirjapro_create_transactions_batch",
|
|
21260
|
-
|
|
21320
|
+
`Create multiple accounting transactions in one call (up to 50). Ideal for bulk import from bank statements or receipts. Each transaction is validated independently \u2014 valid ones are created even if others fail.
|
|
21321
|
+
Common accounts (YSE 2017):
|
|
21322
|
+
2310 Kassa | 2320 Pankkitili | 2110 Myyntisaamiset | 2140 ALV-saamiset
|
|
21323
|
+
4030 Ostovelat | 4050 ALV-velka | 4060 Ennakonpid\xE4tysvelka | 4080 TyEL-velka
|
|
21324
|
+
5010 Myynti 25,5% | 5020 Myynti 13,5% | 5030 Myynti 10% | 5040 Myynti 0%
|
|
21325
|
+
6010 Ostot | 6030 Ulkopuoliset palvelut | 7010 Palkat | 7020 El\xE4kekulut
|
|
21326
|
+
8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut
|
|
21327
|
+
Prefer kirjapro_create_from_template_batch for standard patterns.`,
|
|
21261
21328
|
{
|
|
21262
|
-
fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID"),
|
|
21329
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21263
21330
|
transactions: external_exports.array(external_exports.object({
|
|
21264
21331
|
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
|
|
21265
21332
|
description: external_exports.string().min(1).max(500),
|
|
@@ -21274,9 +21341,15 @@ function registerTransactionTools(server2) {
|
|
|
21274
21341
|
async (params) => {
|
|
21275
21342
|
const supabase2 = getSupabase();
|
|
21276
21343
|
const companyId2 = getCompanyId();
|
|
21277
|
-
|
|
21344
|
+
let fiscalYearId = params.fiscalYearId;
|
|
21345
|
+
if (!fiscalYearId) {
|
|
21346
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
21347
|
+
if ("isError" in resolved) return resolved;
|
|
21348
|
+
fiscalYearId = resolved.id;
|
|
21349
|
+
}
|
|
21350
|
+
const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
21278
21351
|
if (fyError || !fy) {
|
|
21279
|
-
return toErrorContent(`Fiscal year ${
|
|
21352
|
+
return toErrorContent(`Fiscal year ${fiscalYearId} not found.`);
|
|
21280
21353
|
}
|
|
21281
21354
|
if (fy.is_closed) {
|
|
21282
21355
|
return toErrorContent(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`);
|
|
@@ -21307,7 +21380,7 @@ function registerTransactionTools(server2) {
|
|
|
21307
21380
|
}
|
|
21308
21381
|
const { data: newTx, error: txErr } = await supabase2.from("transactions").insert({
|
|
21309
21382
|
company_id: companyId2,
|
|
21310
|
-
fiscal_year_id:
|
|
21383
|
+
fiscal_year_id: fiscalYearId,
|
|
21311
21384
|
transaction_date: tx.transactionDate,
|
|
21312
21385
|
description: tx.description,
|
|
21313
21386
|
document_number: tx.documentNumber || null
|
|
@@ -21376,6 +21449,14 @@ function registerTransactionTools(server2) {
|
|
|
21376
21449
|
}
|
|
21377
21450
|
|
|
21378
21451
|
// src/types.ts
|
|
21452
|
+
var VALID_OFFER_TRANSITIONS = {
|
|
21453
|
+
draft: ["sent"],
|
|
21454
|
+
sent: ["viewed", "accepted", "rejected", "expired", "draft"],
|
|
21455
|
+
viewed: ["accepted", "rejected", "expired", "draft"],
|
|
21456
|
+
accepted: ["draft"],
|
|
21457
|
+
rejected: ["draft"],
|
|
21458
|
+
expired: ["draft"]
|
|
21459
|
+
};
|
|
21379
21460
|
var VALID_TRANSITIONS = {
|
|
21380
21461
|
draft: ["sent", "cancelled"],
|
|
21381
21462
|
sent: ["paid", "partial", "overdue", "cancelled", "draft"],
|
|
@@ -21410,7 +21491,7 @@ function registerInvoiceTools(server2) {
|
|
|
21410
21491
|
"kirjapro_list_invoices",
|
|
21411
21492
|
"List invoices with customer names. Filter by status (draft, sent, paid, overdue, cancelled).",
|
|
21412
21493
|
{
|
|
21413
|
-
status: external_exports.enum(["draft", "sent", "paid", "overdue", "cancelled"]).optional().describe("Filter by invoice status"),
|
|
21494
|
+
status: external_exports.enum(["draft", "sent", "paid", "partial", "overdue", "cancelled"]).optional().describe("Filter by invoice status"),
|
|
21414
21495
|
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
21415
21496
|
offset: external_exports.number().min(0).default(0).describe("Skip first N results")
|
|
21416
21497
|
},
|
|
@@ -21571,10 +21652,13 @@ function registerInvoiceTools(server2) {
|
|
|
21571
21652
|
const newPaidTotal = roundCents(paidSoFar + params.amount);
|
|
21572
21653
|
const newStatus = newPaidTotal >= invoice.total ? "paid" : invoice.status;
|
|
21573
21654
|
if (newStatus !== invoice.status) {
|
|
21574
|
-
await supabase2.from("invoices").update({
|
|
21655
|
+
const { error: statusError } = await supabase2.from("invoices").update({
|
|
21575
21656
|
status: newStatus,
|
|
21576
21657
|
paid_date: newStatus === "paid" ? params.paymentDate : null
|
|
21577
21658
|
}).eq("id", params.invoiceId);
|
|
21659
|
+
if (statusError) {
|
|
21660
|
+
return toErrorContent(`Payment recorded but invoice status update failed: ${statusError.message}`);
|
|
21661
|
+
}
|
|
21578
21662
|
}
|
|
21579
21663
|
return toTextContent({
|
|
21580
21664
|
message: `Payment of ${params.amount.toFixed(2)} EUR recorded on invoice ${invoice.invoice_number}`,
|
|
@@ -21825,12 +21909,15 @@ function registerCustomerTools(server2) {
|
|
|
21825
21909
|
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
21826
21910
|
);
|
|
21827
21911
|
}
|
|
21828
|
-
const { data: invoices } = await supabase2.from("invoices").select("total, status").eq("company_id", companyId2).eq("customer_id", params.customerId);
|
|
21912
|
+
const { data: invoices } = await supabase2.from("invoices").select("total, status, invoice_payments(amount)").eq("company_id", companyId2).eq("customer_id", params.customerId);
|
|
21829
21913
|
const invoiceList = invoices || [];
|
|
21830
21914
|
const invoiceCount = invoiceList.length;
|
|
21831
21915
|
const totalRevenue = roundCents(invoiceList.reduce((s, i) => s + (i.total || 0), 0));
|
|
21832
21916
|
const outstandingBalance = roundCents(
|
|
21833
|
-
invoiceList.filter((i) => i.status === "sent" || i.status === "partial" || i.status === "overdue").reduce((s, i) =>
|
|
21917
|
+
invoiceList.filter((i) => i.status === "sent" || i.status === "partial" || i.status === "overdue").reduce((s, i) => {
|
|
21918
|
+
const paid = (i.invoice_payments || []).reduce((ps, p) => ps + (p.amount || 0), 0);
|
|
21919
|
+
return s + (i.total || 0) - paid;
|
|
21920
|
+
}, 0)
|
|
21834
21921
|
);
|
|
21835
21922
|
return toTextContent({
|
|
21836
21923
|
...customer,
|
|
@@ -21912,6 +21999,12 @@ function registerReportTools(server2) {
|
|
|
21912
21999
|
async (params) => {
|
|
21913
22000
|
const supabase2 = getSupabase();
|
|
21914
22001
|
const companyId2 = getCompanyId();
|
|
22002
|
+
const { data: fiscalYear, error: fyError } = await supabase2.from("fiscal_years").select("start_date, end_date").eq("id", params.fiscalYearId).eq("company_id", companyId2).single();
|
|
22003
|
+
if (fyError || !fiscalYear) {
|
|
22004
|
+
return toErrorContent(
|
|
22005
|
+
`Fiscal year ${params.fiscalYearId} not found. Use kirjapro_list_fiscal_years to find available fiscal years.`
|
|
22006
|
+
);
|
|
22007
|
+
}
|
|
21915
22008
|
const [entriesResult, txCountResult, invoiceResult, bankResult] = await Promise.all([
|
|
21916
22009
|
// Revenue & expense totals from journal entries
|
|
21917
22010
|
supabase2.from("journal_entries").select(`
|
|
@@ -21921,8 +22014,8 @@ function registerReportTools(server2) {
|
|
|
21921
22014
|
`).eq("transaction.fiscal_year_id", params.fiscalYearId),
|
|
21922
22015
|
// Transaction count
|
|
21923
22016
|
supabase2.from("transactions").select("*", { count: "exact", head: true }).eq("company_id", companyId2).eq("fiscal_year_id", params.fiscalYearId),
|
|
21924
|
-
// Invoice stats
|
|
21925
|
-
supabase2.from("invoices").select("id, status, total, due_date").eq("company_id", companyId2),
|
|
22017
|
+
// Invoice stats scoped to fiscal year date range
|
|
22018
|
+
supabase2.from("invoices").select("id, status, total, due_date").eq("company_id", companyId2).gte("invoice_date", fiscalYear.start_date).lte("invoice_date", fiscalYear.end_date),
|
|
21926
22019
|
// Bank balances
|
|
21927
22020
|
supabase2.from("bank_accounts").select("account_name, iban, current_balance").eq("company_id", companyId2).eq("is_active", true)
|
|
21928
22021
|
]);
|
|
@@ -22305,7 +22398,7 @@ function registerBankingTools(server2) {
|
|
|
22305
22398
|
if (payError) {
|
|
22306
22399
|
return toErrorContent(`Failed to create payment: ${payError.message}`);
|
|
22307
22400
|
}
|
|
22308
|
-
const { data: match, error:
|
|
22401
|
+
const { data: match, error: matchError } = await supabase2.from("reconciliation_matches").insert({
|
|
22309
22402
|
company_id: companyId2,
|
|
22310
22403
|
bank_transaction_id: params.bankTransactionId,
|
|
22311
22404
|
match_type: "invoice",
|
|
@@ -22315,20 +22408,29 @@ function registerBankingTools(server2) {
|
|
|
22315
22408
|
status: "confirmed",
|
|
22316
22409
|
confirmed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
22317
22410
|
}).select("id").single();
|
|
22318
|
-
|
|
22411
|
+
if (matchError) {
|
|
22412
|
+
return toErrorContent(`Payment recorded but reconciliation match failed: ${matchError.message}`);
|
|
22413
|
+
}
|
|
22414
|
+
const { error: btUpdateError } = await supabase2.from("bank_transactions").update({
|
|
22319
22415
|
reconciled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22320
22416
|
matched_invoice_id: params.invoiceId,
|
|
22321
22417
|
match_confidence: 1,
|
|
22322
22418
|
match_method: "mcp_manual",
|
|
22323
22419
|
reconciliation_match_id: match?.id || null
|
|
22324
22420
|
}).eq("id", params.bankTransactionId);
|
|
22421
|
+
if (btUpdateError) {
|
|
22422
|
+
return toErrorContent(`Payment and match created but bank transaction update failed: ${btUpdateError.message}`);
|
|
22423
|
+
}
|
|
22325
22424
|
const newPaidTotal = roundCents(paidSoFar + paymentAmount);
|
|
22326
22425
|
const newStatus = newPaidTotal >= invoice.total ? "paid" : invoice.status;
|
|
22327
22426
|
if (newStatus !== invoice.status) {
|
|
22328
|
-
await supabase2.from("invoices").update({
|
|
22427
|
+
const { error: statusError } = await supabase2.from("invoices").update({
|
|
22329
22428
|
status: newStatus,
|
|
22330
22429
|
paid_date: newStatus === "paid" ? bankTx.booking_date : null
|
|
22331
22430
|
}).eq("id", params.invoiceId);
|
|
22431
|
+
if (statusError) {
|
|
22432
|
+
return toErrorContent(`Bank transaction matched but invoice status update failed: ${statusError.message}`);
|
|
22433
|
+
}
|
|
22332
22434
|
}
|
|
22333
22435
|
return toTextContent({
|
|
22334
22436
|
message: `Bank transaction matched to invoice ${invoice.invoice_number}`,
|
|
@@ -22534,16 +22636,864 @@ function registerFiscalYearTools(server2) {
|
|
|
22534
22636
|
);
|
|
22535
22637
|
}
|
|
22536
22638
|
|
|
22639
|
+
// src/tools/templates.ts
|
|
22640
|
+
function splitVat(gross, vatRate) {
|
|
22641
|
+
if (vatRate === 0) return { net: roundCents(gross), vat: 0 };
|
|
22642
|
+
const net = roundCents(gross / (1 + vatRate / 100));
|
|
22643
|
+
const vat = roundCents(gross - net);
|
|
22644
|
+
return { net, vat };
|
|
22645
|
+
}
|
|
22646
|
+
function applyOverrides(entries, overrides) {
|
|
22647
|
+
if (!overrides) return entries;
|
|
22648
|
+
return entries.map((e) => ({
|
|
22649
|
+
...e,
|
|
22650
|
+
accountNumber: overrides[e.accountNumber] ?? e.accountNumber
|
|
22651
|
+
}));
|
|
22652
|
+
}
|
|
22653
|
+
function saleCashTemplate(revenueAccount, vatRate) {
|
|
22654
|
+
return (amount, opts) => {
|
|
22655
|
+
const { net, vat } = splitVat(amount, vatRate);
|
|
22656
|
+
const entries = [
|
|
22657
|
+
{ accountNumber: "2310", debitAmount: roundCents(amount), creditAmount: 0 },
|
|
22658
|
+
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
|
|
22659
|
+
];
|
|
22660
|
+
if (vat > 0) {
|
|
22661
|
+
entries.push({ accountNumber: "4050", debitAmount: 0, creditAmount: vat });
|
|
22662
|
+
}
|
|
22663
|
+
return applyOverrides(entries, opts?.accountOverrides);
|
|
22664
|
+
};
|
|
22665
|
+
}
|
|
22666
|
+
function saleBankTemplate(revenueAccount, vatRate) {
|
|
22667
|
+
return (amount, opts) => {
|
|
22668
|
+
const { net, vat } = splitVat(amount, vatRate);
|
|
22669
|
+
const entries = [
|
|
22670
|
+
{ accountNumber: "2320", debitAmount: roundCents(amount), creditAmount: 0 },
|
|
22671
|
+
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
|
|
22672
|
+
];
|
|
22673
|
+
if (vat > 0) {
|
|
22674
|
+
entries.push({ accountNumber: "4050", debitAmount: 0, creditAmount: vat });
|
|
22675
|
+
}
|
|
22676
|
+
return applyOverrides(entries, opts?.accountOverrides);
|
|
22677
|
+
};
|
|
22678
|
+
}
|
|
22679
|
+
var TEMPLATES = [
|
|
22680
|
+
// Cash sales
|
|
22681
|
+
{
|
|
22682
|
+
id: "cash_sale_25",
|
|
22683
|
+
description: "K\xE4teismyynti ALV 25,5%",
|
|
22684
|
+
vatRate: 25.5,
|
|
22685
|
+
expand: saleCashTemplate("5010", 25.5)
|
|
22686
|
+
},
|
|
22687
|
+
{
|
|
22688
|
+
id: "cash_sale_14",
|
|
22689
|
+
description: "K\xE4teismyynti ALV 13,5%",
|
|
22690
|
+
vatRate: 13.5,
|
|
22691
|
+
expand: saleCashTemplate("5020", 13.5)
|
|
22692
|
+
},
|
|
22693
|
+
{
|
|
22694
|
+
id: "cash_sale_10",
|
|
22695
|
+
description: "K\xE4teismyynti ALV 10%",
|
|
22696
|
+
vatRate: 10,
|
|
22697
|
+
expand: saleCashTemplate("5030", 10)
|
|
22698
|
+
},
|
|
22699
|
+
{
|
|
22700
|
+
id: "cash_sale_0",
|
|
22701
|
+
description: "K\xE4teismyynti ALV 0%",
|
|
22702
|
+
vatRate: 0,
|
|
22703
|
+
expand: saleCashTemplate("5040", 0)
|
|
22704
|
+
},
|
|
22705
|
+
// Bank sales
|
|
22706
|
+
{
|
|
22707
|
+
id: "bank_sale_25",
|
|
22708
|
+
description: "Pankkimyynti ALV 25,5%",
|
|
22709
|
+
vatRate: 25.5,
|
|
22710
|
+
expand: saleBankTemplate("5010", 25.5)
|
|
22711
|
+
},
|
|
22712
|
+
{
|
|
22713
|
+
id: "bank_sale_14",
|
|
22714
|
+
description: "Pankkimyynti ALV 13,5%",
|
|
22715
|
+
vatRate: 13.5,
|
|
22716
|
+
expand: saleBankTemplate("5020", 13.5)
|
|
22717
|
+
},
|
|
22718
|
+
{
|
|
22719
|
+
id: "bank_sale_10",
|
|
22720
|
+
description: "Pankkimyynti ALV 10%",
|
|
22721
|
+
vatRate: 10,
|
|
22722
|
+
expand: saleBankTemplate("5030", 10)
|
|
22723
|
+
},
|
|
22724
|
+
// Invoice sale
|
|
22725
|
+
{
|
|
22726
|
+
id: "invoice_sale_25",
|
|
22727
|
+
description: "Laskumyynti ALV 25,5% (myyntisaamiset)",
|
|
22728
|
+
vatRate: 25.5,
|
|
22729
|
+
expand: (amount, opts) => {
|
|
22730
|
+
const { net, vat } = splitVat(amount, 25.5);
|
|
22731
|
+
return applyOverrides([
|
|
22732
|
+
{ accountNumber: "2110", debitAmount: roundCents(amount), creditAmount: 0 },
|
|
22733
|
+
{ accountNumber: "5010", debitAmount: 0, creditAmount: net },
|
|
22734
|
+
{ accountNumber: "4050", debitAmount: 0, creditAmount: vat }
|
|
22735
|
+
], opts?.accountOverrides);
|
|
22736
|
+
}
|
|
22737
|
+
},
|
|
22738
|
+
// Purchase expenses
|
|
22739
|
+
{
|
|
22740
|
+
id: "purchase_expense",
|
|
22741
|
+
description: "Osto ALV 25,5% (pankkitililt\xE4)",
|
|
22742
|
+
vatRate: 25.5,
|
|
22743
|
+
expand: (amount, opts) => {
|
|
22744
|
+
const { net, vat } = splitVat(amount, 25.5);
|
|
22745
|
+
return applyOverrides([
|
|
22746
|
+
{ accountNumber: "6010", debitAmount: net, creditAmount: 0 },
|
|
22747
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
22748
|
+
{ accountNumber: "2320", debitAmount: 0, creditAmount: roundCents(amount) }
|
|
22749
|
+
], opts?.accountOverrides);
|
|
22750
|
+
}
|
|
22751
|
+
},
|
|
22752
|
+
{
|
|
22753
|
+
id: "service_expense",
|
|
22754
|
+
description: "Ulkopuolinen palvelu ALV 25,5%",
|
|
22755
|
+
vatRate: 25.5,
|
|
22756
|
+
expand: (amount, opts) => {
|
|
22757
|
+
const { net, vat } = splitVat(amount, 25.5);
|
|
22758
|
+
return applyOverrides([
|
|
22759
|
+
{ accountNumber: "6030", debitAmount: net, creditAmount: 0 },
|
|
22760
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
22761
|
+
{ accountNumber: "2320", debitAmount: 0, creditAmount: roundCents(amount) }
|
|
22762
|
+
], opts?.accountOverrides);
|
|
22763
|
+
}
|
|
22764
|
+
},
|
|
22765
|
+
// Rent (no VAT)
|
|
22766
|
+
{
|
|
22767
|
+
id: "rent_payment",
|
|
22768
|
+
description: "Vuokranmaksu (pankkitililt\xE4)",
|
|
22769
|
+
vatRate: null,
|
|
22770
|
+
expand: (amount, opts) => applyOverrides([
|
|
22771
|
+
{ accountNumber: "8010", debitAmount: roundCents(amount), creditAmount: 0 },
|
|
22772
|
+
{ accountNumber: "2320", debitAmount: 0, creditAmount: roundCents(amount) }
|
|
22773
|
+
], opts?.accountOverrides)
|
|
22774
|
+
},
|
|
22775
|
+
// Salary
|
|
22776
|
+
{
|
|
22777
|
+
id: "salary_payment",
|
|
22778
|
+
description: "Palkanmaksu (bruttopalkka, ennakonpid\xE4tys, TyEL)",
|
|
22779
|
+
vatRate: null,
|
|
22780
|
+
expand: (amount, opts) => {
|
|
22781
|
+
const gross = roundCents(amount);
|
|
22782
|
+
const withholdingRate = opts?.withholdingRate ?? 20;
|
|
22783
|
+
const tyelEmployeeRate = opts?.tyelRate ?? 7.3;
|
|
22784
|
+
const tyelEmployerRate = 17.55;
|
|
22785
|
+
const withholding = roundCents(gross * withholdingRate / 100);
|
|
22786
|
+
const tyelEmployee = roundCents(gross * tyelEmployeeRate / 100);
|
|
22787
|
+
const tyelEmployer = roundCents(gross * tyelEmployerRate / 100);
|
|
22788
|
+
const netPay = roundCents(gross - withholding - tyelEmployee);
|
|
22789
|
+
return applyOverrides([
|
|
22790
|
+
{ accountNumber: "7010", debitAmount: gross, creditAmount: 0 },
|
|
22791
|
+
{ accountNumber: "7020", debitAmount: tyelEmployer, creditAmount: 0 },
|
|
22792
|
+
{ accountNumber: "2320", debitAmount: 0, creditAmount: netPay },
|
|
22793
|
+
{ accountNumber: "4060", debitAmount: 0, creditAmount: withholding },
|
|
22794
|
+
{ accountNumber: "4080", debitAmount: 0, creditAmount: roundCents(tyelEmployee + tyelEmployer) }
|
|
22795
|
+
], opts?.accountOverrides);
|
|
22796
|
+
}
|
|
22797
|
+
},
|
|
22798
|
+
// Bank transfer
|
|
22799
|
+
{
|
|
22800
|
+
id: "bank_transfer",
|
|
22801
|
+
description: "Tilisiirto kassasta pankkitilille",
|
|
22802
|
+
vatRate: null,
|
|
22803
|
+
expand: (amount, opts) => applyOverrides([
|
|
22804
|
+
{ accountNumber: "2320", debitAmount: roundCents(amount), creditAmount: 0 },
|
|
22805
|
+
{ accountNumber: "2310", debitAmount: 0, creditAmount: roundCents(amount) }
|
|
22806
|
+
], opts?.accountOverrides)
|
|
22807
|
+
}
|
|
22808
|
+
];
|
|
22809
|
+
var TEMPLATE_MAP = new Map(TEMPLATES.map((t) => [t.id, t]));
|
|
22810
|
+
function expandTemplate(templateId, amount, opts) {
|
|
22811
|
+
const template = TEMPLATE_MAP.get(templateId);
|
|
22812
|
+
if (!template) {
|
|
22813
|
+
return { error: `Unknown template "${templateId}". Use kirjapro_list_templates to see available templates.` };
|
|
22814
|
+
}
|
|
22815
|
+
if (amount <= 0) {
|
|
22816
|
+
return { error: "Amount must be positive." };
|
|
22817
|
+
}
|
|
22818
|
+
const entries = template.expand(amount, opts);
|
|
22819
|
+
return { entries };
|
|
22820
|
+
}
|
|
22821
|
+
async function resolveAndCreate(params) {
|
|
22822
|
+
const supabase2 = getSupabase();
|
|
22823
|
+
const companyId2 = getCompanyId();
|
|
22824
|
+
let fiscalYearId = params.fiscalYearId;
|
|
22825
|
+
if (!fiscalYearId) {
|
|
22826
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
22827
|
+
if ("isError" in resolved) return resolved;
|
|
22828
|
+
fiscalYearId = resolved.id;
|
|
22829
|
+
}
|
|
22830
|
+
const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
22831
|
+
if (fyError || !fy) {
|
|
22832
|
+
return toErrorContent(
|
|
22833
|
+
`Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard or read kirjapro://fiscal-years resource.`
|
|
22834
|
+
);
|
|
22835
|
+
}
|
|
22836
|
+
if (fy.is_closed) {
|
|
22837
|
+
return toErrorContent(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`);
|
|
22838
|
+
}
|
|
22839
|
+
if (params.transactionDate < fy.start_date || params.transactionDate > fy.end_date) {
|
|
22840
|
+
return toErrorContent(
|
|
22841
|
+
`Transaction date ${params.transactionDate} outside fiscal year (${fy.start_date} to ${fy.end_date}).`
|
|
22842
|
+
);
|
|
22843
|
+
}
|
|
22844
|
+
const accountNumbers = [...new Set(params.entries.map((e) => e.accountNumber))];
|
|
22845
|
+
const { data: accounts, error: accError } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", accountNumbers);
|
|
22846
|
+
if (accError) {
|
|
22847
|
+
return toErrorContent(`Failed to resolve accounts: ${accError.message}`);
|
|
22848
|
+
}
|
|
22849
|
+
const accountMap = new Map((accounts || []).map((a) => [a.account_number, a.id]));
|
|
22850
|
+
const missing = accountNumbers.filter((n) => !accountMap.has(n));
|
|
22851
|
+
if (missing.length > 0) {
|
|
22852
|
+
return toErrorContent(`Account numbers not found: ${missing.join(", ")}. Use kirjapro_list_accounts to see available accounts.`);
|
|
22853
|
+
}
|
|
22854
|
+
const totalDebit = roundCents(params.entries.reduce((s, e) => s + e.debitAmount, 0));
|
|
22855
|
+
const totalCredit = roundCents(params.entries.reduce((s, e) => s + e.creditAmount, 0));
|
|
22856
|
+
if (Math.abs(totalDebit - totalCredit) >= 0.01) {
|
|
22857
|
+
return toErrorContent(
|
|
22858
|
+
`Template produced unbalanced entries: debits ${totalDebit.toFixed(2)} != credits ${totalCredit.toFixed(2)}.`
|
|
22859
|
+
);
|
|
22860
|
+
}
|
|
22861
|
+
const { data: transaction, error: txError } = await supabase2.from("transactions").insert({
|
|
22862
|
+
company_id: companyId2,
|
|
22863
|
+
fiscal_year_id: fiscalYearId,
|
|
22864
|
+
transaction_date: params.transactionDate,
|
|
22865
|
+
description: params.description,
|
|
22866
|
+
document_number: params.documentNumber || null
|
|
22867
|
+
}).select().single();
|
|
22868
|
+
if (txError) {
|
|
22869
|
+
return toErrorContent(`Failed to create transaction: ${txError.message}`);
|
|
22870
|
+
}
|
|
22871
|
+
const journalEntries = params.entries.map((e) => ({
|
|
22872
|
+
transaction_id: transaction.id,
|
|
22873
|
+
account_id: accountMap.get(e.accountNumber),
|
|
22874
|
+
debit_amount: e.debitAmount,
|
|
22875
|
+
credit_amount: e.creditAmount
|
|
22876
|
+
}));
|
|
22877
|
+
const { error: jeError } = await supabase2.from("journal_entries").insert(journalEntries);
|
|
22878
|
+
if (jeError) {
|
|
22879
|
+
return toErrorContent(`Transaction created but journal entries failed: ${jeError.message}`);
|
|
22880
|
+
}
|
|
22881
|
+
return { id: transaction.id };
|
|
22882
|
+
}
|
|
22883
|
+
var ACCOUNT_REFERENCE = `
|
|
22884
|
+
Common accounts (YSE 2017):
|
|
22885
|
+
2310 Kassa | 2320 Pankkitili | 2110 Myyntisaamiset | 2140 ALV-saamiset
|
|
22886
|
+
4030 Ostovelat | 4050 ALV-velka | 4060 Ennakonpid\xE4tysvelka | 4080 TyEL-velka
|
|
22887
|
+
5010 Myynti 25,5% | 5020 Myynti 13,5% | 5030 Myynti 10% | 5040 Myynti 0%
|
|
22888
|
+
6010 Ostot | 6030 Ulkopuoliset palvelut | 7010 Palkat | 7020 El\xE4kekulut
|
|
22889
|
+
8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut`;
|
|
22890
|
+
function registerTemplateTools(server2) {
|
|
22891
|
+
server2.tool(
|
|
22892
|
+
"kirjapro_list_templates",
|
|
22893
|
+
`List available transaction templates for common Finnish small business bookkeeping patterns. Templates handle double-entry balancing and VAT splitting automatically.${ACCOUNT_REFERENCE}`,
|
|
22894
|
+
{},
|
|
22895
|
+
async () => {
|
|
22896
|
+
const list = TEMPLATES.map((t) => ({
|
|
22897
|
+
id: t.id,
|
|
22898
|
+
description: t.description,
|
|
22899
|
+
vatRate: t.vatRate
|
|
22900
|
+
}));
|
|
22901
|
+
return toTextContent({ templates: list });
|
|
22902
|
+
}
|
|
22903
|
+
);
|
|
22904
|
+
server2.tool(
|
|
22905
|
+
"kirjapro_create_from_template",
|
|
22906
|
+
`Create an accounting transaction from a predefined template. Handles double-entry balancing and VAT splitting automatically. Use kirjapro_list_templates to see available templates. Use accountOverrides to swap default accounts (e.g. {"2320": "2330"} to use a different bank account).${ACCOUNT_REFERENCE}`,
|
|
22907
|
+
{
|
|
22908
|
+
template: external_exports.string().describe('Template ID (e.g. "cash_sale_25", "purchase_expense")'),
|
|
22909
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
22910
|
+
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
22911
|
+
amount: external_exports.number().positive().describe("Gross amount in EUR"),
|
|
22912
|
+
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
22913
|
+
documentNumber: external_exports.string().optional().describe("Document/receipt number"),
|
|
22914
|
+
withholdingRate: external_exports.number().min(0).max(100).optional().describe("Withholding tax % for salary_payment (default 20)"),
|
|
22915
|
+
tyelRate: external_exports.number().min(0).max(100).optional().describe("TyEL employee % for salary_payment (default 7.30)"),
|
|
22916
|
+
accountOverrides: external_exports.record(external_exports.string(), external_exports.string()).optional().describe('Account number swaps, e.g. {"2320": "2330"}')
|
|
22917
|
+
},
|
|
22918
|
+
async (params) => {
|
|
22919
|
+
const result = expandTemplate(params.template, params.amount, {
|
|
22920
|
+
withholdingRate: params.withholdingRate,
|
|
22921
|
+
tyelRate: params.tyelRate,
|
|
22922
|
+
accountOverrides: params.accountOverrides
|
|
22923
|
+
});
|
|
22924
|
+
if ("error" in result) {
|
|
22925
|
+
return toErrorContent(result.error);
|
|
22926
|
+
}
|
|
22927
|
+
const createResult = await resolveAndCreate({
|
|
22928
|
+
fiscalYearId: params.fiscalYearId,
|
|
22929
|
+
transactionDate: params.transactionDate,
|
|
22930
|
+
description: params.description,
|
|
22931
|
+
documentNumber: params.documentNumber,
|
|
22932
|
+
entries: result.entries
|
|
22933
|
+
});
|
|
22934
|
+
if ("isError" in createResult) {
|
|
22935
|
+
return createResult;
|
|
22936
|
+
}
|
|
22937
|
+
return toTextContent({
|
|
22938
|
+
message: "Transaction created from template",
|
|
22939
|
+
template: params.template,
|
|
22940
|
+
transaction_id: createResult.id,
|
|
22941
|
+
entries: result.entries
|
|
22942
|
+
});
|
|
22943
|
+
}
|
|
22944
|
+
);
|
|
22945
|
+
server2.tool(
|
|
22946
|
+
"kirjapro_create_from_template_batch",
|
|
22947
|
+
`Create multiple transactions from templates in one call (up to 50). Each transaction is validated independently \u2014 valid ones are created even if others fail. Ideal for bulk recording of similar transactions (e.g. daily cash sales).${ACCOUNT_REFERENCE}`,
|
|
22948
|
+
{
|
|
22949
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
22950
|
+
transactions: external_exports.array(external_exports.object({
|
|
22951
|
+
template: external_exports.string().describe("Template ID"),
|
|
22952
|
+
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
|
|
22953
|
+
amount: external_exports.number().positive().describe("Gross amount in EUR"),
|
|
22954
|
+
description: external_exports.string().min(1).max(500),
|
|
22955
|
+
documentNumber: external_exports.string().optional(),
|
|
22956
|
+
withholdingRate: external_exports.number().min(0).max(100).optional(),
|
|
22957
|
+
tyelRate: external_exports.number().min(0).max(100).optional(),
|
|
22958
|
+
accountOverrides: external_exports.record(external_exports.string(), external_exports.string()).optional()
|
|
22959
|
+
})).min(1).max(50).describe("Array of template transactions to create (max 50)")
|
|
22960
|
+
},
|
|
22961
|
+
async (params) => {
|
|
22962
|
+
const created = [];
|
|
22963
|
+
const failed = [];
|
|
22964
|
+
for (let i = 0; i < params.transactions.length; i++) {
|
|
22965
|
+
const tx = params.transactions[i];
|
|
22966
|
+
const result = expandTemplate(tx.template, tx.amount, {
|
|
22967
|
+
withholdingRate: tx.withholdingRate,
|
|
22968
|
+
tyelRate: tx.tyelRate,
|
|
22969
|
+
accountOverrides: tx.accountOverrides
|
|
22970
|
+
});
|
|
22971
|
+
if ("error" in result) {
|
|
22972
|
+
failed.push({ index: i, error: result.error });
|
|
22973
|
+
continue;
|
|
22974
|
+
}
|
|
22975
|
+
const createResult = await resolveAndCreate({
|
|
22976
|
+
fiscalYearId: params.fiscalYearId,
|
|
22977
|
+
transactionDate: tx.transactionDate,
|
|
22978
|
+
description: tx.description,
|
|
22979
|
+
documentNumber: tx.documentNumber,
|
|
22980
|
+
entries: result.entries
|
|
22981
|
+
});
|
|
22982
|
+
if ("isError" in createResult) {
|
|
22983
|
+
failed.push({ index: i, error: createResult.content[0].text });
|
|
22984
|
+
continue;
|
|
22985
|
+
}
|
|
22986
|
+
created.push({ id: createResult.id, description: tx.description, date: tx.transactionDate });
|
|
22987
|
+
}
|
|
22988
|
+
return toTextContent({
|
|
22989
|
+
created_count: created.length,
|
|
22990
|
+
failed_count: failed.length,
|
|
22991
|
+
created,
|
|
22992
|
+
failed
|
|
22993
|
+
});
|
|
22994
|
+
}
|
|
22995
|
+
);
|
|
22996
|
+
}
|
|
22997
|
+
|
|
22998
|
+
// src/tools/bookings.ts
|
|
22999
|
+
var DEFAULTS = {
|
|
23000
|
+
tyelEmployee: 7.3,
|
|
23001
|
+
tyelEmployer: 17.55,
|
|
23002
|
+
sotu: 1.91,
|
|
23003
|
+
ttvEmployer: 0.31,
|
|
23004
|
+
ttvEmployee: 0.89
|
|
23005
|
+
};
|
|
23006
|
+
function registerBookingTools(server2) {
|
|
23007
|
+
server2.tool(
|
|
23008
|
+
"kirjapro_book_purchase",
|
|
23009
|
+
`Book a purchase (osto) in one call with automatic VAT splitting and Finnish compliance.
|
|
23010
|
+
Handles: standard purchases, reverse charge (EU/non-EU services), entertainment (edustus, AVL 114\xA7 no VAT deduction).
|
|
23011
|
+
Common expense accounts: 6010 Ostot | 6030 Ulkopuoliset palvelut | 8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut | 8070 Vakuutukset | 8080 Tietoliikennekulut`,
|
|
23012
|
+
{
|
|
23013
|
+
amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
|
|
23014
|
+
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
23015
|
+
date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
23016
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("Finnish VAT rate"),
|
|
23017
|
+
expenseAccount: external_exports.string().describe('Expense account number (e.g. "8080")'),
|
|
23018
|
+
reverseCharge: external_exports.boolean().default(false).describe("EU reverse charge: D expense + D 2140 + C 4050 + C bank"),
|
|
23019
|
+
edustus: external_exports.boolean().default(false).describe("Entertainment expense (AVL 114\xA7): 0% VAT deduction, full gross to expense"),
|
|
23020
|
+
paymentAccount: external_exports.string().default("2320").describe('Payment account (default "2320" Pankkitili)'),
|
|
23021
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23022
|
+
documentNumber: external_exports.string().optional().describe("Document/receipt number")
|
|
23023
|
+
},
|
|
23024
|
+
async (params) => {
|
|
23025
|
+
const rate = Number.parseFloat(params.vatRate);
|
|
23026
|
+
const gross = roundCents(params.amount);
|
|
23027
|
+
let entries;
|
|
23028
|
+
if (params.edustus) {
|
|
23029
|
+
entries = [
|
|
23030
|
+
{ accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
|
|
23031
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23032
|
+
];
|
|
23033
|
+
} else if (params.reverseCharge) {
|
|
23034
|
+
const vat = roundCents(gross * rate / 100);
|
|
23035
|
+
entries = [
|
|
23036
|
+
{ accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
|
|
23037
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23038
|
+
// ALV-saamiset (input)
|
|
23039
|
+
{ accountNumber: "4050", debitAmount: 0, creditAmount: vat },
|
|
23040
|
+
// ALV-velka (output)
|
|
23041
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23042
|
+
];
|
|
23043
|
+
} else if (rate === 0) {
|
|
23044
|
+
entries = [
|
|
23045
|
+
{ accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
|
|
23046
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23047
|
+
];
|
|
23048
|
+
} else {
|
|
23049
|
+
const { net, vat } = splitVat(gross, rate);
|
|
23050
|
+
entries = [
|
|
23051
|
+
{ accountNumber: params.expenseAccount, debitAmount: net, creditAmount: 0 },
|
|
23052
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23053
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
|
|
23054
|
+
];
|
|
23055
|
+
}
|
|
23056
|
+
const result = await resolveAndCreate({
|
|
23057
|
+
fiscalYearId: params.fiscalYearId,
|
|
23058
|
+
transactionDate: params.date,
|
|
23059
|
+
description: params.description,
|
|
23060
|
+
documentNumber: params.documentNumber,
|
|
23061
|
+
entries
|
|
23062
|
+
});
|
|
23063
|
+
if ("isError" in result) return result;
|
|
23064
|
+
return toTextContent({
|
|
23065
|
+
message: "Purchase booked",
|
|
23066
|
+
transaction_id: result.id,
|
|
23067
|
+
entries
|
|
23068
|
+
});
|
|
23069
|
+
}
|
|
23070
|
+
);
|
|
23071
|
+
server2.tool(
|
|
23072
|
+
"kirjapro_book_salary",
|
|
23073
|
+
`Book salary payment (palkanmaksu) in one call. Creates 2 balanced transactions:
|
|
23074
|
+
1. Salary: D 7010(gross) \u2192 C 4060(withholding) + C 4080(TyEL employee) + C 4070(TTV employee) + C 2320(net)
|
|
23075
|
+
2. Employer costs: D 7020(TyEL employer) + D 7030(sotu + TTV employer) \u2192 C 4080 + C 4070
|
|
23076
|
+
Default rates are 2026 Finnish social insurance rates.`,
|
|
23077
|
+
{
|
|
23078
|
+
grossSalary: external_exports.number().positive().describe("Gross salary EUR"),
|
|
23079
|
+
employeeName: external_exports.string().min(1).describe("Employee name (for description)"),
|
|
23080
|
+
date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Payment date (YYYY-MM-DD)"),
|
|
23081
|
+
withholdingRate: external_exports.number().min(0).max(100).describe("Withholding tax % (ennakonpid\xE4tys)"),
|
|
23082
|
+
tyelEmployeeRate: external_exports.number().min(0).max(100).default(DEFAULTS.tyelEmployee).describe("TyEL employee % (default 7.30)"),
|
|
23083
|
+
tyelEmployerRate: external_exports.number().min(0).max(100).default(DEFAULTS.tyelEmployer).describe("TyEL employer % (default 17.55)"),
|
|
23084
|
+
sotuRate: external_exports.number().min(0).max(100).default(DEFAULTS.sotu).describe("Sairausvakuutusmaksu employer % (default 1.91)"),
|
|
23085
|
+
ttvEmployerRate: external_exports.number().min(0).max(100).default(DEFAULTS.ttvEmployer).describe("Ty\xF6tt\xF6myysvakuutus employer % (default 0.31)"),
|
|
23086
|
+
ttvEmployeeRate: external_exports.number().min(0).max(100).default(DEFAULTS.ttvEmployee).describe("Ty\xF6tt\xF6myysvakuutus employee % (default 0.89)"),
|
|
23087
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23088
|
+
paymentAccount: external_exports.string().default("2320").describe('Bank account (default "2320")')
|
|
23089
|
+
},
|
|
23090
|
+
async (params) => {
|
|
23091
|
+
const gross = roundCents(params.grossSalary);
|
|
23092
|
+
const withholding = roundCents(gross * params.withholdingRate / 100);
|
|
23093
|
+
const tyelEmployee = roundCents(gross * params.tyelEmployeeRate / 100);
|
|
23094
|
+
const ttvEmployee = roundCents(gross * params.ttvEmployeeRate / 100);
|
|
23095
|
+
const netPay = roundCents(gross - withholding - tyelEmployee - ttvEmployee);
|
|
23096
|
+
const tyelEmployer = roundCents(gross * params.tyelEmployerRate / 100);
|
|
23097
|
+
const sotuAmount = roundCents(gross * params.sotuRate / 100);
|
|
23098
|
+
const ttvEmployer = roundCents(gross * params.ttvEmployerRate / 100);
|
|
23099
|
+
if (netPay < 0) {
|
|
23100
|
+
return toErrorContent(
|
|
23101
|
+
`Net pay is negative (${netPay.toFixed(2)}). Check withholding rate (${params.withholdingRate}%) and deduction rates.`
|
|
23102
|
+
);
|
|
23103
|
+
}
|
|
23104
|
+
const salaryEntries = [
|
|
23105
|
+
{ accountNumber: "7010", debitAmount: gross, creditAmount: 0 },
|
|
23106
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: netPay },
|
|
23107
|
+
{ accountNumber: "4060", debitAmount: 0, creditAmount: withholding },
|
|
23108
|
+
{ accountNumber: "4080", debitAmount: 0, creditAmount: tyelEmployee },
|
|
23109
|
+
{ accountNumber: "4070", debitAmount: 0, creditAmount: ttvEmployee }
|
|
23110
|
+
];
|
|
23111
|
+
const salaryResult = await resolveAndCreate({
|
|
23112
|
+
fiscalYearId: params.fiscalYearId,
|
|
23113
|
+
transactionDate: params.date,
|
|
23114
|
+
description: `Palkka ${params.employeeName}`,
|
|
23115
|
+
entries: salaryEntries
|
|
23116
|
+
});
|
|
23117
|
+
if ("isError" in salaryResult) return salaryResult;
|
|
23118
|
+
const employerSotuTtv = roundCents(sotuAmount + ttvEmployer);
|
|
23119
|
+
const employerEntries = [
|
|
23120
|
+
{ accountNumber: "7020", debitAmount: tyelEmployer, creditAmount: 0 },
|
|
23121
|
+
{ accountNumber: "7030", debitAmount: employerSotuTtv, creditAmount: 0 },
|
|
23122
|
+
{ accountNumber: "4080", debitAmount: 0, creditAmount: tyelEmployer },
|
|
23123
|
+
{ accountNumber: "4070", debitAmount: 0, creditAmount: employerSotuTtv }
|
|
23124
|
+
];
|
|
23125
|
+
const employerResult = await resolveAndCreate({
|
|
23126
|
+
fiscalYearId: params.fiscalYearId,
|
|
23127
|
+
transactionDate: params.date,
|
|
23128
|
+
description: `Sivukulut ${params.employeeName}`,
|
|
23129
|
+
entries: employerEntries
|
|
23130
|
+
});
|
|
23131
|
+
if ("isError" in employerResult) return employerResult;
|
|
23132
|
+
return toTextContent({
|
|
23133
|
+
message: "Salary booked (2 transactions)",
|
|
23134
|
+
salary_transaction_id: salaryResult.id,
|
|
23135
|
+
employer_costs_transaction_id: employerResult.id,
|
|
23136
|
+
summary: {
|
|
23137
|
+
gross,
|
|
23138
|
+
withholding,
|
|
23139
|
+
tyelEmployee,
|
|
23140
|
+
ttvEmployee,
|
|
23141
|
+
netPay,
|
|
23142
|
+
tyelEmployer,
|
|
23143
|
+
sotu: sotuAmount,
|
|
23144
|
+
ttvEmployer,
|
|
23145
|
+
totalEmployerCost: roundCents(gross + tyelEmployer + sotuAmount + ttvEmployer)
|
|
23146
|
+
}
|
|
23147
|
+
});
|
|
23148
|
+
}
|
|
23149
|
+
);
|
|
23150
|
+
server2.tool(
|
|
23151
|
+
"kirjapro_book_vat_settlement",
|
|
23152
|
+
`Book ALV-tilitys (VAT settlement) for a month. Queries journal entries on ALV accounts (2140 ALV-saamiset, 4050 ALV-velka) for the month, calculates the net, and creates a settlement transaction.
|
|
23153
|
+
If ALV-velka > ALV-saamiset \u2192 pay to tax authority (D 4050, C 2140, D/C bank for difference).
|
|
23154
|
+
If ALV-saamiset > ALV-velka \u2192 claim refund (D 4050, C 2140, D bank for difference).`,
|
|
23155
|
+
{
|
|
23156
|
+
month: external_exports.string().regex(/^\d{4}-\d{2}$/).describe("Settlement month (YYYY-MM)"),
|
|
23157
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23158
|
+
settlementDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Settlement date (default: 12th of month+1)"),
|
|
23159
|
+
paymentAccount: external_exports.string().default("2320").describe('Bank account (default "2320")')
|
|
23160
|
+
},
|
|
23161
|
+
async (params) => {
|
|
23162
|
+
const supabase2 = getSupabase();
|
|
23163
|
+
const companyId2 = getCompanyId();
|
|
23164
|
+
let fiscalYearId = params.fiscalYearId;
|
|
23165
|
+
if (!fiscalYearId) {
|
|
23166
|
+
const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
|
|
23167
|
+
if ("isError" in resolved) return resolved;
|
|
23168
|
+
fiscalYearId = resolved.id;
|
|
23169
|
+
}
|
|
23170
|
+
const [year, monthStr] = params.month.split("-");
|
|
23171
|
+
const monthStart = `${params.month}-01`;
|
|
23172
|
+
const monthNum = Number.parseInt(monthStr, 10);
|
|
23173
|
+
const nextMonth = monthNum === 12 ? `${Number.parseInt(year, 10) + 1}-01` : `${year}-${String(monthNum + 1).padStart(2, "0")}`;
|
|
23174
|
+
const monthEnd = /* @__PURE__ */ new Date(`${nextMonth}-01`);
|
|
23175
|
+
monthEnd.setDate(monthEnd.getDate() - 1);
|
|
23176
|
+
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
|
23177
|
+
const settlementDate = params.settlementDate ?? `${nextMonth}-12`;
|
|
23178
|
+
const { data: alvSaamiset, error: err1 } = await supabase2.from("journal_entries").select("debit_amount, credit_amount, accounts!inner(account_number), transactions!inner(transaction_date, company_id, fiscal_year_id)").eq("accounts.account_number", "2140").eq("transactions.company_id", companyId2).eq("transactions.fiscal_year_id", fiscalYearId).gte("transactions.transaction_date", monthStart).lte("transactions.transaction_date", monthEndStr);
|
|
23179
|
+
if (err1) {
|
|
23180
|
+
return toErrorContent(`Failed to query ALV-saamiset: ${err1.message}`);
|
|
23181
|
+
}
|
|
23182
|
+
const { data: alvVelka, error: err2 } = await supabase2.from("journal_entries").select("debit_amount, credit_amount, accounts!inner(account_number), transactions!inner(transaction_date, company_id, fiscal_year_id)").eq("accounts.account_number", "4050").eq("transactions.company_id", companyId2).eq("transactions.fiscal_year_id", fiscalYearId).gte("transactions.transaction_date", monthStart).lte("transactions.transaction_date", monthEndStr);
|
|
23183
|
+
if (err2) {
|
|
23184
|
+
return toErrorContent(`Failed to query ALV-velka: ${err2.message}`);
|
|
23185
|
+
}
|
|
23186
|
+
const saamisetBalance = roundCents(
|
|
23187
|
+
(alvSaamiset || []).reduce((s, e) => s + (e.debit_amount || 0) - (e.credit_amount || 0), 0)
|
|
23188
|
+
);
|
|
23189
|
+
const velkaBalance = roundCents(
|
|
23190
|
+
(alvVelka || []).reduce((s, e) => s + (e.credit_amount || 0) - (e.debit_amount || 0), 0)
|
|
23191
|
+
);
|
|
23192
|
+
if (saamisetBalance === 0 && velkaBalance === 0) {
|
|
23193
|
+
return toTextContent({
|
|
23194
|
+
message: `No VAT entries found for ${params.month}. Nothing to settle.`,
|
|
23195
|
+
alv_saamiset: 0,
|
|
23196
|
+
alv_velka: 0
|
|
23197
|
+
});
|
|
23198
|
+
}
|
|
23199
|
+
const netVat = roundCents(velkaBalance - saamisetBalance);
|
|
23200
|
+
let entries;
|
|
23201
|
+
if (netVat > 0) {
|
|
23202
|
+
entries = [
|
|
23203
|
+
{ accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
|
|
23204
|
+
{ accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance },
|
|
23205
|
+
{ accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: netVat }
|
|
23206
|
+
];
|
|
23207
|
+
} else if (netVat < 0) {
|
|
23208
|
+
const refund = Math.abs(netVat);
|
|
23209
|
+
entries = [
|
|
23210
|
+
{ accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
|
|
23211
|
+
{ accountNumber: params.paymentAccount, debitAmount: refund, creditAmount: 0 },
|
|
23212
|
+
{ accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance }
|
|
23213
|
+
];
|
|
23214
|
+
} else {
|
|
23215
|
+
entries = [
|
|
23216
|
+
{ accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
|
|
23217
|
+
{ accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance }
|
|
23218
|
+
];
|
|
23219
|
+
}
|
|
23220
|
+
const result = await resolveAndCreate({
|
|
23221
|
+
fiscalYearId,
|
|
23222
|
+
transactionDate: settlementDate,
|
|
23223
|
+
description: `ALV-tilitys ${params.month}`,
|
|
23224
|
+
entries
|
|
23225
|
+
});
|
|
23226
|
+
if ("isError" in result) return result;
|
|
23227
|
+
return toTextContent({
|
|
23228
|
+
message: `VAT settlement booked for ${params.month}`,
|
|
23229
|
+
transaction_id: result.id,
|
|
23230
|
+
alv_saamiset: saamisetBalance,
|
|
23231
|
+
alv_velka: velkaBalance,
|
|
23232
|
+
net_vat: netVat,
|
|
23233
|
+
direction: netVat > 0 ? "pay" : netVat < 0 ? "refund" : "zero"
|
|
23234
|
+
});
|
|
23235
|
+
}
|
|
23236
|
+
);
|
|
23237
|
+
}
|
|
23238
|
+
|
|
23239
|
+
// src/tools/offers.ts
|
|
23240
|
+
function generateOfferNumber(lastNumber, type) {
|
|
23241
|
+
const prefix = type === "sopimus" ? "SOP" : "TAR";
|
|
23242
|
+
if (!lastNumber) return `${prefix}00001`;
|
|
23243
|
+
const numPart = lastNumber.replace(/\D/g, "");
|
|
23244
|
+
const next = parseInt(numPart || "0") + 1;
|
|
23245
|
+
return `${prefix}${next.toString().padStart(5, "0")}`;
|
|
23246
|
+
}
|
|
23247
|
+
function registerOfferTools(server2) {
|
|
23248
|
+
server2.tool(
|
|
23249
|
+
"kirjapro_list_offers",
|
|
23250
|
+
"List offers/contracts with customer names. Filter by status (draft, sent, viewed, accepted, rejected, expired) and type (tarjous, sopimus).",
|
|
23251
|
+
{
|
|
23252
|
+
status: external_exports.enum(["draft", "sent", "viewed", "accepted", "rejected", "expired"]).optional().describe("Filter by offer status"),
|
|
23253
|
+
type: external_exports.enum(["tarjous", "sopimus"]).optional().describe("Filter by type (tarjous = offer, sopimus = contract)"),
|
|
23254
|
+
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
23255
|
+
offset: external_exports.number().min(0).default(0).describe("Skip first N results")
|
|
23256
|
+
},
|
|
23257
|
+
async (params) => {
|
|
23258
|
+
const supabase2 = getSupabase();
|
|
23259
|
+
const companyId2 = getCompanyId();
|
|
23260
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
23261
|
+
let query = supabase2.from("offers").select(`
|
|
23262
|
+
id, offer_number, type, title, status, subtotal, vat_total, total,
|
|
23263
|
+
valid_from, valid_until, invoice_id, created_at,
|
|
23264
|
+
customers ( name, business_id )
|
|
23265
|
+
`, { count: "exact" }).eq("company_id", companyId2).order("created_at", { ascending: false });
|
|
23266
|
+
if (params.status) {
|
|
23267
|
+
query = query.eq("status", params.status);
|
|
23268
|
+
}
|
|
23269
|
+
if (params.type) {
|
|
23270
|
+
query = query.eq("type", params.type);
|
|
23271
|
+
}
|
|
23272
|
+
query = query.range(offset, offset + limit - 1);
|
|
23273
|
+
const { data, count, error: error2 } = await query;
|
|
23274
|
+
if (error2) {
|
|
23275
|
+
return toErrorContent(`Failed to list offers: ${error2.message}`);
|
|
23276
|
+
}
|
|
23277
|
+
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
23278
|
+
}
|
|
23279
|
+
);
|
|
23280
|
+
server2.tool(
|
|
23281
|
+
"kirjapro_get_offer",
|
|
23282
|
+
"Get a single offer/contract with full detail: customer info, line items, events, and VAT breakdown.",
|
|
23283
|
+
{
|
|
23284
|
+
offerId: external_exports.string().uuid().describe("Offer UUID")
|
|
23285
|
+
},
|
|
23286
|
+
async (params) => {
|
|
23287
|
+
const supabase2 = getSupabase();
|
|
23288
|
+
const companyId2 = getCompanyId();
|
|
23289
|
+
const { data: offer, error: error2 } = await supabase2.from("offers").select(`
|
|
23290
|
+
id, offer_number, type, title, content, status,
|
|
23291
|
+
subtotal, vat_total, total, notes, valid_from, valid_until,
|
|
23292
|
+
invoice_id, sent_at, created_at,
|
|
23293
|
+
customers ( id, name, business_id, email, address, postal_code, city ),
|
|
23294
|
+
offer_lines ( id, line_number, description, quantity, unit, unit_price, vat_rate, subtotal, vat_amount, total ),
|
|
23295
|
+
offer_events ( id, event_type, created_at, metadata )
|
|
23296
|
+
`).eq("id", params.offerId).eq("company_id", companyId2).single();
|
|
23297
|
+
if (error2 || !offer) {
|
|
23298
|
+
return toErrorContent(
|
|
23299
|
+
`Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
|
|
23300
|
+
);
|
|
23301
|
+
}
|
|
23302
|
+
return toTextContent(offer);
|
|
23303
|
+
}
|
|
23304
|
+
);
|
|
23305
|
+
server2.tool(
|
|
23306
|
+
"kirjapro_create_offer",
|
|
23307
|
+
"Create a new offer (tarjous) or contract (sopimus) with line items. Automatically generates offer number and calculates VAT. Returns the created offer.",
|
|
23308
|
+
{
|
|
23309
|
+
type: external_exports.enum(["tarjous", "sopimus"]).default("tarjous").describe("Type: tarjous (offer) or sopimus (contract)"),
|
|
23310
|
+
title: external_exports.string().min(1).describe("Offer title"),
|
|
23311
|
+
customerId: external_exports.string().uuid().optional().describe("Customer UUID (optional)"),
|
|
23312
|
+
content: external_exports.string().optional().describe("Free-text content/description"),
|
|
23313
|
+
validFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Valid from date (YYYY-MM-DD)"),
|
|
23314
|
+
validUntil: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Valid until date (YYYY-MM-DD)"),
|
|
23315
|
+
notes: external_exports.string().optional().describe("Internal notes (not visible to customer)"),
|
|
23316
|
+
lines: external_exports.array(external_exports.object({
|
|
23317
|
+
description: external_exports.string().min(1).describe("Line item description"),
|
|
23318
|
+
quantity: external_exports.number().min(0).describe("Quantity"),
|
|
23319
|
+
unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
|
|
23320
|
+
unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
|
|
23321
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
|
|
23322
|
+
})).min(1).describe("Line items")
|
|
23323
|
+
},
|
|
23324
|
+
async (params) => {
|
|
23325
|
+
const supabase2 = getSupabase();
|
|
23326
|
+
const companyId2 = getCompanyId();
|
|
23327
|
+
if (params.customerId) {
|
|
23328
|
+
const { data: customer, error: custError } = await supabase2.from("customers").select("id, name").eq("id", params.customerId).eq("company_id", companyId2).single();
|
|
23329
|
+
if (custError || !customer) {
|
|
23330
|
+
return toErrorContent(
|
|
23331
|
+
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
23332
|
+
);
|
|
23333
|
+
}
|
|
23334
|
+
}
|
|
23335
|
+
const { data: lastOffer } = await supabase2.from("offers").select("offer_number").eq("company_id", companyId2).eq("type", params.type).order("created_at", { ascending: false }).limit(1).single();
|
|
23336
|
+
const offerNumber = generateOfferNumber(lastOffer?.offer_number || null, params.type);
|
|
23337
|
+
const calculatedLines = params.lines.map((line, i) => {
|
|
23338
|
+
const vatRate = parseFloat(line.vatRate);
|
|
23339
|
+
const subtotal2 = roundCents(line.quantity * line.unitPrice);
|
|
23340
|
+
const vatAmount = roundCents(subtotal2 * (vatRate / 100));
|
|
23341
|
+
const total2 = roundCents(subtotal2 + vatAmount);
|
|
23342
|
+
return {
|
|
23343
|
+
line_number: i + 1,
|
|
23344
|
+
description: line.description,
|
|
23345
|
+
quantity: line.quantity,
|
|
23346
|
+
unit: line.unit,
|
|
23347
|
+
unit_price: line.unitPrice,
|
|
23348
|
+
vat_rate: vatRate,
|
|
23349
|
+
subtotal: subtotal2,
|
|
23350
|
+
vat_amount: vatAmount,
|
|
23351
|
+
total: total2
|
|
23352
|
+
};
|
|
23353
|
+
});
|
|
23354
|
+
const subtotal = roundCents(calculatedLines.reduce((s, l) => s + l.subtotal, 0));
|
|
23355
|
+
const vatTotal = roundCents(calculatedLines.reduce((s, l) => s + l.vat_amount, 0));
|
|
23356
|
+
const total = roundCents(subtotal + vatTotal);
|
|
23357
|
+
const { data: offerId, error: rpcError } = await supabase2.rpc("create_offer_with_lines", {
|
|
23358
|
+
p_company_id: companyId2,
|
|
23359
|
+
p_customer_id: params.customerId || null,
|
|
23360
|
+
p_type: params.type,
|
|
23361
|
+
p_title: params.title,
|
|
23362
|
+
p_offer_number: offerNumber,
|
|
23363
|
+
p_content: params.content || null,
|
|
23364
|
+
p_status: "draft",
|
|
23365
|
+
p_valid_from: params.validFrom || null,
|
|
23366
|
+
p_valid_until: params.validUntil || null,
|
|
23367
|
+
p_notes: params.notes || null,
|
|
23368
|
+
p_subtotal: subtotal,
|
|
23369
|
+
p_vat_total: vatTotal,
|
|
23370
|
+
p_total: total,
|
|
23371
|
+
p_lines: calculatedLines
|
|
23372
|
+
});
|
|
23373
|
+
if (rpcError) {
|
|
23374
|
+
return toErrorContent(`Failed to create offer: ${rpcError.message}`);
|
|
23375
|
+
}
|
|
23376
|
+
return toTextContent({
|
|
23377
|
+
message: `${params.type === "sopimus" ? "Contract" : "Offer"} ${offerNumber} created`,
|
|
23378
|
+
offer_id: offerId,
|
|
23379
|
+
offer_number: offerNumber,
|
|
23380
|
+
type: params.type,
|
|
23381
|
+
title: params.title,
|
|
23382
|
+
subtotal,
|
|
23383
|
+
vat_total: vatTotal,
|
|
23384
|
+
total,
|
|
23385
|
+
lines: calculatedLines
|
|
23386
|
+
});
|
|
23387
|
+
}
|
|
23388
|
+
);
|
|
23389
|
+
server2.tool(
|
|
23390
|
+
"kirjapro_update_offer_status",
|
|
23391
|
+
"Update offer status with state machine validation. Valid transitions: draft\u2192sent, sent\u2192viewed/accepted/rejected/expired/draft, viewed\u2192accepted/rejected/expired/draft, accepted/rejected/expired\u2192draft.",
|
|
23392
|
+
{
|
|
23393
|
+
offerId: external_exports.string().uuid().describe("Offer UUID"),
|
|
23394
|
+
status: external_exports.enum(["draft", "sent", "viewed", "accepted", "rejected", "expired"]).describe("New status")
|
|
23395
|
+
},
|
|
23396
|
+
async (params) => {
|
|
23397
|
+
const supabase2 = getSupabase();
|
|
23398
|
+
const companyId2 = getCompanyId();
|
|
23399
|
+
const { data: offer, error: fetchError } = await supabase2.from("offers").select("id, offer_number, status, invoice_id").eq("id", params.offerId).eq("company_id", companyId2).single();
|
|
23400
|
+
if (fetchError || !offer) {
|
|
23401
|
+
return toErrorContent(
|
|
23402
|
+
`Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
|
|
23403
|
+
);
|
|
23404
|
+
}
|
|
23405
|
+
const currentStatus = offer.status;
|
|
23406
|
+
const newStatus = params.status;
|
|
23407
|
+
if (currentStatus === newStatus) {
|
|
23408
|
+
return toErrorContent(
|
|
23409
|
+
`Offer ${offer.offer_number} is already in status "${currentStatus}".`
|
|
23410
|
+
);
|
|
23411
|
+
}
|
|
23412
|
+
const validNext = VALID_OFFER_TRANSITIONS[currentStatus] || [];
|
|
23413
|
+
if (!validNext.includes(newStatus)) {
|
|
23414
|
+
return toErrorContent(
|
|
23415
|
+
`Cannot transition offer ${offer.offer_number} from "${currentStatus}" to "${newStatus}". Valid next statuses: ${validNext.length > 0 ? validNext.join(", ") : "(none)"}.`
|
|
23416
|
+
);
|
|
23417
|
+
}
|
|
23418
|
+
if (newStatus === "draft" && offer.invoice_id) {
|
|
23419
|
+
return toErrorContent(
|
|
23420
|
+
`Cannot revert offer ${offer.offer_number} to draft \u2014 it has already been converted to an invoice.`
|
|
23421
|
+
);
|
|
23422
|
+
}
|
|
23423
|
+
const updateFields = { status: newStatus };
|
|
23424
|
+
const timestampMap = {
|
|
23425
|
+
sent: "sent_at",
|
|
23426
|
+
viewed: "viewed_at",
|
|
23427
|
+
accepted: "accepted_at",
|
|
23428
|
+
rejected: "rejected_at"
|
|
23429
|
+
};
|
|
23430
|
+
if (timestampMap[newStatus]) {
|
|
23431
|
+
updateFields[timestampMap[newStatus]] = (/* @__PURE__ */ new Date()).toISOString();
|
|
23432
|
+
}
|
|
23433
|
+
const { error: updateError } = await supabase2.from("offers").update(updateFields).eq("id", params.offerId).eq("company_id", companyId2);
|
|
23434
|
+
if (updateError) {
|
|
23435
|
+
return toErrorContent(`Failed to update offer status: ${updateError.message}`);
|
|
23436
|
+
}
|
|
23437
|
+
const { error: eventError } = await supabase2.from("offer_events").insert({
|
|
23438
|
+
offer_id: params.offerId,
|
|
23439
|
+
event_type: `status_${newStatus}`
|
|
23440
|
+
});
|
|
23441
|
+
if (eventError) {
|
|
23442
|
+
console.error("Failed to log offer event:", eventError);
|
|
23443
|
+
}
|
|
23444
|
+
return toTextContent({
|
|
23445
|
+
message: `Offer ${offer.offer_number} status changed from "${currentStatus}" to "${newStatus}"`,
|
|
23446
|
+
offer_number: offer.offer_number,
|
|
23447
|
+
previous_status: currentStatus,
|
|
23448
|
+
new_status: newStatus
|
|
23449
|
+
});
|
|
23450
|
+
}
|
|
23451
|
+
);
|
|
23452
|
+
server2.tool(
|
|
23453
|
+
"kirjapro_delete_offer",
|
|
23454
|
+
'Delete a draft offer/contract. Only offers with status "draft" can be deleted.',
|
|
23455
|
+
{
|
|
23456
|
+
offerId: external_exports.string().uuid().describe("Offer UUID")
|
|
23457
|
+
},
|
|
23458
|
+
async (params) => {
|
|
23459
|
+
const supabase2 = getSupabase();
|
|
23460
|
+
const companyId2 = getCompanyId();
|
|
23461
|
+
const { data: offer, error: fetchError } = await supabase2.from("offers").select("id, offer_number, status, type").eq("id", params.offerId).eq("company_id", companyId2).single();
|
|
23462
|
+
if (fetchError || !offer) {
|
|
23463
|
+
return toErrorContent(
|
|
23464
|
+
`Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
|
|
23465
|
+
);
|
|
23466
|
+
}
|
|
23467
|
+
if (offer.status !== "draft") {
|
|
23468
|
+
return toErrorContent(
|
|
23469
|
+
`Cannot delete offer ${offer.offer_number} \u2014 status is "${offer.status}". Only draft offers can be deleted.`
|
|
23470
|
+
);
|
|
23471
|
+
}
|
|
23472
|
+
const { error: deleteError } = await supabase2.from("offers").delete().eq("id", params.offerId).eq("company_id", companyId2);
|
|
23473
|
+
if (deleteError) {
|
|
23474
|
+
return toErrorContent(`Failed to delete offer: ${deleteError.message}`);
|
|
23475
|
+
}
|
|
23476
|
+
return toTextContent({
|
|
23477
|
+
message: `Draft ${offer.type === "sopimus" ? "contract" : "offer"} ${offer.offer_number} deleted`,
|
|
23478
|
+
offer_number: offer.offer_number
|
|
23479
|
+
});
|
|
23480
|
+
}
|
|
23481
|
+
);
|
|
23482
|
+
}
|
|
23483
|
+
|
|
22537
23484
|
// src/tools/index.ts
|
|
22538
23485
|
function registerTools(server2) {
|
|
22539
23486
|
registerAccountTools(server2);
|
|
22540
23487
|
registerTransactionTools(server2);
|
|
23488
|
+
registerTemplateTools(server2);
|
|
23489
|
+
registerBookingTools(server2);
|
|
22541
23490
|
registerInvoiceTools(server2);
|
|
22542
23491
|
registerCustomerTools(server2);
|
|
22543
23492
|
registerReportTools(server2);
|
|
22544
23493
|
registerBankingTools(server2);
|
|
22545
23494
|
registerValidationTools(server2);
|
|
22546
23495
|
registerFiscalYearTools(server2);
|
|
23496
|
+
registerOfferTools(server2);
|
|
22547
23497
|
}
|
|
22548
23498
|
|
|
22549
23499
|
// src/resources/schema.ts
|
|
@@ -22649,7 +23599,7 @@ function registerSchemaResources(server2) {
|
|
|
22649
23599
|
contents: [{
|
|
22650
23600
|
uri: uri.href,
|
|
22651
23601
|
mimeType: "application/json",
|
|
22652
|
-
text: JSON.stringify(CHART_OF_ACCOUNTS
|
|
23602
|
+
text: JSON.stringify(CHART_OF_ACCOUNTS)
|
|
22653
23603
|
}]
|
|
22654
23604
|
})
|
|
22655
23605
|
);
|
|
@@ -22664,7 +23614,7 @@ function registerSchemaResources(server2) {
|
|
|
22664
23614
|
contents: [{
|
|
22665
23615
|
uri: uri.href,
|
|
22666
23616
|
mimeType: "application/json",
|
|
22667
|
-
text: JSON.stringify(VAT_RATES
|
|
23617
|
+
text: JSON.stringify(VAT_RATES)
|
|
22668
23618
|
}]
|
|
22669
23619
|
})
|
|
22670
23620
|
);
|
|
@@ -22687,7 +23637,7 @@ function registerCompanyResources(server2) {
|
|
|
22687
23637
|
contents: [{
|
|
22688
23638
|
uri: uri.href,
|
|
22689
23639
|
mimeType: "application/json",
|
|
22690
|
-
text: JSON.stringify(company || {}
|
|
23640
|
+
text: JSON.stringify(company || {})
|
|
22691
23641
|
}]
|
|
22692
23642
|
};
|
|
22693
23643
|
}
|
|
@@ -22703,11 +23653,16 @@ function registerCompanyResources(server2) {
|
|
|
22703
23653
|
const supabase2 = getSupabase();
|
|
22704
23654
|
const companyId2 = getCompanyId();
|
|
22705
23655
|
const { data: fiscalYears } = await supabase2.from("fiscal_years").select("id, start_date, end_date, is_closed, created_at").eq("company_id", companyId2).order("start_date", { ascending: false });
|
|
23656
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
23657
|
+
const enriched = (fiscalYears || []).map((fy) => ({
|
|
23658
|
+
...fy,
|
|
23659
|
+
is_current: !fy.is_closed && fy.start_date <= today && fy.end_date >= today
|
|
23660
|
+
}));
|
|
22706
23661
|
return {
|
|
22707
23662
|
contents: [{
|
|
22708
23663
|
uri: uri.href,
|
|
22709
23664
|
mimeType: "application/json",
|
|
22710
|
-
text: JSON.stringify(
|
|
23665
|
+
text: JSON.stringify(enriched)
|
|
22711
23666
|
}]
|
|
22712
23667
|
};
|
|
22713
23668
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kirjapro/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server for Finnish accounting — exposes Kirjapro bookkeeping, invoicing, and reporting tools to Claude Code, Claude Desktop, and any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|