@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,853 @@
1
+ /**
2
+ * The reading half.
3
+ *
4
+ * Every one of these is a query the signed-in user could have run themselves;
5
+ * nothing here widens what they may see. The reports come from the schema's
6
+ * own functions — `trial_balance`, `general_ledger`, `aged_balance`,
7
+ * `vat_return`, `fec_lines` — rather than from sums computed here, because a
8
+ * second implementation of a balance is a second answer to the same question.
9
+ */
10
+ import { checkFec, fecFileName, fromQueryRow, generateFec as renderFec, } from '@ekwo-ai/fec';
11
+ import { z } from 'zod';
12
+ import { EkwoMcpError } from '../backend.js';
13
+ import { DOC_TYPES, columns, money, moneyFields, namesOf, onlyVisible as only } from '@ekwo-ai/core';
14
+ // Moved to the core with the writing half they are read back by; exported
15
+ // from here under the names they always had.
16
+ export { getDocument, listDocuments, searchContacts } from '@ekwo-ai/core';
17
+ export const uuid = z.string().uuid();
18
+ export const isoDate = z
19
+ .string()
20
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'a calendar date, as YYYY-MM-DD');
21
+ export const companyId = uuid.describe('The company to work in. Ask list_companies if unsure.');
22
+ // ---------------------------------------------------------------------------
23
+ // Companies and their settings
24
+ // ---------------------------------------------------------------------------
25
+ export const ListCompaniesInput = z.object({});
26
+ export async function listCompanies(backend) {
27
+ const companies = await backend.select({
28
+ table: 'companies',
29
+ columns: ['id', 'name', 'country', 'fiscal_country', 'vat_number', 'currency_code'],
30
+ order: [{ column: 'name' }],
31
+ });
32
+ const roles = await backend.select({
33
+ table: 'company_members',
34
+ columns: ['company_id', 'role'],
35
+ });
36
+ const mine = new Map(roles.map((row) => [row.company_id, row.role]));
37
+ return {
38
+ companies: companies.map((company) => ({
39
+ ...company,
40
+ your_role: mine.get(company['id']) ?? null,
41
+ })),
42
+ };
43
+ }
44
+ export const GetCompanyInput = z.object({ company_id: companyId });
45
+ export async function getCompany(backend, args) {
46
+ const company = only(await backend.select({
47
+ table: 'companies',
48
+ columns: columns.COMPANY,
49
+ where: [{ column: 'id', op: 'eq', value: args.company_id }],
50
+ }), `company ${args.company_id}`);
51
+ const [years, journals, accounts] = await Promise.all([
52
+ backend.select({
53
+ table: 'fiscal_years',
54
+ columns: columns.FISCAL_YEAR,
55
+ where: [{ column: 'company_id', op: 'eq', value: args.company_id }],
56
+ order: [{ column: 'start_date' }],
57
+ }),
58
+ backend.select({
59
+ table: 'journals',
60
+ columns: columns.JOURNAL,
61
+ where: [{ column: 'company_id', op: 'eq', value: args.company_id }],
62
+ order: [{ column: 'code' }],
63
+ }),
64
+ backend.select({
65
+ table: 'accounts',
66
+ columns: ['id', 'code', 'name'],
67
+ where: [
68
+ {
69
+ column: 'id',
70
+ op: 'in',
71
+ value: [
72
+ company['receivable_account_id'],
73
+ company['payable_account_id'],
74
+ company['suspense_account_id'],
75
+ company['retained_earnings_account_id'],
76
+ ].filter((id) => typeof id === 'string'),
77
+ },
78
+ ],
79
+ }),
80
+ ]);
81
+ const byId = new Map(accounts.map((account) => [account.id, account]));
82
+ const named = (key) => {
83
+ const id = company[key];
84
+ return typeof id === 'string' ? (byId.get(id) ?? { id }) : null;
85
+ };
86
+ const packs = await backend.select({
87
+ table: 'company_packs',
88
+ columns: ['country', 'version', 'chart_code', 'installed_at'],
89
+ where: [{ column: 'company_id', op: 'eq', value: args.company_id }],
90
+ order: [{ column: 'country' }],
91
+ });
92
+ // Who is on the books, and what the person asking may actually do. A role
93
+ // is a preset here and nothing more: the capabilities are the answer, and
94
+ // a tool that reported the role alone would be reporting the label rather
95
+ // than the permission.
96
+ const members = await backend.select({
97
+ table: 'company_members',
98
+ columns: ['user_id', 'role', 'capabilities_granted', 'capabilities_revoked', 'created_at::text'],
99
+ where: [{ column: 'company_id', op: 'eq', value: args.company_id }],
100
+ });
101
+ // A function returning `setof text` comes back as a list of strings over
102
+ // PostgREST and as a list of one-column rows over Postgres. Both are read
103
+ // here, so the answer is the same list on either route.
104
+ const mine = await backend.rpc('member_capabilities', {
105
+ p_company_id: args.company_id,
106
+ });
107
+ const capabilities = mine
108
+ .map((row) => typeof row === 'string'
109
+ ? row
110
+ : row['member_capabilities'])
111
+ .filter((code) => typeof code === 'string');
112
+ return {
113
+ company,
114
+ country_packs: packs,
115
+ members,
116
+ your_capabilities: capabilities,
117
+ locks: {
118
+ lock_date: company['lock_date'],
119
+ tax_lock_date: company['tax_lock_date'],
120
+ note: 'Nothing may be booked on or before lock_date; tax_lock_date additionally freezes anything carrying a VAT box.',
121
+ },
122
+ default_accounts: {
123
+ receivable: named('receivable_account_id'),
124
+ payable: named('payable_account_id'),
125
+ suspense: named('suspense_account_id'),
126
+ retained_earnings: named('retained_earnings_account_id'),
127
+ },
128
+ fiscal_years: years,
129
+ journals,
130
+ };
131
+ }
132
+ // ---------------------------------------------------------------------------
133
+ // Reference data
134
+ // ---------------------------------------------------------------------------
135
+ export const ListAccountsInput = z.object({
136
+ company_id: companyId,
137
+ code_prefix: z.string().min(1).optional().describe('Only accounts whose code starts with this, e.g. "70".'),
138
+ account_type: z.string().min(1).optional().describe('One of the eighteen account types, e.g. asset_receivable.'),
139
+ search: z.string().min(1).optional().describe('Case-insensitive match on the account name.'),
140
+ in_use_from: isoDate
141
+ .optional()
142
+ .describe('Narrows the movements to entries on or after this date. References and pinned accounts are not dated and stay in.'),
143
+ in_use_to: isoDate.optional().describe('The other end of that period.'),
144
+ include_all: z
145
+ .boolean()
146
+ .optional()
147
+ .describe('Return the whole chart instead of the accounts in use. Use it when a search over the working chart found nothing.'),
148
+ include_deprecated: z
149
+ .boolean()
150
+ .optional()
151
+ .describe('Include deprecated accounts. A deprecated account is never in use, so this returns the whole chart.'),
152
+ limit: z.number().int().min(1).max(2000).optional(),
153
+ });
154
+ /**
155
+ * The account ids `accounts_in_use()` gives back.
156
+ *
157
+ * A function returning `setof uuid` arrives as a list of strings over
158
+ * PostgREST and as a list of one-column rows over Postgres, the same split
159
+ * `member_capabilities` has. Both are read here.
160
+ */
161
+ async function accountsInUse(backend, args) {
162
+ const rows = await backend.rpc('accounts_in_use', {
163
+ p_company_id: args.company_id,
164
+ p_from: args.in_use_from ?? null,
165
+ p_to: args.in_use_to ?? null,
166
+ });
167
+ return rows
168
+ .map((row) => typeof row === 'string'
169
+ ? row
170
+ : row['accounts_in_use'])
171
+ .filter((id) => typeof id === 'string');
172
+ }
173
+ export async function listAccounts(backend, args) {
174
+ const where = [{ column: 'company_id', op: 'eq', value: args.company_id }];
175
+ if (args.code_prefix !== undefined)
176
+ where.push({ column: 'code', op: 'ilike', value: `${args.code_prefix}%` });
177
+ if (args.account_type !== undefined)
178
+ where.push({ column: 'account_type', op: 'eq', value: args.account_type });
179
+ if (args.search !== undefined)
180
+ where.push({ column: 'name', op: 'ilike', value: `%${args.search}%` });
181
+ if (args.include_deprecated !== true)
182
+ where.push({ column: 'deprecated', op: 'eq', value: false });
183
+ // A country pack is a transcription of the regulation — three hundred
184
+ // accounts in Belgium, a thousand in Luxembourg — and a company works with a
185
+ // few dozen of them. The default is therefore the working chart the schema
186
+ // computes, and the whole thing is one flag away. Asking for deprecated
187
+ // accounts is asking for the whole chart by definition: a deprecated account
188
+ // is never in use.
189
+ const wholeChart = args.include_all === true || args.include_deprecated === true;
190
+ let scope = 'whole_chart';
191
+ if (!wholeChart) {
192
+ scope = 'in_use';
193
+ const ids = await accountsInUse(backend, args);
194
+ if (ids.length === 0) {
195
+ return {
196
+ accounts: [],
197
+ count: 0,
198
+ scope,
199
+ note: 'No account of this company is in use yet. Pass include_all to see the whole chart.',
200
+ };
201
+ }
202
+ where.push({ column: 'id', op: 'in', value: ids });
203
+ }
204
+ const accounts = await backend.select({
205
+ table: 'accounts',
206
+ columns: columns.ACCOUNT,
207
+ where,
208
+ order: [{ column: 'code' }],
209
+ limit: args.limit ?? 200,
210
+ });
211
+ return {
212
+ accounts,
213
+ count: accounts.length,
214
+ scope,
215
+ note: scope === 'in_use'
216
+ ? 'The accounts this company works with: moved, referenced by its settings, held by a module, or pinned. Pass include_all for the whole chart — any account of it may still be booked on.'
217
+ : 'The whole chart of the company.',
218
+ };
219
+ }
220
+ export const SearchContactsInput = z.object({
221
+ company_id: companyId,
222
+ query: z.string().min(1).optional().describe('Case-insensitive match on the contact name.'),
223
+ contact_type: z.enum(['customer', 'supplier', 'both', 'employee', 'other']).optional(),
224
+ vat_number: z.string().min(1).optional().describe('Exact match, e.g. BE0123456749.'),
225
+ limit: z.number().int().min(1).max(200).optional(),
226
+ });
227
+ // ---------------------------------------------------------------------------
228
+ // Documents
229
+ // ---------------------------------------------------------------------------
230
+ export const ListDocumentsInput = z.object({
231
+ company_id: companyId,
232
+ doc_type: z.enum(DOC_TYPES).optional(),
233
+ state: z.enum(['draft', 'posted', 'cancelled']).optional(),
234
+ payment_state: z.enum(['not_paid', 'partially_paid', 'paid', 'overpaid', 'reversed']).optional(),
235
+ contact_id: uuid.optional(),
236
+ from: isoDate.optional().describe('Earliest document date.'),
237
+ to: isoDate.optional().describe('Latest document date.'),
238
+ unpaid: z.boolean().optional().describe('True: posted and still owed — not paid, or partially.'),
239
+ limit: z.number().int().min(1).max(200).optional(),
240
+ });
241
+ export const GetDocumentInput = z.object({ document_id: uuid });
242
+ // ---------------------------------------------------------------------------
243
+ // Bank
244
+ // ---------------------------------------------------------------------------
245
+ export const ListBankTransactionsInput = z.object({
246
+ company_id: companyId,
247
+ bank_account_id: uuid.optional(),
248
+ state: z.enum(['pending', 'reconciled', 'ignored', 'all']).optional().describe('Defaults to pending: what still has to be dealt with.'),
249
+ from: isoDate.optional(),
250
+ to: isoDate.optional(),
251
+ limit: z.number().int().min(1).max(200).optional(),
252
+ });
253
+ export async function listBankTransactions(backend, args) {
254
+ const where = [{ column: 'company_id', op: 'eq', value: args.company_id }];
255
+ const state = args.state ?? 'pending';
256
+ if (state !== 'all')
257
+ where.push({ column: 'state', op: 'eq', value: state });
258
+ if (args.bank_account_id !== undefined)
259
+ where.push({ column: 'bank_account_id', op: 'eq', value: args.bank_account_id });
260
+ if (args.from !== undefined)
261
+ where.push({ column: 'transaction_date', op: 'gte', value: args.from });
262
+ if (args.to !== undefined)
263
+ where.push({ column: 'transaction_date', op: 'lte', value: args.to });
264
+ const [transactions, bankAccounts] = await Promise.all([
265
+ backend.select({
266
+ table: 'bank_transactions',
267
+ columns: columns.BANK_TRANSACTION,
268
+ where,
269
+ order: [{ column: 'transaction_date', ascending: false }],
270
+ limit: args.limit ?? 50,
271
+ }),
272
+ backend.select({
273
+ table: 'bank_accounts',
274
+ columns: columns.BANK_ACCOUNT,
275
+ where: [{ column: 'company_id', op: 'eq', value: args.company_id }],
276
+ order: [{ column: 'name' }],
277
+ }),
278
+ ]);
279
+ return { transactions, count: transactions.length, bank_accounts: bankAccounts };
280
+ }
281
+ // ---------------------------------------------------------------------------
282
+ // Reports
283
+ // ---------------------------------------------------------------------------
284
+ // ---------------------------------------------------------------------------
285
+ // Products
286
+ // ---------------------------------------------------------------------------
287
+ export const SearchProductsInput = z.object({
288
+ company_id: companyId,
289
+ query: z.string().min(1).optional().describe('Matches the code, the name or the description.'),
290
+ kind: z.enum(['service', 'goods']).optional(),
291
+ include_inactive: z.boolean().optional().describe('Default false: a retired product is hidden.'),
292
+ limit: z.number().int().min(1).max(200).optional(),
293
+ });
294
+ export async function searchProducts(backend, args) {
295
+ const where = [{ column: 'company_id', op: 'eq', value: args.company_id }];
296
+ if (args.kind !== undefined)
297
+ where.push({ column: 'kind', op: 'eq', value: args.kind });
298
+ if (args.include_inactive !== true)
299
+ where.push({ column: 'active', op: 'eq', value: true });
300
+ const products = await backend.select({
301
+ table: 'products',
302
+ columns: columns.PRODUCT,
303
+ where,
304
+ order: [{ column: 'code' }],
305
+ limit: args.limit ?? 100,
306
+ });
307
+ // The filter on the text is applied here rather than in three `ilike`
308
+ // clauses, because neither backend offers an OR and a second round trip per
309
+ // column would be the alternative.
310
+ const needle = args.query?.toLowerCase();
311
+ const matching = needle === undefined
312
+ ? products
313
+ : products.filter((product) => [product['code'], product['name'], product['description']]
314
+ .filter((value) => typeof value === 'string')
315
+ .some((value) => value.toLowerCase().includes(needle)));
316
+ return { products: matching };
317
+ }
318
+ export const ListBankAccountsInput = z.object({
319
+ company_id: companyId,
320
+ include_inactive: z.boolean().optional().describe('Default false.'),
321
+ });
322
+ export async function listBankAccounts(backend, args) {
323
+ const accounts = await backend.select({
324
+ table: 'bank_accounts',
325
+ columns: columns.BANK_ACCOUNT,
326
+ where: [
327
+ { column: 'company_id', op: 'eq', value: args.company_id },
328
+ ...(args.include_inactive === true
329
+ ? []
330
+ : [{ column: 'active', op: 'eq', value: true }]),
331
+ ],
332
+ order: [{ column: 'name' }],
333
+ });
334
+ return {
335
+ bank_accounts: accounts,
336
+ note: accounts.length === 0
337
+ ? 'This company has no bank account. create_bank_account adds one; until then a payment books on the default account of its journal.'
338
+ : undefined,
339
+ };
340
+ }
341
+ // ---------------------------------------------------------------------------
342
+ // Members and invitations
343
+ // ---------------------------------------------------------------------------
344
+ export const ListApiKeysInput = z.object({
345
+ company_id: companyId,
346
+ include_withdrawn: z.boolean().optional().describe('Also the ones withdrawn or expired. Default false.'),
347
+ });
348
+ export async function listApiKeys(backend, args) {
349
+ const keys = await backend.select({
350
+ table: 'api_keys',
351
+ columns: columns.API_KEY,
352
+ where: [{ column: 'company_id', op: 'eq', value: args.company_id }],
353
+ order: [{ column: 'created_at', ascending: false }],
354
+ });
355
+ const now = Date.now();
356
+ const described = keys.map((key) => ({ ...key, state: apiKeyState(key, now) }));
357
+ return {
358
+ api_keys: args.include_withdrawn === true
359
+ ? described
360
+ : described.filter((key) => key.state === 'live'),
361
+ note: 'The secret of a key exists only in the answer that created it. A key that is lost is withdrawn and issued again.',
362
+ };
363
+ }
364
+ function apiKeyState(key, now) {
365
+ if (key['revoked_at'] !== null && key['revoked_at'] !== undefined)
366
+ return 'withdrawn';
367
+ const expires = key['expires_at'];
368
+ if (typeof expires === 'string' && Date.parse(expires) <= now)
369
+ return 'expired';
370
+ return 'live';
371
+ }
372
+ export const GetPreferencesInput = z.object({
373
+ company_id: uuid
374
+ .optional()
375
+ .describe('Resolve the language chain against this company. Left out, only what the user themselves chose is returned.'),
376
+ });
377
+ export async function getPreferences(backend, args) {
378
+ const [stored] = await backend.select({
379
+ table: 'user_preferences',
380
+ columns: columns.USER_PREFERENCES,
381
+ limit: 1,
382
+ });
383
+ const chain = await backend.rpc('preferred_languages', {
384
+ p_company_id: args.company_id ?? null,
385
+ });
386
+ const languages = (chain[0] ?? []);
387
+ return {
388
+ preferences: stored ?? null,
389
+ languages: Array.isArray(languages) ? languages : (languages['preferred_languages'] ?? []),
390
+ note: 'Every preference may be null, and null is an answer: take the company\u2019s, then the country pack\u2019s. A label is picked with label_for(name, name_i18n, languages), in that order.',
391
+ };
392
+ }
393
+ export const ListInvitationsInput = z.object({
394
+ company_id: companyId,
395
+ include_settled: z
396
+ .boolean()
397
+ .optional()
398
+ .describe('Also the ones already accepted or withdrawn. Default false.'),
399
+ });
400
+ export async function listInvitations(backend, args) {
401
+ const invitations = await backend.select({
402
+ table: 'company_invitations',
403
+ columns: columns.INVITATION,
404
+ where: [{ column: 'company_id', op: 'eq', value: args.company_id }],
405
+ order: [{ column: 'created_at', ascending: false }],
406
+ });
407
+ const now = Date.now();
408
+ const described = invitations.map((invitation) => ({
409
+ ...invitation,
410
+ state: invitationState(invitation, now),
411
+ }));
412
+ return {
413
+ invitations: args.include_settled === true
414
+ ? described
415
+ : described.filter((invitation) => invitation.state === 'pending'),
416
+ note: 'The token is shown once, when the invitation is issued, and is never readable afterwards. A lost one is replaced by inviting the same address again.',
417
+ };
418
+ }
419
+ export const ListSharesInput = z.object({
420
+ company_id: companyId,
421
+ document_id: uuid.optional().describe('Only the links onto this document. Left out, the whole company.'),
422
+ include_withdrawn: z
423
+ .boolean()
424
+ .optional()
425
+ .describe('Also the ones withdrawn or expired. Default false.'),
426
+ });
427
+ export async function listShares(backend, args) {
428
+ const where = [{ column: 'company_id', op: 'eq', value: args.company_id }];
429
+ if (args.document_id !== undefined) {
430
+ where.push({ column: 'document_id', op: 'eq', value: args.document_id });
431
+ }
432
+ const shares = await backend.select({
433
+ table: 'document_shares',
434
+ columns: columns.DOCUMENT_SHARE,
435
+ where,
436
+ order: [{ column: 'created_at', ascending: false }],
437
+ });
438
+ const now = Date.now();
439
+ const described = shares.map((share) => ({ ...share, state: shareState(share, now) }));
440
+ return {
441
+ shares: args.include_withdrawn === true
442
+ ? described
443
+ : described.filter((share) => share.state === 'live'),
444
+ note: 'The token of a link exists only in the answer that created it. A link that is lost is withdrawn and made again.',
445
+ };
446
+ }
447
+ /** Live, expired or withdrawn — three states off two columns. */
448
+ function shareState(share, now) {
449
+ if (share['revoked_at'] !== null && share['revoked_at'] !== undefined)
450
+ return 'withdrawn';
451
+ const expires = share['expires_at'];
452
+ if (typeof expires === 'string' && Date.parse(expires) <= now)
453
+ return 'expired';
454
+ return 'live';
455
+ }
456
+ /** Pending, expired, accepted or withdrawn — four states off three columns. */
457
+ function invitationState(invitation, now) {
458
+ if (invitation['accepted_at'] !== null && invitation['accepted_at'] !== undefined)
459
+ return 'accepted';
460
+ if (invitation['revoked_at'] !== null && invitation['revoked_at'] !== undefined)
461
+ return 'withdrawn';
462
+ const expires = Date.parse(String(invitation['expires_at']));
463
+ return Number.isNaN(expires) || expires > now ? 'pending' : 'expired';
464
+ }
465
+ export const TrialBalanceInput = z.object({
466
+ company_id: companyId,
467
+ from: isoDate,
468
+ to: isoDate,
469
+ });
470
+ export async function trialBalance(backend, args) {
471
+ const rows = await backend.rpc('trial_balance', {
472
+ p_company_id: args.company_id,
473
+ p_from: args.from,
474
+ p_to: args.to,
475
+ });
476
+ const accounts = moneyFields(rows, ['opening_balance', 'debit', 'credit', 'closing_balance']);
477
+ const total = (key) => accounts.reduce((sum, row) => sum + Number(row[key] ?? 0), 0).toFixed(2);
478
+ return {
479
+ period: { from: args.from, to: args.to },
480
+ accounts,
481
+ totals: { debit: total('debit'), credit: total('credit') },
482
+ };
483
+ }
484
+ export const GeneralLedgerInput = z.object({
485
+ company_id: companyId,
486
+ from: isoDate,
487
+ to: isoDate,
488
+ account_code: z.string().min(1).optional().describe('The account to detail, by its code, e.g. "400000".'),
489
+ account_ids: z.array(uuid).optional().describe('The accounts to detail, by id. Leave both out for every account.'),
490
+ });
491
+ export async function generalLedger(backend, args) {
492
+ let ids = args.account_ids ?? null;
493
+ if (args.account_code !== undefined) {
494
+ const accounts = await backend.select({
495
+ table: 'accounts',
496
+ columns: ['id'],
497
+ where: [
498
+ { column: 'company_id', op: 'eq', value: args.company_id },
499
+ { column: 'code', op: 'eq', value: args.account_code },
500
+ ],
501
+ });
502
+ ids = [...(ids ?? []), ...accounts.map((account) => account.id)];
503
+ if (ids.length === 0) {
504
+ throw new EkwoMcpError(`not_found: no account ${args.account_code} in this company.`);
505
+ }
506
+ }
507
+ const rows = await backend.rpc('general_ledger', {
508
+ p_company_id: args.company_id,
509
+ p_from: args.from,
510
+ p_to: args.to,
511
+ p_account_ids: ids,
512
+ });
513
+ return {
514
+ period: { from: args.from, to: args.to },
515
+ lines: moneyFields(rows, ['debit', 'credit', 'running_balance']),
516
+ count: rows.length,
517
+ };
518
+ }
519
+ export const AgedBalanceInput = z.object({
520
+ company_id: companyId,
521
+ at: isoDate.optional().describe('The day to age at. Defaults to today.'),
522
+ group: z.enum(['receivable', 'payable']).optional(),
523
+ });
524
+ export async function agedBalance(backend, args) {
525
+ const rows = await backend.rpc('aged_balance', {
526
+ p_company_id: args.company_id,
527
+ p_at: args.at ?? new Date().toISOString().slice(0, 10),
528
+ p_group: args.group ?? 'receivable',
529
+ });
530
+ return {
531
+ at: args.at ?? new Date().toISOString().slice(0, 10),
532
+ group: args.group ?? 'receivable',
533
+ rows: moneyFields(rows, ['not_due', 'days_1_30', 'days_31_60', 'days_61_90', 'days_over_90', 'total']),
534
+ };
535
+ }
536
+ export const VatReturnInput = z.object({
537
+ company_id: companyId,
538
+ from: isoDate,
539
+ to: isoDate,
540
+ report_code: z
541
+ .string()
542
+ .optional()
543
+ .describe('Which declaration form, when the country files more than one (BE-VAT-PERIODIC, FR-CA3). Leave it out and the periodic return of the company country is used.'),
544
+ });
545
+ export async function vatReturn(backend, args) {
546
+ const rows = await backend.rpc('vat_return', {
547
+ p_company_id: args.company_id,
548
+ p_from: args.from,
549
+ p_to: args.to,
550
+ p_report_code: args.report_code ?? null,
551
+ });
552
+ const form = rows.find((row) => row['report_code'] !== null)?.['report_code'] ?? null;
553
+ return {
554
+ period: { from: args.from, to: args.to },
555
+ report_code: form,
556
+ boxes: moneyFields(rows, ['amount']),
557
+ note: 'A box flagged computed is a total the form derives from the others, following the plus and minus lists of the country pack, or the rate of one other box where the form states a line as a multiplication; hidden means the form does not print it. Everything else is summed from what the tax postings wrote on the ledger lines. Order by print_sequence to print the form the way its administration does, which is not always the order the boxes are worked out in.',
558
+ };
559
+ }
560
+ export const EcSalesListInput = z.object({
561
+ company_id: companyId,
562
+ from: isoDate,
563
+ to: isoDate,
564
+ report_code: z
565
+ .string()
566
+ .optional()
567
+ .describe('Which recapitulative statement form, when the installation carries one and you are filing it. Name it and the period is checked against the cadence this company files that statement on, which is not the cadence of its periodic return in most countries. Leave it out to read the figures without any period being refused.'),
568
+ });
569
+ export async function ecSalesList(backend, args) {
570
+ const rows = await backend.rpc('ec_sales_list', {
571
+ p_company_id: args.company_id,
572
+ p_from: args.from,
573
+ p_to: args.to,
574
+ p_report_code: args.report_code ?? null,
575
+ });
576
+ const undeclarable = rows.filter((row) => row['issue'] !== null);
577
+ return {
578
+ period: { from: args.from, to: args.to },
579
+ lines: moneyFields(rows, ['amount']),
580
+ undeclarable_lines: undeclarable.length,
581
+ note: 'One line per customer VAT number and per nature of supply — goods, services — summed from the posted ledger in the company currency, credit notes deducted. A line carrying an issue cannot be filed as it stands: no_vat_number means the customer has none recorded, vat_country_is_the_company_country means the number is not in another Member State. Report those separately instead of adding them into the total, and do not remove them from the figures. It prepares a statement; it files nothing.',
582
+ };
583
+ }
584
+ export const PortfolioUpcomingFilingsInput = z.object({
585
+ from: isoDate.describe('First day a return may fall due on. Go back a few days to catch what is already late.'),
586
+ to: isoDate.describe('Last day a return may fall due on.'),
587
+ });
588
+ export async function portfolioUpcomingFilings(backend, args) {
589
+ const rows = await backend.rpc('portfolio_upcoming_filings', {
590
+ p_from: args.from,
591
+ p_to: args.to,
592
+ });
593
+ const companies = new Set(rows.map((row) => row['company_id']));
594
+ return {
595
+ window: { from: args.from, to: args.to },
596
+ companies: companies.size,
597
+ filings: rows,
598
+ note: rows.length === 0
599
+ ? 'You hold filings.read on no company of this installation, so there is no portfolio to read. It is not the same as nothing being due: a company you could read would be listed even with nothing to say.'
600
+ : 'One row per period falling due in the window, across every company you hold filings.read on, and at least one row per company. state and filing_id are the declaration already prepared or sent for that period; both empty means nothing has been started. A row without a due_date says why in reason: no_deadline_rule — the country pack names no day for this form, usually because the schedule depends on who is filing, so the period is listed and the date has to come from the administration; nothing_due — the company files, and nothing of it falls in this window; no_form — the installation carries no return for the fiscal country of the company. Never read a missing date as "not due". It reads a calendar; it prepares and files nothing.',
601
+ };
602
+ }
603
+ export const PortfolioFilingsTouchedSinceInput = z.object({
604
+ from: isoDate.optional().describe('Only declarations whose period ends on or after this day.'),
605
+ to: isoDate.optional().describe('Only declarations whose period starts on or before this day.'),
606
+ });
607
+ export async function portfolioFilingsTouchedSince(backend, args) {
608
+ const rows = await backend.rpc('portfolio_filings_touched_since', {
609
+ p_from: args.from ?? null,
610
+ p_to: args.to ?? null,
611
+ });
612
+ const touched = rows.filter((row) => row['filing_id'] !== null);
613
+ return {
614
+ companies: new Set(rows.map((row) => row['company_id'])).size,
615
+ touched: touched.length,
616
+ filings: touched,
617
+ untouched: rows
618
+ .filter((row) => row['filing_id'] === null)
619
+ .map((row) => ({
620
+ company_id: row['company_id'],
621
+ company_name: row['company_name'],
622
+ filed: row['filed'],
623
+ })),
624
+ note: rows.length === 0
625
+ ? 'You hold filings.read on no company of this installation, so nothing was looked at.'
626
+ : 'filings lists every declaration that has gone and whose period received entries afterwards, latest first, with the company named: entries is how many posted entries carrying a declaration box landed in it, boxes_moved how many filed figures now disagree with the ledger. An entry that moves no figure is still listed. untouched names the companies that were looked at and had nothing to report, with how many filed declarations were examined — filed 0 means the company has sent none, which is different news. Whether a change calls for a corrective is a judgement for whoever keeps the books; this reads, and changes nothing.',
627
+ };
628
+ }
629
+ export const ListStatementsInput = z.object({
630
+ company_id: companyId,
631
+ at: isoDate
632
+ .optional()
633
+ .describe('The day to read the schemes in force at. Defaults to today.'),
634
+ });
635
+ export async function listStatements(backend, args) {
636
+ const rows = await backend.rpc('available_statements', {
637
+ p_company_id: args.company_id,
638
+ p_at: args.at ?? null,
639
+ });
640
+ return {
641
+ statements: rows,
642
+ note: rows.length === 0
643
+ ? 'No statement applies to this company. Its country pack declares none and the generic framework has not been seeded.'
644
+ : 'is_default marks the schemes the chart of accounts of this company reports on. The ones with no country are the generic framework by account type, which fits any chart.',
645
+ };
646
+ }
647
+ export const FinancialStatementInput = z.object({
648
+ company_id: companyId,
649
+ statement_code: z
650
+ .string()
651
+ .min(1)
652
+ .describe('Which scheme, from list_statements (BE-BNB-ABBR-BS, FR-2050, IFRS-SME-BS).'),
653
+ from: isoDate,
654
+ to: isoDate,
655
+ });
656
+ export async function financialStatement(backend, args) {
657
+ const [lines, unmapped] = await Promise.all([
658
+ backend.rpc('financial_statement', {
659
+ p_company_id: args.company_id,
660
+ p_statement_code: args.statement_code,
661
+ p_from: args.from,
662
+ p_to: args.to,
663
+ }),
664
+ backend.rpc('unmapped_accounts', {
665
+ p_company_id: args.company_id,
666
+ p_statement_code: args.statement_code,
667
+ p_from: args.from,
668
+ p_to: args.to,
669
+ }),
670
+ ]);
671
+ return {
672
+ period: { from: args.from, to: args.to },
673
+ statement_code: args.statement_code,
674
+ lines: moneyFields(lines, ['amount']),
675
+ unmapped_accounts: moneyFields(unmapped, ['balance']),
676
+ note: 'Every line of the scheme is returned, nil included, in the order it is printed; a line flagged is_total is derived from the others. ' +
677
+ (unmapped.length === 0
678
+ ? 'No account of this company falls outside the scheme, so it ties out.'
679
+ : 'unmapped_accounts lists accounts this scheme catches on no line — say so rather than presenting a statement that does not tie out.'),
680
+ };
681
+ }
682
+ export const GenerateFecInput = z.object({
683
+ company_id: companyId,
684
+ from: isoDate,
685
+ to: isoDate,
686
+ });
687
+ export async function generateFec(backend, args) {
688
+ const rows = await backend.rpc('fec_lines', {
689
+ p_company_id: args.company_id,
690
+ p_from: args.from,
691
+ p_to: args.to,
692
+ });
693
+ const lines = rows.map(fromQueryRow);
694
+ const violations = checkFec(lines);
695
+ const file = renderFec(lines);
696
+ const company = only(await backend.select({
697
+ table: 'companies',
698
+ columns: ['id', 'name', 'registration_number', 'fiscal_country'],
699
+ where: [{ column: 'id', op: 'eq', value: args.company_id }],
700
+ }), `company ${args.company_id}`);
701
+ const siren = String(company['registration_number'] ?? '').replace(/\D/g, '');
702
+ let filename = null;
703
+ let filename_note = null;
704
+ if (siren.length === 9) {
705
+ filename = fecFileName(siren, args.to);
706
+ }
707
+ else {
708
+ filename_note =
709
+ 'No filename: the FEC name is built from a nine-digit SIREN, and this company has no such registration number.';
710
+ }
711
+ return {
712
+ period: { from: args.from, to: args.to },
713
+ lines: lines.length,
714
+ violations,
715
+ filename,
716
+ filename_note,
717
+ file,
718
+ };
719
+ }
720
+ // ---------------------------------------------------------------------------
721
+ // describe_pack
722
+ // ---------------------------------------------------------------------------
723
+ export const DescribePackInput = z.object({
724
+ country: z
725
+ .string()
726
+ .length(2)
727
+ .optional()
728
+ .describe('ISO 3166-1 alpha-2, upper case. Left out, every pack this installation holds.'),
729
+ });
730
+ /**
731
+ * Where a country's rules come from, and how much anyone has read them.
732
+ *
733
+ * The pack is the transcription of a régime — a chart of accounts, the taxes
734
+ * and the boxes of the return — and a transcription is only worth what its
735
+ * sources are worth. `certification_status` says whether a named professional
736
+ * has read it, and `sources` is the register the pack declares: the texts it
737
+ * was built from, each with the publisher that serves it, an absolute link and
738
+ * the day somebody opened it.
739
+ *
740
+ * It answers a question that used to have no answer here: shown a rate or a
741
+ * grid, where is the text it comes from. The article itself is on the tax and
742
+ * on the box — `legal_reference` — and `source_key` there names which of these
743
+ * entries it is in.
744
+ */
745
+ export async function describePack(backend, args) {
746
+ const where = args.country === undefined ? [] : [{ column: 'country', op: 'eq', value: args.country.toUpperCase() }];
747
+ const packs = await backend.select({
748
+ table: 'country_packs',
749
+ columns: columns.COUNTRY_PACK,
750
+ where,
751
+ order: [{ column: 'country' }],
752
+ });
753
+ if (packs.length === 0) {
754
+ return {
755
+ packs: [],
756
+ count: 0,
757
+ note: args.country === undefined
758
+ ? 'This installation holds no country pack. Its seeds have not been applied.'
759
+ : `No pack is loaded for ${args.country.toUpperCase()}. A company of that country cannot be installed until its seed has run.`,
760
+ };
761
+ }
762
+ return {
763
+ packs,
764
+ count: packs.length,
765
+ note: 'certification_status is what somebody claims, not a certificate: community means nobody has read it, maintained means the maintainers keep it current and nobody has reviewed it, reviewed means the named professional in certified_by read it on certified_at. `sources` is the register the pack declares — each entry is a text, its official publisher, an absolute link and the day it was opened — and it holds no copy of the law itself. A tax and a box of the declaration each carry their own article in legal_reference and name the register entry it is in; a link that no longer answers is the register being stale, never the rule being wrong.',
766
+ };
767
+ }
768
+ // ---------------------------------------------------------------------------
769
+ // status
770
+ // ---------------------------------------------------------------------------
771
+ export const StatusInput = z.object({});
772
+ export async function status(backend) {
773
+ const version = await backend.rpc('ekwo_schema_version');
774
+ const instance = await backend.select({ table: 'instance', columns: columns.INSTANCE });
775
+ const companies = await backend.select({
776
+ table: 'companies',
777
+ columns: ['id', 'name', 'country', 'currency_code'],
778
+ order: [{ column: 'name' }],
779
+ });
780
+ return {
781
+ schema_version: version[0] ?? null,
782
+ connection: { mode: backend.mode, acting_as: backend.actingAs ?? null },
783
+ instance: instance[0] ?? null,
784
+ companies,
785
+ note: backend.mode === 'postgrest'
786
+ ? 'Reading and writing as the signed-in user, over PostgREST. Row level security decides what is visible.'
787
+ : 'Reading and writing over a direct Postgres connection, with the claims and the role of the user this server acts for.',
788
+ };
789
+ }
790
+ /** Re-exported so the write tools can format the same way. */
791
+ export { money };
792
+ // ---------------------------------------------------------------------------
793
+ // The audit trail
794
+ // ---------------------------------------------------------------------------
795
+ export const ReadAuditLogInput = z.object({
796
+ company_id: companyId,
797
+ table: z
798
+ .string()
799
+ .optional()
800
+ .describe('One table of the schema: accounts, journals, taxes, tax_postings, contacts, products, bank_accounts, companies, fiscal_years, company_members, company_packs, api_keys, documents, payments, entries, reconciliations.'),
801
+ record_key: z
802
+ .string()
803
+ .optional()
804
+ .describe('The natural key of one row — an account code, a tax code, an invoice number.'),
805
+ actor_id: uuid.optional().describe('Only what this user changed. Their auth.users id.'),
806
+ action: z
807
+ .string()
808
+ .optional()
809
+ .describe('One act: document_posted, document_cancelled, entry_posted, entry_reversed, payment_posted, payment_reconciled, payment_unreconciled, fiscal_year_closed, fiscal_year_reopened, pack_upgraded.'),
810
+ operation: z.enum(['insert', 'update', 'delete']).optional(),
811
+ from: isoDate.optional().describe('Earliest date, inclusive.'),
812
+ to: isoDate.optional().describe('Latest date, inclusive — the whole of that day.'),
813
+ limit: z.number().int().min(1).max(200).optional(),
814
+ });
815
+ export async function readAuditLog(backend, args) {
816
+ const where = [{ column: 'company_id', op: 'eq', value: args.company_id }];
817
+ if (args.table !== undefined)
818
+ where.push({ column: 'table_name', op: 'eq', value: args.table });
819
+ if (args.record_key !== undefined)
820
+ where.push({ column: 'record_key', op: 'eq', value: args.record_key });
821
+ if (args.actor_id !== undefined)
822
+ where.push({ column: 'actor_id', op: 'eq', value: args.actor_id });
823
+ if (args.action !== undefined)
824
+ where.push({ column: 'action', op: 'eq', value: args.action });
825
+ if (args.operation !== undefined)
826
+ where.push({ column: 'operation', op: 'eq', value: args.operation });
827
+ if (args.from !== undefined)
828
+ where.push({ column: 'occurred_at', op: 'gte', value: args.from });
829
+ // A date names a day, not the instant it begins: `to: 2026-06-15` has to
830
+ // include everything that happened that afternoon.
831
+ if (args.to !== undefined) {
832
+ where.push({ column: 'occurred_at', op: 'lt', value: nextDay(args.to) });
833
+ }
834
+ const entries = await backend.select({
835
+ table: 'audit_log',
836
+ columns: columns.AUDIT_LOG,
837
+ where,
838
+ order: [{ column: 'occurred_at', ascending: false }, { column: 'id', ascending: false }],
839
+ limit: args.limit ?? 50,
840
+ });
841
+ return {
842
+ changes: entries,
843
+ count: entries.length,
844
+ note: 'Append-only. Nothing writes this trail but the database itself, and nothing removes a row from it.',
845
+ };
846
+ }
847
+ /** The day after an ISO date, so a range on a timestamp can end on a day. */
848
+ function nextDay(date) {
849
+ const day = new Date(`${date}T00:00:00Z`);
850
+ day.setUTCDate(day.getUTCDate() + 1);
851
+ return day.toISOString().slice(0, 10);
852
+ }
853
+ //# sourceMappingURL=read.js.map