receipt/Invoice.js

/**
 * @module receipt
 */

import Item from "./Item.js"
import Receipt from "./Receipt.js"
import Share from "./Share.js"
import Sale from "./Sale.js"
import Charge from "./Charge.js"
import PrepaidPaymentReference from "./PrepaidPaymentReference.js"
import Detraction from "./Detraction.js"
import DocumentReference from "./DocumentReference.js"
import TagsGenerator from "./xml/TagsGenerator.js"

/**
 * The Invoice class represents an electronic invoice compliant with Sunat standards.
 * There are 2 options: factura or boleta de venta.
 *
 * @example
 * invoice = new Invoice(taxpayer, customer)
 * invoice.setIssueDate(new Date("13-Sep-2026 UTC"))
 * invoice.setCurrencyId("PEN")
 * invoice.setTypeCode(1)
 * invoice.setSerie("F000")
 * invoice.setNumeration(19931105)
 * invoice.setOrderReference("kar01")
 * invoice.setOrderReferenceText("Exchanging money")
 * invoice.addItem(product)
 * invoice.addDocumentReference(additionalDocumentReference)
 * invoice.toXml()
 * await invoice.finalize(subtle)
 */
class Invoice extends Sale {
	/**
	 * @param {Taxpayer} taxpayer - Taxpayer with all their details ready.
	 * @param {Person} customer - Generic person.
	 */
	constructor(taxpayer, customer) {
		super(taxpayer, customer, "Invoice")
	}

	#orderReference
	#orderReferenceText
	#dueDate

	#shares = Array()
	#sharesAmount = 0

	#detraction

	#allowanceCharges = Array()

	#prepaidBaseAmount = 0
	#prepaidTaxAmount = 0

	getShares() {
		return this.#shares
	}

	addShare(share) {
		this.#shares.push(share)

		this.#sharesAmount += share.getAmount()
	}

	recalcSharesAmount() {
		this.#sharesAmount = 0

		for (const share of this.#shares) {
			this.#sharesAmount += share.getAmount()
		}
	}

	getSharesAmount() {
		return this.#sharesAmount
	}

	recalcPrepaidAmount() {
		this.#prepaidBaseAmount = 0
		this.#prepaidTaxAmount = 0
		let totalAmount = 0

		for (const ref of this.prepaidPaymentReferences) {
			// Conditional just to avoid TS complain
			if (ref instanceof PrepaidPaymentReference) {
				this.#prepaidBaseAmount += ref.getBaseAmount() || 0
				this.#prepaidTaxAmount += ref.getTaxAmount() || 0
				totalAmount += ref.getAmount() || 0
			}
		}
		this.prepaidAmount = totalAmount // inherited setter from Sale
	}

	/**
	 * Recreate shares array without a share.
	 * @param index in array.
	 */
	removeShare(index) {
		this.#shares = [...this.#shares.slice(0, index), ...this.#shares.slice(index + 1)]
	}

	getDueDate() {
		return this.#dueDate
	}

	setDueDate(dd) {
		if (dd instanceof Date) {
			this.#dueDate = dd
		}
		else {
			this.#dueDate = null
		}
	}

	/**
	 * Set order reference identity.
	 * @param {string} reference - Order reference identity up to 20 characters without spaces.
	 */
	setOrderReference(reference) {
		if ((typeof reference === "string") && reference.length > 0) {
			if (/\s/g.test(reference)) {
				throw new Error("La referencia numérica no debe contener espacios.")
			}
			if (reference.length > 20) {
				throw new Error("La referencia numérica debe tener 20 caracteres como máximo.")
			}
			this.#orderReference = reference
		}
	}

	/**
	 * Empty reference identity and its description.
	 */
	clearOrderReference() {
		this.#orderReference = null
		this.#orderReferenceText = null
	}

	clearOrderReferenceText() {
		this.#orderReferenceText = null
	}

	getOrderReference() {
		return this.#orderReference
	}

	setOrderReferenceText(referenceText) {
		if (!this.#orderReference) {
			throw new Error("Asignar previamente la identidad de referencia.")
		}
		if ((typeof referenceText === "string" || referenceText instanceof String) && referenceText.length > 0) {
			this.#orderReferenceText = referenceText
		}
	}

	getOrderReferenceText() {
		return this.#orderReferenceText
	}

	setDiscount(discountAmount, fromBase = false, direct = false) {
		// Converting in number
		discountAmount = parseFloat(discountAmount)

		const index = this.#allowanceCharges.findIndex(c => c.getTypeCode() === "02")

		// Removing discount
		if (isNaN(discountAmount) || discountAmount <= 0) {
			if (index !== -1) {
				this.#allowanceCharges.splice(index, 1)
			}
			return
		}

		let discount = index !== -1 ? this.#allowanceCharges[index] : null

		if (!discount) {
			discount = new Charge(false)
			discount.setTypeCode("02")
			this.#allowanceCharges.push(discount)
		}

		if (fromBase) {
			// IGV percentage: 18 / 100: 0.18. Then sum is 1.18
			discountAmount *= 1.0 + (this.igvPercentage / 100)
		}

		if (direct) {
			discount.setAmount(discountAmount)
			return
		}

		discount.setFactor(discountAmount / this.taxInclusiveAmount, this.lineExtensionAmount)
	}

	addAllowanceCharge(charge) {
		this.#allowanceCharges.push(charge)
	}

	getAllowanceCharges() {
		return this.#allowanceCharges
	}

	get prepaidBaseAmount() {
		return this.#prepaidBaseAmount
	}

	get prepaidTaxAmount() {
		return this.#prepaidTaxAmount
	}

	get netTaxTotalAmount() {
		return this.taxTotalAmount - this.prepaidTaxAmount
	}

	get netIgvAmount() {
		return this.igvAmount - this.prepaidTaxAmount
	}

	getNetOperationAmount(index) {
		const amount = this.getOperationAmount(index)
		return (index === 0) ? amount - this.prepaidBaseAmount : amount
	}


	/**
	 * Adds a document reference to the invoice.
	 * If the document reference is a prepaid payment reference, it will also add a charge to the invoice.
	 * @param {DocumentReference} documentReference
	 * @override
	 */
	addDocumentReference(documentReference) {
		super.addDocumentReference(documentReference)

		if (documentReference instanceof PrepaidPaymentReference) {
			const charge = new Charge(false)
			charge.setTypeCode("04")
			charge.setAmount(documentReference.getBaseAmount())
			this.addAllowanceCharge(charge)

			this.#prepaidBaseAmount += documentReference.getBaseAmount() || 0
			this.#prepaidTaxAmount += documentReference.getTaxAmount() || 0
		}
	}

	/**
	 * After setting discount exists factor so recalc setting all amounts.
	 * NOTE: This currently only considers the main discount (code 02).
	 */
	recalcWithDiscountFactor() {
		const discount = this.getDiscount()
		if (discount == undefined) {
			return
		}

		//Recalc amounts
		const factorInverse = 1 - discount.factor
		this.igvAmount *= factorInverse
		this.iscAmount *= factorInverse
		this.taxTotalAmount *= factorInverse
		this.taxInclusiveAmount *= factorInverse
		this.lineExtensionAmount *= factorInverse
		this.setOperationAmount(0, this.getOperationAmount(0) * factorInverse)
		this.setOperationAmount(1, this.getOperationAmount(1) * factorInverse)
		this.setOperationAmount(2, this.getOperationAmount(2) * factorInverse)
		this.setOperationAmount(3, this.getOperationAmount(3) * factorInverse)
	}

	getDiscount() {
		return this.#allowanceCharges.find(c => c.getTypeCode() === "02")
	}

	/**
	 * Performs substraction payableAmount with detractionAmount.
	 */
	getShareableAmount(withFormat = false) {
		const shareableAmount = this.payableAmount - (this.#detraction?.getAmount() ?? 0.0)
		return withFormat ? shareableAmount.toFixed(2) : shareableAmount
	}

	/**
	 * Create detraction object with given percentage.
	 */
	setDetraction(detractionPercentage) {
		// Removing detraction
		if (isNaN(detractionPercentage) || detractionPercentage <= 0) {
			this.#detraction = undefined
			return
		}

		if (this.#detraction == undefined) {
			this.#detraction = new Detraction()
		}

		this.#detraction.setPercentage(detractionPercentage)
	}

	getDetraction() {
		return this.#detraction
	}

	/**
	 * Checks if detraction is applicable
	 * @returns {boolean} True if detraction is applicable, false otherwise.
	 */
	hasDetraction() {
		return this.#detraction != null && this.#detraction.getAmount() > 0.0
	}

	/**
	 * Check if everything can be processed.
	 * It does some calculations
	 */
	validate(validateNumeration) {
		super.validate(validateNumeration)

		switch (this.getTypeCode()) {
			case 1: // for "factura"
				if (this.getCustomer().getIdentification().getType() != 6) {
					throw new Error("El cliente debe tener RUC.")
				}
			case 3: // for "boleta"
				if (this.taxInclusiveAmount >= 700.0 && !(this.getCustomer()?.getIdentification())) {
					throw new Error("El cliente no puede ser omitido.")
				}
		}

		if (this.#sharesAmount) {
			if (this.hasDetraction()) {
				if (this.#sharesAmount.toFixed(2) != (this.payableAmount - this.#detraction.getAmount()).toFixed(2)) {
					throw new Error("La suma de las cuotas difiere del total por pagar menos detracción.")
				}
			}
			else if (this.#sharesAmount.toFixed(2) != this.payableAmount.toFixed(2)) {
				throw new Error("La suma de las cuotas difiere del total por pagar.")
			}
		}

		if (this.hasDetraction() && !this.#detraction.getCode()) {
			throw new Error("Falta código de detracción.")
		}
	}

	toXml() {
		// All parts of XML in array of strings
		const xmlParts = [
			TagsGenerator.generateUpperWrapper(this),
			TagsGenerator.generateUblExtensions(this),
			TagsGenerator.generateHeader(this),
			TagsGenerator.generateIdentity(this),
			TagsGenerator.generateDates(this),
			TagsGenerator.generateTypeCode(this),
			TagsGenerator.generateNotes(this),
			TagsGenerator.generateCurrencyCode(this),
			TagsGenerator.generateReference(this),
			TagsGenerator.generateDespatchDocumentReferences(this),
			TagsGenerator.generateAdditionalDocumentReferences(this),
			TagsGenerator.generateSignature(this),
			TagsGenerator.generateSupplier(this),
			TagsGenerator.generateCustomer(this),
			TagsGenerator.generatePaymentMeans(this),
			TagsGenerator.generatePaymentTerms(this),
			TagsGenerator.generatePrepaidPayment(this),
			TagsGenerator.generateCharge(this),
			TagsGenerator.generateTaxes(this),
			TagsGenerator.generateTotal(this),
			TagsGenerator.generateLines(this),
			TagsGenerator.generateLowerWrapper(this)
		]

		// Together in string inmediately
		this.xmlString = xmlParts.join("")
	}

	/**
	 * @return xmlDoc parsed
	 */
	fromXml(xmlContent) {
		// All about sale
		const xmlDoc = super.fromXml(xmlContent)

		{
			const orderReference = xmlDoc.getElementsByTagNameNS(Receipt.namespaces.cac, "OrderReference")[0];
			if (orderReference) {
				const orderReferenceId = orderReference.getElementsByTagNameNS(Receipt.namespaces.cbc, "ID")[0]?.textContent;

				if (orderReferenceId) {
					this.setOrderReference(orderReferenceId)
					const orderReferenceText = orderReference.getElementsByTagNameNS(Receipt.namespaces.cbc, "CustomerReference")[0]?.textContent;
					if (orderReferenceText) {
						this.setOrderReferenceText(orderReferenceText)
					}
				}
			}
		}

		// All items. Always present.
		const items = xmlDoc.getElementsByTagNameNS(Receipt.namespaces.cac, "InvoiceLine")
		for (let i = 0; i < items.length; i++) {
			const item = new Item(items[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "Description")[0]?.textContent || "")
			item.setQuantity(parseFloat(items[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "InvoicedQuantity")[0]?.textContent))
			item.setUnitValue(
				parseFloat(items[i].getElementsByTagNameNS(Receipt.namespaces.cac, "Price")[0]?.getElementsByTagNameNS(Receipt.namespaces.cbc, "PriceAmount")[0]?.textContent),
				false
			)
			item.setUnitCode(items[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "InvoicedQuantity")[0]?.getAttribute("unitCode") || "")

			// Warning because there are many tags with same name
			item.setIgvPercentage(parseInt(items[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "Percent")[0]?.textContent))
			item.setExemptionReasonCode(parseInt(items[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "TaxExemptionReasonCode")[0]?.textContent))

			item.calcMounts()
			this.addItem(item, true)
		}

		{ // check if there are shares
			const paymentTerms = xmlDoc.getElementsByTagNameNS(Receipt.namespaces.cac, "PaymentTerms")
			for (let i = 0; i < paymentTerms.length; ++i) {
				const idNode = paymentTerms[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "ID")[0]
				if (idNode && idNode.textContent == "FormaPago") {
					const paymentMeansIDNode = paymentTerms[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "PaymentMeansID")[0]
					if (paymentMeansIDNode && paymentMeansIDNode.textContent.startsWith("Cuota")) {
						const share = new Share()
						// capture date and amount
						share.setAmount(Number(paymentTerms[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "Amount")[0].textContent))
						const dateParts = paymentTerms[i].getElementsByTagNameNS(Receipt.namespaces.cbc, "PaymentDueDate")[0].textContent.split('-') // split in year, month and day
						share.setDueDate(new Date(parseInt(dateParts[0]), parseInt(dateParts[1]) - 1, parseInt(dateParts[2])))
						this.addShare(share)
					}
				}
			}
		}

		// Discount or recharge
		{
			// possible discount
			const allowanceCharge = xmlDoc.getElementsByTagNameNS(Receipt.namespaces.cac, "AllowanceCharge")[0]
			if (allowanceCharge) {
				const chargeIndicator = allowanceCharge.getElementsByTagNameNS(Receipt.namespaces.cbc, "ChargeIndicator")[0].textContent === "true"
				if (!chargeIndicator) { // it's discount
					this.setDiscount(Number(allowanceCharge.getElementsByTagNameNS(Receipt.namespaces.cbc, "Amount")[0].textContent), false, true)
				}
			}
		}

		{ // about detractions
			// possible deduction
			const paymentMean = xmlDoc.getElementsByTagNameNS(Receipt.namespaces.cac, "PaymentMeans")[0]
			if (paymentMean) { // exists deduction
				const id = paymentMean.getElementsByTagNameNS(Receipt.namespaces.cbc, "ID")[0]?.textContent
				if (id == "Detraccion") { // deduction exists
					// look for more about this deduction
					const paymentTerms = xmlDoc.getElementsByTagNameNS(Receipt.namespaces.cac, "PaymentTerms")
					for (const paymentTerm of paymentTerms) {
						if (paymentTerm.getElementsByTagNameNS(Receipt.namespaces.cbc, "ID")[0].textContent != "Detraccion") {
							continue
						}

						// we found it
						this.setDetraction(parseInt(paymentTerm.getElementsByTagNameNS(Receipt.namespaces.cbc, "PaymentPercent")[0].textContent))
						this.getDetraction().setCode(paymentTerm.getElementsByTagNameNS(Receipt.namespaces.cbc, "PaymentMeansID")[0].textContent)
						this.getDetraction().calcAmount(this.taxInclusiveAmount)
						break // then nothing else
					}

					// parsing bank account
					const payeeFinancialAccount = paymentMean.getElementsByTagNameNS(Receipt.namespaces.cac, "PayeeFinancialAccount")[0]
					// setting bank account
					this.getDetraction().setFinancialAccount(payeeFinancialAccount.getElementsByTagNameNS(Receipt.namespaces.cbc, "ID")[0]?.textContent)
				}
			}
		}

		return xmlDoc
	}
}

export default Invoice;