@kirjapro/mcp-server 0.5.5 → 0.5.6

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.
Files changed (3) hide show
  1. package/README.md +6 -5
  2. package/dist/index.js +672 -182
  3. package/package.json +8 -3
package/dist/index.js CHANGED
@@ -20980,6 +20980,27 @@ async function initLegacyAuth() {
20980
20980
  console.error(
20981
20981
  `[kirjapro-mcp] Authenticated as ${userData.user.email} | Company: ${company.name} | Tier: ${tier}`
20982
20982
  );
20983
+ try {
20984
+ const payload = JSON.parse(
20985
+ Buffer.from(supabaseAccessToken.split(".")[1], "base64url").toString()
20986
+ );
20987
+ if (typeof payload.exp === "number") {
20988
+ const expiresInMs = payload.exp * 1e3 - Date.now();
20989
+ if (expiresInMs > 0 && expiresInMs < 24 * 60 * 60 * 1e3) {
20990
+ const warnMs = Math.max(0, expiresInMs - 5 * 60 * 1e3);
20991
+ const timer = setTimeout(() => {
20992
+ console.error(
20993
+ "[kirjapro-mcp] WARNING: Legacy auth token expires in ~5 minutes. Restart with a fresh token or switch to KIRJAPRO_API_KEY for automatic refresh."
20994
+ );
20995
+ }, warnMs);
20996
+ timer.unref?.();
20997
+ console.error(
20998
+ `[kirjapro-mcp] Token expires in ${Math.round(expiresInMs / 6e4)} minutes. Consider switching to KIRJAPRO_API_KEY for automatic refresh.`
20999
+ );
21000
+ }
21001
+ }
21002
+ } catch {
21003
+ }
20983
21004
  }
20984
21005
  async function initAuth() {
20985
21006
  const apiKey = process.env.KIRJAPRO_API_KEY;
@@ -21020,6 +21041,18 @@ function formatCurrency(amount) {
21020
21041
  currency: "EUR"
21021
21042
  }).format(amount);
21022
21043
  }
21044
+ function helsinkiDateString(now = /* @__PURE__ */ new Date()) {
21045
+ const parts = new Intl.DateTimeFormat("fi-FI", {
21046
+ timeZone: "Europe/Helsinki",
21047
+ year: "numeric",
21048
+ month: "2-digit",
21049
+ day: "2-digit"
21050
+ }).formatToParts(now);
21051
+ const year = parts.find((p) => p.type === "year")?.value ?? "1970";
21052
+ const month = parts.find((p) => p.type === "month")?.value ?? "01";
21053
+ const day = parts.find((p) => p.type === "day")?.value ?? "01";
21054
+ return `${year}-${month}-${day}`;
21055
+ }
21023
21056
  function clampPagination(limit, offset) {
21024
21057
  const safeLimit = Math.min(Math.max(limit ?? 50, 1), 200);
21025
21058
  const safeOffset = Math.max(offset ?? 0, 0);
@@ -21145,6 +21178,18 @@ function tagUntrusted(value, maxLen = 500) {
21145
21178
  const cleaned = value.replace(ZERO_WIDTH, "").replace(CONTROL, " ").replace(INJECTION_MARKERS, "\u27E6redacted\u27E7").replace(/<\/?untrusted-external>/gi, "").slice(0, maxLen);
21146
21179
  return `<untrusted-external>${cleaned}</untrusted-external>`;
21147
21180
  }
21181
+ function tagCustomer(row) {
21182
+ if (row == null) return row;
21183
+ const wrapped = { ...row };
21184
+ if ("name" in row) wrapped.name = tagUntrusted(row.name);
21185
+ if ("contact_person" in row)
21186
+ wrapped.contact_person = tagUntrusted(row.contact_person);
21187
+ if ("address" in row) wrapped.address = tagUntrusted(row.address);
21188
+ if ("city" in row) wrapped.city = tagUntrusted(row.city);
21189
+ if ("email" in row) wrapped.email = tagUntrusted(row.email);
21190
+ if ("notes" in row) wrapped.notes = tagUntrusted(row.notes);
21191
+ return wrapped;
21192
+ }
21148
21193
  function addDaysToIsoDate(date3, days) {
21149
21194
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date3);
21150
21195
  if (!match) {
@@ -21181,37 +21226,76 @@ function toDryRunResponse(tool, preview) {
21181
21226
  }
21182
21227
  async function withIdempotency(supabase2, companyId2, toolName, key, fn) {
21183
21228
  if (!key) return fn();
21229
+ const CLAIM_STALE_MS = 6e4;
21230
+ const CLAIM_WAIT_MS = 1500;
21231
+ const filter = () => supabase2.from("mcp_idempotency_keys").select("response_payload, created_at").eq("company_id", companyId2).eq("tool_name", toolName).eq("key", key).maybeSingle();
21184
21232
  try {
21185
- const { data: existing } = await supabase2.from("mcp_idempotency_keys").select("response_payload").eq("company_id", companyId2).eq("key", key).maybeSingle();
21233
+ const { data: existing } = await filter();
21186
21234
  if (existing?.response_payload) {
21187
21235
  return existing.response_payload;
21188
21236
  }
21237
+ if (existing && !existing.response_payload) {
21238
+ const age = Date.now() - new Date(existing.created_at).getTime();
21239
+ if (age > CLAIM_STALE_MS) {
21240
+ await supabase2.from("mcp_idempotency_keys").delete().eq("company_id", companyId2).eq("tool_name", toolName).eq("key", key);
21241
+ } else {
21242
+ await new Promise((r) => setTimeout(r, CLAIM_WAIT_MS));
21243
+ const { data: retried } = await filter();
21244
+ if (retried?.response_payload) {
21245
+ return retried.response_payload;
21246
+ }
21247
+ }
21248
+ }
21189
21249
  } catch (err) {
21190
21250
  console.error("[kirjapro-mcp] idempotency lookup failed:", {
21191
21251
  tool: toolName,
21192
21252
  err: err.message
21193
21253
  });
21194
21254
  }
21255
+ let claimed = false;
21256
+ try {
21257
+ const { error: claimErr } = await supabase2.from("mcp_idempotency_keys").insert({
21258
+ company_id: companyId2,
21259
+ key,
21260
+ tool_name: toolName,
21261
+ response_payload: null
21262
+ });
21263
+ claimed = !claimErr;
21264
+ if (claimErr) {
21265
+ await new Promise((r) => setTimeout(r, CLAIM_WAIT_MS));
21266
+ const { data: retried } = await filter();
21267
+ if (retried?.response_payload) {
21268
+ return retried.response_payload;
21269
+ }
21270
+ }
21271
+ } catch (err) {
21272
+ console.error("[kirjapro-mcp] idempotency claim failed:", {
21273
+ tool: toolName,
21274
+ err: err.message
21275
+ });
21276
+ }
21195
21277
  const result = await fn();
21196
- if (!result.isError) {
21278
+ if (!result.isError && claimed) {
21197
21279
  try {
21198
- await supabase2.from("mcp_idempotency_keys").insert({
21199
- company_id: companyId2,
21200
- key,
21201
- tool_name: toolName,
21280
+ await supabase2.from("mcp_idempotency_keys").update({
21202
21281
  response_payload: result
21203
- });
21282
+ }).eq("company_id", companyId2).eq("tool_name", toolName).eq("key", key);
21204
21283
  } catch (err) {
21205
- console.error("[kirjapro-mcp] idempotency cache insert failed:", {
21284
+ console.error("[kirjapro-mcp] idempotency cache update failed:", {
21206
21285
  tool: toolName,
21207
21286
  err: err.message
21208
21287
  });
21209
21288
  }
21289
+ } else if (result.isError && claimed) {
21290
+ try {
21291
+ await supabase2.from("mcp_idempotency_keys").delete().eq("company_id", companyId2).eq("tool_name", toolName).eq("key", key);
21292
+ } catch {
21293
+ }
21210
21294
  }
21211
21295
  return result;
21212
21296
  }
21213
21297
  async function resolveOpenFiscalYear(supabase2, companyId2) {
21214
- const { data, error: error2 } = await supabase2.from("fiscal_years").select("id").eq("company_id", companyId2).eq("is_closed", false);
21298
+ const { data, error: error2 } = await supabase2.from("fiscal_years").select("id").eq("company_id", companyId2).eq("is_closed", false).order("end_date", { ascending: false }).limit(2);
21215
21299
  if (error2) {
21216
21300
  return toErrorContent(sanitizeDbError("Failed to query fiscal years", error2));
21217
21301
  }
@@ -21228,6 +21312,19 @@ async function resolveOpenFiscalYear(supabase2, companyId2) {
21228
21312
  return { id: data[0].id };
21229
21313
  }
21230
21314
 
21315
+ // src/schemas.ts
21316
+ var PAGINATED_OUTPUT_SHAPE = {
21317
+ results: external_exports.array(external_exports.record(external_exports.unknown())),
21318
+ total_count: external_exports.number(),
21319
+ has_more: external_exports.boolean(),
21320
+ next_offset: external_exports.number()
21321
+ };
21322
+ var VALIDATION_OUTPUT_SHAPE = {
21323
+ input: external_exports.string(),
21324
+ valid: external_exports.boolean(),
21325
+ error: external_exports.string().optional()
21326
+ };
21327
+
21231
21328
  // src/tools/accounts.ts
21232
21329
  function registerAccountTools(server2) {
21233
21330
  server2.registerTool(
@@ -21242,6 +21339,7 @@ function registerAccountTools(server2) {
21242
21339
  activeOnly: external_exports.boolean().default(true).describe("Only show active accounts"),
21243
21340
  includeIds: external_exports.boolean().default(false).describe("Include UUID id and parent_id fields (rarely needed)")
21244
21341
  },
21342
+ outputSchema: PAGINATED_OUTPUT_SHAPE,
21245
21343
  annotations: READ_ONLY_ANNOTATIONS
21246
21344
  },
21247
21345
  async (params) => {
@@ -21265,12 +21363,22 @@ function registerAccountTools(server2) {
21265
21363
  if (error2) {
21266
21364
  return toErrorContent(sanitizeDbError("Failed to list accounts", error2));
21267
21365
  }
21268
- return toTextContent(paginate(data || [], count || 0, offset, limit));
21366
+ return toResult(paginate(data || [], count || 0, offset, limit));
21269
21367
  }
21270
21368
  );
21271
21369
  }
21272
21370
 
21273
21371
  // src/tools/transactions.ts
21372
+ var TRANSACTION_DETAIL_OUTPUT_SHAPE = {
21373
+ id: external_exports.string(),
21374
+ transaction_date: external_exports.string(),
21375
+ description: external_exports.string().nullable(),
21376
+ document_number: external_exports.string().nullable(),
21377
+ journal_entries: external_exports.array(external_exports.record(external_exports.unknown())),
21378
+ total_debit: external_exports.number(),
21379
+ total_credit: external_exports.number(),
21380
+ balanced: external_exports.boolean()
21381
+ };
21274
21382
  var LIST_TRANSACTIONS_ROW_SHAPE = {
21275
21383
  id: external_exports.string().uuid(),
21276
21384
  transaction_date: external_exports.string(),
@@ -21366,6 +21474,7 @@ function registerTransactionTools(server2) {
21366
21474
  limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
21367
21475
  offset: external_exports.number().min(0).default(0).describe("Skip first N results")
21368
21476
  },
21477
+ outputSchema: PAGINATED_OUTPUT_SHAPE,
21369
21478
  annotations: READ_ONLY_ANNOTATIONS
21370
21479
  },
21371
21480
  async (params) => {
@@ -21443,12 +21552,12 @@ function registerTransactionTools(server2) {
21443
21552
  const filteredTotal = results.length;
21444
21553
  const paginatedResults = results.slice(offset, offset + limit);
21445
21554
  const cappedWarning = (count || 0) >= AMOUNT_FILTER_CAP ? " (results capped at 1000 rows \u2014 narrow your date range for complete results)" : "";
21446
- return toTextContent({
21555
+ return toResult({
21447
21556
  ...paginate(paginatedResults, filteredTotal, offset, limit),
21448
21557
  amount_filter_note: `Filtered client-side from ${count || 0} transactions${cappedWarning}`
21449
21558
  });
21450
21559
  }
21451
- return toTextContent(paginate(results, count || 0, offset, limit));
21560
+ return toResult(paginate(results, count || 0, offset, limit));
21452
21561
  }
21453
21562
  );
21454
21563
  server2.registerTool(
@@ -21463,8 +21572,8 @@ function registerTransactionTools(server2) {
21463
21572
  entries: external_exports.array(
21464
21573
  external_exports.object({
21465
21574
  accountNumber: external_exports.string().describe('Account number (e.g. "4000") or UUID'),
21466
- debitAmount: external_exports.number().min(0).default(0).describe("Debit amount in EUR"),
21467
- creditAmount: external_exports.number().min(0).default(0).describe("Credit amount in EUR")
21575
+ debitAmount: external_exports.number().finite().min(0).max(1e9).default(0).describe("Debit amount in EUR"),
21576
+ creditAmount: external_exports.number().finite().min(0).max(1e9).default(0).describe("Credit amount in EUR")
21468
21577
  })
21469
21578
  ).min(2).describe("Journal entries (min 2, must balance)"),
21470
21579
  idempotencyKey: IDEMPOTENCY_KEY_SCHEMA
@@ -21592,9 +21701,12 @@ function registerTransactionTools(server2) {
21592
21701
  server2.registerTool(
21593
21702
  "kirjapro_create_transactions_batch",
21594
21703
  {
21595
- description: "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.",
21704
+ description: 'Create multiple transactions in one call (up to 50). Default mode "atomic" rolls back ALL on any failure; "best_effort" collects per-row errors. Prefer kirjapro_create_from_template_batch for standard patterns.',
21596
21705
  inputSchema: {
21597
21706
  fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
21707
+ mode: external_exports.enum(["atomic", "best_effort"]).default("atomic").describe(
21708
+ 'Batch mode. "atomic" (default): all-or-nothing, rolls back on any row failure. "best_effort": commits what it can, returns per-row errors.'
21709
+ ),
21598
21710
  transactions: external_exports.array(
21599
21711
  external_exports.object({
21600
21712
  transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
@@ -21603,8 +21715,8 @@ function registerTransactionTools(server2) {
21603
21715
  entries: external_exports.array(
21604
21716
  external_exports.object({
21605
21717
  accountNumber: external_exports.string().describe('Account number (e.g. "4000")'),
21606
- debitAmount: external_exports.number().min(0).default(0),
21607
- creditAmount: external_exports.number().min(0).default(0)
21718
+ debitAmount: external_exports.number().finite().min(0).max(1e9).default(0),
21719
+ creditAmount: external_exports.number().finite().min(0).max(1e9).default(0)
21608
21720
  })
21609
21721
  ).min(2)
21610
21722
  })
@@ -21644,11 +21756,9 @@ function registerTransactionTools(server2) {
21644
21756
  )
21645
21757
  )
21646
21758
  ];
21647
- const uuidIdentifiers = allAccountNumbers.filter(
21648
- (identifier) => isUuid(identifier)
21649
- );
21759
+ const uuidIdentifiers = allAccountNumbers.filter((id) => isUuid(id));
21650
21760
  const numberIdentifiers = allAccountNumbers.filter(
21651
- (identifier) => !isUuid(identifier)
21761
+ (id) => !isUuid(id)
21652
21762
  );
21653
21763
  const [accountsByNumber, accountsById] = await Promise.all([
21654
21764
  numberIdentifiers.length > 0 ? supabase2.from("accounts").select("id, account_number").eq("company_id", companyId2).in("account_number", numberIdentifiers) : Promise.resolve({ data: [], error: null }),
@@ -21669,12 +21779,12 @@ function registerTransactionTools(server2) {
21669
21779
  accountMap.set(account.account_number, account.id);
21670
21780
  accountMap.set(account.id, account.id);
21671
21781
  }
21672
- const created = [];
21673
- const failed = [];
21782
+ const preValidated = [];
21783
+ const preFailed = [];
21674
21784
  for (let i = 0; i < params.transactions.length; i++) {
21675
21785
  const tx = params.transactions[i];
21676
21786
  if (tx.transactionDate < fy.start_date || tx.transactionDate > fy.end_date) {
21677
- failed.push({
21787
+ preFailed.push({
21678
21788
  index: i,
21679
21789
  error: `Date ${tx.transactionDate} outside fiscal year range.`
21680
21790
  });
@@ -21684,7 +21794,7 @@ function registerTransactionTools(server2) {
21684
21794
  (e) => !accountMap.has(e.accountNumber)
21685
21795
  );
21686
21796
  if (missing.length > 0) {
21687
- failed.push({
21797
+ preFailed.push({
21688
21798
  index: i,
21689
21799
  error: `Unknown accounts: ${missing.map((m) => m.accountNumber).join(", ")}`
21690
21800
  });
@@ -21697,46 +21807,78 @@ function registerTransactionTools(server2) {
21697
21807
  tx.entries.reduce((s, e) => s + e.creditAmount, 0)
21698
21808
  );
21699
21809
  if (totalDebit !== totalCredit) {
21700
- failed.push({
21810
+ preFailed.push({
21701
21811
  index: i,
21702
21812
  error: `Unbalanced: debits ${totalDebit} != credits ${totalCredit}`
21703
21813
  });
21704
21814
  continue;
21705
21815
  }
21706
- const entries = tx.entries.map((e) => ({
21707
- account_id: accountMap.get(e.accountNumber),
21708
- debit_amount: e.debitAmount,
21709
- credit_amount: e.creditAmount
21710
- }));
21711
- const { data: newTxId, error: txErr } = await supabase2.rpc(
21712
- "create_transaction_with_entries",
21713
- {
21714
- p_company_id: companyId2,
21715
- p_fiscal_year_id: fiscalYearId,
21716
- p_transaction_date: tx.transactionDate,
21717
- p_description: tx.description,
21718
- p_document_number: tx.documentNumber || "",
21719
- p_entries: entries
21720
- }
21721
- );
21722
- if (txErr || !newTxId) {
21723
- failed.push({
21724
- index: i,
21725
- error: txErr?.message || "No transaction ID returned"
21726
- });
21727
- continue;
21728
- }
21729
- created.push({
21730
- id: newTxId,
21816
+ preValidated.push({
21817
+ index: i,
21818
+ transaction_date: tx.transactionDate,
21731
21819
  description: tx.description,
21732
- date: tx.transactionDate
21820
+ document_number: tx.documentNumber || "",
21821
+ entries: tx.entries.map((e) => ({
21822
+ account_id: accountMap.get(e.accountNumber),
21823
+ debit_amount: e.debitAmount,
21824
+ credit_amount: e.creditAmount
21825
+ }))
21826
+ });
21827
+ }
21828
+ if (params.mode === "atomic" && preFailed.length > 0) {
21829
+ return toErrorContent(
21830
+ `Atomic batch aborted: ${preFailed.length} transaction(s) failed validation. Fix all errors or use mode "best_effort" for partial commits.
21831
+ ` + preFailed.map((f) => ` [${f.index}] ${f.error}`).join("\n")
21832
+ );
21833
+ }
21834
+ if (preValidated.length === 0) {
21835
+ return toTextContent({
21836
+ mode: params.mode,
21837
+ created_count: 0,
21838
+ failed_count: preFailed.length,
21839
+ created: [],
21840
+ failed: preFailed
21733
21841
  });
21734
21842
  }
21843
+ const rpcTransactions = preValidated.map((v) => ({
21844
+ transaction_date: v.transaction_date,
21845
+ description: v.description,
21846
+ document_number: v.document_number,
21847
+ entries: v.entries
21848
+ }));
21849
+ const { data: rpcResult, error: rpcError } = await supabase2.rpc(
21850
+ "create_transactions_batch_atomic",
21851
+ {
21852
+ p_company_id: companyId2,
21853
+ p_fiscal_year_id: fiscalYearId,
21854
+ p_transactions: rpcTransactions,
21855
+ p_mode: params.mode
21856
+ }
21857
+ );
21858
+ if (rpcError) {
21859
+ return toErrorContent(
21860
+ sanitizeDbError("Batch create failed", rpcError)
21861
+ );
21862
+ }
21863
+ const result = rpcResult;
21864
+ const created = result.created.map((c) => ({
21865
+ id: c.id,
21866
+ description: c.description,
21867
+ date: c.date,
21868
+ index: preValidated[c.index].index
21869
+ }));
21870
+ const rpcFailed = result.failed.map((f) => ({
21871
+ index: preValidated[f.index].index,
21872
+ error: sanitizeDbError("Failed to create transaction", {
21873
+ message: f.error
21874
+ })
21875
+ }));
21735
21876
  return toTextContent({
21877
+ mode: params.mode,
21736
21878
  created_count: created.length,
21737
- failed_count: failed.length,
21879
+ failed_count: preFailed.length + rpcFailed.length,
21738
21880
  created,
21739
- failed
21881
+ failed: [...preFailed, ...rpcFailed]
21740
21882
  });
21741
21883
  }
21742
21884
  );
@@ -21749,6 +21891,7 @@ function registerTransactionTools(server2) {
21749
21891
  inputSchema: {
21750
21892
  transactionId: external_exports.string().uuid().describe("Transaction UUID")
21751
21893
  },
21894
+ outputSchema: TRANSACTION_DETAIL_OUTPUT_SHAPE,
21752
21895
  annotations: READ_ONLY_ANNOTATIONS
21753
21896
  },
21754
21897
  async (params) => {
@@ -21775,7 +21918,7 @@ function registerTransactionTools(server2) {
21775
21918
  const totalCredit = roundCents(
21776
21919
  entries.reduce((s, e) => s + (e.credit_amount || 0), 0)
21777
21920
  );
21778
- return toTextContent({
21921
+ return toResult({
21779
21922
  ...transaction,
21780
21923
  total_debit: totalDebit,
21781
21924
  total_credit: totalCredit,
@@ -21875,11 +22018,7 @@ function registerInvoiceTools(server2) {
21875
22018
  has_more: external_exports.boolean(),
21876
22019
  next_offset: external_exports.number()
21877
22020
  },
21878
- annotations: {
21879
- readOnlyHint: true,
21880
- idempotentHint: true,
21881
- openWorldHint: false
21882
- }
22021
+ annotations: READ_ONLY_ANNOTATIONS
21883
22022
  },
21884
22023
  async (params) => {
21885
22024
  const supabase2 = getSupabase();
@@ -21901,7 +22040,13 @@ function registerInvoiceTools(server2) {
21901
22040
  if (error2) {
21902
22041
  return toErrorContent(sanitizeDbError("Failed to list invoices", error2));
21903
22042
  }
21904
- return toResult(paginate(data || [], count || 0, offset, limit));
22043
+ const tagged = (data || []).map((row) => ({
22044
+ ...row,
22045
+ customers: tagCustomer(
22046
+ row.customers
22047
+ )
22048
+ }));
22049
+ return toResult(paginate(tagged, count || 0, offset, limit));
21905
22050
  }
21906
22051
  );
21907
22052
  server2.registerTool(
@@ -21918,7 +22063,7 @@ function registerInvoiceTools(server2) {
21918
22063
  description: external_exports.string().min(1).describe("Line item description"),
21919
22064
  quantity: external_exports.number().min(0).describe("Quantity"),
21920
22065
  unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
21921
- unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
22066
+ unitPrice: external_exports.number().finite().min(-1e9).max(1e9).describe("Unit price in EUR (excl. VAT). Can be negative for credit lines."),
21922
22067
  vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
21923
22068
  })
21924
22069
  ).min(1).describe("Invoice line items"),
@@ -22032,7 +22177,7 @@ function registerInvoiceTools(server2) {
22032
22177
  inputSchema: {
22033
22178
  invoiceId: external_exports.string().uuid().describe("Invoice UUID"),
22034
22179
  paymentDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Payment date (YYYY-MM-DD)"),
22035
- amount: external_exports.number().min(0.01).describe("Payment amount in EUR"),
22180
+ amount: external_exports.number().finite().min(0.01).max(1e9).describe("Payment amount in EUR"),
22036
22181
  paymentMethod: external_exports.string().default("bank_transfer").describe("Payment method (bank_transfer, cash, card)"),
22037
22182
  reference: external_exports.string().optional().describe("Payment reference"),
22038
22183
  notes: external_exports.string().optional().describe("Payment notes"),
@@ -22118,11 +22263,7 @@ function registerInvoiceTools(server2) {
22118
22263
  invoiceId: external_exports.string().uuid().describe("Invoice UUID")
22119
22264
  },
22120
22265
  outputSchema: GET_INVOICE_OUTPUT_SHAPE,
22121
- annotations: {
22122
- readOnlyHint: true,
22123
- idempotentHint: true,
22124
- openWorldHint: false
22125
- }
22266
+ annotations: READ_ONLY_ANNOTATIONS
22126
22267
  },
22127
22268
  async (params) => {
22128
22269
  const supabase2 = getSupabase();
@@ -22167,6 +22308,9 @@ function registerInvoiceTools(server2) {
22167
22308
  const remainingBalance = roundCents(invoice.total - paidTotal);
22168
22309
  return toResult({
22169
22310
  ...invoice,
22311
+ customers: tagCustomer(
22312
+ invoice.customers
22313
+ ),
22170
22314
  vat_breakdown: vatBreakdown,
22171
22315
  paid_total: paidTotal,
22172
22316
  remaining_balance: remainingBalance
@@ -22223,14 +22367,19 @@ function registerInvoiceTools(server2) {
22223
22367
  async () => {
22224
22368
  const updateFields = { status: newStatus };
22225
22369
  if (newStatus === "paid") {
22226
- updateFields.paid_date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
22370
+ updateFields.paid_date = helsinkiDateString();
22227
22371
  }
22228
- const { error: updateError } = await supabase2.from("invoices").update(updateFields).eq("id", params.invoiceId).eq("company_id", companyId2);
22372
+ const { data: updated, error: updateError } = await supabase2.from("invoices").update(updateFields).eq("id", params.invoiceId).eq("company_id", companyId2).eq("status", currentStatus).select("id").maybeSingle();
22229
22373
  if (updateError) {
22230
22374
  return toErrorContent(
22231
22375
  sanitizeDbError("Failed to update invoice status", updateError)
22232
22376
  );
22233
22377
  }
22378
+ if (!updated) {
22379
+ return toErrorContent(
22380
+ `Invoice ${invoice.invoice_number} status changed concurrently (was "${currentStatus}" when read). Re-read with kirjapro_get_invoice and decide whether to retry.`
22381
+ );
22382
+ }
22234
22383
  return toTextContent({
22235
22384
  message: `Invoice ${invoice.invoice_number} status changed from "${currentStatus}" to "${newStatus}"`,
22236
22385
  invoice_number: invoice.invoice_number,
@@ -22279,22 +22428,16 @@ function registerInvoiceTools(server2) {
22279
22428
  "kirjapro_delete_invoice",
22280
22429
  params.idempotencyKey,
22281
22430
  async () => {
22282
- const { error: paymentsError } = await supabase2.from("invoice_payments").delete().eq("invoice_id", params.invoiceId);
22283
- if (paymentsError) {
22284
- return toErrorContent(
22285
- sanitizeDbError("Failed to delete invoice payments", paymentsError)
22286
- );
22287
- }
22288
- const { error: linesError } = await supabase2.from("invoice_lines").delete().eq("invoice_id", params.invoiceId);
22289
- if (linesError) {
22290
- return toErrorContent(
22291
- sanitizeDbError("Failed to delete invoice lines", linesError)
22292
- );
22293
- }
22294
- const { error: deleteError } = await supabase2.from("invoices").delete().eq("id", params.invoiceId);
22295
- if (deleteError) {
22431
+ const { error: rpcError } = await supabase2.rpc(
22432
+ "delete_draft_invoice_atomic",
22433
+ {
22434
+ p_company_id: companyId2,
22435
+ p_invoice_id: params.invoiceId
22436
+ }
22437
+ );
22438
+ if (rpcError) {
22296
22439
  return toErrorContent(
22297
- sanitizeDbError("Failed to delete invoice", deleteError)
22440
+ sanitizeDbError("Failed to delete invoice", rpcError)
22298
22441
  );
22299
22442
  }
22300
22443
  return toTextContent({
@@ -22308,6 +22451,34 @@ function registerInvoiceTools(server2) {
22308
22451
  }
22309
22452
 
22310
22453
  // src/tools/customers.ts
22454
+ var CUSTOMER_DETAIL_OUTPUT_SHAPE = {
22455
+ id: external_exports.string(),
22456
+ customer_number: external_exports.number(),
22457
+ name: external_exports.string(),
22458
+ business_id: external_exports.string().nullable(),
22459
+ email: external_exports.string().nullable(),
22460
+ payment_term_days: external_exports.number(),
22461
+ is_active: external_exports.boolean(),
22462
+ stats: external_exports.object({
22463
+ invoice_count: external_exports.number(),
22464
+ total_revenue: external_exports.number(),
22465
+ total_revenue_formatted: external_exports.string(),
22466
+ outstanding_balance: external_exports.number(),
22467
+ outstanding_balance_formatted: external_exports.string()
22468
+ }).passthrough()
22469
+ };
22470
+ var PRH_LOOKUP_OUTPUT_SHAPE = {
22471
+ business_id: external_exports.string(),
22472
+ name: external_exports.string(),
22473
+ registration_date: external_exports.string().nullable(),
22474
+ company_form: external_exports.string().nullable(),
22475
+ address: external_exports.string().nullable(),
22476
+ postal_code: external_exports.string().nullable(),
22477
+ city: external_exports.string().nullable(),
22478
+ country: external_exports.string().nullable(),
22479
+ website: external_exports.string().nullable(),
22480
+ phone: external_exports.string().nullable()
22481
+ };
22311
22482
  function validateFinnishBusinessId(input) {
22312
22483
  const match = input.match(/^(\d{7})-(\d)$/);
22313
22484
  if (!match) return false;
@@ -22336,6 +22507,7 @@ function registerCustomerTools(server2) {
22336
22507
  offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
22337
22508
  search: external_exports.string().optional().describe("Search by name, email, or business ID")
22338
22509
  },
22510
+ outputSchema: PAGINATED_OUTPUT_SHAPE,
22339
22511
  annotations: READ_ONLY_ANNOTATIONS
22340
22512
  },
22341
22513
  async (params) => {
@@ -22357,7 +22529,7 @@ function registerCustomerTools(server2) {
22357
22529
  if (error2) {
22358
22530
  return toErrorContent(sanitizeDbError("Failed to list customers", error2));
22359
22531
  }
22360
- return toTextContent(paginate(data || [], count || 0, offset, limit));
22532
+ return toResult(paginate((data || []).map((c) => tagCustomer(c)), count || 0, offset, limit));
22361
22533
  }
22362
22534
  );
22363
22535
  server2.registerTool(
@@ -22416,13 +22588,13 @@ function registerCustomerTools(server2) {
22416
22588
  city: sanitizeText(params.city),
22417
22589
  country: params.country,
22418
22590
  payment_term_days: params.paymentTermDays
22419
- }).select().single();
22591
+ }).select("id, customer_number, name, business_id, contact_person, email, phone, address, postal_code, city, country, payment_term_days, notes, created_at").single();
22420
22592
  if (error2) {
22421
22593
  return toErrorContent(sanitizeDbError("Failed to create customer", error2));
22422
22594
  }
22423
22595
  return toTextContent({
22424
22596
  message: `Customer "${params.name}" created with number ${customerNumber}`,
22425
- customer
22597
+ customer: tagCustomer(customer)
22426
22598
  });
22427
22599
  }
22428
22600
  );
@@ -22435,6 +22607,7 @@ function registerCustomerTools(server2) {
22435
22607
  inputSchema: {
22436
22608
  customerId: external_exports.string().uuid().describe("Customer UUID")
22437
22609
  },
22610
+ outputSchema: CUSTOMER_DETAIL_OUTPUT_SHAPE,
22438
22611
  annotations: READ_ONLY_ANNOTATIONS
22439
22612
  },
22440
22613
  async (params) => {
@@ -22456,8 +22629,8 @@ function registerCustomerTools(server2) {
22456
22629
  return s + (i.total || 0) - paid;
22457
22630
  }, 0)
22458
22631
  );
22459
- return toTextContent({
22460
- ...customer,
22632
+ return toResult({
22633
+ ...tagCustomer(customer),
22461
22634
  stats: {
22462
22635
  invoice_count: invoiceCount,
22463
22636
  total_revenue: totalRevenue,
@@ -22522,13 +22695,13 @@ function registerCustomerTools(server2) {
22522
22695
  "kirjapro_update_customer",
22523
22696
  params.idempotencyKey,
22524
22697
  async () => {
22525
- const { data: updated, error: updateError } = await supabase2.from("customers").update(updates).eq("id", params.customerId).eq("company_id", companyId2).select().single();
22698
+ const { data: updated, error: updateError } = await supabase2.from("customers").update(updates).eq("id", params.customerId).eq("company_id", companyId2).select("id, customer_number, name, business_id, contact_person, email, phone, address, postal_code, city, country, payment_term_days, notes, created_at, updated_at").single();
22526
22699
  if (updateError) {
22527
22700
  return toErrorContent(sanitizeDbError("Failed to update customer", updateError));
22528
22701
  }
22529
22702
  return toTextContent({
22530
22703
  message: `Customer "${updated.name}" updated`,
22531
- customer: updated,
22704
+ customer: tagCustomer(updated),
22532
22705
  fields_changed: Object.keys(updates)
22533
22706
  });
22534
22707
  }
@@ -22542,6 +22715,7 @@ function registerCustomerTools(server2) {
22542
22715
  inputSchema: {
22543
22716
  businessId: external_exports.string().regex(/^\d{7}-\d$/).describe('Finnish Y-tunnus (e.g. "1234567-8")')
22544
22717
  },
22718
+ outputSchema: PRH_LOOKUP_OUTPUT_SHAPE,
22545
22719
  annotations: READ_ONLY_EXTERNAL_ANNOTATIONS
22546
22720
  },
22547
22721
  async (params) => {
@@ -22560,7 +22734,7 @@ function registerCustomerTools(server2) {
22560
22734
  return toErrorContent(result.error);
22561
22735
  }
22562
22736
  const company = result.data.company;
22563
- return toTextContent({
22737
+ return toResult({
22564
22738
  business_id: company.businessId,
22565
22739
  name: tagUntrusted(company.name),
22566
22740
  registration_date: company.registrationDate,
@@ -22595,8 +22769,11 @@ var DASHBOARD_OUTPUT_SHAPE = {
22595
22769
  invoice_stats: external_exports.object({
22596
22770
  draft: INVOICE_STAT_BUCKET,
22597
22771
  sent: INVOICE_STAT_BUCKET,
22772
+ partial: INVOICE_STAT_BUCKET,
22598
22773
  paid: INVOICE_STAT_BUCKET,
22599
- overdue: INVOICE_STAT_BUCKET
22774
+ overdue: INVOICE_STAT_BUCKET,
22775
+ cancelled: INVOICE_STAT_BUCKET,
22776
+ no_due_date: INVOICE_STAT_BUCKET
22600
22777
  }),
22601
22778
  bank_summary: external_exports.object({
22602
22779
  accounts: external_exports.array(
@@ -22636,9 +22813,10 @@ var INCOME_STATEMENT_OUTPUT_SHAPE = {
22636
22813
  };
22637
22814
  var ALV_SUMMARY_OUTPUT_SHAPE = {
22638
22815
  period: external_exports.object({ from: external_exports.string(), to: external_exports.string() }),
22639
- output_vat: external_exports.unknown(),
22640
- input_vat: external_exports.unknown(),
22641
- net_vat: external_exports.number().optional(),
22816
+ output_vat: external_exports.unknown().optional(),
22817
+ input_vat: external_exports.unknown().optional(),
22818
+ net_vat_payable: external_exports.number().optional(),
22819
+ net_vat_payable_formatted: external_exports.string().optional(),
22642
22820
  omavero_fields: external_exports.unknown().optional(),
22643
22821
  warning: external_exports.string().optional(),
22644
22822
  message: external_exports.string().optional()
@@ -22687,6 +22865,11 @@ var AGING_BUCKET_SHAPE = external_exports.object({
22687
22865
  });
22688
22866
  var AR_AGING_OUTPUT_SHAPE = {
22689
22867
  buckets: external_exports.array(AGING_BUCKET_SHAPE),
22868
+ no_due_date: external_exports.object({
22869
+ count: external_exports.number(),
22870
+ total: external_exports.number(),
22871
+ invoices: external_exports.array(external_exports.record(external_exports.unknown()))
22872
+ }).passthrough(),
22690
22873
  total_receivables: external_exports.number(),
22691
22874
  total_receivables_formatted: external_exports.string(),
22692
22875
  days_sales_outstanding: external_exports.number()
@@ -22712,7 +22895,9 @@ var GENERAL_LEDGER_OUTPUT_SHAPE = {
22712
22895
  entry_count: external_exports.number(),
22713
22896
  opening_balance: external_exports.number(),
22714
22897
  closing_balance: external_exports.number(),
22715
- closing_balance_formatted: external_exports.string()
22898
+ closing_balance_formatted: external_exports.string(),
22899
+ capped: external_exports.boolean(),
22900
+ capped_note: external_exports.string().nullable()
22716
22901
  };
22717
22902
  function registerReportTools(server2) {
22718
22903
  server2.registerTool(
@@ -22767,20 +22952,31 @@ function registerReportTools(server2) {
22767
22952
  expenses += (entry.debit_amount || 0) - (entry.credit_amount || 0);
22768
22953
  }
22769
22954
  }
22770
- const now = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
22955
+ const now = helsinkiDateString();
22771
22956
  const invoices = invoiceResult.data || [];
22772
22957
  const invoiceStats = {
22773
22958
  draft: { count: 0, total: 0 },
22774
22959
  sent: { count: 0, total: 0 },
22960
+ partial: { count: 0, total: 0 },
22775
22961
  paid: { count: 0, total: 0 },
22776
- overdue: { count: 0, total: 0 }
22962
+ overdue: { count: 0, total: 0 },
22963
+ cancelled: { count: 0, total: 0 },
22964
+ no_due_date: { count: 0, total: 0 }
22777
22965
  };
22778
22966
  for (const inv of invoices) {
22779
- const status = inv.status === "sent" && inv.due_date < now ? "overdue" : inv.status;
22780
- if (status in invoiceStats) {
22781
- invoiceStats[status].count++;
22782
- invoiceStats[status].total += inv.total || 0;
22967
+ const hasDueDate = typeof inv.due_date === "string" && inv.due_date.length > 0;
22968
+ let status;
22969
+ if (inv.status === "sent" && !hasDueDate) {
22970
+ status = "no_due_date";
22971
+ } else if (inv.status === "sent" && hasDueDate && inv.due_date < now) {
22972
+ status = "overdue";
22973
+ } else if (inv.status in invoiceStats) {
22974
+ status = inv.status;
22975
+ } else {
22976
+ continue;
22783
22977
  }
22978
+ invoiceStats[status].count++;
22979
+ invoiceStats[status].total += inv.total || 0;
22784
22980
  }
22785
22981
  const bankAccounts = (bankResult.data || []).map((b) => ({
22786
22982
  name: b.account_name || "Unnamed",
@@ -23012,15 +23208,40 @@ function registerReportTools(server2) {
23012
23208
  ];
23013
23209
  let totalReceivables = 0;
23014
23210
  let weightedDays = 0;
23211
+ const noDueDate = [];
23015
23212
  for (const inv of invoices || []) {
23016
- const dueDate = new Date(inv.due_date);
23017
- const daysOverdue = Math.max(0, Math.floor((now.getTime() - dueDate.getTime()) / 864e5));
23018
- const bucket = buckets.find((b) => daysOverdue >= b.min && daysOverdue <= b.max);
23213
+ if (!inv.due_date) {
23214
+ noDueDate.push({
23215
+ invoice_number: inv.invoice_number,
23216
+ customer: tagUntrusted(inv.customers?.name),
23217
+ total: inv.total || 0
23218
+ });
23219
+ totalReceivables += inv.total || 0;
23220
+ continue;
23221
+ }
23222
+ const dueMs = Date.parse(inv.due_date);
23223
+ if (Number.isNaN(dueMs)) {
23224
+ noDueDate.push({
23225
+ invoice_number: inv.invoice_number,
23226
+ customer: tagUntrusted(inv.customers?.name),
23227
+ total: inv.total || 0
23228
+ });
23229
+ totalReceivables += inv.total || 0;
23230
+ continue;
23231
+ }
23232
+ const daysOverdue = Math.max(
23233
+ 0,
23234
+ Math.floor((now.getTime() - dueMs) / 864e5)
23235
+ );
23236
+ const bucket = buckets.find(
23237
+ (b) => daysOverdue >= b.min && daysOverdue <= b.max
23238
+ );
23239
+ if (!bucket) continue;
23019
23240
  bucket.count++;
23020
23241
  bucket.total += inv.total || 0;
23021
23242
  bucket.invoices.push({
23022
23243
  invoice_number: inv.invoice_number,
23023
- customer: inv.customers?.name,
23244
+ customer: tagUntrusted(inv.customers?.name),
23024
23245
  total: inv.total,
23025
23246
  due_date: inv.due_date,
23026
23247
  days_overdue: daysOverdue
@@ -23037,6 +23258,13 @@ function registerReportTools(server2) {
23037
23258
  total_formatted: formatCurrency(b.total),
23038
23259
  invoices: b.invoices
23039
23260
  })),
23261
+ no_due_date: {
23262
+ count: noDueDate.length,
23263
+ total: roundCents(
23264
+ noDueDate.reduce((s, i) => s + (i.total || 0), 0)
23265
+ ),
23266
+ invoices: noDueDate
23267
+ },
23040
23268
  total_receivables: roundCents(totalReceivables),
23041
23269
  total_receivables_formatted: formatCurrency(totalReceivables),
23042
23270
  days_sales_outstanding: dso
@@ -23073,10 +23301,11 @@ function registerReportTools(server2) {
23073
23301
  `Account "${params.accountNumber}" not found. Use kirjapro_list_accounts to see available accounts.`
23074
23302
  );
23075
23303
  }
23304
+ const LEDGER_FETCH_LIMIT = 5e3;
23076
23305
  let query = supabase2.from("journal_entries").select(`
23077
23306
  debit_amount, credit_amount,
23078
23307
  transaction:transactions!inner(id, transaction_date, description, document_number, fiscal_year_id, company_id)
23079
- `).eq("account_id", account.id).eq("transaction.fiscal_year_id", fiscalYearId).eq("transaction.company_id", companyId2).order("transaction(transaction_date)", { ascending: true }).order("id", { ascending: true });
23308
+ `).eq("account_id", account.id).eq("transaction.fiscal_year_id", fiscalYearId).eq("transaction.company_id", companyId2).order("transaction(transaction_date)", { ascending: true }).order("id", { ascending: true }).range(0, LEDGER_FETCH_LIMIT);
23080
23309
  if (params.dateFrom) {
23081
23310
  query = query.gte("transaction.transaction_date", params.dateFrom);
23082
23311
  }
@@ -23089,9 +23318,27 @@ function registerReportTools(server2) {
23089
23318
  }
23090
23319
  const debitNormal = ["asset", "expense"];
23091
23320
  const isDebitNormal = debitNormal.includes(account.account_type);
23092
- let runningBalance = 0;
23321
+ let openingBalance = 0;
23322
+ if (params.dateFrom) {
23323
+ const { data: priorEntries, error: priorErr } = await supabase2.from("journal_entries").select(`
23324
+ debit_amount, credit_amount,
23325
+ transaction:transactions!inner(fiscal_year_id, company_id, transaction_date)
23326
+ `).eq("account_id", account.id).eq("transaction.fiscal_year_id", fiscalYearId).eq("transaction.company_id", companyId2).lt("transaction.transaction_date", params.dateFrom);
23327
+ if (!priorErr && priorEntries) {
23328
+ for (const e of priorEntries) {
23329
+ const d = e.debit_amount || 0;
23330
+ const c = e.credit_amount || 0;
23331
+ openingBalance += isDebitNormal ? d - c : c - d;
23332
+ }
23333
+ openingBalance = roundCents(openingBalance);
23334
+ }
23335
+ }
23336
+ const fetched = entries || [];
23337
+ const capped = fetched.length > LEDGER_FETCH_LIMIT;
23338
+ const consumed = capped ? fetched.slice(0, LEDGER_FETCH_LIMIT) : fetched;
23339
+ let runningBalance = openingBalance;
23093
23340
  const ledgerEntries = [];
23094
- for (const entry of entries || []) {
23341
+ for (const entry of consumed) {
23095
23342
  const tx = entry.transaction;
23096
23343
  const debit = entry.debit_amount || 0;
23097
23344
  const credit = entry.credit_amount || 0;
@@ -23119,9 +23366,11 @@ function registerReportTools(server2) {
23119
23366
  },
23120
23367
  entries: ledgerEntries,
23121
23368
  entry_count: ledgerEntries.length,
23122
- opening_balance: 0,
23369
+ opening_balance: openingBalance,
23123
23370
  closing_balance: roundCents(runningBalance),
23124
- closing_balance_formatted: formatCurrency(runningBalance)
23371
+ closing_balance_formatted: formatCurrency(runningBalance),
23372
+ capped,
23373
+ capped_note: capped ? `Result truncated at ${LEDGER_FETCH_LIMIT} entries. Closing balance is computed from fetched rows only and may not reflect the full fiscal-year total. Narrow the date range with dateFrom/dateTo to see the remainder.` : null
23125
23374
  });
23126
23375
  }
23127
23376
  );
@@ -23253,13 +23502,12 @@ function registerBankingTools(server2) {
23253
23502
  matchError ? sanitizeDbError("Failed to confirm reconciliation match", matchError) : "Failed to confirm reconciliation match."
23254
23503
  );
23255
23504
  }
23256
- const newPaidTotal = roundCents(paidSoFar + paymentAmount);
23257
- const newStatus = newPaidTotal >= invoice.total ? "paid" : "partial";
23505
+ const result = matchResult;
23258
23506
  return toTextContent({
23259
23507
  message: `Bank transaction matched to invoice ${invoice.invoice_number}`,
23260
23508
  payment_amount: paymentAmount,
23261
- invoice_status: newStatus,
23262
- remaining_balance: roundCents(remaining - paymentAmount),
23509
+ invoice_status: result.invoice_status || "partial",
23510
+ remaining_balance: result.remaining_balance ?? roundCents(remaining - paymentAmount),
23263
23511
  bank_transaction_reconciled: true
23264
23512
  });
23265
23513
  }
@@ -23271,6 +23519,10 @@ function registerBankingTools(server2) {
23271
23519
  {
23272
23520
  description: "List active bank accounts with balances, IBAN, and sync status.",
23273
23521
  inputSchema: {},
23522
+ outputSchema: {
23523
+ accounts: external_exports.array(external_exports.record(external_exports.unknown())),
23524
+ count: external_exports.number()
23525
+ },
23274
23526
  annotations: READ_ONLY_ANNOTATIONS
23275
23527
  },
23276
23528
  async () => {
@@ -23282,7 +23534,7 @@ function registerBankingTools(server2) {
23282
23534
  if (error2) {
23283
23535
  return toErrorContent(sanitizeDbError("Failed to list bank accounts", error2));
23284
23536
  }
23285
- return toTextContent({
23537
+ return toResult({
23286
23538
  accounts: data || [],
23287
23539
  count: (data || []).length
23288
23540
  });
@@ -23302,6 +23554,7 @@ function registerBankingTools(server2) {
23302
23554
  limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
23303
23555
  offset: external_exports.number().min(0).default(0).describe("Skip first N results")
23304
23556
  },
23557
+ outputSchema: PAGINATED_OUTPUT_SHAPE,
23305
23558
  annotations: READ_ONLY_ANNOTATIONS
23306
23559
  },
23307
23560
  async (params) => {
@@ -23343,15 +23596,21 @@ function registerBankingTools(server2) {
23343
23596
  const tagged = (data || []).map((tx) => ({
23344
23597
  ...tx,
23345
23598
  counterparty_name: tagUntrusted(tx.counterparty_name),
23599
+ counterparty_iban: tagUntrusted(tx.counterparty_iban),
23346
23600
  reference: tagUntrusted(tx.reference),
23347
23601
  message: tagUntrusted(tx.message)
23348
23602
  }));
23349
- return toTextContent(paginate(tagged, count || 0, offset, limit));
23603
+ return toResult(paginate(tagged, count || 0, offset, limit));
23350
23604
  }
23351
23605
  );
23352
23606
  }
23353
23607
 
23354
23608
  // src/tools/validation.ts
23609
+ var REFERENCE_OUTPUT_SHAPE = {
23610
+ input: external_exports.string(),
23611
+ reference: external_exports.string(),
23612
+ error: external_exports.string().optional()
23613
+ };
23355
23614
  function validateFinnishBusinessId2(input) {
23356
23615
  const match = input.match(/^(\d{7})-(\d)$/);
23357
23616
  if (!match) {
@@ -23419,11 +23678,12 @@ function registerValidationTools(server2) {
23419
23678
  inputSchema: {
23420
23679
  businessId: external_exports.string().describe('Y-tunnus to validate (e.g. "1234567-8")')
23421
23680
  },
23681
+ outputSchema: VALIDATION_OUTPUT_SHAPE,
23422
23682
  annotations: READ_ONLY_ANNOTATIONS
23423
23683
  },
23424
23684
  async (params) => {
23425
23685
  const result = validateFinnishBusinessId2(params.businessId);
23426
- return toTextContent({
23686
+ return toResult({
23427
23687
  input: params.businessId,
23428
23688
  ...result
23429
23689
  });
@@ -23436,11 +23696,12 @@ function registerValidationTools(server2) {
23436
23696
  inputSchema: {
23437
23697
  iban: external_exports.string().describe('Finnish IBAN to validate (e.g. "FI2112345678901234")')
23438
23698
  },
23699
+ outputSchema: VALIDATION_OUTPUT_SHAPE,
23439
23700
  annotations: READ_ONLY_ANNOTATIONS
23440
23701
  },
23441
23702
  async (params) => {
23442
23703
  const result = validateFinnishIBAN(params.iban);
23443
- return toTextContent({
23704
+ return toResult({
23444
23705
  input: params.iban,
23445
23706
  ...result
23446
23707
  });
@@ -23453,11 +23714,12 @@ function registerValidationTools(server2) {
23453
23714
  inputSchema: {
23454
23715
  baseNumber: external_exports.string().describe('Base number (1-19 digits, e.g. invoice number "00042")')
23455
23716
  },
23717
+ outputSchema: REFERENCE_OUTPUT_SHAPE,
23456
23718
  annotations: READ_ONLY_ANNOTATIONS
23457
23719
  },
23458
23720
  async (params) => {
23459
23721
  const result = generateReferenceNumber2(params.baseNumber);
23460
- return toTextContent({
23722
+ return toResult({
23461
23723
  input: params.baseNumber,
23462
23724
  ...result
23463
23725
  });
@@ -23472,6 +23734,16 @@ function registerFiscalYearTools(server2) {
23472
23734
  {
23473
23735
  description: "List fiscal years (tilikaudet) for the company. Shows start/end dates and open/closed status. Many tools require a fiscal year ID \u2014 use this to find the right one.",
23474
23736
  inputSchema: {},
23737
+ outputSchema: {
23738
+ fiscal_years: external_exports.array(external_exports.object({
23739
+ id: external_exports.string().uuid(),
23740
+ start_date: external_exports.string(),
23741
+ end_date: external_exports.string(),
23742
+ is_closed: external_exports.boolean(),
23743
+ created_at: external_exports.string()
23744
+ }).passthrough()),
23745
+ count: external_exports.number()
23746
+ },
23475
23747
  annotations: READ_ONLY_ANNOTATIONS
23476
23748
  },
23477
23749
  async () => {
@@ -23481,7 +23753,7 @@ function registerFiscalYearTools(server2) {
23481
23753
  if (error2) {
23482
23754
  return toErrorContent(sanitizeDbError("Failed to list fiscal years", error2));
23483
23755
  }
23484
- return toTextContent({
23756
+ return toResult({
23485
23757
  fiscal_years: data || [],
23486
23758
  count: (data || []).length
23487
23759
  });
@@ -23926,6 +24198,13 @@ function registerTemplateTools(server2) {
23926
24198
  {
23927
24199
  description: "List available transaction templates. Templates handle double-entry balancing and VAT splitting automatically.",
23928
24200
  inputSchema: {},
24201
+ outputSchema: {
24202
+ templates: external_exports.array(external_exports.object({
24203
+ id: external_exports.string(),
24204
+ description: external_exports.string(),
24205
+ vatRate: external_exports.number().nullable()
24206
+ }))
24207
+ },
23929
24208
  annotations: READ_ONLY_ANNOTATIONS
23930
24209
  },
23931
24210
  async () => {
@@ -23934,7 +24213,7 @@ function registerTemplateTools(server2) {
23934
24213
  description: t.description,
23935
24214
  vatRate: t.vatRate
23936
24215
  }));
23937
- return toTextContent({ templates: list });
24216
+ return toResult({ templates: list });
23938
24217
  }
23939
24218
  );
23940
24219
  server2.registerTool(
@@ -23945,7 +24224,7 @@ function registerTemplateTools(server2) {
23945
24224
  template: external_exports.string().describe('Template ID (e.g. "cash_sale_25", "purchase_expense")'),
23946
24225
  fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
23947
24226
  transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
23948
- amount: external_exports.number().positive().describe("Gross amount in EUR"),
24227
+ amount: external_exports.number().finite().positive().max(1e9).describe("Gross amount in EUR"),
23949
24228
  description: external_exports.string().min(1).max(500).describe("Transaction description"),
23950
24229
  documentNumber: external_exports.string().optional().describe("Document/receipt number"),
23951
24230
  withholdingRate: external_exports.number().min(0).max(100).optional().describe("Withholding tax % for salary_payment (default 20)"),
@@ -23995,14 +24274,17 @@ function registerTemplateTools(server2) {
23995
24274
  server2.registerTool(
23996
24275
  "kirjapro_create_from_template_batch",
23997
24276
  {
23998
- description: "Create multiple transactions from templates in one call (up to 50). Each is validated independently \u2014 valid ones succeed even if others fail.",
24277
+ description: 'Create multiple transactions from templates in one call (up to 50). Default mode "atomic" rolls back ALL on any failure; "best_effort" collects per-row errors.',
23999
24278
  inputSchema: {
24000
24279
  fiscalYearId: external_exports.string().uuid().optional().describe("Fiscal year UUID (auto-resolved if omitted)"),
24280
+ mode: external_exports.enum(["atomic", "best_effort"]).default("atomic").describe(
24281
+ 'Batch mode. "atomic" (default): all-or-nothing, rolls back on any row failure. "best_effort": commits what it can, returns per-row errors.'
24282
+ ),
24001
24283
  transactions: external_exports.array(
24002
24284
  external_exports.object({
24003
24285
  template: external_exports.string().describe("Template ID"),
24004
24286
  transactionDate: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date (YYYY-MM-DD)"),
24005
- amount: external_exports.number().positive().describe("Gross amount in EUR"),
24287
+ amount: external_exports.number().finite().positive().max(1e9).describe("Gross amount in EUR"),
24006
24288
  description: external_exports.string().min(1).max(500),
24007
24289
  documentNumber: external_exports.string().optional(),
24008
24290
  withholdingRate: external_exports.number().min(0).max(100).optional(),
@@ -24023,41 +24305,126 @@ function registerTemplateTools(server2) {
24023
24305
  "kirjapro_create_from_template_batch",
24024
24306
  params.idempotencyKey,
24025
24307
  async () => {
24026
- const created = [];
24027
- const failed = [];
24308
+ let fiscalYearId = params.fiscalYearId;
24309
+ if (!fiscalYearId) {
24310
+ const resolved = await resolveOpenFiscalYear(supabase2, companyId2);
24311
+ if ("isError" in resolved) return resolved;
24312
+ fiscalYearId = resolved.id;
24313
+ }
24314
+ 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();
24315
+ if (fyError || !fy) {
24316
+ return toErrorContent(
24317
+ `Fiscal year ${fiscalYearId} not found. Use kirjapro_get_dashboard or read kirjapro://fiscal-years resource.`
24318
+ );
24319
+ }
24320
+ if (fy.is_closed) {
24321
+ return toErrorContent(
24322
+ `Fiscal year is closed (${fy.start_date} to ${fy.end_date}).`
24323
+ );
24324
+ }
24325
+ const expanded = [];
24326
+ const preFailed = [];
24028
24327
  for (let i = 0; i < params.transactions.length; i++) {
24029
24328
  const tx = params.transactions[i];
24030
- const result = expandTemplate(tx.template, tx.amount, {
24329
+ const result2 = expandTemplate(tx.template, tx.amount, {
24031
24330
  withholdingRate: tx.withholdingRate,
24032
24331
  tyelRate: tx.tyelRate,
24033
24332
  accountOverrides: tx.accountOverrides
24034
24333
  });
24035
- if ("error" in result) {
24036
- failed.push({ index: i, error: result.error });
24334
+ if ("error" in result2) {
24335
+ preFailed.push({ index: i, error: result2.error });
24037
24336
  continue;
24038
24337
  }
24039
- const createResult = await resolveAndCreate({
24040
- fiscalYearId: params.fiscalYearId,
24041
- transactionDate: tx.transactionDate,
24042
- description: tx.description,
24043
- documentNumber: tx.documentNumber,
24044
- entries: result.entries
24045
- });
24046
- if ("isError" in createResult) {
24047
- failed.push({ index: i, error: createResult.content[0].text });
24338
+ if (tx.transactionDate < fy.start_date || tx.transactionDate > fy.end_date) {
24339
+ preFailed.push({
24340
+ index: i,
24341
+ error: `Date ${tx.transactionDate} outside fiscal year range (${fy.start_date} to ${fy.end_date}).`
24342
+ });
24343
+ continue;
24344
+ }
24345
+ const totalDebit = roundCents(
24346
+ result2.entries.reduce((s, e) => s + e.debitAmount, 0)
24347
+ );
24348
+ const totalCredit = roundCents(
24349
+ result2.entries.reduce((s, e) => s + e.creditAmount, 0)
24350
+ );
24351
+ if (Math.abs(totalDebit - totalCredit) >= 0.01) {
24352
+ preFailed.push({
24353
+ index: i,
24354
+ error: `Template produced unbalanced entries: debits ${totalDebit.toFixed(2)} != credits ${totalCredit.toFixed(2)}.`
24355
+ });
24048
24356
  continue;
24049
24357
  }
24050
- created.push({
24051
- id: createResult.id,
24358
+ expanded.push({
24359
+ index: i,
24360
+ transactionDate: tx.transactionDate,
24052
24361
  description: tx.description,
24053
- date: tx.transactionDate
24362
+ documentNumber: tx.documentNumber || "",
24363
+ entries: result2.entries
24054
24364
  });
24055
24365
  }
24366
+ if (params.mode === "atomic" && preFailed.length > 0) {
24367
+ return toErrorContent(
24368
+ `Atomic batch aborted: ${preFailed.length} transaction(s) failed validation. Fix all errors or use mode "best_effort" for partial commits.
24369
+ ` + preFailed.map((f) => ` [${f.index}] ${f.error}`).join("\n")
24370
+ );
24371
+ }
24372
+ if (expanded.length === 0) {
24373
+ return toTextContent({
24374
+ mode: params.mode,
24375
+ created_count: 0,
24376
+ failed_count: preFailed.length,
24377
+ created: [],
24378
+ failed: preFailed
24379
+ });
24380
+ }
24381
+ const allAccountNumbers = [
24382
+ ...new Set(
24383
+ expanded.flatMap((e) => e.entries.map((en) => en.accountNumber))
24384
+ )
24385
+ ];
24386
+ const accountResult = await resolveAccountNumbers(allAccountNumbers);
24387
+ if ("isError" in accountResult) return accountResult;
24388
+ const accountMap = accountResult;
24389
+ const rpcTransactions = expanded.map((e) => ({
24390
+ transaction_date: e.transactionDate,
24391
+ description: e.description,
24392
+ document_number: e.documentNumber,
24393
+ entries: toJournalEntries(e.entries, accountMap)
24394
+ }));
24395
+ const { data: rpcResult, error: rpcError } = await supabase2.rpc(
24396
+ "create_transactions_batch_atomic",
24397
+ {
24398
+ p_company_id: companyId2,
24399
+ p_fiscal_year_id: fiscalYearId,
24400
+ p_transactions: rpcTransactions,
24401
+ p_mode: params.mode
24402
+ }
24403
+ );
24404
+ if (rpcError) {
24405
+ return toErrorContent(
24406
+ sanitizeDbError("Batch create failed", rpcError)
24407
+ );
24408
+ }
24409
+ const result = rpcResult;
24410
+ const created = result.created.map((c) => ({
24411
+ id: c.id,
24412
+ description: c.description,
24413
+ date: c.date,
24414
+ index: expanded[c.index].index
24415
+ }));
24416
+ const rpcFailed = result.failed.map((f) => ({
24417
+ index: expanded[f.index].index,
24418
+ error: sanitizeDbError("Failed to create transaction", {
24419
+ message: f.error
24420
+ })
24421
+ }));
24056
24422
  return toTextContent({
24423
+ mode: params.mode,
24057
24424
  created_count: created.length,
24058
- failed_count: failed.length,
24425
+ failed_count: preFailed.length + rpcFailed.length,
24059
24426
  created,
24060
- failed
24427
+ failed: [...preFailed, ...rpcFailed]
24061
24428
  });
24062
24429
  }
24063
24430
  );
@@ -24079,7 +24446,9 @@ function registerBookingTools(server2) {
24079
24446
  {
24080
24447
  description: "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%.",
24081
24448
  inputSchema: {
24082
- amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
24449
+ amount: external_exports.number().finite().positive().max(1e9).describe(
24450
+ "EUR amount. Standard purchase: GROSS (VAT-inclusive) \u2014 VAT is split out of this. Reverse charge (reverseCharge=true): NET foreign-invoice amount \u2014 the foreign supplier does not charge Finnish VAT, so the parameter must be net. VAT is self-assessed on top per AVL 9\xA7 producing equal debit to ALV-saamiset and credit to ALV-velka. Entertainment (edustus=true): GROSS (full amount to expense, no VAT deduction per AVL 114\xA7)."
24451
+ ),
24083
24452
  description: external_exports.string().min(1).max(500).describe("Transaction description"),
24084
24453
  date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
24085
24454
  vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("Finnish VAT rate"),
@@ -24164,7 +24533,7 @@ function registerBookingTools(server2) {
24164
24533
  {
24165
24534
  description: "Book salary payment (palkanmaksu). Creates salary + employer cost transactions with withholding, TyEL, TTV, sotu. Default rates are 2026 Finnish social insurance rates.",
24166
24535
  inputSchema: {
24167
- grossSalary: external_exports.number().positive().describe("Gross salary EUR"),
24536
+ grossSalary: external_exports.number().finite().positive().max(1e8).describe("Gross salary EUR"),
24168
24537
  employeeName: external_exports.string().min(1).describe("Employee name (for description)"),
24169
24538
  date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Payment date (YYYY-MM-DD)"),
24170
24539
  withholdingRate: external_exports.number().min(0).max(100).describe("Withholding tax % (ennakonpid\xE4tys)"),
@@ -24380,7 +24749,7 @@ function registerBookingTools(server2) {
24380
24749
  {
24381
24750
  description: "Book a sale (myynti) with automatic VAT splitting. Payment types: invoice (default, D Myyntisaamiset), cash (D bank), prepaid.",
24382
24751
  inputSchema: {
24383
- amount: external_exports.number().positive().describe("Gross EUR amount (VAT-inclusive)"),
24752
+ amount: external_exports.number().finite().positive().max(1e9).describe("Gross EUR amount (VAT-inclusive)"),
24384
24753
  description: external_exports.string().min(1).max(500).describe('Transaction description (e.g. "Konsultointi Acme Oy, 40h \xD7 95 \u20AC")'),
24385
24754
  date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Transaction date (YYYY-MM-DD)"),
24386
24755
  vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("Finnish VAT rate"),
@@ -24460,6 +24829,19 @@ function registerBookingTools(server2) {
24460
24829
  }
24461
24830
 
24462
24831
  // src/tools/offers.ts
24832
+ var OFFER_DETAIL_OUTPUT_SHAPE = {
24833
+ id: external_exports.string(),
24834
+ offer_number: external_exports.string(),
24835
+ type: external_exports.string(),
24836
+ title: external_exports.string(),
24837
+ status: external_exports.string(),
24838
+ subtotal: external_exports.number(),
24839
+ vat_total: external_exports.number(),
24840
+ total: external_exports.number(),
24841
+ customers: external_exports.record(external_exports.unknown()).nullable(),
24842
+ offer_lines: external_exports.array(external_exports.record(external_exports.unknown())),
24843
+ offer_events: external_exports.array(external_exports.record(external_exports.unknown()))
24844
+ };
24463
24845
  function registerOfferTools(server2) {
24464
24846
  server2.registerTool(
24465
24847
  "kirjapro_list_offers",
@@ -24471,6 +24853,7 @@ function registerOfferTools(server2) {
24471
24853
  limit: external_exports.number().min(1).max(200).default(50).describe("Max results (default 50)"),
24472
24854
  offset: external_exports.number().min(0).default(0).describe("Skip first N results")
24473
24855
  },
24856
+ outputSchema: PAGINATED_OUTPUT_SHAPE,
24474
24857
  annotations: READ_ONLY_ANNOTATIONS
24475
24858
  },
24476
24859
  async (params) => {
@@ -24493,7 +24876,13 @@ function registerOfferTools(server2) {
24493
24876
  if (error2) {
24494
24877
  return toErrorContent(sanitizeDbError("Failed to list offers", error2));
24495
24878
  }
24496
- return toTextContent(paginate(data || [], count || 0, offset, limit));
24879
+ const tagged = (data || []).map((row) => ({
24880
+ ...row,
24881
+ customers: tagCustomer(
24882
+ row.customers
24883
+ )
24884
+ }));
24885
+ return toResult(paginate(tagged, count || 0, offset, limit));
24497
24886
  }
24498
24887
  );
24499
24888
  server2.registerTool(
@@ -24503,6 +24892,7 @@ function registerOfferTools(server2) {
24503
24892
  inputSchema: {
24504
24893
  offerId: external_exports.string().uuid().describe("Offer UUID")
24505
24894
  },
24895
+ outputSchema: OFFER_DETAIL_OUTPUT_SHAPE,
24506
24896
  annotations: READ_ONLY_ANNOTATIONS
24507
24897
  },
24508
24898
  async (params) => {
@@ -24521,7 +24911,12 @@ function registerOfferTools(server2) {
24521
24911
  `Offer ${params.offerId} not found. Use kirjapro_list_offers to find offer IDs.`
24522
24912
  );
24523
24913
  }
24524
- return toTextContent(offer);
24914
+ return toResult({
24915
+ ...offer,
24916
+ customers: tagCustomer(
24917
+ offer.customers
24918
+ )
24919
+ });
24525
24920
  }
24526
24921
  );
24527
24922
  server2.registerTool(
@@ -24540,7 +24935,7 @@ function registerOfferTools(server2) {
24540
24935
  description: external_exports.string().min(1).describe("Line item description"),
24541
24936
  quantity: external_exports.number().min(0).describe("Quantity"),
24542
24937
  unit: external_exports.string().default("kpl").describe("Unit (kpl, h, kk, etc.)"),
24543
- unitPrice: external_exports.number().describe("Unit price in EUR (excl. VAT)"),
24938
+ unitPrice: external_exports.number().finite().min(-1e9).max(1e9).describe("Unit price in EUR (excl. VAT). Can be negative for credit lines."),
24544
24939
  vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).default("25.5").describe("VAT rate")
24545
24940
  })).min(1).describe("Line items"),
24546
24941
  idempotencyKey: IDEMPOTENCY_KEY_SCHEMA
@@ -24870,7 +25265,7 @@ function registerOfferTools(server2) {
24870
25265
  `Offer ${offer.offer_number} has already been converted to invoice ${offer.invoice_id}.`
24871
25266
  );
24872
25267
  }
24873
- const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
25268
+ const today = helsinkiDateString();
24874
25269
  const { data: result, error: rpcError } = await supabase2.rpc("convert_offer_to_invoice", {
24875
25270
  p_offer_id: params.offerId,
24876
25271
  p_company_id: companyId2,
@@ -24898,6 +25293,16 @@ function registerOfferTools(server2) {
24898
25293
  }
24899
25294
 
24900
25295
  // src/tools/recurring-invoices.ts
25296
+ var RECURRING_INVOICE_DETAIL_OUTPUT_SHAPE = {
25297
+ id: external_exports.string(),
25298
+ name: external_exports.string(),
25299
+ frequency: external_exports.string(),
25300
+ next_date: external_exports.string().nullable(),
25301
+ is_active: external_exports.boolean(),
25302
+ template_data: external_exports.record(external_exports.unknown()).nullable(),
25303
+ customer: external_exports.record(external_exports.unknown()).nullable(),
25304
+ recent_logs: external_exports.array(external_exports.record(external_exports.unknown()))
25305
+ };
24901
25306
  function registerRecurringInvoiceTools(server2) {
24902
25307
  server2.registerTool(
24903
25308
  "kirjapro_list_recurring_invoices",
@@ -24908,6 +25313,7 @@ function registerRecurringInvoiceTools(server2) {
24908
25313
  offset: external_exports.number().min(0).default(0).describe("Skip first N results"),
24909
25314
  activeOnly: external_exports.boolean().default(true).describe("Only show active templates (default true)")
24910
25315
  },
25316
+ outputSchema: PAGINATED_OUTPUT_SHAPE,
24911
25317
  annotations: READ_ONLY_ANNOTATIONS
24912
25318
  },
24913
25319
  async (params) => {
@@ -24926,7 +25332,13 @@ function registerRecurringInvoiceTools(server2) {
24926
25332
  if (error2) {
24927
25333
  return toErrorContent(sanitizeDbError("Failed to list recurring invoices", error2));
24928
25334
  }
24929
- return toTextContent(paginate(data || [], count || 0, offset, limit));
25335
+ const tagged = (data || []).map((row) => ({
25336
+ ...row,
25337
+ customer: tagCustomer(
25338
+ row.customer
25339
+ )
25340
+ }));
25341
+ return toResult(paginate(tagged, count || 0, offset, limit));
24930
25342
  }
24931
25343
  );
24932
25344
  server2.registerTool(
@@ -24936,13 +25348,14 @@ function registerRecurringInvoiceTools(server2) {
24936
25348
  inputSchema: {
24937
25349
  recurringInvoiceId: external_exports.string().uuid().describe("Recurring invoice UUID")
24938
25350
  },
25351
+ outputSchema: RECURRING_INVOICE_DETAIL_OUTPUT_SHAPE,
24939
25352
  annotations: READ_ONLY_ANNOTATIONS
24940
25353
  },
24941
25354
  async (params) => {
24942
25355
  const supabase2 = getSupabase();
24943
25356
  const companyId2 = getCompanyId();
24944
25357
  const { data, error: error2 } = await supabase2.from("recurring_invoices").select(
24945
- "*, customer:customers(id, name, business_id, email)"
25358
+ "id, company_id, customer_id, name, frequency, next_date, end_date, is_active, auto_send, template_data, last_generated_at, times_generated, created_at, updated_at, customer:customers(id, name, business_id, email)"
24946
25359
  ).eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
24947
25360
  if (error2 || !data) {
24948
25361
  return toErrorContent(
@@ -24950,8 +25363,11 @@ function registerRecurringInvoiceTools(server2) {
24950
25363
  );
24951
25364
  }
24952
25365
  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);
24953
- return toTextContent({
25366
+ return toResult({
24954
25367
  ...data,
25368
+ customer: tagCustomer(
25369
+ data.customer
25370
+ ),
24955
25371
  recent_logs: logs || []
24956
25372
  });
24957
25373
  }
@@ -24971,7 +25387,7 @@ function registerRecurringInvoiceTools(server2) {
24971
25387
  description: external_exports.string().describe("Line item description"),
24972
25388
  quantity: external_exports.number().positive().describe("Quantity"),
24973
25389
  unit: external_exports.string().default("kpl").describe('Unit (default "kpl")'),
24974
- unitPrice: external_exports.number().describe("Unit price (EUR)"),
25390
+ unitPrice: external_exports.number().finite().min(-1e9).max(1e9).describe("Unit price in EUR (excl. VAT). Can be negative for credit lines."),
24975
25391
  vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("VAT rate")
24976
25392
  })).min(1).describe("Invoice line items"),
24977
25393
  notes: external_exports.string().optional().describe("Invoice notes"),
@@ -25017,7 +25433,7 @@ function registerRecurringInvoiceTools(server2) {
25017
25433
  is_active: true,
25018
25434
  auto_send: params.autoSend,
25019
25435
  template_data: templateData
25020
- }).select().single();
25436
+ }).select("id, name, frequency, next_date, end_date, is_active, auto_send, created_at, updated_at").single();
25021
25437
  if (error2) {
25022
25438
  return toErrorContent(sanitizeDbError("Failed to create recurring invoice", error2));
25023
25439
  }
@@ -25045,7 +25461,7 @@ function registerRecurringInvoiceTools(server2) {
25045
25461
  description: external_exports.string().describe("Line item description"),
25046
25462
  quantity: external_exports.number().positive().describe("Quantity"),
25047
25463
  unit: external_exports.string().default("kpl").describe("Unit"),
25048
- unitPrice: external_exports.number().describe("Unit price (EUR)"),
25464
+ unitPrice: external_exports.number().finite().min(-1e9).max(1e9).describe("Unit price in EUR (excl. VAT). Can be negative for credit lines."),
25049
25465
  vatRate: external_exports.enum(["25.5", "13.5", "10", "0"]).describe("VAT rate")
25050
25466
  })).min(1).optional().describe("Replace all invoice lines"),
25051
25467
  notes: external_exports.string().optional().nullable().describe("Invoice notes (null to remove)"),
@@ -25057,12 +25473,19 @@ function registerRecurringInvoiceTools(server2) {
25057
25473
  async (params) => {
25058
25474
  const supabase2 = getSupabase();
25059
25475
  const companyId2 = getCompanyId();
25060
- const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, template_data").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
25476
+ const { data: existing, error: fetchError } = await supabase2.from("recurring_invoices").select("id, name, template_data, next_date, end_date").eq("id", params.recurringInvoiceId).eq("company_id", companyId2).single();
25061
25477
  if (fetchError || !existing) {
25062
25478
  return toErrorContent(
25063
25479
  `Recurring invoice ${params.recurringInvoiceId} not found. Use kirjapro_list_recurring_invoices to find IDs.`
25064
25480
  );
25065
25481
  }
25482
+ const effectiveNext = params.nextDate !== void 0 ? params.nextDate : existing.next_date;
25483
+ const effectiveEnd = params.endDate !== void 0 ? params.endDate : existing.end_date;
25484
+ if (effectiveEnd && effectiveNext && effectiveEnd < effectiveNext) {
25485
+ return toErrorContent(
25486
+ `endDate (${effectiveEnd}) must not be earlier than nextDate (${effectiveNext}). Either clear endDate (set to null) or advance nextDate first.`
25487
+ );
25488
+ }
25066
25489
  const updates = {};
25067
25490
  if (params.name !== void 0) updates.name = params.name;
25068
25491
  if (params.frequency !== void 0) updates.frequency = params.frequency;
@@ -25096,7 +25519,7 @@ function registerRecurringInvoiceTools(server2) {
25096
25519
  "kirjapro_update_recurring_invoice",
25097
25520
  params.idempotencyKey,
25098
25521
  async () => {
25099
- const { data, error: error2 } = await supabase2.from("recurring_invoices").update(updates).eq("id", params.recurringInvoiceId).eq("company_id", companyId2).select().single();
25522
+ const { data, error: error2 } = await supabase2.from("recurring_invoices").update(updates).eq("id", params.recurringInvoiceId).eq("company_id", companyId2).select("id, name, frequency, next_date, end_date, is_active, auto_send, created_at, updated_at").single();
25100
25523
  if (error2) {
25101
25524
  return toErrorContent(sanitizeDbError("Failed to update recurring invoice", error2));
25102
25525
  }
@@ -25193,7 +25616,8 @@ function registerRecurringInvoiceTools(server2) {
25193
25616
  }
25194
25617
  return toTextContent({
25195
25618
  message: `Invoice generated from "${existing.name}"`,
25196
- ...result.data
25619
+ invoice_id: result.data.invoice_id,
25620
+ invoice_number: result.data.invoice_number
25197
25621
  });
25198
25622
  }
25199
25623
  );
@@ -25202,6 +25626,23 @@ function registerRecurringInvoiceTools(server2) {
25202
25626
  }
25203
25627
 
25204
25628
  // src/tools/received-invoices.ts
25629
+ var RECEIVED_INVOICE_DETAIL_OUTPUT_SHAPE = {
25630
+ id: external_exports.string(),
25631
+ status: external_exports.string(),
25632
+ sender_name: external_exports.string().nullable(),
25633
+ sender_business_id: external_exports.string().nullable(),
25634
+ invoice_number: external_exports.string().nullable(),
25635
+ invoice_date: external_exports.string().nullable(),
25636
+ due_date: external_exports.string().nullable(),
25637
+ total_amount: external_exports.number().nullable(),
25638
+ total_amount_formatted: external_exports.string().nullable(),
25639
+ vat_amount: external_exports.number().nullable(),
25640
+ vat_amount_formatted: external_exports.string().nullable(),
25641
+ currency: external_exports.string().nullable(),
25642
+ transaction_id: external_exports.string().nullable(),
25643
+ pdf_storage_path: external_exports.string().nullable(),
25644
+ linked_transaction: external_exports.record(external_exports.unknown()).nullable()
25645
+ };
25205
25646
  function registerReceivedInvoiceTools(server2) {
25206
25647
  server2.registerTool(
25207
25648
  "kirjapro_list_received_invoices",
@@ -25215,6 +25656,7 @@ function registerReceivedInvoiceTools(server2) {
25215
25656
  dateFrom: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date from (YYYY-MM-DD)"),
25216
25657
  dateTo: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Invoice date to (YYYY-MM-DD)")
25217
25658
  },
25659
+ outputSchema: PAGINATED_OUTPUT_SHAPE,
25218
25660
  annotations: READ_ONLY_ANNOTATIONS
25219
25661
  },
25220
25662
  async (params) => {
@@ -25251,9 +25693,10 @@ function registerReceivedInvoiceTools(server2) {
25251
25693
  sender_business_id: tagUntrusted(inv.sender_business_id),
25252
25694
  invoice_number: tagUntrusted(inv.invoice_number),
25253
25695
  payment_reference: tagUntrusted(inv.payment_reference),
25696
+ iban: tagUntrusted(inv.iban),
25254
25697
  total_amount_formatted: inv.total_amount != null ? formatCurrency(inv.total_amount) : null
25255
25698
  }));
25256
- return toTextContent(paginate(results, count || 0, offset, limit));
25699
+ return toResult(paginate(results, count || 0, offset, limit));
25257
25700
  }
25258
25701
  );
25259
25702
  server2.registerTool(
@@ -25263,12 +25706,15 @@ function registerReceivedInvoiceTools(server2) {
25263
25706
  inputSchema: {
25264
25707
  receivedInvoiceId: external_exports.string().uuid().describe("Received invoice UUID")
25265
25708
  },
25709
+ outputSchema: RECEIVED_INVOICE_DETAIL_OUTPUT_SHAPE,
25266
25710
  annotations: READ_ONLY_ANNOTATIONS
25267
25711
  },
25268
25712
  async (params) => {
25269
25713
  const supabase2 = getSupabase();
25270
25714
  const companyId2 = getCompanyId();
25271
- const { data, error: error2 } = await supabase2.from("received_invoices").select("*").eq("id", params.receivedInvoiceId).eq("company_id", companyId2).single();
25715
+ const { data, error: error2 } = await supabase2.from("received_invoices").select(
25716
+ "id, company_id, status, operator, sender_operator, sender_eia, sender_business_id, sender_name, invoice_number, invoice_date, due_date, total_amount, vat_amount, currency, payment_reference, iban, bic, notes, pdf_storage_path, xml_content, transmission_id, transaction_id, purchase_vat_entry_id, received_at, reviewed_at, reviewed_by, created_at, updated_at"
25717
+ ).eq("id", params.receivedInvoiceId).eq("company_id", companyId2).single();
25272
25718
  if (error2 || !data) {
25273
25719
  return toErrorContent(
25274
25720
  `Received invoice ${params.receivedInvoiceId} not found. Use kirjapro_list_received_invoices to find IDs.`
@@ -25279,12 +25725,16 @@ function registerReceivedInvoiceTools(server2) {
25279
25725
  const { data: tx } = await supabase2.from("transactions").select("id, transaction_date, description, document_number").eq("id", data.transaction_id).eq("company_id", companyId2).single();
25280
25726
  linkedTransaction = tx;
25281
25727
  }
25282
- return toTextContent({
25283
- ...data,
25728
+ const { xml_content: _xml, ...rest } = data;
25729
+ return toResult({
25730
+ ...rest,
25284
25731
  sender_name: tagUntrusted(data.sender_name),
25285
25732
  sender_business_id: tagUntrusted(data.sender_business_id),
25286
25733
  invoice_number: tagUntrusted(data.invoice_number),
25287
25734
  payment_reference: tagUntrusted(data.payment_reference),
25735
+ iban: tagUntrusted(data.iban),
25736
+ bic: tagUntrusted(data.bic),
25737
+ notes: tagUntrusted(data.notes),
25288
25738
  total_amount_formatted: data.total_amount != null ? formatCurrency(data.total_amount) : null,
25289
25739
  vat_amount_formatted: data.vat_amount != null ? formatCurrency(data.vat_amount) : null,
25290
25740
  linked_transaction: linkedTransaction
@@ -25326,11 +25776,11 @@ var CHART_OF_ACCOUNTS_SUMMARY = {
25326
25776
  ],
25327
25777
  note: "Use kirjapro_list_accounts to get full account list with search/filter."
25328
25778
  };
25329
- var VAT_RATES = [
25330
- { rate: 25.5, label: "Yleinen verokanta", description: "Standard rate for most goods and services (from 1.9.2024, was 24%)", basis: "AVL 84\xA7" },
25331
- { 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" },
25332
- { rate: 10, label: "Lehdet ja aikakauslehdet", description: "Newspapers and magazines (print and digital) only", basis: "AVL 85a\xA7" },
25333
- { 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" },
25779
+ var VAT_RATE_METADATA = [
25780
+ { rate: FINNISH_RATES_2026.vat.standard, label: "Yleinen verokanta", description: "Standard rate for most goods and services (from 1.9.2024, was 24%)", basis: "AVL 84\xA7" },
25781
+ { rate: FINNISH_RATES_2026.vat.reduced, 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" },
25782
+ { rate: FINNISH_RATES_2026.vat.secondReduced, label: "Lehdet ja aikakauslehdet", description: "Newspapers and magazines (print and digital) only", basis: "AVL 85a\xA7" },
25783
+ { rate: FINNISH_RATES_2026.vat.zero, 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" },
25334
25784
  { 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" }
25335
25785
  ];
25336
25786
  function registerSchemaResources(server2) {
@@ -25360,7 +25810,10 @@ function registerSchemaResources(server2) {
25360
25810
  contents: [{
25361
25811
  uri: uri.href,
25362
25812
  mimeType: "application/json",
25363
- text: JSON.stringify(VAT_RATES)
25813
+ text: JSON.stringify({
25814
+ rates: VAT_RATE_METADATA,
25815
+ _meta: freshnessMeta(FINNISH_RATES_2026)
25816
+ })
25364
25817
  }]
25365
25818
  })
25366
25819
  );
@@ -25378,12 +25831,23 @@ function registerCompanyResources(server2) {
25378
25831
  async (uri) => {
25379
25832
  const supabase2 = getSupabase();
25380
25833
  const companyId2 = getCompanyId();
25381
- const { data: company } = await supabase2.from("companies").select("id, name, business_id, address, postal_code, city, country, created_at").eq("id", companyId2).single();
25834
+ const { data: company, error: error2 } = await supabase2.from("companies").select("id, name, business_id, address, postal_code, city, country, created_at").eq("id", companyId2).single();
25835
+ if (error2 || !company) {
25836
+ return {
25837
+ contents: [{
25838
+ uri: uri.href,
25839
+ mimeType: "application/json",
25840
+ text: JSON.stringify({
25841
+ error: error2 ? sanitizeDbError("Failed to read company", error2) : "Company not found for current context."
25842
+ })
25843
+ }]
25844
+ };
25845
+ }
25382
25846
  return {
25383
25847
  contents: [{
25384
25848
  uri: uri.href,
25385
25849
  mimeType: "application/json",
25386
- text: JSON.stringify(company || {})
25850
+ text: JSON.stringify(company)
25387
25851
  }]
25388
25852
  };
25389
25853
  }
@@ -25398,12 +25862,25 @@ function registerCompanyResources(server2) {
25398
25862
  async (uri) => {
25399
25863
  const supabase2 = getSupabase();
25400
25864
  const companyId2 = getCompanyId();
25401
- 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 });
25402
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
25403
- const enriched = (fiscalYears || []).map((fy) => ({
25404
- ...fy,
25405
- is_current: !fy.is_closed && fy.start_date <= today && fy.end_date >= today
25406
- }));
25865
+ const { data: fiscalYears, 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 });
25866
+ if (error2) {
25867
+ return {
25868
+ contents: [{
25869
+ uri: uri.href,
25870
+ mimeType: "application/json",
25871
+ text: JSON.stringify({
25872
+ error: sanitizeDbError("Failed to read fiscal years", error2)
25873
+ })
25874
+ }]
25875
+ };
25876
+ }
25877
+ const today = helsinkiDateString();
25878
+ const enriched = (fiscalYears || []).map(
25879
+ (fy) => ({
25880
+ ...fy,
25881
+ is_current: !fy.is_closed && fy.start_date <= today && fy.end_date >= today
25882
+ })
25883
+ );
25407
25884
  return {
25408
25885
  contents: [{
25409
25886
  uri: uri.href,
@@ -25791,10 +26268,13 @@ function registerResources(server2) {
25791
26268
  registerSecurityResources(server2);
25792
26269
  }
25793
26270
 
26271
+ // src/version.ts
26272
+ var VERSION = "0.5.6";
26273
+
25794
26274
  // src/index.ts
25795
26275
  var server = new McpServer({
25796
26276
  name: "kirjapro",
25797
- version: "0.5.0"
26277
+ version: VERSION
25798
26278
  });
25799
26279
  async function main() {
25800
26280
  try {
@@ -25805,13 +26285,23 @@ async function main() {
25805
26285
  }
25806
26286
  registerTools(server);
25807
26287
  registerResources(server);
26288
+ const freshness = freshnessMeta(FINNISH_RATES_2026);
26289
+ if (freshness.stale) {
26290
+ console.error(
26291
+ `[kirjapro-mcp] WARNING: Finnish rates dataset is STALE (valid through ${freshness.valid_through}). Running on expired VAT and social-insurance rates. Upgrade @kirjapro/mcp-server immediately.`
26292
+ );
26293
+ } else if (freshness.days_until_expiry < 30) {
26294
+ console.error(
26295
+ `[kirjapro-mcp] NOTICE: Finnish rates dataset expires in ${freshness.days_until_expiry} days (${freshness.valid_through}). Upgrade @kirjapro/mcp-server before year-end to avoid stale rates.`
26296
+ );
26297
+ }
25808
26298
  const transport = new StdioServerTransport();
25809
26299
  transport.onclose = () => {
25810
26300
  stopTokenRefresh();
25811
26301
  process.exit(0);
25812
26302
  };
25813
26303
  await server.connect(transport);
25814
- console.error("[kirjapro-mcp] Server started on stdio");
26304
+ console.error(`[kirjapro-mcp] Server v${VERSION} started on stdio`);
25815
26305
  }
25816
26306
  function shutdown() {
25817
26307
  stopTokenRefresh();