@ekwo-ai/core 0.13.0 → 0.15.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 (44) hide show
  1. package/dist/books/analytics.d.ts +32 -0
  2. package/dist/books/analytics.d.ts.map +1 -0
  3. package/dist/books/analytics.js +94 -0
  4. package/dist/books/analytics.js.map +1 -0
  5. package/dist/books/columns.d.ts.map +1 -1
  6. package/dist/books/columns.js +5 -0
  7. package/dist/books/columns.js.map +1 -1
  8. package/dist/books/contacts.d.ts +43 -0
  9. package/dist/books/contacts.d.ts.map +1 -1
  10. package/dist/books/contacts.js +61 -0
  11. package/dist/books/contacts.js.map +1 -1
  12. package/dist/books/index.d.ts +3 -0
  13. package/dist/books/index.d.ts.map +1 -1
  14. package/dist/books/index.js +3 -0
  15. package/dist/books/index.js.map +1 -1
  16. package/dist/books/lines.d.ts +38 -0
  17. package/dist/books/lines.d.ts.map +1 -0
  18. package/dist/books/lines.js +73 -0
  19. package/dist/books/lines.js.map +1 -0
  20. package/dist/books/matching.d.ts +65 -0
  21. package/dist/books/matching.d.ts.map +1 -0
  22. package/dist/books/matching.js +95 -0
  23. package/dist/books/matching.js.map +1 -0
  24. package/dist/einvoicing/fiscalisation.d.ts +296 -0
  25. package/dist/einvoicing/fiscalisation.d.ts.map +1 -0
  26. package/dist/einvoicing/fiscalisation.js +642 -0
  27. package/dist/einvoicing/fiscalisation.js.map +1 -0
  28. package/dist/einvoicing/formats.d.ts +16 -0
  29. package/dist/einvoicing/formats.d.ts.map +1 -1
  30. package/dist/einvoicing/formats.js +110 -0
  31. package/dist/einvoicing/formats.js.map +1 -1
  32. package/dist/einvoicing/index.d.ts +4 -1
  33. package/dist/einvoicing/index.d.ts.map +1 -1
  34. package/dist/einvoicing/index.js +4 -1
  35. package/dist/einvoicing/index.js.map +1 -1
  36. package/dist/einvoicing/issue.d.ts +20 -1
  37. package/dist/einvoicing/issue.d.ts.map +1 -1
  38. package/dist/einvoicing/issue.js +54 -9
  39. package/dist/einvoicing/issue.js.map +1 -1
  40. package/dist/schema.d.ts +1 -1
  41. package/dist/schema.js +1 -1
  42. package/dist/types.d.ts +12 -1
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +16 -14
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Fiscalisation: the chained record a country requires the invoicing
3
+ * software to leave for every invoice it issues.
4
+ *
5
+ * A capability of the `einvoicing` module, and the same shape as issuing an
6
+ * electronic invoice: a posted sale, an artefact a brick writes, a state the
7
+ * database records. Here the artefact is a fiscal record, and what the
8
+ * database keeps is a chain — each record carries the hash of the one before
9
+ * it, in one company and one invoicing system.
10
+ *
11
+ * **The core knows no country.** Whether a company has such a regime, and
12
+ * every rule of it, is the `einvoicing.fiscalisation` block of its country
13
+ * pack, which the socle compiles into `country_defaults.einvoice_fiscalisation`
14
+ * and `einvoicing.fiscal_regime()` reads back. Which brick writes the record
15
+ * is the pack's `format`, looked up in `FISCALISATION_FORMATS` — the same
16
+ * pattern as `EINVOICE_FORMATS` — and a format no brick of this release writes
17
+ * is refused by name, `format_without_brick`.
18
+ *
19
+ * **The gesture is explicit.** Nothing here runs on posting. A posted sale of
20
+ * a company whose pack declares a regime and that has no record is named by
21
+ * `fiscalisationStatus()`, and `fiscaliseDocument()` is called by somebody who
22
+ * decided to chain it — `ekwo einvoice fiscalise`, `einvoicing_fiscalise`.
23
+ *
24
+ * What only a program can do is here: run the brick, read the tail of the
25
+ * chain, read the hashed fields back out of the text the brick hashed. What
26
+ * can be checked without trusting this code is checked again by
27
+ * `einvoicing.append_fiscal_record()`: the regime, the digest, the fields, the
28
+ * tail, a record made once.
29
+ *
30
+ * **A clearance regime chains nothing.** There the authority validates every
31
+ * invoice before it is issued, and what the books keep is the submission and
32
+ * the answer: `recordClearanceSubmission()` and `recordClearanceAnswer()`
33
+ * write them through `einvoicing.record_clearance_submission()` and
34
+ * `einvoicing.record_clearance_answer()`, which check every transition. The
35
+ * call to the authority is the host's; nothing here dials anybody.
36
+ */
37
+ import { VERIFACTU } from '@ekwo-ai/verifactu';
38
+ import { BooksError } from '../books/backend.js';
39
+ import { EINVOICE_FORMATS } from './formats.js';
40
+ import { assertPostedSale, einvoiceChecksum, readEinvoiceSource } from './issue.js';
41
+ const SCHEMA = 'einvoicing';
42
+ export const FISCAL_SUBMISSION_MODES = ['realtime', 'batch', 'none'];
43
+ export const FISCAL_RECORD_KINDS = ['alta', 'anulacion'];
44
+ /** Every fiscalisation format a brick of this release writes, by the name a pack declares it under. */
45
+ export const FISCALISATION_FORMATS = {
46
+ [VERIFACTU.format]: VERIFACTU,
47
+ };
48
+ /**
49
+ * The format of a pack's regime, or the refusal that names the missing brick.
50
+ * Nothing is written in the nearest format that exists: a record in another
51
+ * format is a record the authority refuses.
52
+ */
53
+ export function fiscalisationFormat(format, formats = FISCALISATION_FORMATS) {
54
+ const found = formats[format];
55
+ if (found === undefined) {
56
+ throw new BooksError(`format_without_brick: the country pack of this company keeps its fiscal records as ${format}, and no brick of packages/formats/ writes ${format} yet. ` +
57
+ `This release writes ${Object.keys(formats).join(' and ') || 'none'}; a record in another format is not a ${format} record, and none is written in its place.`);
58
+ }
59
+ return found;
60
+ }
61
+ /** The fiscalisation regime of a company's fiscal country, or null. */
62
+ export async function fiscalRegime(backend, companyId) {
63
+ const [regime] = await backend.rpc('fiscal_regime', { p_company_id: companyId }, SCHEMA);
64
+ return regime ?? null;
65
+ }
66
+ /**
67
+ * The fields a hash covers, read back out of the text the brick hashed, in
68
+ * the order the pack names them: `name=value` joined by the pack's separator.
69
+ * Null where the text does not hold them in that order — the pack and the
70
+ * brick then disagree about what is hashed, and nothing is chained.
71
+ */
72
+ export function hashedFields(text, fields, separator) {
73
+ const out = {};
74
+ let rest = text;
75
+ for (const [index, field] of fields.entries()) {
76
+ const prefix = `${index === 0 ? '' : separator}${field}=`;
77
+ if (!rest.startsWith(prefix))
78
+ return null;
79
+ rest = rest.slice(prefix.length);
80
+ const next = fields[index + 1];
81
+ if (next === undefined) {
82
+ out[field] = rest;
83
+ rest = '';
84
+ }
85
+ else {
86
+ const at = rest.indexOf(`${separator}${next}=`);
87
+ if (at < 0)
88
+ return null;
89
+ out[field] = rest.slice(0, at);
90
+ rest = rest.slice(at);
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+ /** Now, as ISO 8601 with the offset of this machine: what a record says it was generated at. */
96
+ export function generatedNow(at = new Date()) {
97
+ const offset = -at.getTimezoneOffset();
98
+ const local = new Date(at.getTime() + offset * 60_000).toISOString().slice(0, 19);
99
+ const sign = offset < 0 ? '-' : '+';
100
+ const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
101
+ const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
102
+ return `${local}${sign}${hours}:${minutes}`;
103
+ }
104
+ /** The chain a system keeps for a company: its identifier and its installation. */
105
+ export const fiscalSystemId = (system) => `${system.id}:${system.installation}`;
106
+ const CHAIN = [
107
+ 'id', 'company_id', 'system_id', 'seq', 'document_id', 'kind', 'format', 'issuer_tax_id', 'series', 'number',
108
+ 'issued_on::text', 'record', 'hash', 'previous_hash', 'is_first', 'generated_at', 'mode', 'verification_url',
109
+ 'issue_id', 'recorded_at', 'recorded_by',
110
+ ];
111
+ /**
112
+ * Writes the fiscal record of a posted sale with the brick its pack's regime
113
+ * names, and — unless it breaks a rule or `dryRun` is asked — chains it after
114
+ * the tail of the system's chain and keeps its file. A document whose record
115
+ * is in the chain already is answered with that record, not chained twice.
116
+ */
117
+ export async function fiscaliseDocument(backend, documentId, options, formats = FISCALISATION_FORMATS) {
118
+ const source = await readEinvoiceSource(backend, documentId);
119
+ const { header } = source;
120
+ assertPostedSale(header, documentId);
121
+ const companyId = String(header['company_id']);
122
+ const what = `${String(header['doc_type'])} ${String(header['number'] ?? documentId)}`;
123
+ const regime = await fiscalRegime(backend, companyId);
124
+ if (regime === null) {
125
+ throw new BooksError(`no_fiscalisation_regime: the country pack of this company declares no fiscalisation regime, so ${what} has no fiscal record to keep.`, { hint: 'A pack declares one in einvoicing.fiscalisation; describe_pack (ekwo pack describe) shows it.' });
126
+ }
127
+ if (regime.kind === 'clearance') {
128
+ throw new BooksError(`fiscal_regime_without_chain: the country pack of this company declares a clearance regime, ${regime.format}: every invoice is validated by the authority before it is issued and nothing is chained, so ${what} has no record to chain. Its submission and the authority's answer are recorded with einvoicing_clearance (ekwo einvoice clearance).`);
129
+ }
130
+ const format = fiscalisationFormat(regime.format, formats);
131
+ if (regime.hash === null) {
132
+ throw new BooksError(`fiscal_regime_without_chain: the country pack of this company declares a reporting regime, ${regime.format}: the data of its documents are reported to the authority and nothing is chained, so ${what} has no record to chain. The report is written by ${format.brick}, not by fiscaliseDocument().`);
133
+ }
134
+ const hashRule = regime.hash;
135
+ const kind = options.kind ?? 'alta';
136
+ const mode = options.mode ?? regime.submission.mode;
137
+ if (!regime.submission.modes.includes(mode)) {
138
+ throw new BooksError(`fiscal_mode_not_allowed: a record of this company is sent as one of ${regime.submission.modes.join(', ')}, not as ${mode}.`);
139
+ }
140
+ const systemId = fiscalSystemId(options.system);
141
+ const chain = await backend.select({
142
+ schema: SCHEMA,
143
+ table: 'fiscal_chain',
144
+ columns: CHAIN,
145
+ where: [
146
+ { column: 'company_id', op: 'eq', value: companyId },
147
+ { column: 'system_id', op: 'eq', value: systemId },
148
+ ],
149
+ order: [{ column: 'seq', ascending: false }],
150
+ limit: 1,
151
+ });
152
+ const tail = chain[0] ?? null;
153
+ // Asking twice is not a second record.
154
+ const [last] = await backend.select({
155
+ schema: SCHEMA,
156
+ table: 'fiscal_chain',
157
+ columns: CHAIN,
158
+ where: [
159
+ { column: 'document_id', op: 'eq', value: documentId },
160
+ { column: 'system_id', op: 'eq', value: systemId },
161
+ ],
162
+ order: [{ column: 'seq', ascending: false }],
163
+ limit: 1,
164
+ });
165
+ if (last !== undefined && last['kind'] === kind && options.dryRun !== true) {
166
+ return {
167
+ document_id: documentId,
168
+ number: header['number'] ?? null,
169
+ doc_type: header['doc_type'],
170
+ format: format.format,
171
+ brick: format.brick,
172
+ kind,
173
+ system_id: systemId,
174
+ chained: true,
175
+ already: true,
176
+ record: last,
177
+ violations: [],
178
+ verification_url: last['verification_url'] ?? null,
179
+ note: `${what} has its ${kind} record in this system already, number ${String(last['seq'])} of the chain; nothing was chained again.`,
180
+ };
181
+ }
182
+ // The tax lines carry what the regime needs that the category does not decide alone.
183
+ const taxes = source.taxes.map((tax) => ({ ...tax, ...(regime.tax_attributes[String(tax['tax_code'])] ?? {}) }));
184
+ const input = {
185
+ header: {
186
+ ...header,
187
+ ...(source.preceding === null
188
+ ? {}
189
+ : { rectified_number: source.preceding.number, rectified_date: source.preceding.issueDate }),
190
+ ...(options.rectification === undefined ? {} : { rectification_type: options.rectification }),
191
+ },
192
+ lines: source.lines,
193
+ taxes,
194
+ kind,
195
+ generatedAt: options.generatedAt ?? generatedNow(),
196
+ system: options.system,
197
+ previous: tail === null
198
+ ? null
199
+ : {
200
+ taxId: String(tail['issuer_tax_id']),
201
+ series: String(tail['series'] ?? ''),
202
+ number: String(tail['number']),
203
+ issuedOn: String(tail['issued_on']),
204
+ hash: String(tail['hash']),
205
+ },
206
+ };
207
+ const written = format.write(input);
208
+ const hashInput = format.hashInput(input);
209
+ const hash = format.hash(input);
210
+ const verificationUrl = format.verificationUrl(input, mode, options.environment ?? 'production');
211
+ const fields = hashRule.fields[kind] ?? hashRule.fields['*'] ?? [];
212
+ const record = hashRule.separator === null ? null : hashedFields(hashInput, fields, hashRule.separator);
213
+ if (record === null) {
214
+ throw new BooksError(`fiscal_hash_fields_unread: the pack says the hash of a ${kind} record covers ${fields.join(', ')}, and the text ${format.brick} hashed does not hold them in that order. The pack and the brick disagree about what is hashed; nothing is chained.`);
215
+ }
216
+ const base = {
217
+ document_id: documentId,
218
+ number: header['number'] ?? null,
219
+ doc_type: header['doc_type'],
220
+ format: format.format,
221
+ brick: format.brick,
222
+ specification: format.specification,
223
+ kind,
224
+ mode,
225
+ system_id: systemId,
226
+ generated_at: input.generatedAt,
227
+ hash,
228
+ previous_hash: input.previous?.hash ?? null,
229
+ fields: record,
230
+ verification_url: verificationUrl,
231
+ filename: written.filename,
232
+ violations: written.violations,
233
+ ...(options.includeFile === true ? { file: written.content } : {}),
234
+ };
235
+ if (written.violations.length > 0 || options.dryRun === true) {
236
+ return {
237
+ ...base,
238
+ chained: false,
239
+ record: null,
240
+ note: written.violations.length > 0
241
+ ? 'The record breaks the rules listed, and a record that breaks one is not chained: the next one would follow a record the authority refuses. They are completed in the books, not in the record. Nothing was recorded.'
242
+ : 'The record breaks no rule this release re-reads. Nothing was recorded: fiscalise it without dry run to chain it.',
243
+ };
244
+ }
245
+ const [row] = await backend.rpc('append_fiscal_record', {
246
+ p_document_id: documentId,
247
+ p_system_id: systemId,
248
+ p_kind: kind,
249
+ p_format: format.format,
250
+ p_mode: mode,
251
+ p_issuer_tax_id: String(header['seller_vat_number'] ?? ''),
252
+ p_series: '',
253
+ p_number: String(header['number'] ?? ''),
254
+ p_issued_on: header['document_date'],
255
+ p_record: record,
256
+ p_hash_input: hashInput,
257
+ p_hash: hash,
258
+ p_previous_hash: input.previous?.hash ?? null,
259
+ p_generated_at: input.generatedAt,
260
+ p_verification_url: verificationUrl,
261
+ p_brick: format.brick,
262
+ p_specification: format.specification,
263
+ p_filename: written.filename,
264
+ p_media_type: written.mediaType,
265
+ p_content: written.content,
266
+ p_checksum: einvoiceChecksum(written.content),
267
+ p_violations: written.violations,
268
+ }, SCHEMA);
269
+ if (row === undefined)
270
+ throw new BooksError('einvoicing_no_answer: einvoicing.append_fiscal_record answered nothing');
271
+ return {
272
+ ...base,
273
+ chained: true,
274
+ record: row,
275
+ note: `Chained as record ${String(row['seq'])} of system ${systemId}${row['is_first'] === true ? ', the first of its chain' : ''}, and its file kept. ${mode === 'none'
276
+ ? 'It is kept, and handed over when the authority asks.'
277
+ : 'It is to be sent to the authority: its file is an issue of artefact fiscal_record, sent by a transport the host brings.'}`,
278
+ };
279
+ }
280
+ /** The earliest day a regime binds anybody, or null where the pack names none. */
281
+ function earliestObligation(regime) {
282
+ const days = regime.mandatory_from.map((entry) => entry.from).sort();
283
+ return days[0] ?? null;
284
+ }
285
+ /**
286
+ * Whether the status counts posted sales as missing a record, or a
287
+ * validation. Only under a chained or a clearance regime in force: an
288
+ * announced day binds nobody, and a reporting regime keeps no chain — which
289
+ * documents it reports is its brick's to say.
290
+ */
291
+ function countsMissing(regime) {
292
+ return regime !== null && (regime.kind === 'clearance' || regime.hash !== null) && (regime.announced_from ?? null) === null;
293
+ }
294
+ /**
295
+ * The brick that writes what a regime needs written, or null where none of
296
+ * this release does. Under a clearance regime what is submitted is the
297
+ * electronic invoice itself, so its brick is looked up with the e-invoicing
298
+ * profiles; under the other two, with the fiscalisation formats.
299
+ */
300
+ function brickOf(regime) {
301
+ return (regime.kind === 'clearance' ? EINVOICE_FORMATS[regime.format] : FISCALISATION_FORMATS[regime.format])?.brick ?? null;
302
+ }
303
+ /** The threshold of exemption that holds on a day, or null where the pack declares none that does. */
304
+ export function exemptionOn(regime, day) {
305
+ return [...(regime.exemption ?? [])].reverse().find((entry) => entry.valid_from <= day) ?? null;
306
+ }
307
+ /**
308
+ * Where the fiscal records of a company stand: its regime, each chain
309
+ * verified, the records still to be sent, the posted sales without a record,
310
+ * and the latest events. A posted sale without a record is named here and
311
+ * never fiscalised on the way.
312
+ */
313
+ export async function fiscalisationStatus(backend, companyId, options = {}) {
314
+ const limit = options.limit ?? 50;
315
+ const regime = await fiscalRegime(backend, companyId);
316
+ const records = await backend.select({
317
+ schema: SCHEMA,
318
+ table: 'fiscal_chain',
319
+ columns: ['system_id', 'seq', 'document_id', 'kind', 'hash', 'mode', 'issue_id', 'recorded_at'],
320
+ where: [{ column: 'company_id', op: 'eq', value: companyId }],
321
+ order: [{ column: 'system_id' }, { column: 'seq' }],
322
+ });
323
+ const systems = [];
324
+ for (const systemId of [...new Set(records.map((r) => String(r['system_id'])))]) {
325
+ const own = records.filter((r) => r['system_id'] === systemId);
326
+ const tail = own[own.length - 1];
327
+ const breaks = await backend.rpc('verify_fiscal_chain', { p_company_id: companyId, p_system_id: systemId }, SCHEMA);
328
+ systems.push({
329
+ system_id: systemId,
330
+ records: own.length,
331
+ last_seq: tail['seq'],
332
+ last_hash: tail['hash'],
333
+ last_at: tail['recorded_at'],
334
+ verified: breaks.length === 0,
335
+ break: breaks[0] ?? null,
336
+ });
337
+ }
338
+ // Sent means somebody on the way took charge of it.
339
+ const reached = new Set((await backend.select({
340
+ schema: SCHEMA,
341
+ table: 'transmissions',
342
+ columns: ['issue_id', 'state'],
343
+ where: [
344
+ { column: 'company_id', op: 'eq', value: companyId },
345
+ { column: 'artefact', op: 'eq', value: 'fiscal_record' },
346
+ { column: 'state', op: 'in', value: ['accepted_by_access_point', 'delivered'] },
347
+ ],
348
+ })).map((t) => String(t['issue_id'])));
349
+ const pending = records
350
+ .filter((r) => r['mode'] !== 'none' && !reached.has(String(r['issue_id'])))
351
+ .map((r) => ({ system_id: r['system_id'], seq: r['seq'], document_id: r['document_id'], kind: r['kind'], issue_id: r['issue_id'] }));
352
+ const fiscalised = new Set(records.filter((r) => r['kind'] === 'alta').map((r) => String(r['document_id'])));
353
+ const posted = await backend.select({
354
+ table: 'documents',
355
+ columns: ['id', 'doc_type', 'number', 'document_date::text', 'reversed_document_id'],
356
+ where: [
357
+ { column: 'company_id', op: 'eq', value: companyId },
358
+ { column: 'state', op: 'eq', value: 'posted' },
359
+ { column: 'doc_type', op: 'in', value: ['sale_invoice', 'sale_credit_note'] },
360
+ ],
361
+ order: [{ column: 'document_date' }, { column: 'number' }],
362
+ });
363
+ const from = regime === null ? null : earliestObligation(regime);
364
+ const counted = countsMissing(regime);
365
+ const cleared = regime?.kind === 'clearance' ? await clearanceOf(backend, companyId, posted, limit) : null;
366
+ const without = !counted
367
+ ? []
368
+ : cleared !== null
369
+ ? posted.filter((d) => cleared.unvalidated.has(String(d['id'])))
370
+ : posted.filter((d) => !fiscalised.has(String(d['id'])));
371
+ const missing = without.filter((d) => from === null || String(d['document_date']) >= from);
372
+ const events = await backend.select({
373
+ schema: SCHEMA,
374
+ table: 'fiscal_events',
375
+ columns: ['system_id', 'seq', 'kind', 'detail', 'at', 'recorded_by'],
376
+ where: [{ column: 'company_id', op: 'eq', value: companyId }],
377
+ order: [{ column: 'at', ascending: false }, { column: 'seq', ascending: false }],
378
+ limit: 10,
379
+ });
380
+ const broken = systems.filter((s) => s['verified'] !== true);
381
+ return {
382
+ company_id: companyId,
383
+ regime: regime === null
384
+ ? null
385
+ : {
386
+ kind: regime.kind,
387
+ format: regime.format,
388
+ brick: brickOf(regime),
389
+ mandatory_from: regime.mandatory_from,
390
+ ...(regime.clearance === undefined ? {} : { clearance: regime.clearance }),
391
+ ...(regime.exemption === undefined ? {} : { exemption: exemptionOn(regime, today()) }),
392
+ announced_from: regime.announced_from,
393
+ announced_by: regime.announced_by,
394
+ announced_on: regime.announced_on,
395
+ mode: regime.submission.mode,
396
+ modes: regime.submission.modes,
397
+ channel: regime.submission.channel,
398
+ cadence: regime.submission.cadence,
399
+ events: regime.events,
400
+ reported: regime.reported,
401
+ legal_reference: regime.legal_reference,
402
+ },
403
+ systems,
404
+ verified: broken.length === 0,
405
+ pending: pending.slice(0, limit),
406
+ pending_count: pending.length,
407
+ missing: missing.slice(0, limit),
408
+ missing_count: missing.length,
409
+ before_obligation_count: without.length - missing.length,
410
+ ...(cleared === null ? {} : { clearance: cleared.summary }),
411
+ events,
412
+ note: regime === null
413
+ ? 'The country pack of this company declares no fiscalisation regime.'
414
+ : !counted
415
+ ? uncountedNote(regime)
416
+ : cleared !== null
417
+ ? clearanceNote(regime, missing.length, from, cleared.summary)
418
+ : broken.length > 0
419
+ ? `The chain of ${broken.map((s) => String(s['system_id'])).join(', ')} breaks where break says. Nothing more is chained to it; this is for the company's accountant before anything else.`
420
+ : missing.length > 0
421
+ ? `${missing.length} posted sale(s) dated from ${from ?? 'the start of the obligation'} have no fiscal record. Nothing is fiscalised on its own: einvoicing_fiscalise (ekwo einvoice fiscalise) chains one.`
422
+ : 'Every posted sale within the obligation has its record, and every chain is intact.',
423
+ };
424
+ }
425
+ export const CLEARANCE_STATES = ['submitted', 'validated', 'rejected', 'cancelled'];
426
+ /** What the authority, the buyer or the issuer can bring a submission to. */
427
+ export const CLEARANCE_ANSWERS = ['validated', 'rejected', 'cancelled'];
428
+ /** The arguments of a call, without the ones nobody gave: the function's own default then holds. */
429
+ const given = (args) => Object.fromEntries(Object.entries(args).filter(([, value]) => value !== undefined));
430
+ /**
431
+ * Records that a posted sale was submitted to the authority under its
432
+ * company's clearance regime. Calls nobody: the submission is the host's,
433
+ * and this keeps what it reports. The database refuses a second submission
434
+ * while one waits, after a validation or after a cancellation.
435
+ */
436
+ export async function recordClearanceSubmission(backend, documentId, submission = {}) {
437
+ const [row] = await backend.rpc('record_clearance_submission', given({
438
+ p_document_id: documentId,
439
+ p_reference: submission.reference,
440
+ p_issue_id: submission.issueId,
441
+ p_submitted_at: submission.submittedAt,
442
+ p_answer: submission.answer,
443
+ }), SCHEMA);
444
+ if (row === undefined)
445
+ throw new BooksError('einvoicing_no_answer: einvoicing.record_clearance_submission answered nothing');
446
+ return {
447
+ document_id: documentId,
448
+ clearance: row,
449
+ note: `Recorded as attempt ${String(row['attempt'])} of this document, submitted as ${String(row['format'])} and waiting for the authority's answer, which einvoicing_clearance (ekwo einvoice clearance) records. The invoice is valid once the authority has validated it.`,
450
+ };
451
+ }
452
+ /**
453
+ * Records what came back about the latest submission of a document: its
454
+ * validation, with the identifier and the moment the authority gave it; a
455
+ * rejection by the authority or, within the regime's window, by the buyer;
456
+ * its cancellation by the issuer, within the regime's window. Every
457
+ * transition is checked by `einvoicing.record_clearance_answer()`, which
458
+ * raises on one that cannot have happened.
459
+ */
460
+ export async function recordClearanceAnswer(backend, documentId, answer) {
461
+ const [row] = await backend.rpc('record_clearance_answer', given({
462
+ p_document_id: documentId,
463
+ p_state: answer.state,
464
+ p_identifier: answer.identifier,
465
+ p_validated_at: answer.validatedAt,
466
+ p_verification_url: answer.verificationUrl,
467
+ p_reason: answer.reason,
468
+ p_by: answer.by,
469
+ p_at: answer.at,
470
+ p_answer: answer.answer,
471
+ }), SCHEMA);
472
+ if (row === undefined)
473
+ throw new BooksError('einvoicing_no_answer: einvoicing.record_clearance_answer answered nothing');
474
+ const state = String(row['state']);
475
+ return {
476
+ document_id: documentId,
477
+ clearance: row,
478
+ note: state === 'validated'
479
+ ? `Validated by the authority as ${String(row['identifier'])}: the invoice is valid${row['verification_url'] === null ? '' : ', and carries the link to its verification'}.`
480
+ : state === 'cancelled'
481
+ ? 'Cancelled at the authority: the invoice is void there. It is undone in the books with cancel_document.'
482
+ : row['rejected_by'] === 'buyer'
483
+ ? 'Rejected by the buyer: the invoice stays valid at the authority until the issuer cancels it, within the regime\'s window, or settles with the buyer.'
484
+ : 'Rejected by the authority, which did not validate it. The books are corrected and the invoice submitted again: that is the next attempt.',
485
+ };
486
+ }
487
+ const CLEARANCE = [
488
+ 'id', 'document_id', 'attempt', 'format', 'state', 'reference', 'submitted_at', 'identifier', 'validated_at',
489
+ 'verification_url', 'rejected_by', 'rejected_at', 'rejection_reason', 'cancelled_at', 'cancellation_reason',
490
+ ];
491
+ /**
492
+ * Where the posted sales of a company stand under a clearance regime, by the
493
+ * latest submission of each: validated, waiting, rejected by the authority,
494
+ * rejected by the buyer, or cancelled at the authority while still posted
495
+ * here. `unvalidated` is the sales never submitted or rejected by the
496
+ * authority — the ones the status counts as missing. A credit note that
497
+ * undoes an invoice cancelled at the authority is the books' half of that
498
+ * cancellation, and is not one of them.
499
+ */
500
+ async function clearanceOf(backend, companyId, posted, limit) {
501
+ const all = await backend.select({
502
+ schema: SCHEMA,
503
+ table: 'clearances',
504
+ columns: CLEARANCE,
505
+ where: [{ column: 'company_id', op: 'eq', value: companyId }],
506
+ order: [{ column: 'document_id' }, { column: 'attempt' }],
507
+ });
508
+ const latest = new Map();
509
+ for (const clearance of all)
510
+ latest.set(String(clearance['document_id']), clearance);
511
+ const voided = new Set([...latest.values()].filter((c) => c['state'] === 'cancelled').map((c) => String(c['document_id'])));
512
+ const unvalidated = new Set();
513
+ const awaiting = [];
514
+ const rejected = [];
515
+ const buyerRejected = [];
516
+ const cancelled = [];
517
+ let validated = 0;
518
+ for (const document of posted) {
519
+ const id = String(document['id']);
520
+ const last = latest.get(id);
521
+ if (last === undefined) {
522
+ const undoes = document['reversed_document_id'];
523
+ if (!(undoes !== null && undoes !== undefined && voided.has(String(undoes))))
524
+ unvalidated.add(id);
525
+ continue;
526
+ }
527
+ const { id: clearanceId, ...clearance } = last;
528
+ const entry = { id, doc_type: document['doc_type'], number: document['number'], document_date: document['document_date'], clearance_id: clearanceId, ...clearance };
529
+ if (last['state'] === 'validated')
530
+ validated += 1;
531
+ else if (last['state'] === 'submitted')
532
+ awaiting.push(entry);
533
+ else if (last['state'] === 'cancelled')
534
+ cancelled.push(entry);
535
+ else if (last['rejected_by'] === 'buyer')
536
+ buyerRejected.push(entry);
537
+ else {
538
+ unvalidated.add(id);
539
+ rejected.push(entry);
540
+ }
541
+ }
542
+ return {
543
+ unvalidated,
544
+ summary: {
545
+ validated_count: validated,
546
+ awaiting: awaiting.slice(0, limit),
547
+ awaiting_count: awaiting.length,
548
+ rejected: rejected.slice(0, limit),
549
+ rejected_count: rejected.length,
550
+ buyer_rejected: buyerRejected.slice(0, limit),
551
+ buyer_rejected_count: buyerRejected.length,
552
+ cancelled_still_posted: cancelled.slice(0, limit),
553
+ cancelled_still_posted_count: cancelled.length,
554
+ },
555
+ };
556
+ }
557
+ /** Today, as the calendar day of this machine. */
558
+ const today = () => generatedNow().slice(0, 10);
559
+ /** What the status says of a company under a clearance regime in force. */
560
+ function clearanceNote(regime, missing, from, summary) {
561
+ const brick = brickOf(regime);
562
+ const sentences = [
563
+ `The country pack of this company declares a clearance regime, ${regime.format}: an invoice is valid once the authority has validated it, and nothing is chained.`,
564
+ brick === null
565
+ ? `No brick of packages/formats/ writes ${regime.format} yet (format_without_brick): Ekwo writes no file to submit, and records the submissions a host makes and the answers it receives.`
566
+ : `${brick} writes the file submitted.`,
567
+ ];
568
+ if (missing > 0) {
569
+ sentences.push(`${missing} posted sale(s) dated from ${from ?? 'the start of the obligation'} have no validation and none on its way${summary.rejected_count > 0 ? `, ${summary.rejected_count} of them rejected by the authority` : ''}. Nothing is submitted on its own: a host submits them, and einvoicing_clearance (ekwo einvoice clearance) records the submission and the answer.`);
570
+ }
571
+ if (summary.awaiting_count > 0)
572
+ sentences.push(`${summary.awaiting_count} wait for the authority's answer.`);
573
+ if (summary.buyer_rejected_count > 0) {
574
+ sentences.push(`${summary.buyer_rejected_count} were rejected by the buyer after their validation: the issuer cancels them within the regime's window, or settles with the buyer.`);
575
+ }
576
+ if (summary.cancelled_still_posted_count > 0) {
577
+ sentences.push(`${summary.cancelled_still_posted_count} were cancelled at the authority and are still posted here: cancel_document undoes them in the books.`);
578
+ }
579
+ if (missing === 0 && summary.awaiting_count + summary.buyer_rejected_count + summary.cancelled_still_posted_count === 0) {
580
+ sentences.push('Every posted sale within the obligation is validated by the authority.');
581
+ }
582
+ const exemption = exemptionOn(regime, today());
583
+ if (exemption !== null || regime.mandatory_from.some((entry) => entry.turnover_above !== undefined)) {
584
+ sentences.push(`The obligation depends on the size of the taxpayer${exemption === null
585
+ ? ''
586
+ : `, and a taxpayer below ${exemption.turnover_below.amount} ${exemption.turnover_below.currency} of annual turnover is exempted since ${exemption.valid_from}`}; the books do not decide which this company is, so the count starts on the earliest day, ${from ?? 'none declared'}.`);
587
+ }
588
+ return sentences.join(' ');
589
+ }
590
+ /** Why the status counts no sale as missing a record under a regime that is announced only, or that reports. */
591
+ function uncountedNote(regime) {
592
+ if (regime.announced_from !== null) {
593
+ return `The fiscalisation of this company's country is only announced, from ${regime.announced_from} (${regime.announced_by ?? 'by nobody named'}, ${regime.announced_on ?? 'undated'}); no text in force sets it, so no sale is counted as missing a record.`;
594
+ }
595
+ const brick = FISCALISATION_FORMATS[regime.format]?.brick;
596
+ return `The fiscalisation of this company's country is a reporting regime, ${regime.format}: the data of its documents are reported to the authority and nothing is chained, so no sale is counted as missing a record. ${brick === undefined ? `No brick of packages/formats/ writes ${regime.format} yet (format_without_brick), and Ekwo writes no report for it.` : `${brick} writes the report.`}`;
597
+ }
598
+ /** The variable the command line and the MCP server read the invoicing system from, as JSON. */
599
+ export const FISCAL_SYSTEM_ENV = 'EKWO_FISCAL_SYSTEM';
600
+ /**
601
+ * The invoicing system, from the JSON an operator wrote: every text field
602
+ * present and not blank, the three booleans booleans where given. Nothing is
603
+ * filled in — a producer's tax number guessed is a record the authority
604
+ * attributes to somebody else.
605
+ */
606
+ export function parseFiscalSystem(value, where = FISCAL_SYSTEM_ENV) {
607
+ let raw = value;
608
+ if (typeof raw === 'string') {
609
+ try {
610
+ raw = JSON.parse(raw);
611
+ }
612
+ catch (error) {
613
+ throw new BooksError(`bad_fiscal_system: ${where} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
614
+ }
615
+ }
616
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
617
+ throw new BooksError(`bad_fiscal_system: ${where} describes the invoicing system as an object.`);
618
+ }
619
+ const entry = raw;
620
+ const missing = ['name', 'id', 'version', 'installation', 'producerName', 'producerTaxId'].filter((key) => typeof entry[key] !== 'string' || entry[key].trim() === '');
621
+ if (missing.length > 0) {
622
+ throw new BooksError(`bad_fiscal_system: ${where} names no ${missing.join(', ')}. The invoicing system is described by its operator — name, id, version, installation, producerName, producerTaxId — and none of it is invented.`);
623
+ }
624
+ const system = {
625
+ name: String(entry['name']),
626
+ id: String(entry['id']),
627
+ version: String(entry['version']),
628
+ installation: String(entry['installation']),
629
+ producerName: String(entry['producerName']),
630
+ producerTaxId: String(entry['producerTaxId']),
631
+ };
632
+ for (const key of ['verifactuOnly', 'multipleTaxpayers', 'multipleTaxpayersNow']) {
633
+ const flag = entry[key];
634
+ if (flag === undefined)
635
+ continue;
636
+ if (typeof flag !== 'boolean')
637
+ throw new BooksError(`bad_fiscal_system: ${key} in ${where} is true or false.`);
638
+ system[key] = flag;
639
+ }
640
+ return system;
641
+ }
642
+ //# sourceMappingURL=fiscalisation.js.map