uae-einvoice-mcp 0.2.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,830 @@
1
+ import { validateTaxId } from "./trn.js";
2
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
3
+ const EXPECTED_CUSTOMIZATION = "urn:peppol:pint:billing-1@ae-1";
4
+ const EXPECTED_PROFILE = "urn:peppol:bis:billing";
5
+ function isTaxDoc(invoice) {
6
+ const t = invoice.documentType ?? "tax_invoice";
7
+ return t === "tax_invoice" || t === "credit_note";
8
+ }
9
+ function hasText(v) {
10
+ return typeof v === "string" && v.trim().length > 0;
11
+ }
12
+ function hasNumber(v) {
13
+ return typeof v === "number" && Number.isFinite(v);
14
+ }
15
+ function partyTaxId(party) {
16
+ if (!party)
17
+ return { trn: null, tin: null, valid: false };
18
+ if (party.trn) {
19
+ const r = validateTaxId(party.trn);
20
+ return {
21
+ trn: r.kind === "trn" ? r.normalized : null,
22
+ tin: r.tin,
23
+ valid: r.valid,
24
+ };
25
+ }
26
+ if (party.tin) {
27
+ const r = validateTaxId(party.tin);
28
+ return { trn: null, tin: r.tin, valid: r.valid };
29
+ }
30
+ return { trn: null, tin: null, valid: false };
31
+ }
32
+ function firstLine(invoice) {
33
+ return invoice.lines?.[0];
34
+ }
35
+ function firstTax(invoice) {
36
+ return invoice.taxBreakdown?.[0];
37
+ }
38
+ function parsePeppol(peppolId) {
39
+ if (!peppolId?.trim())
40
+ return null;
41
+ const raw = peppolId.replace(/^iso6523-actorid-upis::/i, "").trim();
42
+ const idx = raw.indexOf(":");
43
+ if (idx === -1)
44
+ return { scheme: "0235", value: raw };
45
+ return { scheme: raw.slice(0, idx), value: raw.slice(idx + 1) };
46
+ }
47
+ function sellerEndpoint(invoice) {
48
+ const parsed = parsePeppol(invoice.seller?.peppolId);
49
+ if (parsed)
50
+ return parsed;
51
+ const id = partyTaxId(invoice.seller);
52
+ if (id.tin)
53
+ return { scheme: "0235", value: id.tin };
54
+ return null;
55
+ }
56
+ function buyerEndpoint(invoice) {
57
+ const parsed = parsePeppol(invoice.buyer?.peppolId);
58
+ if (parsed)
59
+ return parsed;
60
+ const id = partyTaxId(invoice.buyer);
61
+ if (id.tin)
62
+ return { scheme: "0235", value: id.tin };
63
+ return null;
64
+ }
65
+ /**
66
+ * Working model of the UAE Electronic Invoice Mandatory Fields (51 for tax invoices).
67
+ * Numbering follows commonly published field lists mapped to UBL 2.1 / PINT AE paths.
68
+ * Confirm against the latest MoF mandatory-fields PDF before production go-live.
69
+ */
70
+ export const PINT_AE_FIELDS = [
71
+ {
72
+ id: 1,
73
+ key: "customizationId",
74
+ name: "Customization ID",
75
+ ublPath: "cbc:CustomizationID",
76
+ category: "header",
77
+ requirement: "always",
78
+ evaluate: (inv) => {
79
+ const v = inv.customizationId ?? EXPECTED_CUSTOMIZATION;
80
+ if (!hasText(inv.customizationId)) {
81
+ return { status: "missing", note: `Expected ${EXPECTED_CUSTOMIZATION}` };
82
+ }
83
+ if (inv.customizationId !== EXPECTED_CUSTOMIZATION) {
84
+ return { status: "invalid", value: v, note: `Expected ${EXPECTED_CUSTOMIZATION}` };
85
+ }
86
+ return { status: "present", value: v };
87
+ },
88
+ },
89
+ {
90
+ id: 2,
91
+ key: "profileId",
92
+ name: "Profile ID",
93
+ ublPath: "cbc:ProfileID",
94
+ category: "header",
95
+ requirement: "always",
96
+ evaluate: (inv) => {
97
+ const v = inv.profileId ?? EXPECTED_PROFILE;
98
+ if (!hasText(inv.profileId)) {
99
+ return {
100
+ status: "missing",
101
+ note: `Expected ${EXPECTED_PROFILE} (generator can default this)`,
102
+ };
103
+ }
104
+ return { status: "present", value: v };
105
+ },
106
+ },
107
+ {
108
+ id: 3,
109
+ key: "invoiceNumber",
110
+ name: "Invoice Number",
111
+ ublPath: "cbc:ID",
112
+ category: "header",
113
+ requirement: "always",
114
+ evaluate: (inv) => hasText(inv.invoiceNumber)
115
+ ? { status: "present", value: inv.invoiceNumber }
116
+ : { status: "missing" },
117
+ },
118
+ {
119
+ id: 4,
120
+ key: "issueDate",
121
+ name: "Invoice Issue Date",
122
+ ublPath: "cbc:IssueDate",
123
+ category: "header",
124
+ requirement: "always",
125
+ evaluate: (inv) => {
126
+ if (!hasText(inv.issueDate))
127
+ return { status: "missing" };
128
+ if (!ISO_DATE.test(inv.issueDate)) {
129
+ return { status: "invalid", value: inv.issueDate, note: "Use YYYY-MM-DD" };
130
+ }
131
+ return { status: "present", value: inv.issueDate };
132
+ },
133
+ },
134
+ {
135
+ id: 5,
136
+ key: "dueDate",
137
+ name: "Invoice Due Date",
138
+ ublPath: "cbc:DueDate",
139
+ category: "header",
140
+ requirement: "conditional",
141
+ conditionNote: "Expected when payment terms / due date apply",
142
+ evaluate: (inv) => {
143
+ if (!hasText(inv.dueDate))
144
+ return { status: "missing" };
145
+ if (!ISO_DATE.test(inv.dueDate)) {
146
+ return { status: "invalid", value: inv.dueDate, note: "Use YYYY-MM-DD" };
147
+ }
148
+ return { status: "present", value: inv.dueDate };
149
+ },
150
+ },
151
+ {
152
+ id: 6,
153
+ key: "typeCode",
154
+ name: "Invoice Type Code",
155
+ ublPath: "cbc:InvoiceTypeCode / cbc:CreditNoteTypeCode",
156
+ category: "header",
157
+ requirement: "always",
158
+ evaluate: (inv) => {
159
+ const doc = inv.documentType ?? "tax_invoice";
160
+ const expected = doc === "credit_note" ? "381" : doc === "commercial_invoice" ? "380" : "380";
161
+ if (!hasText(inv.typeCode)) {
162
+ return { status: "missing", note: `Expected ${expected}` };
163
+ }
164
+ return { status: "present", value: inv.typeCode };
165
+ },
166
+ },
167
+ {
168
+ id: 7,
169
+ key: "currencyCode",
170
+ name: "Invoice Currency Code",
171
+ ublPath: "cbc:DocumentCurrencyCode",
172
+ category: "header",
173
+ requirement: "always",
174
+ evaluate: (inv) => hasText(inv.currencyCode)
175
+ ? { status: "present", value: inv.currencyCode }
176
+ : { status: "missing" },
177
+ },
178
+ {
179
+ id: 8,
180
+ key: "taxCurrencyCode",
181
+ name: "Tax Currency Code",
182
+ ublPath: "cbc:TaxCurrencyCode",
183
+ category: "header",
184
+ requirement: "conditional",
185
+ conditionNote: "Mandatory when DocumentCurrencyCode is not AED (must be AED)",
186
+ evaluate: (inv) => {
187
+ if (!inv.currencyCode || inv.currencyCode === "AED") {
188
+ return {
189
+ status: hasText(inv.taxCurrencyCode) ? "present" : "not_applicable",
190
+ value: inv.taxCurrencyCode ?? null,
191
+ };
192
+ }
193
+ if (!hasText(inv.taxCurrencyCode))
194
+ return { status: "missing", note: "Must be AED" };
195
+ if (inv.taxCurrencyCode !== "AED") {
196
+ return { status: "invalid", value: inv.taxCurrencyCode, note: "Must be AED" };
197
+ }
198
+ return { status: "present", value: inv.taxCurrencyCode };
199
+ },
200
+ },
201
+ {
202
+ id: 9,
203
+ key: "buyerReference",
204
+ name: "Buyer Reference",
205
+ ublPath: "cbc:BuyerReference",
206
+ category: "header",
207
+ requirement: "conditional",
208
+ conditionNote: "Mandatory where the buyer has provided a PO / contract reference",
209
+ evaluate: (inv) => hasText(inv.buyerReference)
210
+ ? { status: "present", value: inv.buyerReference }
211
+ : { status: "missing" },
212
+ },
213
+ {
214
+ id: 10,
215
+ key: "periodStart",
216
+ name: "Invoice Period Start Date",
217
+ ublPath: "cac:InvoicePeriod/cbc:StartDate",
218
+ category: "header",
219
+ requirement: "conditional",
220
+ conditionNote: "Mandatory for periodic / continuous supplies",
221
+ evaluate: (inv) => hasText(inv.periodStart)
222
+ ? ISO_DATE.test(inv.periodStart)
223
+ ? { status: "present", value: inv.periodStart }
224
+ : { status: "invalid", value: inv.periodStart }
225
+ : { status: "missing" },
226
+ },
227
+ {
228
+ id: 11,
229
+ key: "periodEnd",
230
+ name: "Invoice Period End Date",
231
+ ublPath: "cac:InvoicePeriod/cbc:EndDate",
232
+ category: "header",
233
+ requirement: "conditional",
234
+ conditionNote: "Mandatory for periodic / continuous supplies",
235
+ evaluate: (inv) => hasText(inv.periodEnd)
236
+ ? ISO_DATE.test(inv.periodEnd)
237
+ ? { status: "present", value: inv.periodEnd }
238
+ : { status: "invalid", value: inv.periodEnd }
239
+ : { status: "missing" },
240
+ },
241
+ {
242
+ id: 12,
243
+ key: "taxPointDate",
244
+ name: "Tax Point Date",
245
+ ublPath: "cbc:TaxPointDate",
246
+ category: "header",
247
+ requirement: "conditional",
248
+ conditionNote: "Required when tax point differs from issue date",
249
+ evaluate: (inv) => hasText(inv.taxPointDate)
250
+ ? ISO_DATE.test(inv.taxPointDate)
251
+ ? { status: "present", value: inv.taxPointDate }
252
+ : { status: "invalid", value: inv.taxPointDate }
253
+ : { status: "missing" },
254
+ },
255
+ {
256
+ id: 13,
257
+ key: "seller.legalName",
258
+ name: "Supplier Legal Name",
259
+ ublPath: "cac:AccountingSupplierParty/.../cbc:RegistrationName",
260
+ category: "seller",
261
+ requirement: "always",
262
+ evaluate: (inv) => hasText(inv.seller?.legalName)
263
+ ? { status: "present", value: inv.seller.legalName }
264
+ : { status: "missing" },
265
+ },
266
+ {
267
+ id: 14,
268
+ key: "seller.trn",
269
+ name: "Supplier VAT Registration Number (TRN)",
270
+ ublPath: "cac:AccountingSupplierParty/.../cac:PartyTaxScheme/cbc:CompanyID",
271
+ category: "seller",
272
+ requirement: "tax_doc",
273
+ evaluate: (inv) => {
274
+ if (!isTaxDoc(inv))
275
+ return { status: "not_applicable" };
276
+ const id = partyTaxId(inv.seller);
277
+ if (id.trn && id.valid)
278
+ return { status: "present", value: id.trn };
279
+ if (id.tin && id.valid) {
280
+ return {
281
+ status: "invalid",
282
+ value: id.tin,
283
+ note: "TIN present, but Field 14 expects the full 15-digit TRN in PartyTaxScheme",
284
+ };
285
+ }
286
+ return { status: "missing" };
287
+ },
288
+ },
289
+ {
290
+ id: 15,
291
+ key: "seller.tradeName",
292
+ name: "Supplier Trade Name",
293
+ ublPath: "cac:AccountingSupplierParty/.../cac:PartyName/cbc:Name",
294
+ category: "seller",
295
+ requirement: "conditional",
296
+ conditionNote: "Required when trading name differs from legal name",
297
+ evaluate: (inv) => hasText(inv.seller?.tradeName)
298
+ ? { status: "present", value: inv.seller.tradeName }
299
+ : { status: "missing" },
300
+ },
301
+ {
302
+ id: 16,
303
+ key: "seller.addressLine",
304
+ name: "Supplier Address — Street",
305
+ ublPath: "cac:AccountingSupplierParty/.../cbc:StreetName",
306
+ category: "seller",
307
+ requirement: "always",
308
+ evaluate: (inv) => hasText(inv.seller?.addressLine)
309
+ ? { status: "present", value: inv.seller.addressLine }
310
+ : { status: "missing" },
311
+ },
312
+ {
313
+ id: 17,
314
+ key: "seller.cityOrEmirate",
315
+ name: "Supplier Address — City",
316
+ ublPath: "cac:AccountingSupplierParty/.../cbc:CityName",
317
+ category: "seller",
318
+ requirement: "always",
319
+ evaluate: (inv) => hasText(inv.seller?.cityOrEmirate)
320
+ ? { status: "present", value: inv.seller.cityOrEmirate }
321
+ : { status: "missing" },
322
+ },
323
+ {
324
+ id: 18,
325
+ key: "seller.countryCode",
326
+ name: "Supplier Address — Country",
327
+ ublPath: "cac:AccountingSupplierParty/.../cac:Country/cbc:IdentificationCode",
328
+ category: "seller",
329
+ requirement: "always",
330
+ evaluate: (inv) => hasText(inv.seller?.countryCode)
331
+ ? { status: "present", value: inv.seller.countryCode }
332
+ : { status: "missing" },
333
+ },
334
+ {
335
+ id: 19,
336
+ key: "seller.peppolId",
337
+ name: "Supplier Electronic Address",
338
+ ublPath: "cac:AccountingSupplierParty/.../cbc:EndpointID",
339
+ category: "seller",
340
+ requirement: "always",
341
+ evaluate: (inv) => {
342
+ const ep = sellerEndpoint(inv);
343
+ if (!ep)
344
+ return { status: "missing", note: "Use 0235:<TIN>" };
345
+ return { status: "present", value: `${ep.scheme}:${ep.value}` };
346
+ },
347
+ },
348
+ {
349
+ id: 20,
350
+ key: "seller.peppolScheme",
351
+ name: "Supplier Electronic Address Scheme",
352
+ ublPath: "cbc:EndpointID@schemeID",
353
+ category: "seller",
354
+ requirement: "always",
355
+ evaluate: (inv) => {
356
+ const ep = sellerEndpoint(inv);
357
+ if (!ep)
358
+ return { status: "missing", note: "Expected schemeID 0235" };
359
+ return { status: "present", value: ep.scheme };
360
+ },
361
+ },
362
+ {
363
+ id: 21,
364
+ key: "seller.contact",
365
+ name: "Supplier Contact Information",
366
+ ublPath: "cac:AccountingSupplierParty/.../cac:Contact",
367
+ category: "seller",
368
+ requirement: "conditional",
369
+ conditionNote: "Name / email / phone where available",
370
+ evaluate: (inv) => {
371
+ const ok = hasText(inv.seller?.contactName) ||
372
+ hasText(inv.seller?.email) ||
373
+ hasText(inv.seller?.phone);
374
+ return ok
375
+ ? {
376
+ status: "present",
377
+ value: inv.seller?.email ?? inv.seller?.phone ?? inv.seller?.contactName,
378
+ }
379
+ : { status: "missing" };
380
+ },
381
+ },
382
+ {
383
+ id: 22,
384
+ key: "buyer.legalName",
385
+ name: "Buyer Legal Name",
386
+ ublPath: "cac:AccountingCustomerParty/.../cbc:RegistrationName",
387
+ category: "buyer",
388
+ requirement: "always",
389
+ evaluate: (inv) => hasText(inv.buyer?.legalName)
390
+ ? { status: "present", value: inv.buyer.legalName }
391
+ : { status: "missing" },
392
+ },
393
+ {
394
+ id: 23,
395
+ key: "buyer.trn",
396
+ name: "Buyer VAT Registration Number (TRN)",
397
+ ublPath: "cac:AccountingCustomerParty/.../cac:PartyTaxScheme/cbc:CompanyID",
398
+ category: "buyer",
399
+ requirement: "conditional",
400
+ conditionNote: "Mandatory for B2B UAE tax invoices where buyer is VAT-registered",
401
+ evaluate: (inv) => {
402
+ if (!isTaxDoc(inv))
403
+ return { status: "not_applicable" };
404
+ const id = partyTaxId(inv.buyer);
405
+ if (id.trn && id.valid)
406
+ return { status: "present", value: id.trn };
407
+ if (id.tin && id.valid) {
408
+ return {
409
+ status: "invalid",
410
+ value: id.tin,
411
+ note: "TIN present; Field 23 expects full 15-digit TRN when buyer is VAT-registered",
412
+ };
413
+ }
414
+ return { status: "missing" };
415
+ },
416
+ },
417
+ {
418
+ id: 24,
419
+ key: "buyer.peppolId",
420
+ name: "Buyer Electronic ID",
421
+ ublPath: "cac:AccountingCustomerParty/.../cbc:EndpointID",
422
+ category: "buyer",
423
+ requirement: "always",
424
+ evaluate: (inv) => {
425
+ const ep = buyerEndpoint(inv);
426
+ if (!ep)
427
+ return { status: "missing", note: "Use 0235:<TIN>" };
428
+ return { status: "present", value: `${ep.scheme}:${ep.value}` };
429
+ },
430
+ },
431
+ {
432
+ id: 25,
433
+ key: "buyer.peppolScheme",
434
+ name: "Buyer Electronic ID Scheme",
435
+ ublPath: "cbc:EndpointID@schemeID",
436
+ category: "buyer",
437
+ requirement: "always",
438
+ evaluate: (inv) => {
439
+ const ep = buyerEndpoint(inv);
440
+ if (!ep)
441
+ return { status: "missing", note: "Expected schemeID 0235" };
442
+ return { status: "present", value: ep.scheme };
443
+ },
444
+ },
445
+ {
446
+ id: 26,
447
+ key: "buyer.addressLine",
448
+ name: "Buyer Address — Street",
449
+ ublPath: "cac:AccountingCustomerParty/.../cbc:StreetName",
450
+ category: "buyer",
451
+ requirement: "always",
452
+ evaluate: (inv) => hasText(inv.buyer?.addressLine)
453
+ ? { status: "present", value: inv.buyer.addressLine }
454
+ : { status: "missing" },
455
+ },
456
+ {
457
+ id: 27,
458
+ key: "buyer.cityOrEmirate",
459
+ name: "Buyer Address — City",
460
+ ublPath: "cac:AccountingCustomerParty/.../cbc:CityName",
461
+ category: "buyer",
462
+ requirement: "always",
463
+ evaluate: (inv) => hasText(inv.buyer?.cityOrEmirate)
464
+ ? { status: "present", value: inv.buyer.cityOrEmirate }
465
+ : { status: "missing" },
466
+ },
467
+ {
468
+ id: 28,
469
+ key: "buyer.countryCode",
470
+ name: "Buyer Address — Country",
471
+ ublPath: "cac:AccountingCustomerParty/.../cac:Country/cbc:IdentificationCode",
472
+ category: "buyer",
473
+ requirement: "always",
474
+ evaluate: (inv) => hasText(inv.buyer?.countryCode)
475
+ ? { status: "present", value: inv.buyer.countryCode }
476
+ : { status: "missing" },
477
+ },
478
+ {
479
+ id: 29,
480
+ key: "buyer.tradeName",
481
+ name: "Buyer Trade Name",
482
+ ublPath: "cac:AccountingCustomerParty/.../cac:PartyName/cbc:Name",
483
+ category: "buyer",
484
+ requirement: "conditional",
485
+ conditionNote: "Required when trading name differs from legal name",
486
+ evaluate: (inv) => hasText(inv.buyer?.tradeName)
487
+ ? { status: "present", value: inv.buyer.tradeName }
488
+ : { status: "missing" },
489
+ },
490
+ {
491
+ id: 30,
492
+ key: "buyer.contact",
493
+ name: "Buyer Contact Information",
494
+ ublPath: "cac:AccountingCustomerParty/.../cac:Contact",
495
+ category: "buyer",
496
+ requirement: "conditional",
497
+ conditionNote: "Name / email / phone where available",
498
+ evaluate: (inv) => {
499
+ const ok = hasText(inv.buyer?.contactName) ||
500
+ hasText(inv.buyer?.email) ||
501
+ hasText(inv.buyer?.phone);
502
+ return ok
503
+ ? {
504
+ status: "present",
505
+ value: inv.buyer?.email ?? inv.buyer?.phone ?? inv.buyer?.contactName,
506
+ }
507
+ : { status: "missing" };
508
+ },
509
+ },
510
+ {
511
+ id: 31,
512
+ key: "taxTotal",
513
+ name: "Tax Total Amount",
514
+ ublPath: "cac:TaxTotal/cbc:TaxAmount",
515
+ category: "tax",
516
+ requirement: "tax_doc",
517
+ evaluate: (inv) => {
518
+ if (!isTaxDoc(inv))
519
+ return { status: "not_applicable" };
520
+ return hasNumber(inv.taxTotal)
521
+ ? { status: "present", value: inv.taxTotal }
522
+ : { status: "missing" };
523
+ },
524
+ },
525
+ {
526
+ id: 32,
527
+ key: "taxTotalAed",
528
+ name: "Tax Total Amount in AED",
529
+ ublPath: "cac:TaxTotal[2]/cbc:TaxAmount",
530
+ category: "tax",
531
+ requirement: "conditional",
532
+ conditionNote: "Required when document currency is not AED",
533
+ evaluate: (inv) => {
534
+ if (!inv.currencyCode || inv.currencyCode === "AED") {
535
+ return {
536
+ status: hasNumber(inv.taxTotalAed) ? "present" : "not_applicable",
537
+ value: inv.taxTotalAed ?? null,
538
+ };
539
+ }
540
+ return hasNumber(inv.taxTotalAed)
541
+ ? { status: "present", value: inv.taxTotalAed }
542
+ : { status: "missing" };
543
+ },
544
+ },
545
+ {
546
+ id: 33,
547
+ key: "taxBreakdown.taxableAmount",
548
+ name: "Tax Subtotal Taxable Amount",
549
+ ublPath: "cac:TaxSubtotal/cbc:TaxableAmount",
550
+ category: "tax",
551
+ requirement: "tax_doc",
552
+ evaluate: (inv) => {
553
+ if (!isTaxDoc(inv))
554
+ return { status: "not_applicable" };
555
+ const row = firstTax(inv);
556
+ return hasNumber(row?.taxableAmount)
557
+ ? { status: "present", value: row.taxableAmount }
558
+ : { status: "missing" };
559
+ },
560
+ },
561
+ {
562
+ id: 34,
563
+ key: "taxBreakdown.taxAmount",
564
+ name: "Tax Subtotal Tax Amount",
565
+ ublPath: "cac:TaxSubtotal/cbc:TaxAmount",
566
+ category: "tax",
567
+ requirement: "tax_doc",
568
+ evaluate: (inv) => {
569
+ if (!isTaxDoc(inv))
570
+ return { status: "not_applicable" };
571
+ const row = firstTax(inv);
572
+ return hasNumber(row?.taxAmount)
573
+ ? { status: "present", value: row.taxAmount }
574
+ : { status: "missing" };
575
+ },
576
+ },
577
+ {
578
+ id: 35,
579
+ key: "taxBreakdown.taxCategoryCode",
580
+ name: "Tax Category Code",
581
+ ublPath: "cac:TaxCategory/cbc:ID",
582
+ category: "tax",
583
+ requirement: "tax_doc",
584
+ evaluate: (inv) => {
585
+ if (!isTaxDoc(inv))
586
+ return { status: "not_applicable" };
587
+ const row = firstTax(inv);
588
+ return hasText(row?.taxCategoryCode)
589
+ ? { status: "present", value: row.taxCategoryCode }
590
+ : { status: "missing" };
591
+ },
592
+ },
593
+ {
594
+ id: 36,
595
+ key: "taxBreakdown.taxPercent",
596
+ name: "Tax Category Percent",
597
+ ublPath: "cac:TaxCategory/cbc:Percent",
598
+ category: "tax",
599
+ requirement: "tax_doc",
600
+ evaluate: (inv) => {
601
+ if (!isTaxDoc(inv))
602
+ return { status: "not_applicable" };
603
+ const row = firstTax(inv);
604
+ return hasNumber(row?.taxPercent)
605
+ ? { status: "present", value: row.taxPercent }
606
+ : { status: "missing" };
607
+ },
608
+ },
609
+ {
610
+ id: 37,
611
+ key: "taxScheme",
612
+ name: "Tax Scheme ID",
613
+ ublPath: "cac:TaxScheme/cbc:ID",
614
+ category: "tax",
615
+ requirement: "tax_doc",
616
+ evaluate: (inv) => {
617
+ if (!isTaxDoc(inv))
618
+ return { status: "not_applicable" };
619
+ return inv.taxBreakdown?.length
620
+ ? { status: "present", value: "VAT", note: "Assumed VAT for UAE supplies" }
621
+ : { status: "missing" };
622
+ },
623
+ },
624
+ {
625
+ id: 38,
626
+ key: "taxBreakdown.exemptionReasonCode",
627
+ name: "Tax Exemption Reason Code",
628
+ ublPath: "cac:TaxCategory/cbc:TaxExemptionReasonCode",
629
+ category: "tax",
630
+ requirement: "conditional",
631
+ conditionNote: "Required for Z / E / O / G categories",
632
+ evaluate: (inv) => {
633
+ const needs = (inv.taxBreakdown ?? []).some((r) => ["Z", "E", "O", "G"].includes(r.taxCategoryCode ?? ""));
634
+ if (!needs) {
635
+ return {
636
+ status: hasText(firstTax(inv)?.exemptionReasonCode) ? "present" : "not_applicable",
637
+ };
638
+ }
639
+ const ok = (inv.taxBreakdown ?? []).every((r) => {
640
+ if (!["Z", "E", "O", "G"].includes(r.taxCategoryCode ?? ""))
641
+ return true;
642
+ return hasText(r.exemptionReasonCode) || hasText(r.exemptionReason);
643
+ });
644
+ return ok
645
+ ? { status: "present", value: firstTax(inv)?.exemptionReasonCode }
646
+ : { status: "missing" };
647
+ },
648
+ },
649
+ {
650
+ id: 39,
651
+ key: "lineNetTotal",
652
+ name: "Line Extension Amount (document)",
653
+ ublPath: "cac:LegalMonetaryTotal/cbc:LineExtensionAmount",
654
+ category: "totals",
655
+ requirement: "always",
656
+ evaluate: (inv) => hasNumber(inv.lineNetTotal)
657
+ ? { status: "present", value: inv.lineNetTotal }
658
+ : { status: "missing" },
659
+ },
660
+ {
661
+ id: 40,
662
+ key: "taxExclusiveTotal",
663
+ name: "Tax Exclusive Amount",
664
+ ublPath: "cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount",
665
+ category: "totals",
666
+ requirement: "always",
667
+ evaluate: (inv) => hasNumber(inv.taxExclusiveTotal)
668
+ ? { status: "present", value: inv.taxExclusiveTotal }
669
+ : { status: "missing" },
670
+ },
671
+ {
672
+ id: 41,
673
+ key: "taxInclusiveTotal",
674
+ name: "Tax Inclusive Amount",
675
+ ublPath: "cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount",
676
+ category: "totals",
677
+ requirement: "always",
678
+ evaluate: (inv) => hasNumber(inv.taxInclusiveTotal)
679
+ ? { status: "present", value: inv.taxInclusiveTotal }
680
+ : { status: "missing" },
681
+ },
682
+ {
683
+ id: 42,
684
+ key: "allowanceTotal",
685
+ name: "Allowance Total Amount",
686
+ ublPath: "cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount",
687
+ category: "totals",
688
+ requirement: "conditional",
689
+ conditionNote: "Required when document-level allowances exist",
690
+ evaluate: (inv) => hasNumber(inv.allowanceTotal)
691
+ ? { status: "present", value: inv.allowanceTotal }
692
+ : { status: "missing" },
693
+ },
694
+ {
695
+ id: 43,
696
+ key: "chargeTotal",
697
+ name: "Charge Total Amount",
698
+ ublPath: "cac:LegalMonetaryTotal/cbc:ChargeTotalAmount",
699
+ category: "totals",
700
+ requirement: "conditional",
701
+ conditionNote: "Required when document-level charges exist",
702
+ evaluate: (inv) => hasNumber(inv.chargeTotal)
703
+ ? { status: "present", value: inv.chargeTotal }
704
+ : { status: "missing" },
705
+ },
706
+ {
707
+ id: 44,
708
+ key: "payableAmount",
709
+ name: "Payable Amount",
710
+ ublPath: "cac:LegalMonetaryTotal/cbc:PayableAmount",
711
+ category: "totals",
712
+ requirement: "always",
713
+ evaluate: (inv) => hasNumber(inv.payableAmount)
714
+ ? { status: "present", value: inv.payableAmount }
715
+ : { status: "missing" },
716
+ },
717
+ {
718
+ id: 45,
719
+ key: "lines.id",
720
+ name: "Line ID",
721
+ ublPath: "cac:InvoiceLine/cbc:ID",
722
+ category: "lines",
723
+ requirement: "always",
724
+ evaluate: (inv) => {
725
+ const line = firstLine(inv);
726
+ return hasText(line?.id)
727
+ ? { status: "present", value: line.id }
728
+ : { status: "missing" };
729
+ },
730
+ },
731
+ {
732
+ id: 46,
733
+ key: "lines.quantity",
734
+ name: "Invoiced Quantity (+ unitCode)",
735
+ ublPath: "cac:InvoiceLine/cbc:InvoicedQuantity",
736
+ category: "lines",
737
+ requirement: "always",
738
+ evaluate: (inv) => {
739
+ const line = firstLine(inv);
740
+ if (!hasNumber(line?.quantity))
741
+ return { status: "missing" };
742
+ if (!hasText(line?.unitCode)) {
743
+ return {
744
+ status: "invalid",
745
+ value: line.quantity,
746
+ note: "Quantity present but unitCode (UN/ECE Rec 20) missing",
747
+ };
748
+ }
749
+ return { status: "present", value: `${line.quantity} ${line.unitCode}` };
750
+ },
751
+ },
752
+ {
753
+ id: 47,
754
+ key: "lines.netAmount",
755
+ name: "Line Extension Amount",
756
+ ublPath: "cac:InvoiceLine/cbc:LineExtensionAmount",
757
+ category: "lines",
758
+ requirement: "always",
759
+ evaluate: (inv) => {
760
+ const line = firstLine(inv);
761
+ return hasNumber(line?.netAmount)
762
+ ? { status: "present", value: line.netAmount }
763
+ : { status: "missing" };
764
+ },
765
+ },
766
+ {
767
+ id: 48,
768
+ key: "lines.name",
769
+ name: "Item Name",
770
+ ublPath: "cac:InvoiceLine/cac:Item/cbc:Name",
771
+ category: "lines",
772
+ requirement: "always",
773
+ evaluate: (inv) => {
774
+ const line = firstLine(inv);
775
+ return hasText(line?.name)
776
+ ? { status: "present", value: line.name }
777
+ : { status: "missing" };
778
+ },
779
+ },
780
+ {
781
+ id: 49,
782
+ key: "lines.unitPrice",
783
+ name: "Item Net Price",
784
+ ublPath: "cac:InvoiceLine/cac:Price/cbc:PriceAmount",
785
+ category: "lines",
786
+ requirement: "always",
787
+ evaluate: (inv) => {
788
+ const line = firstLine(inv);
789
+ return hasNumber(line?.unitPrice)
790
+ ? { status: "present", value: line.unitPrice }
791
+ : { status: "missing" };
792
+ },
793
+ },
794
+ {
795
+ id: 50,
796
+ key: "lines.taxCategoryCode",
797
+ name: "Item Tax Category",
798
+ ublPath: "cac:InvoiceLine/cac:Item/cac:ClassifiedTaxCategory/cbc:ID",
799
+ category: "lines",
800
+ requirement: "tax_doc",
801
+ evaluate: (inv) => {
802
+ if (!isTaxDoc(inv))
803
+ return { status: "not_applicable" };
804
+ const line = firstLine(inv);
805
+ return hasText(line?.taxCategoryCode)
806
+ ? { status: "present", value: line.taxCategoryCode }
807
+ : { status: "missing" };
808
+ },
809
+ },
810
+ {
811
+ id: 51,
812
+ key: "lines.taxPercent",
813
+ name: "Item Tax Rate",
814
+ ublPath: "cac:InvoiceLine/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent",
815
+ category: "lines",
816
+ requirement: "tax_doc",
817
+ evaluate: (inv) => {
818
+ if (!isTaxDoc(inv))
819
+ return { status: "not_applicable" };
820
+ const line = firstLine(inv);
821
+ return hasNumber(line?.taxPercent)
822
+ ? { status: "present", value: line.taxPercent }
823
+ : { status: "missing" };
824
+ },
825
+ },
826
+ ];
827
+ if (PINT_AE_FIELDS.length !== 51) {
828
+ throw new Error(`PINT_AE_FIELDS must contain 51 entries, found ${PINT_AE_FIELDS.length}`);
829
+ }
830
+ export { EXPECTED_CUSTOMIZATION, EXPECTED_PROFILE, sellerEndpoint, buyerEndpoint, partyTaxId };