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.
- package/README.md +83 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +149 -0
- package/dist/lib/checklist.d.ts +31 -0
- package/dist/lib/checklist.js +92 -0
- package/dist/lib/compliance.d.ts +23 -0
- package/dist/lib/compliance.js +202 -0
- package/dist/lib/fields.d.ts +41 -0
- package/dist/lib/fields.js +830 -0
- package/dist/lib/invoice-types.d.ts +63 -0
- package/dist/lib/invoice-types.js +1 -0
- package/dist/lib/requirements.d.ts +26 -0
- package/dist/lib/requirements.js +88 -0
- package/dist/lib/trn.d.ts +39 -0
- package/dist/lib/trn.js +110 -0
- package/dist/lib/xml.d.ts +21 -0
- package/dist/lib/xml.js +222 -0
- package/dist/selfcheck.d.ts +1 -0
- package/dist/selfcheck.js +44 -0
- package/package.json +44 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type InvoiceDocumentType = "tax_invoice" | "commercial_invoice" | "credit_note";
|
|
2
|
+
export interface PartyInput {
|
|
3
|
+
legalName?: string;
|
|
4
|
+
tradeName?: string;
|
|
5
|
+
trn?: string;
|
|
6
|
+
tin?: string;
|
|
7
|
+
countryCode?: string;
|
|
8
|
+
addressLine?: string;
|
|
9
|
+
cityOrEmirate?: string;
|
|
10
|
+
postalZone?: string;
|
|
11
|
+
contactName?: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
phone?: string;
|
|
14
|
+
peppolId?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface InvoiceLineInput {
|
|
17
|
+
id?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
quantity?: number;
|
|
20
|
+
unitCode?: string;
|
|
21
|
+
netAmount?: number;
|
|
22
|
+
unitPrice?: number;
|
|
23
|
+
taxCategoryCode?: string;
|
|
24
|
+
taxPercent?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface TaxBreakdownInput {
|
|
27
|
+
taxableAmount?: number;
|
|
28
|
+
taxAmount?: number;
|
|
29
|
+
taxCategoryCode?: string;
|
|
30
|
+
taxPercent?: number;
|
|
31
|
+
exemptionReason?: string;
|
|
32
|
+
exemptionReasonCode?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface InvoiceDataInput {
|
|
35
|
+
documentType?: InvoiceDocumentType;
|
|
36
|
+
invoiceNumber?: string;
|
|
37
|
+
issueDate?: string;
|
|
38
|
+
dueDate?: string;
|
|
39
|
+
currencyCode?: string;
|
|
40
|
+
taxCurrencyCode?: string;
|
|
41
|
+
customizationId?: string;
|
|
42
|
+
profileId?: string;
|
|
43
|
+
typeCode?: string;
|
|
44
|
+
buyerReference?: string;
|
|
45
|
+
periodStart?: string;
|
|
46
|
+
periodEnd?: string;
|
|
47
|
+
taxPointDate?: string;
|
|
48
|
+
/** Original invoice number — important for credit notes */
|
|
49
|
+
billingReference?: string;
|
|
50
|
+
seller?: PartyInput;
|
|
51
|
+
buyer?: PartyInput;
|
|
52
|
+
lineNetTotal?: number;
|
|
53
|
+
taxExclusiveTotal?: number;
|
|
54
|
+
taxTotal?: number;
|
|
55
|
+
taxTotalAed?: number;
|
|
56
|
+
taxInclusiveTotal?: number;
|
|
57
|
+
allowanceTotal?: number;
|
|
58
|
+
chargeTotal?: number;
|
|
59
|
+
prepaidAmount?: number;
|
|
60
|
+
payableAmount?: number;
|
|
61
|
+
taxBreakdown?: TaxBreakdownInput[];
|
|
62
|
+
lines?: InvoiceLineInput[];
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAE e-invoicing mandate timeline and high-level requirements.
|
|
3
|
+
* Based on Cabinet Decision / Ministerial Decisions (MD 243 & 244 of 2025)
|
|
4
|
+
* and MoF Electronic Invoicing Guidelines (v1.0, Feb 2026).
|
|
5
|
+
*
|
|
6
|
+
* Dates can shift — treat this as guidance and verify against official MoF/FTA notices.
|
|
7
|
+
*/
|
|
8
|
+
export type BusinessSegment = "large" | "sme" | "government" | "all";
|
|
9
|
+
export interface MandatePhase {
|
|
10
|
+
segment: Exclude<BusinessSegment, "all">;
|
|
11
|
+
label: string;
|
|
12
|
+
revenueThresholdAed: number | null;
|
|
13
|
+
aspAppointmentDeadline: string;
|
|
14
|
+
goLiveDate: string;
|
|
15
|
+
notes: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface RequirementsResult {
|
|
18
|
+
asOf: string;
|
|
19
|
+
disclaimer: string;
|
|
20
|
+
segment: BusinessSegment;
|
|
21
|
+
phases: MandatePhase[];
|
|
22
|
+
technicalBaseline: string[];
|
|
23
|
+
keyIdentifiers: Record<string, string>;
|
|
24
|
+
nextActions: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function getEinvoicingRequirements(segment?: BusinessSegment, annualRevenueAed?: number): RequirementsResult;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAE e-invoicing mandate timeline and high-level requirements.
|
|
3
|
+
* Based on Cabinet Decision / Ministerial Decisions (MD 243 & 244 of 2025)
|
|
4
|
+
* and MoF Electronic Invoicing Guidelines (v1.0, Feb 2026).
|
|
5
|
+
*
|
|
6
|
+
* Dates can shift — treat this as guidance and verify against official MoF/FTA notices.
|
|
7
|
+
*/
|
|
8
|
+
const PHASES = [
|
|
9
|
+
{
|
|
10
|
+
segment: "large",
|
|
11
|
+
label: "Large businesses (revenue ≥ AED 50 million)",
|
|
12
|
+
revenueThresholdAed: 50_000_000,
|
|
13
|
+
aspAppointmentDeadline: "2026-10-30",
|
|
14
|
+
goLiveDate: "2027-01-01",
|
|
15
|
+
notes: [
|
|
16
|
+
"Appoint a Ministry of Finance Accredited Service Provider (ASP) by the appointment deadline.",
|
|
17
|
+
"Mandatory electronic invoicing via Peppol / PINT AE from go-live.",
|
|
18
|
+
"Pilot / early adoption window opened in 2026 via EmaraTax.",
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
segment: "sme",
|
|
23
|
+
label: "Other in-scope persons (revenue < AED 50 million)",
|
|
24
|
+
revenueThresholdAed: null,
|
|
25
|
+
aspAppointmentDeadline: "2027-03-31",
|
|
26
|
+
goLiveDate: "2027-07-01",
|
|
27
|
+
notes: [
|
|
28
|
+
"Includes many B2B traders even below the VAT registration threshold if they fall in scope.",
|
|
29
|
+
"Entities without any FTA tax registration must still obtain a TIN for Peppol participation.",
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
segment: "government",
|
|
34
|
+
label: "Government entities",
|
|
35
|
+
revenueThresholdAed: null,
|
|
36
|
+
aspAppointmentDeadline: "2027-10-01",
|
|
37
|
+
goLiveDate: "2027-10-01",
|
|
38
|
+
notes: [
|
|
39
|
+
"Government entities follow a later mandatory wave (verify current MD / circulars).",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
const TECHNICAL_BASELINE = [
|
|
44
|
+
"Document format: UBL 2.1 XML conforming to PINT AE (Peppol International Invoice — UAE).",
|
|
45
|
+
"Typical specification identifier: urn:peppol:pint:billing-1@ae-1 (confirm against current PINT AE release).",
|
|
46
|
+
"Transport: Peppol AS4 via an Accredited Service Provider (ASP) — not direct FTA file upload from ERP.",
|
|
47
|
+
"Tax invoice mandatory fields: 51 data elements (MoF mandatory-fields document); commercial e-invoice: 49.",
|
|
48
|
+
"Seller electronic address / participant ID uses scheme 0235 + 10-digit TIN (first 10 digits of TRN).",
|
|
49
|
+
"Currency and amounts must be consistent; DocumentCurrencyCode is typically AED for domestic supplies.",
|
|
50
|
+
"Invoice type codes commonly include 380 (invoice) and 381 (credit note).",
|
|
51
|
+
"Validate against UBL 2.1 XSD and PINT AE Schematron rules before transmission.",
|
|
52
|
+
];
|
|
53
|
+
export function getEinvoicingRequirements(segment = "all", annualRevenueAed) {
|
|
54
|
+
let phases = PHASES;
|
|
55
|
+
let resolved = segment;
|
|
56
|
+
if (segment === "all" && typeof annualRevenueAed === "number") {
|
|
57
|
+
resolved = annualRevenueAed >= 50_000_000 ? "large" : "sme";
|
|
58
|
+
phases = PHASES.filter((p) => p.segment === resolved);
|
|
59
|
+
}
|
|
60
|
+
else if (segment !== "all") {
|
|
61
|
+
phases = PHASES.filter((p) => p.segment === segment);
|
|
62
|
+
}
|
|
63
|
+
const primary = phases[0];
|
|
64
|
+
const nextActions = [
|
|
65
|
+
"Confirm whether your entity is in scope under MD 243/244 of 2025.",
|
|
66
|
+
"Obtain or verify your TIN (first 10 digits of your TRN) in EmaraTax.",
|
|
67
|
+
"Shortlist and appoint an MoF Accredited Service Provider before your appointment deadline.",
|
|
68
|
+
"Map ERP invoice fields to the PINT AE data dictionary (51 tax-invoice fields).",
|
|
69
|
+
"Run sample invoices through ASP validation (XSD + Schematron) in the pilot window.",
|
|
70
|
+
];
|
|
71
|
+
if (primary) {
|
|
72
|
+
nextActions.unshift(`Target ASP appointment by ${primary.aspAppointmentDeadline} and go-live by ${primary.goLiveDate} for: ${primary.label}.`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
asOf: "2026-09-08",
|
|
76
|
+
disclaimer: "Informational summary only — not legal or tax advice. Verify dates and obligations against official UAE MoF / FTA publications.",
|
|
77
|
+
segment: resolved,
|
|
78
|
+
phases,
|
|
79
|
+
technicalBaseline: TECHNICAL_BASELINE,
|
|
80
|
+
keyIdentifiers: {
|
|
81
|
+
TRN: "15-digit Tax Registration Number (VAT)",
|
|
82
|
+
TIN: "First 10 digits of TRN — Peppol endpoint identifier body",
|
|
83
|
+
PeppolParticipantId: "0235:<TIN>",
|
|
84
|
+
Specification: "PINT AE on UBL 2.1",
|
|
85
|
+
},
|
|
86
|
+
nextActions,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAE Tax Registration Number (TRN) and Tax Identification Number (TIN) helpers.
|
|
3
|
+
*
|
|
4
|
+
* - TRN: 15 digits issued by the FTA for VAT-registered entities
|
|
5
|
+
* - TIN: first 10 digits of the TRN; used as the Peppol participant identifier
|
|
6
|
+
* under scheme 0235 (format: 0235:TIN)
|
|
7
|
+
*
|
|
8
|
+
* TIN structure (Peppol AE:TIN / ISO 6523 scheme 0235):
|
|
9
|
+
* - digit 1: UAE country identifier — must be "1"
|
|
10
|
+
* - digits 2–9: FTA business identifier
|
|
11
|
+
* - digit 10: Luhn check digit (odd-placed digits from the left ×2)
|
|
12
|
+
*/
|
|
13
|
+
export type IdentifierKind = "trn" | "tin" | "unknown";
|
|
14
|
+
export interface TrnValidationResult {
|
|
15
|
+
input: string;
|
|
16
|
+
normalized: string;
|
|
17
|
+
kind: IdentifierKind;
|
|
18
|
+
valid: boolean;
|
|
19
|
+
errors: string[];
|
|
20
|
+
warnings: string[];
|
|
21
|
+
tin: string | null;
|
|
22
|
+
trn: string | null;
|
|
23
|
+
peppolParticipantId: string | null;
|
|
24
|
+
details: {
|
|
25
|
+
startsWithUaeCountryCode: boolean;
|
|
26
|
+
formatOk: boolean;
|
|
27
|
+
checksumOk: boolean | null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Peppol AE:TIN Luhn variant: odd-placed digits (1-indexed from the left) ×2;
|
|
32
|
+
* if product ≥ 10, sum its digits; even-placed ×1. Check digit is sum % 10.
|
|
33
|
+
*/
|
|
34
|
+
export declare function computeTinCheckDigit(nineDigits: string): number;
|
|
35
|
+
export declare function isValidTinChecksum(tin: string): boolean;
|
|
36
|
+
export declare function tinToPeppolId(tin: string): string;
|
|
37
|
+
export declare function validateTaxId(raw: string): TrnValidationResult;
|
|
38
|
+
/** Generate a structurally valid sample TIN for demos/tests (not a real FTA number). */
|
|
39
|
+
export declare function makeSampleTin(bodyEightDigits?: string): string;
|
package/dist/lib/trn.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAE Tax Registration Number (TRN) and Tax Identification Number (TIN) helpers.
|
|
3
|
+
*
|
|
4
|
+
* - TRN: 15 digits issued by the FTA for VAT-registered entities
|
|
5
|
+
* - TIN: first 10 digits of the TRN; used as the Peppol participant identifier
|
|
6
|
+
* under scheme 0235 (format: 0235:TIN)
|
|
7
|
+
*
|
|
8
|
+
* TIN structure (Peppol AE:TIN / ISO 6523 scheme 0235):
|
|
9
|
+
* - digit 1: UAE country identifier — must be "1"
|
|
10
|
+
* - digits 2–9: FTA business identifier
|
|
11
|
+
* - digit 10: Luhn check digit (odd-placed digits from the left ×2)
|
|
12
|
+
*/
|
|
13
|
+
function digitsOnly(value) {
|
|
14
|
+
return value.replace(/\D/g, "");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Peppol AE:TIN Luhn variant: odd-placed digits (1-indexed from the left) ×2;
|
|
18
|
+
* if product ≥ 10, sum its digits; even-placed ×1. Check digit is sum % 10.
|
|
19
|
+
*/
|
|
20
|
+
export function computeTinCheckDigit(nineDigits) {
|
|
21
|
+
if (!/^\d{9}$/.test(nineDigits)) {
|
|
22
|
+
throw new Error("TIN body must be exactly 9 digits");
|
|
23
|
+
}
|
|
24
|
+
let sum = 0;
|
|
25
|
+
for (let i = 0; i < nineDigits.length; i++) {
|
|
26
|
+
const digit = Number(nineDigits[i]);
|
|
27
|
+
const position = i + 1; // 1-indexed from the left
|
|
28
|
+
let value = position % 2 === 1 ? digit * 2 : digit;
|
|
29
|
+
if (value >= 10) {
|
|
30
|
+
value = Math.floor(value / 10) + (value % 10);
|
|
31
|
+
}
|
|
32
|
+
sum += value;
|
|
33
|
+
}
|
|
34
|
+
return sum % 10;
|
|
35
|
+
}
|
|
36
|
+
export function isValidTinChecksum(tin) {
|
|
37
|
+
if (!/^\d{10}$/.test(tin))
|
|
38
|
+
return false;
|
|
39
|
+
const expected = computeTinCheckDigit(tin.slice(0, 9));
|
|
40
|
+
return Number(tin[9]) === expected;
|
|
41
|
+
}
|
|
42
|
+
export function tinToPeppolId(tin) {
|
|
43
|
+
return `0235:${tin}`;
|
|
44
|
+
}
|
|
45
|
+
export function validateTaxId(raw) {
|
|
46
|
+
const normalized = digitsOnly(raw.trim());
|
|
47
|
+
const errors = [];
|
|
48
|
+
const warnings = [];
|
|
49
|
+
let kind = "unknown";
|
|
50
|
+
if (normalized.length === 15)
|
|
51
|
+
kind = "trn";
|
|
52
|
+
else if (normalized.length === 10)
|
|
53
|
+
kind = "tin";
|
|
54
|
+
const formatOk = (kind === "trn" && /^\d{15}$/.test(normalized)) ||
|
|
55
|
+
(kind === "tin" && /^\d{10}$/.test(normalized));
|
|
56
|
+
if (!normalized) {
|
|
57
|
+
errors.push("Value is empty after removing non-digit characters.");
|
|
58
|
+
}
|
|
59
|
+
else if (kind === "unknown") {
|
|
60
|
+
errors.push(`Expected a 10-digit TIN or 15-digit TRN; got ${normalized.length} digit(s).`);
|
|
61
|
+
}
|
|
62
|
+
const tinCandidate = kind === "trn"
|
|
63
|
+
? normalized.slice(0, 10)
|
|
64
|
+
: kind === "tin"
|
|
65
|
+
? normalized
|
|
66
|
+
: null;
|
|
67
|
+
const startsWithUaeCountryCode = tinCandidate?.startsWith("1") ?? false;
|
|
68
|
+
if (tinCandidate && !startsWithUaeCountryCode) {
|
|
69
|
+
errors.push("TIN/TRN must start with 1 (UAE country identifier per GCC / Peppol AE:TIN).");
|
|
70
|
+
}
|
|
71
|
+
let checksumOk = null;
|
|
72
|
+
if (tinCandidate && /^\d{10}$/.test(tinCandidate)) {
|
|
73
|
+
checksumOk = isValidTinChecksum(tinCandidate);
|
|
74
|
+
if (!checksumOk) {
|
|
75
|
+
errors.push(`TIN check digit is invalid (expected ${computeTinCheckDigit(tinCandidate.slice(0, 9))}, got ${tinCandidate[9]}).`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (kind === "trn" && formatOk) {
|
|
79
|
+
warnings.push("Full 15-digit TRN format checked locally only — live FTA register lookup is not performed.");
|
|
80
|
+
}
|
|
81
|
+
const valid = errors.length === 0 && formatOk;
|
|
82
|
+
const tin = valid && tinCandidate ? tinCandidate : tinCandidate;
|
|
83
|
+
const trn = kind === "trn" && formatOk ? normalized : null;
|
|
84
|
+
return {
|
|
85
|
+
input: raw,
|
|
86
|
+
normalized,
|
|
87
|
+
kind,
|
|
88
|
+
valid,
|
|
89
|
+
errors,
|
|
90
|
+
warnings,
|
|
91
|
+
tin: tinCandidate,
|
|
92
|
+
trn,
|
|
93
|
+
peppolParticipantId: tinCandidate && /^\d{10}$/.test(tinCandidate)
|
|
94
|
+
? tinToPeppolId(tinCandidate)
|
|
95
|
+
: null,
|
|
96
|
+
details: {
|
|
97
|
+
startsWithUaeCountryCode,
|
|
98
|
+
formatOk,
|
|
99
|
+
checksumOk,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Generate a structurally valid sample TIN for demos/tests (not a real FTA number). */
|
|
104
|
+
export function makeSampleTin(bodyEightDigits = "23456789") {
|
|
105
|
+
if (!/^\d{8}$/.test(bodyEightDigits)) {
|
|
106
|
+
throw new Error("bodyEightDigits must be 8 digits");
|
|
107
|
+
}
|
|
108
|
+
const nine = `1${bodyEightDigits}`;
|
|
109
|
+
return `${nine}${computeTinCheckDigit(nine)}`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { InvoiceDataInput } from "./invoice-types.js";
|
|
2
|
+
export interface GenerateXmlOptions {
|
|
3
|
+
/** If true, refuse XML when applicable mandatory fields have gaps */
|
|
4
|
+
strict?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface GenerateXmlResult {
|
|
7
|
+
generated: boolean;
|
|
8
|
+
rootElement: "Invoice" | "CreditNote";
|
|
9
|
+
xml: string | null;
|
|
10
|
+
checklistComplete: boolean;
|
|
11
|
+
gaps: Array<{
|
|
12
|
+
id: number;
|
|
13
|
+
key: string;
|
|
14
|
+
name: string;
|
|
15
|
+
status: string;
|
|
16
|
+
note?: string;
|
|
17
|
+
}>;
|
|
18
|
+
warnings: string[];
|
|
19
|
+
note: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function generatePintAeXml(invoice: InvoiceDataInput, options?: GenerateXmlOptions): GenerateXmlResult;
|
package/dist/lib/xml.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { EXPECTED_CUSTOMIZATION, EXPECTED_PROFILE, buyerEndpoint, partyTaxId, sellerEndpoint, } from "./fields.js";
|
|
2
|
+
import { checkPintAeFields } from "./checklist.js";
|
|
3
|
+
function esc(value) {
|
|
4
|
+
if (value === undefined || value === null)
|
|
5
|
+
return "";
|
|
6
|
+
return String(value)
|
|
7
|
+
.replace(/&/g, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/"/g, """);
|
|
11
|
+
}
|
|
12
|
+
function amountTag(tag, amount, currency) {
|
|
13
|
+
return `<${tag} currencyID="${esc(currency)}">${amount.toFixed(2)}</${tag}>`;
|
|
14
|
+
}
|
|
15
|
+
function partyXml(wrapper, party, role, invoice) {
|
|
16
|
+
const ep = role === "seller" ? sellerEndpoint(invoice) : buyerEndpoint(invoice);
|
|
17
|
+
const tax = partyTaxId(party);
|
|
18
|
+
const name = party?.tradeName ?? party?.legalName ?? "";
|
|
19
|
+
const currency = invoice.currencyCode ?? "AED";
|
|
20
|
+
const contactParts = [];
|
|
21
|
+
if (party?.contactName)
|
|
22
|
+
contactParts.push(` <cbc:Name>${esc(party.contactName)}</cbc:Name>`);
|
|
23
|
+
if (party?.phone)
|
|
24
|
+
contactParts.push(` <cbc:Telephone>${esc(party.phone)}</cbc:Telephone>`);
|
|
25
|
+
if (party?.email) {
|
|
26
|
+
contactParts.push(` <cbc:ElectronicMail>${esc(party.email)}</cbc:ElectronicMail>`);
|
|
27
|
+
}
|
|
28
|
+
return `
|
|
29
|
+
<cac:${wrapper}>
|
|
30
|
+
<cac:Party>
|
|
31
|
+
${ep ? ` <cbc:EndpointID schemeID="${esc(ep.scheme)}">${esc(ep.value)}</cbc:EndpointID>` : ""}
|
|
32
|
+
${name ? ` <cac:PartyName>
|
|
33
|
+
<cbc:Name>${esc(name)}</cbc:Name>
|
|
34
|
+
</cac:PartyName>` : ""}
|
|
35
|
+
<cac:PostalAddress>
|
|
36
|
+
${party?.addressLine ? ` <cbc:StreetName>${esc(party.addressLine)}</cbc:StreetName>` : ""}
|
|
37
|
+
${party?.cityOrEmirate ? ` <cbc:CityName>${esc(party.cityOrEmirate)}</cbc:CityName>` : ""}
|
|
38
|
+
${party?.postalZone ? ` <cbc:PostalZone>${esc(party.postalZone)}</cbc:PostalZone>` : ""}
|
|
39
|
+
<cac:Country>
|
|
40
|
+
<cbc:IdentificationCode>${esc(party?.countryCode ?? "AE")}</cbc:IdentificationCode>
|
|
41
|
+
</cac:Country>
|
|
42
|
+
</cac:PostalAddress>
|
|
43
|
+
${tax.trn || tax.tin
|
|
44
|
+
? ` <cac:PartyTaxScheme>
|
|
45
|
+
<cbc:CompanyID>${esc(tax.trn ?? tax.tin)}</cbc:CompanyID>
|
|
46
|
+
<cac:TaxScheme>
|
|
47
|
+
<cbc:ID>VAT</cbc:ID>
|
|
48
|
+
</cac:TaxScheme>
|
|
49
|
+
</cac:PartyTaxScheme>`
|
|
50
|
+
: ""}
|
|
51
|
+
<cac:PartyLegalEntity>
|
|
52
|
+
<cbc:RegistrationName>${esc(party?.legalName ?? "")}</cbc:RegistrationName>
|
|
53
|
+
</cac:PartyLegalEntity>
|
|
54
|
+
${contactParts.length
|
|
55
|
+
? ` <cac:Contact>
|
|
56
|
+
${contactParts.join("\n")}
|
|
57
|
+
</cac:Contact>`
|
|
58
|
+
: ""}
|
|
59
|
+
</cac:Party>
|
|
60
|
+
</cac:${wrapper}>`.replaceAll(`\n\n`, "\n");
|
|
61
|
+
}
|
|
62
|
+
export function generatePintAeXml(invoice, options = {}) {
|
|
63
|
+
const checklist = checkPintAeFields(invoice);
|
|
64
|
+
const gaps = checklist.gaps.map((g) => ({
|
|
65
|
+
id: g.id,
|
|
66
|
+
key: g.key,
|
|
67
|
+
name: g.name,
|
|
68
|
+
status: g.status,
|
|
69
|
+
note: g.note,
|
|
70
|
+
}));
|
|
71
|
+
const warnings = [];
|
|
72
|
+
const strict = options.strict ?? false;
|
|
73
|
+
if (strict && gaps.length > 0) {
|
|
74
|
+
return {
|
|
75
|
+
generated: false,
|
|
76
|
+
rootElement: invoice.documentType === "credit_note" ? "CreditNote" : "Invoice",
|
|
77
|
+
xml: null,
|
|
78
|
+
checklistComplete: false,
|
|
79
|
+
gaps,
|
|
80
|
+
warnings: [
|
|
81
|
+
"strict=true: XML not generated because applicable mandatory fields are incomplete.",
|
|
82
|
+
],
|
|
83
|
+
note: "Fix checklist gaps or call again with strict=false to emit a best-effort stub.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (gaps.length > 0) {
|
|
87
|
+
warnings.push(`Emitting best-effort XML with ${gaps.length} checklist gap(s). Not ASP-ready.`);
|
|
88
|
+
}
|
|
89
|
+
const isCredit = invoice.documentType === "credit_note";
|
|
90
|
+
const root = isCredit ? "CreditNote" : "Invoice";
|
|
91
|
+
const typeTag = isCredit ? "CreditNoteTypeCode" : "InvoiceTypeCode";
|
|
92
|
+
const lineWrapper = isCredit ? "CreditNoteLine" : "InvoiceLine";
|
|
93
|
+
const qtyTag = isCredit ? "CreditedQuantity" : "InvoicedQuantity";
|
|
94
|
+
const currency = invoice.currencyCode ?? "AED";
|
|
95
|
+
const customization = invoice.customizationId ?? EXPECTED_CUSTOMIZATION;
|
|
96
|
+
const profile = invoice.profileId ?? EXPECTED_PROFILE;
|
|
97
|
+
const typeCode = invoice.typeCode ?? (isCredit ? "381" : "380");
|
|
98
|
+
if (!invoice.profileId) {
|
|
99
|
+
warnings.push(`Defaulted ProfileID to ${EXPECTED_PROFILE}`);
|
|
100
|
+
}
|
|
101
|
+
if (!invoice.customizationId) {
|
|
102
|
+
warnings.push(`Defaulted CustomizationID to ${EXPECTED_CUSTOMIZATION}`);
|
|
103
|
+
}
|
|
104
|
+
const taxBlocks = (invoice.taxBreakdown ?? [])
|
|
105
|
+
.map((row) => {
|
|
106
|
+
const exemption = row.exemptionReasonCode || row.exemptionReason
|
|
107
|
+
? `${row.exemptionReasonCode
|
|
108
|
+
? `
|
|
109
|
+
<cbc:TaxExemptionReasonCode>${esc(row.exemptionReasonCode)}</cbc:TaxExemptionReasonCode>`
|
|
110
|
+
: ""}${row.exemptionReason
|
|
111
|
+
? `
|
|
112
|
+
<cbc:TaxExemptionReason>${esc(row.exemptionReason)}</cbc:TaxExemptionReason>`
|
|
113
|
+
: ""}`
|
|
114
|
+
: "";
|
|
115
|
+
return `
|
|
116
|
+
<cac:TaxSubtotal>
|
|
117
|
+
${amountTag("cbc:TaxableAmount", row.taxableAmount ?? 0, currency)}
|
|
118
|
+
${amountTag("cbc:TaxAmount", row.taxAmount ?? 0, currency)}
|
|
119
|
+
<cac:TaxCategory>
|
|
120
|
+
<cbc:ID>${esc(row.taxCategoryCode ?? "S")}</cbc:ID>
|
|
121
|
+
<cbc:Percent>${(row.taxPercent ?? 0).toFixed(2)}</cbc:Percent>${exemption}
|
|
122
|
+
<cac:TaxScheme>
|
|
123
|
+
<cbc:ID>VAT</cbc:ID>
|
|
124
|
+
</cac:TaxScheme>
|
|
125
|
+
</cac:TaxCategory>
|
|
126
|
+
</cac:TaxSubtotal>`;
|
|
127
|
+
})
|
|
128
|
+
.join("");
|
|
129
|
+
const lines = (invoice.lines ?? [])
|
|
130
|
+
.map((line, idx) => {
|
|
131
|
+
return `
|
|
132
|
+
<cac:${lineWrapper}>
|
|
133
|
+
<cbc:ID>${esc(line.id ?? String(idx + 1))}</cbc:ID>
|
|
134
|
+
<cbc:${qtyTag} unitCode="${esc(line.unitCode ?? "EA")}">${line.quantity ?? 0}</cbc:${qtyTag}>
|
|
135
|
+
${amountTag("cbc:LineExtensionAmount", line.netAmount ?? 0, currency)}
|
|
136
|
+
<cac:Item>
|
|
137
|
+
<cbc:Name>${esc(line.name ?? "Item")}</cbc:Name>
|
|
138
|
+
<cac:ClassifiedTaxCategory>
|
|
139
|
+
<cbc:ID>${esc(line.taxCategoryCode ?? "S")}</cbc:ID>
|
|
140
|
+
<cbc:Percent>${(line.taxPercent ?? 0).toFixed(2)}</cbc:Percent>
|
|
141
|
+
<cac:TaxScheme>
|
|
142
|
+
<cbc:ID>VAT</cbc:ID>
|
|
143
|
+
</cac:TaxScheme>
|
|
144
|
+
</cac:ClassifiedTaxCategory>
|
|
145
|
+
</cac:Item>
|
|
146
|
+
<cac:Price>
|
|
147
|
+
${amountTag("cbc:PriceAmount", line.unitPrice ?? 0, currency)}
|
|
148
|
+
</cac:Price>
|
|
149
|
+
</cac:${lineWrapper}>`;
|
|
150
|
+
})
|
|
151
|
+
.join("");
|
|
152
|
+
const period = invoice.periodStart || invoice.periodEnd
|
|
153
|
+
? `
|
|
154
|
+
<cac:InvoicePeriod>
|
|
155
|
+
${invoice.periodStart ? ` <cbc:StartDate>${esc(invoice.periodStart)}</cbc:StartDate>` : ""}
|
|
156
|
+
${invoice.periodEnd ? ` <cbc:EndDate>${esc(invoice.periodEnd)}</cbc:EndDate>` : ""}
|
|
157
|
+
</cac:InvoicePeriod>`
|
|
158
|
+
: "";
|
|
159
|
+
const billingRef = invoice.billingReference
|
|
160
|
+
? `
|
|
161
|
+
<cac:BillingReference>
|
|
162
|
+
<cac:InvoiceDocumentReference>
|
|
163
|
+
<cbc:ID>${esc(invoice.billingReference)}</cbc:ID>
|
|
164
|
+
</cac:InvoiceDocumentReference>
|
|
165
|
+
</cac:BillingReference>`
|
|
166
|
+
: "";
|
|
167
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
168
|
+
<${root} xmlns="urn:oasis:names:specification:ubl:schema:xsd:${root}-2"
|
|
169
|
+
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
|
170
|
+
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
|
171
|
+
<cbc:CustomizationID>${esc(customization)}</cbc:CustomizationID>
|
|
172
|
+
<cbc:ProfileID>${esc(profile)}</cbc:ProfileID>
|
|
173
|
+
<cbc:ID>${esc(invoice.invoiceNumber ?? "UNKNOWN")}</cbc:ID>
|
|
174
|
+
<cbc:IssueDate>${esc(invoice.issueDate ?? "1970-01-01")}</cbc:IssueDate>
|
|
175
|
+
${invoice.dueDate ? ` <cbc:DueDate>${esc(invoice.dueDate)}</cbc:DueDate>` : ""}
|
|
176
|
+
${invoice.taxPointDate ? ` <cbc:TaxPointDate>${esc(invoice.taxPointDate)}</cbc:TaxPointDate>` : ""}
|
|
177
|
+
<cbc:${typeTag}>${esc(typeCode)}</cbc:${typeTag}>
|
|
178
|
+
<cbc:DocumentCurrencyCode>${esc(currency)}</cbc:DocumentCurrencyCode>
|
|
179
|
+
${invoice.taxCurrencyCode
|
|
180
|
+
? ` <cbc:TaxCurrencyCode>${esc(invoice.taxCurrencyCode)}</cbc:TaxCurrencyCode>`
|
|
181
|
+
: ""}
|
|
182
|
+
${invoice.buyerReference ? ` <cbc:BuyerReference>${esc(invoice.buyerReference)}</cbc:BuyerReference>` : ""}
|
|
183
|
+
${period}${billingRef}
|
|
184
|
+
${partyXml("AccountingSupplierParty", invoice.seller, "seller", invoice)}
|
|
185
|
+
${partyXml("AccountingCustomerParty", invoice.buyer, "buyer", invoice)}
|
|
186
|
+
<cac:TaxTotal>
|
|
187
|
+
${amountTag("cbc:TaxAmount", invoice.taxTotal ?? 0, currency)}
|
|
188
|
+
${taxBlocks}
|
|
189
|
+
</cac:TaxTotal>
|
|
190
|
+
${typeof invoice.taxTotalAed === "number" && currency !== "AED"
|
|
191
|
+
? ` <cac:TaxTotal>
|
|
192
|
+
${amountTag("cbc:TaxAmount", invoice.taxTotalAed, "AED")}
|
|
193
|
+
</cac:TaxTotal>`
|
|
194
|
+
: ""}
|
|
195
|
+
<cac:LegalMonetaryTotal>
|
|
196
|
+
${amountTag("cbc:LineExtensionAmount", invoice.lineNetTotal ?? 0, currency)}
|
|
197
|
+
${amountTag("cbc:TaxExclusiveAmount", invoice.taxExclusiveTotal ?? 0, currency)}
|
|
198
|
+
${amountTag("cbc:TaxInclusiveAmount", invoice.taxInclusiveTotal ?? 0, currency)}
|
|
199
|
+
${typeof invoice.allowanceTotal === "number"
|
|
200
|
+
? ` ${amountTag("cbc:AllowanceTotalAmount", invoice.allowanceTotal, currency)}`
|
|
201
|
+
: ""}
|
|
202
|
+
${typeof invoice.chargeTotal === "number"
|
|
203
|
+
? ` ${amountTag("cbc:ChargeTotalAmount", invoice.chargeTotal, currency)}`
|
|
204
|
+
: ""}
|
|
205
|
+
${typeof invoice.prepaidAmount === "number"
|
|
206
|
+
? ` ${amountTag("cbc:PrepaidAmount", invoice.prepaidAmount, currency)}`
|
|
207
|
+
: ""}
|
|
208
|
+
${amountTag("cbc:PayableAmount", invoice.payableAmount ?? 0, currency)}
|
|
209
|
+
</cac:LegalMonetaryTotal>
|
|
210
|
+
${lines}
|
|
211
|
+
</${root}>
|
|
212
|
+
`;
|
|
213
|
+
return {
|
|
214
|
+
generated: true,
|
|
215
|
+
rootElement: root,
|
|
216
|
+
xml: xml.replace(/\n{3,}/g, "\n\n").trim() + "\n",
|
|
217
|
+
checklistComplete: checklist.complete,
|
|
218
|
+
gaps,
|
|
219
|
+
warnings,
|
|
220
|
+
note: "UBL 2.1 stub shaped for PINT AE demos. Validate with your ASP XSD/Schematron before transmission.",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { checkPintAeFields } from "./lib/checklist.js";
|
|
6
|
+
import { checkInvoiceCompliance } from "./lib/compliance.js";
|
|
7
|
+
import { getEinvoicingRequirements } from "./lib/requirements.js";
|
|
8
|
+
import { computeTinCheckDigit, makeSampleTin, validateTaxId } from "./lib/trn.js";
|
|
9
|
+
import { generatePintAeXml } from "./lib/xml.js";
|
|
10
|
+
import { PINT_AE_FIELDS } from "./lib/fields.js";
|
|
11
|
+
const sampleTin = makeSampleTin("23456789");
|
|
12
|
+
assert.equal(sampleTin.length, 10);
|
|
13
|
+
assert.equal(sampleTin[0], "1");
|
|
14
|
+
assert.equal(Number(sampleTin[9]), computeTinCheckDigit(sampleTin.slice(0, 9)));
|
|
15
|
+
assert.equal(validateTaxId(sampleTin).valid, true);
|
|
16
|
+
assert.equal(validateTaxId(`${sampleTin}12345`).valid, true);
|
|
17
|
+
assert.equal(validateTaxId("1234567890").valid, false);
|
|
18
|
+
assert.equal(PINT_AE_FIELDS.length, 51);
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const fixture = JSON.parse(readFileSync(join(__dirname, "../examples/compliant-invoice.json"), "utf8")).invoiceData;
|
|
21
|
+
const good = checkInvoiceCompliance(fixture);
|
|
22
|
+
assert.equal(good.compliant, true, JSON.stringify(good.findings, null, 2));
|
|
23
|
+
const checklist = checkPintAeFields(fixture);
|
|
24
|
+
assert.equal(checklist.totalFields, 51);
|
|
25
|
+
assert.ok(checklist.completionPercent >= 90, JSON.stringify(checklist.gaps, null, 2));
|
|
26
|
+
assert.equal(checklist.complete, true, JSON.stringify(checklist.gaps, null, 2));
|
|
27
|
+
const xml = generatePintAeXml(fixture);
|
|
28
|
+
assert.equal(xml.generated, true);
|
|
29
|
+
assert.ok(xml.xml?.includes("CustomizationID"));
|
|
30
|
+
assert.ok(xml.xml?.includes("AccountingSupplierParty"));
|
|
31
|
+
assert.ok(xml.xml?.includes("<cbc:ID>INV-1001</cbc:ID>"));
|
|
32
|
+
const strictFail = generatePintAeXml({ documentType: "tax_invoice" }, { strict: true });
|
|
33
|
+
assert.equal(strictFail.generated, false);
|
|
34
|
+
const bad = checkInvoiceCompliance({ documentType: "tax_invoice" });
|
|
35
|
+
assert.equal(bad.compliant, false);
|
|
36
|
+
assert.ok(bad.findings.some((f) => f.severity === "error"));
|
|
37
|
+
const reqs = getEinvoicingRequirements("all", 60_000_000);
|
|
38
|
+
assert.equal(reqs.segment, "large");
|
|
39
|
+
assert.ok(reqs.phases.length === 1);
|
|
40
|
+
console.log("All self-checks passed.");
|
|
41
|
+
console.log("Sample TIN:", sampleTin);
|
|
42
|
+
console.log("Compliance score:", good.score);
|
|
43
|
+
console.log("Checklist completion %:", checklist.completionPercent);
|
|
44
|
+
console.log("XML bytes:", xml.xml?.length);
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "uae-einvoice-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for UAE e-invoicing / PINT AE: TRN validation, 51-field checklist, XML generation, mandate timeline",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"uae-einvoice-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"selfcheck": "tsx src/selfcheck.ts",
|
|
19
|
+
"inspector": "npm run build && npx @modelcontextprotocol/inspector node dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"uae",
|
|
28
|
+
"e-invoicing",
|
|
29
|
+
"pint-ae",
|
|
30
|
+
"peppol",
|
|
31
|
+
"trn",
|
|
32
|
+
"fta"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
37
|
+
"zod": "^4.5.4"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^26.5.0",
|
|
41
|
+
"tsx": "^4.23.13",
|
|
42
|
+
"typescript": "^7.0.2"
|
|
43
|
+
}
|
|
44
|
+
}
|