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
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# uae-einvoice-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for **UAE e-invoicing / PINT AE** — TRN/TIN validation, structural compliance, the **51-field mandatory checklist**, UBL XML stub generation, and mandate timeline helpers.
|
|
4
|
+
|
|
5
|
+
Built with the MCP TypeScript SDK v2 (`@modelcontextprotocol/server`) over **stdio**. Free to run locally; no API keys required for v0.2.
|
|
6
|
+
|
|
7
|
+
> Informational tooling only — not legal or tax advice, and not a substitute for Accredited Service Provider (ASP) XSD/Schematron validation or live FTA register checks.
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
UAE e-invoicing (Peppol + PINT AE) forces ERP and finance teams to get dozens of mandatory fields, TIN vs TRN, and Peppol endpoint IDs right before an ASP will transmit anything. This server gives AI assistants a small, installable toolkit for those checks.
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
| Tool | What it does |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `lookup_business_trn` | Validates 15-digit TRN or 10-digit TIN format, UAE `1…` prefix, Peppol AE:TIN check digit, and returns `0235:<TIN>` |
|
|
18
|
+
| `check_invoice_compliance` | Structural checks (parties, totals math, tax breakdown, lines) with errors/warnings/score |
|
|
19
|
+
| `check_pint_ae_fields` | Full **51-field** mandatory matrix: present / missing / invalid / N/A + gaps |
|
|
20
|
+
| `generate_pint_ae_xml` | Builds a UBL 2.1 PINT AE-shaped Invoice/CreditNote XML stub (optional `strict` mode) |
|
|
21
|
+
| `get_einvoicing_requirements` | Mandate phases (large / SME / government), technical baseline, and next actions |
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Node.js **20+**
|
|
26
|
+
|
|
27
|
+
## Install / run
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npm run build
|
|
32
|
+
npm start
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Dev (TypeScript directly):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm run dev
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Self-check (no MCP client needed):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm run selfcheck
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### MCP Inspector
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm run inspector
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Paste fixtures from `examples/compliant-invoice.json` or `examples/broken-invoice.json` into the tools.
|
|
54
|
+
|
|
55
|
+
### Cursor / Claude Desktop (stdio)
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"uae-einvoice": {
|
|
61
|
+
"command": "node",
|
|
62
|
+
"args": ["/absolute/path/to/uae-einvoice-mcp/dist/index.js"]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Suggested test flow
|
|
69
|
+
|
|
70
|
+
1. `check_pint_ae_fields` with `examples/compliant-invoice.json`
|
|
71
|
+
2. `generate_pint_ae_xml` with the same payload (`strict: true` should succeed)
|
|
72
|
+
3. `generate_pint_ae_xml` with `examples/broken-invoice.json` and `strict: true` (should refuse)
|
|
73
|
+
|
|
74
|
+
## Roadmap
|
|
75
|
+
|
|
76
|
+
- [x] 51-field checklist + PINT AE XML stub generation
|
|
77
|
+
- [ ] Deeper code-list coverage (UOM, exemption reason codes)
|
|
78
|
+
- [ ] Optional HTTP + API-key transport for hosted / paid tier
|
|
79
|
+
- [ ] Publish to npm and MCP directories
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
3
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { checkPintAeFields } from "./lib/checklist.js";
|
|
6
|
+
import { checkInvoiceCompliance } from "./lib/compliance.js";
|
|
7
|
+
import { getEinvoicingRequirements } from "./lib/requirements.js";
|
|
8
|
+
import { makeSampleTin, validateTaxId } from "./lib/trn.js";
|
|
9
|
+
import { generatePintAeXml } from "./lib/xml.js";
|
|
10
|
+
const partySchema = z
|
|
11
|
+
.object({
|
|
12
|
+
legalName: z.string().optional(),
|
|
13
|
+
tradeName: z.string().optional(),
|
|
14
|
+
trn: z.string().optional(),
|
|
15
|
+
tin: z.string().optional(),
|
|
16
|
+
countryCode: z.string().optional(),
|
|
17
|
+
addressLine: z.string().optional(),
|
|
18
|
+
cityOrEmirate: z.string().optional(),
|
|
19
|
+
postalZone: z.string().optional(),
|
|
20
|
+
contactName: z.string().optional(),
|
|
21
|
+
email: z.string().optional(),
|
|
22
|
+
phone: z.string().optional(),
|
|
23
|
+
peppolId: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
.optional();
|
|
26
|
+
const invoiceDataSchema = z.object({
|
|
27
|
+
documentType: z
|
|
28
|
+
.enum(["tax_invoice", "commercial_invoice", "credit_note"])
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Defaults to tax_invoice"),
|
|
31
|
+
invoiceNumber: z.string().optional(),
|
|
32
|
+
issueDate: z.string().optional().describe("ISO date YYYY-MM-DD"),
|
|
33
|
+
dueDate: z.string().optional(),
|
|
34
|
+
currencyCode: z.string().optional().describe("Typically AED"),
|
|
35
|
+
taxCurrencyCode: z.string().optional(),
|
|
36
|
+
customizationId: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("PINT AE CustomizationID, e.g. urn:peppol:pint:billing-1@ae-1"),
|
|
40
|
+
profileId: z.string().optional(),
|
|
41
|
+
typeCode: z.string().optional().describe("380 invoice / 381 credit note"),
|
|
42
|
+
buyerReference: z.string().optional(),
|
|
43
|
+
periodStart: z.string().optional(),
|
|
44
|
+
periodEnd: z.string().optional(),
|
|
45
|
+
taxPointDate: z.string().optional(),
|
|
46
|
+
billingReference: z.string().optional().describe("Original invoice id for credit notes"),
|
|
47
|
+
seller: partySchema,
|
|
48
|
+
buyer: partySchema,
|
|
49
|
+
lineNetTotal: z.number().optional(),
|
|
50
|
+
taxExclusiveTotal: z.number().optional(),
|
|
51
|
+
taxTotal: z.number().optional(),
|
|
52
|
+
taxTotalAed: z.number().optional(),
|
|
53
|
+
taxInclusiveTotal: z.number().optional(),
|
|
54
|
+
allowanceTotal: z.number().optional(),
|
|
55
|
+
chargeTotal: z.number().optional(),
|
|
56
|
+
prepaidAmount: z.number().optional(),
|
|
57
|
+
payableAmount: z.number().optional(),
|
|
58
|
+
taxBreakdown: z
|
|
59
|
+
.array(z.object({
|
|
60
|
+
taxableAmount: z.number().optional(),
|
|
61
|
+
taxAmount: z.number().optional(),
|
|
62
|
+
taxCategoryCode: z.string().optional(),
|
|
63
|
+
taxPercent: z.number().optional(),
|
|
64
|
+
exemptionReason: z.string().optional(),
|
|
65
|
+
exemptionReasonCode: z.string().optional(),
|
|
66
|
+
}))
|
|
67
|
+
.optional(),
|
|
68
|
+
lines: z
|
|
69
|
+
.array(z.object({
|
|
70
|
+
id: z.string().optional(),
|
|
71
|
+
name: z.string().optional(),
|
|
72
|
+
quantity: z.number().optional(),
|
|
73
|
+
unitCode: z.string().optional(),
|
|
74
|
+
netAmount: z.number().optional(),
|
|
75
|
+
unitPrice: z.number().optional(),
|
|
76
|
+
taxCategoryCode: z.string().optional(),
|
|
77
|
+
taxPercent: z.number().optional(),
|
|
78
|
+
}))
|
|
79
|
+
.optional(),
|
|
80
|
+
});
|
|
81
|
+
function textResult(payload) {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: JSON.stringify(payload, null, 2),
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function createServer() {
|
|
92
|
+
const server = new McpServer({
|
|
93
|
+
name: "uae-einvoice-mcp",
|
|
94
|
+
version: "0.2.0",
|
|
95
|
+
});
|
|
96
|
+
server.registerTool("lookup_business_trn", {
|
|
97
|
+
description: "Validate a UAE Tax Registration Number (15-digit TRN) or Tax Identification Number (10-digit TIN): format, UAE country prefix, Peppol AE:TIN check digit, and derived Peppol participant ID (0235:TIN). Does not call the live FTA register.",
|
|
98
|
+
inputSchema: z.object({
|
|
99
|
+
taxId: z
|
|
100
|
+
.string()
|
|
101
|
+
.describe("15-digit TRN or 10-digit TIN (spaces/dashes allowed)"),
|
|
102
|
+
}),
|
|
103
|
+
}, async ({ taxId }) => {
|
|
104
|
+
const result = validateTaxId(taxId);
|
|
105
|
+
return textResult({
|
|
106
|
+
...result,
|
|
107
|
+
note: "Local structural validation only. Confirm active registration on EmaraTax / FTA services before production use.",
|
|
108
|
+
exampleValidTinShape: makeSampleTin(),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
server.registerTool("check_invoice_compliance", {
|
|
112
|
+
description: "Structural PINT AE / UAE e-invoicing checks (parties, tax IDs, totals arithmetic, tax breakdown, lines). Returns errors, warnings, a score, and derived Peppol IDs. Prefer check_pint_ae_fields for the full 51-field matrix.",
|
|
113
|
+
inputSchema: z.object({
|
|
114
|
+
invoiceData: invoiceDataSchema,
|
|
115
|
+
}),
|
|
116
|
+
}, async ({ invoiceData }) => textResult(checkInvoiceCompliance(invoiceData)));
|
|
117
|
+
server.registerTool("check_pint_ae_fields", {
|
|
118
|
+
description: "Evaluate invoice JSON against the 51-field UAE e-invoice / PINT AE mandatory field matrix. Returns per-field present/missing/invalid/not_applicable status, completion %, and gaps.",
|
|
119
|
+
inputSchema: z.object({
|
|
120
|
+
invoiceData: invoiceDataSchema,
|
|
121
|
+
}),
|
|
122
|
+
}, async ({ invoiceData }) => textResult(checkPintAeFields(invoiceData)));
|
|
123
|
+
server.registerTool("generate_pint_ae_xml", {
|
|
124
|
+
description: "Generate a UBL 2.1 PINT AE-shaped Invoice/CreditNote XML stub from invoice JSON. Runs the 51-field checklist first; with strict=true refuses to emit XML when mandatory applicable fields are incomplete.",
|
|
125
|
+
inputSchema: z.object({
|
|
126
|
+
invoiceData: invoiceDataSchema,
|
|
127
|
+
strict: z
|
|
128
|
+
.boolean()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("If true, do not generate XML when checklist has gaps (default false)"),
|
|
131
|
+
}),
|
|
132
|
+
}, async ({ invoiceData, strict }) => textResult(generatePintAeXml(invoiceData, { strict: strict ?? false })));
|
|
133
|
+
server.registerTool("get_einvoicing_requirements", {
|
|
134
|
+
description: "Return the UAE e-invoicing mandate timeline, technical baseline (PINT AE / Peppol / ASP), and next actions for a business segment (large, sme, government, or all). Optional annual revenue (AED) auto-selects large vs SME.",
|
|
135
|
+
inputSchema: z.object({
|
|
136
|
+
businessType: z
|
|
137
|
+
.enum(["large", "sme", "government", "all"])
|
|
138
|
+
.optional()
|
|
139
|
+
.describe("Defaults to all"),
|
|
140
|
+
annualRevenueAed: z
|
|
141
|
+
.number()
|
|
142
|
+
.nonnegative()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe("If set with businessType all/omitted, selects large (≥50M) vs sme"),
|
|
145
|
+
}),
|
|
146
|
+
}, async ({ businessType, annualRevenueAed }) => textResult(getEinvoicingRequirements(businessType ?? "all", annualRevenueAed)));
|
|
147
|
+
return server;
|
|
148
|
+
}
|
|
149
|
+
serveStdio(createServer);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type FieldStatus } from "./fields.js";
|
|
2
|
+
import type { InvoiceDataInput } from "./invoice-types.js";
|
|
3
|
+
export interface FieldCheckResult {
|
|
4
|
+
id: number;
|
|
5
|
+
key: string;
|
|
6
|
+
name: string;
|
|
7
|
+
ublPath: string;
|
|
8
|
+
category: string;
|
|
9
|
+
requirement: string;
|
|
10
|
+
conditionNote?: string;
|
|
11
|
+
status: FieldStatus;
|
|
12
|
+
value?: string | number | boolean | null;
|
|
13
|
+
note?: string;
|
|
14
|
+
countsTowardMandatory: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface ChecklistResult {
|
|
17
|
+
documentType: string;
|
|
18
|
+
totalFields: 51;
|
|
19
|
+
mandatoryApplicable: number;
|
|
20
|
+
present: number;
|
|
21
|
+
missing: number;
|
|
22
|
+
invalid: number;
|
|
23
|
+
notApplicable: number;
|
|
24
|
+
complete: boolean;
|
|
25
|
+
completionPercent: number;
|
|
26
|
+
summary: string;
|
|
27
|
+
fields: FieldCheckResult[];
|
|
28
|
+
gaps: FieldCheckResult[];
|
|
29
|
+
disclaimer: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function checkPintAeFields(invoice: InvoiceDataInput): ChecklistResult;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { PINT_AE_FIELDS } from "./fields.js";
|
|
2
|
+
function countsTowardMandatory(requirement, status, isTaxDoc) {
|
|
3
|
+
if (status === "not_applicable")
|
|
4
|
+
return false;
|
|
5
|
+
if (requirement === "always")
|
|
6
|
+
return true;
|
|
7
|
+
if (requirement === "tax_doc")
|
|
8
|
+
return isTaxDoc;
|
|
9
|
+
// Conditional fields that are still applicable (present/missing/invalid)
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
export function checkPintAeFields(invoice) {
|
|
13
|
+
const documentType = invoice.documentType ?? "tax_invoice";
|
|
14
|
+
const isTaxDoc = documentType === "tax_invoice" || documentType === "credit_note";
|
|
15
|
+
const fields = PINT_AE_FIELDS.map((def) => {
|
|
16
|
+
const result = def.evaluate(invoice);
|
|
17
|
+
let status = result.status;
|
|
18
|
+
// Soften conditional "missing" into not_applicable when the condition
|
|
19
|
+
// evaluator already uses missing for "not provided" — for scoring we only
|
|
20
|
+
// treat always + tax_doc (+ conditional when marked missing but needed) as gaps.
|
|
21
|
+
// Conditional fields that evaluate to "missing" without an active condition
|
|
22
|
+
// are treated as not_applicable for completion % except when evaluator
|
|
23
|
+
// returned missing because the condition IS active (tax currency, exemption, etc.).
|
|
24
|
+
if (def.requirement === "conditional" && status === "missing") {
|
|
25
|
+
// Heuristic: if evaluate returned missing with a note saying Must/Required,
|
|
26
|
+
// keep as missing; otherwise treat optional-absent as not_applicable.
|
|
27
|
+
const active = Boolean(result.note) ||
|
|
28
|
+
def.key === "taxCurrencyCode" ||
|
|
29
|
+
def.key === "taxTotalAed" ||
|
|
30
|
+
def.key === "taxBreakdown.exemptionReasonCode";
|
|
31
|
+
// For most soft conditionals (due date, trade name, contact, allowance),
|
|
32
|
+
// absence is OK unless business data implies they are needed.
|
|
33
|
+
if (!active &&
|
|
34
|
+
[
|
|
35
|
+
"dueDate",
|
|
36
|
+
"buyerReference",
|
|
37
|
+
"periodStart",
|
|
38
|
+
"periodEnd",
|
|
39
|
+
"taxPointDate",
|
|
40
|
+
"seller.tradeName",
|
|
41
|
+
"seller.contact",
|
|
42
|
+
"buyer.tradeName",
|
|
43
|
+
"buyer.contact",
|
|
44
|
+
"allowanceTotal",
|
|
45
|
+
"chargeTotal",
|
|
46
|
+
].includes(def.key)) {
|
|
47
|
+
status = "not_applicable";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const mandatory = countsTowardMandatory(def.requirement, status, isTaxDoc);
|
|
51
|
+
return {
|
|
52
|
+
id: def.id,
|
|
53
|
+
key: def.key,
|
|
54
|
+
name: def.name,
|
|
55
|
+
ublPath: def.ublPath,
|
|
56
|
+
category: def.category,
|
|
57
|
+
requirement: def.requirement,
|
|
58
|
+
conditionNote: def.conditionNote,
|
|
59
|
+
status,
|
|
60
|
+
value: result.value,
|
|
61
|
+
note: result.note,
|
|
62
|
+
countsTowardMandatory: mandatory,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
const applicable = fields.filter((f) => f.countsTowardMandatory);
|
|
66
|
+
const present = applicable.filter((f) => f.status === "present").length;
|
|
67
|
+
const missing = applicable.filter((f) => f.status === "missing").length;
|
|
68
|
+
const invalid = applicable.filter((f) => f.status === "invalid").length;
|
|
69
|
+
const notApplicable = fields.filter((f) => f.status === "not_applicable").length;
|
|
70
|
+
const complete = missing === 0 && invalid === 0 && applicable.length > 0;
|
|
71
|
+
const completionPercent = applicable.length === 0
|
|
72
|
+
? 0
|
|
73
|
+
: Math.round((present / applicable.length) * 1000) / 10;
|
|
74
|
+
const gaps = applicable.filter((f) => f.status === "missing" || f.status === "invalid");
|
|
75
|
+
return {
|
|
76
|
+
documentType,
|
|
77
|
+
totalFields: 51,
|
|
78
|
+
mandatoryApplicable: applicable.length,
|
|
79
|
+
present,
|
|
80
|
+
missing,
|
|
81
|
+
invalid,
|
|
82
|
+
notApplicable,
|
|
83
|
+
complete,
|
|
84
|
+
completionPercent,
|
|
85
|
+
summary: complete
|
|
86
|
+
? `All ${applicable.length} applicable mandatory fields present (${completionPercent}%).`
|
|
87
|
+
: `${gaps.length} gap(s) across ${applicable.length} applicable fields (${completionPercent}% complete).`,
|
|
88
|
+
fields,
|
|
89
|
+
gaps,
|
|
90
|
+
disclaimer: "Working model of the 51-field UAE e-invoice matrix for tooling/demo use. Verify against the official MoF mandatory-fields document before production.",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { InvoiceDataInput, InvoiceDocumentType } from "./invoice-types.js";
|
|
2
|
+
export type { InvoiceDataInput, InvoiceDocumentType, InvoiceLineInput, PartyInput, TaxBreakdownInput, } from "./invoice-types.js";
|
|
3
|
+
export type FindingSeverity = "error" | "warning" | "info";
|
|
4
|
+
export interface ComplianceFinding {
|
|
5
|
+
code: string;
|
|
6
|
+
severity: FindingSeverity;
|
|
7
|
+
field: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ComplianceResult {
|
|
11
|
+
compliant: boolean;
|
|
12
|
+
score: number;
|
|
13
|
+
summary: string;
|
|
14
|
+
documentType: InvoiceDocumentType;
|
|
15
|
+
findings: ComplianceFinding[];
|
|
16
|
+
derived: {
|
|
17
|
+
sellerTin: string | null;
|
|
18
|
+
sellerPeppolId: string | null;
|
|
19
|
+
buyerTin: string | null;
|
|
20
|
+
expectedCustomizationId: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare function checkInvoiceCompliance(invoice: InvoiceDataInput): ComplianceResult;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { validateTaxId } from "./trn.js";
|
|
2
|
+
const EXPECTED_CUSTOMIZATION_ID = "urn:peppol:pint:billing-1@ae-1";
|
|
3
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
4
|
+
const TAX_CATEGORIES = new Set(["S", "Z", "E", "O", "AE", "G"]);
|
|
5
|
+
function add(findings, severity, code, field, message) {
|
|
6
|
+
findings.push({ code, severity, field, message });
|
|
7
|
+
}
|
|
8
|
+
function resolveTin(party) {
|
|
9
|
+
const findings = [];
|
|
10
|
+
if (!party)
|
|
11
|
+
return { tin: null, findings };
|
|
12
|
+
const raw = party.tin ?? party.trn;
|
|
13
|
+
if (!raw)
|
|
14
|
+
return { tin: null, findings };
|
|
15
|
+
const result = validateTaxId(raw);
|
|
16
|
+
if (!result.valid) {
|
|
17
|
+
for (const err of result.errors) {
|
|
18
|
+
findings.push({
|
|
19
|
+
code: "TAX_ID_INVALID",
|
|
20
|
+
severity: "error",
|
|
21
|
+
field: party.tin ? "tin" : "trn",
|
|
22
|
+
message: err,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { tin: result.tin, findings };
|
|
27
|
+
}
|
|
28
|
+
function approxEqual(a, b, epsilon = 0.02) {
|
|
29
|
+
return Math.abs(a - b) <= epsilon;
|
|
30
|
+
}
|
|
31
|
+
export function checkInvoiceCompliance(invoice) {
|
|
32
|
+
const findings = [];
|
|
33
|
+
const documentType = invoice.documentType ?? "tax_invoice";
|
|
34
|
+
const isTaxDoc = documentType === "tax_invoice" || documentType === "credit_note";
|
|
35
|
+
if (!invoice.invoiceNumber?.trim()) {
|
|
36
|
+
add(findings, "error", "INV_NUMBER_MISSING", "invoiceNumber", "Invoice number (cbc:ID) is required.");
|
|
37
|
+
}
|
|
38
|
+
if (!invoice.issueDate) {
|
|
39
|
+
add(findings, "error", "ISSUE_DATE_MISSING", "issueDate", "Issue date is required (YYYY-MM-DD).");
|
|
40
|
+
}
|
|
41
|
+
else if (!ISO_DATE.test(invoice.issueDate)) {
|
|
42
|
+
add(findings, "error", "ISSUE_DATE_FORMAT", "issueDate", "Issue date must be ISO format YYYY-MM-DD.");
|
|
43
|
+
}
|
|
44
|
+
if (!invoice.currencyCode) {
|
|
45
|
+
add(findings, "error", "CURRENCY_MISSING", "currencyCode", "DocumentCurrencyCode is required.");
|
|
46
|
+
}
|
|
47
|
+
else if (invoice.currencyCode !== "AED") {
|
|
48
|
+
add(findings, "warning", "CURRENCY_NOT_AED", "currencyCode", `Currency is ${invoice.currencyCode}; domestic UAE supplies typically use AED.`);
|
|
49
|
+
}
|
|
50
|
+
if (!invoice.customizationId) {
|
|
51
|
+
add(findings, "warning", "CUSTOMIZATION_MISSING", "customizationId", `Missing CustomizationID; expected ${EXPECTED_CUSTOMIZATION_ID}.`);
|
|
52
|
+
}
|
|
53
|
+
else if (invoice.customizationId !== EXPECTED_CUSTOMIZATION_ID) {
|
|
54
|
+
add(findings, "warning", "CUSTOMIZATION_UNEXPECTED", "customizationId", `CustomizationID is "${invoice.customizationId}"; current PINT AE docs commonly use ${EXPECTED_CUSTOMIZATION_ID}.`);
|
|
55
|
+
}
|
|
56
|
+
if (documentType === "credit_note" && invoice.typeCode && invoice.typeCode !== "381") {
|
|
57
|
+
add(findings, "warning", "TYPE_CODE_CREDIT", "typeCode", "Credit notes commonly use type code 381.");
|
|
58
|
+
}
|
|
59
|
+
if (documentType === "tax_invoice" && invoice.typeCode && invoice.typeCode !== "380") {
|
|
60
|
+
add(findings, "warning", "TYPE_CODE_INVOICE", "typeCode", "Tax invoices commonly use type code 380.");
|
|
61
|
+
}
|
|
62
|
+
if (documentType === "credit_note" && !invoice.billingReference?.trim()) {
|
|
63
|
+
add(findings, "warning", "CREDIT_REF_MISSING", "billingReference", "Credit notes should reference the original invoice number.");
|
|
64
|
+
}
|
|
65
|
+
if (!invoice.seller?.legalName?.trim()) {
|
|
66
|
+
add(findings, "error", "SELLER_NAME", "seller.legalName", "Seller legal name is mandatory.");
|
|
67
|
+
}
|
|
68
|
+
const sellerId = resolveTin(invoice.seller);
|
|
69
|
+
for (const f of sellerId.findings) {
|
|
70
|
+
findings.push({ ...f, field: `seller.${f.field}` });
|
|
71
|
+
}
|
|
72
|
+
if (!invoice.seller?.trn && !invoice.seller?.tin) {
|
|
73
|
+
add(findings, "error", "SELLER_TAX_ID", "seller.trn", "Seller TRN (15) or TIN (10) is required for PINT AE party tax scheme / endpoint.");
|
|
74
|
+
}
|
|
75
|
+
if (!invoice.seller?.countryCode) {
|
|
76
|
+
add(findings, "warning", "SELLER_COUNTRY", "seller.countryCode", "Seller country code should be present (AE for UAE).");
|
|
77
|
+
}
|
|
78
|
+
else if (invoice.seller.countryCode !== "AE") {
|
|
79
|
+
add(findings, "info", "SELLER_COUNTRY_FOREIGN", "seller.countryCode", "Seller country is not AE — confirm cross-border PINT AE rules with your ASP.");
|
|
80
|
+
}
|
|
81
|
+
if (!invoice.seller?.addressLine || !invoice.seller?.cityOrEmirate) {
|
|
82
|
+
add(findings, "warning", "SELLER_ADDRESS", "seller.addressLine", "Seller registered address (line + city/emirate) is expected.");
|
|
83
|
+
}
|
|
84
|
+
if (sellerId.tin && invoice.seller?.peppolId) {
|
|
85
|
+
const expected = `0235:${sellerId.tin}`;
|
|
86
|
+
const normalized = invoice.seller.peppolId.replace(/^iso6523-actorid-upis::/i, "");
|
|
87
|
+
if (normalized !== expected && normalized !== sellerId.tin) {
|
|
88
|
+
add(findings, "error", "SELLER_PEPPOL_MISMATCH", "seller.peppolId", `Seller Peppol ID should be ${expected} (scheme 0235 + TIN).`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!invoice.buyer?.legalName?.trim()) {
|
|
92
|
+
add(findings, "error", "BUYER_NAME", "buyer.legalName", "Buyer legal name is mandatory.");
|
|
93
|
+
}
|
|
94
|
+
const buyerId = resolveTin(invoice.buyer);
|
|
95
|
+
for (const f of buyerId.findings) {
|
|
96
|
+
findings.push({ ...f, field: `buyer.${f.field}` });
|
|
97
|
+
}
|
|
98
|
+
if (isTaxDoc && !invoice.buyer?.trn && !invoice.buyer?.tin) {
|
|
99
|
+
add(findings, "warning", "BUYER_TAX_ID", "buyer.trn", "Buyer TRN/TIN missing — mandatory for B2B UAE tax invoices; optional only for limited B2C carve-outs.");
|
|
100
|
+
}
|
|
101
|
+
const requiredTotals = [
|
|
102
|
+
"taxExclusiveTotal",
|
|
103
|
+
"taxInclusiveTotal",
|
|
104
|
+
"payableAmount",
|
|
105
|
+
];
|
|
106
|
+
for (const key of requiredTotals) {
|
|
107
|
+
if (typeof invoice[key] !== "number") {
|
|
108
|
+
add(findings, "error", "TOTAL_MISSING", key, `${key} is required.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (isTaxDoc && typeof invoice.taxTotal !== "number") {
|
|
112
|
+
add(findings, "error", "TAX_TOTAL_MISSING", "taxTotal", "taxTotal is required on tax invoices / credit notes.");
|
|
113
|
+
}
|
|
114
|
+
if (typeof invoice.taxExclusiveTotal === "number" &&
|
|
115
|
+
typeof invoice.taxTotal === "number" &&
|
|
116
|
+
typeof invoice.taxInclusiveTotal === "number" &&
|
|
117
|
+
!approxEqual(invoice.taxExclusiveTotal + invoice.taxTotal, invoice.taxInclusiveTotal)) {
|
|
118
|
+
add(findings, "error", "TOTAL_ARITHMETIC", "taxInclusiveTotal", "taxExclusiveTotal + taxTotal should equal taxInclusiveTotal.");
|
|
119
|
+
}
|
|
120
|
+
if (isTaxDoc) {
|
|
121
|
+
if (!invoice.taxBreakdown?.length) {
|
|
122
|
+
add(findings, "error", "TAX_BREAKDOWN_MISSING", "taxBreakdown", "At least one tax breakdown (TaxSubtotal) is required for tax invoices.");
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
let breakdownTax = 0;
|
|
126
|
+
for (const [i, row] of invoice.taxBreakdown.entries()) {
|
|
127
|
+
if (typeof row.taxableAmount !== "number" || typeof row.taxAmount !== "number") {
|
|
128
|
+
add(findings, "error", "TAX_BREAKDOWN_AMOUNTS", `taxBreakdown[${i}]`, "Each tax breakdown needs taxableAmount and taxAmount.");
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
breakdownTax += row.taxAmount;
|
|
132
|
+
}
|
|
133
|
+
if (!row.taxCategoryCode) {
|
|
134
|
+
add(findings, "error", "TAX_CATEGORY_MISSING", `taxBreakdown[${i}].taxCategoryCode`, "Tax category code is required (e.g. S, Z, E, O).");
|
|
135
|
+
}
|
|
136
|
+
else if (!TAX_CATEGORIES.has(row.taxCategoryCode)) {
|
|
137
|
+
add(findings, "warning", "TAX_CATEGORY_UNCOMMON", `taxBreakdown[${i}].taxCategoryCode`, `Uncommon tax category "${row.taxCategoryCode}" — verify against PINT AE code list.`);
|
|
138
|
+
}
|
|
139
|
+
if (row.taxCategoryCode === "E" && !row.exemptionReason?.trim() && !row.exemptionReasonCode?.trim()) {
|
|
140
|
+
add(findings, "warning", "EXEMPTION_REASON", `taxBreakdown[${i}].exemptionReason`, "Exemption reason is usually required for exempt (E) supplies.");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (typeof invoice.taxTotal === "number" && !approxEqual(breakdownTax, invoice.taxTotal)) {
|
|
144
|
+
add(findings, "error", "TAX_BREAKDOWN_SUM", "taxTotal", "Sum of taxBreakdown.taxAmount should equal taxTotal.");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!invoice.lines?.length) {
|
|
149
|
+
add(findings, "error", "LINES_MISSING", "lines", "At least one invoice line is required.");
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
let lineSum = 0;
|
|
153
|
+
for (const [i, line] of invoice.lines.entries()) {
|
|
154
|
+
if (!line.id) {
|
|
155
|
+
add(findings, "warning", "LINE_ID", `lines[${i}].id`, "Line identifier is recommended.");
|
|
156
|
+
}
|
|
157
|
+
if (!line.name?.trim()) {
|
|
158
|
+
add(findings, "error", "LINE_NAME", `lines[${i}].name`, "Line item name is required.");
|
|
159
|
+
}
|
|
160
|
+
if (typeof line.quantity !== "number") {
|
|
161
|
+
add(findings, "error", "LINE_QTY", `lines[${i}].quantity`, "Line quantity is required.");
|
|
162
|
+
}
|
|
163
|
+
if (!line.unitCode) {
|
|
164
|
+
add(findings, "warning", "LINE_UOM", `lines[${i}].unitCode`, "Unit of measure should use UN/ECE Rec 20 codes (e.g. EA, HUR).");
|
|
165
|
+
}
|
|
166
|
+
if (typeof line.netAmount !== "number") {
|
|
167
|
+
add(findings, "error", "LINE_NET", `lines[${i}].netAmount`, "Line net amount is required.");
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
lineSum += line.netAmount;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (typeof invoice.lineNetTotal === "number" && !approxEqual(lineSum, invoice.lineNetTotal)) {
|
|
174
|
+
add(findings, "error", "LINE_SUM", "lineNetTotal", "Sum of line net amounts should equal lineNetTotal.");
|
|
175
|
+
}
|
|
176
|
+
else if (typeof invoice.taxExclusiveTotal === "number" &&
|
|
177
|
+
typeof invoice.lineNetTotal !== "number" &&
|
|
178
|
+
!approxEqual(lineSum, invoice.taxExclusiveTotal)) {
|
|
179
|
+
add(findings, "warning", "LINE_SUM_VS_TOTAL", "taxExclusiveTotal", "Sum of line net amounts does not match taxExclusiveTotal.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
183
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
184
|
+
const score = Math.max(0, 100 - errors.length * 12 - warnings.length * 4);
|
|
185
|
+
const compliant = errors.length === 0;
|
|
186
|
+
const summary = compliant
|
|
187
|
+
? `Structurally OK for a ${documentType.replace("_", " ")} at field-presence level (${warnings.length} warning(s)). Not a substitute for ASP XSD/Schematron validation.`
|
|
188
|
+
: `Not compliant: ${errors.length} error(s), ${warnings.length} warning(s). Fix errors before ASP submission.`;
|
|
189
|
+
return {
|
|
190
|
+
compliant,
|
|
191
|
+
score,
|
|
192
|
+
summary,
|
|
193
|
+
documentType,
|
|
194
|
+
findings,
|
|
195
|
+
derived: {
|
|
196
|
+
sellerTin: sellerId.tin,
|
|
197
|
+
sellerPeppolId: sellerId.tin ? `0235:${sellerId.tin}` : null,
|
|
198
|
+
buyerTin: buyerId.tin,
|
|
199
|
+
expectedCustomizationId: EXPECTED_CUSTOMIZATION_ID,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { InvoiceDataInput } from "./invoice-types.js";
|
|
2
|
+
export type FieldCategory = "header" | "seller" | "buyer" | "tax" | "totals" | "lines";
|
|
3
|
+
export type FieldRequirement = "always" | "tax_doc" | "conditional";
|
|
4
|
+
export type FieldStatus = "present" | "missing" | "invalid" | "not_applicable";
|
|
5
|
+
export interface PintFieldDef {
|
|
6
|
+
id: number;
|
|
7
|
+
key: string;
|
|
8
|
+
name: string;
|
|
9
|
+
ublPath: string;
|
|
10
|
+
category: FieldCategory;
|
|
11
|
+
requirement: FieldRequirement;
|
|
12
|
+
/** Human-readable when requirement is conditional */
|
|
13
|
+
conditionNote?: string;
|
|
14
|
+
evaluate: (invoice: InvoiceDataInput) => {
|
|
15
|
+
status: FieldStatus;
|
|
16
|
+
value?: string | number | boolean | null;
|
|
17
|
+
note?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
declare const EXPECTED_CUSTOMIZATION = "urn:peppol:pint:billing-1@ae-1";
|
|
21
|
+
declare const EXPECTED_PROFILE = "urn:peppol:bis:billing";
|
|
22
|
+
declare function partyTaxId(party: InvoiceDataInput["seller"]): {
|
|
23
|
+
trn: string | null;
|
|
24
|
+
tin: string | null;
|
|
25
|
+
valid: boolean;
|
|
26
|
+
};
|
|
27
|
+
declare function sellerEndpoint(invoice: InvoiceDataInput): {
|
|
28
|
+
scheme: string;
|
|
29
|
+
value: string;
|
|
30
|
+
} | null;
|
|
31
|
+
declare function buyerEndpoint(invoice: InvoiceDataInput): {
|
|
32
|
+
scheme: string;
|
|
33
|
+
value: string;
|
|
34
|
+
} | null;
|
|
35
|
+
/**
|
|
36
|
+
* Working model of the UAE Electronic Invoice Mandatory Fields (51 for tax invoices).
|
|
37
|
+
* Numbering follows commonly published field lists mapped to UBL 2.1 / PINT AE paths.
|
|
38
|
+
* Confirm against the latest MoF mandatory-fields PDF before production go-live.
|
|
39
|
+
*/
|
|
40
|
+
export declare const PINT_AE_FIELDS: PintFieldDef[];
|
|
41
|
+
export { EXPECTED_CUSTOMIZATION, EXPECTED_PROFILE, sellerEndpoint, buyerEndpoint, partyTaxId };
|