@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.
- package/dist/books/analytics.d.ts +32 -0
- package/dist/books/analytics.d.ts.map +1 -0
- package/dist/books/analytics.js +94 -0
- package/dist/books/analytics.js.map +1 -0
- package/dist/books/columns.d.ts.map +1 -1
- package/dist/books/columns.js +5 -0
- package/dist/books/columns.js.map +1 -1
- package/dist/books/contacts.d.ts +43 -0
- package/dist/books/contacts.d.ts.map +1 -1
- package/dist/books/contacts.js +61 -0
- package/dist/books/contacts.js.map +1 -1
- package/dist/books/index.d.ts +3 -0
- package/dist/books/index.d.ts.map +1 -1
- package/dist/books/index.js +3 -0
- package/dist/books/index.js.map +1 -1
- package/dist/books/lines.d.ts +38 -0
- package/dist/books/lines.d.ts.map +1 -0
- package/dist/books/lines.js +73 -0
- package/dist/books/lines.js.map +1 -0
- package/dist/books/matching.d.ts +65 -0
- package/dist/books/matching.d.ts.map +1 -0
- package/dist/books/matching.js +95 -0
- package/dist/books/matching.js.map +1 -0
- package/dist/einvoicing/fiscalisation.d.ts +296 -0
- package/dist/einvoicing/fiscalisation.d.ts.map +1 -0
- package/dist/einvoicing/fiscalisation.js +642 -0
- package/dist/einvoicing/fiscalisation.js.map +1 -0
- package/dist/einvoicing/formats.d.ts +16 -0
- package/dist/einvoicing/formats.d.ts.map +1 -1
- package/dist/einvoicing/formats.js +110 -0
- package/dist/einvoicing/formats.js.map +1 -1
- package/dist/einvoicing/index.d.ts +4 -1
- package/dist/einvoicing/index.d.ts.map +1 -1
- package/dist/einvoicing/index.js +4 -1
- package/dist/einvoicing/index.js.map +1 -1
- package/dist/einvoicing/issue.d.ts +20 -1
- package/dist/einvoicing/issue.d.ts.map +1 -1
- package/dist/einvoicing/issue.js +54 -9
- package/dist/einvoicing/issue.js.map +1 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +1 -1
- package/dist/types.d.ts +12 -1
- package/dist/types.d.ts.map +1 -1
- 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
|