e-arveldaja-mcp 0.3.2 → 0.4.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.
@@ -0,0 +1,6 @@
1
+ import type { Account } from "./types/api.js";
2
+ export interface AccountValidationTarget {
3
+ id: number;
4
+ label: string;
5
+ }
6
+ export declare function validateAccounts(accounts: Account[], targets: AccountValidationTarget[]): string[];
@@ -0,0 +1,22 @@
1
+ export function validateAccounts(accounts, targets) {
2
+ const accountMap = new Map(accounts.map(account => [account.id, account]));
3
+ const seen = new Set();
4
+ const errors = [];
5
+ for (const target of targets) {
6
+ const key = `${target.id}:${target.label}`;
7
+ if (seen.has(key))
8
+ continue;
9
+ seen.add(key);
10
+ const account = accountMap.get(target.id);
11
+ if (!account) {
12
+ errors.push(`${target.label} ${target.id} not found in chart of accounts. ` +
13
+ `Activate it in e-arveldaja: Seaded → Kontoplaan → find account ${target.id} and enable it.`);
14
+ continue;
15
+ }
16
+ if (!account.is_valid) {
17
+ errors.push(`${target.label} ${target.id} (${account.name_est}) is inactive. ` +
18
+ `Activate it in e-arveldaja: Seaded → Kontoplaan → ${account.name_est} → mark as active.`);
19
+ }
20
+ }
21
+ return errors;
22
+ }
@@ -1,5 +1,5 @@
1
1
  import type { HttpClient } from "../http-client.js";
2
- import type { PurchaseInvoice, ApiResponse, ApiFile } from "../types/api.js";
2
+ import type { PurchaseInvoice, CreatePurchaseInvoiceData, ApiResponse, ApiFile } from "../types/api.js";
3
3
  import { BaseResource } from "./base-resource.js";
4
4
  export declare class PurchaseInvoicesApi extends BaseResource<PurchaseInvoice> {
5
5
  constructor(client: HttpClient);
@@ -13,7 +13,7 @@ export declare class PurchaseInvoicesApi extends BaseResource<PurchaseInvoice> {
13
13
  * because input VAT is not deductible. gross_price is still set to actual payable amount.
14
14
  * Items get project_no_vat_gross_price set for VAT tracking.
15
15
  */
16
- createAndSetTotals(data: Partial<PurchaseInvoice>, vatPrice?: number, grossPrice?: number, isVatRegistered?: boolean): Promise<PurchaseInvoice>;
16
+ createAndSetTotals(data: CreatePurchaseInvoiceData, vatPrice?: number, grossPrice?: number, isVatRegistered?: boolean): Promise<PurchaseInvoice>;
17
17
  /**
18
18
  * Confirm a purchase invoice. Automatically fixes vat_price/gross_price if missing or inconsistent.
19
19
  * For non-VAT companies: only fixes gross_price, leaves vat_price at 0.
@@ -66,7 +66,7 @@ export class PurchaseInvoicesApi extends BaseResource {
66
66
  const gross = grossPrice !== undefined
67
67
  ? grossPrice
68
68
  : roundMoney(itemNet + itemVat);
69
- if (vat > 0 || gross > 0) {
69
+ if (vat !== undefined || gross !== undefined) {
70
70
  await this.update(id, { vat_price: vat, gross_price: gross, items: invoice.items });
71
71
  this.invalidateCache();
72
72
  }
@@ -85,8 +85,10 @@ export class PurchaseInvoicesApi extends BaseResource {
85
85
  const vat = isVatRegistered ? itemVat : 0;
86
86
  const gross = roundMoney(net + itemVat);
87
87
  const currentGross = invoice.gross_price;
88
- const shouldRepair = !currentGross || Math.abs(currentGross - gross) > 0.02;
89
- if (shouldRepair) {
88
+ const currentVat = invoice.vat_price;
89
+ const grossNeedsRepair = !currentGross || Math.abs(currentGross - gross) > 0.02;
90
+ const vatNeedsRepair = isVatRegistered && (currentVat === undefined || currentVat === null || Math.abs(currentVat - vat) > 0.02);
91
+ if (grossNeedsRepair || vatNeedsRepair) {
90
92
  await this.update(id, { vat_price: vat, gross_price: gross, items });
91
93
  }
92
94
  }
@@ -26,10 +26,10 @@ export class TransactionsApi extends BaseResource {
26
26
  const inv = await this.client.get(`/sale_invoices/${dist.related_id}`);
27
27
  clientsId = inv?.clients_id;
28
28
  }
29
- if (clientsId)
29
+ if (clientsId !== undefined)
30
30
  break;
31
31
  }
32
- if (clientsId) {
32
+ if (clientsId !== undefined) {
33
33
  await this.update(id, { clients_id: clientsId });
34
34
  }
35
35
  }
package/dist/cache.js CHANGED
@@ -20,13 +20,15 @@ export class Cache {
20
20
  return entry.data;
21
21
  }
22
22
  set(key, data, ttlSeconds) {
23
+ if (ttlSeconds === 0)
24
+ return; // Zero TTL = don't cache
23
25
  // Evict oldest entries if at capacity
24
26
  if (this.store.size >= this.maxEntries && !this.store.has(key)) {
25
27
  const firstKey = this.store.keys().next().value;
26
28
  if (firstKey)
27
29
  this.store.delete(firstKey);
28
30
  }
29
- const ttlMs = ttlSeconds ? ttlSeconds * 1000 : this.defaultTtlMs;
31
+ const ttlMs = ttlSeconds !== undefined ? ttlSeconds * 1000 : this.defaultTtlMs;
30
32
  this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
31
33
  }
32
34
  invalidate(pattern) {
package/dist/config.js CHANGED
@@ -1,23 +1,11 @@
1
1
  import dotenv from "dotenv";
2
2
  import { resolve } from "path";
3
3
  import { readFileSync, existsSync, statSync, readdirSync, realpathSync } from "fs";
4
+ import { getProjectRoot } from "./paths.js";
4
5
  const SERVERS = {
5
6
  live: "https://rmp-api.rik.ee/v1",
6
7
  demo: "https://demo-rmp-api.rik.ee/v1",
7
8
  };
8
- /** Find project root by walking up from import.meta.dirname to package.json. */
9
- function getProjectRoot() {
10
- let dir = import.meta.dirname;
11
- for (let i = 0; i < 5; i++) {
12
- if (existsSync(resolve(dir, "package.json")))
13
- return dir;
14
- const parent = resolve(dir, "..");
15
- if (parent === dir)
16
- break;
17
- dir = parent;
18
- }
19
- return process.cwd();
20
- }
21
9
  const PROJECT_ROOT = getProjectRoot();
22
10
  dotenv.config({ path: resolve(PROJECT_ROOT, ".env") });
23
11
  function getBaseUrl() {
package/dist/csv.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function parseCSVLine(line: string): string[];
package/dist/csv.js ADDED
@@ -0,0 +1,34 @@
1
+ export function parseCSVLine(line) {
2
+ const fields = [];
3
+ let current = "";
4
+ let inQuotes = false;
5
+ for (let i = 0; i < line.length; i++) {
6
+ const ch = line[i];
7
+ if (inQuotes) {
8
+ if (ch === '"') {
9
+ if (i + 1 < line.length && line[i + 1] === '"') {
10
+ current += '"';
11
+ i++;
12
+ }
13
+ else {
14
+ inQuotes = false;
15
+ }
16
+ }
17
+ else {
18
+ current += ch;
19
+ }
20
+ }
21
+ else if (ch === '"') {
22
+ inQuotes = true;
23
+ }
24
+ else if (ch === ",") {
25
+ fields.push(current);
26
+ current = "";
27
+ }
28
+ else {
29
+ current += ch;
30
+ }
31
+ }
32
+ fields.push(current);
33
+ return fields;
34
+ }
@@ -2,6 +2,7 @@ import { stat, realpath } from "fs/promises";
2
2
  import { resolve, extname, isAbsolute } from "path";
3
3
  import { existsSync, realpathSync } from "fs";
4
4
  import { homedir } from "os";
5
+ import { getProjectRoot } from "./paths.js";
5
6
  /**
6
7
  * Allowed root directories for file reads. Configurable via EARVELDAJA_ALLOWED_PATHS
7
8
  * (colon-separated list). Defaults to $HOME and /tmp.
@@ -21,23 +22,6 @@ function getAllowedRoots() {
21
22
  }
22
23
  });
23
24
  }
24
- /**
25
- * Get the project root (directory containing package.json).
26
- * Falls back to process.cwd().
27
- */
28
- function getProjectRoot() {
29
- // When running from dist/ or src/, go up until we find package.json
30
- let dir = import.meta.dirname;
31
- for (let i = 0; i < 5; i++) {
32
- if (existsSync(resolve(dir, "package.json")))
33
- return dir;
34
- const parent = resolve(dir, "..");
35
- if (parent === dir)
36
- break;
37
- dir = parent;
38
- }
39
- return process.cwd();
40
- }
41
25
  /**
42
26
  * Resolve a file path. For relative paths, tries:
43
27
  * 1. Parent of project root (where accounting documents typically live)
@@ -13,6 +13,10 @@ export declare class HttpClient {
13
13
  private readonly minIntervalMs;
14
14
  constructor(config: Config, cacheNamespace?: string);
15
15
  private waitForRateLimitTurn;
16
+ private static sleep;
17
+ private static shouldRetryStatus;
18
+ private static isRetryableError;
19
+ private static formatNetworkError;
16
20
  request<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
17
21
  private static getPublicIp;
18
22
  get<T = unknown>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T>;
@@ -1,4 +1,6 @@
1
1
  import { createAuthHeaders } from "./auth.js";
2
+ const MAX_RETRIES = 3;
3
+ const INITIAL_RETRY_DELAY_MS = 1_000;
2
4
  export class HttpClient {
3
5
  config;
4
6
  cacheNamespace;
@@ -22,8 +24,22 @@ export class HttpClient {
22
24
  this.lastRequest = waitTurn;
23
25
  await waitTurn;
24
26
  }
27
+ static async sleep(ms) {
28
+ await new Promise(resolve => setTimeout(resolve, ms));
29
+ }
30
+ static shouldRetryStatus(status) {
31
+ return status === 429 || status >= 500;
32
+ }
33
+ static isRetryableError(error) {
34
+ return error instanceof Error && (error.name === "AbortError" ||
35
+ error.name === "TypeError" ||
36
+ /fetch failed|network/i.test(error.message));
37
+ }
38
+ static formatNetworkError(method, path, error) {
39
+ const suffix = error instanceof Error && error.message ? `: ${error.message}` : "";
40
+ return new Error(`API request failed: ${method} ${path} → network error${suffix}`);
41
+ }
25
42
  async request(path, options = {}) {
26
- await this.waitForRateLimitTurn();
27
43
  const { method = "GET", body, params } = options;
28
44
  // Build full URL: baseUrl already includes /v1
29
45
  const fullUrl = `${this.config.baseUrl}${path}`;
@@ -45,56 +61,74 @@ export class HttpClient {
45
61
  if (body !== undefined) {
46
62
  headers["Content-Type"] = "application/json";
47
63
  }
48
- const controller = new AbortController();
49
- const timeoutId = setTimeout(() => controller.abort(), 60_000);
50
- try {
51
- const response = await fetch(url.toString(), {
52
- method,
53
- headers,
54
- body: body !== undefined ? JSON.stringify(body) : undefined,
55
- signal: controller.signal,
56
- });
57
- if (!response.ok) {
58
- // Parse structured error if available, but don't expose raw upstream details
59
- let errorMessage = `API request failed: ${method} ${path} → ${response.status}`;
64
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
65
+ await this.waitForRateLimitTurn();
66
+ const controller = new AbortController();
67
+ const timeoutId = setTimeout(() => controller.abort(), 60_000);
68
+ try {
69
+ let response;
60
70
  try {
61
- const body = await response.json();
62
- if (body.messages && Array.isArray(body.messages)) {
63
- errorMessage += `: ${body.messages.join("; ")}`;
71
+ response = await fetch(url.toString(), {
72
+ method,
73
+ headers,
74
+ body: body !== undefined ? JSON.stringify(body) : undefined,
75
+ signal: controller.signal,
76
+ });
77
+ }
78
+ catch (error) {
79
+ if (HttpClient.isRetryableError(error) && attempt < MAX_RETRIES) {
80
+ await HttpClient.sleep(INITIAL_RETRY_DELAY_MS * (2 ** attempt));
81
+ continue;
64
82
  }
83
+ throw HttpClient.formatNetworkError(method, path, error);
65
84
  }
66
- catch {
67
- // Non-JSON error body don't expose raw text
85
+ if (!response.ok) {
86
+ if (HttpClient.shouldRetryStatus(response.status) && attempt < MAX_RETRIES) {
87
+ await HttpClient.sleep(INITIAL_RETRY_DELAY_MS * (2 ** attempt));
88
+ continue;
89
+ }
90
+ // Parse structured error if available, but don't expose raw upstream details
91
+ let errorMessage = `API request failed: ${method} ${path} → ${response.status}`;
92
+ try {
93
+ const body = await response.json();
94
+ if (body.messages && Array.isArray(body.messages)) {
95
+ errorMessage += `: ${body.messages.join("; ")}`;
96
+ }
97
+ }
98
+ catch {
99
+ // Non-JSON error body — don't expose raw text
100
+ }
101
+ if (response.status === 401) {
102
+ const publicIp = await HttpClient.getPublicIp();
103
+ errorMessage += `\n\nTroubleshooting 401 Unauthorized:\n` +
104
+ ` 1. Is the API key downloaded and configured? Check apikey*.txt or environment variables.\n` +
105
+ ` 2. Is this machine's IP address allowed in e-arveldaja API settings?\n` +
106
+ ` Your public IP: ${publicIp}\n` +
107
+ ` 3. Add the IP above to: e-arveldaja → Seaded → API võtmed → Lubatud IP-aadressid\n` +
108
+ ` Multiple IP addresses can be added, separated by ;`;
109
+ }
110
+ throw new Error(errorMessage);
68
111
  }
69
- if (response.status === 401) {
70
- const publicIp = await HttpClient.getPublicIp();
71
- errorMessage += `\n\nTroubleshooting 401 Unauthorized:\n` +
72
- ` 1. Is the API key downloaded and configured? Check apikey*.txt or environment variables.\n` +
73
- ` 2. Is this machine's IP address allowed in e-arveldaja API settings?\n` +
74
- ` Your public IP: ${publicIp}\n` +
75
- ` 3. Add the IP above to: e-arveldaja → Seaded → API võtmed → Lubatud IP-aadressid\n` +
76
- ` Multiple IP addresses can be added, separated by ;`;
112
+ if (response.status === 204) {
113
+ return undefined;
77
114
  }
78
- throw new Error(errorMessage);
79
- }
80
- if (response.status === 204) {
81
- return undefined;
115
+ const contentType = response.headers.get("content-type") ?? "";
116
+ if (contentType.includes("application/json")) {
117
+ return response.json();
118
+ }
119
+ // Binary response (e.g. PDF document download) — return as ApiFile-compatible object
120
+ const arrayBuf = await response.arrayBuffer();
121
+ const base64 = Buffer.from(arrayBuf).toString("base64");
122
+ const disposition = response.headers.get("content-disposition") ?? "";
123
+ const nameMatch = disposition.match(/filename="?([^";\n]+)"?/);
124
+ const name = nameMatch?.[1] ?? "document";
125
+ return { name, contents: base64 };
82
126
  }
83
- const contentType = response.headers.get("content-type") ?? "";
84
- if (contentType.includes("application/json")) {
85
- return response.json();
127
+ finally {
128
+ clearTimeout(timeoutId);
86
129
  }
87
- // Binary response (e.g. PDF document download) — return as ApiFile-compatible object
88
- const arrayBuf = await response.arrayBuffer();
89
- const base64 = Buffer.from(arrayBuf).toString("base64");
90
- const disposition = response.headers.get("content-disposition") ?? "";
91
- const nameMatch = disposition.match(/filename="?([^";\n]+)"?/);
92
- const name = nameMatch?.[1] ?? "document";
93
- return { name, contents: base64 };
94
- }
95
- finally {
96
- clearTimeout(timeoutId);
97
130
  }
131
+ throw new Error(`API request failed: ${method} ${path} → retries exhausted`);
98
132
  }
99
133
  static async getPublicIp() {
100
134
  try {
package/dist/index.js CHANGED
@@ -93,7 +93,7 @@ async function main() {
93
93
  const api = createScopedApiContext(connectionState, connectionContexts, invocationStorage);
94
94
  const server = new McpServer({
95
95
  name: "e-arveldaja",
96
- version: "0.3.2",
96
+ version: "0.4.0",
97
97
  description: "EXPERIMENTAL, UNOFFICIAL MCP server for the Estonian e-arveldaja (e-Financials) API. " +
98
98
  "NOT affiliated with or endorsed by RIK. Use entirely at your own risk — " +
99
99
  "this software interacts with live financial data and can create, modify, and delete accounting records. " +
@@ -167,9 +167,10 @@ Reporting:
167
167
  }
168
168
  const target = allConfigs[index];
169
169
  const previousIndex = connectionState.activeIndex;
170
- clearAllCaches(previousIndex);
171
- connectionState.activeIndex = index;
172
170
  connectionState.generation += 1;
171
+ connectionState.activeIndex = index;
172
+ clearAllCaches(previousIndex);
173
+ clearAllCaches(index);
173
174
  const snapshot = captureSnapshot(connectionState);
174
175
  return {
175
176
  content: [{
@@ -201,6 +202,7 @@ Reporting:
201
202
  });
202
203
  }
203
204
  catch (error) {
205
+ process.stderr.write(`Tool handler error: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
204
206
  return toolError(error);
205
207
  }
206
208
  });
package/dist/money.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Round to 2 decimal places (cents). Use for all monetary arithmetic. */
2
- export const roundMoney = (v) => Math.round(v * 100) / 100;
2
+ export const roundMoney = (v) => Math.sign(v) * Math.round((Math.abs(v) + Number.EPSILON) * 100) / 100;
@@ -0,0 +1,2 @@
1
+ /** Find project root by walking up from import.meta.dirname to package.json. */
2
+ export declare function getProjectRoot(): string;
package/dist/paths.js ADDED
@@ -0,0 +1,15 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ /** Find project root by walking up from import.meta.dirname to package.json. */
4
+ export function getProjectRoot() {
5
+ let dir = import.meta.dirname;
6
+ for (let i = 0; i < 5; i++) {
7
+ if (existsSync(resolve(dir, "package.json")))
8
+ return dir;
9
+ const parent = resolve(dir, "..");
10
+ if (parent === dir)
11
+ break;
12
+ dir = parent;
13
+ }
14
+ return process.cwd();
15
+ }
@@ -189,9 +189,10 @@ export function registerBankReconciliationTools(server, api) {
189
189
  }
190
190
  // Only auto-confirm if exactly one high-confidence match
191
191
  if (candidates.length === 1) {
192
- consumedInvoiceKeys.add(`${candidates[0].type.replace("_invoice", "")}:${candidates[0].id}`);
193
192
  const match = candidates[0];
193
+ const invoiceKey = `${match.type.replace("_invoice", "")}:${match.id}`;
194
194
  if (dryRun) {
195
+ consumedInvoiceKeys.add(invoiceKey);
195
196
  confirmed.push({
196
197
  transaction_id: tx.id,
197
198
  amount: tx.amount,
@@ -207,6 +208,7 @@ export function registerBankReconciliationTools(server, api) {
207
208
  related_id: match.id,
208
209
  amount: tx.amount,
209
210
  }]);
211
+ consumedInvoiceKeys.add(invoiceKey);
210
212
  confirmed.push({
211
213
  transaction_id: tx.id,
212
214
  amount: tx.amount,
@@ -215,7 +217,7 @@ export function registerBankReconciliationTools(server, api) {
215
217
  });
216
218
  }
217
219
  catch (err) {
218
- skipped.push({ transaction_id: tx.id, reason: err.message });
220
+ skipped.push({ transaction_id: tx.id, reason: err instanceof Error ? err.message : String(err) });
219
221
  }
220
222
  }
221
223
  }
@@ -20,5 +20,8 @@ export interface ApiContext {
20
20
  export declare function isCompanyVatRegistered(api: ApiContext): Promise<boolean>;
21
21
  export declare const MAX_JSON_INPUT_SIZE: number;
22
22
  export declare function safeJsonParse(input: string, label: string): unknown;
23
+ export declare function parseJsonObject(input: string, label: string): Record<string, unknown>;
24
+ export declare function parseJsonObjectArray(input: string, label: string): Record<string, unknown>[];
25
+ export declare function requireFields(items: Record<string, unknown>[], label: string, fields: string[]): void;
23
26
  export declare function parsePurchaseInvoiceItems(input: string): PurchaseInvoiceItem[];
24
27
  export declare function registerCrudTools(server: McpServer, api: ApiContext): void;
@@ -21,14 +21,14 @@ export function safeJsonParse(input, label) {
21
21
  function isRecord(value) {
22
22
  return typeof value === "object" && value !== null && !Array.isArray(value);
23
23
  }
24
- function parseJsonObject(input, label) {
24
+ export function parseJsonObject(input, label) {
25
25
  const parsed = safeJsonParse(input, label);
26
26
  if (!isRecord(parsed)) {
27
27
  throw new Error(`"${label}" must be a JSON object`);
28
28
  }
29
29
  return parsed;
30
30
  }
31
- function parseJsonObjectArray(input, label) {
31
+ export function parseJsonObjectArray(input, label) {
32
32
  const parsed = safeJsonParse(input, label);
33
33
  if (!Array.isArray(parsed)) {
34
34
  throw new Error(`"${label}" must be a JSON array`);
@@ -40,7 +40,7 @@ function parseJsonObjectArray(input, label) {
40
40
  });
41
41
  return parsed;
42
42
  }
43
- function requireFields(items, label, fields) {
43
+ export function requireFields(items, label, fields) {
44
44
  items.forEach((item, index) => {
45
45
  for (const field of fields) {
46
46
  if (!(field in item) || item[field] === null || item[field] === undefined || item[field] === "") {
@@ -79,6 +79,8 @@ const pageParam = z.object({
79
79
  modified_since: z.string().optional().describe("Return only objects modified since this timestamp (ISO 8601)"),
80
80
  });
81
81
  const idParam = z.object({ id: z.number().describe("Object ID") });
82
+ const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/;
83
+ const isoDateString = (description) => z.string().regex(isoDateRegex, "Expected YYYY-MM-DD").describe(description);
82
84
  export function registerCrudTools(server, api) {
83
85
  // =====================
84
86
  // CLIENTS
@@ -192,7 +194,7 @@ export function registerCrudTools(server, api) {
192
194
  });
193
195
  server.tool("create_journal", "Create a journal entry with postings", {
194
196
  title: z.string().optional().describe("Journal entry title"),
195
- effective_date: z.string().describe("Entry date (YYYY-MM-DD)"),
197
+ effective_date: isoDateString("Entry date (YYYY-MM-DD)"),
196
198
  clients_id: z.number().optional().describe("Related client ID"),
197
199
  document_number: z.string().optional().describe("Document number"),
198
200
  cl_currencies_id: z.string().optional().describe("Currency (default EUR)"),
@@ -240,7 +242,7 @@ export function registerCrudTools(server, api) {
240
242
  type: z.string().describe("Transaction type: D (incoming) or C (outgoing)"),
241
243
  amount: z.number().describe("Transaction amount"),
242
244
  cl_currencies_id: z.string().optional().describe("Currency (default EUR)"),
243
- date: z.string().describe("Transaction date (YYYY-MM-DD)"),
245
+ date: isoDateString("Transaction date (YYYY-MM-DD)"),
244
246
  description: z.string().optional().describe("Description"),
245
247
  clients_id: z.number().optional().describe("Related client ID"),
246
248
  bank_account_name: z.string().optional().describe("Remitter/beneficiary name"),
@@ -283,8 +285,8 @@ export function registerCrudTools(server, api) {
283
285
  clients_id: z.number().describe("Buyer client ID"),
284
286
  cl_templates_id: z.number().describe("Invoice template ID"),
285
287
  number_suffix: z.string().optional().describe("Invoice number suffix (omit or empty string for auto-assign from invoice series)"),
286
- create_date: z.string().describe("Invoice date (YYYY-MM-DD)"),
287
- journal_date: z.string().describe("Turnover date (YYYY-MM-DD)"),
288
+ create_date: isoDateString("Invoice date (YYYY-MM-DD)"),
289
+ journal_date: isoDateString("Turnover date (YYYY-MM-DD)"),
288
290
  term_days: z.number().describe("Payment term in days"),
289
291
  cl_currencies_id: z.string().optional().describe("Currency (default EUR)"),
290
292
  cl_countries_id: z.string().optional().describe("Country (default EST)"),
@@ -353,8 +355,8 @@ export function registerCrudTools(server, api) {
353
355
  clients_id: z.number().describe("Supplier client ID"),
354
356
  client_name: z.string().describe("Supplier name"),
355
357
  number: z.string().describe("Invoice number"),
356
- create_date: z.string().describe("Invoice date (YYYY-MM-DD)"),
357
- journal_date: z.string().describe("Turnover date (YYYY-MM-DD)"),
358
+ create_date: isoDateString("Invoice date (YYYY-MM-DD)"),
359
+ journal_date: isoDateString("Turnover date (YYYY-MM-DD)"),
358
360
  term_days: z.number().describe("Payment term in days"),
359
361
  vat_price: z.number().optional().describe("Total VAT amount from original invoice (EXACT, for payment matching)"),
360
362
  gross_price: z.number().optional().describe("Total gross amount from original invoice (EXACT, for payment matching)"),
@@ -369,7 +371,7 @@ export function registerCrudTools(server, api) {
369
371
  const purchaseArticles = await getPurchaseArticlesWithVat(api);
370
372
  const rawItems = parsePurchaseInvoiceItems(params.items);
371
373
  const items = rawItems.map(item => applyPurchaseVatDefaults(purchaseArticles, item, isVatReg));
372
- const result = await api.purchaseInvoices.createAndSetTotals({
374
+ const invoiceData = {
373
375
  clients_id: params.clients_id,
374
376
  client_name: params.client_name,
375
377
  number: params.number,
@@ -382,7 +384,8 @@ export function registerCrudTools(server, api) {
382
384
  bank_account_no: params.bank_account_no,
383
385
  notes: params.notes,
384
386
  items,
385
- }, params.vat_price, params.gross_price, isVatReg);
387
+ };
388
+ const result = await api.purchaseInvoices.createAndSetTotals(invoiceData, params.vat_price, params.gross_price, isVatReg);
386
389
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
387
390
  });
388
391
  server.tool("update_purchase_invoice", "Update a purchase invoice", {
@@ -3,22 +3,7 @@ import { isCompanyVatRegistered } from "./crud-tools.js";
3
3
  import { computeAllBalances } from "./financial-statements.js";
4
4
  import { roundMoney } from "../money.js";
5
5
  import { create } from "../annotations.js";
6
- async function validateAccounts(api, ...accountIds) {
7
- const accounts = await api.readonly.getAccounts();
8
- const accountMap = new Map(accounts.map(account => [account.id, account]));
9
- const errors = [];
10
- for (const id of new Set(accountIds)) {
11
- const account = accountMap.get(id);
12
- if (!account) {
13
- errors.push(`Account ${id} not found in chart of accounts`);
14
- continue;
15
- }
16
- if (!account.is_valid) {
17
- errors.push(`Account ${id} (${account.name_est}) is inactive. Activate in e-arveldaja: Seaded → Kontoplaan → ${account.name_est} → mark as active.`);
18
- }
19
- }
20
- return errors;
21
- }
6
+ import { validateAccounts } from "../account-validation.js";
22
7
  async function computeRetainedEarningsBalance(api, accountId, asOfDate) {
23
8
  const allJournals = await api.journals.listAllWithPostings();
24
9
  let debit = 0;
@@ -63,7 +48,13 @@ export function registerEstonianTaxTools(server, api) {
63
48
  const taxAccount = tax_payable_account ?? 2540;
64
49
  const shareCapitalAccount = share_capital_account ?? 3000;
65
50
  // Validate all accounts exist in chart of accounts
66
- const accountErrors = await validateAccounts(api, retainedAccount, payableAccount, taxAccount, shareCapitalAccount);
51
+ const accounts = await api.readonly.getAccounts();
52
+ const accountErrors = validateAccounts(accounts, [
53
+ { id: retainedAccount, label: "Retained earnings account" },
54
+ { id: payableAccount, label: "Dividend payable account" },
55
+ { id: taxAccount, label: "Tax payable account" },
56
+ { id: shareCapitalAccount, label: "Share capital account" },
57
+ ]);
67
58
  if (accountErrors.length > 0) {
68
59
  return {
69
60
  content: [{
@@ -189,7 +180,12 @@ export function registerEstonianTaxTools(server, api) {
189
180
  const vat = vat_amount ?? roundMoney(net_amount * vat_rate);
190
181
  if (vat > 0 && vatRegistered)
191
182
  accountsToCheck.push(vatAcc);
192
- const accountErrors = await validateAccounts(api, ...accountsToCheck);
183
+ const accounts = await api.readonly.getAccounts();
184
+ const accountErrors = validateAccounts(accounts, [
185
+ { id: expense_account, label: "Expense account" },
186
+ ...(vat > 0 && vatRegistered ? [{ id: vatAcc, label: "VAT account" }] : []),
187
+ { id: payAcc, label: "Payable account" },
188
+ ]);
193
189
  if (accountErrors.length > 0) {
194
190
  return {
195
191
  content: [{
@@ -75,6 +75,7 @@ function getMonthLastDay(month) {
75
75
  const [year, monthNumber] = month.split("-").map(Number);
76
76
  return new Date(Date.UTC(year, monthNumber, 0)).getUTCDate();
77
77
  }
78
+ const monthRegex = /^\d{4}-\d{2}$/;
78
79
  export function registerFinancialStatementTools(server, api) {
79
80
  server.tool("compute_trial_balance", "Compute trial balance (käibeandmik/proovibilanss) from journal postings. " +
80
81
  "Shows debit/credit totals and balance for each account.", {
@@ -186,24 +187,23 @@ export function registerFinancialStatementTools(server, api) {
186
187
  });
187
188
  server.tool("month_end_close_checklist", "Generate month-end close checklist: unconfirmed journals/invoices, " +
188
189
  "unreconciled bank transactions, overdue receivables/payables.", {
189
- month: z.string().describe("Month to check (YYYY-MM, e.g. 2026-02)"),
190
+ month: z.string().regex(monthRegex, "Expected YYYY-MM").describe("Month to check (YYYY-MM, e.g. 2026-02)"),
190
191
  }, { ...readOnly, title: "Month-End Close Checklist" }, async ({ month }) => {
191
192
  const dateFrom = `${month}-01`;
192
193
  const lastDay = getMonthLastDay(month);
193
194
  const dateTo = `${month}-${String(lastDay).padStart(2, "0")}`;
194
- // Unconfirmed journals
195
- const allJournals = await api.journals.listAll();
195
+ const [allJournals, allTx, allSales, allPurchases] = await Promise.all([
196
+ api.journals.listAll(),
197
+ api.transactions.listAll(),
198
+ api.saleInvoices.listAll(),
199
+ api.purchaseInvoices.listAll(),
200
+ ]);
196
201
  const unconfirmedJournals = allJournals.filter(j => !j.is_deleted && !j.registered &&
197
202
  j.effective_date >= dateFrom && j.effective_date <= dateTo);
198
- // Unconfirmed transactions
199
- const allTx = await api.transactions.listAll();
200
203
  const unconfirmedTx = allTx.filter(tx => !tx.is_deleted && tx.status !== "CONFIRMED" &&
201
204
  tx.date >= dateFrom && tx.date <= dateTo);
202
- // Unconfirmed invoices
203
- const allSales = await api.saleInvoices.listAll();
204
205
  const unconfirmedSales = allSales.filter((inv) => inv.status === "PROJECT" &&
205
206
  inv.journal_date >= dateFrom && inv.journal_date <= dateTo);
206
- const allPurchases = await api.purchaseInvoices.listAll();
207
207
  const unconfirmedPurchases = allPurchases.filter((inv) => inv.status === "PROJECT" &&
208
208
  inv.journal_date >= dateFrom && inv.journal_date <= dateTo);
209
209
  // Overdue receivables (compare to month-end date for reproducibility)
@@ -4,6 +4,8 @@ import { validateFilePath } from "../file-validation.js";
4
4
  import { roundMoney } from "../money.js";
5
5
  import { readOnly, batch } from "../annotations.js";
6
6
  import { reportProgress } from "../progress.js";
7
+ import { parseCSVLine } from "../csv.js";
8
+ import { validateAccounts } from "../account-validation.js";
7
9
  const MAX_CSV_SIZE = 10 * 1024 * 1024; // 10 MB
8
10
  // BRICEKSP is Lightyear's money market cash fund - not a real investment
9
11
  const CASH_FUND_TICKER = "BRICEKSP";
@@ -22,42 +24,6 @@ function parseLightyearDate(d) {
22
24
  }
23
25
  return d;
24
26
  }
25
- function parseCSVLine(line) {
26
- const fields = [];
27
- let current = "";
28
- let inQuotes = false;
29
- for (let i = 0; i < line.length; i++) {
30
- const ch = line[i];
31
- if (inQuotes) {
32
- if (ch === '"') {
33
- if (i + 1 < line.length && line[i + 1] === '"') {
34
- current += '"';
35
- i++;
36
- }
37
- else {
38
- inQuotes = false;
39
- }
40
- }
41
- else {
42
- current += ch;
43
- }
44
- }
45
- else {
46
- if (ch === '"') {
47
- inQuotes = true;
48
- }
49
- else if (ch === ",") {
50
- fields.push(current);
51
- current = "";
52
- }
53
- else {
54
- current += ch;
55
- }
56
- }
57
- }
58
- fields.push(current);
59
- return fields;
60
- }
61
27
  function validateHeaders(actual, expected, label) {
62
28
  if (actual.length < expected.length) {
63
29
  throw new Error(`${label}: expected ${expected.length} columns, got ${actual.length}. ` +
@@ -431,25 +397,13 @@ export function registerLightyearTools(server, api) {
431
397
  const skipSet = new Set((skip_tickers ?? CASH_FUND_TICKER).split(",").map(t => t.trim()));
432
398
  // Validate accounts exist and are active
433
399
  const accounts = await api.readonly.getAccounts();
434
- const accountMap = new Map(accounts.map(a => [a.id, a]));
435
- const errors = [];
436
- function checkAccount(id, label) {
437
- const acc = accountMap.get(id);
438
- if (!acc) {
439
- errors.push(`${label} ${id} not found in chart of accounts. Activate it in e-arveldaja: Seaded → Kontoplaan → find account ${id} and enable it.`);
440
- }
441
- else if (!acc.is_valid) {
442
- errors.push(`${label} ${id} (${acc.name_est}) is inactive. Activate it in e-arveldaja: Seaded → Kontoplaan → ${acc.name_est} → mark as active.`);
443
- }
444
- }
445
- checkAccount(investment_account, "Investment account");
446
- checkAccount(broker_account, "Broker account");
447
- if (fee_account)
448
- checkAccount(fee_account, "Fee account");
449
- if (gain_loss_account)
450
- checkAccount(gain_loss_account, "Gain/loss account");
451
- if (loss_account)
452
- checkAccount(loss_account, "Loss account");
400
+ const errors = validateAccounts(accounts, [
401
+ { id: investment_account, label: "Investment account" },
402
+ { id: broker_account, label: "Broker account" },
403
+ ...(fee_account ? [{ id: fee_account, label: "Fee account" }] : []),
404
+ ...(gain_loss_account ? [{ id: gain_loss_account, label: "Gain/loss account" }] : []),
405
+ ...(loss_account ? [{ id: loss_account, label: "Loss account" }] : []),
406
+ ]);
453
407
  if (errors.length > 0) {
454
408
  return {
455
409
  content: [{
@@ -674,22 +628,12 @@ export function registerLightyearTools(server, api) {
674
628
  const fee_account = fee_account_param ?? 8610;
675
629
  // Validate accounts exist and are active
676
630
  const accounts = await api.readonly.getAccounts();
677
- const accountMap = new Map(accounts.map(a => [a.id, a]));
678
- const errors = [];
679
- function checkAccount(id, label) {
680
- const acc = accountMap.get(id);
681
- if (!acc) {
682
- errors.push(`${label} ${id} not found in chart of accounts. Activate it in e-arveldaja: Seaded → Kontoplaan → find account ${id} and enable it.`);
683
- }
684
- else if (!acc.is_valid) {
685
- errors.push(`${label} ${id} (${acc.name_est}) is inactive. Activate it in e-arveldaja: Seaded → Kontoplaan → ${acc.name_est} → mark as active.`);
686
- }
687
- }
688
- checkAccount(broker_account, "Broker account");
689
- checkAccount(income_account, "Income account");
690
- if (tax_account)
691
- checkAccount(tax_account, "Tax account");
692
- checkAccount(fee_account, "Fee account");
631
+ const errors = validateAccounts(accounts, [
632
+ { id: broker_account, label: "Broker account" },
633
+ { id: income_account, label: "Income account" },
634
+ ...(tax_account ? [{ id: tax_account, label: "Tax account" }] : []),
635
+ { id: fee_account, label: "Fee account" },
636
+ ]);
693
637
  if (errors.length > 0) {
694
638
  return {
695
639
  content: [{
@@ -371,6 +371,7 @@ export function registerPdfWorkflowTools(server, api) {
371
371
  notes: z.string().optional().describe("Notes (e.g. PDF filename)"),
372
372
  ref_number: z.string().optional().describe("Reference number"),
373
373
  bank_account_no: z.string().optional().describe("Supplier bank account"),
374
+ currency: z.string().optional().describe("Currency code (default EUR)"),
374
375
  }, { ...create, title: "Create Purchase Invoice from PDF" }, async (params) => {
375
376
  const supplier = await api.clients.get(params.supplier_client_id);
376
377
  const isVatReg = await isCompanyVatRegistered(api);
@@ -384,7 +385,7 @@ export function registerPdfWorkflowTools(server, api) {
384
385
  create_date: params.invoice_date,
385
386
  journal_date: params.journal_date,
386
387
  term_days: params.term_days,
387
- cl_currencies_id: "EUR",
388
+ cl_currencies_id: params.currency ?? "EUR",
388
389
  liability_accounts_id: params.liability_accounts_id ?? 2310,
389
390
  bank_ref_number: params.ref_number,
390
391
  bank_account_no: params.bank_account_no,
@@ -6,6 +6,7 @@ type PurchaseArticleWithVat = PurchaseArticle & {
6
6
  vat_rate_dropdown?: string | null;
7
7
  vat_rate?: number | null;
8
8
  };
9
+ export declare function normalizeVatRate(value: unknown): string | undefined;
9
10
  export declare function getPurchaseArticlesWithVat(api: ApiContext): Promise<PurchaseArticleWithVat[]>;
10
11
  export declare function applyPurchaseVatDefaults(purchaseArticles: PurchaseArticleWithVat[], item: PurchaseInvoiceItem, isVatRegistered: boolean): PurchaseInvoiceItem;
11
12
  export {};
@@ -16,7 +16,7 @@ function warnFallbackOnce(key, message) {
16
16
  function toNumber(value) {
17
17
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
18
18
  }
19
- function normalizeVatRate(value) {
19
+ export function normalizeVatRate(value) {
20
20
  if (typeof value === "number" && Number.isFinite(value)) {
21
21
  return String(value);
22
22
  }
@@ -3,6 +3,7 @@ import { readFile } from "fs/promises";
3
3
  import { validateFilePath } from "../file-validation.js";
4
4
  import { batch } from "../annotations.js";
5
5
  import { reportProgress } from "../progress.js";
6
+ import { parseCSVLine } from "../csv.js";
6
7
  const EXPECTED_HEADERS = [
7
8
  "ID", "Status", "Direction", "Created on", "Finished on",
8
9
  "Source fee amount", "Source fee currency", "Target fee amount", "Target fee currency",
@@ -10,32 +11,6 @@ const EXPECTED_HEADERS = [
10
11
  "Target name", "Target amount (after fees)", "Target currency",
11
12
  "Exchange rate", "Reference", "Batch", "Created by", "Category", "Note",
12
13
  ];
13
- function parseCSVLine(line) {
14
- const fields = [];
15
- let current = "";
16
- let inQuotes = false;
17
- for (let i = 0; i < line.length; i++) {
18
- const ch = line[i];
19
- if (ch === '"') {
20
- if (inQuotes && line[i + 1] === '"') {
21
- current += '"';
22
- i++;
23
- }
24
- else {
25
- inQuotes = !inQuotes;
26
- }
27
- }
28
- else if (ch === "," && !inQuotes) {
29
- fields.push(current);
30
- current = "";
31
- }
32
- else {
33
- current += ch;
34
- }
35
- }
36
- fields.push(current);
37
- return fields;
38
- }
39
14
  function parseWiseCSV(csv) {
40
15
  const lines = csv.trim().split("\n").filter(l => l.trim());
41
16
  if (lines.length < 2)
@@ -346,6 +346,9 @@ export interface PurchaseInvoice {
346
346
  settlements?: number[];
347
347
  transactions?: number[];
348
348
  }
349
+ export interface CreatePurchaseInvoiceData extends Pick<PurchaseInvoice, "clients_id" | "client_name" | "number" | "create_date" | "journal_date" | "term_days" | "cl_currencies_id" | "liability_accounts_id" | "bank_ref_number" | "bank_account_no" | "notes"> {
350
+ items: PurchaseInvoiceItem[];
351
+ }
349
352
  export interface InvoiceSeries {
350
353
  id?: number;
351
354
  is_active: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "e-arveldaja-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Estonian e-arveldaja (e-Financials) API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,7 +34,10 @@
34
34
  "build": "tsc",
35
35
  "start": "node dist/index.js",
36
36
  "dev": "tsx src/index.ts",
37
- "prepublishOnly": "npm run build"
37
+ "prepublishOnly": "npm run build",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "test:integration": "vitest run --config vitest.integration.config.ts"
38
41
  },
39
42
  "dependencies": {
40
43
  "@modelcontextprotocol/sdk": "^1.12.1",
@@ -47,6 +50,7 @@
47
50
  "@types/node": "^22.13.14",
48
51
  "@types/pdf-parse": "^1.1.4",
49
52
  "tsx": "^4.19.3",
50
- "typescript": "^5.8.2"
53
+ "typescript": "^5.8.2",
54
+ "vitest": "^4.1.0"
51
55
  }
52
56
  }