@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/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(`Failed to query fiscal years: ${error2.message}`);
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(`Failed to list accounts: ${error2.message}`);
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
- `, { count: "exact" }).eq("company_id", companyId2).eq("fiscal_year_id", fiscalYearId).order("transaction_date", { ascending: false }).range(offset, offset + limit - 1);
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(`Failed to list transactions: ${error2.message}`);
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((s, e) => s + (e.debit_amount || 0), 0)
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((s, e) => s + (e.credit_amount || 0), 0)
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
- `, { count: "exact" }).eq("company_id", companyId2).order("transaction_date", { ascending: false });
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
- query = query.range(offset, offset + limit - 1);
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(`Failed to search transactions: ${error2.message}`);
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
- if (params.maxAmount !== void 0) {
21209
- results = results.filter((t) => t.total_debit <= params.maxAmount);
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
- const hasAmountFilter = params.minAmount !== void 0 || params.maxAmount !== void 0;
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
- `Create a new accounting transaction (tosite) with journal entries. Entries must balance (total debits = total credits). Accepts account numbers (e.g. "4000") which are resolved to UUIDs server-side.
21223
- Common accounts (YSE 2017):
21224
- 2310 Kassa | 2320 Pankkitili | 2110 Myyntisaamiset | 2140 ALV-saamiset
21225
- 4030 Ostovelat | 4050 ALV-velka | 4060 Ennakonpid\xE4tysvelka | 4080 TyEL-velka
21226
- 5010 Myynti 25,5% | 5020 Myynti 13,5% | 5030 Myynti 10% | 5040 Myynti 0%
21227
- 6010 Ostot | 6030 Ulkopuoliset palvelut | 7010 Palkat | 7020 El\xE4kekulut
21228
- 8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut
21229
- Prefer kirjapro_create_from_template for standard patterns.`,
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(external_exports.object({
21236
- accountNumber: external_exports.string().describe('Account number (e.g. "4000") or UUID'),
21237
- debitAmount: external_exports.number().min(0).default(0).describe("Debit amount in EUR"),
21238
- creditAmount: external_exports.number().min(0).default(0).describe("Credit amount in EUR")
21239
- })).min(2).describe("Journal entries (min 2, must balance)")
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 accountNumbers = params.entries.map((e) => e.accountNumber);
21267
- const { data: accounts, error: accError } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", accountNumbers);
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(`Failed to resolve accounts: ${accError.message}`);
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 accountMap = new Map((accounts || []).map((a) => [a.account_number, a.id]));
21272
- const missingAccounts = accountNumbers.filter((n) => !accountMap.has(n));
21372
+ const missingAccounts = accountIdentifiers.filter(
21373
+ (identifier) => !accountMap.has(identifier)
21374
+ );
21273
21375
  if (missingAccounts.length > 0) {
21274
21376
  return toErrorContent(
21275
- `Account numbers not found: ${missingAccounts.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
21377
+ `Accounts not found: ${missingAccounts.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
21276
21378
  );
21277
21379
  }
21278
- const totalDebit = roundCents(params.entries.reduce((s, e) => s + e.debitAmount, 0));
21279
- const totalCredit = roundCents(params.entries.reduce((s, e) => s + e.creditAmount, 0));
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: jeError } = await supabase2.from("journal_entries").insert(journalEntries);
21302
- if (jeError) {
21303
- return toErrorContent(`Transaction created but journal entries failed: ${jeError.message}`);
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
- `).eq("id", transaction.id).single();
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
- `Create multiple accounting transactions in one call (up to 50). Ideal for bulk import from bank statements or receipts. Each transaction is validated independently \u2014 valid ones are created even if others fail.
21321
- Common accounts (YSE 2017):
21322
- 2310 Kassa | 2320 Pankkitili | 2110 Myyntisaamiset | 2140 ALV-saamiset
21323
- 4030 Ostovelat | 4050 ALV-velka | 4060 Ennakonpid\xE4tysvelka | 4080 TyEL-velka
21324
- 5010 Myynti 25,5% | 5020 Myynti 13,5% | 5030 Myynti 10% | 5040 Myynti 0%
21325
- 6010 Ostot | 6030 Ulkopuoliset palvelut | 7010 Palkat | 7020 El\xE4kekulut
21326
- 8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut
21327
- Prefer kirjapro_create_from_template_batch for standard patterns.`,
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(external_exports.object({
21331
- transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
21332
- description: external_exports.string().min(1).max(500),
21333
- documentNumber: external_exports.string().optional(),
21334
- entries: external_exports.array(external_exports.object({
21335
- accountNumber: external_exports.string().describe('Account number (e.g. "4000")'),
21336
- debitAmount: external_exports.number().min(0).default(0),
21337
- creditAmount: external_exports.number().min(0).default(0)
21338
- })).min(2)
21339
- })).min(1).max(50).describe("Array of transactions to create (max 50)")
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(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`);
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({ index: i, error: `Date ${tx.transactionDate} outside fiscal year range.` });
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((e) => !accountMap.has(e.accountNumber));
21508
+ const missing = tx.entries.filter(
21509
+ (e) => !accountMap.has(e.accountNumber)
21510
+ );
21371
21511
  if (missing.length > 0) {
21372
- failed.push({ index: i, error: `Unknown accounts: ${missing.map((m) => m.accountNumber).join(", ")}` });
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(tx.entries.reduce((s, e) => s + e.debitAmount, 0));
21376
- const totalCredit = roundCents(tx.entries.reduce((s, e) => s + e.creditAmount, 0));
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({ index: i, error: `Unbalanced: debits ${totalDebit} != credits ${totalCredit}` });
21379
- continue;
21380
- }
21381
- const { data: newTx, error: txErr } = await supabase2.from("transactions").insert({
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: jeErr } = await supabase2.from("journal_entries").insert(entries);
21399
- if (jeErr) {
21400
- failed.push({ index: i, error: `Transaction created but entries failed: ${jeErr.message}` });
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({ id: newTx.id, description: tx.description, date: tx.transactionDate });
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
- `).eq("id", params.transactionId).eq("company_id", companyId2).single();
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
- `, { count: "exact" }).eq("company_id", companyId2).order("invoice_date", { ascending: false });
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(`Failed to list invoices: ${error2.message}`);
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(external_exports.object({
21527
- description: external_exports.string().min(1).describe("Line item description"),
21528
- quantity: external_exports.number().min(0).describe("Quantity"),
21529
- unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
21530
- unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
21531
- vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
21532
- })).min(1).describe("Invoice line items")
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: lastInvoice } = await supabase2.from("invoices").select("invoice_number").eq("company_id", companyId2).order("created_at", { ascending: false }).limit(1).single();
21544
- const invoiceNumber = generateInvoiceNumber(lastInvoice?.invoice_number || null);
21545
- const referenceNumber = generateReferenceNumber(invoiceNumber.replace(/\D/g, ""));
21546
- const dueDate = new Date(params.invoiceDate);
21547
- dueDate.setDate(dueDate.getDate() + params.paymentTermDays);
21548
- const dueDateStr = dueDate.toISOString().split("T")[0];
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(calculatedLines.reduce((s, l) => s + l.subtotal, 0));
21567
- const vatTotal = roundCents(calculatedLines.reduce((s, l) => s + l.vat_amount, 0));
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: invoice, error: invError } = await supabase2.from("invoices").insert({
21570
- company_id: companyId2,
21571
- customer_id: params.customerId,
21572
- invoice_number: invoiceNumber,
21573
- reference_number: referenceNumber,
21574
- invoice_date: params.invoiceDate,
21575
- due_date: dueDateStr,
21576
- status: "draft",
21577
- subtotal,
21578
- vat_total: vatTotal,
21579
- total,
21580
- notes: params.notes || null
21581
- }).select().single();
21582
- if (invError) {
21583
- return toErrorContent(`Failed to create invoice: ${invError.message}`);
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 lineInserts = calculatedLines.map((line) => ({
21586
- invoice_id: invoice.id,
21587
- ...line
21588
- }));
21589
- const { error: lineError } = await supabase2.from("invoice_lines").insert(lineInserts);
21590
- if (lineError) {
21591
- return toErrorContent(`Invoice created but lines failed: ${lineError.message}`);
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
- `).eq("id", params.invoiceId).eq("company_id", companyId2).single();
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(`Invoice ${invoice.invoice_number} is already fully paid.`);
21807
+ return toErrorContent(
21808
+ `Invoice ${invoice.invoice_number} is already fully paid.`
21809
+ );
21628
21810
  }
21629
21811
  if (invoice.status === "cancelled") {
21630
- return toErrorContent(`Invoice ${invoice.invoice_number} is cancelled and cannot accept payments.`);
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((s, p) => s + (p.amount || 0), 0)
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: payment, error: payError } = await supabase2.from("invoice_payments").insert({
21642
- invoice_id: params.invoiceId,
21643
- payment_date: params.paymentDate,
21644
- amount: params.amount,
21645
- payment_method: params.paymentMethod,
21646
- reference: params.reference || null,
21647
- notes: params.notes || null
21648
- }).select().single();
21649
- if (payError) {
21650
- return toErrorContent(`Failed to record payment: ${payError.message}`);
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
- payment,
21666
- invoice_status: newStatus,
21667
- remaining_balance: roundCents(remaining - params.amount)
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
- `).eq("id", params.invoiceId).eq("company_id", companyId2).single();
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) || { vat_rate: line.vat_rate, subtotal: 0, vat_amount: 0, total: 0 };
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(payments.reduce((s, p) => s + (p.amount || 0), 0));
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(`Failed to update invoice status: ${updateError.message}`);
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(`Failed to delete invoice lines: ${linesError.message}`);
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(`Failed to delete invoice: ${deleteError.message}`);
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(`Failed to list customers: ${error2.message}`);
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).single();
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(`Failed to create customer: ${error2.message}`);
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(`Failed to update customer: ${updateError.message}`);
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
- `).eq("transaction.fiscal_year_id", params.fiscalYearId),
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 { data: entries, error: error2 } = await supabase2.from("journal_entries").select(`
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
- `).eq("transaction.fiscal_year_id", params.fiscalYearId).gte("transaction.transaction_date", params.dateFrom).lte("transaction.transaction_date", params.dateTo);
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(`Failed to generate income statement: ${error2.message}`);
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 { data: entries, error: error2 } = await supabase2.from("journal_entries").select(`
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
- `).eq("transaction.fiscal_year_id", params.fiscalYearId).lte("transaction.transaction_date", params.reportDate);
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(`Failed to generate balance sheet: ${error2.message}`);
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(`Failed to get AR aging: ${error2.message}`);
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(`Failed to get general ledger: ${error2.message}`);
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("id, amount, booking_date, credit_debit, counterparty_name, reference, reconciled_at").eq("id", params.bankTransactionId).eq("company_id", companyId2).single();
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
- `).eq("id", params.invoiceId).eq("company_id", companyId2).single();
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(`Invoice ${invoice.invoice_number} is already fully paid.`);
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((s, p) => s + (p.amount || 0), 0)
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 = Math.min(Math.abs(bankTx.amount), remaining);
22391
- const { data: _payment, error: payError } = await supabase2.from("invoice_payments").insert({
22392
- invoice_id: params.invoiceId,
22393
- payment_date: bankTx.booking_date,
22394
- amount: paymentAmount,
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 newPaidTotal = roundCents(paidSoFar + paymentAmount);
22425
- const newStatus = newPaidTotal >= invoice.total ? "paid" : invoice.status;
22426
- if (newStatus !== invoice.status) {
22427
- const { error: statusError } = await supabase2.from("invoices").update({
22428
- status: newStatus,
22429
- paid_date: newStatus === "paid" ? bankTx.booking_date : null
22430
- }).eq("id", params.invoiceId);
22431
- if (statusError) {
22432
- return toErrorContent(`Bank transaction matched but invoice status update failed: ${statusError.message}`);
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("id, account_name, iban, bic, bank_name, current_balance, available_balance, is_primary, last_sync_at, connection_status").eq("company_id", companyId2).eq("is_active", true).order("is_primary", { ascending: false });
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(`Failed to list bank accounts: ${error2.message}`);
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(`Failed to list bank transactions: ${error2.message}`);
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(`Failed to list fiscal years: ${error2.message}`);
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
- { accountNumber: "2310", debitAmount: roundCents(amount), creditAmount: 0 },
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({ accountNumber: "4050", debitAmount: 0, creditAmount: vat });
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
- { accountNumber: "2320", debitAmount: roundCents(amount), creditAmount: 0 },
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({ accountNumber: "4050", debitAmount: 0, creditAmount: vat });
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
- { accountNumber: "2110", debitAmount: roundCents(amount), creditAmount: 0 },
22733
- { accountNumber: "5010", debitAmount: 0, creditAmount: net },
22734
- { accountNumber: "4050", debitAmount: 0, creditAmount: vat }
22735
- ], opts?.accountOverrides);
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
- { accountNumber: "6010", debitAmount: net, creditAmount: 0 },
22747
- { accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
22748
- { accountNumber: "2320", debitAmount: 0, creditAmount: roundCents(amount) }
22749
- ], opts?.accountOverrides);
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
- { accountNumber: "6030", debitAmount: net, creditAmount: 0 },
22760
- { accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
22761
- { accountNumber: "2320", debitAmount: 0, creditAmount: roundCents(amount) }
22762
- ], opts?.accountOverrides);
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
- { accountNumber: "8010", debitAmount: roundCents(amount), creditAmount: 0 },
22772
- { accountNumber: "2320", debitAmount: 0, creditAmount: roundCents(amount) }
22773
- ], opts?.accountOverrides)
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
- { accountNumber: "7010", debitAmount: gross, creditAmount: 0 },
22791
- { accountNumber: "7020", debitAmount: tyelEmployer, creditAmount: 0 },
22792
- { accountNumber: "2320", debitAmount: 0, creditAmount: netPay },
22793
- { accountNumber: "4060", debitAmount: 0, creditAmount: withholding },
22794
- { accountNumber: "4080", debitAmount: 0, creditAmount: roundCents(tyelEmployee + tyelEmployer) }
22795
- ], opts?.accountOverrides);
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
- { accountNumber: "2320", debitAmount: roundCents(amount), creditAmount: 0 },
22805
- { accountNumber: "2310", debitAmount: 0, creditAmount: roundCents(amount) }
22806
- ], opts?.accountOverrides)
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 { error: `Unknown template "${templateId}". Use kirjapro_list_templates to see available templates.` };
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(`Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`);
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 = [...new Set(params.entries.map((e) => e.accountNumber))];
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((accounts || []).map((a) => [a.account_number, a.id]));
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
- `Template produced unbalanced entries: debits ${totalDebit.toFixed(2)} != credits ${totalCredit.toFixed(2)}.`
23205
+ `Account numbers not found: ${missing.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
22859
23206
  );
22860
23207
  }
22861
- const { data: transaction, error: txError } = await supabase2.from("transactions").insert({
22862
- company_id: companyId2,
22863
- fiscal_year_id: fiscalYearId,
22864
- transaction_date: params.transactionDate,
22865
- description: params.description,
22866
- document_number: params.documentNumber || null
22867
- }).select().single();
22868
- if (txError) {
22869
- return toErrorContent(`Failed to create transaction: ${txError.message}`);
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: jeError } = await supabase2.from("journal_entries").insert(journalEntries);
22878
- if (jeError) {
22879
- return toErrorContent(`Transaction created but journal entries failed: ${jeError.message}`);
22880
- }
22881
- return { id: transaction.id };
22882
- }
22883
- var ACCOUNT_REFERENCE = `
22884
- Common accounts (YSE 2017):
22885
- 2310 Kassa | 2320 Pankkitili | 2110 Myyntisaamiset | 2140 ALV-saamiset
22886
- 4030 Ostovelat | 4050 ALV-velka | 4060 Ennakonpid\xE4tysvelka | 4080 TyEL-velka
22887
- 5010 Myynti 25,5% | 5020 Myynti 13,5% | 5030 Myynti 10% | 5040 Myynti 0%
22888
- 6010 Ostot | 6030 Ulkopuoliset palvelut | 7010 Palkat | 7020 El\xE4kekulut
22889
- 8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut`;
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
- `List available transaction templates for common Finnish small business bookkeeping patterns. Templates handle double-entry balancing and VAT splitting automatically.${ACCOUNT_REFERENCE}`,
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
- `Create an accounting transaction from a predefined template. Handles double-entry balancing and VAT splitting automatically. Use kirjapro_list_templates to see available templates. Use accountOverrides to swap default accounts (e.g. {"2320": "2330"} to use a different bank account).${ACCOUNT_REFERENCE}`,
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
- `Create multiple transactions from templates in one call (up to 50). Each transaction is validated independently \u2014 valid ones are created even if others fail. Ideal for bulk recording of similar transactions (e.g. daily cash sales).${ACCOUNT_REFERENCE}`,
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(external_exports.object({
22951
- template: external_exports.string().describe("Template ID"),
22952
- transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
22953
- amount: external_exports.number().positive().describe("Gross amount in EUR"),
22954
- description: external_exports.string().min(1).max(500),
22955
- documentNumber: external_exports.string().optional(),
22956
- withholdingRate: external_exports.number().min(0).max(100).optional(),
22957
- tyelRate: external_exports.number().min(0).max(100).optional(),
22958
- accountOverrides: external_exports.record(external_exports.string(), external_exports.string()).optional()
22959
- })).min(1).max(50).describe("Array of template transactions to create (max 50)")
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({ id: createResult.id, description: tx.description, date: tx.transactionDate });
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
- `Book a purchase (osto) in one call with automatic VAT splitting and Finnish compliance.
23010
- Handles: standard purchases, reverse charge (EU/non-EU services), entertainment (edustus, AVL 114\xA7 no VAT deduction).
23011
- Common expense accounts: 6010 Ostot | 6030 Ulkopuoliset palvelut | 8010 Toimitilakulut | 8050 Markkinointikulut | 8060 Hallintokulut | 8070 Vakuutukset | 8080 Tietoliikennekulut`,
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
- `Book salary payment (palkanmaksu) in one call. Creates 2 balanced transactions:
23074
- 1. Salary: D 7010(gross) \u2192 C 4060(withholding) + C 4080(TyEL employee) + C 4070(TTV employee) + C 2320(net)
23075
- 2. Employer costs: D 7020(TyEL employer) + D 7030(sotu + TTV employer) \u2192 C 4080 + C 4070
23076
- Default rates are 2026 Finnish social insurance rates.`,
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
- `Book ALV-tilitys (VAT settlement) for a month. Queries journal entries on ALV accounts (2140 ALV-saamiset, 4050 ALV-velka) for the month, calculates the net, and creates a settlement transaction.
23153
- If ALV-velka > ALV-saamiset \u2192 pay to tax authority (D 4050, C 2140, D/C bank for difference).
23154
- If ALV-saamiset > ALV-velka \u2192 claim refund (D 4050, C 2140, D bank for difference).`,
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
- const settlementDate = params.settlementDate ?? `${nextMonth}-12`;
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(`Failed to list offers: ${error2.message}`);
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).single();
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(`Failed to create offer: ${rpcError.message}`);
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(`Failed to update offer status: ${updateError.message}`);
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(`Failed to delete offer: ${deleteError.message}`);
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 CHART_OF_ACCOUNTS = [
23501
- // Pysyvät vastaavat (Fixed assets)
23502
- { number: "1000", name: "AINEETTOMAT HY\xD6DYKKEET", type: "asset", header: true },
23503
- { number: "1010", name: "Perustamismenot", type: "asset" },
23504
- { number: "1020", name: "Tutkimusmenot", type: "asset" },
23505
- { number: "1030", name: "Kehitt\xE4mismenot", type: "asset" },
23506
- { number: "1100", name: "AINEELLISET HY\xD6DYKKEET", type: "asset", header: true },
23507
- { number: "1110", name: "Maa- ja vesialueet", type: "asset" },
23508
- { number: "1120", name: "Rakennukset ja rakennelmat", type: "asset" },
23509
- { number: "1130", name: "Koneet ja kalusto", type: "asset" },
23510
- { number: "1140", name: "Muut aineelliset hy\xF6dykkeet", type: "asset" },
23511
- { number: "1200", name: "SIJOITUKSET", type: "asset", header: true },
23512
- { number: "1210", name: "Osakkeet ja osuudet", type: "asset" },
23513
- // Vaihtuvat vastaavat (Current assets)
23514
- { number: "2000", name: "VAIHTO-OMAISUUS", type: "asset", header: true },
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: "Elintarvikkeet, ravintolat", description: "Food, restaurant services (lowered from 14% Jan 2025)" },
23587
- { rate: 10, label: "Kirjat, l\xE4\xE4kkeet, liikunta", description: "Books, medicines, sports services, accommodation" },
23588
- { rate: 0, label: "Veroton", description: "Tax-exempt: exports, international trade, certain financial services" }
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 standard chart of accounts (YSE 2017). Reference data for account numbers, names, and types.",
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(CHART_OF_ACCOUNTS)
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% (books/medicine), 0% (exempt).",
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.2.0"
24654
+ version: "0.5.0"
23682
24655
  });
23683
24656
  async function main() {
23684
24657
  try {