@kirjapro/mcp-server 0.3.0 → 0.5.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 +46 -0
- package/README.md +15 -3
- package/dist/index.js +1441 -468
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -21031,16 +21031,66 @@ function toErrorContent(message) {
|
|
|
21031
21031
|
isError: true
|
|
21032
21032
|
};
|
|
21033
21033
|
}
|
|
21034
|
+
var SAFE_ERROR_MAP = {
|
|
21035
|
+
PGRST116: "Record not found.",
|
|
21036
|
+
PGRST301: "Request timed out. Try a narrower query.",
|
|
21037
|
+
"23505": "A record with this value already exists (duplicate).",
|
|
21038
|
+
"23503": "Referenced record does not exist.",
|
|
21039
|
+
"23514": "Value violates a constraint. Check your input.",
|
|
21040
|
+
"42501": "Permission denied.",
|
|
21041
|
+
"42P01": "Data not available."
|
|
21042
|
+
};
|
|
21043
|
+
function sanitizeDbError(context, error2) {
|
|
21044
|
+
console.error(`[kirjapro-mcp] ${context}:`, {
|
|
21045
|
+
code: error2.code,
|
|
21046
|
+
message: error2.message,
|
|
21047
|
+
details: error2.details,
|
|
21048
|
+
hint: error2.hint
|
|
21049
|
+
});
|
|
21050
|
+
if (error2.code && SAFE_ERROR_MAP[error2.code]) {
|
|
21051
|
+
return `${context}: ${SAFE_ERROR_MAP[error2.code]}`;
|
|
21052
|
+
}
|
|
21053
|
+
if (error2.message) {
|
|
21054
|
+
const stripped = error2.message.replace(/relation "[^"]*"/g, "table").replace(/constraint "[^"]*"/g, "constraint").replace(/column "[^"]*"/g, "column").replace(/schema "[^"]*"/g, "schema").replace(/function "[^"]*"/g, "function");
|
|
21055
|
+
return `${context}: ${stripped}`;
|
|
21056
|
+
}
|
|
21057
|
+
return `${context}. Please try again.`;
|
|
21058
|
+
}
|
|
21059
|
+
async function invokeEdgeFunction(supabase2, name, body) {
|
|
21060
|
+
const { data, error: error2 } = await supabase2.functions.invoke(name, { body });
|
|
21061
|
+
if (error2) {
|
|
21062
|
+
console.error(`[kirjapro-mcp] Edge function "${name}" error:`, error2.message);
|
|
21063
|
+
return { data: null, error: `Edge function "${name}" failed: ${error2.message}` };
|
|
21064
|
+
}
|
|
21065
|
+
return { data, error: null };
|
|
21066
|
+
}
|
|
21034
21067
|
function roundCents(amount) {
|
|
21035
21068
|
return Math.round(amount * 100) / 100;
|
|
21036
21069
|
}
|
|
21070
|
+
function addDaysToIsoDate(date3, days) {
|
|
21071
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date3);
|
|
21072
|
+
if (!match) {
|
|
21073
|
+
throw new Error(`Invalid ISO date: ${date3}`);
|
|
21074
|
+
}
|
|
21075
|
+
const year = Number.parseInt(match[1], 10);
|
|
21076
|
+
const month = Number.parseInt(match[2], 10);
|
|
21077
|
+
const day = Number.parseInt(match[3], 10);
|
|
21078
|
+
const utcDate = new Date(Date.UTC(year, month - 1, day));
|
|
21079
|
+
utcDate.setUTCDate(utcDate.getUTCDate() + days);
|
|
21080
|
+
return utcDate.toISOString().slice(0, 10);
|
|
21081
|
+
}
|
|
21082
|
+
function isUuid(value) {
|
|
21083
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
21084
|
+
value
|
|
21085
|
+
);
|
|
21086
|
+
}
|
|
21037
21087
|
function escapeLike(input) {
|
|
21038
21088
|
return input.replace(/[%_\\]/g, "\\$&");
|
|
21039
21089
|
}
|
|
21040
21090
|
async function resolveOpenFiscalYear(supabase2, companyId2) {
|
|
21041
21091
|
const { data, error: error2 } = await supabase2.from("fiscal_years").select("id").eq("company_id", companyId2).eq("is_closed", false);
|
|
21042
21092
|
if (error2) {
|
|
21043
|
-
return toErrorContent(
|
|
21093
|
+
return toErrorContent(sanitizeDbError("Failed to query fiscal years", error2));
|
|
21044
21094
|
}
|
|
21045
21095
|
if (!data || data.length === 0) {
|
|
21046
21096
|
return toErrorContent(
|
|
@@ -21087,7 +21137,7 @@ function registerAccountTools(server2) {
|
|
|
21087
21137
|
query = query.range(offset, offset + limit - 1);
|
|
21088
21138
|
const { data, count, error: error2 } = await query;
|
|
21089
21139
|
if (error2) {
|
|
21090
|
-
return toErrorContent(
|
|
21140
|
+
return toErrorContent(sanitizeDbError("Failed to list accounts", error2));
|
|
21091
21141
|
}
|
|
21092
21142
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
21093
21143
|
}
|
|
@@ -21114,15 +21164,18 @@ function registerTransactionTools(server2) {
|
|
|
21114
21164
|
if ("isError" in resolved) return resolved;
|
|
21115
21165
|
fiscalYearId = resolved.id;
|
|
21116
21166
|
}
|
|
21117
|
-
const { data, count, error: error2 } = await supabase2.from("transactions").select(
|
|
21167
|
+
const { data, count, error: error2 } = await supabase2.from("transactions").select(
|
|
21168
|
+
`
|
|
21118
21169
|
id, transaction_date, description, document_number, created_at,
|
|
21119
21170
|
journal_entries (
|
|
21120
21171
|
id, account_id, debit_amount, credit_amount,
|
|
21121
21172
|
accounts ( account_number, name )
|
|
21122
21173
|
)
|
|
21123
|
-
`,
|
|
21174
|
+
`,
|
|
21175
|
+
{ count: "exact" }
|
|
21176
|
+
).eq("company_id", companyId2).eq("fiscal_year_id", fiscalYearId).order("transaction_date", { ascending: false }).range(offset, offset + limit - 1);
|
|
21124
21177
|
if (error2) {
|
|
21125
|
-
return toErrorContent(
|
|
21178
|
+
return toErrorContent(sanitizeDbError("Failed to list transactions", error2));
|
|
21126
21179
|
}
|
|
21127
21180
|
const transactions = (data || []).map((t) => ({
|
|
21128
21181
|
id: t.id,
|
|
@@ -21135,10 +21188,16 @@ function registerTransactionTools(server2) {
|
|
|
21135
21188
|
credit: e.credit_amount || 0
|
|
21136
21189
|
})),
|
|
21137
21190
|
total_debit: roundCents(
|
|
21138
|
-
(t.journal_entries || []).reduce(
|
|
21191
|
+
(t.journal_entries || []).reduce(
|
|
21192
|
+
(s, e) => s + (e.debit_amount || 0),
|
|
21193
|
+
0
|
|
21194
|
+
)
|
|
21139
21195
|
),
|
|
21140
21196
|
total_credit: roundCents(
|
|
21141
|
-
(t.journal_entries || []).reduce(
|
|
21197
|
+
(t.journal_entries || []).reduce(
|
|
21198
|
+
(s, e) => s + (e.credit_amount || 0),
|
|
21199
|
+
0
|
|
21200
|
+
)
|
|
21142
21201
|
)
|
|
21143
21202
|
}));
|
|
21144
21203
|
return toTextContent(paginate(transactions, count || 0, offset, limit));
|
|
@@ -21160,13 +21219,16 @@ function registerTransactionTools(server2) {
|
|
|
21160
21219
|
const supabase2 = getSupabase();
|
|
21161
21220
|
const companyId2 = getCompanyId();
|
|
21162
21221
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21163
|
-
let query = supabase2.from("transactions").select(
|
|
21222
|
+
let query = supabase2.from("transactions").select(
|
|
21223
|
+
`
|
|
21164
21224
|
id, transaction_date, description, document_number, created_at,
|
|
21165
21225
|
journal_entries (
|
|
21166
21226
|
id, debit_amount, credit_amount,
|
|
21167
21227
|
accounts ( account_number, name )
|
|
21168
21228
|
)
|
|
21169
|
-
`,
|
|
21229
|
+
`,
|
|
21230
|
+
{ count: "exact" }
|
|
21231
|
+
).eq("company_id", companyId2).order("transaction_date", { ascending: false });
|
|
21170
21232
|
if (params.query) {
|
|
21171
21233
|
query = query.ilike("description", `%${escapeLike(params.query)}%`);
|
|
21172
21234
|
}
|
|
@@ -21176,67 +21238,81 @@ function registerTransactionTools(server2) {
|
|
|
21176
21238
|
if (params.dateTo) {
|
|
21177
21239
|
query = query.lte("transaction_date", params.dateTo);
|
|
21178
21240
|
}
|
|
21179
|
-
|
|
21241
|
+
const hasAmountFilter = params.minAmount !== void 0 || params.maxAmount !== void 0;
|
|
21242
|
+
const AMOUNT_FILTER_CAP = 1e3;
|
|
21243
|
+
if (hasAmountFilter) {
|
|
21244
|
+
query = query.range(0, AMOUNT_FILTER_CAP - 1);
|
|
21245
|
+
} else {
|
|
21246
|
+
query = query.range(offset, offset + limit - 1);
|
|
21247
|
+
}
|
|
21180
21248
|
const { data, count, error: error2 } = await query;
|
|
21181
21249
|
if (error2) {
|
|
21182
|
-
return toErrorContent(
|
|
21183
|
-
|
|
21184
|
-
let results = (data || []).map((t) => {
|
|
21185
|
-
const totalDebit = roundCents(
|
|
21186
|
-
(t.journal_entries || []).reduce((s, e) => s + (e.debit_amount || 0), 0)
|
|
21187
|
-
);
|
|
21188
|
-
const totalCredit = roundCents(
|
|
21189
|
-
(t.journal_entries || []).reduce((s, e) => s + (e.credit_amount || 0), 0)
|
|
21250
|
+
return toErrorContent(
|
|
21251
|
+
"Failed to search transactions. Check your filters and try again."
|
|
21190
21252
|
);
|
|
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
|
-
};
|
|
21204
|
-
});
|
|
21205
|
-
if (params.minAmount !== void 0) {
|
|
21206
|
-
results = results.filter((t) => t.total_debit >= params.minAmount);
|
|
21207
21253
|
}
|
|
21208
|
-
|
|
21209
|
-
|
|
21254
|
+
let results = (data || []).map(
|
|
21255
|
+
(t) => {
|
|
21256
|
+
const totalDebit = roundCents(
|
|
21257
|
+
(t.journal_entries || []).reduce(
|
|
21258
|
+
(s, e) => s + (e.debit_amount || 0),
|
|
21259
|
+
0
|
|
21260
|
+
)
|
|
21261
|
+
);
|
|
21262
|
+
const totalCredit = roundCents(
|
|
21263
|
+
(t.journal_entries || []).reduce(
|
|
21264
|
+
(s, e) => s + (e.credit_amount || 0),
|
|
21265
|
+
0
|
|
21266
|
+
)
|
|
21267
|
+
);
|
|
21268
|
+
return {
|
|
21269
|
+
id: t.id,
|
|
21270
|
+
transaction_date: t.transaction_date,
|
|
21271
|
+
description: t.description,
|
|
21272
|
+
document_number: t.document_number,
|
|
21273
|
+
entries: (t.journal_entries || []).map((e) => ({
|
|
21274
|
+
account: `${e.accounts?.account_number} ${e.accounts?.name}`,
|
|
21275
|
+
debit: e.debit_amount || 0,
|
|
21276
|
+
credit: e.credit_amount || 0
|
|
21277
|
+
})),
|
|
21278
|
+
total_debit: totalDebit,
|
|
21279
|
+
total_credit: totalCredit
|
|
21280
|
+
};
|
|
21281
|
+
}
|
|
21282
|
+
);
|
|
21283
|
+
if (hasAmountFilter) {
|
|
21284
|
+
if (params.minAmount !== void 0) {
|
|
21285
|
+
results = results.filter((t) => t.total_debit >= params.minAmount);
|
|
21286
|
+
}
|
|
21287
|
+
if (params.maxAmount !== void 0) {
|
|
21288
|
+
results = results.filter((t) => t.total_debit <= params.maxAmount);
|
|
21289
|
+
}
|
|
21290
|
+
const filteredTotal = results.length;
|
|
21291
|
+
const paginatedResults = results.slice(offset, offset + limit);
|
|
21292
|
+
const cappedWarning = (count || 0) >= AMOUNT_FILTER_CAP ? " (results capped at 1000 rows \u2014 narrow your date range for complete results)" : "";
|
|
21293
|
+
return toTextContent({
|
|
21294
|
+
...paginate(paginatedResults, filteredTotal, offset, limit),
|
|
21295
|
+
amount_filter_note: `Filtered client-side from ${count || 0} transactions${cappedWarning}`
|
|
21296
|
+
});
|
|
21210
21297
|
}
|
|
21211
|
-
|
|
21212
|
-
return toTextContent(paginate(
|
|
21213
|
-
results,
|
|
21214
|
-
hasAmountFilter ? results.length : count || 0,
|
|
21215
|
-
offset,
|
|
21216
|
-
limit
|
|
21217
|
-
));
|
|
21298
|
+
return toTextContent(paginate(results, count || 0, offset, limit));
|
|
21218
21299
|
}
|
|
21219
21300
|
);
|
|
21220
21301
|
server2.tool(
|
|
21221
21302
|
"kirjapro_create_transaction",
|
|
21222
|
-
|
|
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.`,
|
|
21303
|
+
"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.",
|
|
21230
21304
|
{
|
|
21231
21305
|
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21232
21306
|
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
21233
21307
|
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
21234
21308
|
documentNumber: external_exports.string().optional().describe("Document/receipt number"),
|
|
21235
|
-
entries: external_exports.array(
|
|
21236
|
-
|
|
21237
|
-
|
|
21238
|
-
|
|
21239
|
-
|
|
21309
|
+
entries: external_exports.array(
|
|
21310
|
+
external_exports.object({
|
|
21311
|
+
accountNumber: external_exports.string().describe('Account number (e.g. "4000") or UUID'),
|
|
21312
|
+
debitAmount: external_exports.number().min(0).default(0).describe("Debit amount in EUR"),
|
|
21313
|
+
creditAmount: external_exports.number().min(0).default(0).describe("Credit amount in EUR")
|
|
21314
|
+
})
|
|
21315
|
+
).min(2).describe("Journal entries (min 2, must balance)")
|
|
21240
21316
|
},
|
|
21241
21317
|
async (params) => {
|
|
21242
21318
|
const supabase2 = getSupabase();
|
|
@@ -21263,52 +21339,85 @@ Prefer kirjapro_create_from_template for standard patterns.`,
|
|
|
21263
21339
|
`Transaction date ${params.transactionDate} is outside fiscal year range (${fy.start_date} to ${fy.end_date}).`
|
|
21264
21340
|
);
|
|
21265
21341
|
}
|
|
21266
|
-
const
|
|
21267
|
-
const
|
|
21342
|
+
const accountIdentifiers = params.entries.map((e) => e.accountNumber);
|
|
21343
|
+
const uuidIdentifiers = [
|
|
21344
|
+
...new Set(
|
|
21345
|
+
accountIdentifiers.filter((identifier) => isUuid(identifier))
|
|
21346
|
+
)
|
|
21347
|
+
];
|
|
21348
|
+
const numberIdentifiers = [
|
|
21349
|
+
...new Set(
|
|
21350
|
+
accountIdentifiers.filter((identifier) => !isUuid(identifier))
|
|
21351
|
+
)
|
|
21352
|
+
];
|
|
21353
|
+
const [accountsByNumber, accountsById] = await Promise.all([
|
|
21354
|
+
numberIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", numberIdentifiers) : Promise.resolve({ data: [], error: null }),
|
|
21355
|
+
uuidIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("id", uuidIdentifiers) : Promise.resolve({ data: [], error: null })
|
|
21356
|
+
]);
|
|
21357
|
+
const accError = accountsByNumber.error || accountsById.error;
|
|
21268
21358
|
if (accError) {
|
|
21269
|
-
return toErrorContent(
|
|
21359
|
+
return toErrorContent(
|
|
21360
|
+
sanitizeDbError("Failed to resolve accounts", accError)
|
|
21361
|
+
);
|
|
21362
|
+
}
|
|
21363
|
+
const accounts = [
|
|
21364
|
+
...accountsByNumber.data || [],
|
|
21365
|
+
...accountsById.data || []
|
|
21366
|
+
];
|
|
21367
|
+
const accountMap = /* @__PURE__ */ new Map();
|
|
21368
|
+
for (const account of accounts) {
|
|
21369
|
+
accountMap.set(account.account_number, account.id);
|
|
21370
|
+
accountMap.set(account.id, account.id);
|
|
21270
21371
|
}
|
|
21271
|
-
const
|
|
21272
|
-
|
|
21372
|
+
const missingAccounts = accountIdentifiers.filter(
|
|
21373
|
+
(identifier) => !accountMap.has(identifier)
|
|
21374
|
+
);
|
|
21273
21375
|
if (missingAccounts.length > 0) {
|
|
21274
21376
|
return toErrorContent(
|
|
21275
|
-
`
|
|
21377
|
+
`Accounts not found: ${missingAccounts.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
|
|
21276
21378
|
);
|
|
21277
21379
|
}
|
|
21278
|
-
const totalDebit = roundCents(
|
|
21279
|
-
|
|
21380
|
+
const totalDebit = roundCents(
|
|
21381
|
+
params.entries.reduce((s, e) => s + e.debitAmount, 0)
|
|
21382
|
+
);
|
|
21383
|
+
const totalCredit = roundCents(
|
|
21384
|
+
params.entries.reduce((s, e) => s + e.creditAmount, 0)
|
|
21385
|
+
);
|
|
21280
21386
|
if (totalDebit !== totalCredit) {
|
|
21281
21387
|
return toErrorContent(
|
|
21282
21388
|
`Transaction is unbalanced: total debits (${totalDebit.toFixed(2)}) != total credits (${totalCredit.toFixed(2)}). Difference: ${Math.abs(totalDebit - totalCredit).toFixed(2)} EUR. Add a ${totalDebit > totalCredit ? "credit" : "debit"} entry of ${Math.abs(totalDebit - totalCredit).toFixed(2)} to balance.`
|
|
21283
21389
|
);
|
|
21284
21390
|
}
|
|
21285
|
-
const { data: transaction, error: txError } = await supabase2.from("transactions").insert({
|
|
21286
|
-
company_id: companyId2,
|
|
21287
|
-
fiscal_year_id: fiscalYearId,
|
|
21288
|
-
transaction_date: params.transactionDate,
|
|
21289
|
-
description: params.description,
|
|
21290
|
-
document_number: params.documentNumber || null
|
|
21291
|
-
}).select().single();
|
|
21292
|
-
if (txError) {
|
|
21293
|
-
return toErrorContent(`Failed to create transaction: ${txError.message}`);
|
|
21294
|
-
}
|
|
21295
21391
|
const journalEntries = params.entries.map((e) => ({
|
|
21296
|
-
transaction_id: transaction.id,
|
|
21297
21392
|
account_id: accountMap.get(e.accountNumber),
|
|
21298
21393
|
debit_amount: e.debitAmount,
|
|
21299
21394
|
credit_amount: e.creditAmount
|
|
21300
21395
|
}));
|
|
21301
|
-
const { error:
|
|
21302
|
-
|
|
21303
|
-
|
|
21396
|
+
const { data: transactionId, error: txError } = await supabase2.rpc(
|
|
21397
|
+
"create_transaction_with_entries",
|
|
21398
|
+
{
|
|
21399
|
+
p_company_id: companyId2,
|
|
21400
|
+
p_fiscal_year_id: fiscalYearId,
|
|
21401
|
+
p_transaction_date: params.transactionDate,
|
|
21402
|
+
p_description: params.description,
|
|
21403
|
+
p_document_number: params.documentNumber || "",
|
|
21404
|
+
p_entries: journalEntries
|
|
21405
|
+
}
|
|
21406
|
+
);
|
|
21407
|
+
if (txError || !transactionId) {
|
|
21408
|
+
return toErrorContent(
|
|
21409
|
+
txError ? sanitizeDbError("Failed to create transaction", txError) : "Failed to create transaction: no ID returned."
|
|
21410
|
+
);
|
|
21304
21411
|
}
|
|
21305
|
-
const { data: complete } = await supabase2.from("transactions").select(
|
|
21412
|
+
const { data: complete } = await supabase2.from("transactions").select(
|
|
21413
|
+
`
|
|
21306
21414
|
id, transaction_date, description, document_number, created_at,
|
|
21307
21415
|
journal_entries (
|
|
21308
21416
|
id, debit_amount, credit_amount,
|
|
21309
21417
|
accounts ( account_number, name )
|
|
21310
21418
|
)
|
|
21311
|
-
`
|
|
21419
|
+
`
|
|
21420
|
+
).eq("id", transactionId).single();
|
|
21312
21421
|
return toTextContent({
|
|
21313
21422
|
message: "Transaction created successfully",
|
|
21314
21423
|
transaction: complete
|
|
@@ -21317,26 +21426,23 @@ Prefer kirjapro_create_from_template for standard patterns.`,
|
|
|
21317
21426
|
);
|
|
21318
21427
|
server2.tool(
|
|
21319
21428
|
"kirjapro_create_transactions_batch",
|
|
21320
|
-
|
|
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.`,
|
|
21429
|
+
"Create multiple transactions in one call (up to 50). Each must balance (debits = credits) and is validated independently. Prefer kirjapro_create_from_template_batch for standard patterns.",
|
|
21328
21430
|
{
|
|
21329
21431
|
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
21330
|
-
transactions: external_exports.array(
|
|
21331
|
-
|
|
21332
|
-
|
|
21333
|
-
|
|
21334
|
-
|
|
21335
|
-
|
|
21336
|
-
|
|
21337
|
-
|
|
21338
|
-
|
|
21339
|
-
|
|
21432
|
+
transactions: external_exports.array(
|
|
21433
|
+
external_exports.object({
|
|
21434
|
+
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
|
|
21435
|
+
description: external_exports.string().min(1).max(500),
|
|
21436
|
+
documentNumber: external_exports.string().optional(),
|
|
21437
|
+
entries: external_exports.array(
|
|
21438
|
+
external_exports.object({
|
|
21439
|
+
accountNumber: external_exports.string().describe('Account number (e.g. "4000")'),
|
|
21440
|
+
debitAmount: external_exports.number().min(0).default(0),
|
|
21441
|
+
creditAmount: external_exports.number().min(0).default(0)
|
|
21442
|
+
})
|
|
21443
|
+
).min(2)
|
|
21444
|
+
})
|
|
21445
|
+
).min(1).max(50).describe("Array of transactions to create (max 50)")
|
|
21340
21446
|
},
|
|
21341
21447
|
async (params) => {
|
|
21342
21448
|
const supabase2 = getSupabase();
|
|
@@ -21352,55 +21458,104 @@ Prefer kirjapro_create_from_template_batch for standard patterns.`,
|
|
|
21352
21458
|
return toErrorContent(`Fiscal year ${fiscalYearId} not found.`);
|
|
21353
21459
|
}
|
|
21354
21460
|
if (fy.is_closed) {
|
|
21355
|
-
return toErrorContent(
|
|
21461
|
+
return toErrorContent(
|
|
21462
|
+
`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`
|
|
21463
|
+
);
|
|
21464
|
+
}
|
|
21465
|
+
const allAccountNumbers = [
|
|
21466
|
+
...new Set(
|
|
21467
|
+
params.transactions.flatMap(
|
|
21468
|
+
(t) => t.entries.map((e) => e.accountNumber)
|
|
21469
|
+
)
|
|
21470
|
+
)
|
|
21471
|
+
];
|
|
21472
|
+
const uuidIdentifiers = allAccountNumbers.filter(
|
|
21473
|
+
(identifier) => isUuid(identifier)
|
|
21474
|
+
);
|
|
21475
|
+
const numberIdentifiers = allAccountNumbers.filter(
|
|
21476
|
+
(identifier) => !isUuid(identifier)
|
|
21477
|
+
);
|
|
21478
|
+
const [accountsByNumber, accountsById] = await Promise.all([
|
|
21479
|
+
numberIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", numberIdentifiers) : Promise.resolve({ data: [], error: null }),
|
|
21480
|
+
uuidIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("id", uuidIdentifiers) : Promise.resolve({ data: [], error: null })
|
|
21481
|
+
]);
|
|
21482
|
+
const accError = accountsByNumber.error || accountsById.error;
|
|
21483
|
+
if (accError) {
|
|
21484
|
+
return toErrorContent(
|
|
21485
|
+
sanitizeDbError("Failed to resolve accounts", accError)
|
|
21486
|
+
);
|
|
21487
|
+
}
|
|
21488
|
+
const accounts = [
|
|
21489
|
+
...accountsByNumber.data || [],
|
|
21490
|
+
...accountsById.data || []
|
|
21491
|
+
];
|
|
21492
|
+
const accountMap = /* @__PURE__ */ new Map();
|
|
21493
|
+
for (const account of accounts) {
|
|
21494
|
+
accountMap.set(account.account_number, account.id);
|
|
21495
|
+
accountMap.set(account.id, account.id);
|
|
21356
21496
|
}
|
|
21357
|
-
const allAccountNumbers = [...new Set(
|
|
21358
|
-
params.transactions.flatMap((t) => t.entries.map((e) => e.accountNumber))
|
|
21359
|
-
)];
|
|
21360
|
-
const { data: accounts } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", allAccountNumbers);
|
|
21361
|
-
const accountMap = new Map((accounts || []).map((a) => [a.account_number, a.id]));
|
|
21362
21497
|
const created = [];
|
|
21363
21498
|
const failed = [];
|
|
21364
21499
|
for (let i = 0; i < params.transactions.length; i++) {
|
|
21365
21500
|
const tx = params.transactions[i];
|
|
21366
21501
|
if (tx.transactionDate < fy.start_date || tx.transactionDate > fy.end_date) {
|
|
21367
|
-
failed.push({
|
|
21502
|
+
failed.push({
|
|
21503
|
+
index: i,
|
|
21504
|
+
error: `Date ${tx.transactionDate} outside fiscal year range.`
|
|
21505
|
+
});
|
|
21368
21506
|
continue;
|
|
21369
21507
|
}
|
|
21370
|
-
const missing = tx.entries.filter(
|
|
21508
|
+
const missing = tx.entries.filter(
|
|
21509
|
+
(e) => !accountMap.has(e.accountNumber)
|
|
21510
|
+
);
|
|
21371
21511
|
if (missing.length > 0) {
|
|
21372
|
-
failed.push({
|
|
21512
|
+
failed.push({
|
|
21513
|
+
index: i,
|
|
21514
|
+
error: `Unknown accounts: ${missing.map((m) => m.accountNumber).join(", ")}`
|
|
21515
|
+
});
|
|
21373
21516
|
continue;
|
|
21374
21517
|
}
|
|
21375
|
-
const totalDebit = roundCents(
|
|
21376
|
-
|
|
21518
|
+
const totalDebit = roundCents(
|
|
21519
|
+
tx.entries.reduce((s, e) => s + e.debitAmount, 0)
|
|
21520
|
+
);
|
|
21521
|
+
const totalCredit = roundCents(
|
|
21522
|
+
tx.entries.reduce((s, e) => s + e.creditAmount, 0)
|
|
21523
|
+
);
|
|
21377
21524
|
if (totalDebit !== totalCredit) {
|
|
21378
|
-
failed.push({
|
|
21379
|
-
|
|
21380
|
-
|
|
21381
|
-
|
|
21382
|
-
company_id: companyId2,
|
|
21383
|
-
fiscal_year_id: fiscalYearId,
|
|
21384
|
-
transaction_date: tx.transactionDate,
|
|
21385
|
-
description: tx.description,
|
|
21386
|
-
document_number: tx.documentNumber || null
|
|
21387
|
-
}).select("id").single();
|
|
21388
|
-
if (txErr) {
|
|
21389
|
-
failed.push({ index: i, error: txErr.message });
|
|
21525
|
+
failed.push({
|
|
21526
|
+
index: i,
|
|
21527
|
+
error: `Unbalanced: debits ${totalDebit} != credits ${totalCredit}`
|
|
21528
|
+
});
|
|
21390
21529
|
continue;
|
|
21391
21530
|
}
|
|
21392
21531
|
const entries = tx.entries.map((e) => ({
|
|
21393
|
-
transaction_id: newTx.id,
|
|
21394
21532
|
account_id: accountMap.get(e.accountNumber),
|
|
21395
21533
|
debit_amount: e.debitAmount,
|
|
21396
21534
|
credit_amount: e.creditAmount
|
|
21397
21535
|
}));
|
|
21398
|
-
const { error:
|
|
21399
|
-
|
|
21400
|
-
|
|
21536
|
+
const { data: newTxId, error: txErr } = await supabase2.rpc(
|
|
21537
|
+
"create_transaction_with_entries",
|
|
21538
|
+
{
|
|
21539
|
+
p_company_id: companyId2,
|
|
21540
|
+
p_fiscal_year_id: fiscalYearId,
|
|
21541
|
+
p_transaction_date: tx.transactionDate,
|
|
21542
|
+
p_description: tx.description,
|
|
21543
|
+
p_document_number: tx.documentNumber || "",
|
|
21544
|
+
p_entries: entries
|
|
21545
|
+
}
|
|
21546
|
+
);
|
|
21547
|
+
if (txErr || !newTxId) {
|
|
21548
|
+
failed.push({
|
|
21549
|
+
index: i,
|
|
21550
|
+
error: txErr?.message || "No transaction ID returned"
|
|
21551
|
+
});
|
|
21401
21552
|
continue;
|
|
21402
21553
|
}
|
|
21403
|
-
created.push({
|
|
21554
|
+
created.push({
|
|
21555
|
+
id: newTxId,
|
|
21556
|
+
description: tx.description,
|
|
21557
|
+
date: tx.transactionDate
|
|
21558
|
+
});
|
|
21404
21559
|
}
|
|
21405
21560
|
return toTextContent({
|
|
21406
21561
|
created_count: created.length,
|
|
@@ -21419,13 +21574,15 @@ Prefer kirjapro_create_from_template_batch for standard patterns.`,
|
|
|
21419
21574
|
async (params) => {
|
|
21420
21575
|
const supabase2 = getSupabase();
|
|
21421
21576
|
const companyId2 = getCompanyId();
|
|
21422
|
-
const { data: transaction, error: error2 } = await supabase2.from("transactions").select(
|
|
21577
|
+
const { data: transaction, error: error2 } = await supabase2.from("transactions").select(
|
|
21578
|
+
`
|
|
21423
21579
|
id, transaction_date, description, document_number, created_at,
|
|
21424
21580
|
journal_entries (
|
|
21425
21581
|
id, account_id, debit_amount, credit_amount, description,
|
|
21426
21582
|
accounts ( account_number, name, account_type )
|
|
21427
21583
|
)
|
|
21428
|
-
`
|
|
21584
|
+
`
|
|
21585
|
+
).eq("id", params.transactionId).eq("company_id", companyId2).single();
|
|
21429
21586
|
if (error2 || !transaction) {
|
|
21430
21587
|
return toErrorContent(
|
|
21431
21588
|
`Transaction ${params.transactionId} not found. Use kirjapro_list_transactions to find transaction IDs.`
|
|
@@ -21480,12 +21637,6 @@ function generateReferenceNumber(baseNumber) {
|
|
|
21480
21637
|
const checkDigit = (10 - sum % 10) % 10;
|
|
21481
21638
|
return digits + checkDigit;
|
|
21482
21639
|
}
|
|
21483
|
-
function generateInvoiceNumber(lastNumber, prefix = "INV") {
|
|
21484
|
-
if (!lastNumber) return `${prefix}00001`;
|
|
21485
|
-
const numPart = lastNumber.replace(/\D/g, "");
|
|
21486
|
-
const next = parseInt(numPart || "0") + 1;
|
|
21487
|
-
return `${prefix}${next.toString().padStart(5, "0")}`;
|
|
21488
|
-
}
|
|
21489
21640
|
function registerInvoiceTools(server2) {
|
|
21490
21641
|
server2.tool(
|
|
21491
21642
|
"kirjapro_list_invoices",
|
|
@@ -21499,18 +21650,21 @@ function registerInvoiceTools(server2) {
|
|
|
21499
21650
|
const supabase2 = getSupabase();
|
|
21500
21651
|
const companyId2 = getCompanyId();
|
|
21501
21652
|
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
21502
|
-
let query = supabase2.from("invoices").select(
|
|
21653
|
+
let query = supabase2.from("invoices").select(
|
|
21654
|
+
`
|
|
21503
21655
|
id, invoice_number, reference_number, invoice_date, due_date,
|
|
21504
21656
|
status, subtotal, vat_total, total, notes,
|
|
21505
21657
|
customers ( name, business_id )
|
|
21506
|
-
`,
|
|
21658
|
+
`,
|
|
21659
|
+
{ count: "exact" }
|
|
21660
|
+
).eq("company_id", companyId2).order("invoice_date", { ascending: false });
|
|
21507
21661
|
if (params.status) {
|
|
21508
21662
|
query = query.eq("status", params.status);
|
|
21509
21663
|
}
|
|
21510
21664
|
query = query.range(offset, offset + limit - 1);
|
|
21511
21665
|
const { data, count, error: error2 } = await query;
|
|
21512
21666
|
if (error2) {
|
|
21513
|
-
return toErrorContent(
|
|
21667
|
+
return toErrorContent(sanitizeDbError("Failed to list invoices", error2));
|
|
21514
21668
|
}
|
|
21515
21669
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
21516
21670
|
}
|
|
@@ -21523,13 +21677,15 @@ function registerInvoiceTools(server2) {
|
|
|
21523
21677
|
invoiceDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Invoice date (YYYY-MM-DD)"),
|
|
21524
21678
|
paymentTermDays: external_exports.number().min(0).max(365).default(14).describe("Payment term in days (default 14)"),
|
|
21525
21679
|
notes: external_exports.string().optional().describe("Notes printed on the invoice"),
|
|
21526
|
-
lines: external_exports.array(
|
|
21527
|
-
|
|
21528
|
-
|
|
21529
|
-
|
|
21530
|
-
|
|
21531
|
-
|
|
21532
|
-
|
|
21680
|
+
lines: external_exports.array(
|
|
21681
|
+
external_exports.object({
|
|
21682
|
+
description: external_exports.string().min(1).describe("Line item description"),
|
|
21683
|
+
quantity: external_exports.number().min(0).describe("Quantity"),
|
|
21684
|
+
unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
|
|
21685
|
+
unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
|
|
21686
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
|
|
21687
|
+
})
|
|
21688
|
+
).min(1).describe("Invoice line items")
|
|
21533
21689
|
},
|
|
21534
21690
|
async (params) => {
|
|
21535
21691
|
const supabase2 = getSupabase();
|
|
@@ -21540,12 +21696,24 @@ function registerInvoiceTools(server2) {
|
|
|
21540
21696
|
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
21541
21697
|
);
|
|
21542
21698
|
}
|
|
21543
|
-
const { data:
|
|
21544
|
-
|
|
21545
|
-
|
|
21546
|
-
|
|
21547
|
-
|
|
21548
|
-
|
|
21699
|
+
const { data: invoiceNumber, error: numError } = await supabase2.rpc(
|
|
21700
|
+
"get_next_invoice_number",
|
|
21701
|
+
{
|
|
21702
|
+
p_company_id: companyId2
|
|
21703
|
+
}
|
|
21704
|
+
);
|
|
21705
|
+
if (numError || !invoiceNumber) {
|
|
21706
|
+
return toErrorContent(
|
|
21707
|
+
numError ? sanitizeDbError("Failed to generate invoice number", numError) : "Failed to generate invoice number."
|
|
21708
|
+
);
|
|
21709
|
+
}
|
|
21710
|
+
const referenceNumber = generateReferenceNumber(
|
|
21711
|
+
invoiceNumber.replace(/\D/g, "")
|
|
21712
|
+
);
|
|
21713
|
+
const dueDateStr = addDaysToIsoDate(
|
|
21714
|
+
params.invoiceDate,
|
|
21715
|
+
params.paymentTermDays
|
|
21716
|
+
);
|
|
21549
21717
|
const calculatedLines = params.lines.map((line, i) => {
|
|
21550
21718
|
const vatRate = parseFloat(line.vatRate);
|
|
21551
21719
|
const subtotal2 = roundCents(line.quantity * line.unitPrice);
|
|
@@ -21563,32 +21731,42 @@ function registerInvoiceTools(server2) {
|
|
|
21563
21731
|
total: total2
|
|
21564
21732
|
};
|
|
21565
21733
|
});
|
|
21566
|
-
const subtotal = roundCents(
|
|
21567
|
-
|
|
21734
|
+
const subtotal = roundCents(
|
|
21735
|
+
calculatedLines.reduce((s, l) => s + l.subtotal, 0)
|
|
21736
|
+
);
|
|
21737
|
+
const vatTotal = roundCents(
|
|
21738
|
+
calculatedLines.reduce((s, l) => s + l.vat_amount, 0)
|
|
21739
|
+
);
|
|
21568
21740
|
const total = roundCents(subtotal + vatTotal);
|
|
21569
|
-
const { data:
|
|
21570
|
-
|
|
21571
|
-
|
|
21572
|
-
|
|
21573
|
-
|
|
21574
|
-
|
|
21575
|
-
|
|
21576
|
-
|
|
21577
|
-
|
|
21578
|
-
|
|
21579
|
-
|
|
21580
|
-
|
|
21581
|
-
|
|
21582
|
-
|
|
21583
|
-
|
|
21741
|
+
const { data: invoiceId, error: invError } = await supabase2.rpc(
|
|
21742
|
+
"create_invoice_with_lines",
|
|
21743
|
+
{
|
|
21744
|
+
p_company_id: companyId2,
|
|
21745
|
+
p_customer_id: params.customerId,
|
|
21746
|
+
p_invoice_number: invoiceNumber,
|
|
21747
|
+
p_reference_number: referenceNumber,
|
|
21748
|
+
p_invoice_date: params.invoiceDate,
|
|
21749
|
+
p_due_date: dueDateStr,
|
|
21750
|
+
p_status: "draft",
|
|
21751
|
+
p_subtotal: subtotal,
|
|
21752
|
+
p_vat_total: vatTotal,
|
|
21753
|
+
p_total: total,
|
|
21754
|
+
p_notes: params.notes || "",
|
|
21755
|
+
p_lines: calculatedLines
|
|
21756
|
+
}
|
|
21757
|
+
);
|
|
21758
|
+
if (invError || !invoiceId) {
|
|
21759
|
+
return toErrorContent(
|
|
21760
|
+
invError ? sanitizeDbError("Failed to create invoice", invError) : "Failed to create invoice."
|
|
21761
|
+
);
|
|
21584
21762
|
}
|
|
21585
|
-
const
|
|
21586
|
-
|
|
21587
|
-
|
|
21588
|
-
|
|
21589
|
-
|
|
21590
|
-
|
|
21591
|
-
|
|
21763
|
+
const { data: invoice, error: fetchError } = await supabase2.from("invoices").select(
|
|
21764
|
+
"id, invoice_number, reference_number, invoice_date, due_date, status, subtotal, vat_total, total, notes, created_at"
|
|
21765
|
+
).eq("id", invoiceId).eq("company_id", companyId2).single();
|
|
21766
|
+
if (fetchError || !invoice) {
|
|
21767
|
+
return toErrorContent(
|
|
21768
|
+
fetchError ? sanitizeDbError("Invoice created but fetch failed", fetchError) : "Invoice created but could not be retrieved."
|
|
21769
|
+
);
|
|
21592
21770
|
}
|
|
21593
21771
|
return toTextContent({
|
|
21594
21772
|
message: `Invoice ${invoiceNumber} created for ${customer.name}`,
|
|
@@ -21614,23 +21792,32 @@ function registerInvoiceTools(server2) {
|
|
|
21614
21792
|
async (params) => {
|
|
21615
21793
|
const supabase2 = getSupabase();
|
|
21616
21794
|
const companyId2 = getCompanyId();
|
|
21617
|
-
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
21795
|
+
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
21796
|
+
`
|
|
21618
21797
|
id, invoice_number, total, status,
|
|
21619
21798
|
invoice_payments ( amount )
|
|
21620
|
-
`
|
|
21799
|
+
`
|
|
21800
|
+
).eq("id", params.invoiceId).eq("company_id", companyId2).single();
|
|
21621
21801
|
if (invError || !invoice) {
|
|
21622
21802
|
return toErrorContent(
|
|
21623
21803
|
`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
|
|
21624
21804
|
);
|
|
21625
21805
|
}
|
|
21626
21806
|
if (invoice.status === "paid") {
|
|
21627
|
-
return toErrorContent(
|
|
21807
|
+
return toErrorContent(
|
|
21808
|
+
`Invoice ${invoice.invoice_number} is already fully paid.`
|
|
21809
|
+
);
|
|
21628
21810
|
}
|
|
21629
21811
|
if (invoice.status === "cancelled") {
|
|
21630
|
-
return toErrorContent(
|
|
21812
|
+
return toErrorContent(
|
|
21813
|
+
`Invoice ${invoice.invoice_number} is cancelled and cannot accept payments.`
|
|
21814
|
+
);
|
|
21631
21815
|
}
|
|
21632
21816
|
const paidSoFar = roundCents(
|
|
21633
|
-
(invoice.invoice_payments || []).reduce(
|
|
21817
|
+
(invoice.invoice_payments || []).reduce(
|
|
21818
|
+
(s, p) => s + (p.amount || 0),
|
|
21819
|
+
0
|
|
21820
|
+
)
|
|
21634
21821
|
);
|
|
21635
21822
|
const remaining = roundCents(invoice.total - paidSoFar);
|
|
21636
21823
|
if (params.amount > remaining + 0.01) {
|
|
@@ -21638,33 +21825,29 @@ function registerInvoiceTools(server2) {
|
|
|
21638
21825
|
`Payment amount ${params.amount.toFixed(2)} exceeds remaining balance ${remaining.toFixed(2)} EUR on invoice ${invoice.invoice_number} (total: ${invoice.total.toFixed(2)}, paid: ${paidSoFar.toFixed(2)}). Reduce the payment amount to ${remaining.toFixed(2)} or less.`
|
|
21639
21826
|
);
|
|
21640
21827
|
}
|
|
21641
|
-
const { data:
|
|
21642
|
-
|
|
21643
|
-
|
|
21644
|
-
|
|
21645
|
-
|
|
21646
|
-
|
|
21647
|
-
|
|
21648
|
-
|
|
21649
|
-
|
|
21650
|
-
|
|
21651
|
-
}
|
|
21652
|
-
const newPaidTotal = roundCents(paidSoFar + params.amount);
|
|
21653
|
-
const newStatus = newPaidTotal >= invoice.total ? "paid" : invoice.status;
|
|
21654
|
-
if (newStatus !== invoice.status) {
|
|
21655
|
-
const { error: statusError } = await supabase2.from("invoices").update({
|
|
21656
|
-
status: newStatus,
|
|
21657
|
-
paid_date: newStatus === "paid" ? params.paymentDate : null
|
|
21658
|
-
}).eq("id", params.invoiceId);
|
|
21659
|
-
if (statusError) {
|
|
21660
|
-
return toErrorContent(`Payment recorded but invoice status update failed: ${statusError.message}`);
|
|
21828
|
+
const { data: paymentResult, error: payError } = await supabase2.rpc(
|
|
21829
|
+
"record_invoice_payment",
|
|
21830
|
+
{
|
|
21831
|
+
p_company_id: companyId2,
|
|
21832
|
+
p_invoice_id: params.invoiceId,
|
|
21833
|
+
p_payment_date: params.paymentDate,
|
|
21834
|
+
p_amount: params.amount,
|
|
21835
|
+
p_payment_method: params.paymentMethod,
|
|
21836
|
+
p_reference: params.reference || void 0,
|
|
21837
|
+
p_notes: params.notes || void 0
|
|
21661
21838
|
}
|
|
21839
|
+
);
|
|
21840
|
+
if (payError || !paymentResult) {
|
|
21841
|
+
return toErrorContent(
|
|
21842
|
+
payError ? sanitizeDbError("Failed to record payment", payError) : "Failed to record payment."
|
|
21843
|
+
);
|
|
21662
21844
|
}
|
|
21845
|
+
const result = paymentResult;
|
|
21663
21846
|
return toTextContent({
|
|
21664
21847
|
message: `Payment of ${params.amount.toFixed(2)} EUR recorded on invoice ${invoice.invoice_number}`,
|
|
21665
|
-
|
|
21666
|
-
invoice_status:
|
|
21667
|
-
remaining_balance:
|
|
21848
|
+
payment_id: result.payment_id,
|
|
21849
|
+
invoice_status: result.invoice_status,
|
|
21850
|
+
remaining_balance: result.remaining_balance
|
|
21668
21851
|
});
|
|
21669
21852
|
}
|
|
21670
21853
|
);
|
|
@@ -21677,13 +21860,15 @@ function registerInvoiceTools(server2) {
|
|
|
21677
21860
|
async (params) => {
|
|
21678
21861
|
const supabase2 = getSupabase();
|
|
21679
21862
|
const companyId2 = getCompanyId();
|
|
21680
|
-
const { data: invoice, error: error2 } = await supabase2.from("invoices").select(
|
|
21863
|
+
const { data: invoice, error: error2 } = await supabase2.from("invoices").select(
|
|
21864
|
+
`
|
|
21681
21865
|
id, invoice_number, reference_number, invoice_date, due_date,
|
|
21682
21866
|
status, subtotal, vat_total, total, notes, created_at,
|
|
21683
21867
|
customers ( id, name, business_id, email, address, postal_code, city ),
|
|
21684
21868
|
invoice_lines ( id, line_number, description, quantity, unit, unit_price, vat_rate, subtotal, vat_amount, total ),
|
|
21685
21869
|
invoice_payments ( id, payment_date, amount, payment_method, reference )
|
|
21686
|
-
`
|
|
21870
|
+
`
|
|
21871
|
+
).eq("id", params.invoiceId).eq("company_id", companyId2).single();
|
|
21687
21872
|
if (error2 || !invoice) {
|
|
21688
21873
|
return toErrorContent(
|
|
21689
21874
|
`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
|
|
@@ -21691,7 +21876,12 @@ function registerInvoiceTools(server2) {
|
|
|
21691
21876
|
}
|
|
21692
21877
|
const vatMap = /* @__PURE__ */ new Map();
|
|
21693
21878
|
for (const line of invoice.invoice_lines || []) {
|
|
21694
|
-
const existing = vatMap.get(line.vat_rate) || {
|
|
21879
|
+
const existing = vatMap.get(line.vat_rate) || {
|
|
21880
|
+
vat_rate: line.vat_rate,
|
|
21881
|
+
subtotal: 0,
|
|
21882
|
+
vat_amount: 0,
|
|
21883
|
+
total: 0
|
|
21884
|
+
};
|
|
21695
21885
|
existing.subtotal += line.subtotal || 0;
|
|
21696
21886
|
existing.vat_amount += line.vat_amount || 0;
|
|
21697
21887
|
existing.total += line.total || 0;
|
|
@@ -21704,7 +21894,9 @@ function registerInvoiceTools(server2) {
|
|
|
21704
21894
|
total: roundCents(v.total)
|
|
21705
21895
|
}));
|
|
21706
21896
|
const payments = invoice.invoice_payments || [];
|
|
21707
|
-
const paidTotal = roundCents(
|
|
21897
|
+
const paidTotal = roundCents(
|
|
21898
|
+
payments.reduce((s, p) => s + (p.amount || 0), 0)
|
|
21899
|
+
);
|
|
21708
21900
|
const remainingBalance = roundCents(invoice.total - paidTotal);
|
|
21709
21901
|
return toTextContent({
|
|
21710
21902
|
...invoice,
|
|
@@ -21747,9 +21939,11 @@ function registerInvoiceTools(server2) {
|
|
|
21747
21939
|
if (newStatus === "paid") {
|
|
21748
21940
|
updateFields.paid_date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
21749
21941
|
}
|
|
21750
|
-
const { error: updateError } = await supabase2.from("invoices").update(updateFields).eq("id", params.invoiceId);
|
|
21942
|
+
const { error: updateError } = await supabase2.from("invoices").update(updateFields).eq("id", params.invoiceId).eq("company_id", companyId2);
|
|
21751
21943
|
if (updateError) {
|
|
21752
|
-
return toErrorContent(
|
|
21944
|
+
return toErrorContent(
|
|
21945
|
+
sanitizeDbError("Failed to update invoice status", updateError)
|
|
21946
|
+
);
|
|
21753
21947
|
}
|
|
21754
21948
|
return toTextContent({
|
|
21755
21949
|
message: `Invoice ${invoice.invoice_number} status changed from "${currentStatus}" to "${newStatus}"`,
|
|
@@ -21779,13 +21973,23 @@ function registerInvoiceTools(server2) {
|
|
|
21779
21973
|
`Cannot delete invoice ${invoice.invoice_number} \u2014 status is "${invoice.status}". Only draft invoices can be deleted. Use kirjapro_update_invoice_status to cancel it first.`
|
|
21780
21974
|
);
|
|
21781
21975
|
}
|
|
21976
|
+
const { error: paymentsError } = await supabase2.from("invoice_payments").delete().eq("invoice_id", params.invoiceId);
|
|
21977
|
+
if (paymentsError) {
|
|
21978
|
+
return toErrorContent(
|
|
21979
|
+
sanitizeDbError("Failed to delete invoice payments", paymentsError)
|
|
21980
|
+
);
|
|
21981
|
+
}
|
|
21782
21982
|
const { error: linesError } = await supabase2.from("invoice_lines").delete().eq("invoice_id", params.invoiceId);
|
|
21783
21983
|
if (linesError) {
|
|
21784
|
-
return toErrorContent(
|
|
21984
|
+
return toErrorContent(
|
|
21985
|
+
sanitizeDbError("Failed to delete invoice lines", linesError)
|
|
21986
|
+
);
|
|
21785
21987
|
}
|
|
21786
21988
|
const { error: deleteError } = await supabase2.from("invoices").delete().eq("id", params.invoiceId);
|
|
21787
21989
|
if (deleteError) {
|
|
21788
|
-
return toErrorContent(
|
|
21990
|
+
return toErrorContent(
|
|
21991
|
+
sanitizeDbError("Failed to delete invoice", deleteError)
|
|
21992
|
+
);
|
|
21789
21993
|
}
|
|
21790
21994
|
return toTextContent({
|
|
21791
21995
|
message: `Draft invoice ${invoice.invoice_number} deleted`,
|
|
@@ -21840,7 +22044,7 @@ function registerCustomerTools(server2) {
|
|
|
21840
22044
|
query = query.range(offset, offset + limit - 1);
|
|
21841
22045
|
const { data, count, error: error2 } = await query;
|
|
21842
22046
|
if (error2) {
|
|
21843
|
-
return toErrorContent(
|
|
22047
|
+
return toErrorContent(sanitizeDbError("Failed to list customers", error2));
|
|
21844
22048
|
}
|
|
21845
22049
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
21846
22050
|
}
|
|
@@ -21868,7 +22072,7 @@ function registerCustomerTools(server2) {
|
|
|
21868
22072
|
`Y-tunnus '${params.businessId}' is invalid (checksum failed). Valid format: 7 digits + hyphen + check digit. Example: 1234567-8.`
|
|
21869
22073
|
);
|
|
21870
22074
|
}
|
|
21871
|
-
const { data: lastCustomer } = await supabase2.from("customers").select("customer_number").eq("company_id", companyId2).order("created_at", { ascending: false }).limit(1).
|
|
22075
|
+
const { data: lastCustomer } = await supabase2.from("customers").select("customer_number").eq("company_id", companyId2).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
21872
22076
|
const lastNum = parseInt(lastCustomer?.customer_number?.replace(/\D/g, "") || "0");
|
|
21873
22077
|
const customerNumber = (lastNum + 1).toString().padStart(5, "0");
|
|
21874
22078
|
const { data: customer, error: error2 } = await supabase2.from("customers").insert({
|
|
@@ -21886,7 +22090,7 @@ function registerCustomerTools(server2) {
|
|
|
21886
22090
|
payment_term_days: params.paymentTermDays
|
|
21887
22091
|
}).select().single();
|
|
21888
22092
|
if (error2) {
|
|
21889
|
-
return toErrorContent(
|
|
22093
|
+
return toErrorContent(sanitizeDbError("Failed to create customer", error2));
|
|
21890
22094
|
}
|
|
21891
22095
|
return toTextContent({
|
|
21892
22096
|
message: `Customer "${params.name}" created with number ${customerNumber}`,
|
|
@@ -21977,7 +22181,7 @@ function registerCustomerTools(server2) {
|
|
|
21977
22181
|
}
|
|
21978
22182
|
const { data: updated, error: updateError } = await supabase2.from("customers").update(updates).eq("id", params.customerId).select().single();
|
|
21979
22183
|
if (updateError) {
|
|
21980
|
-
return toErrorContent(
|
|
22184
|
+
return toErrorContent(sanitizeDbError("Failed to update customer", updateError));
|
|
21981
22185
|
}
|
|
21982
22186
|
return toTextContent({
|
|
21983
22187
|
message: `Customer "${updated.name}" updated`,
|
|
@@ -21986,6 +22190,42 @@ function registerCustomerTools(server2) {
|
|
|
21986
22190
|
});
|
|
21987
22191
|
}
|
|
21988
22192
|
);
|
|
22193
|
+
server2.tool(
|
|
22194
|
+
"kirjapro_lookup_business",
|
|
22195
|
+
"Look up a Finnish company by Y-tunnus (business ID) from PRH (Patent and Registration Office). Returns official company name, address, company form, and registration date. Useful for auto-filling customer details.",
|
|
22196
|
+
{
|
|
22197
|
+
businessId: external_exports.string().regex(/^\d{7}-\d$/).describe('Finnish Y-tunnus (e.g. "1234567-8")')
|
|
22198
|
+
},
|
|
22199
|
+
async (params) => {
|
|
22200
|
+
if (!validateFinnishBusinessId(params.businessId)) {
|
|
22201
|
+
return toErrorContent(
|
|
22202
|
+
`Y-tunnus '${params.businessId}' is invalid (checksum failed). Valid format: 7 digits + hyphen + check digit. Example: 1234567-8.`
|
|
22203
|
+
);
|
|
22204
|
+
}
|
|
22205
|
+
const supabase2 = getSupabase();
|
|
22206
|
+
const result = await invokeEdgeFunction(
|
|
22207
|
+
supabase2,
|
|
22208
|
+
"prh-lookup",
|
|
22209
|
+
{ businessId: params.businessId }
|
|
22210
|
+
);
|
|
22211
|
+
if (result.error !== null) {
|
|
22212
|
+
return toErrorContent(result.error);
|
|
22213
|
+
}
|
|
22214
|
+
const company = result.data.company;
|
|
22215
|
+
return toTextContent({
|
|
22216
|
+
business_id: company.businessId,
|
|
22217
|
+
name: company.name,
|
|
22218
|
+
registration_date: company.registrationDate,
|
|
22219
|
+
company_form: company.companyForm,
|
|
22220
|
+
address: company.address,
|
|
22221
|
+
postal_code: company.postalCode,
|
|
22222
|
+
city: company.city,
|
|
22223
|
+
country: company.country,
|
|
22224
|
+
website: company.website,
|
|
22225
|
+
phone: company.phone
|
|
22226
|
+
});
|
|
22227
|
+
}
|
|
22228
|
+
);
|
|
21989
22229
|
}
|
|
21990
22230
|
|
|
21991
22231
|
// src/tools/reports.ts
|
|
@@ -22005,13 +22245,14 @@ function registerReportTools(server2) {
|
|
|
22005
22245
|
`Fiscal year ${params.fiscalYearId} not found. Use kirjapro_list_fiscal_years to find available fiscal years.`
|
|
22006
22246
|
);
|
|
22007
22247
|
}
|
|
22248
|
+
const JOURNAL_ENTRY_CAP = 5e4;
|
|
22008
22249
|
const [entriesResult, txCountResult, invoiceResult, bankResult] = await Promise.all([
|
|
22009
|
-
// Revenue & expense totals from journal entries
|
|
22250
|
+
// Revenue & expense totals from journal entries (capped to prevent OOM on large datasets)
|
|
22010
22251
|
supabase2.from("journal_entries").select(`
|
|
22011
22252
|
debit_amount, credit_amount,
|
|
22012
22253
|
account:accounts!inner(account_type),
|
|
22013
22254
|
transaction:transactions!inner(fiscal_year_id)
|
|
22014
|
-
|
|
22255
|
+
`, { count: "exact" }).eq("transaction.fiscal_year_id", params.fiscalYearId).limit(JOURNAL_ENTRY_CAP),
|
|
22015
22256
|
// Transaction count
|
|
22016
22257
|
supabase2.from("transactions").select("*", { count: "exact", head: true }).eq("company_id", companyId2).eq("fiscal_year_id", params.fiscalYearId),
|
|
22017
22258
|
// Invoice stats scoped to fiscal year date range
|
|
@@ -22045,11 +22286,12 @@ function registerReportTools(server2) {
|
|
|
22045
22286
|
}
|
|
22046
22287
|
}
|
|
22047
22288
|
const bankAccounts = (bankResult.data || []).map((b) => ({
|
|
22048
|
-
name: b.account_name,
|
|
22289
|
+
name: b.account_name || "Unnamed",
|
|
22049
22290
|
iban: b.iban,
|
|
22050
22291
|
balance: b.current_balance || 0
|
|
22051
22292
|
}));
|
|
22052
22293
|
const totalBankBalance = bankAccounts.reduce((s, b) => s + b.balance, 0);
|
|
22294
|
+
const entriesCapped = (entriesResult.count || 0) >= JOURNAL_ENTRY_CAP;
|
|
22053
22295
|
return toTextContent({
|
|
22054
22296
|
financial_summary: {
|
|
22055
22297
|
revenue: roundCents(revenue),
|
|
@@ -22058,7 +22300,8 @@ function registerReportTools(server2) {
|
|
|
22058
22300
|
expenses_formatted: formatCurrency(expenses),
|
|
22059
22301
|
profit: roundCents(revenue - expenses),
|
|
22060
22302
|
profit_formatted: formatCurrency(revenue - expenses),
|
|
22061
|
-
transaction_count: txCountResult.count || 0
|
|
22303
|
+
transaction_count: txCountResult.count || 0,
|
|
22304
|
+
...entriesCapped && { warning: `Results capped at ${JOURNAL_ENTRY_CAP} journal entries. Totals may be approximate.` }
|
|
22062
22305
|
},
|
|
22063
22306
|
invoice_stats: invoiceStats,
|
|
22064
22307
|
bank_summary: {
|
|
@@ -22080,13 +22323,14 @@ function registerReportTools(server2) {
|
|
|
22080
22323
|
async (params) => {
|
|
22081
22324
|
const supabase2 = getSupabase();
|
|
22082
22325
|
getCompanyId();
|
|
22083
|
-
const
|
|
22326
|
+
const ENTRY_CAP = 5e4;
|
|
22327
|
+
const { data: entries, count: entryCount, error: error2 } = await supabase2.from("journal_entries").select(`
|
|
22084
22328
|
debit_amount, credit_amount,
|
|
22085
22329
|
account:accounts!inner(id, account_number, name, account_type),
|
|
22086
22330
|
transaction:transactions!inner(fiscal_year_id, transaction_date)
|
|
22087
|
-
|
|
22331
|
+
`, { count: "exact" }).eq("transaction.fiscal_year_id", params.fiscalYearId).gte("transaction.transaction_date", params.dateFrom).lte("transaction.transaction_date", params.dateTo).limit(ENTRY_CAP);
|
|
22088
22332
|
if (error2) {
|
|
22089
|
-
return toErrorContent(
|
|
22333
|
+
return toErrorContent(sanitizeDbError("Failed to generate income statement", error2));
|
|
22090
22334
|
}
|
|
22091
22335
|
const accountTotals = /* @__PURE__ */ new Map();
|
|
22092
22336
|
for (const entry of entries || []) {
|
|
@@ -22120,6 +22364,7 @@ function registerReportTools(server2) {
|
|
|
22120
22364
|
expenseAccounts.push(acc);
|
|
22121
22365
|
}
|
|
22122
22366
|
}
|
|
22367
|
+
const capped = (entryCount || 0) >= ENTRY_CAP;
|
|
22123
22368
|
return toTextContent({
|
|
22124
22369
|
period: { from: params.dateFrom, to: params.dateTo },
|
|
22125
22370
|
revenue: {
|
|
@@ -22133,7 +22378,8 @@ function registerReportTools(server2) {
|
|
|
22133
22378
|
total_formatted: formatCurrency(totalExpenses)
|
|
22134
22379
|
},
|
|
22135
22380
|
net_profit: roundCents(totalRevenue - totalExpenses),
|
|
22136
|
-
net_profit_formatted: formatCurrency(totalRevenue - totalExpenses)
|
|
22381
|
+
net_profit_formatted: formatCurrency(totalRevenue - totalExpenses),
|
|
22382
|
+
...capped && { warning: `Results capped at ${ENTRY_CAP} journal entries. Narrow the date range for accurate totals.` }
|
|
22137
22383
|
});
|
|
22138
22384
|
}
|
|
22139
22385
|
);
|
|
@@ -22147,13 +22393,14 @@ function registerReportTools(server2) {
|
|
|
22147
22393
|
async (params) => {
|
|
22148
22394
|
const supabase2 = getSupabase();
|
|
22149
22395
|
getCompanyId();
|
|
22150
|
-
const
|
|
22396
|
+
const BS_ENTRY_CAP = 5e4;
|
|
22397
|
+
const { data: entries, count: bsEntryCount, error: error2 } = await supabase2.from("journal_entries").select(`
|
|
22151
22398
|
debit_amount, credit_amount,
|
|
22152
22399
|
account:accounts!inner(id, account_number, name, account_type),
|
|
22153
22400
|
transaction:transactions!inner(fiscal_year_id, transaction_date)
|
|
22154
|
-
|
|
22401
|
+
`, { count: "exact" }).eq("transaction.fiscal_year_id", params.fiscalYearId).lte("transaction.transaction_date", params.reportDate).limit(BS_ENTRY_CAP);
|
|
22155
22402
|
if (error2) {
|
|
22156
|
-
return toErrorContent(
|
|
22403
|
+
return toErrorContent(sanitizeDbError("Failed to generate balance sheet", error2));
|
|
22157
22404
|
}
|
|
22158
22405
|
const accountTotals = /* @__PURE__ */ new Map();
|
|
22159
22406
|
for (const entry of entries || []) {
|
|
@@ -22198,6 +22445,7 @@ function registerReportTools(server2) {
|
|
|
22198
22445
|
break;
|
|
22199
22446
|
}
|
|
22200
22447
|
}
|
|
22448
|
+
const bsCapped = (bsEntryCount || 0) >= BS_ENTRY_CAP;
|
|
22201
22449
|
return toTextContent({
|
|
22202
22450
|
report_date: params.reportDate,
|
|
22203
22451
|
assets: {
|
|
@@ -22216,7 +22464,8 @@ function registerReportTools(server2) {
|
|
|
22216
22464
|
total_formatted: formatCurrency(totalEquity)
|
|
22217
22465
|
},
|
|
22218
22466
|
total_liabilities_and_equity: roundCents(totalLiabilities + totalEquity),
|
|
22219
|
-
balanced: Math.abs(totalAssets - (totalLiabilities + totalEquity)) < 0.01
|
|
22467
|
+
balanced: Math.abs(totalAssets - (totalLiabilities + totalEquity)) < 0.01,
|
|
22468
|
+
...bsCapped && { warning: `Results capped at ${BS_ENTRY_CAP} journal entries. Totals may be approximate.` }
|
|
22220
22469
|
});
|
|
22221
22470
|
}
|
|
22222
22471
|
);
|
|
@@ -22229,7 +22478,7 @@ function registerReportTools(server2) {
|
|
|
22229
22478
|
const companyId2 = getCompanyId();
|
|
22230
22479
|
const { data: invoices, error: error2 } = await supabase2.from("invoices").select("id, invoice_number, total, due_date, invoice_date, customers(name)").eq("company_id", companyId2).in("status", ["sent", "overdue"]);
|
|
22231
22480
|
if (error2) {
|
|
22232
|
-
return toErrorContent(
|
|
22481
|
+
return toErrorContent(sanitizeDbError("Failed to get AR aging", error2));
|
|
22233
22482
|
}
|
|
22234
22483
|
const now = /* @__PURE__ */ new Date();
|
|
22235
22484
|
const buckets = [
|
|
@@ -22301,7 +22550,7 @@ function registerReportTools(server2) {
|
|
|
22301
22550
|
}
|
|
22302
22551
|
const { data: entries, error: error2 } = await query;
|
|
22303
22552
|
if (error2) {
|
|
22304
|
-
return toErrorContent(
|
|
22553
|
+
return toErrorContent(sanitizeDbError("Failed to get general ledger", error2));
|
|
22305
22554
|
}
|
|
22306
22555
|
const debitNormal = ["asset", "expense"];
|
|
22307
22556
|
const isDebitNormal = debitNormal.includes(account.account_type);
|
|
@@ -22341,6 +22590,38 @@ function registerReportTools(server2) {
|
|
|
22341
22590
|
});
|
|
22342
22591
|
}
|
|
22343
22592
|
);
|
|
22593
|
+
server2.tool(
|
|
22594
|
+
"kirjapro_get_alv_summary",
|
|
22595
|
+
"Get VAT (ALV) summary for a date range. Returns output/input VAT breakdown by rate and OmaVero field codes (301-320). For filing deadlines, read kirjapro://compliance/deadlines.",
|
|
22596
|
+
{
|
|
22597
|
+
dateFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Period start date (YYYY-MM-DD)"),
|
|
22598
|
+
dateTo: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Period end date (YYYY-MM-DD)")
|
|
22599
|
+
},
|
|
22600
|
+
async (params) => {
|
|
22601
|
+
const supabase2 = getSupabase();
|
|
22602
|
+
const companyId2 = getCompanyId();
|
|
22603
|
+
const { data, error: error2 } = await supabase2.rpc("get_alv_summary", {
|
|
22604
|
+
p_company_id: companyId2,
|
|
22605
|
+
p_date_from: params.dateFrom,
|
|
22606
|
+
p_date_to: params.dateTo
|
|
22607
|
+
});
|
|
22608
|
+
if (error2) {
|
|
22609
|
+
return toErrorContent(sanitizeDbError("Failed to get ALV summary", error2));
|
|
22610
|
+
}
|
|
22611
|
+
const result = data;
|
|
22612
|
+
if (!result) {
|
|
22613
|
+
return toTextContent({
|
|
22614
|
+
period: { from: params.dateFrom, to: params.dateTo },
|
|
22615
|
+
message: "No VAT data found for this period."
|
|
22616
|
+
});
|
|
22617
|
+
}
|
|
22618
|
+
return toTextContent({
|
|
22619
|
+
...result,
|
|
22620
|
+
period: { from: params.dateFrom, to: params.dateTo },
|
|
22621
|
+
net_vat_payable_formatted: formatCurrency(result.net_vat_payable || 0)
|
|
22622
|
+
});
|
|
22623
|
+
}
|
|
22624
|
+
);
|
|
22344
22625
|
}
|
|
22345
22626
|
|
|
22346
22627
|
// src/tools/banking.ts
|
|
@@ -22355,7 +22636,9 @@ function registerBankingTools(server2) {
|
|
|
22355
22636
|
async (params) => {
|
|
22356
22637
|
const supabase2 = getSupabase();
|
|
22357
22638
|
const companyId2 = getCompanyId();
|
|
22358
|
-
const { data: bankTx, error: btError } = await supabase2.from("bank_transactions").select(
|
|
22639
|
+
const { data: bankTx, error: btError } = await supabase2.from("bank_transactions").select(
|
|
22640
|
+
"id, amount, booking_date, credit_debit, counterparty_name, reference, reconciled_at"
|
|
22641
|
+
).eq("id", params.bankTransactionId).eq("company_id", companyId2).single();
|
|
22359
22642
|
if (btError || !bankTx) {
|
|
22360
22643
|
return toErrorContent(
|
|
22361
22644
|
`Bank transaction ${params.bankTransactionId} not found. Verify the ID and ensure it belongs to your company.`
|
|
@@ -22371,67 +22654,58 @@ function registerBankingTools(server2) {
|
|
|
22371
22654
|
`Only credit (incoming) bank transactions can be matched to invoices. This is a debit (outgoing) transaction of ${Math.abs(bankTx.amount).toFixed(2)} EUR.`
|
|
22372
22655
|
);
|
|
22373
22656
|
}
|
|
22374
|
-
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
22657
|
+
const { data: invoice, error: invError } = await supabase2.from("invoices").select(
|
|
22658
|
+
`
|
|
22375
22659
|
id, invoice_number, total, status,
|
|
22376
22660
|
invoice_payments ( amount )
|
|
22377
|
-
`
|
|
22661
|
+
`
|
|
22662
|
+
).eq("id", params.invoiceId).eq("company_id", companyId2).single();
|
|
22378
22663
|
if (invError || !invoice) {
|
|
22379
22664
|
return toErrorContent(
|
|
22380
22665
|
`Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
|
|
22381
22666
|
);
|
|
22382
22667
|
}
|
|
22383
22668
|
if (invoice.status === "paid") {
|
|
22384
|
-
return toErrorContent(
|
|
22669
|
+
return toErrorContent(
|
|
22670
|
+
`Invoice ${invoice.invoice_number} is already fully paid.`
|
|
22671
|
+
);
|
|
22672
|
+
}
|
|
22673
|
+
if (invoice.status === "cancelled") {
|
|
22674
|
+
return toErrorContent(
|
|
22675
|
+
`Invoice ${invoice.invoice_number} is cancelled and cannot accept payments.`
|
|
22676
|
+
);
|
|
22385
22677
|
}
|
|
22386
22678
|
const paidSoFar = roundCents(
|
|
22387
|
-
(invoice.invoice_payments || []).reduce(
|
|
22679
|
+
(invoice.invoice_payments || []).reduce(
|
|
22680
|
+
(s, p) => s + (p.amount || 0),
|
|
22681
|
+
0
|
|
22682
|
+
)
|
|
22388
22683
|
);
|
|
22389
22684
|
const remaining = roundCents(invoice.total - paidSoFar);
|
|
22390
|
-
const paymentAmount =
|
|
22391
|
-
|
|
22392
|
-
|
|
22393
|
-
|
|
22394
|
-
|
|
22395
|
-
payment_method: "bank_transfer",
|
|
22396
|
-
reference: bankTx.reference || null
|
|
22397
|
-
}).select().single();
|
|
22398
|
-
if (payError) {
|
|
22399
|
-
return toErrorContent(`Failed to create payment: ${payError.message}`);
|
|
22400
|
-
}
|
|
22401
|
-
const { data: match, error: matchError } = await supabase2.from("reconciliation_matches").insert({
|
|
22402
|
-
company_id: companyId2,
|
|
22403
|
-
bank_transaction_id: params.bankTransactionId,
|
|
22404
|
-
match_type: "invoice",
|
|
22405
|
-
matched_invoice_id: params.invoiceId,
|
|
22406
|
-
confidence: 100,
|
|
22407
|
-
match_method: "mcp_manual",
|
|
22408
|
-
status: "confirmed",
|
|
22409
|
-
confirmed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
22410
|
-
}).select("id").single();
|
|
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({
|
|
22415
|
-
reconciled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22416
|
-
matched_invoice_id: params.invoiceId,
|
|
22417
|
-
match_confidence: 1,
|
|
22418
|
-
match_method: "mcp_manual",
|
|
22419
|
-
reconciliation_match_id: match?.id || null
|
|
22420
|
-
}).eq("id", params.bankTransactionId);
|
|
22421
|
-
if (btUpdateError) {
|
|
22422
|
-
return toErrorContent(`Payment and match created but bank transaction update failed: ${btUpdateError.message}`);
|
|
22685
|
+
const paymentAmount = roundCents(Math.abs(bankTx.amount));
|
|
22686
|
+
if (paymentAmount > remaining + 0.01) {
|
|
22687
|
+
return toErrorContent(
|
|
22688
|
+
`Bank transaction amount ${paymentAmount.toFixed(2)} exceeds remaining invoice balance ${remaining.toFixed(2)} EUR on ${invoice.invoice_number}. Use a smaller matching target or split the payment before reconciling.`
|
|
22689
|
+
);
|
|
22423
22690
|
}
|
|
22424
|
-
const
|
|
22425
|
-
|
|
22426
|
-
|
|
22427
|
-
|
|
22428
|
-
|
|
22429
|
-
|
|
22430
|
-
|
|
22431
|
-
|
|
22432
|
-
|
|
22691
|
+
const { data: matchResult, error: matchError } = await supabase2.rpc(
|
|
22692
|
+
"confirm_reconciliation_match",
|
|
22693
|
+
{
|
|
22694
|
+
p_company_id: companyId2,
|
|
22695
|
+
p_bank_transaction_id: params.bankTransactionId,
|
|
22696
|
+
p_match_type: "invoice",
|
|
22697
|
+
p_matched_invoice_id: params.invoiceId,
|
|
22698
|
+
p_confidence: 100,
|
|
22699
|
+
p_match_method: "mcp_manual"
|
|
22433
22700
|
}
|
|
22701
|
+
);
|
|
22702
|
+
if (matchError || !matchResult) {
|
|
22703
|
+
return toErrorContent(
|
|
22704
|
+
matchError ? sanitizeDbError("Failed to confirm reconciliation match", matchError) : "Failed to confirm reconciliation match."
|
|
22705
|
+
);
|
|
22434
22706
|
}
|
|
22707
|
+
const newPaidTotal = roundCents(paidSoFar + paymentAmount);
|
|
22708
|
+
const newStatus = newPaidTotal >= invoice.total ? "paid" : "partial";
|
|
22435
22709
|
return toTextContent({
|
|
22436
22710
|
message: `Bank transaction matched to invoice ${invoice.invoice_number}`,
|
|
22437
22711
|
payment_amount: paymentAmount,
|
|
@@ -22448,9 +22722,11 @@ function registerBankingTools(server2) {
|
|
|
22448
22722
|
async () => {
|
|
22449
22723
|
const supabase2 = getSupabase();
|
|
22450
22724
|
const companyId2 = getCompanyId();
|
|
22451
|
-
const { data, error: error2 } = await supabase2.from("bank_accounts").select(
|
|
22725
|
+
const { data, error: error2 } = await supabase2.from("bank_accounts").select(
|
|
22726
|
+
"id, account_name, iban, bic, bank_name, current_balance, available_balance, is_primary, last_sync_at, connection_status"
|
|
22727
|
+
).eq("company_id", companyId2).eq("is_active", true).order("is_primary", { ascending: false });
|
|
22452
22728
|
if (error2) {
|
|
22453
|
-
return toErrorContent(
|
|
22729
|
+
return toErrorContent(sanitizeDbError("Failed to list bank accounts", error2));
|
|
22454
22730
|
}
|
|
22455
22731
|
return toTextContent({
|
|
22456
22732
|
accounts: data || [],
|
|
@@ -22503,7 +22779,9 @@ function registerBankingTools(server2) {
|
|
|
22503
22779
|
query = query.range(offset, offset + limit - 1);
|
|
22504
22780
|
const { data, count, error: error2 } = await query;
|
|
22505
22781
|
if (error2) {
|
|
22506
|
-
return toErrorContent(
|
|
22782
|
+
return toErrorContent(
|
|
22783
|
+
sanitizeDbError("Failed to list bank transactions", error2)
|
|
22784
|
+
);
|
|
22507
22785
|
}
|
|
22508
22786
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
22509
22787
|
}
|
|
@@ -22626,7 +22904,7 @@ function registerFiscalYearTools(server2) {
|
|
|
22626
22904
|
const companyId2 = getCompanyId();
|
|
22627
22905
|
const { data, error: error2 } = await supabase2.from("fiscal_years").select("id, start_date, end_date, is_closed, created_at").eq("company_id", companyId2).order("start_date", { ascending: false });
|
|
22628
22906
|
if (error2) {
|
|
22629
|
-
return toErrorContent(
|
|
22907
|
+
return toErrorContent(sanitizeDbError("Failed to list fiscal years", error2));
|
|
22630
22908
|
}
|
|
22631
22909
|
return toTextContent({
|
|
22632
22910
|
fiscal_years: data || [],
|
|
@@ -22654,11 +22932,19 @@ function saleCashTemplate(revenueAccount, vatRate) {
|
|
|
22654
22932
|
return (amount, opts) => {
|
|
22655
22933
|
const { net, vat } = splitVat(amount, vatRate);
|
|
22656
22934
|
const entries = [
|
|
22657
|
-
{
|
|
22935
|
+
{
|
|
22936
|
+
accountNumber: "2310",
|
|
22937
|
+
debitAmount: roundCents(amount),
|
|
22938
|
+
creditAmount: 0
|
|
22939
|
+
},
|
|
22658
22940
|
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
|
|
22659
22941
|
];
|
|
22660
22942
|
if (vat > 0) {
|
|
22661
|
-
entries.push({
|
|
22943
|
+
entries.push({
|
|
22944
|
+
accountNumber: "4050",
|
|
22945
|
+
debitAmount: 0,
|
|
22946
|
+
creditAmount: vat
|
|
22947
|
+
});
|
|
22662
22948
|
}
|
|
22663
22949
|
return applyOverrides(entries, opts?.accountOverrides);
|
|
22664
22950
|
};
|
|
@@ -22667,11 +22953,19 @@ function saleBankTemplate(revenueAccount, vatRate) {
|
|
|
22667
22953
|
return (amount, opts) => {
|
|
22668
22954
|
const { net, vat } = splitVat(amount, vatRate);
|
|
22669
22955
|
const entries = [
|
|
22670
|
-
{
|
|
22956
|
+
{
|
|
22957
|
+
accountNumber: "2320",
|
|
22958
|
+
debitAmount: roundCents(amount),
|
|
22959
|
+
creditAmount: 0
|
|
22960
|
+
},
|
|
22671
22961
|
{ accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
|
|
22672
22962
|
];
|
|
22673
22963
|
if (vat > 0) {
|
|
22674
|
-
entries.push({
|
|
22964
|
+
entries.push({
|
|
22965
|
+
accountNumber: "4050",
|
|
22966
|
+
debitAmount: 0,
|
|
22967
|
+
creditAmount: vat
|
|
22968
|
+
});
|
|
22675
22969
|
}
|
|
22676
22970
|
return applyOverrides(entries, opts?.accountOverrides);
|
|
22677
22971
|
};
|
|
@@ -22728,11 +23022,18 @@ var TEMPLATES = [
|
|
|
22728
23022
|
vatRate: 25.5,
|
|
22729
23023
|
expand: (amount, opts) => {
|
|
22730
23024
|
const { net, vat } = splitVat(amount, 25.5);
|
|
22731
|
-
return applyOverrides(
|
|
22732
|
-
|
|
22733
|
-
|
|
22734
|
-
|
|
22735
|
-
|
|
23025
|
+
return applyOverrides(
|
|
23026
|
+
[
|
|
23027
|
+
{
|
|
23028
|
+
accountNumber: "2110",
|
|
23029
|
+
debitAmount: roundCents(amount),
|
|
23030
|
+
creditAmount: 0
|
|
23031
|
+
},
|
|
23032
|
+
{ accountNumber: "5010", debitAmount: 0, creditAmount: net },
|
|
23033
|
+
{ accountNumber: "4050", debitAmount: 0, creditAmount: vat }
|
|
23034
|
+
],
|
|
23035
|
+
opts?.accountOverrides
|
|
23036
|
+
);
|
|
22736
23037
|
}
|
|
22737
23038
|
},
|
|
22738
23039
|
// Purchase expenses
|
|
@@ -22742,11 +23043,18 @@ var TEMPLATES = [
|
|
|
22742
23043
|
vatRate: 25.5,
|
|
22743
23044
|
expand: (amount, opts) => {
|
|
22744
23045
|
const { net, vat } = splitVat(amount, 25.5);
|
|
22745
|
-
return applyOverrides(
|
|
22746
|
-
|
|
22747
|
-
|
|
22748
|
-
|
|
22749
|
-
|
|
23046
|
+
return applyOverrides(
|
|
23047
|
+
[
|
|
23048
|
+
{ accountNumber: "6010", debitAmount: net, creditAmount: 0 },
|
|
23049
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23050
|
+
{
|
|
23051
|
+
accountNumber: "2320",
|
|
23052
|
+
debitAmount: 0,
|
|
23053
|
+
creditAmount: roundCents(amount)
|
|
23054
|
+
}
|
|
23055
|
+
],
|
|
23056
|
+
opts?.accountOverrides
|
|
23057
|
+
);
|
|
22750
23058
|
}
|
|
22751
23059
|
},
|
|
22752
23060
|
{
|
|
@@ -22755,11 +23063,18 @@ var TEMPLATES = [
|
|
|
22755
23063
|
vatRate: 25.5,
|
|
22756
23064
|
expand: (amount, opts) => {
|
|
22757
23065
|
const { net, vat } = splitVat(amount, 25.5);
|
|
22758
|
-
return applyOverrides(
|
|
22759
|
-
|
|
22760
|
-
|
|
22761
|
-
|
|
22762
|
-
|
|
23066
|
+
return applyOverrides(
|
|
23067
|
+
[
|
|
23068
|
+
{ accountNumber: "6030", debitAmount: net, creditAmount: 0 },
|
|
23069
|
+
{ accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
|
|
23070
|
+
{
|
|
23071
|
+
accountNumber: "2320",
|
|
23072
|
+
debitAmount: 0,
|
|
23073
|
+
creditAmount: roundCents(amount)
|
|
23074
|
+
}
|
|
23075
|
+
],
|
|
23076
|
+
opts?.accountOverrides
|
|
23077
|
+
);
|
|
22763
23078
|
}
|
|
22764
23079
|
},
|
|
22765
23080
|
// Rent (no VAT)
|
|
@@ -22767,10 +23082,21 @@ var TEMPLATES = [
|
|
|
22767
23082
|
id: "rent_payment",
|
|
22768
23083
|
description: "Vuokranmaksu (pankkitililt\xE4)",
|
|
22769
23084
|
vatRate: null,
|
|
22770
|
-
expand: (amount, opts) => applyOverrides(
|
|
22771
|
-
|
|
22772
|
-
|
|
22773
|
-
|
|
23085
|
+
expand: (amount, opts) => applyOverrides(
|
|
23086
|
+
[
|
|
23087
|
+
{
|
|
23088
|
+
accountNumber: "8010",
|
|
23089
|
+
debitAmount: roundCents(amount),
|
|
23090
|
+
creditAmount: 0
|
|
23091
|
+
},
|
|
23092
|
+
{
|
|
23093
|
+
accountNumber: "2320",
|
|
23094
|
+
debitAmount: 0,
|
|
23095
|
+
creditAmount: roundCents(amount)
|
|
23096
|
+
}
|
|
23097
|
+
],
|
|
23098
|
+
opts?.accountOverrides
|
|
23099
|
+
)
|
|
22774
23100
|
},
|
|
22775
23101
|
// Salary
|
|
22776
23102
|
{
|
|
@@ -22786,13 +23112,20 @@ var TEMPLATES = [
|
|
|
22786
23112
|
const tyelEmployee = roundCents(gross * tyelEmployeeRate / 100);
|
|
22787
23113
|
const tyelEmployer = roundCents(gross * tyelEmployerRate / 100);
|
|
22788
23114
|
const netPay = roundCents(gross - withholding - tyelEmployee);
|
|
22789
|
-
return applyOverrides(
|
|
22790
|
-
|
|
22791
|
-
|
|
22792
|
-
|
|
22793
|
-
|
|
22794
|
-
|
|
22795
|
-
|
|
23115
|
+
return applyOverrides(
|
|
23116
|
+
[
|
|
23117
|
+
{ accountNumber: "7010", debitAmount: gross, creditAmount: 0 },
|
|
23118
|
+
{ accountNumber: "7020", debitAmount: tyelEmployer, creditAmount: 0 },
|
|
23119
|
+
{ accountNumber: "2320", debitAmount: 0, creditAmount: netPay },
|
|
23120
|
+
{ accountNumber: "4060", debitAmount: 0, creditAmount: withholding },
|
|
23121
|
+
{
|
|
23122
|
+
accountNumber: "4080",
|
|
23123
|
+
debitAmount: 0,
|
|
23124
|
+
creditAmount: roundCents(tyelEmployee + tyelEmployer)
|
|
23125
|
+
}
|
|
23126
|
+
],
|
|
23127
|
+
opts?.accountOverrides
|
|
23128
|
+
);
|
|
22796
23129
|
}
|
|
22797
23130
|
},
|
|
22798
23131
|
// Bank transfer
|
|
@@ -22800,17 +23133,30 @@ var TEMPLATES = [
|
|
|
22800
23133
|
id: "bank_transfer",
|
|
22801
23134
|
description: "Tilisiirto kassasta pankkitilille",
|
|
22802
23135
|
vatRate: null,
|
|
22803
|
-
expand: (amount, opts) => applyOverrides(
|
|
22804
|
-
|
|
22805
|
-
|
|
22806
|
-
|
|
23136
|
+
expand: (amount, opts) => applyOverrides(
|
|
23137
|
+
[
|
|
23138
|
+
{
|
|
23139
|
+
accountNumber: "2320",
|
|
23140
|
+
debitAmount: roundCents(amount),
|
|
23141
|
+
creditAmount: 0
|
|
23142
|
+
},
|
|
23143
|
+
{
|
|
23144
|
+
accountNumber: "2310",
|
|
23145
|
+
debitAmount: 0,
|
|
23146
|
+
creditAmount: roundCents(amount)
|
|
23147
|
+
}
|
|
23148
|
+
],
|
|
23149
|
+
opts?.accountOverrides
|
|
23150
|
+
)
|
|
22807
23151
|
}
|
|
22808
23152
|
];
|
|
22809
23153
|
var TEMPLATE_MAP = new Map(TEMPLATES.map((t) => [t.id, t]));
|
|
22810
23154
|
function expandTemplate(templateId, amount, opts) {
|
|
22811
23155
|
const template = TEMPLATE_MAP.get(templateId);
|
|
22812
23156
|
if (!template) {
|
|
22813
|
-
return {
|
|
23157
|
+
return {
|
|
23158
|
+
error: `Unknown template "${templateId}". Use kirjapro_list_templates to see available templates.`
|
|
23159
|
+
};
|
|
22814
23160
|
}
|
|
22815
23161
|
if (amount <= 0) {
|
|
22816
23162
|
return { error: "Amount must be positive." };
|
|
@@ -22834,63 +23180,69 @@ async function resolveAndCreate(params) {
|
|
|
22834
23180
|
);
|
|
22835
23181
|
}
|
|
22836
23182
|
if (fy.is_closed) {
|
|
22837
|
-
return toErrorContent(
|
|
23183
|
+
return toErrorContent(
|
|
23184
|
+
`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`
|
|
23185
|
+
);
|
|
22838
23186
|
}
|
|
22839
23187
|
if (params.transactionDate < fy.start_date || params.transactionDate > fy.end_date) {
|
|
22840
23188
|
return toErrorContent(
|
|
22841
23189
|
`Transaction date ${params.transactionDate} outside fiscal year (${fy.start_date} to ${fy.end_date}).`
|
|
22842
23190
|
);
|
|
22843
23191
|
}
|
|
22844
|
-
const accountNumbers = [
|
|
23192
|
+
const accountNumbers = [
|
|
23193
|
+
...new Set(params.entries.map((e) => e.accountNumber))
|
|
23194
|
+
];
|
|
22845
23195
|
const { data: accounts, error: accError } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", accountNumbers);
|
|
22846
23196
|
if (accError) {
|
|
22847
23197
|
return toErrorContent(`Failed to resolve accounts: ${accError.message}`);
|
|
22848
23198
|
}
|
|
22849
|
-
const accountMap = new Map(
|
|
23199
|
+
const accountMap = new Map(
|
|
23200
|
+
(accounts || []).map((a) => [a.account_number, a.id])
|
|
23201
|
+
);
|
|
22850
23202
|
const missing = accountNumbers.filter((n) => !accountMap.has(n));
|
|
22851
23203
|
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
23204
|
return toErrorContent(
|
|
22858
|
-
`
|
|
23205
|
+
`Account numbers not found: ${missing.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
|
|
22859
23206
|
);
|
|
22860
23207
|
}
|
|
22861
|
-
const
|
|
22862
|
-
|
|
22863
|
-
|
|
22864
|
-
|
|
22865
|
-
|
|
22866
|
-
|
|
22867
|
-
|
|
22868
|
-
|
|
22869
|
-
|
|
23208
|
+
const totalDebit = roundCents(
|
|
23209
|
+
params.entries.reduce((s, e) => s + e.debitAmount, 0)
|
|
23210
|
+
);
|
|
23211
|
+
const totalCredit = roundCents(
|
|
23212
|
+
params.entries.reduce((s, e) => s + e.creditAmount, 0)
|
|
23213
|
+
);
|
|
23214
|
+
if (Math.abs(totalDebit - totalCredit) >= 0.01) {
|
|
23215
|
+
return toErrorContent(
|
|
23216
|
+
`Template produced unbalanced entries: debits ${totalDebit.toFixed(2)} != credits ${totalCredit.toFixed(2)}.`
|
|
23217
|
+
);
|
|
22870
23218
|
}
|
|
22871
23219
|
const journalEntries = params.entries.map((e) => ({
|
|
22872
|
-
transaction_id: transaction.id,
|
|
22873
23220
|
account_id: accountMap.get(e.accountNumber),
|
|
22874
23221
|
debit_amount: e.debitAmount,
|
|
22875
23222
|
credit_amount: e.creditAmount
|
|
22876
23223
|
}));
|
|
22877
|
-
const { error:
|
|
22878
|
-
|
|
22879
|
-
|
|
22880
|
-
|
|
22881
|
-
|
|
22882
|
-
|
|
22883
|
-
|
|
22884
|
-
|
|
22885
|
-
|
|
22886
|
-
|
|
22887
|
-
|
|
22888
|
-
|
|
22889
|
-
|
|
23224
|
+
const { data: transactionId, error: txError } = await supabase2.rpc(
|
|
23225
|
+
"create_transaction_with_entries",
|
|
23226
|
+
{
|
|
23227
|
+
p_company_id: companyId2,
|
|
23228
|
+
p_fiscal_year_id: fiscalYearId,
|
|
23229
|
+
p_transaction_date: params.transactionDate,
|
|
23230
|
+
p_description: params.description,
|
|
23231
|
+
p_document_number: params.documentNumber || "",
|
|
23232
|
+
p_entries: journalEntries
|
|
23233
|
+
}
|
|
23234
|
+
);
|
|
23235
|
+
if (txError || !transactionId) {
|
|
23236
|
+
return toErrorContent(
|
|
23237
|
+
txError ? sanitizeDbError("Failed to create transaction", txError) : "Failed to create transaction: no ID returned."
|
|
23238
|
+
);
|
|
23239
|
+
}
|
|
23240
|
+
return { id: transactionId };
|
|
23241
|
+
}
|
|
22890
23242
|
function registerTemplateTools(server2) {
|
|
22891
23243
|
server2.tool(
|
|
22892
23244
|
"kirjapro_list_templates",
|
|
22893
|
-
|
|
23245
|
+
"List available transaction templates. Templates handle double-entry balancing and VAT splitting automatically.",
|
|
22894
23246
|
{},
|
|
22895
23247
|
async () => {
|
|
22896
23248
|
const list = TEMPLATES.map((t) => ({
|
|
@@ -22903,7 +23255,7 @@ function registerTemplateTools(server2) {
|
|
|
22903
23255
|
);
|
|
22904
23256
|
server2.tool(
|
|
22905
23257
|
"kirjapro_create_from_template",
|
|
22906
|
-
|
|
23258
|
+
'Create a transaction from a template. Use kirjapro_list_templates for available templates. accountOverrides swaps default accounts (e.g. {"2320": "2330"}).',
|
|
22907
23259
|
{
|
|
22908
23260
|
template: external_exports.string().describe('Template ID (e.g. "cash_sale_25", "purchase_expense")'),
|
|
22909
23261
|
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
@@ -22944,19 +23296,21 @@ function registerTemplateTools(server2) {
|
|
|
22944
23296
|
);
|
|
22945
23297
|
server2.tool(
|
|
22946
23298
|
"kirjapro_create_from_template_batch",
|
|
22947
|
-
|
|
23299
|
+
"Create multiple transactions from templates in one call (up to 50). Each is validated independently \u2014 valid ones succeed even if others fail.",
|
|
22948
23300
|
{
|
|
22949
23301
|
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
22950
|
-
transactions: external_exports.array(
|
|
22951
|
-
|
|
22952
|
-
|
|
22953
|
-
|
|
22954
|
-
|
|
22955
|
-
|
|
22956
|
-
|
|
22957
|
-
|
|
22958
|
-
|
|
22959
|
-
|
|
23302
|
+
transactions: external_exports.array(
|
|
23303
|
+
external_exports.object({
|
|
23304
|
+
template: external_exports.string().describe("Template ID"),
|
|
23305
|
+
transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
|
|
23306
|
+
amount: external_exports.number().positive().describe("Gross amount in EUR"),
|
|
23307
|
+
description: external_exports.string().min(1).max(500),
|
|
23308
|
+
documentNumber: external_exports.string().optional(),
|
|
23309
|
+
withholdingRate: external_exports.number().min(0).max(100).optional(),
|
|
23310
|
+
tyelRate: external_exports.number().min(0).max(100).optional(),
|
|
23311
|
+
accountOverrides: external_exports.record(external_exports.string(), external_exports.string()).optional()
|
|
23312
|
+
})
|
|
23313
|
+
).min(1).max(50).describe("Array of template transactions to create (max 50)")
|
|
22960
23314
|
},
|
|
22961
23315
|
async (params) => {
|
|
22962
23316
|
const created = [];
|
|
@@ -22983,7 +23337,11 @@ function registerTemplateTools(server2) {
|
|
|
22983
23337
|
failed.push({ index: i, error: createResult.content[0].text });
|
|
22984
23338
|
continue;
|
|
22985
23339
|
}
|
|
22986
|
-
created.push({
|
|
23340
|
+
created.push({
|
|
23341
|
+
id: createResult.id,
|
|
23342
|
+
description: tx.description,
|
|
23343
|
+
date: tx.transactionDate
|
|
23344
|
+
});
|
|
22987
23345
|
}
|
|
22988
23346
|
return toTextContent({
|
|
22989
23347
|
created_count: created.length,
|
|
@@ -23006,9 +23364,7 @@ var DEFAULTS = {
|
|
|
23006
23364
|
function registerBookingTools(server2) {
|
|
23007
23365
|
server2.tool(
|
|
23008
23366
|
"kirjapro_book_purchase",
|
|
23009
|
-
|
|
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`,
|
|
23367
|
+
"Book a purchase (osto) with automatic VAT splitting. Supports reverse charge (AVL 9\xA7) and entertainment (edustus, AVL 114\xA7). Rate rules: food/restaurants 13.5%, insurance 0% (exempt), alcohol 25.5%.",
|
|
23012
23368
|
{
|
|
23013
23369
|
amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
|
|
23014
23370
|
description: external_exports.string().min(1).max(500).describe("Transaction description"),
|
|
@@ -23070,10 +23426,7 @@ Common expense accounts: 6010 Ostot | 6030 Ulkopuoliset palvelut | 8010 Toimitil
|
|
|
23070
23426
|
);
|
|
23071
23427
|
server2.tool(
|
|
23072
23428
|
"kirjapro_book_salary",
|
|
23073
|
-
|
|
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.`,
|
|
23429
|
+
"Book salary payment (palkanmaksu). Creates salary + employer cost transactions with withholding, TyEL, TTV, sotu. Default rates are 2026 Finnish social insurance rates.",
|
|
23077
23430
|
{
|
|
23078
23431
|
grossSalary: external_exports.number().positive().describe("Gross salary EUR"),
|
|
23079
23432
|
employeeName: external_exports.string().min(1).describe("Employee name (for description)"),
|
|
@@ -23149,9 +23502,7 @@ Default rates are 2026 Finnish social insurance rates.`,
|
|
|
23149
23502
|
);
|
|
23150
23503
|
server2.tool(
|
|
23151
23504
|
"kirjapro_book_vat_settlement",
|
|
23152
|
-
|
|
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).`,
|
|
23505
|
+
"Book ALV-tilitys (VAT settlement) for a month. Calculates net from ALV accounts (2140/4050) and creates settlement. Default date: 12th of month+1.",
|
|
23155
23506
|
{
|
|
23156
23507
|
month: external_exports.string().regex(/^\d{4}-\d{2}$/).describe("Settlement month (YYYY-MM)"),
|
|
23157
23508
|
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
@@ -23174,7 +23525,11 @@ If ALV-saamiset > ALV-velka \u2192 claim refund (D 4050, C 2140, D bank for diff
|
|
|
23174
23525
|
const monthEnd = /* @__PURE__ */ new Date(`${nextMonth}-01`);
|
|
23175
23526
|
monthEnd.setDate(monthEnd.getDate() - 1);
|
|
23176
23527
|
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
|
23177
|
-
|
|
23528
|
+
let settlementDate = params.settlementDate ?? `${nextMonth}-12`;
|
|
23529
|
+
const { data: fy } = await supabase2.from("fiscal_years").select("start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
|
|
23530
|
+
if (fy && settlementDate > fy.end_date) {
|
|
23531
|
+
settlementDate = fy.end_date;
|
|
23532
|
+
}
|
|
23178
23533
|
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
23534
|
if (err1) {
|
|
23180
23535
|
return toErrorContent(`Failed to query ALV-saamiset: ${err1.message}`);
|
|
@@ -23234,6 +23589,61 @@ If ALV-saamiset > ALV-velka \u2192 claim refund (D 4050, C 2140, D bank for diff
|
|
|
23234
23589
|
});
|
|
23235
23590
|
}
|
|
23236
23591
|
);
|
|
23592
|
+
server2.tool(
|
|
23593
|
+
"kirjapro_book_sale",
|
|
23594
|
+
"Book a sale (myynti) with automatic VAT splitting. Payment types: invoice (default, D Myyntisaamiset), cash (D bank), prepaid.",
|
|
23595
|
+
{
|
|
23596
|
+
amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
|
|
23597
|
+
description: external_exports.string().min(1).max(500).describe('Transaction description (e.g. "Konsultointi Acme Oy, 40h \xD7 95 \u20AC")'),
|
|
23598
|
+
date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
|
|
23599
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("Finnish VAT rate"),
|
|
23600
|
+
revenueAccount: external_exports.string().default("3000").describe('Revenue account number (default "3000" Myynti)'),
|
|
23601
|
+
paymentType: external_exports.enum(["invoice", "cash", "prepaid"]).default("invoice").describe("invoice = receivable (1700), cash/prepaid = bank payment (2320)"),
|
|
23602
|
+
paymentAccount: external_exports.string().default("2320").describe('Bank account for cash/prepaid payments (default "2320")'),
|
|
23603
|
+
fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
|
|
23604
|
+
documentNumber: external_exports.string().optional().describe("Document/receipt number")
|
|
23605
|
+
},
|
|
23606
|
+
async (params) => {
|
|
23607
|
+
const rate = Number.parseFloat(params.vatRate);
|
|
23608
|
+
const gross = roundCents(params.amount);
|
|
23609
|
+
let entries;
|
|
23610
|
+
const debitAccount = params.paymentType === "invoice" ? "1700" : params.paymentAccount;
|
|
23611
|
+
if (rate === 0) {
|
|
23612
|
+
entries = [
|
|
23613
|
+
{ accountNumber: debitAccount, debitAmount: gross, creditAmount: 0 },
|
|
23614
|
+
{ accountNumber: params.revenueAccount, debitAmount: 0, creditAmount: gross }
|
|
23615
|
+
];
|
|
23616
|
+
} else {
|
|
23617
|
+
const { net: net2, vat: vat2 } = splitVat(gross, rate);
|
|
23618
|
+
entries = [
|
|
23619
|
+
{ accountNumber: debitAccount, debitAmount: gross, creditAmount: 0 },
|
|
23620
|
+
{ accountNumber: params.revenueAccount, debitAmount: 0, creditAmount: net2 },
|
|
23621
|
+
{ accountNumber: "4050", debitAmount: 0, creditAmount: vat2 }
|
|
23622
|
+
];
|
|
23623
|
+
}
|
|
23624
|
+
const result = await resolveAndCreate({
|
|
23625
|
+
fiscalYearId: params.fiscalYearId,
|
|
23626
|
+
transactionDate: params.date,
|
|
23627
|
+
description: params.description,
|
|
23628
|
+
documentNumber: params.documentNumber,
|
|
23629
|
+
entries
|
|
23630
|
+
});
|
|
23631
|
+
if ("isError" in result) return result;
|
|
23632
|
+
const { net, vat } = rate > 0 ? splitVat(gross, rate) : { net: gross, vat: 0 };
|
|
23633
|
+
return toTextContent({
|
|
23634
|
+
message: "Sale booked",
|
|
23635
|
+
transaction_id: result.id,
|
|
23636
|
+
gross,
|
|
23637
|
+
net,
|
|
23638
|
+
vat,
|
|
23639
|
+
vat_rate: `${params.vatRate} %`,
|
|
23640
|
+
payment_type: params.paymentType,
|
|
23641
|
+
debit_account: debitAccount,
|
|
23642
|
+
revenue_account: params.revenueAccount,
|
|
23643
|
+
entries
|
|
23644
|
+
});
|
|
23645
|
+
}
|
|
23646
|
+
);
|
|
23237
23647
|
}
|
|
23238
23648
|
|
|
23239
23649
|
// src/tools/offers.ts
|
|
@@ -23272,7 +23682,7 @@ function registerOfferTools(server2) {
|
|
|
23272
23682
|
query = query.range(offset, offset + limit - 1);
|
|
23273
23683
|
const { data, count, error: error2 } = await query;
|
|
23274
23684
|
if (error2) {
|
|
23275
|
-
return toErrorContent(
|
|
23685
|
+
return toErrorContent(sanitizeDbError("Failed to list offers", error2));
|
|
23276
23686
|
}
|
|
23277
23687
|
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
23278
23688
|
}
|
|
@@ -23332,7 +23742,7 @@ function registerOfferTools(server2) {
|
|
|
23332
23742
|
);
|
|
23333
23743
|
}
|
|
23334
23744
|
}
|
|
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).
|
|
23745
|
+
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).maybeSingle();
|
|
23336
23746
|
const offerNumber = generateOfferNumber(lastOffer?.offer_number || null, params.type);
|
|
23337
23747
|
const calculatedLines = params.lines.map((line, i) => {
|
|
23338
23748
|
const vatRate = parseFloat(line.vatRate);
|
|
@@ -23371,7 +23781,7 @@ function registerOfferTools(server2) {
|
|
|
23371
23781
|
p_lines: calculatedLines
|
|
23372
23782
|
});
|
|
23373
23783
|
if (rpcError) {
|
|
23374
|
-
return toErrorContent(
|
|
23784
|
+
return toErrorContent(sanitizeDbError("Failed to create offer", rpcError));
|
|
23375
23785
|
}
|
|
23376
23786
|
return toTextContent({
|
|
23377
23787
|
message: `${params.type === "sopimus" ? "Contract" : "Offer"} ${offerNumber} created`,
|
|
@@ -23432,7 +23842,7 @@ function registerOfferTools(server2) {
|
|
|
23432
23842
|
}
|
|
23433
23843
|
const { error: updateError } = await supabase2.from("offers").update(updateFields).eq("id", params.offerId).eq("company_id", companyId2);
|
|
23434
23844
|
if (updateError) {
|
|
23435
|
-
return toErrorContent(
|
|
23845
|
+
return toErrorContent(sanitizeDbError("Failed to update offer status", updateError));
|
|
23436
23846
|
}
|
|
23437
23847
|
const { error: eventError } = await supabase2.from("offer_events").insert({
|
|
23438
23848
|
offer_id: params.offerId,
|
|
@@ -23471,7 +23881,7 @@ function registerOfferTools(server2) {
|
|
|
23471
23881
|
}
|
|
23472
23882
|
const { error: deleteError } = await supabase2.from("offers").delete().eq("id", params.offerId).eq("company_id", companyId2);
|
|
23473
23883
|
if (deleteError) {
|
|
23474
|
-
return toErrorContent(
|
|
23884
|
+
return toErrorContent(sanitizeDbError("Failed to delete offer", deleteError));
|
|
23475
23885
|
}
|
|
23476
23886
|
return toTextContent({
|
|
23477
23887
|
message: `Draft ${offer.type === "sopimus" ? "contract" : "offer"} ${offer.offer_number} deleted`,
|
|
@@ -23481,6 +23891,326 @@ function registerOfferTools(server2) {
|
|
|
23481
23891
|
);
|
|
23482
23892
|
}
|
|
23483
23893
|
|
|
23894
|
+
// src/tools/recurring-invoices.ts
|
|
23895
|
+
function registerRecurringInvoiceTools(server2) {
|
|
23896
|
+
server2.tool(
|
|
23897
|
+
"kirjapro_list_recurring_invoices",
|
|
23898
|
+
"List recurring invoice templates with pagination. Shows frequency, next generation date, active status, and customer info.",
|
|
23899
|
+
{
|
|
23900
|
+
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
23901
|
+
offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
|
|
23902
|
+
activeOnly: external_exports.boolean().default(true).describe("Only show active templates (default true)")
|
|
23903
|
+
},
|
|
23904
|
+
async (params) => {
|
|
23905
|
+
const supabase2 = getSupabase();
|
|
23906
|
+
const companyId2 = getCompanyId();
|
|
23907
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
23908
|
+
let query = supabase2.from("recurring_invoices").select(
|
|
23909
|
+
"id, name, frequency, next_date, end_date, is_active, auto_send, last_generated_at, times_generated, created_at, customer:customers(id, name)",
|
|
23910
|
+
{ count: "exact" }
|
|
23911
|
+
).eq("company_id", companyId2).order("next_date");
|
|
23912
|
+
if (params.activeOnly) {
|
|
23913
|
+
query = query.eq("is_active", true);
|
|
23914
|
+
}
|
|
23915
|
+
query = query.range(offset, offset + limit - 1);
|
|
23916
|
+
const { data, count, error: error2 } = await query;
|
|
23917
|
+
if (error2) {
|
|
23918
|
+
return toErrorContent(sanitizeDbError("Failed to list recurring invoices", error2));
|
|
23919
|
+
}
|
|
23920
|
+
return toTextContent(paginate(data || [], count || 0, offset, limit));
|
|
23921
|
+
}
|
|
23922
|
+
);
|
|
23923
|
+
server2.tool(
|
|
23924
|
+
"kirjapro_get_recurring_invoice",
|
|
23925
|
+
"Get a single recurring invoice template with full details including template data (lines, notes, payment terms) and generation history.",
|
|
23926
|
+
{
|
|
23927
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID")
|
|
23928
|
+
},
|
|
23929
|
+
async (params) => {
|
|
23930
|
+
const supabase2 = getSupabase();
|
|
23931
|
+
const companyId2 = getCompanyId();
|
|
23932
|
+
const { data, error: error2 } = await supabase2.from("recurring_invoices").select(
|
|
23933
|
+
"*, customer:customers(id, name, business_id, email)"
|
|
23934
|
+
).eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
23935
|
+
if (error2 || !data) {
|
|
23936
|
+
return toErrorContent(
|
|
23937
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
23938
|
+
);
|
|
23939
|
+
}
|
|
23940
|
+
const { data: logs } = await supabase2.from("recurring_invoice_logs").select("id, status, scheduled_date, invoice_number, error_message, created_at").eq("recurring_invoice_id", params.recurringInvoiceId).eq("company_id", companyId2).order("created_at", { ascending: false }).limit(10);
|
|
23941
|
+
return toTextContent({
|
|
23942
|
+
...data,
|
|
23943
|
+
recent_logs: logs || []
|
|
23944
|
+
});
|
|
23945
|
+
}
|
|
23946
|
+
);
|
|
23947
|
+
server2.tool(
|
|
23948
|
+
"kirjapro_create_recurring_invoice",
|
|
23949
|
+
"Create a new recurring invoice template. Specify customer, frequency, start date, and invoice line items. Invoices will be generated automatically on the schedule.",
|
|
23950
|
+
{
|
|
23951
|
+
customerId: external_exports.string().uuid().describe("Customer UUID"),
|
|
23952
|
+
name: external_exports.string().min(1).max(200).describe('Template name (e.g. "Monthly hosting fee")'),
|
|
23953
|
+
frequency: external_exports.enum(["weekly", "monthly", "quarterly", "yearly"]).describe("Generation frequency"),
|
|
23954
|
+
nextDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("First generation date (YYYY-MM-DD)"),
|
|
23955
|
+
endDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Stop generating after this date"),
|
|
23956
|
+
autoSend: external_exports.boolean().default(false).describe("Auto-send generated invoices (default false)"),
|
|
23957
|
+
lines: external_exports.array(external_exports.object({
|
|
23958
|
+
description: external_exports.string().describe("Line item description"),
|
|
23959
|
+
quantity: external_exports.number().positive().describe("Quantity"),
|
|
23960
|
+
unit: external_exports.string().default("kpl").describe('Unit (default "kpl")'),
|
|
23961
|
+
unitPrice: external_exports.number().describe("Unit price (EUR)"),
|
|
23962
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("VAT rate")
|
|
23963
|
+
})).min(1).describe("Invoice line items"),
|
|
23964
|
+
notes: external_exports.string().optional().describe("Invoice notes"),
|
|
23965
|
+
paymentTermDays: external_exports.number().min(0).max(365).default(14).describe("Payment term in days")
|
|
23966
|
+
},
|
|
23967
|
+
async (params) => {
|
|
23968
|
+
const supabase2 = getSupabase();
|
|
23969
|
+
const companyId2 = getCompanyId();
|
|
23970
|
+
const { data: customer, error: custError } = await supabase2.from("customers").select("id, name").eq("id", params.customerId).eq("company_id", companyId2).maybeSingle();
|
|
23971
|
+
if (custError || !customer) {
|
|
23972
|
+
return toErrorContent(
|
|
23973
|
+
`Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
|
|
23974
|
+
);
|
|
23975
|
+
}
|
|
23976
|
+
const templateData = {
|
|
23977
|
+
lines: params.lines.map((l, i) => ({
|
|
23978
|
+
line_number: i + 1,
|
|
23979
|
+
description: l.description,
|
|
23980
|
+
quantity: l.quantity,
|
|
23981
|
+
unit: l.unit,
|
|
23982
|
+
unit_price: l.unitPrice,
|
|
23983
|
+
vat_rate: parseFloat(l.vatRate)
|
|
23984
|
+
})),
|
|
23985
|
+
notes: params.notes || null,
|
|
23986
|
+
payment_terms_days: params.paymentTermDays
|
|
23987
|
+
};
|
|
23988
|
+
const { data, error: error2 } = await supabase2.from("recurring_invoices").insert({
|
|
23989
|
+
company_id: companyId2,
|
|
23990
|
+
customer_id: params.customerId,
|
|
23991
|
+
name: params.name,
|
|
23992
|
+
frequency: params.frequency,
|
|
23993
|
+
next_date: params.nextDate,
|
|
23994
|
+
end_date: params.endDate || null,
|
|
23995
|
+
is_active: true,
|
|
23996
|
+
auto_send: params.autoSend,
|
|
23997
|
+
template_data: templateData
|
|
23998
|
+
}).select().single();
|
|
23999
|
+
if (error2) {
|
|
24000
|
+
return toErrorContent(sanitizeDbError("Failed to create recurring invoice", error2));
|
|
24001
|
+
}
|
|
24002
|
+
return toTextContent({
|
|
24003
|
+
message: `Recurring invoice "${params.name}" created for ${customer.name} (${params.frequency}, next: ${params.nextDate})`,
|
|
24004
|
+
recurring_invoice: data
|
|
24005
|
+
});
|
|
24006
|
+
}
|
|
24007
|
+
);
|
|
24008
|
+
server2.tool(
|
|
24009
|
+
"kirjapro_update_recurring_invoice",
|
|
24010
|
+
"Update a recurring invoice template. Only provided fields are changed. Use this to pause/resume (isActive), change schedule, or update line items.",
|
|
24011
|
+
{
|
|
24012
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID"),
|
|
24013
|
+
name: external_exports.string().min(1).max(200).optional().describe("Template name"),
|
|
24014
|
+
frequency: external_exports.enum(["weekly", "monthly", "quarterly", "yearly"]).optional().describe("Generation frequency"),
|
|
24015
|
+
nextDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Next generation date"),
|
|
24016
|
+
endDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable().describe("End date (null to remove)"),
|
|
24017
|
+
isActive: external_exports.boolean().optional().describe("Active status (false to pause)"),
|
|
24018
|
+
autoSend: external_exports.boolean().optional().describe("Auto-send generated invoices"),
|
|
24019
|
+
lines: external_exports.array(external_exports.object({
|
|
24020
|
+
description: external_exports.string().describe("Line item description"),
|
|
24021
|
+
quantity: external_exports.number().positive().describe("Quantity"),
|
|
24022
|
+
unit: external_exports.string().default("kpl").describe("Unit"),
|
|
24023
|
+
unitPrice: external_exports.number().describe("Unit price (EUR)"),
|
|
24024
|
+
vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("VAT rate")
|
|
24025
|
+
})).min(1).optional().describe("Replace all invoice lines"),
|
|
24026
|
+
notes: external_exports.string().optional().nullable().describe("Invoice notes (null to remove)"),
|
|
24027
|
+
paymentTermDays: external_exports.number().min(0).max(365).optional().describe("Payment term in days")
|
|
24028
|
+
},
|
|
24029
|
+
async (params) => {
|
|
24030
|
+
const supabase2 = getSupabase();
|
|
24031
|
+
const companyId2 = getCompanyId();
|
|
24032
|
+
const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, template_data").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
24033
|
+
if (fetchError || !existing) {
|
|
24034
|
+
return toErrorContent(
|
|
24035
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
24036
|
+
);
|
|
24037
|
+
}
|
|
24038
|
+
const updates = {};
|
|
24039
|
+
if (params.name !== void 0) updates.name = params.name;
|
|
24040
|
+
if (params.frequency !== void 0) updates.frequency = params.frequency;
|
|
24041
|
+
if (params.nextDate !== void 0) updates.next_date = params.nextDate;
|
|
24042
|
+
if (params.endDate !== void 0) updates.end_date = params.endDate;
|
|
24043
|
+
if (params.isActive !== void 0) updates.is_active = params.isActive;
|
|
24044
|
+
if (params.autoSend !== void 0) updates.auto_send = params.autoSend;
|
|
24045
|
+
if (params.lines !== void 0 || params.notes !== void 0 || params.paymentTermDays !== void 0) {
|
|
24046
|
+
const currentTemplate = existing.template_data || {};
|
|
24047
|
+
const newTemplate = { ...currentTemplate };
|
|
24048
|
+
if (params.lines !== void 0) {
|
|
24049
|
+
newTemplate.lines = params.lines.map((l, i) => ({
|
|
24050
|
+
line_number: i + 1,
|
|
24051
|
+
description: l.description,
|
|
24052
|
+
quantity: l.quantity,
|
|
24053
|
+
unit: l.unit,
|
|
24054
|
+
unit_price: l.unitPrice,
|
|
24055
|
+
vat_rate: parseFloat(l.vatRate)
|
|
24056
|
+
}));
|
|
24057
|
+
}
|
|
24058
|
+
if (params.notes !== void 0) newTemplate.notes = params.notes;
|
|
24059
|
+
if (params.paymentTermDays !== void 0) newTemplate.payment_terms_days = params.paymentTermDays;
|
|
24060
|
+
updates.template_data = newTemplate;
|
|
24061
|
+
}
|
|
24062
|
+
if (Object.keys(updates).length === 0) {
|
|
24063
|
+
return toErrorContent("No fields to update. Provide at least one field to change.");
|
|
24064
|
+
}
|
|
24065
|
+
const { data, error: error2 } = await supabase2.from("recurring_invoices").update(updates).eq("id", params.recurringInvoiceId).eq("company_id", companyId2).select().single();
|
|
24066
|
+
if (error2) {
|
|
24067
|
+
return toErrorContent(sanitizeDbError("Failed to update recurring invoice", error2));
|
|
24068
|
+
}
|
|
24069
|
+
return toTextContent({
|
|
24070
|
+
message: `Recurring invoice "${data.name}" updated`,
|
|
24071
|
+
recurring_invoice: data,
|
|
24072
|
+
fields_changed: Object.keys(updates)
|
|
24073
|
+
});
|
|
24074
|
+
}
|
|
24075
|
+
);
|
|
24076
|
+
server2.tool(
|
|
24077
|
+
"kirjapro_delete_recurring_invoice",
|
|
24078
|
+
"Delete a recurring invoice template. Previously generated invoices are NOT affected.",
|
|
24079
|
+
{
|
|
24080
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID")
|
|
24081
|
+
},
|
|
24082
|
+
async (params) => {
|
|
24083
|
+
const supabase2 = getSupabase();
|
|
24084
|
+
const companyId2 = getCompanyId();
|
|
24085
|
+
const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
24086
|
+
if (fetchError || !existing) {
|
|
24087
|
+
return toErrorContent(
|
|
24088
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
24089
|
+
);
|
|
24090
|
+
}
|
|
24091
|
+
const { error: error2 } = await supabase2.from("recurring_invoices").delete().eq("id", params.recurringInvoiceId).eq("company_id", companyId2);
|
|
24092
|
+
if (error2) {
|
|
24093
|
+
return toErrorContent(sanitizeDbError("Failed to delete recurring invoice", error2));
|
|
24094
|
+
}
|
|
24095
|
+
return toTextContent({
|
|
24096
|
+
message: `Recurring invoice "${existing.name}" deleted. Previously generated invoices are unaffected.`
|
|
24097
|
+
});
|
|
24098
|
+
}
|
|
24099
|
+
);
|
|
24100
|
+
server2.tool(
|
|
24101
|
+
"kirjapro_trigger_recurring_invoice",
|
|
24102
|
+
"Manually trigger generation of a recurring invoice now, regardless of its next scheduled date. Returns the generated invoice.",
|
|
24103
|
+
{
|
|
24104
|
+
recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID")
|
|
24105
|
+
},
|
|
24106
|
+
async (params) => {
|
|
24107
|
+
const supabase2 = getSupabase();
|
|
24108
|
+
const companyId2 = getCompanyId();
|
|
24109
|
+
const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, is_active").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
|
|
24110
|
+
if (fetchError || !existing) {
|
|
24111
|
+
return toErrorContent(
|
|
24112
|
+
`Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
|
|
24113
|
+
);
|
|
24114
|
+
}
|
|
24115
|
+
if (!existing.is_active) {
|
|
24116
|
+
return toErrorContent(
|
|
24117
|
+
`Recurring invoice "${existing.name}" is paused. Activate it first with kirjapro_update_recurring_invoice.`
|
|
24118
|
+
);
|
|
24119
|
+
}
|
|
24120
|
+
const result = await invokeEdgeFunction(
|
|
24121
|
+
supabase2,
|
|
24122
|
+
"generate-recurring-invoices",
|
|
24123
|
+
{ recurring_invoice_id: params.recurringInvoiceId, company_id: companyId2 }
|
|
24124
|
+
);
|
|
24125
|
+
if (result.error !== null) {
|
|
24126
|
+
return toErrorContent(result.error);
|
|
24127
|
+
}
|
|
24128
|
+
return toTextContent({
|
|
24129
|
+
message: `Invoice generated from "${existing.name}"`,
|
|
24130
|
+
...result.data
|
|
24131
|
+
});
|
|
24132
|
+
}
|
|
24133
|
+
);
|
|
24134
|
+
}
|
|
24135
|
+
|
|
24136
|
+
// src/tools/received-invoices.ts
|
|
24137
|
+
function registerReceivedInvoiceTools(server2) {
|
|
24138
|
+
server2.tool(
|
|
24139
|
+
"kirjapro_list_received_invoices",
|
|
24140
|
+
"List received (purchase) invoices with pagination and filters. Shows sender, amounts, status, and due dates.",
|
|
24141
|
+
{
|
|
24142
|
+
limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
|
|
24143
|
+
offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
|
|
24144
|
+
status: external_exports.enum(["new", "reviewed", "approved", "rejected", "booked"]).optional().describe("Filter by status"),
|
|
24145
|
+
search: external_exports.string().optional().describe("Search by sender name, invoice number, or business ID"),
|
|
24146
|
+
dateFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date from (YYYY-MM-DD)"),
|
|
24147
|
+
dateTo: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date to (YYYY-MM-DD)")
|
|
24148
|
+
},
|
|
24149
|
+
async (params) => {
|
|
24150
|
+
const supabase2 = getSupabase();
|
|
24151
|
+
const companyId2 = getCompanyId();
|
|
24152
|
+
const { limit, offset } = clampPagination(params.limit, params.offset);
|
|
24153
|
+
let query = supabase2.from("received_invoices").select(
|
|
24154
|
+
"id, sender_business_id, sender_name, invoice_number, invoice_date, due_date, total_amount, vat_amount, currency, payment_reference, iban, status, created_at",
|
|
24155
|
+
{ count: "exact" }
|
|
24156
|
+
).eq("company_id", companyId2).order("created_at", { ascending: false });
|
|
24157
|
+
if (params.status) {
|
|
24158
|
+
query = query.eq("status", params.status);
|
|
24159
|
+
}
|
|
24160
|
+
if (params.search) {
|
|
24161
|
+
const s = escapeLike(params.search);
|
|
24162
|
+
query = query.or(
|
|
24163
|
+
`sender_name.ilike.%${s}%,invoice_number.ilike.%${s}%,sender_business_id.ilike.%${s}%`
|
|
24164
|
+
);
|
|
24165
|
+
}
|
|
24166
|
+
if (params.dateFrom) {
|
|
24167
|
+
query = query.gte("invoice_date", params.dateFrom);
|
|
24168
|
+
}
|
|
24169
|
+
if (params.dateTo) {
|
|
24170
|
+
query = query.lte("invoice_date", params.dateTo);
|
|
24171
|
+
}
|
|
24172
|
+
query = query.range(offset, offset + limit - 1);
|
|
24173
|
+
const { data, count, error: error2 } = await query;
|
|
24174
|
+
if (error2) {
|
|
24175
|
+
return toErrorContent(sanitizeDbError("Failed to list received invoices", error2));
|
|
24176
|
+
}
|
|
24177
|
+
const results = (data || []).map((inv) => ({
|
|
24178
|
+
...inv,
|
|
24179
|
+
total_amount_formatted: inv.total_amount != null ? formatCurrency(inv.total_amount) : null
|
|
24180
|
+
}));
|
|
24181
|
+
return toTextContent(paginate(results, count || 0, offset, limit));
|
|
24182
|
+
}
|
|
24183
|
+
);
|
|
24184
|
+
server2.tool(
|
|
24185
|
+
"kirjapro_get_received_invoice",
|
|
24186
|
+
"Get a single received invoice with full details including sender info, line items, and linked transaction.",
|
|
24187
|
+
{
|
|
24188
|
+
receivedInvoiceId: external_exports.string().uuid().describe("Received invoice UUID")
|
|
24189
|
+
},
|
|
24190
|
+
async (params) => {
|
|
24191
|
+
const supabase2 = getSupabase();
|
|
24192
|
+
const companyId2 = getCompanyId();
|
|
24193
|
+
const { data, error: error2 } = await supabase2.from("received_invoices").select("*").eq("id", params.receivedInvoiceId).eq("company_id", companyId2).single();
|
|
24194
|
+
if (error2 || !data) {
|
|
24195
|
+
return toErrorContent(
|
|
24196
|
+
`Received invoice ${params.receivedInvoiceId} not found. Use kirjapro_list_received_invoices to find IDs.`
|
|
24197
|
+
);
|
|
24198
|
+
}
|
|
24199
|
+
let linkedTransaction = null;
|
|
24200
|
+
if (data.transaction_id) {
|
|
24201
|
+
const { data: tx } = await supabase2.from("transactions").select("id, transaction_date, description, document_number").eq("id", data.transaction_id).eq("company_id", companyId2).single();
|
|
24202
|
+
linkedTransaction = tx;
|
|
24203
|
+
}
|
|
24204
|
+
return toTextContent({
|
|
24205
|
+
...data,
|
|
24206
|
+
total_amount_formatted: data.total_amount != null ? formatCurrency(data.total_amount) : null,
|
|
24207
|
+
vat_amount_formatted: data.vat_amount != null ? formatCurrency(data.vat_amount) : null,
|
|
24208
|
+
linked_transaction: linkedTransaction
|
|
24209
|
+
});
|
|
24210
|
+
}
|
|
24211
|
+
);
|
|
24212
|
+
}
|
|
24213
|
+
|
|
23484
24214
|
// src/tools/index.ts
|
|
23485
24215
|
function registerTools(server2) {
|
|
23486
24216
|
registerAccountTools(server2);
|
|
@@ -23494,112 +24224,46 @@ function registerTools(server2) {
|
|
|
23494
24224
|
registerValidationTools(server2);
|
|
23495
24225
|
registerFiscalYearTools(server2);
|
|
23496
24226
|
registerOfferTools(server2);
|
|
24227
|
+
registerRecurringInvoiceTools(server2);
|
|
24228
|
+
registerReceivedInvoiceTools(server2);
|
|
23497
24229
|
}
|
|
23498
24230
|
|
|
23499
24231
|
// src/resources/schema.ts
|
|
23500
|
-
var
|
|
23501
|
-
|
|
23502
|
-
|
|
23503
|
-
|
|
23504
|
-
|
|
23505
|
-
|
|
23506
|
-
|
|
23507
|
-
|
|
23508
|
-
|
|
23509
|
-
|
|
23510
|
-
|
|
23511
|
-
|
|
23512
|
-
|
|
23513
|
-
|
|
23514
|
-
|
|
23515
|
-
{ number: "2010", name: "Aineet ja tarvikkeet", type: "asset" },
|
|
23516
|
-
{ number: "2020", name: "Keskener\xE4iset tuotteet", type: "asset" },
|
|
23517
|
-
{ number: "2030", name: "Valmiit tuotteet", type: "asset" },
|
|
23518
|
-
{ number: "2100", name: "SAAMISET", type: "asset", header: true },
|
|
23519
|
-
{ number: "2110", name: "Myyntisaamiset", type: "asset" },
|
|
23520
|
-
{ number: "2120", name: "Lainasaamiset", type: "asset" },
|
|
23521
|
-
{ number: "2130", name: "Siirtosaamiset", type: "asset" },
|
|
23522
|
-
{ number: "2140", name: "ALV-saamiset", type: "asset" },
|
|
23523
|
-
{ number: "2200", name: "RAHOITUSARVOPAPERIT", type: "asset", header: true },
|
|
23524
|
-
{ number: "2300", name: "RAHAT JA PANKKISAAMISET", type: "asset", header: true },
|
|
23525
|
-
{ number: "2310", name: "Kassa", type: "asset" },
|
|
23526
|
-
{ number: "2320", name: "Pankkitili", type: "asset" },
|
|
23527
|
-
// Oma pääoma (Equity)
|
|
23528
|
-
{ number: "3000", name: "OMA P\xC4\xC4OMA", type: "equity", header: true },
|
|
23529
|
-
{ number: "3010", name: "Osakep\xE4\xE4oma / Perusp\xE4\xE4oma", type: "equity" },
|
|
23530
|
-
{ number: "3020", name: "Ylikurssirahasto", type: "equity" },
|
|
23531
|
-
{ number: "3030", name: "Sijoitetun vapaan oman p\xE4\xE4oman rahasto (SVOP)", type: "equity" },
|
|
23532
|
-
{ number: "3040", name: "Edellisten tilikausien voitto/tappio", type: "equity" },
|
|
23533
|
-
{ number: "3050", name: "Tilikauden voitto/tappio", type: "equity" },
|
|
23534
|
-
{ number: "3060", name: "Yksityistilit (toiminimi)", type: "equity" },
|
|
23535
|
-
// Vieras pääoma (Liabilities)
|
|
23536
|
-
{ number: "4000", name: "VIERAS P\xC4\xC4OMA", type: "liability", header: true },
|
|
23537
|
-
{ number: "4010", name: "Pitk\xE4aikaiset lainat", type: "liability" },
|
|
23538
|
-
{ number: "4020", name: "Lyhytaikaiset lainat", type: "liability" },
|
|
23539
|
-
{ number: "4030", name: "Ostovelat", type: "liability" },
|
|
23540
|
-
{ number: "4040", name: "Siirtovelat", type: "liability" },
|
|
23541
|
-
{ number: "4050", name: "ALV-velka", type: "liability" },
|
|
23542
|
-
{ number: "4060", name: "Ennakonpid\xE4tysvelka", type: "liability" },
|
|
23543
|
-
{ number: "4070", name: "Sosiaaliturvamaksuvelka", type: "liability" },
|
|
23544
|
-
{ number: "4080", name: "TyEL-velka", type: "liability" },
|
|
23545
|
-
// Tuotot (Revenue)
|
|
23546
|
-
{ number: "5000", name: "LIIKEVAIHTO", type: "revenue", header: true },
|
|
23547
|
-
{ number: "5010", name: "Myynti 25,5 %", type: "revenue" },
|
|
23548
|
-
{ number: "5020", name: "Myynti 13,5 %", type: "revenue" },
|
|
23549
|
-
{ number: "5030", name: "Myynti 10 %", type: "revenue" },
|
|
23550
|
-
{ number: "5040", name: "Myynti 0 %", type: "revenue" },
|
|
23551
|
-
{ number: "5100", name: "LIIKETOIMINNAN MUUT TUOTOT", type: "revenue", header: true },
|
|
23552
|
-
{ number: "5110", name: "Vuokratuotot", type: "revenue" },
|
|
23553
|
-
{ number: "5120", name: "K\xE4ytt\xF6omaisuuden myyntivoitot", type: "revenue" },
|
|
23554
|
-
// Kulut (Expenses)
|
|
23555
|
-
{ number: "6000", name: "MATERIAALIT JA PALVELUT", type: "expense", header: true },
|
|
23556
|
-
{ number: "6010", name: "Ostot tilikauden aikana", type: "expense" },
|
|
23557
|
-
{ number: "6020", name: "Varastonmuutos", type: "expense" },
|
|
23558
|
-
{ number: "6030", name: "Ulkopuoliset palvelut", type: "expense" },
|
|
23559
|
-
{ number: "7000", name: "HENKIL\xD6ST\xD6KULUT", type: "expense", header: true },
|
|
23560
|
-
{ number: "7010", name: "Palkat ja palkkiot", type: "expense" },
|
|
23561
|
-
{ number: "7020", name: "El\xE4kekulut (TyEL)", type: "expense" },
|
|
23562
|
-
{ number: "7030", name: "Muut henkil\xF6sivukulut", type: "expense" },
|
|
23563
|
-
{ number: "7500", name: "POISTOT JA ARVONALENTUMISET", type: "expense", header: true },
|
|
23564
|
-
{ number: "7510", name: "Suunnitelman mukaiset poistot", type: "expense" },
|
|
23565
|
-
{ number: "8000", name: "LIIKETOIMINNAN MUUT KULUT", type: "expense", header: true },
|
|
23566
|
-
{ number: "8010", name: "Toimitilakulut", type: "expense" },
|
|
23567
|
-
{ number: "8020", name: "Ajoneuvokulut", type: "expense" },
|
|
23568
|
-
{ number: "8030", name: "Matkakulut", type: "expense" },
|
|
23569
|
-
{ number: "8040", name: "Edustuskulut", type: "expense" },
|
|
23570
|
-
{ number: "8050", name: "Markkinointikulut", type: "expense" },
|
|
23571
|
-
{ number: "8060", name: "Hallintokulut", type: "expense" },
|
|
23572
|
-
{ number: "8070", name: "Vakuutukset", type: "expense" },
|
|
23573
|
-
{ number: "8080", name: "Tietoliikennekulut", type: "expense" },
|
|
23574
|
-
{ number: "8090", name: "Toimistokulut", type: "expense" },
|
|
23575
|
-
{ number: "8400", name: "RAHOITUSTUOTOT JA -KULUT", type: "expense", header: true },
|
|
23576
|
-
{ number: "8410", name: "Korkotuotot", type: "revenue" },
|
|
23577
|
-
{ number: "8420", name: "Korkokulut", type: "expense" },
|
|
23578
|
-
{ number: "8430", name: "Muut rahoitustuotot", type: "revenue" },
|
|
23579
|
-
{ number: "8440", name: "Muut rahoituskulut", type: "expense" },
|
|
23580
|
-
{ number: "9000", name: "TULOVEROT", type: "expense", header: true },
|
|
23581
|
-
{ number: "9010", name: "Tilikauden tuloverot", type: "expense" },
|
|
23582
|
-
{ number: "9020", name: "Laskennalliset verot", type: "expense" }
|
|
23583
|
-
];
|
|
24232
|
+
var CHART_OF_ACCOUNTS_SUMMARY = {
|
|
24233
|
+
standard: "YSE 2017",
|
|
24234
|
+
ranges: [
|
|
24235
|
+
{ range: "1000-1299", type: "asset", label: "Pysyv\xE4t vastaavat (fixed assets)", examples: "1130 Koneet ja kalusto, 1210 Osakkeet" },
|
|
24236
|
+
{ range: "2000-2399", type: "asset", label: "Vaihtuvat vastaavat (current assets)", examples: "2110 Myyntisaamiset, 2140 ALV-saamiset, 2310 Kassa, 2320 Pankkitili" },
|
|
24237
|
+
{ range: "3000-3099", type: "equity", label: "Oma p\xE4\xE4oma (equity)", examples: "3010 Osakep\xE4\xE4oma, 3030 SVOP, 3050 Tilikauden tulos" },
|
|
24238
|
+
{ range: "4000-4099", type: "liability", label: "Vieras p\xE4\xE4oma (liabilities)", examples: "4030 Ostovelat, 4050 ALV-velka, 4060 Ennakonpid\xE4tysvelka, 4080 TyEL-velka" },
|
|
24239
|
+
{ range: "5000-5199", type: "revenue", label: "Tuotot (revenue)", examples: "5010 Myynti 25,5%, 5020 Myynti 13,5%, 5040 Myynti 0%" },
|
|
24240
|
+
{ range: "6000-6099", type: "expense", label: "Materiaalit ja palvelut", examples: "6010 Ostot, 6030 Ulkopuoliset palvelut" },
|
|
24241
|
+
{ range: "7000-7599", type: "expense", label: "Henkil\xF6st\xF6 ja poistot", examples: "7010 Palkat, 7020 El\xE4kekulut, 7510 Poistot" },
|
|
24242
|
+
{ range: "8000-8499", type: "expense", label: "Liiketoiminnan muut kulut", examples: "8010 Toimitila, 8050 Markkinointi, 8060 Hallinto, 8070 Vakuutukset" },
|
|
24243
|
+
{ range: "9000-9099", type: "expense", label: "Tuloverot", examples: "9010 Tilikauden tuloverot" }
|
|
24244
|
+
],
|
|
24245
|
+
note: "Use kirjapro_list_accounts to get full account list with search/filter."
|
|
24246
|
+
};
|
|
23584
24247
|
var VAT_RATES = [
|
|
23585
|
-
{ rate: 25.5, label: "Yleinen verokanta", description: "Standard rate for most goods and services" },
|
|
23586
|
-
{ rate: 13.5, label: "
|
|
23587
|
-
{ rate: 10, label: "
|
|
23588
|
-
{ rate: 0, label: "
|
|
24248
|
+
{ rate: 25.5, label: "Yleinen verokanta", description: "Standard rate for most goods and services (from 1.9.2024, was 24%)", basis: "AVL 84\xA7" },
|
|
24249
|
+
{ rate: 13.5, label: "Alennettu verokanta", description: "Food, animal feed, restaurants, transport, accommodation, books, medicine, sports, culture, performing artists, sanitary products, broadcasting (from 1.1.2026, was 14%)", basis: "AVL 85\xA7, 85a\xA7" },
|
|
24250
|
+
{ rate: 10, label: "Lehdet ja aikakauslehdet", description: "Newspapers and magazines (print and digital) only", basis: "AVL 85a\xA7" },
|
|
24251
|
+
{ rate: 0, label: "Nollaverokanta", description: "Exports (AVL 70\xA7), intra-EU B2B supplies (AVL 72a\xA7), vessels, international transport. Input VAT deductible", basis: "AVL 55-72h\xA7" },
|
|
24252
|
+
{ rate: null, label: "Verovapaat myynnit", description: "Healthcare (AVL 34-36\xA7), education (AVL 39-40\xA7), financial services (AVL 41-42\xA7), insurance (AVL 44\xA7). NO input VAT deduction", basis: "AVL 34-44\xA7" }
|
|
23589
24253
|
];
|
|
23590
24254
|
function registerSchemaResources(server2) {
|
|
23591
24255
|
server2.resource(
|
|
23592
24256
|
"chart-of-accounts",
|
|
23593
24257
|
"kirjapro://accounts/chart",
|
|
23594
24258
|
{
|
|
23595
|
-
description: "Finnish
|
|
24259
|
+
description: "Finnish chart of accounts summary (YSE 2017). Shows account number ranges and common accounts per category. Use kirjapro_list_accounts tool for full detail.",
|
|
23596
24260
|
mimeType: "application/json"
|
|
23597
24261
|
},
|
|
23598
24262
|
async (uri) => ({
|
|
23599
24263
|
contents: [{
|
|
23600
24264
|
uri: uri.href,
|
|
23601
24265
|
mimeType: "application/json",
|
|
23602
|
-
text: JSON.stringify(
|
|
24266
|
+
text: JSON.stringify(CHART_OF_ACCOUNTS_SUMMARY)
|
|
23603
24267
|
}]
|
|
23604
24268
|
})
|
|
23605
24269
|
);
|
|
@@ -23607,7 +24271,7 @@ function registerSchemaResources(server2) {
|
|
|
23607
24271
|
"vat-rates",
|
|
23608
24272
|
"kirjapro://vat-rates",
|
|
23609
24273
|
{
|
|
23610
|
-
description: "Finnish VAT rates (2026). Rates: 25.5% (general), 13.5% (food/restaurants), 10% (
|
|
24274
|
+
description: "Finnish VAT rates (2026). Rates: 25.5% (general), 13.5% (food/restaurants/books/medicine/transport/accommodation/sports), 10% (newspapers/magazines only), 0% (exports/vessels \u2014 input VAT deductible), exempt (healthcare/education/finance/insurance \u2014 NO input VAT deduction).",
|
|
23611
24275
|
mimeType: "application/json"
|
|
23612
24276
|
},
|
|
23613
24277
|
async (uri) => ({
|
|
@@ -23669,16 +24333,325 @@ function registerCompanyResources(server2) {
|
|
|
23669
24333
|
);
|
|
23670
24334
|
}
|
|
23671
24335
|
|
|
24336
|
+
// src/resources/compliance.ts
|
|
24337
|
+
var VAT_COMPLIANCE = {
|
|
24338
|
+
rates: {
|
|
24339
|
+
current_year: 2026,
|
|
24340
|
+
standard: { rate: 25.5, finnish: "Yleinen verokanta", effective: "2024-09-01", basis: "AVL 84\xA7", examples: "IT services, consulting, retail, construction, telecom, energy" },
|
|
24341
|
+
reduced: { rate: 13.5, finnish: "Alennettu verokanta", effective: "2026-01-01", basis: "AVL 85\xA7, 85a\xA7", examples: "Food, animal feed, restaurants, transport, accommodation, books, medicine, sports, culture, performing artists, sanitary products, broadcasting" },
|
|
24342
|
+
second_reduced: { rate: 10, finnish: "Toinen alennettu verokanta", basis: "AVL 85a\xA7", examples: "Newspapers, magazines, digital periodicals only" },
|
|
24343
|
+
zero: { rate: 0, finnish: "Nollaverokanta", basis: "AVL 55-72h\xA7", examples: "Exports outside EU, intra-community supplies, vessels (sale/rental/chartering), international transport" },
|
|
24344
|
+
exempt: { rate: null, finnish: "Verovapaat myynnit", basis: "AVL 34-44\xA7", examples: "Healthcare (AVL 34-36\xA7), education (AVL 39-40\xA7), financial services (AVL 41-42\xA7), insurance (AVL 44\xA7), social services (AVL 37-38\xA7), real estate transfers (AVL 27-28\xA7)" }
|
|
24345
|
+
},
|
|
24346
|
+
rate_history: [
|
|
24347
|
+
{ period: "1.1.2026 \u2192", standard: 25.5, reduced: 13.5, second_reduced: 10, note: "Reduced rate unified from 14% \u2192 13.5%" },
|
|
24348
|
+
{ period: "1.9.2024 \u2192 31.12.2025", standard: 25.5, reduced: 14, second_reduced: 10, note: "Standard raised from 24% \u2192 25.5%" },
|
|
24349
|
+
{ period: "1.1.2013 \u2192 31.8.2024", standard: 24, reduced: 14, second_reduced: 10 }
|
|
24350
|
+
],
|
|
24351
|
+
deduction_rules: [
|
|
24352
|
+
{ type: "Edustuskulut (entertainment)", vat_deduction: "0%", income_tax: "50%", basis: "AVL 114\xA7", booking: "Full gross to expense, NO ALV-saamiset entry" },
|
|
24353
|
+
{ type: "Neuvottelukulut (meetings)", vat_deduction: "100%", income_tax: "100%", basis: "AVL 102\xA7", booking: "Normal VAT split" },
|
|
24354
|
+
{ type: "Henkil\xF6st\xF6kulut (staff events)", vat_deduction: "100%", income_tax: "100%", basis: "AVL 102\xA7", booking: "Normal VAT split" },
|
|
24355
|
+
{ type: "Insurance premiums", vat_deduction: "0% (exempt)", income_tax: "100%", basis: "AVL 44\xA7", booking: "Full gross to expense, no VAT (vakuutusmaksuvero is separate)" },
|
|
24356
|
+
{ type: "Healthcare (staff)", vat_deduction: "0% (exempt)", income_tax: "100%", basis: "AVL 34-36\xA7" },
|
|
24357
|
+
{ type: "Financial services", vat_deduction: "0% (exempt)", income_tax: "100%", basis: "AVL 41-42\xA7" },
|
|
24358
|
+
{ type: "Henkil\xF6auto (passenger car M1)", vat_deduction: "100% if exclusive business use, 0% if ANY private use (incl. commuting)", income_tax: "Proportional", basis: "AVL 114\xA7 1 mom 4 kohta", booking: "Ajop\xE4iv\xE4kirja (mileage logbook) required as proof" },
|
|
24359
|
+
{ type: "Pakettiauto/kuorma-auto (van N1+)", vat_deduction: "Proportional to business use", income_tax: "Proportional", basis: "AVL 102\xA7, 117\xA7" },
|
|
24360
|
+
{ type: "Markkinointitilaisuudet (marketing events)", vat_deduction: "100%", income_tax: "100%", basis: "AVL 102\xA7, KHO 2013:63", booking: "Product launches, trade shows \u2014 NOT client entertainment" }
|
|
24361
|
+
],
|
|
24362
|
+
reverse_charge: {
|
|
24363
|
+
rule: "Buyer self-assesses both output and input VAT. Applies to: (1) foreign B2B providers not charging Finnish VAT, (2) domestic construction services, (3) metal scrap, (4) emissions allowances, (5) investment gold",
|
|
24364
|
+
basis: "AVL 8a-8d\xA7, 9\xA7, 65\xA7; EU VAT Directive Art. 196, Art. 199",
|
|
24365
|
+
scenarios: [
|
|
24366
|
+
{ type: "Foreign B2B", basis: "AVL 9\xA7", note: "Most common: EU and non-EU service providers" },
|
|
24367
|
+
{ type: "Construction services", basis: "AVL 8c\xA7", note: 'Since 1.4.2011. Buyer must itself sell/rent construction services "not just occasionally". Regular company commissioning renovation: NO reverse charge' },
|
|
24368
|
+
{ type: "Metal scrap and waste", basis: "AVL 8d\xA7", note: "Since 1.1.2015. Between VAT-registered entities" },
|
|
24369
|
+
{ type: "Emissions allowances", basis: "AVL 8b\xA7", note: "Greenhouse gas emission allowances" },
|
|
24370
|
+
{ type: "Investment gold", basis: "AVL 8a\xA7", note: "Taxable investment gold, gold materials, gold semi-finished products" }
|
|
24371
|
+
],
|
|
24372
|
+
booking: "4 entries: D expense + D ALV-saamiset + C bank + C ALV-velka (k\xE4\xE4nnetty)",
|
|
24373
|
+
note: "Net VAT effect is zero but BOTH must be reported on ALV-ilmoitus. Invoice must NOT show VAT rate or VAT amount",
|
|
24374
|
+
omavero_fields: "Foreign: 305 (EU goods), 306 (EU services). Domestic construction/scrap: 318 (VAT amount), 319/320 (tax bases)"
|
|
24375
|
+
},
|
|
24376
|
+
common_mistakes: [
|
|
24377
|
+
"Food/coffee at 25.5% \u2014 should be 13.5% (AVL 85\xA7)",
|
|
24378
|
+
"Train ticket at 10% \u2014 should be 13.5% (transport = reduced rate)",
|
|
24379
|
+
"Using 14% after 1.1.2026 \u2014 rate changed to 13.5%",
|
|
24380
|
+
"Restaurant alcohol at 13.5% \u2014 alcohol is 25.5% even at restaurants",
|
|
24381
|
+
"Newspaper/magazine at 13.5% \u2014 periodicals are 10%",
|
|
24382
|
+
"Splitting VAT from insurance premium \u2014 insurance is VAT-exempt, not zero-rated",
|
|
24383
|
+
"Deducting vakuutusmaksuvero as input VAT \u2014 it is not ALV"
|
|
24384
|
+
]
|
|
24385
|
+
};
|
|
24386
|
+
var INVOICE_REQUIREMENTS = {
|
|
24387
|
+
legal_basis: "AVL 209 e \xA7, EU VAT Directive Art. 226",
|
|
24388
|
+
source: "https://www.vero.fi/syventavat-vero-ohjeet/ohje-hakusivu/48090/laskutusvaatimukset-arvonlisaverotuksessa3/",
|
|
24389
|
+
total_markings: 18,
|
|
24390
|
+
mandatory_fields: [
|
|
24391
|
+
{ kohta: 1, field: "Laskun antamisp\xE4iv\xE4", english: "Invoice date", status: "implemented", relevance: "always" },
|
|
24392
|
+
{ kohta: 2, field: "Juokseva tunniste", english: "Sequential invoice number", status: "implemented", relevance: "always", note: "Gaps must be detectable per Vero requirement" },
|
|
24393
|
+
{ kohta: 3, field: "Myyj\xE4n ALV-tunniste", english: "Seller VAT ID (FI + Y-tunnus)", status: "implemented", relevance: "always", note: "Y-tunnus always required; FI-format strictly mandatory for EU cross-border" },
|
|
24394
|
+
{ kohta: 4, field: "Ostajan ALV-tunniste", english: "Buyer VAT ID", status: "implemented", relevance: "conditional", note: "Required for reverse charge and EU B2B" },
|
|
24395
|
+
{ kohta: 5, field: "Myyj\xE4n ja ostajan nimi ja osoite", english: "Names and addresses", status: "implemented", relevance: "always" },
|
|
24396
|
+
{ kohta: 6, field: "Tavaroiden/palvelujen kuvaus ja m\xE4\xE4r\xE4", english: "Description and quantity", status: "implemented", relevance: "always" },
|
|
24397
|
+
{ kohta: 7, field: "Toimitusp\xE4iv\xE4 / suoritusp\xE4iv\xE4", english: "Delivery date", status: "partial", relevance: "conditional", note: "NOT required when same as invoice date (Vero.fi 5.1.7). Required when different" },
|
|
24398
|
+
{ kohta: 8, field: "Veron peruste, yksikk\xF6hinta, alennukset", english: "Tax base, unit price, discounts", status: "implemented", relevance: "always" },
|
|
24399
|
+
{ kohta: 9, field: "Verokanta", english: "VAT rate", status: "implemented", relevance: "always" },
|
|
24400
|
+
{ kohta: 10, field: "Suoritettavan veron m\xE4\xE4r\xE4", english: "VAT amount", status: "implemented", relevance: "always" },
|
|
24401
|
+
{ kohta: 11, field: "Merkint\xE4 verottomuudesta", english: "Tax exemption notation", status: "gap", relevance: "conditional", note: 'Simple "Veroton myynti" suffices. Citing AVL section is recommended but not mandatory (Vero.fi 5.1.11)' },
|
|
24402
|
+
{ kohta: 12, field: "Merkint\xE4 k\xE4\xE4nnetyst\xE4 verovelvollisuudesta", english: "Reverse charge notation", status: "gap", relevance: "conditional", note: '"K\xE4\xE4nnetty verovelvollisuus" phrase is mandatory. Legal basis citation is recommended, not required' },
|
|
24403
|
+
{ kohta: 13, field: "Merkint\xE4 itselaskutuksesta", english: "Self-billing notation", status: "n/a", relevance: "situational", note: "When buyer issues invoice on seller behalf" },
|
|
24404
|
+
{ kohta: 14, field: "Uuden kuljetusv\xE4lineen tiedot", english: "New means of transport details", status: "n/a", relevance: "situational" },
|
|
24405
|
+
{ kohta: 15, field: "Voittomarginaalij\xE4rjestelm\xE4", english: "Margin scheme (used goods)", status: "n/a", relevance: "situational" },
|
|
24406
|
+
{ kohta: 16, field: "Voittomarginaalij\xE4rjestelm\xE4 \u2014 matkatoimistot", english: "Margin scheme (travel agents)", status: "n/a", relevance: "situational" },
|
|
24407
|
+
{ kohta: 17, field: "Verollisen sijoituskullan myynti", english: "Taxable investment gold", status: "n/a", relevance: "situational" },
|
|
24408
|
+
{ kohta: 18, field: "Viittaus alkuper\xE4iseen laskuun", english: "Credit note reference (muutoslasku)", status: "not_implemented", relevance: "conditional", note: "Only mandatory requirement: unambiguous reference to original invoice" }
|
|
24409
|
+
],
|
|
24410
|
+
simplified_invoice: {
|
|
24411
|
+
threshold: 400,
|
|
24412
|
+
note: "Invoices \u2264 400 \u20AC may use simplified format (AVL 209 f \xA7). Also: retail to consumers, restaurant/catering, passenger transport \u2014 regardless of amount"
|
|
24413
|
+
},
|
|
24414
|
+
muutoslasku: {
|
|
24415
|
+
status: "not_implemented",
|
|
24416
|
+
mandatory: "Unambiguous reference to original invoice (number or period)",
|
|
24417
|
+
recommended: "Hyvityslasku label, reason for credit, same numbering sequence \u2014 common practice, not legally mandated"
|
|
24418
|
+
}
|
|
24419
|
+
};
|
|
24420
|
+
var FILING_DEADLINES = {
|
|
24421
|
+
vat_filing: {
|
|
24422
|
+
thresholds: [
|
|
24423
|
+
{ revenue: "< 30,000 \u20AC", periods: ["monthly", "quarterly", "annually"], note: "Annual: deadline last day of February of following year (28.2 or 29.2 in leap year)" },
|
|
24424
|
+
{ revenue: "30,000\u2013100,000 \u20AC", periods: ["monthly", "quarterly"], note: "Quarterly: 12th of 2nd month after quarter" },
|
|
24425
|
+
{ revenue: "> 100,000 \u20AC", periods: ["monthly"], note: "Monthly: 12th of 2nd month after reporting month" }
|
|
24426
|
+
],
|
|
24427
|
+
monthly_example: "January 2026 VAT \u2192 deadline 12.3.2026",
|
|
24428
|
+
quarterly_deadlines: {
|
|
24429
|
+
Q1: "12.5",
|
|
24430
|
+
Q2: "12.8",
|
|
24431
|
+
Q3: "12.11",
|
|
24432
|
+
Q4: "12.2 (next year)"
|
|
24433
|
+
}
|
|
24434
|
+
},
|
|
24435
|
+
tyel: {
|
|
24436
|
+
monthly_report: "5th of following month \u2192 pension company",
|
|
24437
|
+
payment: "20th of following month \u2192 pension company"
|
|
24438
|
+
},
|
|
24439
|
+
tulorekisteri: {
|
|
24440
|
+
palkkatietoilmoitus: "5 calendar days from payment date \u2192 Tulorekisteri",
|
|
24441
|
+
tyonantajan_erillisilmoitus: "5th of following month \u2192 Tulorekisteri (sotu totals)"
|
|
24442
|
+
},
|
|
24443
|
+
sairausvakuutusmaksu_payment: "12th of following month \u2192 Vero (OmaVero, oma-aloitteinen)",
|
|
24444
|
+
tilinpaatos: "4 months from fiscal year end (KPL 3:6)",
|
|
24445
|
+
prh_filing: "8 months from fiscal year end (OYL 8:10)",
|
|
24446
|
+
pienyrittaja: {
|
|
24447
|
+
threshold: 2e4,
|
|
24448
|
+
note: "Revenue under 20,000 \u20AC/year: no VAT registration obligation (from 1.1.2025, was 15,000 \u20AC)",
|
|
24449
|
+
alarajahuojennus: "REMOVED from 1.1.2025 \u2014 hard threshold, no progressive relief"
|
|
24450
|
+
},
|
|
24451
|
+
maksuperusteinen_alv: {
|
|
24452
|
+
threshold: 5e5,
|
|
24453
|
+
note: "Revenue under 500,000 \u20AC: may opt for cash-basis VAT (report when payment received/made, not when invoiced)",
|
|
24454
|
+
application: "Opt-in via OmaVero"
|
|
24455
|
+
},
|
|
24456
|
+
late_penalties: {
|
|
24457
|
+
myohastymismaksu: "3 \u20AC/day for days 1-45 (max 135 \u20AC), then +2% of tax amount (max 15,000 \u20AC per tax type)",
|
|
24458
|
+
viivastyskorko_2026: "9.5% for most taxes, 4.5% for inheritance tax",
|
|
24459
|
+
correction_grace: "Corrections within 45 days of original deadline: no penalty if change \u2264 500 \u20AC",
|
|
24460
|
+
nil_return: "Even when no VAT activity occurred, a nil return must be filed for each verokausi"
|
|
24461
|
+
},
|
|
24462
|
+
payment_deadline: "Same as filing deadline (12th of M+2 for monthly/quarterly, last day of Feb for annual)",
|
|
24463
|
+
omavero_field_codes: {
|
|
24464
|
+
source: "VSRALVKV tietuekuvaus v1.10 (27.2.2026)",
|
|
24465
|
+
source_url: "https://www.vero.fi/contentassets/ef5905e0f5b74bcba89aa9ba9c34015d/verohallinto_tietuekuvaus_vsralvkv_290824.pdf",
|
|
24466
|
+
vat_amounts: {
|
|
24467
|
+
301: "Suoritettava 24%/25,5% vero kotimaan myynnist\xE4 (domestic sales standard rate)",
|
|
24468
|
+
302: "Suoritettava 14%/13,5% vero kotimaan myynnist\xE4 (domestic sales reduced rate)",
|
|
24469
|
+
303: "Suoritettava 10% vero kotimaan myynnist\xE4 (domestic sales 10%)",
|
|
24470
|
+
304: "Vero tavaroiden maahantuonnista EU:n ulkopuolelta (import VAT from outside EU)",
|
|
24471
|
+
305: "Vero tavaraostoista muista EU-maista (reverse charge VAT on EU goods)",
|
|
24472
|
+
306: "Vero palveluostoista muista EU-maista (reverse charge VAT on EU services)",
|
|
24473
|
+
307: "Verokauden v\xE4hennett\xE4v\xE4 vero (total deductible input VAT)",
|
|
24474
|
+
308: "Maksettava vero / Palautukseen oikeuttava vero (net payable/refundable). Formula: (301+302+303+304+305+306+318) - 307",
|
|
24475
|
+
318: "Vero rakentamispalvelun ja metalliromun ostoista (construction/scrap reverse charge VAT)"
|
|
24476
|
+
},
|
|
24477
|
+
tax_bases: {
|
|
24478
|
+
309: "0-verokannan alainen liikevaihto (zero-rate turnover: exports, EU sales with deduction right)",
|
|
24479
|
+
310: "Tavaroiden maahantuonnit EU:n ulkopuolelta (goods imports from outside EU)",
|
|
24480
|
+
311: "Tavaroiden myynnit muihin EU-maihin (goods sales to EU)",
|
|
24481
|
+
312: "Palveluiden myynnit muihin EU-maihin (service sales to EU)",
|
|
24482
|
+
313: "Tavaraostot muista EU-maista (goods purchases from EU)",
|
|
24483
|
+
314: "Palveluostot muista EU-maista (service purchases from EU)",
|
|
24484
|
+
319: "Rakentamispalvelun ja metalliromun myynnit (construction/scrap sales)",
|
|
24485
|
+
320: "Rakentamispalvelun ja metalliromun ostot (construction/scrap purchases)"
|
|
24486
|
+
},
|
|
24487
|
+
removed_2025: "315, 316, 317 (alarajahuojennus fields) removed from 1.1.2025"
|
|
24488
|
+
}
|
|
24489
|
+
};
|
|
24490
|
+
var SOCIAL_INSURANCE_2026 = {
|
|
24491
|
+
year: 2026,
|
|
24492
|
+
sources: [
|
|
24493
|
+
"https://www.etk.fi/ajankohtaista/tyoelakemaksut-vuonna-2026/",
|
|
24494
|
+
"https://www.elo.fi/fi-fi/tyonantaja/tyel-vakuuttaminen/tyel-maksu/sosiaalivakuutusmaksut-2026",
|
|
24495
|
+
"https://tyel-vakuutus.fi/tyonantajan-sivukulut/"
|
|
24496
|
+
],
|
|
24497
|
+
tyel: {
|
|
24498
|
+
keskimaarainen: 24.4,
|
|
24499
|
+
perusmaksu: 24.85,
|
|
24500
|
+
tilapaistyonantaja: 25.85,
|
|
24501
|
+
employee: 7.3,
|
|
24502
|
+
employer_average: 17.1,
|
|
24503
|
+
employer_perusmaksu: 17.55,
|
|
24504
|
+
note: "Perusmaksu 24.85% is what pension companies bill before company-specific adjustments. Keskim\xE4\xE4r\xE4inen 24.40% is the actual average. Age differentiation eliminated in 2026 (ETK)"
|
|
24505
|
+
},
|
|
24506
|
+
sotu: {
|
|
24507
|
+
rate: 1.91,
|
|
24508
|
+
payer: "employer",
|
|
24509
|
+
note: "Sairausvakuutusmaksu, up from 1.87% in 2025. Paid monthly to Vero"
|
|
24510
|
+
},
|
|
24511
|
+
ttv: {
|
|
24512
|
+
employer: 0.31,
|
|
24513
|
+
employee: 0.89,
|
|
24514
|
+
note: "Ty\xF6tt\xF6myysvakuutus. Employer rate 0.31% for payroll \u2264 2,509,500 \u20AC; 1.23% above. Employee up from 0.59% in 2025"
|
|
24515
|
+
},
|
|
24516
|
+
tapaturma: {
|
|
24517
|
+
average: 0.51,
|
|
24518
|
+
range: "0.05\u20135.00%",
|
|
24519
|
+
payer: "employer",
|
|
24520
|
+
note: "Company-specific rate based on industry and claims history"
|
|
24521
|
+
},
|
|
24522
|
+
ryhmahenkivakuutus: {
|
|
24523
|
+
rate: 0.06,
|
|
24524
|
+
payer: "employer",
|
|
24525
|
+
note: "Standardized via collective agreement"
|
|
24526
|
+
},
|
|
24527
|
+
total_employer_side_costs: "~20% of gross salary (incl. TyEL employer share)",
|
|
24528
|
+
accounts: {
|
|
24529
|
+
salary: "7010 Palkat ja palkkiot",
|
|
24530
|
+
tyel_expense: "7020 El\xE4kekulut (TyEL)",
|
|
24531
|
+
sotu_expense: "7030 Muut henkil\xF6sivukulut",
|
|
24532
|
+
withholding_payable: "4060 Ennakonpid\xE4tysvelka",
|
|
24533
|
+
tyel_payable: "4080 TyEL-velka",
|
|
24534
|
+
ttv_payable: "4070 Sosiaaliturvamaksuvelka",
|
|
24535
|
+
bank: "2320 Pankkitili"
|
|
24536
|
+
}
|
|
24537
|
+
};
|
|
24538
|
+
var BOOKKEEPING_RULES = {
|
|
24539
|
+
legal_basis: "Kirjanpitolaki 1336/1997",
|
|
24540
|
+
core_principles: [
|
|
24541
|
+
{ rule: "Double-entry (kahdenkertainen kirjanpito)", basis: "KPL 2:1", note: "Every entry: total debits = total credits" },
|
|
24542
|
+
{ rule: "Voucher principle (tositeperiaate)", basis: "KPL 2:5", note: "Every entry must be based on a numbered voucher" },
|
|
24543
|
+
{ rule: "Audit trail (kirjausketju)", basis: "KPL 2:6", note: "Tosite \u2192 tositenumero \u2192 p\xE4iv\xE4kirja \u2192 p\xE4\xE4kirja \u2192 tilinp\xE4\xE4t\xF6s \u2014 unbroken chain" },
|
|
24544
|
+
{ rule: "Sequential numbering", basis: "KPL 2:5", note: "Gap detection must be possible from the numbering sequence" },
|
|
24545
|
+
{ rule: "Closed period immutability", basis: "KPL 2:7", note: "Corrections to closed periods must use new entries in current period" }
|
|
24546
|
+
],
|
|
24547
|
+
retention: [
|
|
24548
|
+
{ type: "Accounting records (kirjanpitokirjat)", years: 10, basis: "KPL 2:10 1 mom" },
|
|
24549
|
+
{ type: "Financial statements (tilinp\xE4\xE4t\xF6s)", years: 10, basis: "KPL 3:9" },
|
|
24550
|
+
{ type: "Vouchers, invoices (tositteet)", years: 6, basis: "KPL 2:10 2 mom" },
|
|
24551
|
+
{ type: "E-invoice XML originals", years: 6, basis: "KPL 2:7" }
|
|
24552
|
+
],
|
|
24553
|
+
fiscal_year: {
|
|
24554
|
+
normal_duration: "12 months",
|
|
24555
|
+
max_duration: "18 months (first/changed period only)",
|
|
24556
|
+
tilinpaatos_deadline: "4 months from fiscal year end",
|
|
24557
|
+
prh_deadline: "8 months from fiscal year end (Oy, Oyj)"
|
|
24558
|
+
},
|
|
24559
|
+
company_sizes: [
|
|
24560
|
+
{ class: "Mikroyritys", revenue: "< 900K", balance: "< 450K", employees: "< 10", tilinpaatos: "Simplified (PMA)" },
|
|
24561
|
+
{ class: "Pienyritys", revenue: "< 15M", balance: "< 7.5M", employees: "< 50", tilinpaatos: "Standard (PMA)" },
|
|
24562
|
+
{ class: "Keskisuuri", revenue: "< 50M", balance: "< 25M", employees: "< 250", tilinpaatos: "Full (KPA) + toimintakertomus for Oy/Osk" },
|
|
24563
|
+
{ class: "Suuryritys", revenue: "\u2265 50M", balance: "\u2265 25M", employees: "\u2265 250", tilinpaatos: "Full + toimintakertomus + rahoituslaskelma" }
|
|
24564
|
+
]
|
|
24565
|
+
};
|
|
24566
|
+
function registerComplianceResources(server2) {
|
|
24567
|
+
server2.resource(
|
|
24568
|
+
"compliance-vat",
|
|
24569
|
+
"kirjapro://compliance/vat",
|
|
24570
|
+
{
|
|
24571
|
+
description: "Finnish VAT compliance: rates (25.5/13.5/10/0%/exempt), rate history, deduction rules (edustus/neuvottelu/insurance), reverse charge, and common mistakes. CRITICAL: 0% (zero-rated, input VAT deductible) \u2260 exempt (no input VAT deduction). Legal basis: AVL 1501/1993.",
|
|
24572
|
+
mimeType: "application/json"
|
|
24573
|
+
},
|
|
24574
|
+
async (uri) => ({
|
|
24575
|
+
contents: [{
|
|
24576
|
+
uri: uri.href,
|
|
24577
|
+
mimeType: "application/json",
|
|
24578
|
+
text: JSON.stringify(VAT_COMPLIANCE)
|
|
24579
|
+
}]
|
|
24580
|
+
})
|
|
24581
|
+
);
|
|
24582
|
+
server2.resource(
|
|
24583
|
+
"compliance-invoicing",
|
|
24584
|
+
"kirjapro://compliance/invoicing",
|
|
24585
|
+
{
|
|
24586
|
+
description: "Invoice requirements per AVL 209 e \xA7: 18 mandatory markings (kohdat 1-18), muutoslasku/hyvityslasku rules, simplified invoice (\u2264400\u20AC + retail/restaurant). Shows implementation status. Source: vero.fi",
|
|
24587
|
+
mimeType: "application/json"
|
|
24588
|
+
},
|
|
24589
|
+
async (uri) => ({
|
|
24590
|
+
contents: [{
|
|
24591
|
+
uri: uri.href,
|
|
24592
|
+
mimeType: "application/json",
|
|
24593
|
+
text: JSON.stringify(INVOICE_REQUIREMENTS)
|
|
24594
|
+
}]
|
|
24595
|
+
})
|
|
24596
|
+
);
|
|
24597
|
+
server2.resource(
|
|
24598
|
+
"compliance-deadlines",
|
|
24599
|
+
"kirjapro://compliance/deadlines",
|
|
24600
|
+
{
|
|
24601
|
+
description: "Finnish tax filing deadlines: VAT verokausi thresholds (<30K annual, <100K quarterly, >100K monthly), TyEL reporting, tilinp\xE4\xE4t\xF6s deadlines, pienyritt\xE4j\xE4 20K\u20AC threshold, maksuperusteinen ALV.",
|
|
24602
|
+
mimeType: "application/json"
|
|
24603
|
+
},
|
|
24604
|
+
async (uri) => ({
|
|
24605
|
+
contents: [{
|
|
24606
|
+
uri: uri.href,
|
|
24607
|
+
mimeType: "application/json",
|
|
24608
|
+
text: JSON.stringify(FILING_DEADLINES)
|
|
24609
|
+
}]
|
|
24610
|
+
})
|
|
24611
|
+
);
|
|
24612
|
+
server2.resource(
|
|
24613
|
+
"compliance-social-insurance",
|
|
24614
|
+
"kirjapro://compliance/social-insurance",
|
|
24615
|
+
{
|
|
24616
|
+
description: "2026 Finnish social insurance rates: TyEL (perusmaksu 24.85%, keskim\xE4\xE4r\xE4inen 24.40%, employee 7.30%), sotu (1.91%), TTV (0.31%/0.89%), tapaturma (~0.51%), ryhm\xE4henki (0.06%). Sources: ETK, Elo, tyel-vakuutus.fi",
|
|
24617
|
+
mimeType: "application/json"
|
|
24618
|
+
},
|
|
24619
|
+
async (uri) => ({
|
|
24620
|
+
contents: [{
|
|
24621
|
+
uri: uri.href,
|
|
24622
|
+
mimeType: "application/json",
|
|
24623
|
+
text: JSON.stringify(SOCIAL_INSURANCE_2026)
|
|
24624
|
+
}]
|
|
24625
|
+
})
|
|
24626
|
+
);
|
|
24627
|
+
server2.resource(
|
|
24628
|
+
"compliance-bookkeeping",
|
|
24629
|
+
"kirjapro://compliance/bookkeeping",
|
|
24630
|
+
{
|
|
24631
|
+
description: "KPL (Kirjanpitolaki) requirements: double-entry, audit trail, voucher principle, retention periods (10/6 years), fiscal year rules (max 18 months), company size classification (mikro/pien/keski/suuri).",
|
|
24632
|
+
mimeType: "application/json"
|
|
24633
|
+
},
|
|
24634
|
+
async (uri) => ({
|
|
24635
|
+
contents: [{
|
|
24636
|
+
uri: uri.href,
|
|
24637
|
+
mimeType: "application/json",
|
|
24638
|
+
text: JSON.stringify(BOOKKEEPING_RULES)
|
|
24639
|
+
}]
|
|
24640
|
+
})
|
|
24641
|
+
);
|
|
24642
|
+
}
|
|
24643
|
+
|
|
23672
24644
|
// src/resources/index.ts
|
|
23673
24645
|
function registerResources(server2) {
|
|
23674
24646
|
registerSchemaResources(server2);
|
|
23675
24647
|
registerCompanyResources(server2);
|
|
24648
|
+
registerComplianceResources(server2);
|
|
23676
24649
|
}
|
|
23677
24650
|
|
|
23678
24651
|
// src/index.ts
|
|
23679
24652
|
var server = new McpServer({
|
|
23680
24653
|
name: "kirjapro",
|
|
23681
|
-
version: "0.
|
|
24654
|
+
version: "0.5.0"
|
|
23682
24655
|
});
|
|
23683
24656
|
async function main() {
|
|
23684
24657
|
try {
|