@open-nav/mock-server 0.1.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.
@@ -0,0 +1,733 @@
1
+ import { randomBytes, createCipheriv } from 'node:crypto';
2
+ import {
3
+ HEADER_VERSION,
4
+ REQUEST_VERSION,
5
+ decodeInvoiceData,
6
+ decodeToXml,
7
+ passwordHash,
8
+ requestSignature,
9
+ serializeDocument,
10
+ validateInvoice,
11
+ type InvoiceData,
12
+ type InvoiceStatusType,
13
+ type SoftwareType,
14
+ } from '@open-nav/core';
15
+ import type { MockState, StoredInvoice } from './state.js';
16
+
17
+ /** Credentials the mock expects, mirroring a technical user. */
18
+ export interface MockCredentials {
19
+ login: string;
20
+ password: string;
21
+ signKey: string;
22
+ exchangeKey: string;
23
+ taxNumber: string;
24
+ }
25
+
26
+ export interface HandlerConfig {
27
+ credentials: MockCredentials;
28
+ /**
29
+ * Polls before a transaction reaches a terminal state.
30
+ *
31
+ * Zero means the verdict is available immediately, which is what most tests
32
+ * want. A higher number exercises the caller's polling loop.
33
+ */
34
+ pollsBeforeDone: number;
35
+ /**
36
+ * Run the invoices through the validator and abort the invalid ones.
37
+ *
38
+ * On by default, so the mock rejects exactly what the library predicts NAV
39
+ * would reject. A test that submits a bad invoice sees it aborted.
40
+ */
41
+ validate: boolean;
42
+ /** Current time, injectable for deterministic tests. */
43
+ now: () => Date;
44
+ }
45
+
46
+ export interface RequestContext {
47
+ operation: string;
48
+ body: string;
49
+ document: { root: string; value: Record<string, unknown> };
50
+ }
51
+
52
+ export interface HandlerResult {
53
+ status: number;
54
+ body: string;
55
+ }
56
+
57
+ const SOFTWARE: SoftwareType = {
58
+ softwareId: 'OPENNAVMOCK000001',
59
+ softwareName: 'open-nav mock server',
60
+ softwareOperation: 'LOCAL_SOFTWARE',
61
+ softwareMainVersion: '0.1.0',
62
+ softwareDevName: 'open-nav',
63
+ softwareDevContact: 'https://github.com/eshton/open-nav',
64
+ };
65
+
66
+ /** A NAV interface error code the mock can return. */
67
+ type InterfaceError =
68
+ | 'INVALID_REQUEST'
69
+ | 'INVALID_SECURITY_USER'
70
+ | 'INVALID_SIGNATURE'
71
+ | 'INVALID_REQUEST_ID'
72
+ | 'INVALID_EXCHANGE_TOKEN'
73
+ | 'OPERATION_FAILED';
74
+
75
+ export class MockError extends Error {
76
+ constructor(
77
+ readonly errorCode: InterfaceError,
78
+ message: string,
79
+ readonly status = 400,
80
+ ) {
81
+ super(message);
82
+ }
83
+ }
84
+
85
+ function timestamp(config: HandlerConfig): string {
86
+ return config.now().toISOString();
87
+ }
88
+
89
+ function header(config: HandlerConfig) {
90
+ return {
91
+ requestId: `MOCK${config.now().getTime().toString(36).toUpperCase()}`,
92
+ timestamp: timestamp(config),
93
+ requestVersion: REQUEST_VERSION,
94
+ headerVersion: HEADER_VERSION,
95
+ };
96
+ }
97
+
98
+ function ok(config: HandlerConfig) {
99
+ return { header: header(config), result: { funcCode: 'OK' }, software: SOFTWARE };
100
+ }
101
+
102
+ export function errorResponse(config: HandlerConfig, errorCode: string, message: string): string {
103
+ return serializeDocument('GeneralErrorResponse', {
104
+ header: header(config),
105
+ result: { funcCode: 'ERROR', errorCode, message },
106
+ software: SOFTWARE,
107
+ technicalValidationMessages: [
108
+ { validationResultCode: 'ERROR', validationErrorCode: errorCode, message },
109
+ ],
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Verify the request the way NAV does.
115
+ *
116
+ * This is the reason a mock is worth having: it recomputes the signature from
117
+ * the request's own fields and the expected sign key, so a caller that builds
118
+ * the signature wrongly finds out here rather than against the live service,
119
+ * where the only clue is an opaque INVALID_SIGNATURE.
120
+ */
121
+ export function authenticate(
122
+ context: RequestContext,
123
+ config: HandlerConfig,
124
+ state: MockState,
125
+ signedOperations: Array<{ index: number; operation: string; base64Payload: string }> = [],
126
+ ): void {
127
+ const value = context.document.value as {
128
+ header?: { requestId?: string; timestamp?: string; requestVersion?: string };
129
+ user?: {
130
+ login?: string;
131
+ passwordHash?: { value?: string; cryptoType?: string };
132
+ taxNumber?: string;
133
+ requestSignature?: { value?: string; cryptoType?: string };
134
+ };
135
+ software?: unknown;
136
+ };
137
+
138
+ const requestId = value.header?.requestId;
139
+ const stamp = value.header?.timestamp;
140
+ if (!requestId || !stamp) {
141
+ throw new MockError('INVALID_REQUEST', 'header/requestId and header/timestamp are required');
142
+ }
143
+ if (value.header?.requestVersion !== REQUEST_VERSION) {
144
+ throw new MockError(
145
+ 'INVALID_REQUEST',
146
+ `requestVersion must be ${REQUEST_VERSION}, got ${value.header?.requestVersion}`,
147
+ );
148
+ }
149
+ if (!value.software) {
150
+ throw new MockError('INVALID_REQUEST', 'the software block is required on every request');
151
+ }
152
+
153
+ // NAV rejects a replayed requestId, and so does this.
154
+ if (state.requestIds.has(requestId)) {
155
+ throw new MockError('INVALID_REQUEST_ID', `requestId ${requestId} has already been used`);
156
+ }
157
+ state.requestIds.add(requestId);
158
+
159
+ const user = value.user;
160
+ if (user?.login !== config.credentials.login) {
161
+ throw new MockError('INVALID_SECURITY_USER', 'unknown technical user');
162
+ }
163
+ if (user.passwordHash?.value !== passwordHash(config.credentials.password)) {
164
+ throw new MockError('INVALID_SECURITY_USER', 'password hash does not match');
165
+ }
166
+ if (user.passwordHash?.cryptoType !== 'SHA-512') {
167
+ throw new MockError('INVALID_SECURITY_USER', 'passwordHash cryptoType must be SHA-512');
168
+ }
169
+ if (user.taxNumber !== config.credentials.taxNumber) {
170
+ throw new MockError('INVALID_SECURITY_USER', 'taxNumber does not match the technical user');
171
+ }
172
+
173
+ const expected = requestSignature(requestId, stamp, config.credentials.signKey, signedOperations);
174
+ if (user.requestSignature?.value !== expected) {
175
+ throw new MockError(
176
+ 'INVALID_SIGNATURE',
177
+ signedOperations.length > 0
178
+ ? `requestSignature does not match; expected the SHA3-512 of requestId + timestamp + signKey + ${signedOperations.length} operation hash(es) in index order`
179
+ : 'requestSignature does not match the SHA3-512 of requestId + timestamp + signKey',
180
+ );
181
+ }
182
+ if (user.requestSignature?.cryptoType !== 'SHA3-512') {
183
+ throw new MockError('INVALID_SIGNATURE', 'requestSignature cryptoType must be SHA3-512');
184
+ }
185
+ }
186
+
187
+ export function handleTokenExchange(
188
+ context: RequestContext,
189
+ config: HandlerConfig,
190
+ state: MockState,
191
+ ): HandlerResult {
192
+ authenticate(context, config, state);
193
+
194
+ // A 16 character token, encrypted the way NAV encrypts it.
195
+ const token = randomBytes(8).toString('hex').toUpperCase();
196
+ state.tokens.set(token, { issuedAt: config.now().getTime(), spent: false });
197
+
198
+ const cipher = createCipheriv(
199
+ 'aes-128-ecb',
200
+ Buffer.from(config.credentials.exchangeKey, 'utf8'),
201
+ null,
202
+ );
203
+ cipher.setAutoPadding(false);
204
+ const encoded = Buffer.concat([
205
+ cipher.update(Buffer.from(token, 'utf8')),
206
+ cipher.final(),
207
+ ]).toString('base64');
208
+
209
+ const validFrom = config.now();
210
+ const validTo = new Date(validFrom.getTime() + 5 * 60_000);
211
+
212
+ return {
213
+ status: 200,
214
+ body: serializeDocument('TokenExchangeResponse', {
215
+ ...ok(config),
216
+ encodedExchangeToken: encoded,
217
+ tokenValidityFrom: validFrom.toISOString(),
218
+ tokenValidityTo: validTo.toISOString(),
219
+ }),
220
+ };
221
+ }
222
+
223
+ interface OperationEntry {
224
+ index: number;
225
+ operation: string;
226
+ base64: string;
227
+ }
228
+
229
+ function readInvoiceOperations(context: RequestContext): {
230
+ compressed: boolean;
231
+ entries: OperationEntry[];
232
+ } {
233
+ const value = context.document.value as {
234
+ invoiceOperations?: {
235
+ compressedContent?: boolean;
236
+ invoiceOperation?: Array<{ index: number; invoiceOperation: string; invoiceData: string }>;
237
+ };
238
+ };
239
+ const list = value.invoiceOperations?.invoiceOperation ?? [];
240
+ if (list.length === 0) {
241
+ throw new MockError('INVALID_REQUEST', 'invoiceOperations must contain at least one operation');
242
+ }
243
+ return {
244
+ compressed: value.invoiceOperations?.compressedContent === true,
245
+ entries: list.map((entry) => ({
246
+ index: entry.index,
247
+ operation: entry.invoiceOperation,
248
+ base64: entry.invoiceData,
249
+ })),
250
+ };
251
+ }
252
+
253
+ function consumeToken(context: RequestContext, state: MockState): void {
254
+ const token = (context.document.value as { exchangeToken?: string }).exchangeToken;
255
+ if (!token) throw new MockError('INVALID_EXCHANGE_TOKEN', 'exchangeToken is required');
256
+ const record = state.tokens.get(token);
257
+ if (!record) throw new MockError('INVALID_EXCHANGE_TOKEN', 'unknown exchange token');
258
+ if (record.spent) {
259
+ throw new MockError('INVALID_EXCHANGE_TOKEN', 'this exchange token has already been used');
260
+ }
261
+ record.spent = true;
262
+ }
263
+
264
+ export function handleManageInvoice(
265
+ context: RequestContext,
266
+ config: HandlerConfig,
267
+ state: MockState,
268
+ ): HandlerResult {
269
+ const { compressed, entries } = readInvoiceOperations(context);
270
+
271
+ authenticate(
272
+ context,
273
+ config,
274
+ state,
275
+ entries.map((entry) => ({
276
+ index: entry.index,
277
+ operation: entry.operation,
278
+ base64Payload: entry.base64,
279
+ })),
280
+ );
281
+ consumeToken(context, state);
282
+
283
+ const transactionId = `MOCKTX${(state.transactions.size + 1).toString().padStart(6, '0')}`;
284
+ const insDate = timestamp(config);
285
+ const results: Array<{
286
+ index: number;
287
+ invoiceStatus: InvoiceStatusType;
288
+ businessValidationMessages: Array<{
289
+ validationResultCode: 'ERROR' | 'WARN' | 'INFO';
290
+ validationErrorCode?: string;
291
+ message?: string;
292
+ }>;
293
+ }> = [];
294
+
295
+ for (const entry of entries) {
296
+ let invoice: InvoiceData;
297
+ try {
298
+ invoice = decodeInvoiceData(entry.base64, { compressed });
299
+ } catch (cause) {
300
+ results.push({
301
+ index: entry.index,
302
+ invoiceStatus: 'ABORTED',
303
+ businessValidationMessages: [
304
+ {
305
+ validationResultCode: 'ERROR',
306
+ validationErrorCode: 'SCHEMA_VIOLATION',
307
+ message: `invoiceData could not be read: ${(cause as Error).message}`,
308
+ },
309
+ ],
310
+ });
311
+ continue;
312
+ }
313
+
314
+ // The mock rejects what the validator predicts NAV would reject, so a
315
+ // test that submits a broken invoice sees a realistic ABORTED result.
316
+ const report = config.validate
317
+ ? validateInvoice(invoice, {
318
+ operation: entry.operation as 'CREATE',
319
+ supplierTaxNumber: config.credentials.taxNumber,
320
+ })
321
+ : { valid: true, errors: [], warnings: [], issues: [] };
322
+
323
+ if (!report.valid) {
324
+ results.push({
325
+ index: entry.index,
326
+ invoiceStatus: 'ABORTED',
327
+ businessValidationMessages: report.errors.map((issue) => ({
328
+ validationResultCode: 'ERROR' as const,
329
+ validationErrorCode: issue.code,
330
+ message: `${issue.path}: ${issue.message}`,
331
+ })),
332
+ });
333
+ continue;
334
+ }
335
+
336
+ const head = invoice.invoiceMain.invoice?.invoiceHead;
337
+ const stored: StoredInvoice = {
338
+ invoiceNumber: invoice.invoiceNumber,
339
+ operation: entry.operation,
340
+ supplierTaxNumber: head?.supplierInfo.supplierTaxNumber.taxpayerId ?? '',
341
+ ...(head?.customerInfo?.customerVatData?.customerTaxNumber?.taxpayerId
342
+ ? { customerTaxNumber: head.customerInfo.customerVatData.customerTaxNumber.taxpayerId }
343
+ : {}),
344
+ issueDate: invoice.invoiceIssueDate,
345
+ base64: entry.base64,
346
+ compressed,
347
+ invoice,
348
+ transactionId,
349
+ index: entry.index,
350
+ insDate,
351
+ };
352
+ state.invoices.set(invoice.invoiceNumber, stored);
353
+
354
+ results.push({
355
+ index: entry.index,
356
+ invoiceStatus: 'DONE',
357
+ businessValidationMessages: report.warnings.map((issue) => ({
358
+ validationResultCode: 'WARN' as const,
359
+ validationErrorCode: issue.code,
360
+ message: `${issue.path}: ${issue.message}`,
361
+ })),
362
+ });
363
+ }
364
+
365
+ state.transactions.set(transactionId, {
366
+ transactionId,
367
+ insDate,
368
+ polls: 0,
369
+ results,
370
+ finalStatuses: results.map((result) => result.invoiceStatus),
371
+ });
372
+
373
+ return {
374
+ status: 200,
375
+ body: serializeDocument('ManageInvoiceResponse', { ...ok(config), transactionId }),
376
+ };
377
+ }
378
+
379
+ export function handleQueryTransactionStatus(
380
+ context: RequestContext,
381
+ config: HandlerConfig,
382
+ state: MockState,
383
+ ): HandlerResult {
384
+ authenticate(context, config, state);
385
+ const transactionId = (context.document.value as { transactionId?: string }).transactionId;
386
+ if (!transactionId) throw new MockError('INVALID_REQUEST', 'transactionId is required');
387
+
388
+ const transaction = state.transactions.get(transactionId);
389
+ if (!transaction) {
390
+ // NAV answers an unknown transaction with an empty result, not an error.
391
+ return {
392
+ status: 200,
393
+ body: serializeDocument('QueryTransactionStatusResponse', { ...ok(config) }),
394
+ };
395
+ }
396
+
397
+ transaction.polls += 1;
398
+ const settled = transaction.polls > config.pollsBeforeDone;
399
+
400
+ return {
401
+ status: 200,
402
+ body: serializeDocument('QueryTransactionStatusResponse', {
403
+ ...ok(config),
404
+ processingResults: {
405
+ processingResult: transaction.results.map((result, position) => ({
406
+ index: result.index,
407
+ invoiceStatus: settled
408
+ ? (transaction.finalStatuses[position] ?? 'DONE')
409
+ : transaction.polls === 1
410
+ ? 'RECEIVED'
411
+ : 'PROCESSING',
412
+ compressedContentIndicator: false,
413
+ ...(settled && result.businessValidationMessages.length > 0
414
+ ? { businessValidationMessages: result.businessValidationMessages }
415
+ : {}),
416
+ })),
417
+ originalRequestVersion: REQUEST_VERSION,
418
+ },
419
+ }),
420
+ };
421
+ }
422
+
423
+ export function handleQueryTaxpayer(
424
+ context: RequestContext,
425
+ config: HandlerConfig,
426
+ state: MockState,
427
+ ): HandlerResult {
428
+ authenticate(context, config, state);
429
+ const taxNumber = (context.document.value as { taxNumber?: string }).taxNumber;
430
+ if (!taxNumber) throw new MockError('INVALID_REQUEST', 'taxNumber is required');
431
+
432
+ const taxpayer = state.taxpayers.get(taxNumber);
433
+ return {
434
+ status: 200,
435
+ body: serializeDocument('QueryTaxpayerResponse', {
436
+ ...ok(config),
437
+ infoDate: timestamp(config),
438
+ taxpayerValidity: taxpayer?.valid ?? false,
439
+ ...(taxpayer
440
+ ? {
441
+ taxpayerData: {
442
+ taxpayerName: taxpayer.name,
443
+ ...(taxpayer.shortName ? { taxpayerShortName: taxpayer.shortName } : {}),
444
+ taxNumberDetail: {
445
+ taxpayerId: taxpayer.taxNumber,
446
+ ...(taxpayer.vatCode ? { vatCode: taxpayer.vatCode } : {}),
447
+ ...(taxpayer.countyCode ? { countyCode: taxpayer.countyCode } : {}),
448
+ },
449
+ incorporation: 'OTHER',
450
+ },
451
+ }
452
+ : {}),
453
+ }),
454
+ };
455
+ }
456
+
457
+ export function handleQueryInvoiceCheck(
458
+ context: RequestContext,
459
+ config: HandlerConfig,
460
+ state: MockState,
461
+ ): HandlerResult {
462
+ authenticate(context, config, state);
463
+ const query = (
464
+ context.document.value as {
465
+ invoiceNumberQuery?: { invoiceNumber?: string; invoiceDirection?: string };
466
+ }
467
+ ).invoiceNumberQuery;
468
+ const store = query?.invoiceDirection === 'INBOUND' ? state.inbound : state.invoices;
469
+ return {
470
+ status: 200,
471
+ body: serializeDocument('QueryInvoiceCheckResponse', {
472
+ ...ok(config),
473
+ invoiceCheckResult: store.has(query?.invoiceNumber ?? ''),
474
+ }),
475
+ };
476
+ }
477
+
478
+ export function handleQueryInvoiceData(
479
+ context: RequestContext,
480
+ config: HandlerConfig,
481
+ state: MockState,
482
+ ): HandlerResult {
483
+ authenticate(context, config, state);
484
+ const query = (
485
+ context.document.value as {
486
+ invoiceNumberQuery?: {
487
+ invoiceNumber?: string;
488
+ invoiceDirection?: string;
489
+ supplierTaxNumber?: string;
490
+ };
491
+ }
492
+ ).invoiceNumberQuery;
493
+
494
+ const inbound = query?.invoiceDirection === 'INBOUND';
495
+
496
+ // NAV only accepts a supplier tax number when querying as the customer.
497
+ if (!inbound && query?.supplierTaxNumber !== undefined) {
498
+ throw new MockError(
499
+ 'INVALID_REQUEST',
500
+ 'BAD_QUERY_PARAM_SUPPLIER_NOT_EXPECTED: the supplier tax number is only usable when querying as customer',
501
+ );
502
+ }
503
+
504
+ const store = inbound ? state.inbound : state.invoices;
505
+ const stored = store.get(query?.invoiceNumber ?? '');
506
+
507
+ if (!stored) {
508
+ return { status: 200, body: serializeDocument('QueryInvoiceDataResponse', { ...ok(config) }) };
509
+ }
510
+
511
+ return {
512
+ status: 200,
513
+ body: serializeDocument('QueryInvoiceDataResponse', {
514
+ ...ok(config),
515
+ invoiceDataResult: {
516
+ invoiceData: stored.base64,
517
+ auditData: {
518
+ insdate: stored.insDate,
519
+ insCusUser: config.credentials.login,
520
+ source: 'MGM',
521
+ transactionId: stored.transactionId,
522
+ index: stored.index,
523
+ originalRequestVersion: REQUEST_VERSION,
524
+ },
525
+ compressedContentIndicator: stored.compressed,
526
+ },
527
+ }),
528
+ };
529
+ }
530
+
531
+ export function handleQueryInvoiceDigest(
532
+ context: RequestContext,
533
+ config: HandlerConfig,
534
+ state: MockState,
535
+ ): HandlerResult {
536
+ authenticate(context, config, state);
537
+ const value = context.document.value as {
538
+ page?: number;
539
+ invoiceDirection?: string;
540
+ invoiceQueryParams?: {
541
+ mandatoryQueryParams?: { invoiceIssueDate?: { dateFrom?: string; dateTo?: string } };
542
+ };
543
+ };
544
+ const range = value.invoiceQueryParams?.mandatoryQueryParams?.invoiceIssueDate;
545
+
546
+ // NAV rejects a window wider than 35 days, so the mock does too: that is
547
+ // what proves a caller splits its range instead of asking for a year.
548
+ if (range?.dateFrom && range?.dateTo) {
549
+ const days =
550
+ (Date.parse(`${range.dateTo}T00:00:00Z`) - Date.parse(`${range.dateFrom}T00:00:00Z`)) /
551
+ 86_400_000;
552
+ if (days > 34) {
553
+ throw new MockError(
554
+ 'INVALID_REQUEST',
555
+ 'BAD_QUERY_PARAM_RANGE_EXCEEDED: date interval defined by the query parameters must not exceed 35 days',
556
+ );
557
+ }
558
+ }
559
+
560
+ const store = value.invoiceDirection === 'INBOUND' ? state.inbound : state.invoices;
561
+ const matching = [...store.values()].filter((invoice) => {
562
+ if (range?.dateFrom && invoice.issueDate < range.dateFrom) return false;
563
+ if (range?.dateTo && invoice.issueDate > range.dateTo) return false;
564
+ return true;
565
+ });
566
+
567
+ const pageSize = 100;
568
+ const page = value.page ?? 1;
569
+ const slice = matching.slice((page - 1) * pageSize, page * pageSize);
570
+
571
+ return {
572
+ status: 200,
573
+ body: serializeDocument('QueryInvoiceDigestResponse', {
574
+ ...ok(config),
575
+ invoiceDigestResult: {
576
+ currentPage: page,
577
+ availablePage: Math.max(1, Math.ceil(matching.length / pageSize)),
578
+ ...(slice.length > 0
579
+ ? {
580
+ invoiceDigest: slice.map((invoice) => ({
581
+ invoiceNumber: invoice.invoiceNumber,
582
+ invoiceOperation: invoice.operation,
583
+ invoiceCategory:
584
+ invoice.invoice.invoiceMain.invoice?.invoiceHead.invoiceDetail.invoiceCategory ??
585
+ 'NORMAL',
586
+ invoiceIssueDate: invoice.issueDate,
587
+ supplierTaxNumber: invoice.supplierTaxNumber,
588
+ supplierName:
589
+ invoice.invoice.invoiceMain.invoice?.invoiceHead.supplierInfo.supplierName ?? '',
590
+ ...(invoice.customerTaxNumber
591
+ ? { customerTaxNumber: invoice.customerTaxNumber }
592
+ : {}),
593
+ transactionId: invoice.transactionId,
594
+ index: invoice.index,
595
+ insDate: invoice.insDate,
596
+ ...digestAmounts(invoice.invoice),
597
+ })),
598
+ }
599
+ : {}),
600
+ },
601
+ }),
602
+ };
603
+ }
604
+
605
+ /**
606
+ * The amount fields a digest carries.
607
+ *
608
+ * NAV's digest summarises an invoice, so a caller can decide what to fetch in
609
+ * full without downloading everything. Populating them keeps the mock useful
610
+ * for exercising code that reads a digest.
611
+ */
612
+ function digestAmounts(document: InvoiceData): Record<string, string> {
613
+ const invoice = document.invoiceMain.invoice;
614
+ const summary = invoice?.invoiceSummary.summaryNormal;
615
+ const currency = invoice?.invoiceHead.invoiceDetail.currencyCode;
616
+ return {
617
+ ...(currency ? { currency } : {}),
618
+ ...(summary
619
+ ? {
620
+ invoiceNetAmount: summary.invoiceNetAmount,
621
+ invoiceNetAmountHUF: summary.invoiceNetAmountHUF,
622
+ invoiceVatAmount: summary.invoiceVatAmount,
623
+ invoiceVatAmountHUF: summary.invoiceVatAmountHUF,
624
+ }
625
+ : {}),
626
+ };
627
+ }
628
+
629
+ export function handleQueryTransactionList(
630
+ context: RequestContext,
631
+ config: HandlerConfig,
632
+ state: MockState,
633
+ ): HandlerResult {
634
+ authenticate(context, config, state);
635
+ const page = (context.document.value as { page?: number }).page ?? 1;
636
+ const all = [...state.transactions.values()];
637
+
638
+ return {
639
+ status: 200,
640
+ body: serializeDocument('QueryTransactionListResponse', {
641
+ ...ok(config),
642
+ transactionListResult: {
643
+ currentPage: page,
644
+ availablePage: 1,
645
+ ...(all.length > 0
646
+ ? {
647
+ transaction: all.map((transaction) => ({
648
+ insDate: transaction.insDate,
649
+ insCusUser: config.credentials.login,
650
+ source: 'MGM',
651
+ transactionId: transaction.transactionId,
652
+ requestStatus: 'DONE',
653
+ technicalAnnulment: false,
654
+ originalRequestVersion: REQUEST_VERSION,
655
+ itemCount: transaction.results.length,
656
+ })),
657
+ }
658
+ : {}),
659
+ },
660
+ }),
661
+ };
662
+ }
663
+
664
+ export function handleManageAnnulment(
665
+ context: RequestContext,
666
+ config: HandlerConfig,
667
+ state: MockState,
668
+ ): HandlerResult {
669
+ const value = context.document.value as {
670
+ annulmentOperations?: {
671
+ annulmentOperation?: Array<{
672
+ index: number;
673
+ annulmentOperation: string;
674
+ invoiceAnnulment: string;
675
+ }>;
676
+ };
677
+ };
678
+ const entries = value.annulmentOperations?.annulmentOperation ?? [];
679
+ if (entries.length === 0) {
680
+ throw new MockError(
681
+ 'INVALID_REQUEST',
682
+ 'annulmentOperations must contain at least one operation',
683
+ );
684
+ }
685
+
686
+ authenticate(
687
+ context,
688
+ config,
689
+ state,
690
+ entries.map((entry) => ({
691
+ index: entry.index,
692
+ operation: entry.annulmentOperation,
693
+ base64Payload: entry.invoiceAnnulment,
694
+ })),
695
+ );
696
+ consumeToken(context, state);
697
+
698
+ // Confirm the payload is readable, the way the service would.
699
+ for (const entry of entries) decodeToXml(entry.invoiceAnnulment);
700
+
701
+ const transactionId = `MOCKAN${(state.transactions.size + 1).toString().padStart(6, '0')}`;
702
+ state.transactions.set(transactionId, {
703
+ transactionId,
704
+ insDate: timestamp(config),
705
+ polls: 0,
706
+ results: entries.map((entry) => ({
707
+ index: entry.index,
708
+ invoiceStatus: 'DONE' as InvoiceStatusType,
709
+ businessValidationMessages: [],
710
+ })),
711
+ finalStatuses: entries.map(() => 'DONE' as InvoiceStatusType),
712
+ });
713
+
714
+ return {
715
+ status: 200,
716
+ body: serializeDocument('ManageAnnulmentResponse', { ...ok(config), transactionId }),
717
+ };
718
+ }
719
+
720
+ export const HANDLERS: Record<
721
+ string,
722
+ (context: RequestContext, config: HandlerConfig, state: MockState) => HandlerResult
723
+ > = {
724
+ tokenExchange: handleTokenExchange,
725
+ manageInvoice: handleManageInvoice,
726
+ manageAnnulment: handleManageAnnulment,
727
+ queryTransactionStatus: handleQueryTransactionStatus,
728
+ queryTransactionList: handleQueryTransactionList,
729
+ queryInvoiceData: handleQueryInvoiceData,
730
+ queryInvoiceDigest: handleQueryInvoiceDigest,
731
+ queryInvoiceCheck: handleQueryInvoiceCheck,
732
+ queryTaxpayer: handleQueryTaxpayer,
733
+ };