• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gflohr / e-invoice-eu / 14067914634

25 Mar 2025 06:59PM UTC coverage: 68.943%. First build
14067914634

Pull #110

github

web-flow
Merge 128bb09e8 into fbaf9faed
Pull Request #110: Move logic into core library

233 of 371 branches covered (62.8%)

Branch coverage included in aggregate %.

427 of 556 new or added lines in 22 files covered. (76.8%)

726 of 1020 relevant lines covered (71.18%)

72.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

34.04
/apps/cli/src/commands/invoice.ts
1
// FIXME! Name all the commands SubjectCommand so that we can import the
2
// Invoice interface in a normal fashion.
3
import {
3✔
4
        Invoice as CoreInvoice,
5
        InvoiceService,
6
        InvoiceServiceOptions,
7
        MappingService,
8
} from '@e-invoice-eu/core';
9
import { Textdomain } from '@esgettext/runtime';
3✔
10
import { accessSync, statSync } from 'fs';
3✔
11
import * as fs from 'fs/promises';
3✔
12
import * as yaml from 'js-yaml';
3✔
13
import { lookup } from 'mime-types';
3✔
14
import * as os from 'os';
3✔
15
import * as path from 'path';
3✔
16
import yargs, { InferredOptionTypes } from 'yargs';
17

18
import { Command } from '../command';
19
import { coerceOptions, OptSpec } from '../optspec';
3✔
20
import { Package } from '../package';
3✔
21
import { safeStdoutBufferWrite, safeStdoutWrite } from '../safe-stdout-write';
3✔
22

23
const gtx = Textdomain.getInstance('e-invoice-eu-cli');
3✔
24

25
function findExecutable(command: string): string | null {
26
        const paths = process.env.PATH?.split(path.delimiter) || [];
12!
27

28
        for (const dir of paths) {
12✔
29
                const fullPath = path.join(dir, command);
384✔
30
                try {
384✔
31
                        if (
384!
32
                                statSync(fullPath).isFile() &&
256!
33
                                accessSync(fullPath, fs.constants.X_OK) === undefined
34
                        ) {
NEW
35
                                return fullPath;
×
36
                        }
37
                } catch {
38
                        // Ignore errors (e.g., file doesn't exist or no access)
39
                }
40
        }
41

42
        return null;
12✔
43
}
44

45
export function guessLibreOfficePath(): string {
3✔
46
        const platform = os.platform();
6✔
47

48
        if (platform === 'win32') {
6✔
NEW
49
                return 'C:\\Program Files\\LibreOffice\\program\\soffice.exe';
×
50
        } else if (platform === 'darwin') {
6✔
NEW
51
                return '/Applications/LibreOffice.app/Contents/MacOS/soffice';
×
52
        } else {
53
                return (
6✔
54
                        findExecutable('libreoffice') ??
12✔
55
                        findExecutable('soffice') ??
56
                        'libreoffice'
57
                );
58
        }
59
}
60

61
const options: {
62
        format: OptSpec;
63
        output: OptSpec;
64
        invoice: OptSpec;
65
        mapping: OptSpec;
66
        data: OptSpec;
67
        lang: OptSpec;
68
        pdf: OptSpec;
69
        'pdf-id': OptSpec;
70
        'pdf-description': OptSpec;
71
        attachment: OptSpec;
72
        'attachment-id': OptSpec;
73
        'attachment-description': OptSpec;
74
        'attachment-mimetype': OptSpec;
75
        'embed-pdf': OptSpec;
76
        'libre-office': OptSpec;
77
} = {
3✔
78
        format: {
79
                group: gtx._('Format selection'),
80
                alias: ['f'],
81
                type: 'string',
82
                demandOption: true,
83
                describe: gtx._(
84
                        "invoice format (case-insensitive), try 'format --list' for a list of allowed values",
85
                ),
86
        },
87
        output: {
88
                group: gtx._('Output file location'),
89
                alias: ['o'],
90
                type: 'string',
91
                demandOption: false,
92
                describe: gtx._(
93
                        'write output to specified file instead of standard output',
94
                ),
95
        },
96
        invoice: {
97
                group: gtx._('Input data'),
98
                alias: ['i'],
99
                type: 'string',
100
                conflicts: ['mapping'],
101
                demandOption: false,
102
                describe: gtx._(
103
                        'JSON file with invoice data, mandatory for json data input',
104
                ),
105
        },
106
        mapping: {
107
                group: gtx._('Input data'),
108
                alias: ['m'],
109
                type: 'string',
110
                conflicts: ['invoice'],
111
                demandOption: false,
112
                describe: gtx._(
113
                        'mapping file (YAML or JSON), mandatory for spreadsheet data input',
114
                ),
115
        },
116
        data: {
117
                group: gtx._('Input data'),
118
                alias: ['d'],
119
                type: 'string',
120
                demandOption: false,
121
                describe: gtx._(
122
                        'invoice spreadsheet data, mandatory for spreadsheet data input',
123
                ),
124
        },
125
        pdf: {
126
                group: gtx._('Input data'),
127
                alias: ['p'],
128
                type: 'string',
129
                demandOption: false,
130
                describe: gtx._('PDF version of the invoice'),
131
        },
132
        'pdf-id': {
133
                group: gtx._('Input data'),
134
                type: 'string',
135
                demandOption: false,
136
                describe: gtx._('ID of the embedded PDF, defaults to the document number'),
137
        },
138
        'pdf-description': {
139
                group: gtx._('Input data'),
140
                type: 'string',
141
                demandOption: false,
142
                describe: gtx._('optional description of the embedded PDF'),
143
        },
144
        attachment: {
145
                group: gtx._('Input data'),
146
                alias: ['a'],
147
                type: 'string',
148
                multi: true,
149
                demandOption: false,
150
                describe: gtx._('arbitrary number of attachments'),
151
        },
152
        'attachment-id': {
153
                group: gtx._('Input data'),
154
                type: 'string',
155
                multi: true,
156
                demandOption: false,
157
                describe: gtx._('optional ids of the attachments'),
158
        },
159
        'attachment-description': {
160
                group: gtx._('Input data'),
161
                type: 'string',
162
                multi: true,
163
                demandOption: false,
164
                describe: gtx._('optional descriptions of the attachments'),
165
        },
166
        'attachment-mimetype': {
167
                group: gtx._('Input data'),
168
                alias: ['attachment-mime-type'],
169
                type: 'string',
170
                multi: true,
171
                demandOption: false,
172
                describe: gtx._('optional MIME types of the attachments'),
173
        },
174
        lang: {
175
                group: gtx._('Invoice details'),
176
                alias: ['l'],
177
                type: 'string',
178
                demandOption: false,
179
                default: 'en',
180
                describe: gtx._('invoice language code'),
181
        },
182
        'embed-pdf': {
183
                group: gtx._('Invoice details'),
184
                type: 'boolean',
185
                demandOption: false,
186
                describe: gtx._('embed a PDF version of the invoice'),
187
        },
188
        'libre-office': {
189
                group: gtx._('External programs'),
190
                alias: ['libreoffice'],
191
                type: 'string',
192
                demandOption: false,
193
                default: guessLibreOfficePath(),
194
                describe: gtx._(
195
                        'path to LibreOffice executable, mandatory if PDF creation is requested',
196
                ),
197
        },
198
};
199

200
export type ConfigOptions = InferredOptionTypes<typeof options>;
201

202
export class Invoice implements Command {
3✔
203
        description(): string {
204
                return gtx._('Create an e-invoice from spreadsheet data or JSON.');
3✔
205
        }
206

207
        aliases(): Array<string> {
208
                return [];
3✔
209
        }
210

211
        build(argv: yargs.Argv): yargs.Argv<object> {
212
                return argv.options(options);
3✔
213
        }
214

215
        private async addPdf(
216
                options: InvoiceServiceOptions,
217
                configOptions: ConfigOptions,
218
        ) {
NEW
219
                if (configOptions.pdf) {
×
NEW
220
                        options.pdf = {
×
221
                                buffer: await fs.readFile(configOptions.pdf as string),
222
                                filename: path.basename(configOptions.pdf as string),
223
                                mimetype: 'application/pdf',
224
                                id: configOptions['pdf-id'] as string,
225
                                description: configOptions['pdf-id'] as string,
226
                        };
227

NEW
228
                        options.embedPDF = !!configOptions['embed-pdf'];
×
229
                }
230
        }
231

232
        private checkConfigOptions(configOptions: ConfigOptions) {
NEW
233
                if (
×
234
                        typeof configOptions.invoice === 'undefined' &&
×
235
                        typeof configOptions.mapping === 'undefined'
236
                ) {
NEW
237
                        throw new Error(
×
238
                                gtx._("One of the options '--invoice' or '--mapping' is mandatory."),
239
                        );
NEW
240
                } else if (
×
241
                        typeof configOptions.mapping !== 'undefined' &&
×
242
                        typeof configOptions.data === 'undefined'
243
                ) {
NEW
244
                        throw new Error(gtx._('No invoice spreadsheet specified.'));
×
245
                }
246
        }
247

248
        private async addAttachments(
249
                options: InvoiceServiceOptions,
250
                configOptions: ConfigOptions,
251
        ) {
NEW
252
                if (!configOptions.attachment) return;
×
253

NEW
254
                const attachments = configOptions.attachment as string[];
×
NEW
255
                for (let i = 0; i < attachments.length; ++i) {
×
NEW
256
                        const filename = attachments[i];
×
NEW
257
                        const basename = path.basename(filename);
×
258
                        const mimetype =
NEW
259
                                configOptions['attachment-mimetype']?.[i] ?? lookup(basename);
×
260

NEW
261
                        if (!mimetype) {
×
NEW
262
                                throw new Error(
×
263
                                        gtx._x("cannot guess MIME type of attachment '{filename}'!", {
264
                                                filename,
265
                                        }),
266
                                );
267
                        }
268

NEW
269
                        options.attachments ??= [];
×
NEW
270
                        options.attachments.push({
×
271
                                buffer: await fs.readFile(filename),
272
                                filename: basename,
273
                                mimetype,
274
                                id: configOptions['attachment-id']?.[i],
275
                                description: configOptions['attachment-description']?.[i],
276
                        });
277
                }
278
        }
279

280
        private async createInvoice(
281
                options: InvoiceServiceOptions,
282
                configOptions: ConfigOptions,
283
        ): Promise<string | Buffer> {
284
                let invoiceData: CoreInvoice;
285

NEW
286
                const format = configOptions.format as string;
×
287

NEW
288
                if (typeof configOptions.data !== 'undefined') {
×
NEW
289
                        options.data = {
×
290
                                filename: configOptions.data as string,
291
                                buffer: await fs.readFile(configOptions.data as string),
292
                                mimetype: lookup[configOptions.data as string],
293
                        };
294
                }
295

NEW
296
                if (typeof configOptions.invoice !== 'undefined') {
×
NEW
297
                        const filename = configOptions.invoice as string;
×
298

NEW
299
                        const json = await fs.readFile(filename, 'utf-8');
×
NEW
300
                        try {
×
NEW
301
                                invoiceData = JSON.parse(json) as CoreInvoice;
×
302
                        } catch (e) {
NEW
303
                                throw new Error(`${filename}: ${e.message}`);
×
304
                        }
NEW
305
                } else if (typeof configOptions.mapping !== 'undefined') {
×
NEW
306
                        if (typeof configOptions.data == 'undefined') {
×
NEW
307
                                throw new Error(
×
308
                                        gtx._("The option '--data' is mandatory if a mapping is specified!"),
309
                                );
310
                        }
311

NEW
312
                        const mappingYaml = await fs.readFile(
×
313
                                configOptions.mapping as string,
314
                                'utf-8',
315
                        );
316

NEW
317
                        const mapping = yaml.load(mappingYaml);
×
318

NEW
319
                        const mappingService = new MappingService(console);
×
NEW
320
                        invoiceData = mappingService.transform(
×
321
                                options.data!.buffer,
322
                                format.toLowerCase(),
323
                                mapping,
324
                        );
325
                } else {
NEW
326
                        throw new Error(
×
327
                                gtx._("You must either specify '--data' or '--invoice'!"),
328
                        );
329
                }
330

NEW
331
                options.libreOfficePath = configOptions['libre-office'] as string;
×
332

NEW
333
                const invoiceService = new InvoiceService(console);
×
334

NEW
335
                return await invoiceService.generate(invoiceData, options);
×
336
        }
337

338
        private async doRun(configOptions: ConfigOptions) {
NEW
339
                const options: InvoiceServiceOptions = {
×
340
                        format: configOptions.format as string,
341
                        lang: configOptions.lang as string,
342
                        attachments: [],
343
                };
344

NEW
345
                this.checkConfigOptions(configOptions);
×
NEW
346
                await this.addPdf(options, configOptions);
×
NEW
347
                await this.addAttachments(options, configOptions);
×
348

NEW
349
                const document = await this.createInvoice(options, configOptions);
×
NEW
350
                if (typeof document === 'string') {
×
NEW
351
                        if (typeof configOptions.output === 'undefined') {
×
NEW
352
                                safeStdoutWrite(document);
×
353
                        } else {
NEW
354
                                await fs.writeFile(configOptions.output as string, document, 'utf-8');
×
355
                        }
356
                } else {
NEW
357
                        if (typeof configOptions.output === 'undefined') {
×
NEW
358
                                safeStdoutBufferWrite(document);
×
359
                        } else {
NEW
360
                                await fs.writeFile(configOptions.output as string, document);
×
361
                        }
362
                }
363
        }
364

365
        public async run(argv: yargs.Arguments): Promise<number> {
366
                const configOptions = argv as unknown as ConfigOptions;
9✔
367

368
                if (!coerceOptions(argv, options)) {
9✔
369
                        return 1;
3✔
370
                }
371

372
                try {
6✔
373
                        await this.doRun(configOptions);
6✔
374
                        return 0;
3✔
375
                } catch (e) {
376
                        console.error(
3✔
377
                                gtx._x('{programName}: {error}', {
378
                                        programName: Package.getName(),
379
                                        error: e,
380
                                }),
381
                        );
382

383
                        return 1;
3✔
384
                }
385
        }
386
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc