@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.
- package/README.md +253 -0
- package/dist/backend.d.ts +24 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +105 -0
- package/dist/backend.js.map +1 -0
- package/dist/bin.d.ts +15 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +75 -0
- package/dist/bin.js.map +1 -0
- package/dist/columns.d.ts +59 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/columns.js +422 -0
- package/dist/columns.js.map +1 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +102 -0
- package/dist/config.js.map +1 -0
- package/dist/format.d.ts +21 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +49 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/postgrest.d.ts +35 -0
- package/dist/postgrest.d.ts.map +1 -0
- package/dist/postgrest.js +152 -0
- package/dist/postgrest.js.map +1 -0
- package/dist/rounding.d.ts +25 -0
- package/dist/rounding.d.ts.map +1 -0
- package/dist/rounding.js +35 -0
- package/dist/rounding.js.map +1 -0
- package/dist/schema.d.ts +28 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +51 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +521 -0
- package/dist/server.js.map +1 -0
- package/dist/sql.d.ts +36 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +214 -0
- package/dist/sql.js.map +1 -0
- package/dist/tools/modules.d.ts +61 -0
- package/dist/tools/modules.d.ts.map +1 -0
- package/dist/tools/modules.js +335 -0
- package/dist/tools/modules.js.map +1 -0
- package/dist/tools/read.d.ts +233 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +853 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/write.d.ts +343 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +814 -0
- package/dist/tools/write.js.map +1 -0
- 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
|