@ekwo-ai/mcp 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.
Files changed (58) hide show
  1. package/README.md +253 -0
  2. package/dist/backend.d.ts +24 -0
  3. package/dist/backend.d.ts.map +1 -0
  4. package/dist/backend.js +105 -0
  5. package/dist/backend.js.map +1 -0
  6. package/dist/bin.d.ts +15 -0
  7. package/dist/bin.d.ts.map +1 -0
  8. package/dist/bin.js +75 -0
  9. package/dist/bin.js.map +1 -0
  10. package/dist/columns.d.ts +59 -0
  11. package/dist/columns.d.ts.map +1 -0
  12. package/dist/columns.js +422 -0
  13. package/dist/columns.js.map +1 -0
  14. package/dist/config.d.ts +47 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +102 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/format.d.ts +21 -0
  19. package/dist/format.d.ts.map +1 -0
  20. package/dist/format.js +49 -0
  21. package/dist/format.js.map +1 -0
  22. package/dist/index.d.ts +19 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +19 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/postgrest.d.ts +35 -0
  27. package/dist/postgrest.d.ts.map +1 -0
  28. package/dist/postgrest.js +152 -0
  29. package/dist/postgrest.js.map +1 -0
  30. package/dist/rounding.d.ts +25 -0
  31. package/dist/rounding.d.ts.map +1 -0
  32. package/dist/rounding.js +35 -0
  33. package/dist/rounding.js.map +1 -0
  34. package/dist/schema.d.ts +28 -0
  35. package/dist/schema.d.ts.map +1 -0
  36. package/dist/schema.js +51 -0
  37. package/dist/schema.js.map +1 -0
  38. package/dist/server.d.ts +33 -0
  39. package/dist/server.d.ts.map +1 -0
  40. package/dist/server.js +521 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/sql.d.ts +36 -0
  43. package/dist/sql.d.ts.map +1 -0
  44. package/dist/sql.js +214 -0
  45. package/dist/sql.js.map +1 -0
  46. package/dist/tools/modules.d.ts +61 -0
  47. package/dist/tools/modules.d.ts.map +1 -0
  48. package/dist/tools/modules.js +335 -0
  49. package/dist/tools/modules.js.map +1 -0
  50. package/dist/tools/read.d.ts +233 -0
  51. package/dist/tools/read.d.ts.map +1 -0
  52. package/dist/tools/read.js +853 -0
  53. package/dist/tools/read.js.map +1 -0
  54. package/dist/tools/write.d.ts +343 -0
  55. package/dist/tools/write.d.ts.map +1 -0
  56. package/dist/tools/write.js +814 -0
  57. package/dist/tools/write.js.map +1 -0
  58. package/package.json +69 -0
@@ -0,0 +1,814 @@
1
+ /**
2
+ * The writing half.
3
+ *
4
+ * One rule holds everywhere here: the ledger is written by the schema, never
5
+ * by this package. `post_document`, `post_payment`, `post_entry`, `reconcile`
6
+ * and `unreconcile` carry the accounting rules — balance, numbering, period
7
+ * locks, the counterpart that is the difference of everything else — and this
8
+ * server calls them. Nothing in this file inserts an `entries` or an
9
+ * `entry_lines` row. Direct inserts are for the objects a person types:
10
+ * contacts, draft documents and their lines, payments, bank transactions.
11
+ *
12
+ * The second rule is that a refusal from the database is the answer. A
13
+ * `period_locked:` raise is not an error to be worked around by moving a
14
+ * date; it is the company telling the assistant that the month is closed.
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ import { StatementFileError, readCamt053 } from '@ekwo-ai/camt053';
18
+ import { StatementFileError as Cfonb120FileError, readCfonb120 } from '@ekwo-ai/cfonb120';
19
+ import { StatementFileError as CodaFileError, readCoda } from '@ekwo-ai/coda';
20
+ import { z } from 'zod';
21
+ import { EkwoMcpError } from '../backend.js';
22
+ import { DOC_TYPES, amountIn, columns, companyCurrency, idsByCode, moneyFields, only, } from '@ekwo-ai/core';
23
+ import { companyId, isoDate, uuid } from './read.js';
24
+ // What a contact, a document, a payment and a matching are written with moved
25
+ // to the core, where the command line calls the same functions. The inputs a
26
+ // model fills in stay here, beside their descriptions; the functions are
27
+ // exported from here under the names they always had.
28
+ export { createContact, createDocument, postDocument, reconcile, recordPayment, unreconcile, updateDocumentLines, } from '@ekwo-ai/core';
29
+ // ---------------------------------------------------------------------------
30
+ // Contacts
31
+ // ---------------------------------------------------------------------------
32
+ export const CreateContactInput = z.object({
33
+ company_id: companyId,
34
+ name: z.string().min(1),
35
+ contact_type: z.enum(['customer', 'supplier', 'both', 'employee', 'other']),
36
+ vat_number: z.string().min(1).optional(),
37
+ email: z.string().min(1).optional(),
38
+ country: z.string().length(2).optional().describe('ISO country code, e.g. BE.'),
39
+ payment_terms_days: z.number().int().min(0).max(365).optional(),
40
+ auxiliary_code: z.string().min(1).optional().describe('Sub-ledger code; the French FEC reports it.'),
41
+ address_line1: z.string().min(1).optional(),
42
+ postal_code: z.string().min(1).optional(),
43
+ city: z.string().min(1).optional(),
44
+ client_ref: z.string().min(1).max(200).optional().describe('Your own reference for this creation. Calling again with the same one returns what was created the first time (`replayed: true`) instead of creating a second — use it whenever a call might be repeated after a timeout.'),
45
+ });
46
+ // ---------------------------------------------------------------------------
47
+ // The working chart
48
+ // ---------------------------------------------------------------------------
49
+ export const PinAccountsInput = z.object({
50
+ company_id: companyId,
51
+ account_codes: z
52
+ .array(z.string().min(1))
53
+ .min(1)
54
+ .describe('Codes in this company\'s chart. list_accounts with include_all says what exists.'),
55
+ pinned: z
56
+ .boolean()
57
+ .optional()
58
+ .describe('True pins, false unpins. Defaults to true.'),
59
+ });
60
+ export async function pinAccounts(backend, args) {
61
+ const pinned = args.pinned ?? true;
62
+ const ids = await idsByCode(backend, 'accounts', args.company_id, args.account_codes);
63
+ const updated = await backend.update('accounts', { pinned }, [
64
+ { column: 'company_id', op: 'eq', value: args.company_id },
65
+ { column: 'id', op: 'in', value: [...ids.values()] },
66
+ ], ['code', 'name', 'pinned']);
67
+ if (updated.length === 0) {
68
+ throw new EkwoMcpError('not_found: none of those accounts could be changed. Either they are not in this company, or your role on it does not allow writing its chart.');
69
+ }
70
+ return { accounts: updated, pinned, count: updated.length };
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Products
74
+ // ---------------------------------------------------------------------------
75
+ const UNIT_CODE = z
76
+ .string()
77
+ .min(1)
78
+ .max(3)
79
+ .describe('UN/ECE recommendation 20: C62 a piece, HUR an hour, DAY a day, MON a month, KGM, LTR, MTR, KWH. Defaults to C62.');
80
+ export const CreateProductInput = z.object({
81
+ company_id: companyId,
82
+ code: z.string().min(1).describe("Your own reference for the item. Unique in the company, and what EN 16931 calls the seller's item identifier (BT-155)."),
83
+ name: z.string().min(1).describe('What appears on the invoice line (BT-153).'),
84
+ description: z.string().min(1).optional().describe('The longer text under the name (BT-154).'),
85
+ kind: z.enum(['service', 'goods']).optional().describe('Defaults to service. Goods and services are not taxed alike and do not feed the same declaration boxes.'),
86
+ unit_code: UNIT_CODE.optional(),
87
+ currency_code: z.string().length(3).optional(),
88
+ sale_price: z.union([z.string(), z.number()]).optional().describe('Net unit price on a sale. A line may still carry another.'),
89
+ purchase_price: z.union([z.string(), z.number()]).optional(),
90
+ sale_account_id: uuid.optional(),
91
+ sale_account_code: z.string().min(1).optional().describe('Income account a sale of this books to.'),
92
+ purchase_account_id: uuid.optional(),
93
+ purchase_account_code: z.string().min(1).optional().describe('Expense account a purchase of this books to.'),
94
+ sale_tax_id: uuid.optional(),
95
+ sale_tax_code: z.string().min(1).optional().describe('Tax applied when it is sold, by code.'),
96
+ purchase_tax_id: uuid.optional(),
97
+ purchase_tax_code: z.string().min(1).optional(),
98
+ });
99
+ /** Turns the four `*_code` arguments into ids, in one query per table. */
100
+ async function productReferences(backend, company, args) {
101
+ const [accounts, taxes] = await Promise.all([
102
+ idsByCode(backend, 'accounts', company, [args.sale_account_code, args.purchase_account_code].filter((c) => typeof c === 'string')),
103
+ idsByCode(backend, 'taxes', company, [args.sale_tax_code, args.purchase_tax_code].filter((c) => typeof c === 'string')),
104
+ ]);
105
+ return { accounts, taxes };
106
+ }
107
+ export async function createProduct(backend, args) {
108
+ const { accounts, taxes } = await productReferences(backend, args.company_id, args);
109
+ const currency = args.currency_code ?? (await companyCurrency(backend, args.company_id));
110
+ const created = only(await backend.insert('products', [
111
+ {
112
+ company_id: args.company_id,
113
+ code: args.code.trim(),
114
+ name: args.name,
115
+ description: args.description ?? null,
116
+ kind: args.kind ?? 'service',
117
+ unit_code: (args.unit_code ?? 'C62').toUpperCase(),
118
+ currency_code: currency,
119
+ sale_price: args.sale_price === undefined ? null : amountIn(args.sale_price),
120
+ purchase_price: args.purchase_price === undefined ? null : amountIn(args.purchase_price),
121
+ sale_account_id: args.sale_account_id ?? (args.sale_account_code === undefined ? null : accounts.get(args.sale_account_code)),
122
+ purchase_account_id: args.purchase_account_id ??
123
+ (args.purchase_account_code === undefined ? null : accounts.get(args.purchase_account_code)),
124
+ sale_tax_id: args.sale_tax_id ?? (args.sale_tax_code === undefined ? null : taxes.get(args.sale_tax_code)),
125
+ purchase_tax_id: args.purchase_tax_id ?? (args.purchase_tax_code === undefined ? null : taxes.get(args.purchase_tax_code)),
126
+ },
127
+ ], columns.PRODUCT), 'the product could not be created');
128
+ return {
129
+ product: created,
130
+ note: 'A product fills a line in and never constrains it: create_document takes product_code, and anything the line carries wins over it.',
131
+ };
132
+ }
133
+ export const UpdateProductInput = z.object({
134
+ product_id: uuid.optional(),
135
+ company_id: companyId.optional().describe('Needed with product_code, to say which company the code belongs to.'),
136
+ product_code: z.string().min(1).optional().describe('Instead of product_id, with company_id.'),
137
+ code: z.string().min(1).optional().describe('A new reference for it.'),
138
+ name: z.string().min(1).optional(),
139
+ description: z.string().nullable().optional(),
140
+ kind: z.enum(['service', 'goods']).optional(),
141
+ unit_code: UNIT_CODE.optional(),
142
+ sale_price: z.union([z.string(), z.number()]).nullable().optional(),
143
+ purchase_price: z.union([z.string(), z.number()]).nullable().optional(),
144
+ sale_account_code: z.string().min(1).optional(),
145
+ purchase_account_code: z.string().min(1).optional(),
146
+ sale_tax_code: z.string().min(1).optional(),
147
+ purchase_tax_code: z.string().min(1).optional(),
148
+ active: z.boolean().optional().describe('False retires it: searches stop offering it and the lines that already carry it are untouched.'),
149
+ });
150
+ /**
151
+ * Changes a product. What it does not do is reach into the documents that
152
+ * already reference it: a line keeps the text, the price and the account it
153
+ * was invoiced with, because an invoice is a statement about a day and a
154
+ * catalogue edited afterwards must not be able to rewrite it.
155
+ */
156
+ export async function updateProduct(backend, args) {
157
+ const where = args.product_id !== undefined
158
+ ? [{ column: 'id', op: 'eq', value: args.product_id }]
159
+ : [
160
+ { column: 'company_id', op: 'eq', value: args.company_id ?? '' },
161
+ { column: 'code', op: 'eq', value: args.product_code ?? '' },
162
+ ];
163
+ if (args.product_id === undefined && (args.company_id === undefined || args.product_code === undefined)) {
164
+ throw new EkwoMcpError('missing_product: give product_id, or company_id together with product_code.');
165
+ }
166
+ const existing = only(await backend.select({ table: 'products', columns: columns.PRODUCT, where }), args.product_id === undefined ? `product "${String(args.product_code)}"` : `product ${args.product_id}`);
167
+ const company = existing['company_id'];
168
+ const { accounts, taxes } = await productReferences(backend, company, args);
169
+ const patch = {};
170
+ if (args.code !== undefined)
171
+ patch['code'] = args.code.trim();
172
+ if (args.name !== undefined)
173
+ patch['name'] = args.name;
174
+ if (args.description !== undefined)
175
+ patch['description'] = args.description;
176
+ if (args.kind !== undefined)
177
+ patch['kind'] = args.kind;
178
+ if (args.unit_code !== undefined)
179
+ patch['unit_code'] = args.unit_code.toUpperCase();
180
+ if (args.sale_price !== undefined) {
181
+ patch['sale_price'] = args.sale_price === null ? null : amountIn(args.sale_price);
182
+ }
183
+ if (args.purchase_price !== undefined) {
184
+ patch['purchase_price'] = args.purchase_price === null ? null : amountIn(args.purchase_price);
185
+ }
186
+ if (args.sale_account_code !== undefined)
187
+ patch['sale_account_id'] = accounts.get(args.sale_account_code);
188
+ if (args.purchase_account_code !== undefined) {
189
+ patch['purchase_account_id'] = accounts.get(args.purchase_account_code);
190
+ }
191
+ if (args.sale_tax_code !== undefined)
192
+ patch['sale_tax_id'] = taxes.get(args.sale_tax_code);
193
+ if (args.purchase_tax_code !== undefined)
194
+ patch['purchase_tax_id'] = taxes.get(args.purchase_tax_code);
195
+ if (args.active !== undefined)
196
+ patch['active'] = args.active;
197
+ if (Object.keys(patch).length === 0) {
198
+ throw new EkwoMcpError('nothing_to_update: give at least one field to change.');
199
+ }
200
+ const updated = only(await backend.update('products', patch, [{ column: 'id', op: 'eq', value: existing['id'] }], columns.PRODUCT), 'the product could not be updated, and your role on that company may be the reason');
201
+ return {
202
+ product: updated,
203
+ note: 'Documents already booked are untouched: a line keeps the text, the price and the account it was invoiced with.',
204
+ };
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Documents
208
+ // ---------------------------------------------------------------------------
209
+ const LineInput = z.object({
210
+ name: z.string().min(1).optional().describe('What is billed, as it appears on the invoice. Required unless a product supplies it.'),
211
+ description: z.string().min(1).optional().describe('EN 16931 BT-154, under the name. A product supplies it when the line does not.'),
212
+ quantity: z.union([z.string(), z.number()]).optional().describe('Defaults to 1.'),
213
+ unit_code: z.string().min(1).max(3).optional().describe('Unit of measure, UN/ECE rec. 20: C62 a piece, HUR an hour, DAY a day, KGM, LTR, MTR. From the product, then C62.'),
214
+ unit_price: z.union([z.string(), z.number()]).optional().describe("Price of one unit, excluding tax, as a decimal string. Left out, the product's price; without either, the line is refused."),
215
+ discount_percent: z.union([z.string(), z.number()]).optional().describe('A percentage off this line, 0 to 99.'),
216
+ product_id: uuid.optional(),
217
+ product_code: z.string().min(1).optional().describe('A catalogue row, by code. It fills in the text, the price, the unit, the account and the tax; anything given on the line wins.'),
218
+ account_id: uuid.optional(),
219
+ account_code: z.string().min(1).optional().describe('The income or expense account, by code. Left out: the product, then the company default, then the one its country model names. A company with none of them refuses the line.'),
220
+ tax_id: uuid.optional(),
221
+ tax_code: z.string().min(1).optional().describe('The tax, by code, e.g. BE-S-21. Left out, the tax of the product. With no product either, the line books a base with no VAT box, which is not the same as 0 %.'),
222
+ });
223
+ export const CreateDocumentInput = z.object({
224
+ company_id: companyId,
225
+ doc_type: z.enum(DOC_TYPES),
226
+ contact_id: uuid,
227
+ document_date: isoDate,
228
+ due_date: isoDate.optional(),
229
+ accounting_date: isoDate.optional().describe('The date the entry is booked on, when it differs from the document date.'),
230
+ number: z.string().min(1).optional().describe('Your own number. Left out, posting takes the entry number.'),
231
+ supplier_reference: z.string().min(1).optional().describe("The supplier's own invoice number, on a purchase."),
232
+ currency_code: z.string().length(3).optional(),
233
+ journal_id: uuid.optional(),
234
+ payment_reference: z.string().min(1).optional(),
235
+ client_ref: z.string().min(1).max(200).optional().describe('Your own reference for this creation. Calling again with the same one returns what was created the first time (`replayed: true`) instead of creating a second — use it whenever a call might be repeated after a timeout.'),
236
+ lines: z.array(LineInput).min(1),
237
+ });
238
+ export const UpdateDocumentLinesInput = z.object({
239
+ document_id: uuid,
240
+ lines: z.array(LineInput).min(1).describe('The complete new set of lines; what is there now is replaced.'),
241
+ });
242
+ export const PostDocumentInput = z.object({
243
+ document_id: uuid,
244
+ dry_run: z
245
+ .boolean()
246
+ .optional()
247
+ .describe('True: the database posts for real and takes it back, and the entry it would have written is returned. Nothing is written; a refusal is the one posting would give.'),
248
+ });
249
+ // ---------------------------------------------------------------------------
250
+ // Payments and matching
251
+ // ---------------------------------------------------------------------------
252
+ export const RecordPaymentInput = z.object({
253
+ company_id: companyId,
254
+ direction: z.enum(['inbound', 'outbound']).optional().describe('inbound: a customer paid you. outbound: you paid a supplier. May be left out when document_id is given.'),
255
+ amount: z.union([z.string(), z.number()]).describe('A positive decimal string; the direction carries the sign.'),
256
+ payment_date: isoDate,
257
+ contact_id: uuid.optional().describe('Who paid or was paid. Needed for the payment to be matched against their invoices.'),
258
+ journal_id: uuid.optional(),
259
+ journal_code: z.string().min(1).optional().describe('The bank or cash journal, by code, e.g. BNK. Not needed when bank_account_id is given: the account knows its journal.'),
260
+ bank_account_id: uuid
261
+ .optional()
262
+ .describe('Which bank account the money moved on. list_bank_accounts says what exists. Given alone, it also names the journal; left out, the default account of the journal is used.'),
263
+ reference: z.string().min(1).optional(),
264
+ memo: z.string().min(1).optional(),
265
+ currency_code: z.string().length(3).optional().describe('Left out: the currency the company keeps its books in.'),
266
+ exchange_rate: z.union([z.string(), z.number()]).optional().describe('Units of the payment currency for one unit of the company currency, as a rate table states it. Only needed when the payment is in another currency; the realised difference against the invoice is booked at matching.'),
267
+ document_id: uuid.optional().describe('The posted document this money pays. It names the contact and the direction, read off what is still open on it, and the matching is offered to that document alone rather than to the oldest open items.'),
268
+ client_ref: z.string().min(1).max(200).optional().describe('Your own reference for this creation. Calling again with the same one returns what was created the first time (`replayed: true`) instead of creating a second — use it whenever a call might be repeated after a timeout.'),
269
+ match_open_items: z.boolean().optional().describe('Default true: match the payment against the oldest open invoices of that contact, up to the amount paid.'),
270
+ });
271
+ export const ReconcileInput = z.object({
272
+ line_a: uuid.describe('A ledger line to match. Either side; the schema works out which is the debit.'),
273
+ line_b: uuid.describe('The line it settles. Both must be on the same reconcilable account.'),
274
+ amount: z.union([z.string(), z.number()]).optional().describe('Left out: the smaller of the two open amounts.'),
275
+ });
276
+ export const UnreconcileInput = z.object({
277
+ reconciliation_id: uuid.describe('The matching to undo, as returned by reconcile or read from the ledger line.'),
278
+ });
279
+ // ---------------------------------------------------------------------------
280
+ // Bank
281
+ // ---------------------------------------------------------------------------
282
+ export const CreateBankAccountInput = z.object({
283
+ company_id: companyId,
284
+ iban: z.string().min(5).describe('The IBAN. Spaces are removed and the value is upper-cased; it is the natural key of a bank account in a company.'),
285
+ label: z.string().min(1).optional().describe('What it is called in the books. Defaults to the bank name, then to the IBAN.'),
286
+ bic: z.string().min(1).optional(),
287
+ bank_name: z.string().min(1).optional(),
288
+ currency_code: z.string().length(3).optional().describe("Defaults to the company's own currency."),
289
+ journal_id: uuid.optional(),
290
+ journal_code: z.string().min(1).optional().describe('The financial journal it books through. Left out, the bank journal of the company.'),
291
+ account_id: uuid.optional(),
292
+ account_code: z.string().min(1).optional().describe("The ledger account behind it. Left out, the journal's default account — 550000 in Belgium, 512000 in France."),
293
+ });
294
+ /**
295
+ * A bank account, wired to a journal and to a ledger account.
296
+ *
297
+ * Both of those have an answer already: `install_country_template` points the
298
+ * bank journal at the country's bank account, so neither has to be asked for.
299
+ * What nobody can derive is the IBAN, which is why this tool exists at all —
300
+ * an installation with no bank account has no IBAN to put on an invoice and
301
+ * nothing to reconcile a statement against.
302
+ */
303
+ export async function createBankAccount(backend, args) {
304
+ const iban = args.iban.replace(/\s+/g, '').toUpperCase();
305
+ let journalId = args.journal_id;
306
+ if (journalId === undefined && args.journal_code !== undefined) {
307
+ journalId = (await idsByCode(backend, 'journals', args.company_id, [args.journal_code])).get(args.journal_code);
308
+ }
309
+ const journals = await backend.select({
310
+ table: 'journals',
311
+ columns: ['id', 'code', 'name', 'journal_type', 'default_account_id', 'bank_account_id'],
312
+ where: [
313
+ { column: 'company_id', op: 'eq', value: args.company_id },
314
+ ...(journalId === undefined
315
+ ? [{ column: 'journal_type', op: 'eq', value: 'bank' }]
316
+ : [{ column: 'id', op: 'eq', value: journalId }]),
317
+ ],
318
+ order: [{ column: 'code' }],
319
+ });
320
+ const journal = journals[0];
321
+ if (journal === undefined) {
322
+ throw new EkwoMcpError(journalId === undefined
323
+ ? 'no_bank_journal: this company has no journal of type bank. get_company lists the journals; install_country_template creates them.'
324
+ : `not_found: journal ${String(journalId)}. Either it does not exist, or your role on that company does not allow this.`);
325
+ }
326
+ let accountId = args.account_id;
327
+ if (accountId === undefined && args.account_code !== undefined) {
328
+ accountId = (await idsByCode(backend, 'accounts', args.company_id, [args.account_code])).get(args.account_code);
329
+ }
330
+ accountId = accountId ?? journal['default_account_id'] ?? undefined;
331
+ if (accountId === undefined) {
332
+ throw new EkwoMcpError(`no_bank_ledger_account: journal ${String(journal['code'])} has no default account, so this bank account would book nowhere. Give account_code, or set the journal's default account.`);
333
+ }
334
+ const existing = await backend.select({
335
+ table: 'bank_accounts',
336
+ columns: columns.BANK_ACCOUNT,
337
+ where: [
338
+ { column: 'company_id', op: 'eq', value: args.company_id },
339
+ { column: 'iban', op: 'eq', value: iban },
340
+ ],
341
+ });
342
+ if (existing[0] !== undefined) {
343
+ return {
344
+ bank_account: existing[0],
345
+ created: false,
346
+ note: 'A bank account with this IBAN was already there; nothing was created.',
347
+ };
348
+ }
349
+ const currency = args.currency_code ?? (await companyCurrency(backend, args.company_id));
350
+ const created = only(await backend.insert('bank_accounts', [
351
+ {
352
+ company_id: args.company_id,
353
+ name: args.label ?? args.bank_name ?? iban,
354
+ iban,
355
+ bic: args.bic ?? null,
356
+ bank_name: args.bank_name ?? null,
357
+ currency_code: currency,
358
+ account_id: accountId,
359
+ journal_id: journal['id'],
360
+ },
361
+ ], columns.BANK_ACCOUNT), 'the bank account could not be created');
362
+ // The journal points back, so `post_payment` finds the money side from
363
+ // either direction. A journal that already names one keeps it.
364
+ if (journal['bank_account_id'] === null) {
365
+ await backend.update('journals', { bank_account_id: created['id'] }, [{ column: 'id', op: 'eq', value: journal['id'] }], ['id']);
366
+ }
367
+ return {
368
+ bank_account: created,
369
+ created: true,
370
+ journal: { id: journal['id'], code: journal['code'], name: journal['name'] },
371
+ note: 'Payments through this journal now book against this account. record_payment takes its id as bank_account_id.',
372
+ };
373
+ }
374
+ export const CreateBankTransactionInput = z.object({
375
+ company_id: companyId,
376
+ bank_account_id: uuid,
377
+ transaction_date: isoDate,
378
+ amount: z.union([z.string(), z.number()]).describe('Signed: positive is money in, negative is money out.'),
379
+ description: z.string().min(1).optional(),
380
+ counterpart_name: z.string().min(1).optional(),
381
+ counterpart_iban: z.string().min(1).optional(),
382
+ reference: z.string().min(1).optional(),
383
+ structured_reference: z.string().min(1).optional().describe('A structured communication, e.g. +++000/0000/00000+++ or RF…'),
384
+ statement_id: uuid.optional(),
385
+ contact_id: uuid.optional(),
386
+ value_date: isoDate.optional(),
387
+ });
388
+ export async function createBankTransaction(backend, args) {
389
+ const created = only(await backend.insert('bank_transactions', [
390
+ {
391
+ company_id: args.company_id,
392
+ bank_account_id: args.bank_account_id,
393
+ statement_id: args.statement_id ?? null,
394
+ transaction_date: args.transaction_date,
395
+ value_date: args.value_date ?? null,
396
+ amount: amountIn(args.amount),
397
+ description: args.description ?? null,
398
+ counterpart_name: args.counterpart_name ?? null,
399
+ counterpart_iban: args.counterpart_iban ?? null,
400
+ reference: args.reference ?? null,
401
+ structured_reference: args.structured_reference ?? null,
402
+ contact_id: args.contact_id ?? null,
403
+ },
404
+ ], columns.BANK_TRANSACTION), 'the bank transaction could not be created');
405
+ return {
406
+ transaction: created,
407
+ note: 'A statement line is not an entry. It waits as `pending` until a payment is recorded against it.',
408
+ };
409
+ }
410
+ /**
411
+ * The statement formats this server has a reader for. A pack names more than
412
+ * these — MT940, OFX, BAI2 — and a format is offered here the day a brick
413
+ * reads it, not the day a pack mentions it.
414
+ */
415
+ export const STATEMENT_FORMATS = ['camt.053', 'coda', 'cfonb120'];
416
+ /** What each format is delivered as, for the attachment the caller may have stored. */
417
+ const STATEMENT_MIME_TYPES = {
418
+ 'camt.053': 'application/xml',
419
+ coda: 'text/plain',
420
+ cfonb120: 'text/plain',
421
+ };
422
+ export const ImportBankStatementInput = z.object({
423
+ company_id: companyId,
424
+ format: z
425
+ .enum(STATEMENT_FORMATS)
426
+ .describe('What the file is. Required, and never guessed from the content: `camt.053` is the ISO 20022 XML statement, `coda` the coded statement of account of 128-character records, `cfonb120` the statement of 120-character records.'),
427
+ content: z.string().min(1).describe('The file itself, as text (UTF-8).'),
428
+ file_name: z.string().min(1).optional().describe('The name the file had, kept on the statement.'),
429
+ storage_path: z
430
+ .string()
431
+ .min(1)
432
+ .optional()
433
+ .describe('Where the caller stored the file, if it did. With it the file is recorded in attachments, on the statement; this server stores no bytes.'),
434
+ bank_account_id: uuid
435
+ .optional()
436
+ .describe('Only for a file of one statement whose account the company identifies otherwise than the file does. Left out, the account is found by the identifier the statement carries, and an unknown one is refused.'),
437
+ iban_country: z
438
+ .string()
439
+ .length(2)
440
+ .optional()
441
+ .describe('Only for `cfonb120`, which identifies an account by a bank code, a branch code and a number, and names no country. With the two letters of the country the account is held in, the statement is matched on the IBAN those make there; left out, on the three joined, as written. Never guessed.'),
442
+ });
443
+ export async function importBankStatement(backend, args) {
444
+ let file;
445
+ try {
446
+ if (args.format === 'coda')
447
+ file = readCoda(args.content);
448
+ else if (args.format === 'cfonb120') {
449
+ file = readCfonb120(args.content, args.iban_country === undefined ? {} : { ibanCountry: args.iban_country });
450
+ }
451
+ else
452
+ file = readCamt053(args.content);
453
+ }
454
+ catch (error) {
455
+ if (error instanceof StatementFileError ||
456
+ error instanceof CodaFileError ||
457
+ error instanceof Cfonb120FileError) {
458
+ throw new EkwoMcpError(`unreadable_statement_file: ${error.message}`, {
459
+ code: error.code,
460
+ hint: `Nothing was imported. The file is not a ${args.format} this server can read; the message says what stopped it.`,
461
+ });
462
+ }
463
+ throw error;
464
+ }
465
+ const bytes = Buffer.from(args.content, 'utf8');
466
+ const source = {
467
+ file_name: args.file_name ?? null,
468
+ checksum: `sha256:${createHash('sha256').update(bytes).digest('hex')}`,
469
+ byte_size: bytes.byteLength,
470
+ mime_type: STATEMENT_MIME_TYPES[args.format],
471
+ storage_path: args.storage_path ?? null,
472
+ };
473
+ const statements = await backend.rpc('import_bank_statement', {
474
+ p_company_id: args.company_id,
475
+ p_file: file,
476
+ p_source: source,
477
+ ...(args.bank_account_id !== undefined ? { p_bank_account_id: args.bank_account_id } : {}),
478
+ });
479
+ return {
480
+ format: args.format,
481
+ version: file.version,
482
+ version_verified: 'versionVerified' in file ? file.versionVerified : null,
483
+ checksum: source.checksum,
484
+ statements,
485
+ violations: file.violations,
486
+ note: 'A statement is not an entry: every imported line waits as `pending`, and nothing was booked or paid. Importing the same file again creates nothing; a statement that overlaps an earlier one imports only what is new (`lines_known` is the rest). `warnings` names a missing statement — an opening balance that is not the previous closing one — which is signalled and never refused.',
487
+ };
488
+ }
489
+ // ---------------------------------------------------------------------------
490
+ // Locks
491
+ // ---------------------------------------------------------------------------
492
+ // ---------------------------------------------------------------------------
493
+ // Members
494
+ // ---------------------------------------------------------------------------
495
+ // ---------------------------------------------------------------------------
496
+ // The company itself
497
+ // ---------------------------------------------------------------------------
498
+ export const CreateCompanyInput = z.object({
499
+ name: z.string().min(1),
500
+ country: z
501
+ .string()
502
+ .length(2)
503
+ .describe('Which country pack: its chart of accounts, its taxes and its declaration form. list_companies shows what the installation already uses.'),
504
+ currency_code: z.string().length(3).optional().describe("Left out, the country pack's."),
505
+ language: z
506
+ .string()
507
+ .length(2)
508
+ .optional()
509
+ .describe("Language the books are kept in — it decides the labels of the chart. Left out, the country pack's."),
510
+ chart_code: z.string().optional().describe('Which chart, where the country offers several.'),
511
+ fiscal_year: z.number().int().min(1900).max(2200).optional().describe('Calendar year the first financial year opens in. Default this year.'),
512
+ fiscal_year_start: isoDate
513
+ .optional()
514
+ .describe('First day of it. Left out, the month the country pack opens a year on — and a pack that names none is a refusal, not a January.'),
515
+ });
516
+ export async function createCompany(backend, args) {
517
+ const company = only(await backend.rpc('create_company', {
518
+ p_name: args.name,
519
+ p_country: args.country.toUpperCase(),
520
+ p_currency_code: args.currency_code?.toUpperCase() ?? null,
521
+ p_language: args.language?.toLowerCase() ?? null,
522
+ p_chart_code: args.chart_code ?? null,
523
+ p_fiscal_year: args.fiscal_year ?? null,
524
+ p_fiscal_year_start: args.fiscal_year_start ?? null,
525
+ }), 'the company could not be created. Creating one is an instance-level act: it needs an instance administrator');
526
+ const years = await backend.select({
527
+ table: 'fiscal_years',
528
+ columns: columns.FISCAL_YEAR,
529
+ where: [{ column: 'company_id', op: 'eq', value: company['id'] }],
530
+ order: [{ column: 'start_date' }],
531
+ });
532
+ return {
533
+ company,
534
+ fiscal_years: years,
535
+ note: 'The chart of accounts, the journals and the taxes of the country pack have been copied into it, and you are its first member.',
536
+ };
537
+ }
538
+ export const UpdateCompanyProfileInput = z.object({
539
+ company_id: companyId,
540
+ name: z.string().min(1).optional(),
541
+ trade_name: z.string().nullable().optional().describe('The name it trades under, if not the statutory one.'),
542
+ legal_name: z.string().nullable().optional(),
543
+ legal_form: z.string().nullable().optional(),
544
+ vat_number: z.string().nullable().optional(),
545
+ registration_number: z
546
+ .string()
547
+ .nullable()
548
+ .optional()
549
+ .describe('The number of the commercial register of its country. There is no second column for it.'),
550
+ address_line1: z.string().nullable().optional(),
551
+ address_line2: z.string().nullable().optional(),
552
+ postal_code: z.string().nullable().optional(),
553
+ city: z.string().nullable().optional(),
554
+ email: z.string().nullable().optional(),
555
+ phone: z.string().nullable().optional(),
556
+ website: z.string().nullable().optional(),
557
+ logo_url: z
558
+ .string()
559
+ .nullable()
560
+ .optional()
561
+ .describe('Where the logo is. A URL or a storage path: the core keeps no file.'),
562
+ share_capital: z
563
+ .string()
564
+ .nullable()
565
+ .optional()
566
+ .describe('A decimal string. Several legal forms must state it on every document.'),
567
+ share_capital_currency: z
568
+ .string()
569
+ .length(3)
570
+ .nullable()
571
+ .optional()
572
+ .describe("Left out, the company's own currency."),
573
+ activity_code: z.string().nullable().optional().describe('NACE, APE, SIC — the code itself.'),
574
+ activity_scheme: z.string().nullable().optional().describe('Which register the code belongs to.'),
575
+ default_bank_account_id: uuid
576
+ .nullable()
577
+ .optional()
578
+ .describe('Fills the payee IBAN of a sales document that names none. list_bank_accounts says what exists.'),
579
+ document_template: z.string().nullable().optional().describe('A code the renderer interprets.'),
580
+ });
581
+ export async function updateCompanyProfile(backend, args) {
582
+ const { company_id, ...rest } = args;
583
+ const patch = {};
584
+ for (const [key, value] of Object.entries(rest)) {
585
+ if (value !== undefined)
586
+ patch[key] = value;
587
+ }
588
+ if (Object.keys(patch).length === 0) {
589
+ throw new EkwoMcpError('nothing_to_change: name at least one field of the profile to change.');
590
+ }
591
+ const updated = only(await backend.update('companies', patch, [{ column: 'id', op: 'eq', value: company_id }], columns.COMPANY), 'the company could not be changed. Changing the company itself needs company.write, which the owner preset holds');
592
+ return { company: updated };
593
+ }
594
+ // ---------------------------------------------------------------------------
595
+ // Preferences
596
+ // ---------------------------------------------------------------------------
597
+ export const SetPreferencesInput = z.object({
598
+ preferred_company_id: uuid.nullable().optional().describe('The company to open on.'),
599
+ language: z
600
+ .string()
601
+ .nullable()
602
+ .optional()
603
+ .describe('Two letters, optionally a region. Labels are read in it first.'),
604
+ timezone: z.string().nullable().optional().describe('An IANA name.'),
605
+ date_display_format: z.string().nullable().optional().describe('How this person likes a date written.'),
606
+ number_display_format: z
607
+ .string()
608
+ .nullable()
609
+ .optional()
610
+ .describe('How this person likes a number written. Not the pattern a document number is built from, which belongs to the country.'),
611
+ theme: z.string().nullable().optional(),
612
+ });
613
+ export async function setPreferences(backend, args) {
614
+ // Only what the caller actually named crosses: an absent key leaves the
615
+ // preference alone and an explicit null clears it, which is a distinction
616
+ // the schema makes and this tool must not flatten.
617
+ const patch = {};
618
+ for (const [key, value] of Object.entries(args)) {
619
+ if (value !== undefined)
620
+ patch[key] = value;
621
+ }
622
+ const saved = only(await backend.rpc('set_preferences', { p_patch: patch }), 'the preferences could not be saved');
623
+ return { preferences: saved };
624
+ }
625
+ export const InviteMemberInput = z.object({
626
+ company_id: companyId,
627
+ email: z.string().min(3).describe('The address the invitation is for. Matched when it is accepted.'),
628
+ role: z
629
+ .enum(['owner', 'accountant', 'viewer', 'client'])
630
+ .optional()
631
+ .describe('The preset. Default viewer, which reads and changes nothing. client is the person whose company it is, invited by whoever keeps their books: it reads the same, and may also hand a file over (documents.deposit).'),
632
+ capabilities: z
633
+ .array(z.string())
634
+ .optional()
635
+ .describe('Capability codes granted on top of the preset, e.g. members.manage. get_company lists what this installation knows.'),
636
+ valid_for_days: z.number().int().min(1).max(365).optional().describe('Default 14.'),
637
+ });
638
+ export async function inviteMember(backend, args) {
639
+ const answer = only(await backend.rpc('invite_member', {
640
+ p_company_id: args.company_id,
641
+ p_email: args.email,
642
+ p_role: args.role ?? 'viewer',
643
+ p_capabilities: args.capabilities ?? [],
644
+ p_valid_for: `${args.valid_for_days ?? 14} days`,
645
+ }), 'the invitation could not be issued');
646
+ return {
647
+ invitation: answer,
648
+ note: 'The token is in this answer and nowhere else — only its hash is stored. Give it to the person you invited; they accept it signed in with the address above.',
649
+ };
650
+ }
651
+ export const CreateApiKeyInput = z.object({
652
+ company_id: companyId,
653
+ name: z.string().min(1).describe('What this key is for, in the words an operator will read a year from now.'),
654
+ capabilities: z
655
+ .array(z.string())
656
+ .min(1)
657
+ .describe('Exactly what the machine may do, e.g. ["bank.write"]. You cannot put a capability on a key that you do not hold yourself.'),
658
+ expires_at: z
659
+ .string()
660
+ .nullable()
661
+ .optional()
662
+ .describe('When it stops working, as a timestamp. Left out, it does not expire on its own.'),
663
+ });
664
+ export async function createApiKey(backend, args) {
665
+ const created = only(await backend.rpc('create_api_key', {
666
+ p_company_id: args.company_id,
667
+ p_name: args.name,
668
+ p_capabilities: args.capabilities,
669
+ p_expires_at: args.expires_at ?? null,
670
+ }), 'the key could not be issued');
671
+ return {
672
+ api_key: created,
673
+ note: 'The secret is in this answer and nowhere else — only its hash is stored. Show it to the user once, and tell them it cannot be read back.',
674
+ };
675
+ }
676
+ export const RevokeApiKeyInput = z.object({
677
+ api_key_id: uuid,
678
+ });
679
+ export async function revokeApiKey(backend, args) {
680
+ const revoked = only(await backend.rpc('revoke_api_key', { p_api_key_id: args.api_key_id }), 'the key could not be withdrawn');
681
+ return { api_key: revoked };
682
+ }
683
+ export const RevokeInvitationInput = z.object({
684
+ invitation_id: uuid,
685
+ });
686
+ export async function revokeInvitation(backend, args) {
687
+ const answer = only(await backend.rpc('revoke_invitation', { p_invitation_id: args.invitation_id }), 'the invitation could not be withdrawn');
688
+ return { invitation: answer };
689
+ }
690
+ export const ShareDocumentInput = z.object({
691
+ document_id: uuid.describe('The sales document to publish. A purchase document and a draft are refused.'),
692
+ expires_at: z
693
+ .string()
694
+ .nullable()
695
+ .optional()
696
+ .describe('When the link stops answering, as a timestamp. Left out, it answers until it is withdrawn.'),
697
+ });
698
+ export async function shareDocument(backend, args) {
699
+ const share = only(await backend.rpc('share_document', {
700
+ p_document_id: args.document_id,
701
+ p_expires_at: args.expires_at ?? null,
702
+ }), 'the link could not be created');
703
+ return {
704
+ share,
705
+ note: 'The token is in this answer and nowhere else — only its hash is stored. Give the url to the customer; anyone holding it can open the document without an account. A link is never edited: to change when it expires, withdraw it and make another. `url` is null when the installation has not recorded its public address.',
706
+ };
707
+ }
708
+ export const RevokeShareInput = z.object({
709
+ share_id: uuid,
710
+ });
711
+ export async function revokeShare(backend, args) {
712
+ const share = only(await backend.rpc('revoke_share', { p_share_id: args.share_id }), 'the link could not be withdrawn');
713
+ return { share };
714
+ }
715
+ export const LockPeriodInput = z.object({
716
+ company_id: companyId,
717
+ lock_date: isoDate.nullable().optional().describe('Nothing may be booked on or before this date. null lifts the lock.'),
718
+ tax_lock_date: isoDate.nullable().optional().describe('Additionally freezes anything carrying a VAT box. null lifts it.'),
719
+ });
720
+ export async function lockPeriod(backend, args) {
721
+ const patch = {};
722
+ if (args.lock_date !== undefined)
723
+ patch['lock_date'] = args.lock_date;
724
+ if (args.tax_lock_date !== undefined)
725
+ patch['tax_lock_date'] = args.tax_lock_date;
726
+ if (Object.keys(patch).length === 0) {
727
+ throw new EkwoMcpError('nothing_to_lock: give lock_date, tax_lock_date, or both.');
728
+ }
729
+ const rows = await backend.update('companies', patch, [{ column: 'id', op: 'eq', value: args.company_id }], ['id', 'name', 'lock_date::text', 'tax_lock_date::text']);
730
+ const company = rows[0];
731
+ if (company === undefined) {
732
+ throw new EkwoMcpError('not_owner: only an owner of the company may move its lock dates, and nothing was changed.');
733
+ }
734
+ return {
735
+ company,
736
+ note: 'Locking is enforced by triggers on the ledger, not by this server. Everything on or before the date now refuses to move.',
737
+ };
738
+ }
739
+ // ---------------------------------------------------------------------------
740
+ // Opening a set of books, and closing a year
741
+ //
742
+ // All three go straight to the schema function. The rules about which account
743
+ // the result travels through, which entries are written and when a year may
744
+ // be closed live in `close_fiscal_year`, next to the ones about balance and
745
+ // locks, and this server does not repeat a word of them.
746
+ // ---------------------------------------------------------------------------
747
+ export const OpeningBalanceInput = z.object({
748
+ company_id: companyId,
749
+ fiscal_year_id: uuid.describe('The year the balance opens. The entry is dated on its first day.'),
750
+ lines: z
751
+ .array(z.object({
752
+ account_code: z.string().min(1).describe('Code in this company\'s chart. list_accounts says what exists; the codes are the pack\'s, not ours.'),
753
+ debit: z.union([z.string(), z.number()]).optional().describe('A positive decimal string. A line carries a debit or a credit, never both.'),
754
+ credit: z.union([z.string(), z.number()]).optional(),
755
+ contact_id: uuid.optional().describe('The customer or supplier behind a receivable or payable line, so the aged balance knows whose it is.'),
756
+ label: z.string().min(1).optional(),
757
+ }))
758
+ .min(1)
759
+ .describe('The trial balance of the previous system, one entry per row. Total debit must equal total credit.'),
760
+ allow_result_accounts: z
761
+ .boolean()
762
+ .optional()
763
+ .describe('Default false: an opening balance is made of the balance sheet. Pass true only when taking books over in the middle of a year that has already run.'),
764
+ });
765
+ export async function openingBalance(backend, args) {
766
+ const lines = args.lines.map((line) => ({
767
+ account_code: line.account_code,
768
+ debit: amountIn(line.debit ?? 0),
769
+ credit: amountIn(line.credit ?? 0),
770
+ ...(line.contact_id === undefined ? {} : { contact_id: line.contact_id }),
771
+ ...(line.label === undefined ? {} : { label: line.label }),
772
+ }));
773
+ const answer = await backend.rpc('opening_balance', {
774
+ p_company_id: args.company_id,
775
+ p_fiscal_year_id: args.fiscal_year_id,
776
+ p_lines: lines,
777
+ p_allow_result_accounts: args.allow_result_accounts ?? false,
778
+ });
779
+ const entryId = only(answer, 'the opening balance produced no entry');
780
+ const entries = await backend.select({
781
+ table: 'entries',
782
+ columns: ['id', 'number', 'entry_date::text', 'description', 'state', 'kind', 'total_debit::text', 'total_credit::text'],
783
+ where: [{ column: 'id', op: 'eq', value: entryId }],
784
+ });
785
+ return {
786
+ entry: only(entries, `entry ${entryId}`),
787
+ note: 'The opening entry is posted. A year holds one; a second call is refused rather than adding to it.',
788
+ };
789
+ }
790
+ export const CloseFiscalYearInput = z.object({
791
+ fiscal_year_id: uuid.describe('The year to close. Every entry in it must be posted.'),
792
+ });
793
+ export async function closeFiscalYear(backend, args) {
794
+ const answer = await backend.rpc('close_fiscal_year', {
795
+ p_fiscal_year_id: args.fiscal_year_id,
796
+ });
797
+ return {
798
+ close: only(answer, `fiscal year ${args.fiscal_year_id}`),
799
+ note: 'The income statement is back at zero and the year refuses new entries. What a general meeting decides to do with the result — a dividend, a reserve — is a later entry, and the close never writes it.',
800
+ };
801
+ }
802
+ export const ReopenFiscalYearInput = z.object({
803
+ fiscal_year_id: uuid.describe('The closed year to open again.'),
804
+ });
805
+ export async function reopenFiscalYear(backend, args) {
806
+ const answer = await backend.rpc('reopen_fiscal_year', {
807
+ p_fiscal_year_id: args.fiscal_year_id,
808
+ });
809
+ return {
810
+ reopen: only(answer, `fiscal year ${args.fiscal_year_id}`),
811
+ note: 'The entries the close wrote are reversed, not deleted, and the year accepts entries again. Refused once a later year is closed or holds entries of its own.',
812
+ };
813
+ }
814
+ //# sourceMappingURL=write.js.map