@kirjapro/mcp-server 0.2.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
@@ -20891,6 +20891,7 @@ function scheduleTokenRefresh(apiKey, expiresIn) {
20891
20891
  console.error("[kirjapro-mcp] Refreshing API key token...");
20892
20892
  const auth = await authenticateWithApiKey(apiKey);
20893
20893
  supabase = createClientFromAuth(auth);
20894
+ companyId = auth.company_id;
20894
20895
  console.error("[kirjapro-mcp] Token refreshed successfully");
20895
20896
  } catch (error2) {
20896
20897
  console.error("[kirjapro-mcp] Token refresh failed:", error2.message);
@@ -20925,7 +20926,8 @@ async function initLegacyAuth() {
20925
20926
  if (!kirjaproCompanyId) {
20926
20927
  throw new Error("KIRJAPRO_COMPANY_ID environment variable is required.");
20927
20928
  }
20928
- supabase = createClient(supabaseUrl, supabaseAccessToken, {
20929
+ const anonKey = process.env.SUPABASE_ANON_KEY || supabaseAccessToken;
20930
+ supabase = createClient(supabaseUrl, anonKey, {
20929
20931
  auth: {
20930
20932
  persistSession: false,
20931
20933
  autoRefreshToken: false
@@ -21018,7 +21020,7 @@ function toTextContent(data) {
21018
21020
  content: [
21019
21021
  {
21020
21022
  type: "text",
21021
- text: JSON.stringify(data, null, 2)
21023
+ text: JSON.stringify(data)
21022
21024
  }
21023
21025
  ]
21024
21026
  };
@@ -21029,12 +21031,79 @@ function toErrorContent(message) {
21029
21031
  isError: true
21030
21032
  };
21031
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
+ }
21032
21067
  function roundCents(amount) {
21033
21068
  return Math.round(amount * 100) / 100;
21034
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
+ }
21035
21087
  function escapeLike(input) {
21036
21088
  return input.replace(/[%_\\]/g, "\\$&");
21037
21089
  }
21090
+ async function resolveOpenFiscalYear(supabase2, companyId2) {
21091
+ const { data, error: error2 } = await supabase2.from("fiscal_years").select("id").eq("company_id", companyId2).eq("is_closed", false);
21092
+ if (error2) {
21093
+ return toErrorContent(sanitizeDbError("Failed to query fiscal years", error2));
21094
+ }
21095
+ if (!data || data.length === 0) {
21096
+ return toErrorContent(
21097
+ "No open fiscal year found. Create one or specify fiscalYearId explicitly."
21098
+ );
21099
+ }
21100
+ if (data.length > 1) {
21101
+ return toErrorContent(
21102
+ `Multiple open fiscal years found (${data.length}). Specify fiscalYearId explicitly. Read kirjapro://fiscal-years to list them.`
21103
+ );
21104
+ }
21105
+ return { id: data[0].id };
21106
+ }
21038
21107
 
21039
21108
  // src/tools/accounts.ts
21040
21109
  function registerAccountTools(server2) {
@@ -21046,13 +21115,15 @@ function registerAccountTools(server2) {
21046
21115
  offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
21047
21116
  accountType: external_exports.enum(["asset", "liability", "equity", "revenue", "expense"]).optional().describe("Filter by account type"),
21048
21117
  search: external_exports.string().optional().describe("Search by account name or number"),
21049
- activeOnly: external_exports.boolean().default(true).describe("Only show active accounts")
21118
+ activeOnly: external_exports.boolean().default(true).describe("Only show active accounts"),
21119
+ includeIds: external_exports.boolean().default(false).describe("Include UUID id and parent_id fields (rarely needed)")
21050
21120
  },
21051
21121
  async (params) => {
21052
21122
  const supabase2 = getSupabase();
21053
21123
  const companyId2 = getCompanyId();
21054
21124
  const { limit, offset } = clampPagination(params.limit, params.offset);
21055
- let query = supabase2.from("accounts").select("id, account_number, name, account_type, parent_id, is_active", { count: "exact" }).eq("company_id", companyId2).order("account_number");
21125
+ const selectCols = params.includeIds ? "id, account_number, name, account_type, parent_id, is_active" : "account_number, name, account_type";
21126
+ let query = supabase2.from("accounts").select(selectCols, { count: "exact" }).eq("company_id", companyId2).order("account_number");
21056
21127
  if (params.activeOnly) {
21057
21128
  query = query.eq("is_active", true);
21058
21129
  }
@@ -21066,7 +21137,7 @@ function registerAccountTools(server2) {
21066
21137
  query = query.range(offset, offset + limit - 1);
21067
21138
  const { data, count, error: error2 } = await query;
21068
21139
  if (error2) {
21069
- return toErrorContent(`Failed to list accounts: ${error2.message}`);
21140
+ return toErrorContent(sanitizeDbError("Failed to list accounts", error2));
21070
21141
  }
21071
21142
  return toTextContent(paginate(data || [], count || 0, offset, limit));
21072
21143
  }
@@ -21079,7 +21150,7 @@ function registerTransactionTools(server2) {
21079
21150
  "kirjapro_list_transactions",
21080
21151
  "List accounting transactions (tositteet) with their journal entries for a fiscal year. Returns transaction date, description, and debit/credit entries with account details.",
21081
21152
  {
21082
- fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID to filter transactions"),
21153
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
21083
21154
  limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
21084
21155
  offset: external_exports.number().min(0).default(0).describe("Skip first N results")
21085
21156
  },
@@ -21087,23 +21158,46 @@ function registerTransactionTools(server2) {
21087
21158
  const supabase2 = getSupabase();
21088
21159
  const companyId2 = getCompanyId();
21089
21160
  const { limit, offset } = clampPagination(params.limit, params.offset);
21090
- const { data, count, error: error2 } = await supabase2.from("transactions").select(`
21161
+ let fiscalYearId = params.fiscalYearId;
21162
+ if (!fiscalYearId) {
21163
+ const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
21164
+ if ("isError" in resolved) return resolved;
21165
+ fiscalYearId = resolved.id;
21166
+ }
21167
+ const { data, count, error: error2 } = await supabase2.from("transactions").select(
21168
+ `
21091
21169
  id, transaction_date, description, document_number, created_at,
21092
21170
  journal_entries (
21093
21171
  id, account_id, debit_amount, credit_amount,
21094
21172
  accounts ( account_number, name )
21095
21173
  )
21096
- `, { count: "exact" }).eq("company_id", companyId2).eq("fiscal_year_id", params.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);
21097
21177
  if (error2) {
21098
- return toErrorContent(`Failed to list transactions: ${error2.message}`);
21178
+ return toErrorContent(sanitizeDbError("Failed to list transactions", error2));
21099
21179
  }
21100
21180
  const transactions = (data || []).map((t) => ({
21101
- ...t,
21181
+ id: t.id,
21182
+ transaction_date: t.transaction_date,
21183
+ description: t.description,
21184
+ document_number: t.document_number,
21185
+ entries: (t.journal_entries || []).map((e) => ({
21186
+ account: `${e.accounts?.account_number} ${e.accounts?.name}`,
21187
+ debit: e.debit_amount || 0,
21188
+ credit: e.credit_amount || 0
21189
+ })),
21102
21190
  total_debit: roundCents(
21103
- (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
+ )
21104
21195
  ),
21105
21196
  total_credit: roundCents(
21106
- (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
+ )
21107
21201
  )
21108
21202
  }));
21109
21203
  return toTextContent(paginate(transactions, count || 0, offset, limit));
@@ -21125,13 +21219,16 @@ function registerTransactionTools(server2) {
21125
21219
  const supabase2 = getSupabase();
21126
21220
  const companyId2 = getCompanyId();
21127
21221
  const { limit, offset } = clampPagination(params.limit, params.offset);
21128
- let query = supabase2.from("transactions").select(`
21222
+ let query = supabase2.from("transactions").select(
21223
+ `
21129
21224
  id, transaction_date, description, document_number, created_at,
21130
21225
  journal_entries (
21131
21226
  id, debit_amount, credit_amount,
21132
21227
  accounts ( account_number, name )
21133
21228
  )
21134
- `, { 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 });
21135
21232
  if (params.query) {
21136
21233
  query = query.ilike("description", `%${escapeLike(params.query)}%`);
21137
21234
  }
@@ -21141,56 +21238,95 @@ function registerTransactionTools(server2) {
21141
21238
  if (params.dateTo) {
21142
21239
  query = query.lte("transaction_date", params.dateTo);
21143
21240
  }
21144
- 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
+ }
21145
21248
  const { data, count, error: error2 } = await query;
21146
21249
  if (error2) {
21147
- return toErrorContent(`Failed to search transactions: ${error2.message}`);
21148
- }
21149
- let results = (data || []).map((t) => {
21150
- const totalDebit = roundCents(
21151
- (t.journal_entries || []).reduce((s, e) => s + (e.debit_amount || 0), 0)
21152
- );
21153
- const totalCredit = roundCents(
21154
- (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."
21155
21252
  );
21156
- return { ...t, total_debit: totalDebit, total_credit: totalCredit };
21157
- });
21158
- if (params.minAmount !== void 0) {
21159
- results = results.filter((t) => t.total_debit >= params.minAmount);
21160
21253
  }
21161
- if (params.maxAmount !== void 0) {
21162
- 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
+ });
21163
21297
  }
21164
- const hasAmountFilter = params.minAmount !== void 0 || params.maxAmount !== void 0;
21165
- return toTextContent(paginate(
21166
- results,
21167
- hasAmountFilter ? results.length : count || 0,
21168
- offset,
21169
- limit
21170
- ));
21298
+ return toTextContent(paginate(results, count || 0, offset, limit));
21171
21299
  }
21172
21300
  );
21173
21301
  server2.tool(
21174
21302
  "kirjapro_create_transaction",
21175
- '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.',
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.",
21176
21304
  {
21177
- fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID"),
21305
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
21178
21306
  transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
21179
21307
  description: external_exports.string().min(1).max(500).describe("Transaction description"),
21180
21308
  documentNumber: external_exports.string().optional().describe("Document/receipt number"),
21181
- entries: external_exports.array(external_exports.object({
21182
- accountNumber: external_exports.string().describe('Account number (e.g. "4000") or UUID'),
21183
- debitAmount: external_exports.number().min(0).default(0).describe("Debit amount in EUR"),
21184
- creditAmount: external_exports.number().min(0).default(0).describe("Credit amount in EUR")
21185
- })).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)")
21186
21316
  },
21187
21317
  async (params) => {
21188
21318
  const supabase2 = getSupabase();
21189
21319
  const companyId2 = getCompanyId();
21190
- const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", params.fiscalYearId).eq("company_id", companyId2).single();
21320
+ let fiscalYearId = params.fiscalYearId;
21321
+ if (!fiscalYearId) {
21322
+ const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
21323
+ if ("isError" in resolved) return resolved;
21324
+ fiscalYearId = resolved.id;
21325
+ }
21326
+ const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
21191
21327
  if (fyError || !fy) {
21192
21328
  return toErrorContent(
21193
- `Fiscal year ${params.fiscalYearId} not found. Use kirjapro_get_dashboard with a valid fiscal year ID, or read the kirjapro://fiscal-years resource to list available fiscal years.`
21329
+ `Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard with a valid fiscal year ID, or read the kirjapro://fiscal-years resource to list available fiscal years.`
21194
21330
  );
21195
21331
  }
21196
21332
  if (fy.is_closed) {
@@ -21203,52 +21339,85 @@ function registerTransactionTools(server2) {
21203
21339
  `Transaction date ${params.transactionDate} is outside fiscal year range (${fy.start_date} to ${fy.end_date}).`
21204
21340
  );
21205
21341
  }
21206
- const accountNumbers = params.entries.map((e) => e.accountNumber);
21207
- 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;
21208
21358
  if (accError) {
21209
- 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);
21210
21371
  }
21211
- const accountMap = new Map((accounts || []).map((a) => [a.account_number, a.id]));
21212
- const missingAccounts = accountNumbers.filter((n) => !accountMap.has(n));
21372
+ const missingAccounts = accountIdentifiers.filter(
21373
+ (identifier) => !accountMap.has(identifier)
21374
+ );
21213
21375
  if (missingAccounts.length > 0) {
21214
21376
  return toErrorContent(
21215
- `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.`
21216
21378
  );
21217
21379
  }
21218
- const totalDebit = roundCents(params.entries.reduce((s, e) => s + e.debitAmount, 0));
21219
- 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
+ );
21220
21386
  if (totalDebit !== totalCredit) {
21221
21387
  return toErrorContent(
21222
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.`
21223
21389
  );
21224
21390
  }
21225
- const { data: transaction, error: txError } = await supabase2.from("transactions").insert({
21226
- company_id: companyId2,
21227
- fiscal_year_id: params.fiscalYearId,
21228
- transaction_date: params.transactionDate,
21229
- description: params.description,
21230
- document_number: params.documentNumber || null
21231
- }).select().single();
21232
- if (txError) {
21233
- return toErrorContent(`Failed to create transaction: ${txError.message}`);
21234
- }
21235
21391
  const journalEntries = params.entries.map((e) => ({
21236
- transaction_id: transaction.id,
21237
21392
  account_id: accountMap.get(e.accountNumber),
21238
21393
  debit_amount: e.debitAmount,
21239
21394
  credit_amount: e.creditAmount
21240
21395
  }));
21241
- const { error: jeError } = await supabase2.from("journal_entries").insert(journalEntries);
21242
- if (jeError) {
21243
- 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
+ );
21244
21411
  }
21245
- const { data: complete } = await supabase2.from("transactions").select(`
21412
+ const { data: complete } = await supabase2.from("transactions").select(
21413
+ `
21246
21414
  id, transaction_date, description, document_number, created_at,
21247
21415
  journal_entries (
21248
21416
  id, debit_amount, credit_amount,
21249
21417
  accounts ( account_number, name )
21250
21418
  )
21251
- `).eq("id", transaction.id).single();
21419
+ `
21420
+ ).eq("id", transactionId).single();
21252
21421
  return toTextContent({
21253
21422
  message: "Transaction created successfully",
21254
21423
  transaction: complete
@@ -21257,77 +21426,136 @@ function registerTransactionTools(server2) {
21257
21426
  );
21258
21427
  server2.tool(
21259
21428
  "kirjapro_create_transactions_batch",
21260
- "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.",
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.",
21261
21430
  {
21262
- fiscalYearId: external_exports.string().uuid().describe("Fiscal year UUID"),
21263
- transactions: external_exports.array(external_exports.object({
21264
- transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
21265
- description: external_exports.string().min(1).max(500),
21266
- documentNumber: external_exports.string().optional(),
21267
- entries: external_exports.array(external_exports.object({
21268
- accountNumber: external_exports.string().describe('Account number (e.g. "4000")'),
21269
- debitAmount: external_exports.number().min(0).default(0),
21270
- creditAmount: external_exports.number().min(0).default(0)
21271
- })).min(2)
21272
- })).min(1).max(50).describe("Array of transactions to create (max 50)")
21431
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
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)")
21273
21446
  },
21274
21447
  async (params) => {
21275
21448
  const supabase2 = getSupabase();
21276
21449
  const companyId2 = getCompanyId();
21277
- const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", params.fiscalYearId).eq("company_id", companyId2).single();
21450
+ let fiscalYearId = params.fiscalYearId;
21451
+ if (!fiscalYearId) {
21452
+ const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
21453
+ if ("isError" in resolved) return resolved;
21454
+ fiscalYearId = resolved.id;
21455
+ }
21456
+ const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
21278
21457
  if (fyError || !fy) {
21279
- return toErrorContent(`Fiscal year ${params.fiscalYearId} not found.`);
21458
+ return toErrorContent(`Fiscal year ${fiscalYearId} not found.`);
21280
21459
  }
21281
21460
  if (fy.is_closed) {
21282
- 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);
21283
21496
  }
21284
- const allAccountNumbers = [...new Set(
21285
- params.transactions.flatMap((t) => t.entries.map((e) => e.accountNumber))
21286
- )];
21287
- const { data: accounts } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", allAccountNumbers);
21288
- const accountMap = new Map((accounts || []).map((a) => [a.account_number, a.id]));
21289
21497
  const created = [];
21290
21498
  const failed = [];
21291
21499
  for (let i = 0; i < params.transactions.length; i++) {
21292
21500
  const tx = params.transactions[i];
21293
21501
  if (tx.transactionDate < fy.start_date || tx.transactionDate > fy.end_date) {
21294
- 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
+ });
21295
21506
  continue;
21296
21507
  }
21297
- const missing = tx.entries.filter((e) => !accountMap.has(e.accountNumber));
21508
+ const missing = tx.entries.filter(
21509
+ (e) => !accountMap.has(e.accountNumber)
21510
+ );
21298
21511
  if (missing.length > 0) {
21299
- 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
+ });
21300
21516
  continue;
21301
21517
  }
21302
- const totalDebit = roundCents(tx.entries.reduce((s, e) => s + e.debitAmount, 0));
21303
- 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
+ );
21304
21524
  if (totalDebit !== totalCredit) {
21305
- failed.push({ index: i, error: `Unbalanced: debits ${totalDebit} != credits ${totalCredit}` });
21306
- continue;
21307
- }
21308
- const { data: newTx, error: txErr } = await supabase2.from("transactions").insert({
21309
- company_id: companyId2,
21310
- fiscal_year_id: params.fiscalYearId,
21311
- transaction_date: tx.transactionDate,
21312
- description: tx.description,
21313
- document_number: tx.documentNumber || null
21314
- }).select("id").single();
21315
- if (txErr) {
21316
- failed.push({ index: i, error: txErr.message });
21525
+ failed.push({
21526
+ index: i,
21527
+ error: `Unbalanced: debits ${totalDebit} != credits ${totalCredit}`
21528
+ });
21317
21529
  continue;
21318
21530
  }
21319
21531
  const entries = tx.entries.map((e) => ({
21320
- transaction_id: newTx.id,
21321
21532
  account_id: accountMap.get(e.accountNumber),
21322
21533
  debit_amount: e.debitAmount,
21323
21534
  credit_amount: e.creditAmount
21324
21535
  }));
21325
- const { error: jeErr } = await supabase2.from("journal_entries").insert(entries);
21326
- if (jeErr) {
21327
- 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
+ });
21328
21552
  continue;
21329
21553
  }
21330
- 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
+ });
21331
21559
  }
21332
21560
  return toTextContent({
21333
21561
  created_count: created.length,
@@ -21346,13 +21574,15 @@ function registerTransactionTools(server2) {
21346
21574
  async (params) => {
21347
21575
  const supabase2 = getSupabase();
21348
21576
  const companyId2 = getCompanyId();
21349
- const { data: transaction, error: error2 } = await supabase2.from("transactions").select(`
21577
+ const { data: transaction, error: error2 } = await supabase2.from("transactions").select(
21578
+ `
21350
21579
  id, transaction_date, description, document_number, created_at,
21351
21580
  journal_entries (
21352
21581
  id, account_id, debit_amount, credit_amount, description,
21353
21582
  accounts ( account_number, name, account_type )
21354
21583
  )
21355
- `).eq("id", params.transactionId).eq("company_id", companyId2).single();
21584
+ `
21585
+ ).eq("id", params.transactionId).eq("company_id", companyId2).single();
21356
21586
  if (error2 || !transaction) {
21357
21587
  return toErrorContent(
21358
21588
  `Transaction ${params.transactionId} not found. Use kirjapro_list_transactions to find transaction IDs.`
@@ -21376,6 +21606,14 @@ function registerTransactionTools(server2) {
21376
21606
  }
21377
21607
 
21378
21608
  // src/types.ts
21609
+ var VALID_OFFER_TRANSITIONS = {
21610
+ draft: ["sent"],
21611
+ sent: ["viewed", "accepted", "rejected", "expired", "draft"],
21612
+ viewed: ["accepted", "rejected", "expired", "draft"],
21613
+ accepted: ["draft"],
21614
+ rejected: ["draft"],
21615
+ expired: ["draft"]
21616
+ };
21379
21617
  var VALID_TRANSITIONS = {
21380
21618
  draft: ["sent", "cancelled"],
21381
21619
  sent: ["paid", "partial", "overdue", "cancelled", "draft"],
@@ -21399,18 +21637,12 @@ function generateReferenceNumber(baseNumber) {
21399
21637
  const checkDigit = (10 - sum % 10) % 10;
21400
21638
  return digits + checkDigit;
21401
21639
  }
21402
- function generateInvoiceNumber(lastNumber, prefix = "INV") {
21403
- if (!lastNumber) return `${prefix}00001`;
21404
- const numPart = lastNumber.replace(/\D/g, "");
21405
- const next = parseInt(numPart || "0") + 1;
21406
- return `${prefix}${next.toString().padStart(5, "0")}`;
21407
- }
21408
21640
  function registerInvoiceTools(server2) {
21409
21641
  server2.tool(
21410
21642
  "kirjapro_list_invoices",
21411
21643
  "List invoices with customer names. Filter by status (draft, sent, paid, overdue, cancelled).",
21412
21644
  {
21413
- status: external_exports.enum(["draft", "sent", "paid", "overdue", "cancelled"]).optional().describe("Filter by invoice status"),
21645
+ status: external_exports.enum(["draft", "sent", "paid", "partial", "overdue", "cancelled"]).optional().describe("Filter by invoice status"),
21414
21646
  limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
21415
21647
  offset: external_exports.number().min(0).default(0).describe("Skip first N results")
21416
21648
  },
@@ -21418,18 +21650,21 @@ function registerInvoiceTools(server2) {
21418
21650
  const supabase2 = getSupabase();
21419
21651
  const companyId2 = getCompanyId();
21420
21652
  const { limit, offset } = clampPagination(params.limit, params.offset);
21421
- let query = supabase2.from("invoices").select(`
21653
+ let query = supabase2.from("invoices").select(
21654
+ `
21422
21655
  id, invoice_number, reference_number, invoice_date, due_date,
21423
21656
  status, subtotal, vat_total, total, notes,
21424
21657
  customers ( name, business_id )
21425
- `, { 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 });
21426
21661
  if (params.status) {
21427
21662
  query = query.eq("status", params.status);
21428
21663
  }
21429
21664
  query = query.range(offset, offset + limit - 1);
21430
21665
  const { data, count, error: error2 } = await query;
21431
21666
  if (error2) {
21432
- return toErrorContent(`Failed to list invoices: ${error2.message}`);
21667
+ return toErrorContent(sanitizeDbError("Failed to list invoices", error2));
21433
21668
  }
21434
21669
  return toTextContent(paginate(data || [], count || 0, offset, limit));
21435
21670
  }
@@ -21442,13 +21677,15 @@ function registerInvoiceTools(server2) {
21442
21677
  invoiceDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Invoice date (YYYY-MM-DD)"),
21443
21678
  paymentTermDays: external_exports.number().min(0).max(365).default(14).describe("Payment term in days (default 14)"),
21444
21679
  notes: external_exports.string().optional().describe("Notes printed on the invoice"),
21445
- lines: external_exports.array(external_exports.object({
21446
- description: external_exports.string().min(1).describe("Line item description"),
21447
- quantity: external_exports.number().min(0).describe("Quantity"),
21448
- unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
21449
- unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
21450
- vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
21451
- })).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")
21452
21689
  },
21453
21690
  async (params) => {
21454
21691
  const supabase2 = getSupabase();
@@ -21459,12 +21696,24 @@ function registerInvoiceTools(server2) {
21459
21696
  `Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
21460
21697
  );
21461
21698
  }
21462
- const { data: lastInvoice } = await supabase2.from("invoices").select("invoice_number").eq("company_id", companyId2).order("created_at", { ascending: false }).limit(1).single();
21463
- const invoiceNumber = generateInvoiceNumber(lastInvoice?.invoice_number || null);
21464
- const referenceNumber = generateReferenceNumber(invoiceNumber.replace(/\D/g, ""));
21465
- const dueDate = new Date(params.invoiceDate);
21466
- dueDate.setDate(dueDate.getDate() + params.paymentTermDays);
21467
- 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
+ );
21468
21717
  const calculatedLines = params.lines.map((line, i) => {
21469
21718
  const vatRate = parseFloat(line.vatRate);
21470
21719
  const subtotal2 = roundCents(line.quantity * line.unitPrice);
@@ -21482,32 +21731,42 @@ function registerInvoiceTools(server2) {
21482
21731
  total: total2
21483
21732
  };
21484
21733
  });
21485
- const subtotal = roundCents(calculatedLines.reduce((s, l) => s + l.subtotal, 0));
21486
- 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
+ );
21487
21740
  const total = roundCents(subtotal + vatTotal);
21488
- const { data: invoice, error: invError } = await supabase2.from("invoices").insert({
21489
- company_id: companyId2,
21490
- customer_id: params.customerId,
21491
- invoice_number: invoiceNumber,
21492
- reference_number: referenceNumber,
21493
- invoice_date: params.invoiceDate,
21494
- due_date: dueDateStr,
21495
- status: "draft",
21496
- subtotal,
21497
- vat_total: vatTotal,
21498
- total,
21499
- notes: params.notes || null
21500
- }).select().single();
21501
- if (invError) {
21502
- 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
+ );
21503
21762
  }
21504
- const lineInserts = calculatedLines.map((line) => ({
21505
- invoice_id: invoice.id,
21506
- ...line
21507
- }));
21508
- const { error: lineError } = await supabase2.from("invoice_lines").insert(lineInserts);
21509
- if (lineError) {
21510
- 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
+ );
21511
21770
  }
21512
21771
  return toTextContent({
21513
21772
  message: `Invoice ${invoiceNumber} created for ${customer.name}`,
@@ -21533,23 +21792,32 @@ function registerInvoiceTools(server2) {
21533
21792
  async (params) => {
21534
21793
  const supabase2 = getSupabase();
21535
21794
  const companyId2 = getCompanyId();
21536
- const { data: invoice, error: invError } = await supabase2.from("invoices").select(`
21795
+ const { data: invoice, error: invError } = await supabase2.from("invoices").select(
21796
+ `
21537
21797
  id, invoice_number, total, status,
21538
21798
  invoice_payments ( amount )
21539
- `).eq("id", params.invoiceId).eq("company_id", companyId2).single();
21799
+ `
21800
+ ).eq("id", params.invoiceId).eq("company_id", companyId2).single();
21540
21801
  if (invError || !invoice) {
21541
21802
  return toErrorContent(
21542
21803
  `Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
21543
21804
  );
21544
21805
  }
21545
21806
  if (invoice.status === "paid") {
21546
- return toErrorContent(`Invoice ${invoice.invoice_number} is already fully paid.`);
21807
+ return toErrorContent(
21808
+ `Invoice ${invoice.invoice_number} is already fully paid.`
21809
+ );
21547
21810
  }
21548
21811
  if (invoice.status === "cancelled") {
21549
- 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
+ );
21550
21815
  }
21551
21816
  const paidSoFar = roundCents(
21552
- (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
+ )
21553
21821
  );
21554
21822
  const remaining = roundCents(invoice.total - paidSoFar);
21555
21823
  if (params.amount > remaining + 0.01) {
@@ -21557,30 +21825,29 @@ function registerInvoiceTools(server2) {
21557
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.`
21558
21826
  );
21559
21827
  }
21560
- const { data: payment, error: payError } = await supabase2.from("invoice_payments").insert({
21561
- invoice_id: params.invoiceId,
21562
- payment_date: params.paymentDate,
21563
- amount: params.amount,
21564
- payment_method: params.paymentMethod,
21565
- reference: params.reference || null,
21566
- notes: params.notes || null
21567
- }).select().single();
21568
- if (payError) {
21569
- return toErrorContent(`Failed to record payment: ${payError.message}`);
21570
- }
21571
- const newPaidTotal = roundCents(paidSoFar + params.amount);
21572
- const newStatus = newPaidTotal >= invoice.total ? "paid" : invoice.status;
21573
- if (newStatus !== invoice.status) {
21574
- await supabase2.from("invoices").update({
21575
- status: newStatus,
21576
- paid_date: newStatus === "paid" ? params.paymentDate : null
21577
- }).eq("id", params.invoiceId);
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
21838
+ }
21839
+ );
21840
+ if (payError || !paymentResult) {
21841
+ return toErrorContent(
21842
+ payError ? sanitizeDbError("Failed to record payment", payError) : "Failed to record payment."
21843
+ );
21578
21844
  }
21845
+ const result = paymentResult;
21579
21846
  return toTextContent({
21580
21847
  message: `Payment of ${params.amount.toFixed(2)} EUR recorded on invoice ${invoice.invoice_number}`,
21581
- payment,
21582
- invoice_status: newStatus,
21583
- remaining_balance: roundCents(remaining - params.amount)
21848
+ payment_id: result.payment_id,
21849
+ invoice_status: result.invoice_status,
21850
+ remaining_balance: result.remaining_balance
21584
21851
  });
21585
21852
  }
21586
21853
  );
@@ -21593,13 +21860,15 @@ function registerInvoiceTools(server2) {
21593
21860
  async (params) => {
21594
21861
  const supabase2 = getSupabase();
21595
21862
  const companyId2 = getCompanyId();
21596
- const { data: invoice, error: error2 } = await supabase2.from("invoices").select(`
21863
+ const { data: invoice, error: error2 } = await supabase2.from("invoices").select(
21864
+ `
21597
21865
  id, invoice_number, reference_number, invoice_date, due_date,
21598
21866
  status, subtotal, vat_total, total, notes, created_at,
21599
21867
  customers ( id, name, business_id, email, address, postal_code, city ),
21600
21868
  invoice_lines ( id, line_number, description, quantity, unit, unit_price, vat_rate, subtotal, vat_amount, total ),
21601
21869
  invoice_payments ( id, payment_date, amount, payment_method, reference )
21602
- `).eq("id", params.invoiceId).eq("company_id", companyId2).single();
21870
+ `
21871
+ ).eq("id", params.invoiceId).eq("company_id", companyId2).single();
21603
21872
  if (error2 || !invoice) {
21604
21873
  return toErrorContent(
21605
21874
  `Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
@@ -21607,7 +21876,12 @@ function registerInvoiceTools(server2) {
21607
21876
  }
21608
21877
  const vatMap = /* @__PURE__ */ new Map();
21609
21878
  for (const line of invoice.invoice_lines || []) {
21610
- 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
+ };
21611
21885
  existing.subtotal += line.subtotal || 0;
21612
21886
  existing.vat_amount += line.vat_amount || 0;
21613
21887
  existing.total += line.total || 0;
@@ -21620,7 +21894,9 @@ function registerInvoiceTools(server2) {
21620
21894
  total: roundCents(v.total)
21621
21895
  }));
21622
21896
  const payments = invoice.invoice_payments || [];
21623
- 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
+ );
21624
21900
  const remainingBalance = roundCents(invoice.total - paidTotal);
21625
21901
  return toTextContent({
21626
21902
  ...invoice,
@@ -21663,9 +21939,11 @@ function registerInvoiceTools(server2) {
21663
21939
  if (newStatus === "paid") {
21664
21940
  updateFields.paid_date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
21665
21941
  }
21666
- 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);
21667
21943
  if (updateError) {
21668
- return toErrorContent(`Failed to update invoice status: ${updateError.message}`);
21944
+ return toErrorContent(
21945
+ sanitizeDbError("Failed to update invoice status", updateError)
21946
+ );
21669
21947
  }
21670
21948
  return toTextContent({
21671
21949
  message: `Invoice ${invoice.invoice_number} status changed from "${currentStatus}" to "${newStatus}"`,
@@ -21695,13 +21973,23 @@ function registerInvoiceTools(server2) {
21695
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.`
21696
21974
  );
21697
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
+ }
21698
21982
  const { error: linesError } = await supabase2.from("invoice_lines").delete().eq("invoice_id", params.invoiceId);
21699
21983
  if (linesError) {
21700
- return toErrorContent(`Failed to delete invoice lines: ${linesError.message}`);
21984
+ return toErrorContent(
21985
+ sanitizeDbError("Failed to delete invoice lines", linesError)
21986
+ );
21701
21987
  }
21702
21988
  const { error: deleteError } = await supabase2.from("invoices").delete().eq("id", params.invoiceId);
21703
21989
  if (deleteError) {
21704
- return toErrorContent(`Failed to delete invoice: ${deleteError.message}`);
21990
+ return toErrorContent(
21991
+ sanitizeDbError("Failed to delete invoice", deleteError)
21992
+ );
21705
21993
  }
21706
21994
  return toTextContent({
21707
21995
  message: `Draft invoice ${invoice.invoice_number} deleted`,
@@ -21756,7 +22044,7 @@ function registerCustomerTools(server2) {
21756
22044
  query = query.range(offset, offset + limit - 1);
21757
22045
  const { data, count, error: error2 } = await query;
21758
22046
  if (error2) {
21759
- return toErrorContent(`Failed to list customers: ${error2.message}`);
22047
+ return toErrorContent(sanitizeDbError("Failed to list customers", error2));
21760
22048
  }
21761
22049
  return toTextContent(paginate(data || [], count || 0, offset, limit));
21762
22050
  }
@@ -21784,7 +22072,7 @@ function registerCustomerTools(server2) {
21784
22072
  `Y-tunnus '${params.businessId}' is invalid (checksum failed). Valid format: 7 digits + hyphen + check digit. Example: 1234567-8.`
21785
22073
  );
21786
22074
  }
21787
- 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();
21788
22076
  const lastNum = parseInt(lastCustomer?.customer_number?.replace(/\D/g, "") || "0");
21789
22077
  const customerNumber = (lastNum + 1).toString().padStart(5, "0");
21790
22078
  const { data: customer, error: error2 } = await supabase2.from("customers").insert({
@@ -21802,7 +22090,7 @@ function registerCustomerTools(server2) {
21802
22090
  payment_term_days: params.paymentTermDays
21803
22091
  }).select().single();
21804
22092
  if (error2) {
21805
- return toErrorContent(`Failed to create customer: ${error2.message}`);
22093
+ return toErrorContent(sanitizeDbError("Failed to create customer", error2));
21806
22094
  }
21807
22095
  return toTextContent({
21808
22096
  message: `Customer "${params.name}" created with number ${customerNumber}`,
@@ -21825,12 +22113,15 @@ function registerCustomerTools(server2) {
21825
22113
  `Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
21826
22114
  );
21827
22115
  }
21828
- const { data: invoices } = await supabase2.from("invoices").select("total, status").eq("company_id", companyId2).eq("customer_id", params.customerId);
22116
+ const { data: invoices } = await supabase2.from("invoices").select("total, status, invoice_payments(amount)").eq("company_id", companyId2).eq("customer_id", params.customerId);
21829
22117
  const invoiceList = invoices || [];
21830
22118
  const invoiceCount = invoiceList.length;
21831
22119
  const totalRevenue = roundCents(invoiceList.reduce((s, i) => s + (i.total || 0), 0));
21832
22120
  const outstandingBalance = roundCents(
21833
- invoiceList.filter((i) => i.status === "sent" || i.status === "partial" || i.status === "overdue").reduce((s, i) => s + (i.total || 0), 0)
22121
+ invoiceList.filter((i) => i.status === "sent" || i.status === "partial" || i.status === "overdue").reduce((s, i) => {
22122
+ const paid = (i.invoice_payments || []).reduce((ps, p) => ps + (p.amount || 0), 0);
22123
+ return s + (i.total || 0) - paid;
22124
+ }, 0)
21834
22125
  );
21835
22126
  return toTextContent({
21836
22127
  ...customer,
@@ -21890,7 +22181,7 @@ function registerCustomerTools(server2) {
21890
22181
  }
21891
22182
  const { data: updated, error: updateError } = await supabase2.from("customers").update(updates).eq("id", params.customerId).select().single();
21892
22183
  if (updateError) {
21893
- return toErrorContent(`Failed to update customer: ${updateError.message}`);
22184
+ return toErrorContent(sanitizeDbError("Failed to update customer", updateError));
21894
22185
  }
21895
22186
  return toTextContent({
21896
22187
  message: `Customer "${updated.name}" updated`,
@@ -21899,6 +22190,42 @@ function registerCustomerTools(server2) {
21899
22190
  });
21900
22191
  }
21901
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
+ );
21902
22229
  }
21903
22230
 
21904
22231
  // src/tools/reports.ts
@@ -21912,17 +22239,24 @@ function registerReportTools(server2) {
21912
22239
  async (params) => {
21913
22240
  const supabase2 = getSupabase();
21914
22241
  const companyId2 = getCompanyId();
22242
+ const { data: fiscalYear, error: fyError } = await supabase2.from("fiscal_years").select("start_date, end_date").eq("id", params.fiscalYearId).eq("company_id", companyId2).single();
22243
+ if (fyError || !fiscalYear) {
22244
+ return toErrorContent(
22245
+ `Fiscal year ${params.fiscalYearId} not found. Use kirjapro_list_fiscal_years to find available fiscal years.`
22246
+ );
22247
+ }
22248
+ const JOURNAL_ENTRY_CAP = 5e4;
21915
22249
  const [entriesResult, txCountResult, invoiceResult, bankResult] = await Promise.all([
21916
- // Revenue & expense totals from journal entries
22250
+ // Revenue & expense totals from journal entries (capped to prevent OOM on large datasets)
21917
22251
  supabase2.from("journal_entries").select(`
21918
22252
  debit_amount, credit_amount,
21919
22253
  account:accounts!inner(account_type),
21920
22254
  transaction:transactions!inner(fiscal_year_id)
21921
- `).eq("transaction.fiscal_year_id", params.fiscalYearId),
22255
+ `, { count: "exact" }).eq("transaction.fiscal_year_id", params.fiscalYearId).limit(JOURNAL_ENTRY_CAP),
21922
22256
  // Transaction count
21923
22257
  supabase2.from("transactions").select("*", { count: "exact", head: true }).eq("company_id", companyId2).eq("fiscal_year_id", params.fiscalYearId),
21924
- // Invoice stats
21925
- supabase2.from("invoices").select("id, status, total, due_date").eq("company_id", companyId2),
22258
+ // Invoice stats scoped to fiscal year date range
22259
+ supabase2.from("invoices").select("id, status, total, due_date").eq("company_id", companyId2).gte("invoice_date", fiscalYear.start_date).lte("invoice_date", fiscalYear.end_date),
21926
22260
  // Bank balances
21927
22261
  supabase2.from("bank_accounts").select("account_name, iban, current_balance").eq("company_id", companyId2).eq("is_active", true)
21928
22262
  ]);
@@ -21952,11 +22286,12 @@ function registerReportTools(server2) {
21952
22286
  }
21953
22287
  }
21954
22288
  const bankAccounts = (bankResult.data || []).map((b) => ({
21955
- name: b.account_name,
22289
+ name: b.account_name || "Unnamed",
21956
22290
  iban: b.iban,
21957
22291
  balance: b.current_balance || 0
21958
22292
  }));
21959
22293
  const totalBankBalance = bankAccounts.reduce((s, b) => s + b.balance, 0);
22294
+ const entriesCapped = (entriesResult.count || 0) >= JOURNAL_ENTRY_CAP;
21960
22295
  return toTextContent({
21961
22296
  financial_summary: {
21962
22297
  revenue: roundCents(revenue),
@@ -21965,7 +22300,8 @@ function registerReportTools(server2) {
21965
22300
  expenses_formatted: formatCurrency(expenses),
21966
22301
  profit: roundCents(revenue - expenses),
21967
22302
  profit_formatted: formatCurrency(revenue - expenses),
21968
- 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.` }
21969
22305
  },
21970
22306
  invoice_stats: invoiceStats,
21971
22307
  bank_summary: {
@@ -21987,13 +22323,14 @@ function registerReportTools(server2) {
21987
22323
  async (params) => {
21988
22324
  const supabase2 = getSupabase();
21989
22325
  getCompanyId();
21990
- 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(`
21991
22328
  debit_amount, credit_amount,
21992
22329
  account:accounts!inner(id, account_number, name, account_type),
21993
22330
  transaction:transactions!inner(fiscal_year_id, transaction_date)
21994
- `).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);
21995
22332
  if (error2) {
21996
- return toErrorContent(`Failed to generate income statement: ${error2.message}`);
22333
+ return toErrorContent(sanitizeDbError("Failed to generate income statement", error2));
21997
22334
  }
21998
22335
  const accountTotals = /* @__PURE__ */ new Map();
21999
22336
  for (const entry of entries || []) {
@@ -22027,6 +22364,7 @@ function registerReportTools(server2) {
22027
22364
  expenseAccounts.push(acc);
22028
22365
  }
22029
22366
  }
22367
+ const capped = (entryCount || 0) >= ENTRY_CAP;
22030
22368
  return toTextContent({
22031
22369
  period: { from: params.dateFrom, to: params.dateTo },
22032
22370
  revenue: {
@@ -22040,7 +22378,8 @@ function registerReportTools(server2) {
22040
22378
  total_formatted: formatCurrency(totalExpenses)
22041
22379
  },
22042
22380
  net_profit: roundCents(totalRevenue - totalExpenses),
22043
- 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.` }
22044
22383
  });
22045
22384
  }
22046
22385
  );
@@ -22054,13 +22393,14 @@ function registerReportTools(server2) {
22054
22393
  async (params) => {
22055
22394
  const supabase2 = getSupabase();
22056
22395
  getCompanyId();
22057
- 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(`
22058
22398
  debit_amount, credit_amount,
22059
22399
  account:accounts!inner(id, account_number, name, account_type),
22060
22400
  transaction:transactions!inner(fiscal_year_id, transaction_date)
22061
- `).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);
22062
22402
  if (error2) {
22063
- return toErrorContent(`Failed to generate balance sheet: ${error2.message}`);
22403
+ return toErrorContent(sanitizeDbError("Failed to generate balance sheet", error2));
22064
22404
  }
22065
22405
  const accountTotals = /* @__PURE__ */ new Map();
22066
22406
  for (const entry of entries || []) {
@@ -22105,6 +22445,7 @@ function registerReportTools(server2) {
22105
22445
  break;
22106
22446
  }
22107
22447
  }
22448
+ const bsCapped = (bsEntryCount || 0) >= BS_ENTRY_CAP;
22108
22449
  return toTextContent({
22109
22450
  report_date: params.reportDate,
22110
22451
  assets: {
@@ -22123,7 +22464,8 @@ function registerReportTools(server2) {
22123
22464
  total_formatted: formatCurrency(totalEquity)
22124
22465
  },
22125
22466
  total_liabilities_and_equity: roundCents(totalLiabilities + totalEquity),
22126
- 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.` }
22127
22469
  });
22128
22470
  }
22129
22471
  );
@@ -22136,7 +22478,7 @@ function registerReportTools(server2) {
22136
22478
  const companyId2 = getCompanyId();
22137
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"]);
22138
22480
  if (error2) {
22139
- return toErrorContent(`Failed to get AR aging: ${error2.message}`);
22481
+ return toErrorContent(sanitizeDbError("Failed to get AR aging", error2));
22140
22482
  }
22141
22483
  const now = /* @__PURE__ */ new Date();
22142
22484
  const buckets = [
@@ -22208,7 +22550,7 @@ function registerReportTools(server2) {
22208
22550
  }
22209
22551
  const { data: entries, error: error2 } = await query;
22210
22552
  if (error2) {
22211
- return toErrorContent(`Failed to get general ledger: ${error2.message}`);
22553
+ return toErrorContent(sanitizeDbError("Failed to get general ledger", error2));
22212
22554
  }
22213
22555
  const debitNormal = ["asset", "expense"];
22214
22556
  const isDebitNormal = debitNormal.includes(account.account_type);
@@ -22248,6 +22590,38 @@ function registerReportTools(server2) {
22248
22590
  });
22249
22591
  }
22250
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
+ );
22251
22625
  }
22252
22626
 
22253
22627
  // src/tools/banking.ts
@@ -22262,7 +22636,9 @@ function registerBankingTools(server2) {
22262
22636
  async (params) => {
22263
22637
  const supabase2 = getSupabase();
22264
22638
  const companyId2 = getCompanyId();
22265
- 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();
22266
22642
  if (btError || !bankTx) {
22267
22643
  return toErrorContent(
22268
22644
  `Bank transaction ${params.bankTransactionId} not found. Verify the ID and ensure it belongs to your company.`
@@ -22278,58 +22654,58 @@ function registerBankingTools(server2) {
22278
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.`
22279
22655
  );
22280
22656
  }
22281
- const { data: invoice, error: invError } = await supabase2.from("invoices").select(`
22657
+ const { data: invoice, error: invError } = await supabase2.from("invoices").select(
22658
+ `
22282
22659
  id, invoice_number, total, status,
22283
22660
  invoice_payments ( amount )
22284
- `).eq("id", params.invoiceId).eq("company_id", companyId2).single();
22661
+ `
22662
+ ).eq("id", params.invoiceId).eq("company_id", companyId2).single();
22285
22663
  if (invError || !invoice) {
22286
22664
  return toErrorContent(
22287
22665
  `Invoice ${params.invoiceId} not found. Use kirjapro_list_invoices to find invoice IDs.`
22288
22666
  );
22289
22667
  }
22290
22668
  if (invoice.status === "paid") {
22291
- 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
+ );
22292
22677
  }
22293
22678
  const paidSoFar = roundCents(
22294
- (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
+ )
22295
22683
  );
22296
22684
  const remaining = roundCents(invoice.total - paidSoFar);
22297
- const paymentAmount = Math.min(Math.abs(bankTx.amount), remaining);
22298
- const { data: _payment, error: payError } = await supabase2.from("invoice_payments").insert({
22299
- invoice_id: params.invoiceId,
22300
- payment_date: bankTx.booking_date,
22301
- amount: paymentAmount,
22302
- payment_method: "bank_transfer",
22303
- reference: bankTx.reference || null
22304
- }).select().single();
22305
- if (payError) {
22306
- return toErrorContent(`Failed to create payment: ${payError.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
+ );
22307
22690
  }
22308
- const { data: match, error: _matchError } = await supabase2.from("reconciliation_matches").insert({
22309
- company_id: companyId2,
22310
- bank_transaction_id: params.bankTransactionId,
22311
- match_type: "invoice",
22312
- matched_invoice_id: params.invoiceId,
22313
- confidence: 100,
22314
- match_method: "mcp_manual",
22315
- status: "confirmed",
22316
- confirmed_at: (/* @__PURE__ */ new Date()).toISOString()
22317
- }).select("id").single();
22318
- await supabase2.from("bank_transactions").update({
22319
- reconciled_at: (/* @__PURE__ */ new Date()).toISOString(),
22320
- matched_invoice_id: params.invoiceId,
22321
- match_confidence: 1,
22322
- match_method: "mcp_manual",
22323
- reconciliation_match_id: match?.id || null
22324
- }).eq("id", params.bankTransactionId);
22325
- const newPaidTotal = roundCents(paidSoFar + paymentAmount);
22326
- const newStatus = newPaidTotal >= invoice.total ? "paid" : invoice.status;
22327
- if (newStatus !== invoice.status) {
22328
- await supabase2.from("invoices").update({
22329
- status: newStatus,
22330
- paid_date: newStatus === "paid" ? bankTx.booking_date : null
22331
- }).eq("id", params.invoiceId);
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"
22700
+ }
22701
+ );
22702
+ if (matchError || !matchResult) {
22703
+ return toErrorContent(
22704
+ matchError ? sanitizeDbError("Failed to confirm reconciliation match", matchError) : "Failed to confirm reconciliation match."
22705
+ );
22332
22706
  }
22707
+ const newPaidTotal = roundCents(paidSoFar + paymentAmount);
22708
+ const newStatus = newPaidTotal >= invoice.total ? "paid" : "partial";
22333
22709
  return toTextContent({
22334
22710
  message: `Bank transaction matched to invoice ${invoice.invoice_number}`,
22335
22711
  payment_amount: paymentAmount,
@@ -22346,9 +22722,11 @@ function registerBankingTools(server2) {
22346
22722
  async () => {
22347
22723
  const supabase2 = getSupabase();
22348
22724
  const companyId2 = getCompanyId();
22349
- 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 });
22350
22728
  if (error2) {
22351
- return toErrorContent(`Failed to list bank accounts: ${error2.message}`);
22729
+ return toErrorContent(sanitizeDbError("Failed to list bank accounts", error2));
22352
22730
  }
22353
22731
  return toTextContent({
22354
22732
  accounts: data || [],
@@ -22401,7 +22779,9 @@ function registerBankingTools(server2) {
22401
22779
  query = query.range(offset, offset + limit - 1);
22402
22780
  const { data, count, error: error2 } = await query;
22403
22781
  if (error2) {
22404
- return toErrorContent(`Failed to list bank transactions: ${error2.message}`);
22782
+ return toErrorContent(
22783
+ sanitizeDbError("Failed to list bank transactions", error2)
22784
+ );
22405
22785
  }
22406
22786
  return toTextContent(paginate(data || [], count || 0, offset, limit));
22407
22787
  }
@@ -22524,7 +22904,7 @@ function registerFiscalYearTools(server2) {
22524
22904
  const companyId2 = getCompanyId();
22525
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 });
22526
22906
  if (error2) {
22527
- return toErrorContent(`Failed to list fiscal years: ${error2.message}`);
22907
+ return toErrorContent(sanitizeDbError("Failed to list fiscal years", error2));
22528
22908
  }
22529
22909
  return toTextContent({
22530
22910
  fiscal_years: data || [],
@@ -22534,137 +22914,1371 @@ function registerFiscalYearTools(server2) {
22534
22914
  );
22535
22915
  }
22536
22916
 
22537
- // src/tools/index.ts
22538
- function registerTools(server2) {
22539
- registerAccountTools(server2);
22540
- registerTransactionTools(server2);
22541
- registerInvoiceTools(server2);
22542
- registerCustomerTools(server2);
22543
- registerReportTools(server2);
22544
- registerBankingTools(server2);
22545
- registerValidationTools(server2);
22546
- registerFiscalYearTools(server2);
22917
+ // src/tools/templates.ts
22918
+ function splitVat(gross, vatRate) {
22919
+ if (vatRate === 0) return { net: roundCents(gross), vat: 0 };
22920
+ const net = roundCents(gross / (1 + vatRate / 100));
22921
+ const vat = roundCents(gross - net);
22922
+ return { net, vat };
22923
+ }
22924
+ function applyOverrides(entries, overrides) {
22925
+ if (!overrides) return entries;
22926
+ return entries.map((e) => ({
22927
+ ...e,
22928
+ accountNumber: overrides[e.accountNumber] ?? e.accountNumber
22929
+ }));
22930
+ }
22931
+ function saleCashTemplate(revenueAccount, vatRate) {
22932
+ return (amount, opts) => {
22933
+ const { net, vat } = splitVat(amount, vatRate);
22934
+ const entries = [
22935
+ {
22936
+ accountNumber: "2310",
22937
+ debitAmount: roundCents(amount),
22938
+ creditAmount: 0
22939
+ },
22940
+ { accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
22941
+ ];
22942
+ if (vat > 0) {
22943
+ entries.push({
22944
+ accountNumber: "4050",
22945
+ debitAmount: 0,
22946
+ creditAmount: vat
22947
+ });
22948
+ }
22949
+ return applyOverrides(entries, opts?.accountOverrides);
22950
+ };
22547
22951
  }
22548
-
22549
- // src/resources/schema.ts
22550
- var CHART_OF_ACCOUNTS = [
22551
- // Pysyvät vastaavat (Fixed assets)
22552
- { number: "1000", name: "AINEETTOMAT HY\xD6DYKKEET", type: "asset", header: true },
22553
- { number: "1010", name: "Perustamismenot", type: "asset" },
22554
- { number: "1020", name: "Tutkimusmenot", type: "asset" },
22555
- { number: "1030", name: "Kehitt\xE4mismenot", type: "asset" },
22556
- { number: "1100", name: "AINEELLISET HY\xD6DYKKEET", type: "asset", header: true },
22557
- { number: "1110", name: "Maa- ja vesialueet", type: "asset" },
22558
- { number: "1120", name: "Rakennukset ja rakennelmat", type: "asset" },
22559
- { number: "1130", name: "Koneet ja kalusto", type: "asset" },
22560
- { number: "1140", name: "Muut aineelliset hy\xF6dykkeet", type: "asset" },
22561
- { number: "1200", name: "SIJOITUKSET", type: "asset", header: true },
22562
- { number: "1210", name: "Osakkeet ja osuudet", type: "asset" },
22563
- // Vaihtuvat vastaavat (Current assets)
22564
- { number: "2000", name: "VAIHTO-OMAISUUS", type: "asset", header: true },
22565
- { number: "2010", name: "Aineet ja tarvikkeet", type: "asset" },
22566
- { number: "2020", name: "Keskener\xE4iset tuotteet", type: "asset" },
22567
- { number: "2030", name: "Valmiit tuotteet", type: "asset" },
22568
- { number: "2100", name: "SAAMISET", type: "asset", header: true },
22569
- { number: "2110", name: "Myyntisaamiset", type: "asset" },
22570
- { number: "2120", name: "Lainasaamiset", type: "asset" },
22571
- { number: "2130", name: "Siirtosaamiset", type: "asset" },
22572
- { number: "2140", name: "ALV-saamiset", type: "asset" },
22573
- { number: "2200", name: "RAHOITUSARVOPAPERIT", type: "asset", header: true },
22574
- { number: "2300", name: "RAHAT JA PANKKISAAMISET", type: "asset", header: true },
22575
- { number: "2310", name: "Kassa", type: "asset" },
22576
- { number: "2320", name: "Pankkitili", type: "asset" },
22577
- // Oma pääoma (Equity)
22578
- { number: "3000", name: "OMA P\xC4\xC4OMA", type: "equity", header: true },
22579
- { number: "3010", name: "Osakep\xE4\xE4oma / Perusp\xE4\xE4oma", type: "equity" },
22580
- { number: "3020", name: "Ylikurssirahasto", type: "equity" },
22581
- { number: "3030", name: "Sijoitetun vapaan oman p\xE4\xE4oman rahasto (SVOP)", type: "equity" },
22582
- { number: "3040", name: "Edellisten tilikausien voitto/tappio", type: "equity" },
22583
- { number: "3050", name: "Tilikauden voitto/tappio", type: "equity" },
22584
- { number: "3060", name: "Yksityistilit (toiminimi)", type: "equity" },
22585
- // Vieras pääoma (Liabilities)
22586
- { number: "4000", name: "VIERAS P\xC4\xC4OMA", type: "liability", header: true },
22587
- { number: "4010", name: "Pitk\xE4aikaiset lainat", type: "liability" },
22588
- { number: "4020", name: "Lyhytaikaiset lainat", type: "liability" },
22589
- { number: "4030", name: "Ostovelat", type: "liability" },
22590
- { number: "4040", name: "Siirtovelat", type: "liability" },
22591
- { number: "4050", name: "ALV-velka", type: "liability" },
22592
- { number: "4060", name: "Ennakonpid\xE4tysvelka", type: "liability" },
22593
- { number: "4070", name: "Sosiaaliturvamaksuvelka", type: "liability" },
22594
- { number: "4080", name: "TyEL-velka", type: "liability" },
22595
- // Tuotot (Revenue)
22596
- { number: "5000", name: "LIIKEVAIHTO", type: "revenue", header: true },
22597
- { number: "5010", name: "Myynti 25,5 %", type: "revenue" },
22598
- { number: "5020", name: "Myynti 13,5 %", type: "revenue" },
22599
- { number: "5030", name: "Myynti 10 %", type: "revenue" },
22600
- { number: "5040", name: "Myynti 0 %", type: "revenue" },
22601
- { number: "5100", name: "LIIKETOIMINNAN MUUT TUOTOT", type: "revenue", header: true },
22602
- { number: "5110", name: "Vuokratuotot", type: "revenue" },
22603
- { number: "5120", name: "K\xE4ytt\xF6omaisuuden myyntivoitot", type: "revenue" },
22604
- // Kulut (Expenses)
22605
- { number: "6000", name: "MATERIAALIT JA PALVELUT", type: "expense", header: true },
22606
- { number: "6010", name: "Ostot tilikauden aikana", type: "expense" },
22607
- { number: "6020", name: "Varastonmuutos", type: "expense" },
22608
- { number: "6030", name: "Ulkopuoliset palvelut", type: "expense" },
22609
- { number: "7000", name: "HENKIL\xD6ST\xD6KULUT", type: "expense", header: true },
22610
- { number: "7010", name: "Palkat ja palkkiot", type: "expense" },
22611
- { number: "7020", name: "El\xE4kekulut (TyEL)", type: "expense" },
22612
- { number: "7030", name: "Muut henkil\xF6sivukulut", type: "expense" },
22613
- { number: "7500", name: "POISTOT JA ARVONALENTUMISET", type: "expense", header: true },
22614
- { number: "7510", name: "Suunnitelman mukaiset poistot", type: "expense" },
22615
- { number: "8000", name: "LIIKETOIMINNAN MUUT KULUT", type: "expense", header: true },
22616
- { number: "8010", name: "Toimitilakulut", type: "expense" },
22617
- { number: "8020", name: "Ajoneuvokulut", type: "expense" },
22618
- { number: "8030", name: "Matkakulut", type: "expense" },
22619
- { number: "8040", name: "Edustuskulut", type: "expense" },
22620
- { number: "8050", name: "Markkinointikulut", type: "expense" },
22621
- { number: "8060", name: "Hallintokulut", type: "expense" },
22622
- { number: "8070", name: "Vakuutukset", type: "expense" },
22623
- { number: "8080", name: "Tietoliikennekulut", type: "expense" },
22624
- { number: "8090", name: "Toimistokulut", type: "expense" },
22625
- { number: "8400", name: "RAHOITUSTUOTOT JA -KULUT", type: "expense", header: true },
22626
- { number: "8410", name: "Korkotuotot", type: "revenue" },
22627
- { number: "8420", name: "Korkokulut", type: "expense" },
22628
- { number: "8430", name: "Muut rahoitustuotot", type: "revenue" },
22629
- { number: "8440", name: "Muut rahoituskulut", type: "expense" },
22630
- { number: "9000", name: "TULOVEROT", type: "expense", header: true },
22631
- { number: "9010", name: "Tilikauden tuloverot", type: "expense" },
22632
- { number: "9020", name: "Laskennalliset verot", type: "expense" }
22633
- ];
22634
- var VAT_RATES = [
22635
- { rate: 25.5, label: "Yleinen verokanta", description: "Standard rate for most goods and services" },
22636
- { rate: 13.5, label: "Elintarvikkeet, ravintolat", description: "Food, restaurant services (lowered from 14% Jan 2025)" },
22637
- { rate: 10, label: "Kirjat, l\xE4\xE4kkeet, liikunta", description: "Books, medicines, sports services, accommodation" },
22638
- { rate: 0, label: "Veroton", description: "Tax-exempt: exports, international trade, certain financial services" }
22639
- ];
22640
- function registerSchemaResources(server2) {
22641
- server2.resource(
22642
- "chart-of-accounts",
22643
- "kirjapro://accounts/chart",
22644
- {
22645
- description: "Finnish standard chart of accounts (YSE 2017). Reference data for account numbers, names, and types.",
22646
- mimeType: "application/json"
22647
- },
22648
- async (uri) => ({
22649
- contents: [{
22650
- uri: uri.href,
22651
- mimeType: "application/json",
22652
- text: JSON.stringify(CHART_OF_ACCOUNTS, null, 2)
22653
- }]
22654
- })
22655
- );
22952
+ function saleBankTemplate(revenueAccount, vatRate) {
22953
+ return (amount, opts) => {
22954
+ const { net, vat } = splitVat(amount, vatRate);
22955
+ const entries = [
22956
+ {
22957
+ accountNumber: "2320",
22958
+ debitAmount: roundCents(amount),
22959
+ creditAmount: 0
22960
+ },
22961
+ { accountNumber: revenueAccount, debitAmount: 0, creditAmount: net }
22962
+ ];
22963
+ if (vat > 0) {
22964
+ entries.push({
22965
+ accountNumber: "4050",
22966
+ debitAmount: 0,
22967
+ creditAmount: vat
22968
+ });
22969
+ }
22970
+ return applyOverrides(entries, opts?.accountOverrides);
22971
+ };
22972
+ }
22973
+ var TEMPLATES = [
22974
+ // Cash sales
22975
+ {
22976
+ id: "cash_sale_25",
22977
+ description: "K\xE4teismyynti ALV 25,5%",
22978
+ vatRate: 25.5,
22979
+ expand: saleCashTemplate("5010", 25.5)
22980
+ },
22981
+ {
22982
+ id: "cash_sale_14",
22983
+ description: "K\xE4teismyynti ALV 13,5%",
22984
+ vatRate: 13.5,
22985
+ expand: saleCashTemplate("5020", 13.5)
22986
+ },
22987
+ {
22988
+ id: "cash_sale_10",
22989
+ description: "K\xE4teismyynti ALV 10%",
22990
+ vatRate: 10,
22991
+ expand: saleCashTemplate("5030", 10)
22992
+ },
22993
+ {
22994
+ id: "cash_sale_0",
22995
+ description: "K\xE4teismyynti ALV 0%",
22996
+ vatRate: 0,
22997
+ expand: saleCashTemplate("5040", 0)
22998
+ },
22999
+ // Bank sales
23000
+ {
23001
+ id: "bank_sale_25",
23002
+ description: "Pankkimyynti ALV 25,5%",
23003
+ vatRate: 25.5,
23004
+ expand: saleBankTemplate("5010", 25.5)
23005
+ },
23006
+ {
23007
+ id: "bank_sale_14",
23008
+ description: "Pankkimyynti ALV 13,5%",
23009
+ vatRate: 13.5,
23010
+ expand: saleBankTemplate("5020", 13.5)
23011
+ },
23012
+ {
23013
+ id: "bank_sale_10",
23014
+ description: "Pankkimyynti ALV 10%",
23015
+ vatRate: 10,
23016
+ expand: saleBankTemplate("5030", 10)
23017
+ },
23018
+ // Invoice sale
23019
+ {
23020
+ id: "invoice_sale_25",
23021
+ description: "Laskumyynti ALV 25,5% (myyntisaamiset)",
23022
+ vatRate: 25.5,
23023
+ expand: (amount, opts) => {
23024
+ const { net, vat } = splitVat(amount, 25.5);
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
+ );
23037
+ }
23038
+ },
23039
+ // Purchase expenses
23040
+ {
23041
+ id: "purchase_expense",
23042
+ description: "Osto ALV 25,5% (pankkitililt\xE4)",
23043
+ vatRate: 25.5,
23044
+ expand: (amount, opts) => {
23045
+ const { net, vat } = splitVat(amount, 25.5);
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
+ );
23058
+ }
23059
+ },
23060
+ {
23061
+ id: "service_expense",
23062
+ description: "Ulkopuolinen palvelu ALV 25,5%",
23063
+ vatRate: 25.5,
23064
+ expand: (amount, opts) => {
23065
+ const { net, vat } = splitVat(amount, 25.5);
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
+ );
23078
+ }
23079
+ },
23080
+ // Rent (no VAT)
23081
+ {
23082
+ id: "rent_payment",
23083
+ description: "Vuokranmaksu (pankkitililt\xE4)",
23084
+ vatRate: null,
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
+ )
23100
+ },
23101
+ // Salary
23102
+ {
23103
+ id: "salary_payment",
23104
+ description: "Palkanmaksu (bruttopalkka, ennakonpid\xE4tys, TyEL)",
23105
+ vatRate: null,
23106
+ expand: (amount, opts) => {
23107
+ const gross = roundCents(amount);
23108
+ const withholdingRate = opts?.withholdingRate ?? 20;
23109
+ const tyelEmployeeRate = opts?.tyelRate ?? 7.3;
23110
+ const tyelEmployerRate = 17.55;
23111
+ const withholding = roundCents(gross * withholdingRate / 100);
23112
+ const tyelEmployee = roundCents(gross * tyelEmployeeRate / 100);
23113
+ const tyelEmployer = roundCents(gross * tyelEmployerRate / 100);
23114
+ const netPay = roundCents(gross - withholding - tyelEmployee);
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
+ );
23129
+ }
23130
+ },
23131
+ // Bank transfer
23132
+ {
23133
+ id: "bank_transfer",
23134
+ description: "Tilisiirto kassasta pankkitilille",
23135
+ vatRate: null,
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
+ )
23151
+ }
23152
+ ];
23153
+ var TEMPLATE_MAP = new Map(TEMPLATES.map((t) => [t.id, t]));
23154
+ function expandTemplate(templateId, amount, opts) {
23155
+ const template = TEMPLATE_MAP.get(templateId);
23156
+ if (!template) {
23157
+ return {
23158
+ error: `Unknown template "${templateId}". Use kirjapro_list_templates to see available templates.`
23159
+ };
23160
+ }
23161
+ if (amount <= 0) {
23162
+ return { error: "Amount must be positive." };
23163
+ }
23164
+ const entries = template.expand(amount, opts);
23165
+ return { entries };
23166
+ }
23167
+ async function resolveAndCreate(params) {
23168
+ const supabase2 = getSupabase();
23169
+ const companyId2 = getCompanyId();
23170
+ let fiscalYearId = params.fiscalYearId;
23171
+ if (!fiscalYearId) {
23172
+ const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
23173
+ if ("isError" in resolved) return resolved;
23174
+ fiscalYearId = resolved.id;
23175
+ }
23176
+ const { data: fy, error: fyError } = await supabase2.from("fiscal_years").select("id, is_closed, start_date, end_date").eq("id", fiscalYearId).eq("company_id", companyId2).single();
23177
+ if (fyError || !fy) {
23178
+ return toErrorContent(
23179
+ `Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard or read kirjapro://fiscal-years resource.`
23180
+ );
23181
+ }
23182
+ if (fy.is_closed) {
23183
+ return toErrorContent(
23184
+ `Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`
23185
+ );
23186
+ }
23187
+ if (params.transactionDate < fy.start_date || params.transactionDate > fy.end_date) {
23188
+ return toErrorContent(
23189
+ `Transaction date ${params.transactionDate} outside fiscal year (${fy.start_date} to ${fy.end_date}).`
23190
+ );
23191
+ }
23192
+ const accountNumbers = [
23193
+ ...new Set(params.entries.map((e) => e.accountNumber))
23194
+ ];
23195
+ const { data: accounts, error: accError } = await supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", accountNumbers);
23196
+ if (accError) {
23197
+ return toErrorContent(`Failed to resolve accounts: ${accError.message}`);
23198
+ }
23199
+ const accountMap = new Map(
23200
+ (accounts || []).map((a) => [a.account_number, a.id])
23201
+ );
23202
+ const missing = accountNumbers.filter((n) => !accountMap.has(n));
23203
+ if (missing.length > 0) {
23204
+ return toErrorContent(
23205
+ `Account numbers not found: ${missing.join(", ")}. Use kirjapro_list_accounts to see available accounts.`
23206
+ );
23207
+ }
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
+ );
23218
+ }
23219
+ const journalEntries = params.entries.map((e) => ({
23220
+ account_id: accountMap.get(e.accountNumber),
23221
+ debit_amount: e.debitAmount,
23222
+ credit_amount: e.creditAmount
23223
+ }));
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
+ }
23242
+ function registerTemplateTools(server2) {
23243
+ server2.tool(
23244
+ "kirjapro_list_templates",
23245
+ "List available transaction templates. Templates handle double-entry balancing and VAT splitting automatically.",
23246
+ {},
23247
+ async () => {
23248
+ const list = TEMPLATES.map((t) => ({
23249
+ id: t.id,
23250
+ description: t.description,
23251
+ vatRate: t.vatRate
23252
+ }));
23253
+ return toTextContent({ templates: list });
23254
+ }
23255
+ );
23256
+ server2.tool(
23257
+ "kirjapro_create_from_template",
23258
+ 'Create a transaction from a template. Use kirjapro_list_templates for available templates. accountOverrides swaps default accounts (e.g. {"2320": "2330"}).',
23259
+ {
23260
+ template: external_exports.string().describe('Template ID (e.g. "cash_sale_25", "purchase_expense")'),
23261
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
23262
+ transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
23263
+ amount: external_exports.number().positive().describe("Gross amount in EUR"),
23264
+ description: external_exports.string().min(1).max(500).describe("Transaction description"),
23265
+ documentNumber: external_exports.string().optional().describe("Document/receipt number"),
23266
+ withholdingRate: external_exports.number().min(0).max(100).optional().describe("Withholding tax % for salary_payment (default 20)"),
23267
+ tyelRate: external_exports.number().min(0).max(100).optional().describe("TyEL employee % for salary_payment (default 7.30)"),
23268
+ accountOverrides: external_exports.record(external_exports.string(), external_exports.string()).optional().describe('Account number swaps, e.g. {"2320": "2330"}')
23269
+ },
23270
+ async (params) => {
23271
+ const result = expandTemplate(params.template, params.amount, {
23272
+ withholdingRate: params.withholdingRate,
23273
+ tyelRate: params.tyelRate,
23274
+ accountOverrides: params.accountOverrides
23275
+ });
23276
+ if ("error" in result) {
23277
+ return toErrorContent(result.error);
23278
+ }
23279
+ const createResult = await resolveAndCreate({
23280
+ fiscalYearId: params.fiscalYearId,
23281
+ transactionDate: params.transactionDate,
23282
+ description: params.description,
23283
+ documentNumber: params.documentNumber,
23284
+ entries: result.entries
23285
+ });
23286
+ if ("isError" in createResult) {
23287
+ return createResult;
23288
+ }
23289
+ return toTextContent({
23290
+ message: "Transaction created from template",
23291
+ template: params.template,
23292
+ transaction_id: createResult.id,
23293
+ entries: result.entries
23294
+ });
23295
+ }
23296
+ );
23297
+ server2.tool(
23298
+ "kirjapro_create_from_template_batch",
23299
+ "Create multiple transactions from templates in one call (up to 50). Each is validated independently \u2014 valid ones succeed even if others fail.",
23300
+ {
23301
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
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)")
23314
+ },
23315
+ async (params) => {
23316
+ const created = [];
23317
+ const failed = [];
23318
+ for (let i = 0; i < params.transactions.length; i++) {
23319
+ const tx = params.transactions[i];
23320
+ const result = expandTemplate(tx.template, tx.amount, {
23321
+ withholdingRate: tx.withholdingRate,
23322
+ tyelRate: tx.tyelRate,
23323
+ accountOverrides: tx.accountOverrides
23324
+ });
23325
+ if ("error" in result) {
23326
+ failed.push({ index: i, error: result.error });
23327
+ continue;
23328
+ }
23329
+ const createResult = await resolveAndCreate({
23330
+ fiscalYearId: params.fiscalYearId,
23331
+ transactionDate: tx.transactionDate,
23332
+ description: tx.description,
23333
+ documentNumber: tx.documentNumber,
23334
+ entries: result.entries
23335
+ });
23336
+ if ("isError" in createResult) {
23337
+ failed.push({ index: i, error: createResult.content[0].text });
23338
+ continue;
23339
+ }
23340
+ created.push({
23341
+ id: createResult.id,
23342
+ description: tx.description,
23343
+ date: tx.transactionDate
23344
+ });
23345
+ }
23346
+ return toTextContent({
23347
+ created_count: created.length,
23348
+ failed_count: failed.length,
23349
+ created,
23350
+ failed
23351
+ });
23352
+ }
23353
+ );
23354
+ }
23355
+
23356
+ // src/tools/bookings.ts
23357
+ var DEFAULTS = {
23358
+ tyelEmployee: 7.3,
23359
+ tyelEmployer: 17.55,
23360
+ sotu: 1.91,
23361
+ ttvEmployer: 0.31,
23362
+ ttvEmployee: 0.89
23363
+ };
23364
+ function registerBookingTools(server2) {
23365
+ server2.tool(
23366
+ "kirjapro_book_purchase",
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%.",
23368
+ {
23369
+ amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
23370
+ description: external_exports.string().min(1).max(500).describe("Transaction description"),
23371
+ date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
23372
+ vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("Finnish VAT rate"),
23373
+ expenseAccount: external_exports.string().describe('Expense account number (e.g. "8080")'),
23374
+ reverseCharge: external_exports.boolean().default(false).describe("EU reverse charge: D expense + D 2140 + C 4050 + C bank"),
23375
+ edustus: external_exports.boolean().default(false).describe("Entertainment expense (AVL 114\xA7): 0% VAT deduction, full gross to expense"),
23376
+ paymentAccount: external_exports.string().default("2320").describe('Payment account (default "2320" Pankkitili)'),
23377
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
23378
+ documentNumber: external_exports.string().optional().describe("Document/receipt number")
23379
+ },
23380
+ async (params) => {
23381
+ const rate = Number.parseFloat(params.vatRate);
23382
+ const gross = roundCents(params.amount);
23383
+ let entries;
23384
+ if (params.edustus) {
23385
+ entries = [
23386
+ { accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
23387
+ { accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
23388
+ ];
23389
+ } else if (params.reverseCharge) {
23390
+ const vat = roundCents(gross * rate / 100);
23391
+ entries = [
23392
+ { accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
23393
+ { accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
23394
+ // ALV-saamiset (input)
23395
+ { accountNumber: "4050", debitAmount: 0, creditAmount: vat },
23396
+ // ALV-velka (output)
23397
+ { accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
23398
+ ];
23399
+ } else if (rate === 0) {
23400
+ entries = [
23401
+ { accountNumber: params.expenseAccount, debitAmount: gross, creditAmount: 0 },
23402
+ { accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
23403
+ ];
23404
+ } else {
23405
+ const { net, vat } = splitVat(gross, rate);
23406
+ entries = [
23407
+ { accountNumber: params.expenseAccount, debitAmount: net, creditAmount: 0 },
23408
+ { accountNumber: "2140", debitAmount: vat, creditAmount: 0 },
23409
+ { accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: gross }
23410
+ ];
23411
+ }
23412
+ const result = await resolveAndCreate({
23413
+ fiscalYearId: params.fiscalYearId,
23414
+ transactionDate: params.date,
23415
+ description: params.description,
23416
+ documentNumber: params.documentNumber,
23417
+ entries
23418
+ });
23419
+ if ("isError" in result) return result;
23420
+ return toTextContent({
23421
+ message: "Purchase booked",
23422
+ transaction_id: result.id,
23423
+ entries
23424
+ });
23425
+ }
23426
+ );
23427
+ server2.tool(
23428
+ "kirjapro_book_salary",
23429
+ "Book salary payment (palkanmaksu). Creates salary + employer cost transactions with withholding, TyEL, TTV, sotu. Default rates are 2026 Finnish social insurance rates.",
23430
+ {
23431
+ grossSalary: external_exports.number().positive().describe("Gross salary EUR"),
23432
+ employeeName: external_exports.string().min(1).describe("Employee name (for description)"),
23433
+ date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Payment date (YYYY-MM-DD)"),
23434
+ withholdingRate: external_exports.number().min(0).max(100).describe("Withholding tax % (ennakonpid\xE4tys)"),
23435
+ tyelEmployeeRate: external_exports.number().min(0).max(100).default(DEFAULTS.tyelEmployee).describe("TyEL employee % (default 7.30)"),
23436
+ tyelEmployerRate: external_exports.number().min(0).max(100).default(DEFAULTS.tyelEmployer).describe("TyEL employer % (default 17.55)"),
23437
+ sotuRate: external_exports.number().min(0).max(100).default(DEFAULTS.sotu).describe("Sairausvakuutusmaksu employer % (default 1.91)"),
23438
+ ttvEmployerRate: external_exports.number().min(0).max(100).default(DEFAULTS.ttvEmployer).describe("Ty\xF6tt\xF6myysvakuutus employer % (default 0.31)"),
23439
+ ttvEmployeeRate: external_exports.number().min(0).max(100).default(DEFAULTS.ttvEmployee).describe("Ty\xF6tt\xF6myysvakuutus employee % (default 0.89)"),
23440
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
23441
+ paymentAccount: external_exports.string().default("2320").describe('Bank account (default "2320")')
23442
+ },
23443
+ async (params) => {
23444
+ const gross = roundCents(params.grossSalary);
23445
+ const withholding = roundCents(gross * params.withholdingRate / 100);
23446
+ const tyelEmployee = roundCents(gross * params.tyelEmployeeRate / 100);
23447
+ const ttvEmployee = roundCents(gross * params.ttvEmployeeRate / 100);
23448
+ const netPay = roundCents(gross - withholding - tyelEmployee - ttvEmployee);
23449
+ const tyelEmployer = roundCents(gross * params.tyelEmployerRate / 100);
23450
+ const sotuAmount = roundCents(gross * params.sotuRate / 100);
23451
+ const ttvEmployer = roundCents(gross * params.ttvEmployerRate / 100);
23452
+ if (netPay < 0) {
23453
+ return toErrorContent(
23454
+ `Net pay is negative (${netPay.toFixed(2)}). Check withholding rate (${params.withholdingRate}%) and deduction rates.`
23455
+ );
23456
+ }
23457
+ const salaryEntries = [
23458
+ { accountNumber: "7010", debitAmount: gross, creditAmount: 0 },
23459
+ { accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: netPay },
23460
+ { accountNumber: "4060", debitAmount: 0, creditAmount: withholding },
23461
+ { accountNumber: "4080", debitAmount: 0, creditAmount: tyelEmployee },
23462
+ { accountNumber: "4070", debitAmount: 0, creditAmount: ttvEmployee }
23463
+ ];
23464
+ const salaryResult = await resolveAndCreate({
23465
+ fiscalYearId: params.fiscalYearId,
23466
+ transactionDate: params.date,
23467
+ description: `Palkka ${params.employeeName}`,
23468
+ entries: salaryEntries
23469
+ });
23470
+ if ("isError" in salaryResult) return salaryResult;
23471
+ const employerSotuTtv = roundCents(sotuAmount + ttvEmployer);
23472
+ const employerEntries = [
23473
+ { accountNumber: "7020", debitAmount: tyelEmployer, creditAmount: 0 },
23474
+ { accountNumber: "7030", debitAmount: employerSotuTtv, creditAmount: 0 },
23475
+ { accountNumber: "4080", debitAmount: 0, creditAmount: tyelEmployer },
23476
+ { accountNumber: "4070", debitAmount: 0, creditAmount: employerSotuTtv }
23477
+ ];
23478
+ const employerResult = await resolveAndCreate({
23479
+ fiscalYearId: params.fiscalYearId,
23480
+ transactionDate: params.date,
23481
+ description: `Sivukulut ${params.employeeName}`,
23482
+ entries: employerEntries
23483
+ });
23484
+ if ("isError" in employerResult) return employerResult;
23485
+ return toTextContent({
23486
+ message: "Salary booked (2 transactions)",
23487
+ salary_transaction_id: salaryResult.id,
23488
+ employer_costs_transaction_id: employerResult.id,
23489
+ summary: {
23490
+ gross,
23491
+ withholding,
23492
+ tyelEmployee,
23493
+ ttvEmployee,
23494
+ netPay,
23495
+ tyelEmployer,
23496
+ sotu: sotuAmount,
23497
+ ttvEmployer,
23498
+ totalEmployerCost: roundCents(gross + tyelEmployer + sotuAmount + ttvEmployer)
23499
+ }
23500
+ });
23501
+ }
23502
+ );
23503
+ server2.tool(
23504
+ "kirjapro_book_vat_settlement",
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.",
23506
+ {
23507
+ month: external_exports.string().regex(/^\d{4}-\d{2}$/).describe("Settlement month (YYYY-MM)"),
23508
+ fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
23509
+ settlementDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Settlement date (default: 12th of month+1)"),
23510
+ paymentAccount: external_exports.string().default("2320").describe('Bank account (default "2320")')
23511
+ },
23512
+ async (params) => {
23513
+ const supabase2 = getSupabase();
23514
+ const companyId2 = getCompanyId();
23515
+ let fiscalYearId = params.fiscalYearId;
23516
+ if (!fiscalYearId) {
23517
+ const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
23518
+ if ("isError" in resolved) return resolved;
23519
+ fiscalYearId = resolved.id;
23520
+ }
23521
+ const [year, monthStr] = params.month.split("-");
23522
+ const monthStart = `${params.month}-01`;
23523
+ const monthNum = Number.parseInt(monthStr, 10);
23524
+ const nextMonth = monthNum === 12 ? `${Number.parseInt(year, 10) + 1}-01` : `${year}-${String(monthNum + 1).padStart(2, "0")}`;
23525
+ const monthEnd = /* @__PURE__ */ new Date(`${nextMonth}-01`);
23526
+ monthEnd.setDate(monthEnd.getDate() - 1);
23527
+ const monthEndStr = monthEnd.toISOString().slice(0, 10);
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
+ }
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);
23534
+ if (err1) {
23535
+ return toErrorContent(`Failed to query ALV-saamiset: ${err1.message}`);
23536
+ }
23537
+ const { data: alvVelka, error: err2 } = await supabase2.from("journal_entries").select("debit_amount, credit_amount, accounts!inner(account_number), transactions!inner(transaction_date, company_id, fiscal_year_id)").eq("accounts.account_number", "4050").eq("transactions.company_id", companyId2).eq("transactions.fiscal_year_id", fiscalYearId).gte("transactions.transaction_date", monthStart).lte("transactions.transaction_date", monthEndStr);
23538
+ if (err2) {
23539
+ return toErrorContent(`Failed to query ALV-velka: ${err2.message}`);
23540
+ }
23541
+ const saamisetBalance = roundCents(
23542
+ (alvSaamiset || []).reduce((s, e) => s + (e.debit_amount || 0) - (e.credit_amount || 0), 0)
23543
+ );
23544
+ const velkaBalance = roundCents(
23545
+ (alvVelka || []).reduce((s, e) => s + (e.credit_amount || 0) - (e.debit_amount || 0), 0)
23546
+ );
23547
+ if (saamisetBalance === 0 && velkaBalance === 0) {
23548
+ return toTextContent({
23549
+ message: `No VAT entries found for ${params.month}. Nothing to settle.`,
23550
+ alv_saamiset: 0,
23551
+ alv_velka: 0
23552
+ });
23553
+ }
23554
+ const netVat = roundCents(velkaBalance - saamisetBalance);
23555
+ let entries;
23556
+ if (netVat > 0) {
23557
+ entries = [
23558
+ { accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
23559
+ { accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance },
23560
+ { accountNumber: params.paymentAccount, debitAmount: 0, creditAmount: netVat }
23561
+ ];
23562
+ } else if (netVat < 0) {
23563
+ const refund = Math.abs(netVat);
23564
+ entries = [
23565
+ { accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
23566
+ { accountNumber: params.paymentAccount, debitAmount: refund, creditAmount: 0 },
23567
+ { accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance }
23568
+ ];
23569
+ } else {
23570
+ entries = [
23571
+ { accountNumber: "4050", debitAmount: velkaBalance, creditAmount: 0 },
23572
+ { accountNumber: "2140", debitAmount: 0, creditAmount: saamisetBalance }
23573
+ ];
23574
+ }
23575
+ const result = await resolveAndCreate({
23576
+ fiscalYearId,
23577
+ transactionDate: settlementDate,
23578
+ description: `ALV-tilitys ${params.month}`,
23579
+ entries
23580
+ });
23581
+ if ("isError" in result) return result;
23582
+ return toTextContent({
23583
+ message: `VAT settlement booked for ${params.month}`,
23584
+ transaction_id: result.id,
23585
+ alv_saamiset: saamisetBalance,
23586
+ alv_velka: velkaBalance,
23587
+ net_vat: netVat,
23588
+ direction: netVat > 0 ? "pay" : netVat < 0 ? "refund" : "zero"
23589
+ });
23590
+ }
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
+ );
23647
+ }
23648
+
23649
+ // src/tools/offers.ts
23650
+ function generateOfferNumber(lastNumber, type) {
23651
+ const prefix = type === "sopimus" ? "SOP" : "TAR";
23652
+ if (!lastNumber) return `${prefix}00001`;
23653
+ const numPart = lastNumber.replace(/\D/g, "");
23654
+ const next = parseInt(numPart || "0") + 1;
23655
+ return `${prefix}${next.toString().padStart(5, "0")}`;
23656
+ }
23657
+ function registerOfferTools(server2) {
23658
+ server2.tool(
23659
+ "kirjapro_list_offers",
23660
+ "List offers/contracts with customer names. Filter by status (draft, sent, viewed, accepted, rejected, expired) and type (tarjous, sopimus).",
23661
+ {
23662
+ status: external_exports.enum(["draft", "sent", "viewed", "accepted", "rejected", "expired"]).optional().describe("Filter by offer status"),
23663
+ type: external_exports.enum(["tarjous", "sopimus"]).optional().describe("Filter by type (tarjous = offer, sopimus = contract)"),
23664
+ limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
23665
+ offset: external_exports.number().min(0).default(0).describe("Skip first N results")
23666
+ },
23667
+ async (params) => {
23668
+ const supabase2 = getSupabase();
23669
+ const companyId2 = getCompanyId();
23670
+ const { limit, offset } = clampPagination(params.limit, params.offset);
23671
+ let query = supabase2.from("offers").select(`
23672
+ id, offer_number, type, title, status, subtotal, vat_total, total,
23673
+ valid_from, valid_until, invoice_id, created_at,
23674
+ customers ( name, business_id )
23675
+ `, { count: "exact" }).eq("company_id", companyId2).order("created_at", { ascending: false });
23676
+ if (params.status) {
23677
+ query = query.eq("status", params.status);
23678
+ }
23679
+ if (params.type) {
23680
+ query = query.eq("type", params.type);
23681
+ }
23682
+ query = query.range(offset, offset + limit - 1);
23683
+ const { data, count, error: error2 } = await query;
23684
+ if (error2) {
23685
+ return toErrorContent(sanitizeDbError("Failed to list offers", error2));
23686
+ }
23687
+ return toTextContent(paginate(data || [], count || 0, offset, limit));
23688
+ }
23689
+ );
23690
+ server2.tool(
23691
+ "kirjapro_get_offer",
23692
+ "Get a single offer/contract with full detail: customer info, line items, events, and VAT breakdown.",
23693
+ {
23694
+ offerId: external_exports.string().uuid().describe("Offer UUID")
23695
+ },
23696
+ async (params) => {
23697
+ const supabase2 = getSupabase();
23698
+ const companyId2 = getCompanyId();
23699
+ const { data: offer, error: error2 } = await supabase2.from("offers").select(`
23700
+ id, offer_number, type, title, content, status,
23701
+ subtotal, vat_total, total, notes, valid_from, valid_until,
23702
+ invoice_id, sent_at, created_at,
23703
+ customers ( id, name, business_id, email, address, postal_code, city ),
23704
+ offer_lines ( id, line_number, description, quantity, unit, unit_price, vat_rate, subtotal, vat_amount, total ),
23705
+ offer_events ( id, event_type, created_at, metadata )
23706
+ `).eq("id", params.offerId).eq("company_id", companyId2).single();
23707
+ if (error2 || !offer) {
23708
+ return toErrorContent(
23709
+ `Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
23710
+ );
23711
+ }
23712
+ return toTextContent(offer);
23713
+ }
23714
+ );
23715
+ server2.tool(
23716
+ "kirjapro_create_offer",
23717
+ "Create a new offer (tarjous) or contract (sopimus) with line items. Automatically generates offer number and calculates VAT. Returns the created offer.",
23718
+ {
23719
+ type: external_exports.enum(["tarjous", "sopimus"]).default("tarjous").describe("Type: tarjous (offer) or sopimus (contract)"),
23720
+ title: external_exports.string().min(1).describe("Offer title"),
23721
+ customerId: external_exports.string().uuid().optional().describe("Customer UUID (optional)"),
23722
+ content: external_exports.string().optional().describe("Free-text content/description"),
23723
+ validFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Valid from date (YYYY-MM-DD)"),
23724
+ validUntil: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Valid until date (YYYY-MM-DD)"),
23725
+ notes: external_exports.string().optional().describe("Internal notes (not visible to customer)"),
23726
+ lines: external_exports.array(external_exports.object({
23727
+ description: external_exports.string().min(1).describe("Line item description"),
23728
+ quantity: external_exports.number().min(0).describe("Quantity"),
23729
+ unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
23730
+ unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
23731
+ vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
23732
+ })).min(1).describe("Line items")
23733
+ },
23734
+ async (params) => {
23735
+ const supabase2 = getSupabase();
23736
+ const companyId2 = getCompanyId();
23737
+ if (params.customerId) {
23738
+ const { data: customer, error: custError } = await supabase2.from("customers").select("id, name").eq("id", params.customerId).eq("company_id", companyId2).single();
23739
+ if (custError || !customer) {
23740
+ return toErrorContent(
23741
+ `Customer ${params.customerId} not found. Use kirjapro_list_customers to find customer IDs.`
23742
+ );
23743
+ }
23744
+ }
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();
23746
+ const offerNumber = generateOfferNumber(lastOffer?.offer_number || null, params.type);
23747
+ const calculatedLines = params.lines.map((line, i) => {
23748
+ const vatRate = parseFloat(line.vatRate);
23749
+ const subtotal2 = roundCents(line.quantity * line.unitPrice);
23750
+ const vatAmount = roundCents(subtotal2 * (vatRate / 100));
23751
+ const total2 = roundCents(subtotal2 + vatAmount);
23752
+ return {
23753
+ line_number: i + 1,
23754
+ description: line.description,
23755
+ quantity: line.quantity,
23756
+ unit: line.unit,
23757
+ unit_price: line.unitPrice,
23758
+ vat_rate: vatRate,
23759
+ subtotal: subtotal2,
23760
+ vat_amount: vatAmount,
23761
+ total: total2
23762
+ };
23763
+ });
23764
+ const subtotal = roundCents(calculatedLines.reduce((s, l) => s + l.subtotal, 0));
23765
+ const vatTotal = roundCents(calculatedLines.reduce((s, l) => s + l.vat_amount, 0));
23766
+ const total = roundCents(subtotal + vatTotal);
23767
+ const { data: offerId, error: rpcError } = await supabase2.rpc("create_offer_with_lines", {
23768
+ p_company_id: companyId2,
23769
+ p_customer_id: params.customerId || null,
23770
+ p_type: params.type,
23771
+ p_title: params.title,
23772
+ p_offer_number: offerNumber,
23773
+ p_content: params.content || null,
23774
+ p_status: "draft",
23775
+ p_valid_from: params.validFrom || null,
23776
+ p_valid_until: params.validUntil || null,
23777
+ p_notes: params.notes || null,
23778
+ p_subtotal: subtotal,
23779
+ p_vat_total: vatTotal,
23780
+ p_total: total,
23781
+ p_lines: calculatedLines
23782
+ });
23783
+ if (rpcError) {
23784
+ return toErrorContent(sanitizeDbError("Failed to create offer", rpcError));
23785
+ }
23786
+ return toTextContent({
23787
+ message: `${params.type === "sopimus" ? "Contract" : "Offer"} ${offerNumber} created`,
23788
+ offer_id: offerId,
23789
+ offer_number: offerNumber,
23790
+ type: params.type,
23791
+ title: params.title,
23792
+ subtotal,
23793
+ vat_total: vatTotal,
23794
+ total,
23795
+ lines: calculatedLines
23796
+ });
23797
+ }
23798
+ );
23799
+ server2.tool(
23800
+ "kirjapro_update_offer_status",
23801
+ "Update offer status with state machine validation. Valid transitions: draft\u2192sent, sent\u2192viewed/accepted/rejected/expired/draft, viewed\u2192accepted/rejected/expired/draft, accepted/rejected/expired\u2192draft.",
23802
+ {
23803
+ offerId: external_exports.string().uuid().describe("Offer UUID"),
23804
+ status: external_exports.enum(["draft", "sent", "viewed", "accepted", "rejected", "expired"]).describe("New status")
23805
+ },
23806
+ async (params) => {
23807
+ const supabase2 = getSupabase();
23808
+ const companyId2 = getCompanyId();
23809
+ const { data: offer, error: fetchError } = await supabase2.from("offers").select("id, offer_number, status, invoice_id").eq("id", params.offerId).eq("company_id", companyId2).single();
23810
+ if (fetchError || !offer) {
23811
+ return toErrorContent(
23812
+ `Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
23813
+ );
23814
+ }
23815
+ const currentStatus = offer.status;
23816
+ const newStatus = params.status;
23817
+ if (currentStatus === newStatus) {
23818
+ return toErrorContent(
23819
+ `Offer ${offer.offer_number} is already in status "${currentStatus}".`
23820
+ );
23821
+ }
23822
+ const validNext = VALID_OFFER_TRANSITIONS[currentStatus] || [];
23823
+ if (!validNext.includes(newStatus)) {
23824
+ return toErrorContent(
23825
+ `Cannot transition offer ${offer.offer_number} from "${currentStatus}" to "${newStatus}". Valid next statuses: ${validNext.length > 0 ? validNext.join(", ") : "(none)"}.`
23826
+ );
23827
+ }
23828
+ if (newStatus === "draft" && offer.invoice_id) {
23829
+ return toErrorContent(
23830
+ `Cannot revert offer ${offer.offer_number} to draft \u2014 it has already been converted to an invoice.`
23831
+ );
23832
+ }
23833
+ const updateFields = { status: newStatus };
23834
+ const timestampMap = {
23835
+ sent: "sent_at",
23836
+ viewed: "viewed_at",
23837
+ accepted: "accepted_at",
23838
+ rejected: "rejected_at"
23839
+ };
23840
+ if (timestampMap[newStatus]) {
23841
+ updateFields[timestampMap[newStatus]] = (/* @__PURE__ */ new Date()).toISOString();
23842
+ }
23843
+ const { error: updateError } = await supabase2.from("offers").update(updateFields).eq("id", params.offerId).eq("company_id", companyId2);
23844
+ if (updateError) {
23845
+ return toErrorContent(sanitizeDbError("Failed to update offer status", updateError));
23846
+ }
23847
+ const { error: eventError } = await supabase2.from("offer_events").insert({
23848
+ offer_id: params.offerId,
23849
+ event_type: `status_${newStatus}`
23850
+ });
23851
+ if (eventError) {
23852
+ console.error("Failed to log offer event:", eventError);
23853
+ }
23854
+ return toTextContent({
23855
+ message: `Offer ${offer.offer_number} status changed from "${currentStatus}" to "${newStatus}"`,
23856
+ offer_number: offer.offer_number,
23857
+ previous_status: currentStatus,
23858
+ new_status: newStatus
23859
+ });
23860
+ }
23861
+ );
23862
+ server2.tool(
23863
+ "kirjapro_delete_offer",
23864
+ 'Delete a draft offer/contract. Only offers with status "draft" can be deleted.',
23865
+ {
23866
+ offerId: external_exports.string().uuid().describe("Offer UUID")
23867
+ },
23868
+ async (params) => {
23869
+ const supabase2 = getSupabase();
23870
+ const companyId2 = getCompanyId();
23871
+ const { data: offer, error: fetchError } = await supabase2.from("offers").select("id, offer_number, status, type").eq("id", params.offerId).eq("company_id", companyId2).single();
23872
+ if (fetchError || !offer) {
23873
+ return toErrorContent(
23874
+ `Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
23875
+ );
23876
+ }
23877
+ if (offer.status !== "draft") {
23878
+ return toErrorContent(
23879
+ `Cannot delete offer ${offer.offer_number} \u2014 status is "${offer.status}". Only draft offers can be deleted.`
23880
+ );
23881
+ }
23882
+ const { error: deleteError } = await supabase2.from("offers").delete().eq("id", params.offerId).eq("company_id", companyId2);
23883
+ if (deleteError) {
23884
+ return toErrorContent(sanitizeDbError("Failed to delete offer", deleteError));
23885
+ }
23886
+ return toTextContent({
23887
+ message: `Draft ${offer.type === "sopimus" ? "contract" : "offer"} ${offer.offer_number} deleted`,
23888
+ offer_number: offer.offer_number
23889
+ });
23890
+ }
23891
+ );
23892
+ }
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
+
24214
+ // src/tools/index.ts
24215
+ function registerTools(server2) {
24216
+ registerAccountTools(server2);
24217
+ registerTransactionTools(server2);
24218
+ registerTemplateTools(server2);
24219
+ registerBookingTools(server2);
24220
+ registerInvoiceTools(server2);
24221
+ registerCustomerTools(server2);
24222
+ registerReportTools(server2);
24223
+ registerBankingTools(server2);
24224
+ registerValidationTools(server2);
24225
+ registerFiscalYearTools(server2);
24226
+ registerOfferTools(server2);
24227
+ registerRecurringInvoiceTools(server2);
24228
+ registerReceivedInvoiceTools(server2);
24229
+ }
24230
+
24231
+ // src/resources/schema.ts
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
+ };
24247
+ var VAT_RATES = [
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" }
24253
+ ];
24254
+ function registerSchemaResources(server2) {
24255
+ server2.resource(
24256
+ "chart-of-accounts",
24257
+ "kirjapro://accounts/chart",
24258
+ {
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.",
24260
+ mimeType: "application/json"
24261
+ },
24262
+ async (uri) => ({
24263
+ contents: [{
24264
+ uri: uri.href,
24265
+ mimeType: "application/json",
24266
+ text: JSON.stringify(CHART_OF_ACCOUNTS_SUMMARY)
24267
+ }]
24268
+ })
24269
+ );
22656
24270
  server2.resource(
22657
24271
  "vat-rates",
22658
24272
  "kirjapro://vat-rates",
22659
24273
  {
22660
- 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).",
22661
24275
  mimeType: "application/json"
22662
24276
  },
22663
24277
  async (uri) => ({
22664
24278
  contents: [{
22665
24279
  uri: uri.href,
22666
24280
  mimeType: "application/json",
22667
- text: JSON.stringify(VAT_RATES, null, 2)
24281
+ text: JSON.stringify(VAT_RATES)
22668
24282
  }]
22669
24283
  })
22670
24284
  );
@@ -22687,7 +24301,7 @@ function registerCompanyResources(server2) {
22687
24301
  contents: [{
22688
24302
  uri: uri.href,
22689
24303
  mimeType: "application/json",
22690
- text: JSON.stringify(company || {}, null, 2)
24304
+ text: JSON.stringify(company || {})
22691
24305
  }]
22692
24306
  };
22693
24307
  }
@@ -22703,27 +24317,341 @@ function registerCompanyResources(server2) {
22703
24317
  const supabase2 = getSupabase();
22704
24318
  const companyId2 = getCompanyId();
22705
24319
  const { data: fiscalYears } = await supabase2.from("fiscal_years").select("id, start_date, end_date, is_closed, created_at").eq("company_id", companyId2).order("start_date", { ascending: false });
24320
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
24321
+ const enriched = (fiscalYears || []).map((fy) => ({
24322
+ ...fy,
24323
+ is_current: !fy.is_closed && fy.start_date <= today && fy.end_date >= today
24324
+ }));
22706
24325
  return {
22707
24326
  contents: [{
22708
24327
  uri: uri.href,
22709
24328
  mimeType: "application/json",
22710
- text: JSON.stringify(fiscalYears || [], null, 2)
24329
+ text: JSON.stringify(enriched)
22711
24330
  }]
22712
24331
  };
22713
24332
  }
22714
24333
  );
22715
24334
  }
22716
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
+
22717
24644
  // src/resources/index.ts
22718
24645
  function registerResources(server2) {
22719
24646
  registerSchemaResources(server2);
22720
24647
  registerCompanyResources(server2);
24648
+ registerComplianceResources(server2);
22721
24649
  }
22722
24650
 
22723
24651
  // src/index.ts
22724
24652
  var server = new McpServer({
22725
24653
  name: "kirjapro",
22726
- version: "0.2.0"
24654
+ version: "0.5.0"
22727
24655
  });
22728
24656
  async function main() {
22729
24657
  try {