@fin.cx/einvoice 6.2.0 → 7.0.1

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 (35) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/einvoice.js +3 -2
  3. package/dist_ts/formats/ubl/xrechnung/xrechnung.encoder.d.ts +7 -0
  4. package/dist_ts/formats/ubl/xrechnung/xrechnung.encoder.js +19 -3
  5. package/dist_ts/formats/validation/conformance.harness.js +1 -1
  6. package/dist_ts/formats/validation/schematron.downloader.d.ts +43 -15
  7. package/dist_ts/formats/validation/schematron.downloader.js +122 -77
  8. package/dist_ts/formats/validation/schematron.integration.d.ts +5 -1
  9. package/dist_ts/formats/validation/schematron.integration.js +16 -40
  10. package/dist_ts/formats/validation/schematron.validator.d.ts +35 -15
  11. package/dist_ts/formats/validation/schematron.validator.js +136 -138
  12. package/dist_ts/formats/validation/schematron.worker.d.ts +12 -9
  13. package/dist_ts/formats/validation/schematron.worker.js +148 -108
  14. package/dist_ts/plugins.d.ts +3 -1
  15. package/dist_ts/plugins.js +7 -2
  16. package/dist_ts_install/download-schematron.js +6 -3
  17. package/dist_ts_install/index.js +15 -11
  18. package/package.json +6 -8
  19. package/ts/00_commitinfo_data.ts +1 -1
  20. package/ts/einvoice.ts +2 -1
  21. package/ts/formats/ubl/xrechnung/xrechnung.encoder.ts +19 -2
  22. package/ts/formats/validation/conformance.harness.ts +1 -1
  23. package/ts/formats/validation/schematron.downloader.ts +146 -97
  24. package/ts/formats/validation/schematron.integration.ts +18 -44
  25. package/ts/formats/validation/schematron.validator.ts +181 -181
  26. package/ts/formats/validation/schematron.worker.ts +191 -135
  27. package/ts/plugins.ts +8 -0
  28. package/ts/vendor/modules.d.ts +0 -19
  29. package/dist_ts/vendor/saxonjs.d.ts +0 -23
  30. package/dist_ts/vendor/saxonjs.js +0 -9
  31. package/readme.hints.md +0 -1134
  32. package/readme.howtofixtests.md +0 -38
  33. package/readme.literature.md +0 -1
  34. package/readme.plan.md +0 -497
  35. package/ts/vendor/saxonjs.ts +0 -36
@@ -93,55 +93,140 @@ export class SchematronDownloader {
93
93
  }
94
94
 
95
95
  /**
96
- * Download a Schematron file
96
+ * Download a Schematron file together with every file it includes.
97
+ *
98
+ * The published rule sets split their patterns over sch:include files, so a lone
99
+ * entry document cannot be validated with. The upstream directory layout is
100
+ * mirrored under the cache so those relative includes still resolve.
97
101
  */
98
102
  public async download(source: SchematronSource): Promise<string> {
99
- const fileName = `${source.name}-v${source.version}.sch`;
100
- const filePath = path.join(this.cacheDir, fileName);
101
-
103
+ const filePath = this.entryPathFor(source);
104
+
102
105
  // Check if already cached
103
106
  if (await this.isCached(filePath)) {
104
- console.log(`Using cached Schematron: ${fileName}`);
107
+ console.log(`Using cached Schematron: ${source.name} v${source.version}`);
105
108
  return filePath;
106
109
  }
107
-
110
+
108
111
  console.log(`Downloading Schematron: ${source.name} v${source.version}`);
109
-
112
+
110
113
  try {
111
- // Download the file
112
- const response = await fetch(source.url);
113
- if (!response.ok) {
114
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
115
- }
116
-
117
- const content = await response.text();
118
-
119
- // Validate it's actually Schematron
120
- if (!content.includes('schematron') && !content.includes('sch:schema')) {
121
- throw new Error('Downloaded file does not appear to be Schematron');
122
- }
123
-
124
- // Save to cache
125
- await fs.writeFile(filePath, content, 'utf-8');
126
-
114
+ const fileCount = await this.fetchWithIncludes(source.url, filePath, new Set<string>());
115
+
127
116
  // Also save metadata
128
- const metaPath = filePath.replace('.sch', '.meta.json');
117
+ const metaPath = filePath.replace(/\.sch$/, '.meta.json');
129
118
  await fs.writeFile(metaPath, JSON.stringify({
130
119
  source: source.name,
131
120
  version: source.version,
132
121
  url: source.url,
133
122
  format: source.format,
123
+ fileCount,
134
124
  downloadDate: new Date().toISOString()
135
125
  }, null, 2), 'utf-8');
136
-
137
- console.log(`Successfully downloaded: ${fileName}`);
126
+
127
+ await this.writeIndexEntry(source, filePath);
128
+
129
+ console.log(`Successfully downloaded: ${source.name} (${fileCount} file${fileCount === 1 ? '' : 's'})`);
138
130
  return filePath;
139
131
  } catch (error) {
140
132
  const errorMessage = error instanceof Error ? error.message : String(error);
141
133
  throw new Error(`Failed to download ${source.name}: ${errorMessage}`);
142
134
  }
143
135
  }
144
-
136
+
137
+ /**
138
+ * Where the entry document of a source lands: one directory per source and
139
+ * version, holding the upstream path so relative includes keep working.
140
+ */
141
+ private entryPathFor(source: SchematronSource): string {
142
+ return path.join(this.cacheDir, `${source.name}-v${source.version}`, this.repositoryPath(source.url));
143
+ }
144
+
145
+ /**
146
+ * The part of a raw URL below the repository and its ref, e.g.
147
+ * `ubl/schematron/EN16931-UBL-validation.sch`.
148
+ */
149
+ private repositoryPath(url: string): string {
150
+ const { pathname } = new URL(url);
151
+ const match = /\/raw\/[^/]+\/(.+)$/.exec(pathname);
152
+ return match ? match[1] : pathname.replace(/^\//, '');
153
+ }
154
+
155
+ /**
156
+ * Fetches one document and, recursively, every sch:include and sch:extends it
157
+ * names. Returns how many files were written.
158
+ */
159
+ private async fetchWithIncludes(url: string, filePath: string, seen: Set<string>): Promise<number> {
160
+ if (seen.has(url)) {
161
+ return 0;
162
+ }
163
+ seen.add(url);
164
+
165
+ const response = await fetch(url);
166
+ if (!response.ok) {
167
+ throw new Error(`HTTP ${response.status}: ${response.statusText} for ${url}`);
168
+ }
169
+
170
+ const content = await response.text();
171
+
172
+ // Validate it's actually Schematron
173
+ if (!content.includes('schematron') && !content.includes('sch:schema')) {
174
+ throw new Error(`Downloaded file does not appear to be Schematron: ${url}`);
175
+ }
176
+
177
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
178
+ await fs.writeFile(filePath, content, 'utf-8');
179
+
180
+ let fileCount = 1;
181
+ for (const href of SchematronDownloader.extractIncludes(content)) {
182
+ const includePath = path.resolve(path.dirname(filePath), href);
183
+ if (!includePath.startsWith(path.resolve(this.cacheDir) + path.sep)) {
184
+ throw new Error(`Schematron include escapes the cache directory: ${href}`);
185
+ }
186
+ fileCount += await this.fetchWithIncludes(new URL(href, url).toString(), includePath, seen);
187
+ }
188
+
189
+ return fileCount;
190
+ }
191
+
192
+ /**
193
+ * The href of every sch:include and sch:extends in a schema, whatever prefix the
194
+ * document binds the Schematron namespace to.
195
+ */
196
+ private static extractIncludes(content: string): string[] {
197
+ const hrefs: string[] = [];
198
+ const pattern = /<(?:[A-Za-z0-9_.-]+:)?(?:include|extends)\s+[^>]*href\s*=\s*"([^"]+)"/g;
199
+ let match: RegExpExecArray | null;
200
+ while ((match = pattern.exec(content)) !== null) {
201
+ hrefs.push(match[1]);
202
+ }
203
+ return hrefs;
204
+ }
205
+
206
+ /**
207
+ * Records the entry document of each source, so a consumer finds it without
208
+ * guessing at file names.
209
+ */
210
+ private async writeIndexEntry(source: SchematronSource, filePath: string): Promise<void> {
211
+ const indexPath = path.join(this.cacheDir, 'index.json');
212
+ let index: Record<string, unknown> = {};
213
+ try {
214
+ index = JSON.parse(await fs.readFile(indexPath, 'utf-8'));
215
+ } catch {
216
+ // no index yet
217
+ }
218
+
219
+ index[source.name] = {
220
+ entry: path.relative(this.cacheDir, filePath),
221
+ version: source.version,
222
+ format: source.format,
223
+ url: source.url,
224
+ downloadDate: new Date().toISOString(),
225
+ };
226
+
227
+ await fs.writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, 'utf-8');
228
+ }
229
+
145
230
  /**
146
231
  * Download all Schematron files for a standard
147
232
  */
@@ -190,52 +275,56 @@ export class SchematronDownloader {
190
275
  metadata: any;
191
276
  }>> {
192
277
  const files: Array<{ path: string; metadata: any }> = [];
193
-
194
- try {
195
- const entries = await fs.readdir(this.cacheDir);
196
-
197
- for (const entry of entries) {
198
- if (entry.endsWith('.sch')) {
199
- const filePath = path.join(this.cacheDir, entry);
200
- const metaPath = filePath.replace('.sch', '.meta.json');
201
-
202
- try {
203
- const metadata = JSON.parse(await fs.readFile(metaPath, 'utf-8'));
204
- files.push({ path: filePath, metadata });
205
- } catch {
206
- // No metadata file
207
- files.push({ path: filePath, metadata: null });
208
- }
209
- }
278
+
279
+ for (const [name, entry] of Object.entries(await this.readIndex())) {
280
+ const filePath = path.join(this.cacheDir, (entry as { entry: string }).entry);
281
+ if (await this.isCached(filePath)) {
282
+ files.push({ path: filePath, metadata: { source: name, ...(entry as object) } });
210
283
  }
211
- } catch (error) {
212
- const errorMessage = error instanceof Error ? error.message : String(error);
213
- console.warn(`Failed to list cached files: ${errorMessage}`);
214
284
  }
215
-
285
+
216
286
  return files;
217
287
  }
218
-
288
+
289
+ /**
290
+ * The index of downloaded entry documents; empty when nothing was downloaded yet.
291
+ */
292
+ public async readIndex(): Promise<Record<string, { entry: string; version: string; format: string; url: string }>> {
293
+ try {
294
+ return JSON.parse(await fs.readFile(path.join(this.cacheDir, 'index.json'), 'utf-8'));
295
+ } catch {
296
+ return {};
297
+ }
298
+ }
299
+
300
+ /**
301
+ * The entry document of one source, or null when it was not downloaded.
302
+ */
303
+ public async getCachedEntry(sourceName: string): Promise<string | null> {
304
+ const entry = (await this.readIndex())[sourceName];
305
+ if (!entry) {
306
+ return null;
307
+ }
308
+ const filePath = path.join(this.cacheDir, entry.entry);
309
+ return (await this.isCached(filePath)) ? filePath : null;
310
+ }
311
+
219
312
  /**
220
313
  * Clear cache
221
314
  */
222
315
  public async clearCache(): Promise<void> {
223
316
  try {
224
- const entries = await fs.readdir(this.cacheDir);
225
-
226
- for (const entry of entries) {
227
- if (entry.endsWith('.sch') || entry.endsWith('.meta.json')) {
228
- await fs.unlink(path.join(this.cacheDir, entry));
229
- }
317
+ for (const entry of await fs.readdir(this.cacheDir)) {
318
+ await fs.rm(path.join(this.cacheDir, entry), { recursive: true, force: true });
230
319
  }
231
-
320
+
232
321
  console.log('Schematron cache cleared');
233
322
  } catch (error) {
234
323
  const errorMessage = error instanceof Error ? error.message : String(error);
235
324
  console.warn(`Failed to clear cache: ${errorMessage}`);
236
325
  }
237
326
  }
238
-
327
+
239
328
  /**
240
329
  * Get the appropriate Schematron for a format
241
330
  */
@@ -265,43 +354,3 @@ export class SchematronDownloader {
265
354
  console.log('All Schematron files updated');
266
355
  }
267
356
  }
268
-
269
- /**
270
- * ISO Schematron skeleton URLs
271
- * These are needed to compile Schematron to XSLT
272
- */
273
- export const ISO_SCHEMATRON_SKELETONS = {
274
- 'iso_dsdl_include.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_dsdl_include.xsl',
275
- 'iso_abstract_expand.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_abstract_expand.xsl',
276
- 'iso_svrl_for_xslt2.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_svrl_for_xslt2.xsl',
277
- 'iso_schematron_skeleton_for_saxon.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_schematron_skeleton_for_saxon.xsl'
278
- };
279
-
280
- /**
281
- * Download ISO Schematron skeleton files
282
- */
283
- export async function downloadISOSkeletons(targetDir: string = 'assets_downloaded/schematron/iso'): Promise<void> {
284
- await fs.mkdir(targetDir, { recursive: true });
285
-
286
- console.log('Downloading ISO Schematron skeleton files...');
287
-
288
- for (const [name, url] of Object.entries(ISO_SCHEMATRON_SKELETONS)) {
289
- try {
290
- const response = await fetch(url);
291
- if (!response.ok) {
292
- throw new Error(`HTTP ${response.status}`);
293
- }
294
-
295
- const content = await response.text();
296
- const filePath = path.join(targetDir, name);
297
- await fs.writeFile(filePath, content, 'utf-8');
298
-
299
- console.log(`Downloaded: ${name}`);
300
- } catch (error) {
301
- const errorMessage = error instanceof Error ? error.message : String(error);
302
- console.warn(`Failed to download ${name}: ${errorMessage}`);
303
- }
304
- }
305
-
306
- console.log('ISO Schematron skeleton download complete');
307
- }
@@ -3,11 +3,11 @@
3
3
  */
4
4
 
5
5
  import { SchematronValidator, HybridValidator } from './schematron.validator.js';
6
+ import { SchematronDownloader } from './schematron.downloader.js';
6
7
  import { EN16931BusinessRulesValidator } from './en16931.business-rules.validator.js';
7
8
  import { CodeListValidator } from './codelist.validator.js';
8
9
  import type { ValidationResult, ValidationOptions, ValidationReport } from './validation.types.js';
9
10
  import type { EInvoice } from '../../einvoice.js';
10
- import * as path from 'path';
11
11
  import { promises as fs } from 'fs';
12
12
 
13
13
  /**
@@ -66,57 +66,34 @@ export class IntegratedValidator {
66
66
 
67
67
  await this.schematronValidator.loadSchematron(schematronPath, true);
68
68
  if (!this.schematronValidator.canValidate()) {
69
- throw new Error(`Schematron file is not a precompiled Saxon-JS SEF resource: ${schematronPath}`);
69
+ throw new Error(`Schematron file could not be compiled: ${schematronPath}`);
70
70
  }
71
71
  this.schematronLoaded = true;
72
72
  }
73
73
 
74
74
  /**
75
- * Get the path to the appropriate Schematron file
75
+ * Get the path to the appropriate Schematron file.
76
+ *
77
+ * The downloader records the entry document of every rule set in its index, so the
78
+ * file is looked up by name rather than guessed from a pattern; an include of the
79
+ * rule set must never be mistaken for an entry document.
76
80
  */
77
81
  private async getSchematronPath(
78
82
  standard: 'EN16931' | 'PEPPOL' | 'XRECHNUNG',
79
83
  format: 'UBL' | 'CII'
80
84
  ): Promise<string | null> {
81
- const basePath = 'assets_downloaded/schematron';
82
-
83
- // Map standard and format to file pattern
84
- const patterns: Record<string, Record<string, string>> = {
85
- EN16931: {
86
- UBL: 'EN16931-UBL-*.sch',
87
- CII: 'EN16931-CII-*.sch'
88
- },
89
- PEPPOL: {
90
- UBL: 'PEPPOL-EN16931-UBL-*.sch',
91
- CII: 'PEPPOL-EN16931-CII-*.sch'
92
- },
93
- XRECHNUNG: {
94
- UBL: 'XRechnung-UBL-*.sch',
95
- CII: 'XRechnung-CII-*.sch'
96
- }
85
+ const sourceNames: Record<string, Record<string, string>> = {
86
+ EN16931: { UBL: 'EN16931-UBL', CII: 'EN16931-CII' },
87
+ PEPPOL: { UBL: 'PEPPOL-EN16931-UBL', CII: 'PEPPOL-EN16931-CII' },
88
+ XRECHNUNG: { UBL: 'XRechnung-UBL', CII: 'XRechnung-CII' }
97
89
  };
98
-
99
- const pattern = patterns[standard]?.[format];
100
- if (!pattern) return null;
101
-
102
- // Find matching files
103
- try {
104
- const files = await fs.readdir(basePath);
105
- const regex = new RegExp(pattern.replace('*', '.*'));
106
- const matches = files.filter(f => regex.test(f));
107
-
108
- if (matches.length > 0) {
109
- // Return the most recent version (lexicographically last)
110
- matches.sort();
111
- return path.join(basePath, matches[matches.length - 1]);
112
- }
113
- } catch {
114
- // Directory doesn't exist
115
- }
116
-
117
- return null;
90
+
91
+ const sourceName = sourceNames[standard]?.[format];
92
+ if (!sourceName) return null;
93
+
94
+ return new SchematronDownloader().getCachedEntry(sourceName);
118
95
  }
119
-
96
+
120
97
  /**
121
98
  * Validate an invoice using all available validators
122
99
  */
@@ -142,10 +119,7 @@ export class IntegratedValidator {
142
119
  if (xmlContent && this.schematronLoaded) {
143
120
  try {
144
121
  const schematronResults = await this.schematronValidator.validate(xmlContent, {
145
- includeWarnings: !options.strictMode,
146
- parameters: {
147
- profile: options.profile
148
- }
122
+ includeWarnings: !options.strictMode
149
123
  });
150
124
  results.push(...schematronResults);
151
125
  } catch (error) {