anaf-ts-sdk
Version:
Complete TypeScript SDK for Romanian ANAF API -E-Factura, Company checks
450 lines (449 loc) • 15.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildInvoiceXml = buildInvoiceXml;
exports.buildUblInvoiceXml = buildUblInvoiceXml;
const xmlbuilder2_1 = require("xmlbuilder2");
const constants_1 = require("../constants");
const dateUtils_1 = require("../utils/dateUtils");
const errors_1 = require("../errors");
/**
* Build party XML structure for supplier or customer
* @param root XML root element
* @param tagName Tag name (cac:AccountingSupplierParty or cac:AccountingCustomerParty)
* @param party Party information
*/
function buildPartyXml(root, tagName, party) {
const partyElement = root.ele(tagName).ele('cac:Party');
const address = party.address;
// Postal Address
partyElement
.ele('cac:PostalAddress')
.ele('cbc:StreetName')
.txt(address.street || '')
.up()
.ele('cbc:CityName')
.txt(address.city || '')
.up()
.ele('cbc:PostalZone')
.txt(address.postalZone || '')
.up()
.ele('cbc:CountrySubentity')
.txt(address.county || '')
.up()
.ele('cac:Country')
.ele('cbc:IdentificationCode')
.txt(address.countryCode || constants_1.DEFAULT_COUNTRY_CODE)
.up()
.up()
.up();
// Party Legal Entity
partyElement
.ele('cac:PartyLegalEntity')
.ele('cbc:RegistrationName')
.txt(party.registrationName)
.up()
.ele('cbc:CompanyID')
.txt(party.companyId)
.up()
.up();
// Party Tax Scheme (if VAT number provided)
if (party.vatNumber) {
partyElement
.ele('cac:PartyTaxScheme')
.ele('cbc:CompanyID')
.txt(party.vatNumber)
.up()
.ele('cac:TaxScheme')
.ele('cbc:ID')
.txt('VAT')
.up()
.up()
.up();
}
}
/**
* Calculate line extension amount
* @param line Invoice line
* @returns Rounded line extension amount
*/
function calculateLineExtension(line) {
// Round unit price first to ensure consistent calculations
const roundedUnitPrice = parseFloat(line.unitPrice.toFixed(2));
return parseFloat((line.quantity * roundedUnitPrice).toFixed(2));
}
/**
* Group lines by tax percentage for proper tax calculation
* @param lines Invoice lines
* @param isSupplierVatPayer Whether supplier is VAT registered
* @returns Tax groups array
*/
function groupLinesByTax(lines, isSupplierVatPayer) {
const taxGroups = new Map();
lines.forEach((line) => {
const taxPercent = line.taxPercent || 0;
const lineExtension = calculateLineExtension(line);
const taxAmount = parseFloat((lineExtension * (taxPercent / 100)).toFixed(2));
// Determine tax category ID
let categoryId;
let exemptionReasonCode;
if (!isSupplierVatPayer) {
categoryId = 'O'; // Not subject to VAT
exemptionReasonCode = 'VATEX-EU-O';
}
else if (taxPercent > 0) {
categoryId = 'S'; // Standard rated
}
else {
categoryId = 'Z'; // Zero rated
}
const key = `${categoryId}-${taxPercent}`;
if (taxGroups.has(key)) {
const group = taxGroups.get(key);
group.taxableAmount = parseFloat((group.taxableAmount + lineExtension).toFixed(2));
group.taxAmount = parseFloat((group.taxAmount + taxAmount).toFixed(2));
}
else {
taxGroups.set(key, {
categoryId,
percent: taxPercent,
taxableAmount: lineExtension,
taxAmount,
exemptionReasonCode,
});
}
});
return Array.from(taxGroups.values());
}
/**
* Validate invoice input data
* @param input Invoice input data
* @throws {AnafValidationError} If validation fails
*/
function validateInvoiceInput(input) {
var _a;
if (!input) {
throw new errors_1.AnafValidationError('Invoice input data is required');
}
if (!((_a = input.invoiceNumber) === null || _a === void 0 ? void 0 : _a.trim())) {
throw new errors_1.AnafValidationError('Invoice number is required');
}
if (!input.issueDate) {
throw new errors_1.AnafValidationError('Issue date is required');
}
// Validate supplier
if (!input.supplier) {
throw new errors_1.AnafValidationError('Supplier information is required');
}
validateParty(input.supplier, 'Supplier');
// Validate customer
if (!input.customer) {
throw new errors_1.AnafValidationError('Customer information is required');
}
validateParty(input.customer, 'Customer');
// Validate lines - allow empty lines for testing purposes
if (!input.lines) {
throw new errors_1.AnafValidationError('Invoice lines array is required');
}
// Only validate individual lines if there are any
if (input.lines.length > 0) {
input.lines.forEach((line, index) => validateLine(line, index));
}
}
/**
* Validate party information
* @param party Party to validate
* @param role Party role (for error messages)
*/
function validateParty(party, role) {
var _a, _b;
if (!((_a = party.registrationName) === null || _a === void 0 ? void 0 : _a.trim())) {
throw new errors_1.AnafValidationError(`${role} registration name is required`);
}
if (!((_b = party.companyId) === null || _b === void 0 ? void 0 : _b.trim())) {
throw new errors_1.AnafValidationError(`${role} company ID is required`);
}
if (!party.address) {
throw new errors_1.AnafValidationError(`${role} address is required`);
}
validateAddress(party.address, role);
}
/**
* Validate address information
* @param address Address to validate
* @param role Role for error messages
*/
function validateAddress(address, role) {
var _a, _b, _c;
if (!((_a = address.street) === null || _a === void 0 ? void 0 : _a.trim())) {
throw new errors_1.AnafValidationError(`${role} street address is required`);
}
if (!((_b = address.city) === null || _b === void 0 ? void 0 : _b.trim())) {
throw new errors_1.AnafValidationError(`${role} city is required`);
}
if (!((_c = address.postalZone) === null || _c === void 0 ? void 0 : _c.trim())) {
throw new errors_1.AnafValidationError(`${role} postal zone is required`);
}
}
/**
* Validate invoice line
* @param line Line to validate
* @param index Line index for error messages
*/
function validateLine(line, index) {
var _a;
if (!((_a = line.description) === null || _a === void 0 ? void 0 : _a.trim())) {
throw new errors_1.AnafValidationError(`Line ${index + 1}: Description is required`);
}
if (typeof line.quantity !== 'number' || line.quantity <= 0) {
throw new errors_1.AnafValidationError(`Line ${index + 1}: Quantity must be a positive number`);
}
if (typeof line.unitPrice !== 'number' || line.unitPrice < 0) {
throw new errors_1.AnafValidationError(`Line ${index + 1}: Unit price must be a non-negative number`);
}
if (line.taxPercent !== undefined) {
if (typeof line.taxPercent !== 'number' || line.taxPercent < 0 || line.taxPercent > 100) {
throw new errors_1.AnafValidationError(`Line ${index + 1}: Tax percent must be between 0 and 100`);
}
}
}
/**
* Build comprehensive UBL 2.1 Invoice XML
*
* This function creates a complete UBL 2.1 XML invoice that complies with
* the Romanian CIUS-RO specification for ANAF e-Factura.
*
* @param input Invoice data
* @returns UBL XML string
* @throws {AnafValidationError} If input data is invalid
*
* @example
* ```typescript
* const xml = buildInvoiceXml({
* invoiceNumber: 'INV-2024-001',
* issueDate: new Date(),
* supplier: {
* registrationName: 'Furnizor SRL',
* companyId: 'RO12345678',
* vatNumber: 'RO12345678',
* address: {
* street: 'Str. Exemplu 1',
* city: 'București',
* postalZone: '010101'
* }
* },
* customer: {
* registrationName: 'Client SRL',
* companyId: 'RO87654321',
* address: {
* street: 'Str. Client 2',
* city: 'Cluj-Napoca',
* postalZone: '400001'
* }
* },
* lines: [
* {
* description: 'Produs/Serviciu',
* quantity: 1,
* unitPrice: 100,
* taxPercent: 19
* }
* ],
* isSupplierVatPayer: true
* });
* ```
*/
function buildInvoiceXml(input) {
var _a;
// Validate input
validateInvoiceInput(input);
// Set defaults
const currency = input.currency || constants_1.DEFAULT_CURRENCY;
const isSupplierVatPayer = (_a = input.isSupplierVatPayer) !== null && _a !== void 0 ? _a : !!input.supplier.vatNumber;
// Format dates
const issueDate = (0, dateUtils_1.formatDateForAnaf)(input.issueDate);
const dueDate = input.dueDate ? (0, dateUtils_1.formatDateForAnaf)(input.dueDate) : issueDate;
// Calculate tax groups and totals
const taxGroups = input.lines.length > 0 ? groupLinesByTax(input.lines, isSupplierVatPayer) : [];
const totalTaxableAmount = taxGroups.reduce((sum, group) => sum + group.taxableAmount, 0);
const totalTaxAmount = taxGroups.reduce((sum, group) => sum + group.taxAmount, 0);
const grandTotal = parseFloat((totalTaxableAmount + totalTaxAmount).toFixed(2));
// Create XML document
const root = (0, xmlbuilder2_1.create)({ version: '1.0', encoding: 'UTF-8' }).ele('Invoice', {
xmlns: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
'xmlns:cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
'xmlns:cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
});
// Invoice header
root
.ele('cbc:CustomizationID')
.txt(constants_1.UBL_CUSTOMIZATION_ID)
.up()
.ele('cbc:ID')
.txt(input.invoiceNumber)
.up()
.ele('cbc:IssueDate')
.txt(issueDate)
.up()
.ele('cbc:DueDate')
.txt(dueDate)
.up()
.ele('cbc:InvoiceTypeCode')
.txt(constants_1.INVOICE_TYPE_CODE)
.up()
.ele('cbc:DocumentCurrencyCode')
.txt(currency)
.up();
// Parties
buildPartyXml(root, 'cac:AccountingSupplierParty', input.supplier);
buildPartyXml(root, 'cac:AccountingCustomerParty', input.customer);
// Payment means (if IBAN provided)
if (input.paymentIban) {
root
.ele('cac:PaymentMeans')
.ele('cbc:PaymentMeansCode')
.txt('30')
.up() // Credit transfer
.ele('cac:PayeeFinancialAccount')
.ele('cbc:ID')
.txt(input.paymentIban)
.up()
.up()
.up();
}
// Tax total with subtotals for each tax group
const taxTotalElement = root
.ele('cac:TaxTotal')
.ele('cbc:TaxAmount', { currencyID: currency })
.txt(totalTaxAmount.toFixed(2))
.up();
// Add tax subtotal for each tax group (if any)
if (taxGroups.length > 0) {
taxGroups.forEach((group) => {
const subtotalElement = taxTotalElement
.ele('cac:TaxSubtotal')
.ele('cbc:TaxableAmount', { currencyID: currency })
.txt(group.taxableAmount.toFixed(2))
.up()
.ele('cbc:TaxAmount', { currencyID: currency })
.txt(group.taxAmount.toFixed(2))
.up()
.ele('cac:TaxCategory')
.ele('cbc:ID')
.txt(group.categoryId)
.up()
.ele('cbc:Percent')
.txt(group.percent.toFixed(2))
.up();
// Add exemption reason for category O
if (group.exemptionReasonCode) {
subtotalElement.ele('cbc:TaxExemptionReasonCode').txt(group.exemptionReasonCode).up();
}
subtotalElement.ele('cac:TaxScheme').ele('cbc:ID').txt('VAT').up().up().up(); // End TaxCategory and TaxSubtotal
});
}
else {
// Add a default tax subtotal for empty invoices
taxTotalElement
.ele('cac:TaxSubtotal')
.ele('cbc:TaxableAmount', { currencyID: currency })
.txt('0.00')
.up()
.ele('cbc:TaxAmount', { currencyID: currency })
.txt('0.00')
.up()
.ele('cac:TaxCategory')
.ele('cbc:ID')
.txt('S')
.up()
.ele('cbc:Percent')
.txt('0.00')
.up()
.ele('cac:TaxScheme')
.ele('cbc:ID')
.txt('VAT')
.up()
.up()
.up()
.up();
}
// Legal monetary total
root
.ele('cac:LegalMonetaryTotal')
.ele('cbc:LineExtensionAmount', { currencyID: currency })
.txt(totalTaxableAmount.toFixed(2))
.up()
.ele('cbc:TaxExclusiveAmount', { currencyID: currency })
.txt(totalTaxableAmount.toFixed(2))
.up()
.ele('cbc:TaxInclusiveAmount', { currencyID: currency })
.txt(grandTotal.toFixed(2))
.up()
.ele('cbc:PayableAmount', { currencyID: currency })
.txt(grandTotal.toFixed(2))
.up()
.up();
// Invoice lines
input.lines.forEach((line, index) => {
var _a;
const lineId = ((_a = line.id) === null || _a === void 0 ? void 0 : _a.toString()) || (index + 1).toString();
const lineExtension = calculateLineExtension(line);
const taxPercent = line.taxPercent || 0;
const roundedUnitPrice = parseFloat(line.unitPrice.toFixed(2));
// Determine tax category for this line
let lineTaxCategory;
if (!isSupplierVatPayer) {
lineTaxCategory = 'O';
}
else if (taxPercent > 0) {
lineTaxCategory = 'S';
}
else {
lineTaxCategory = 'Z';
}
const lineElement = root
.ele('cac:InvoiceLine')
.ele('cbc:ID')
.txt(lineId)
.up()
.ele('cbc:InvoicedQuantity', { unitCode: line.unitCode || constants_1.DEFAULT_UNIT_CODE })
.txt(line.quantity.toString())
.up()
.ele('cbc:LineExtensionAmount', { currencyID: currency })
.txt(lineExtension.toFixed(2))
.up()
.ele('cac:Item')
.ele('cbc:Description')
.txt(line.description)
.up()
.ele('cac:ClassifiedTaxCategory')
.ele('cbc:ID')
.txt(lineTaxCategory)
.up()
.ele('cbc:Percent')
.txt(taxPercent.toFixed(2))
.up()
.ele('cac:TaxScheme')
.ele('cbc:ID')
.txt('VAT')
.up()
.up()
.up()
.up()
.ele('cac:Price')
.ele('cbc:PriceAmount', { currencyID: currency })
.txt(roundedUnitPrice.toFixed(2))
.up()
.up()
.up();
});
return root.end({ prettyPrint: true });
}
/**
* Legacy compatibility function
* @param input UBL invoice input (legacy format)
* @returns UBL XML string
* @deprecated Use buildInvoiceXml instead
*/
function buildUblInvoiceXml(input) {
return buildInvoiceXml(input);
}