receipt/Receipt.js

/**
 * @module receipt
 */
 
import { Parse } from "xmldsigjs"
import XmlSigner from "./xml/XmlSigner.js"
import writtenNumber from "written-number"
import JSZip from "jszip"
import { DOMImplementation, DOMParser } from "@xmldom/xmldom"
import SoapEnvelope from "./xml/SoapEnvelope.js"
import Endpoint from "../webservice/Endpoint.js"

import DocumentReference from "./DocumentReference.js"
import Item from "./Item.js"
import Taxpayer from "../person/Taxpayer.js"
import Person from "../person/Person.js"

/**
 * The Receipt class serves as the abstract foundation for all document types in the library.
 * It implements the core lifecycle of an electronic tax document (CPE), including identity management, XML structure initialization, digital signing, ZIP packaging and transmission to SUNAT endpoints.
 */
class Receipt {
	static #xmllintInstance // validator for XML

	/** @type {string} */
	#xmlString = ""

	/** @type {string} */
	#name

	/** @type {Taxpayer} */
	#taxpayer

	/** @type {Person} */
	#customer

	/** @type {string} */
	#serie

	/** @type {number} */
	#numeration

	/** @type {number} */
	#typeCode

	/** @type {Date} */
	#issueDate

	/** @type {string} */
	#ublVersion = "2.1"

	/** @type {string} */
	#customizationId = "2.0"

	/** @type {string} */
	#hash

	/** @type {Array<Item>} */
	#items = Array()

	/**
	 * Used to hold many despatche references.
	 * @type {Array<DocumentReference>}
	 */
	#despatchDocumentReferences = Array()

	/**
	 * Used to hold many contract references as water, electricity, gas, etc.
	 * @type {Array<DocumentReference>}
	 */
	#contractDocumentReferences = Array()

	/**
	 * Used to hold many additional document references.
	 * @type {Array<DocumentReference>}
	 */
	#additionalDocumentReferences = Array()

	/**
	 * Used to hold many prepaid payment references.
	 * @type {Array<DocumentReference>}
	 */
	#prepaidPaymentReferences = Array()

	/**
	 * @param {string} ublVersion
	 */
	setUblVersion(ublVersion) {
		this.#ublVersion = ublVersion
	}

	/**
	 * @param {Taxpayer} taxpayer - The taxpayer.
	 * @param {Person} customer - The customer.
	 * @param {string} name - The name of the document.
	 */
	constructor(taxpayer, customer, name) {
		this.#taxpayer = taxpayer
		this.#customer = customer
		this.#name = name
	}

	/**
	 * @param {string} name - The name of the document.
	 */
	setName(name) {
		this.#name = name
	}

	/**
	 * Create new XML document.
	 */
	createXmlWrapper() {
		this.xmlDocument = (new DOMImplementation()).createDocument(`urn:oasis:names:specification:ubl:schema:xsd:${this.#name}-2`, this.#name)
		this.xmlDocument.documentElement.setAttribute("xmlns:cac", Receipt.namespaces.cac)
		this.xmlDocument.documentElement.setAttribute("xmlns:cbc", Receipt.namespaces.cbc)
		this.xmlDocument.documentElement.setAttribute("xmlns:ds", Receipt.namespaces.ds)
		this.xmlDocument.documentElement.setAttribute("xmlns:ext", Receipt.namespaces.ext)
	}

	get xmlString() {
		return this.#xmlString
	}

	set xmlString(xmlStr) {
		this.#xmlString = xmlStr
	}

	get name() {
		return this.#name
	}

	setCustomer(customer) {
		this.#customer = customer
	}

	/**
	 * Format serie and number: F000-00000001
	 * @param {boolean} withType - Include type code.
	 * @param {boolean} compacted - Compact format.
	 * @returns {string}
	 */
	getId(withType = false, compacted = false) {
		if (this.#serie == undefined || this.#numeration == undefined) {
			throw new Error("Serie o número incompletos.")
		}

		// Pre-format numeration as string
		const numStr = compacted ? String(this.#numeration) : String(this.#numeration).padStart(8, '0')

		if (withType) {
			const type = String(this.#typeCode).padStart(2, '0')
			return `${type}-${this.#serie}-${numStr}`
		}

		return `${this.#serie}-${numStr}`
	}

	/**
	 * Set serie and number at once.
	 * @param {string} serie - The serie of the document.
	 * @param {number} numeration - The numeration of the document.
	 */
	setId(serie, numeration) {
		this.setSerie(serie)
		this.setNumeration(numeration)
	}

	/**
	 * @param {string} serie - The serie of the document with 4 characters.
	 */
	setSerie(serie) {
		if (serie.length != 4) {
			throw new Error("Serie inconsistente")
		}
		this.#serie = serie
	}

	/**
	 * @returns {string} The serie of the document.
	 */
	getSerie() {
		return this.#serie
	}

	/**
	 * @param {number} number - The numeration of the document from 1 to 99999999.
	 */
	setNumeration(number) {
		if (number > 0x5F5E0FF) {
			throw new Error("Numeración supera el límite.")
		}
		this.#numeration = number
	}

	/**
	 * Unset serie and number to set an anonymous document.
	 */
	unsetId() {
		this.#serie = undefined
		this.#numeration = undefined
	}

	/**
	 * @returns {number} The numeration of the document.
	 */
	getNumeration() {
		return this.#numeration
	}

	/**
	 * @param {number} code - The type code of the document.
	 */
	setTypeCode(code) {
		this.#typeCode = code
	}

	/**
	 * @param {boolean} withFormat - Format the type code.
	 * @returns {number | string} The type code of the document.
	 */
	getTypeCode(withFormat = false) {
		if (withFormat) {
			return String(this.#typeCode).padStart(2, '0')
		}
		return this.#typeCode
	}

	/**
	 * @param {Date} date - The issue date of the document.
	 */
	setIssueDate(date) {
		if (date) {
			this.#issueDate = date
		}
		else {
			this.#issueDate = new Date()
		}
	}

	/**
	 * @returns {Date} The issue date of the document.
	 */
	getIssueDate() {
		return this.#issueDate
	}

	/**
	 * @returns {Taxpayer} The taxpayer set in constructor.
	 */
	getTaxpayer() {
		return this.#taxpayer
	}

	/**
	 * Replace the taxpayer.
	 * @param {Taxpayer} taxpayer - The new taxpayer.
	 */
	setTaxpayer(taxpayer) {
		this.#taxpayer = taxpayer
	}

	/**
	 * @returns {Person} The customer set in constructor.
	 */
	getCustomer() {
		return this.#customer
	}

	/**
	 * @returns {string} The UBL version.
	 */
	getUblVersion() {
		return this.#ublVersion
	}

	/**
	 * @returns {string} The customization ID.
	 */
	getCustomizationId() {
		return this.#customizationId
	}

	/**
	 * @param {string} hash - The cryptographic hash of the document.
	 */
	setHash(hash) {
		this.#hash = hash
	}

	/**
	 * @returns {string} The cryptographic hash of the document.
	 */
	getHash() {
		return this.#hash
	}

	/**
	 * @param {Item} item - The item to add.
	 */
	addItem(item) {
		this.#items.push(item)
	}

	/**
	 * Recreate items array without an item.
	 * @param {number} index - The index of the item to remove.
	 */
	removeItem(index) {
		this.#items = [...this.#items.slice(0, index), ...this.#items.slice(index + 1)]
	}

	/**
	 * Clear all items setting the array of items to empty.
	 */
	clearItems() {
		this.#items = [];
	}

	/**
	 * @returns {Array<Item>} The items of the document.
	 */
	get items() {
		return this.#items
	}

	/**
	 * Add document reference according internal type code.
	 * Stores documents in its own array by its reference type.
	 * @param {DocumentReference} documentReference
	 */
	addDocumentReference(documentReference) {
		switch (documentReference.getReferenceType()) {
			case DocumentReference.ADDITIONAL:
				this.#additionalDocumentReferences.push(documentReference);
				break;
			case DocumentReference.DESPATCH:
				this.#despatchDocumentReferences.push(documentReference);
				break;
			case DocumentReference.CONTRACT:
				this.#contractDocumentReferences.push(documentReference);
				break;
			case DocumentReference.PREPAID_PAYMENT:
				this.#prepaidPaymentReferences.push(documentReference);
				break;
			default:
				throw new Error("Invalid document reference type.");
		}
	}

	/**
	 * @returns {Array<DocumentReference>}
	 */
	get additionalDocumentReferences() {
		return this.#additionalDocumentReferences;
	}

	/**
	 * @returns {Array<DocumentReference>}
	 */
	get despatchDocumentReferences() {
		return this.#despatchDocumentReferences;
	}

	/**
	 * @returns {Array<DocumentReference>}
	 */
	get contractDocumentReferences() {
		return this.#contractDocumentReferences;
	}

	/**
	 * @returns {Array<DocumentReference>}
	 */
	get prepaidPaymentReferences() {
		return this.#prepaidPaymentReferences;
	}

	/**
	 * Finalize the document signing it.
	 * @param {SubtleCrypto} cryptoSubtle - The crypto subtle to use for signing.
	 * @param {string} canonMethod - The canonicalization method to use for signing.
	 */
	async finalize(cryptoSubtle, canonMethod = "c14n") {
		try {
			const xmlDocForSigning = Parse(this.#xmlString) // Construction of XML document

			// Getting signature using extarnal signer
			const signatureNode = await XmlSigner.getSignedNode(
				cryptoSubtle,
				xmlDocForSigning,
				this.#taxpayer,
				canonMethod
			)

			const serializer = new XMLSerializer()
			const signatureString = serializer.serializeToString(signatureNode)

			// Just insertion of signature literal
			const targetTag = "<ext:ExtensionContent></ext:ExtensionContent>"
			const signedTag = `<ext:ExtensionContent>${signatureString}</ext:ExtensionContent>`

			// Halt everything
			if (!this.#xmlString.includes(targetTag)) {
				throw new Error("No se encontró contenedor <ext:ExtensionContent></ext:ExtensionContent>.")
			}

			this.#xmlString = this.#xmlString.replace(targetTag, signedTag)

			return true
		} catch (e) {
			console.error("Error en la finalización/firma:", e);
			return false
		}
	}

	/**
	 * @deprecated Use finalize() instead for better performance and architecture.
	 * @param {SubtleCrypto} cryptoSubtle - The crypto subtle to use for signing.
	 * @param {string} canonMethod - The canonicalization method to use for signing.
	 */
	async sign(cryptoSubtle, canonMethod = "c14n") {
		// Just calling the real method
		return await this.finalize(cryptoSubtle, canonMethod)
	}

	/**
	 * Create a ZIP file containing XML. Default type is base64.
	 * @param {"base64" | "string" | "text" | "binarystring" | "array" | "uint8array" | "arraybuffer" | "blob" | "nodebuffer"} type - according JSZip API.
	 * @param {string} [xmlString] - that is raw XML.
	 * @return {Promise<any>} A ZIP file containing XML.
	 */
	async createZip(type = "base64", xmlString) {
		const zip = new JSZip()
		// if there is xmlString then use it else the XML string created recently
		zip.file(`${this.#taxpayer.getIdentification().getNumber()}-${this.getId(true)}.xml`, xmlString ?? this.#xmlString)

		return zip.generateAsync({ type: type }).then(zipb64 => {
			return zipb64
		})
	}

	/**
	 * Handle answer that is in a ZIP file.
	 * @param {string} zipStream - The zip file.
	 * @param {boolean} isBase64 - Whether the zip file is base64.
	 * @param {boolean} compacted - Whether the document is compacted.
	 * @returns {Promise<Array<number|string>>} Code and description in the proof of the document.
	 *
	 * @example <caption>Handling CDR</caption>
	 * const [cdrCode, cdrDescription] = await invoice.handleProof(serverZipStream)
	 * if (cdrCode === 0) {
	 *   console.log("OK", cdrDescription)
	 * }
	 * else {
	 *   console.log(cdrDescription)
	 * }
	 */
	async handleProof(zipStream, isBase64 = true, compacted = false) {
		const zip = new JSZip()

		return zip.loadAsync(zipStream, { base64: isBase64 }).then(async (zip) => {
			const xmlFiles = zip.file(/\.xml$/i);
			if (xmlFiles.length > 0) {
				return xmlFiles[0].async("string").then(async (data) => {
					const xmlDoc = new DOMParser().parseFromString(data, "application/xml")

					// Go directly to node <cbc:ResponseCode>
					const codes = xmlDoc.getElementsByTagNameNS("urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", "ResponseCode")

					if (codes.length > 0) {
						const description = xmlDoc.getElementsByTagNameNS("urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", "Description")[0]?.textContent ?? "Sin descripción"
						return [parseInt(codes[0].textContent), description] // 0 when everthing is really OK
					}
					else { // error
						return [-1, "No se encontró respuesta."] // we have problems
					}
				});
			}
			return [-1, "No se encontró archivo XML en la constancia."];
		})
	}

	/**
	 * Declare the document sending it to server.
	 * @param {string} zipStream - The zip file.
	 * @returns {Promise<string>} The proof of the document.
	 */
	async declare(zipStream) {
		const soapXmlDocument = SoapEnvelope.generateSendBill(this, this.#taxpayer, zipStream)

		const responseText = await Endpoint.fetch(Endpoint.INDEX_INVOICE, soapXmlDocument.toString())

		const xmlDoc = new DOMParser().parseFromString(responseText, "text/xml")

		// check if fault node exists
		const faultNode = xmlDoc.getElementsByTagName("soap-env:Fault")[0]

		if (faultNode) {
			throw new Error(faultNode.getElementsByTagName("faultstring")[0].textContent)
		}

		// Maybe it is a successful answer
		const responseNode = xmlDoc.getElementsByTagName("br:sendBillResponse")[0]

		if (responseNode) {
			const applicationResponse = responseNode.getElementsByTagName("applicationResponse")[0].textContent
			return applicationResponse
		}
		else {
			throw new Error("Respuesta inesperada.")
		}
	}

	/**
	 * Convert the document to XML string.
	 * It includes signature node if the document was finalized.
	 * @returns {string} The XML string.
	 */
	toString() {
		return this.#xmlString
	}

	/**
	 * Validate own XML against XSD.
	 * @param {string} mainXsdContent - Main XSD content.
	 * @param {Array<string>} importedXsdContents - Array of imported XSD contents.
	 * @returns {Promise<boolean>} - false if there are errors.
	 */
	async validateXmlWithXsd(mainXsdContent, importedXsdContents) {
		if (!Receipt.#xmllintInstance) {
			const { validateXML } = await import("xmllint-wasm")
			Receipt.#xmllintInstance = validateXML
		}

		const result = await Receipt.#xmllintInstance({
			xml: [{
				fileName: this.getId() + ".xml",
				contents: this.#xmlString
			}],
			schema: mainXsdContent,
			preload: importedXsdContents
		})
			.catch(function (e) {
				console.error(e)
				return false
			})

		if (result.valid) {
			return true
		}
		else {
			console.error('Errores de validación XML:', result.errors)
			return false
		}
	}

	/**
	 * Parse receipt header.
	 * @param {string} xmlContent - The XML content of the document.
	 * @return xmlDoc parsed.
	 */
	fromXml(xmlContent) {
		const xmlDoc = new DOMParser().parseFromString(xmlContent, "application/xml")

		const id = xmlDoc.getElementsByTagNameNS(Receipt.namespaces.cbc, "ID")[0]?.textContent // Everybody has identity
		const [serie, numeration] = id.split('-')
		this.setSerie(serie)
		this.setNumeration(parseInt(numeration))

		this.setHash(xmlDoc.getElementsByTagNameNS(Receipt.namespaces.ds, "DigestValue")[0].textContent)

		return xmlDoc
	}

	validate(validateNumeration = true) {
		if (!this.#serie || this.#serie.length != 4) {
			throw new Error("Serie inconsistente.")
		}

		if (validateNumeration && (!this.#numeration || this.#numeration <= 0 || this.#numeration > 99999999)) {
			throw new Error("Numeración fuera de rango.")
		}

		if (!(this.#issueDate instanceof Date)) {
			throw new Error("No hay fecha de emisión.")
		}
	}

	static namespaces = Object.freeze(
		{
			cac: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
			cbc: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
			ds: "http://www.w3.org/2000/09/xmldsig#",
			ext: "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
			qdt: "urn:oasis:names:specification:ubl:schema:xsd:QualifiedDatatypes-2",
			udt: "urn:un:unece:uncefact:data:specification:UnqualifiedDataTypesSchemaModule:2",
			xsi: "http://www.w3.org/2001/XMLSchema-instance",
			xmlns: "http://www.w3.org/1999/xhtml",
			ccts: "urn:un:unece:uncefact:documentation:2"
		}
	)

	/**
	 * Helper to remove CDATA tags from a string.
	 * @param {string} cdata - The string to remove CDATA tags from.
	 * @returns {string} The string without CDATA tags.
	 */
	static removeCdataTag(cdata) {
		return cdata.trim().replace(/^(\/\/\s*)?<!\[CDATA\[|(\/\/\s*)?\]\]>$/g, '').trim()
	}

	/**
	 * Helper to print a date.
	 * https://stackoverflow.com/a/41480350
	 * @return date as string in format yyyy-mm-dd.
	 */
	static displayDate(date) {
		const day = date.getDate()
		const month = date.getMonth() + 1
		const year = date.getFullYear()

		return year + "-" + ((month < 10 ? "0" : "") + month) + "-" + ((day < 10 ? "0" : "") + day)
	}

	/**
	 * Helper to print a time of a date.
	 * @param {Date} date - The date to handle time.
	 * @return time as string in format HH:MM:SS
	 */
	static displayTime(date) {
		const hour = date.getHours()
		const min = date.getMinutes()
		const sec = date.getSeconds()

		return ((hour < 10 ? "0" : "") + hour) + ":" + ((min < 10 ? "0" : "") + min) + ":" + ((sec < 10 ? "0" : "") + sec)
	}

	/**
	 * Helper to convert an amount to words.
	 * @param {number} amount - The amount to convert to words.
	 * @param {string} junctor - The junctor to use.
	 * @param {string} tail - The tail to use.
	 * @param {number} decimals - The number of decimals to use.
	 * @returns {string} The amount in words.
	 */
	static amountToWords(amount, junctor, tail, decimals = 2) {
		if (amount == 0.0) {
			return `CERO ${junctor} 00/100 ${tail}`
		}

		return writtenNumber(amount | 0 /*truncate positive floating point*/, { lang: "es" }) + ` ${junctor} ${amount.toFixed(decimals).split('.')[1]}/100 ${tail}`
	}
}

export default Receipt;