Facturas de cliente -> cambio en impuestos

This commit is contained in:
David Arranz 2025-11-23 22:44:31 +01:00
parent 7d895d147a
commit 5a6a81738f
40 changed files with 524 additions and 616 deletions

View File

@ -1,4 +1,4 @@
import { TaxCatalogProvider } from "@erp/core"; import type { TaxCatalogProvider } from "@erp/core";
import { Percentage, ValueObject } from "@repo/rdx-ddd"; import { Percentage, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4"; import { z } from "zod/v4";

View File

@ -42,7 +42,7 @@ export class IssuedInvoiceItemsFullPresenter extends Presenter {
discount_amount: allAmounts.discountAmount.toObjectString(), discount_amount: allAmounts.discountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(), taxable_amount: allAmounts.taxableAmount.toObjectString(),
tax_codes: invoiceItem.taxes.getCodesToString().split(","), tax_codes: invoiceItem.taxes.getCodesArray(),
taxes_amount: allAmounts.taxesAmount.toObjectString(), taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(), total_amount: allAmounts.totalAmount.toObjectString(),

View File

@ -40,7 +40,7 @@ export class ProformaItemsFullPresenter extends Presenter {
discount_amount: allAmounts.discountAmount.toObjectString(), discount_amount: allAmounts.discountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(), taxable_amount: allAmounts.taxableAmount.toObjectString(),
tax_codes: proformaItem.taxes.getCodesToString().split(","), tax_codes: proformaItem.taxes.getCodesArray(),
taxes_amount: allAmounts.taxesAmount.toObjectString(), taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(), total_amount: allAmounts.totalAmount.toObjectString(),

View File

@ -39,7 +39,11 @@ export class IssuedInvoiceListPresenter extends Presenter {
language_code: invoice.languageCode.code, language_code: invoice.languageCode.code,
currency_code: invoice.currencyCode.code, currency_code: invoice.currencyCode.code,
taxes: invoice.taxes, taxes: invoice.taxes.map((t) => ({
tax_code: t.tax_code,
taxable_amount: t.taxable_amount.toObjectString(),
taxes_amount: t.taxes_amount.toObjectString(),
})),
subtotal_amount: invoice.subtotalAmount.toObjectString(), subtotal_amount: invoice.subtotalAmount.toObjectString(),
discount_percentage: invoice.discountPercentage.toObjectString(), discount_percentage: invoice.discountPercentage.toObjectString(),

View File

@ -30,7 +30,11 @@ export class ProformaListPresenter extends Presenter {
language_code: proforma.languageCode.code, language_code: proforma.languageCode.code,
currency_code: proforma.currencyCode.code, currency_code: proforma.currencyCode.code,
taxes: proforma.taxes, taxes: proforma.taxes.map((t) => ({
tax_code: t.tax_code,
taxable_amount: t.taxable_amount.toObjectString(),
taxes_amount: t.taxes_amount.toObjectString(),
})),
subtotal_amount: proforma.subtotalAmount.toObjectString(), subtotal_amount: proforma.subtotalAmount.toObjectString(),
discount_percentage: proforma.discountPercentage.toObjectString(), discount_percentage: proforma.discountPercentage.toObjectString(),

View File

@ -18,9 +18,14 @@ export class IssuedInvoiceTaxesReportPresenter extends Presenter<IssuedInvoiceTa
const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code); const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code);
const taxName = taxCatalogItem.match(
(item) => item.name,
() => taxItem.tax_code // fallback
);
return { return {
tax_code: taxItem.tax_code, tax_code: taxItem.tax_code,
tax_name: taxCatalogItem.unwrap().name, tax_name: taxName,
taxable_amount: MoneyDTOHelper.format(taxItem.taxable_amount, this._locale, moneyOptions), taxable_amount: MoneyDTOHelper.format(taxItem.taxable_amount, this._locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(taxItem.taxes_amount, this._locale, moneyOptions), taxes_amount: MoneyDTOHelper.format(taxItem.taxes_amount, this._locale, moneyOptions),
}; };

View File

@ -18,9 +18,14 @@ export class ProformaTaxesReportPresenter extends Presenter<ProformaTaxesDTO, un
const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code); const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code);
const taxName = taxCatalogItem.match(
(item) => item.name,
() => taxItem.tax_code // fallback
);
return { return {
tax_code: taxItem.tax_code, tax_code: taxItem.tax_code,
tax_name: taxCatalogItem.unwrap().name, tax_name: taxName,
taxable_amount: MoneyDTOHelper.format(taxItem.taxable_amount, this._locale, moneyOptions), taxable_amount: MoneyDTOHelper.format(taxItem.taxable_amount, this._locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(taxItem.taxes_amount, this._locale, moneyOptions), taxes_amount: MoneyDTOHelper.format(taxItem.taxes_amount, this._locale, moneyOptions),
}; };

View File

@ -23,9 +23,6 @@ export class IssuedInvoiceReportHTMLPresenter extends TemplatePresenter {
const invoiceDTO = dtoPresenter.toOutput(invoice); const invoiceDTO = dtoPresenter.toOutput(invoice);
const prettyDTO = prePresenter.toOutput(invoiceDTO); const prettyDTO = prePresenter.toOutput(invoiceDTO);
console.log(prettyDTO.verifactu);
// Obtener y compilar la plantilla HTML // Obtener y compilar la plantilla HTML
const template = this.templateResolver.compileTemplate( const template = this.templateResolver.compileTemplate(
"customer-invoices", "customer-invoices",

View File

@ -23,8 +23,6 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
format: "HTML", format: "HTML",
}) as IssuedInvoiceReportHTMLPresenter; }) as IssuedInvoiceReportHTMLPresenter;
console.log(invoice);
const htmlData = htmlPresenter.toOutput(invoice, params); const htmlData = htmlPresenter.toOutput(invoice, params);
// Generar el PDF con Puppeteer // Generar el PDF con Puppeteer

View File

@ -236,6 +236,26 @@ export class CustomerInvoice
return this.paymentMethod.isSome(); return this.paymentMethod.isSome();
} }
/* CALCULOS INTERNOS */
private _getSubtotalAmount(): InvoiceAmount {
const itemsSubtotal = this.items.getSubtotalAmount().convertScale(2);
return InvoiceAmount.create({
value: itemsSubtotal.value,
currency_code: this.currencyCode.code,
}).data;
}
private _getItemsDiscountAmount(): InvoiceAmount {
const itemsDiscountAmount = this.items.getDiscountAmount().convertScale(2);
return InvoiceAmount.create({
value: itemsDiscountAmount.value,
currency_code: this.currencyCode.code,
}).data;
}
private _getHeaderDiscountAmount( private _getHeaderDiscountAmount(
subtotalAmount: InvoiceAmount, subtotalAmount: InvoiceAmount,
itemsDiscountAmount: InvoiceAmount itemsDiscountAmount: InvoiceAmount
@ -251,79 +271,43 @@ export class CustomerInvoice
return subtotalAmount.subtract(itemsDiscountAmount).subtract(headerDiscountAmount); return subtotalAmount.subtract(itemsDiscountAmount).subtract(headerDiscountAmount);
} }
private _getTaxesAmount(taxableAmount: InvoiceAmount): InvoiceAmount { // total impuestos suma(iva + rec + retenciones)
let amount = InvoiceAmount.zero(this.currencyCode.code); private _getTaxesAmount(): InvoiceAmount {
const { iva, rec, retention } = this.items.getAggregatedTaxesByType();
const itemTaxes = this.items.getTaxesAmountByTaxes(); const total = iva.add(rec).add(retention);
return InvoiceAmount.create({
for (const taxItem of itemTaxes) { value: total.convertScale(2).value,
amount = amount.add( currency_code: this.currencyCode.code,
InvoiceAmount.create({ }).data;
value: taxItem.taxesAmount.convertScale(2).value,
currency_code: this.currencyCode.code,
}).data
);
}
return amount;
} }
private _getTotalAmount(taxableAmount: InvoiceAmount, taxesAmount: InvoiceAmount): InvoiceAmount { private _getTotalAmount(taxableAmount: InvoiceAmount, taxesAmount: InvoiceAmount): InvoiceAmount {
return taxableAmount.add(taxesAmount); return taxableAmount.add(taxesAmount);
} }
public _getSubtotalAmount(): InvoiceAmount { /** Totales expuestos */
const itemsSubtotal = this.items.getSubtotalAmount().convertScale(2);
return InvoiceAmount.create({
value: itemsSubtotal.value,
currency_code: this.currencyCode.code,
}).data as InvoiceAmount;
}
public _getItemsDiscountAmount(): InvoiceAmount {
const itemsDiscountAmount = this.items.getDiscountAmount().convertScale(2);
return InvoiceAmount.create({
value: itemsDiscountAmount.value,
currency_code: this.currencyCode.code,
}).data as InvoiceAmount;
}
/*public getHeaderDiscountAmount(): InvoiceAmount {
return this._getHeaderDiscountAmount(this.getSubtotalAmount());
}
public getTaxableAmount(): InvoiceAmount {
return this._getTaxableAmount(this.getSubtotalAmount(), this.getHeaderDiscountAmount());
}
public getTaxesAmount(): InvoiceAmount {
return this._getTaxesAmount(this.getTaxableAmount());
}
public getTotalAmount(): InvoiceAmount {
const taxableAmount = this.getTaxableAmount();
const taxesAmount = this._getTaxesAmount(taxableAmount);
return this._getTotalAmount(taxableAmount, taxesAmount);
}*/
public getTaxes(): InvoiceTaxTotal[] { public getTaxes(): InvoiceTaxTotal[] {
const itemTaxes = this.items.getTaxesAmountByTaxes(); const map = this.items.getAggregatedTaxesByCode();
const currency = this.currencyCode.code;
return itemTaxes.map((item) => { const result: InvoiceTaxTotal[] = [];
return {
tax: item.tax, for (const [tax_code, entry] of map.entries()) {
result.push({
tax: entry.tax,
taxableAmount: InvoiceAmount.create({ taxableAmount: InvoiceAmount.create({
value: item.taxableAmount.convertScale(2).value, value: entry.taxable.convertScale(2).value,
currency_code: this.currencyCode.code, currency_code: currency,
}).data, }).data,
taxesAmount: InvoiceAmount.create({ taxesAmount: InvoiceAmount.create({
value: item.taxesAmount.convertScale(2).value, value: entry.total.convertScale(2).value,
currency_code: this.currencyCode.code, currency_code: currency,
}).data, }).data,
}; });
}); }
return result;
} }
public getAllAmounts() { public getAllAmounts() {
@ -337,7 +321,7 @@ export class CustomerInvoice
headerDiscountAmount headerDiscountAmount
); // ); //
const taxesAmount = this._getTaxesAmount(taxableAmount); const taxesAmount = this._getTaxesAmount();
const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount); const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount);
return { return {

View File

@ -1,10 +1,4 @@
import { import { type CurrencyCode, DomainEntity, type LanguageCode, type UniqueID } from "@repo/rdx-ddd";
type CurrencyCode,
DomainEntity,
type LanguageCode,
type Percentage,
type UniqueID,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; import { type Maybe, Result } from "@repo/rdx-utils";
import { import {
@ -13,7 +7,7 @@ import {
ItemDiscount, ItemDiscount,
ItemQuantity, ItemQuantity,
} from "../../value-objects"; } from "../../value-objects";
import type { ItemTaxTotal, ItemTaxes } from "../item-taxes"; import type { ItemTaxGroup } from "../../value-objects/item-tax-group";
export interface CustomerInvoiceItemProps { export interface CustomerInvoiceItemProps {
description: Maybe<CustomerInvoiceItemDescription>; description: Maybe<CustomerInvoiceItemDescription>;
@ -22,7 +16,7 @@ export interface CustomerInvoiceItemProps {
discountPercentage: Maybe<ItemDiscount>; // % descuento discountPercentage: Maybe<ItemDiscount>; // % descuento
taxes: ItemTaxes; taxes: ItemTaxGroup;
languageCode: LanguageCode; languageCode: LanguageCode;
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
@ -38,7 +32,7 @@ export interface ICustomerInvoiceItem {
discountPercentage: Maybe<ItemDiscount>; // % descuento discountPercentage: Maybe<ItemDiscount>; // % descuento
taxes: ItemTaxes; taxes: ItemTaxGroup;
languageCode: LanguageCode; languageCode: LanguageCode;
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
@ -80,31 +74,25 @@ export class CustomerInvoiceItem
return this._isValued; return this._isValued;
} }
get description(): Maybe<CustomerInvoiceItemDescription> { get description() {
return this.props.description; return this.props.description;
} }
get quantity() {
get quantity(): Maybe<ItemQuantity> {
return this.props.quantity; return this.props.quantity;
} }
get unitAmount() {
get unitAmount(): Maybe<ItemAmount> {
return this.props.unitAmount; return this.props.unitAmount;
} }
get discountPercentage() {
get discountPercentage(): Maybe<Percentage> {
return this.props.discountPercentage; return this.props.discountPercentage;
} }
get languageCode() {
get languageCode(): LanguageCode {
return this.props.languageCode; return this.props.languageCode;
} }
get currencyCode() {
get currencyCode(): CurrencyCode {
return this.props.currencyCode; return this.props.currencyCode;
} }
get taxes() {
get taxes(): ItemTaxes {
return this.props.taxes; return this.props.taxes;
} }
@ -124,7 +112,7 @@ export class CustomerInvoiceItem
*/ */
private _getDiscountAmount(subtotalAmount: ItemAmount): ItemAmount { private _getDiscountAmount(subtotalAmount: ItemAmount): ItemAmount {
const discount = this.discountPercentage.match( const discount = this.discountPercentage.match(
(percentage) => percentage, (discount) => discount,
() => ItemDiscount.zero() () => ItemDiscount.zero()
); );
return subtotalAmount.percentage(discount); return subtotalAmount.percentage(discount);
@ -141,6 +129,11 @@ export class CustomerInvoiceItem
return subtotalAmount.subtract(discountAmount); return subtotalAmount.subtract(discountAmount);
} }
/* importes individuales: iva / rec / ret */
private _getIndividualTaxAmounts(taxableAmount: ItemAmount) {
return this.props.taxes.calculateAmounts(taxableAmount);
}
/** /**
* @private * @private
* @summary Calcula el importe total de impuestos sobre la base imponible. * @summary Calcula el importe total de impuestos sobre la base imponible.
@ -148,7 +141,8 @@ export class CustomerInvoiceItem
* @returns El importe de impuestos calculado. * @returns El importe de impuestos calculado.
*/ */
private _getTaxesAmount(taxableAmount: ItemAmount): ItemAmount { private _getTaxesAmount(taxableAmount: ItemAmount): ItemAmount {
return this.props.taxes.getTaxesAmount(taxableAmount); const { ivaAmount, recAmount, retentionAmount } = this._getIndividualTaxAmounts(taxableAmount);
return ivaAmount.add(recAmount).add(retentionAmount); // retención ya es negativa
} }
/** /**
@ -169,17 +163,15 @@ export class CustomerInvoiceItem
* Si la cantidad o el importe unitario no están definidos, se asumen valores cero. * Si la cantidad o el importe unitario no están definidos, se asumen valores cero.
*/ */
public getSubtotalAmount(): ItemAmount { public getSubtotalAmount(): ItemAmount {
const curCode = this.currencyCode.code; const qty = this.quantity.match(
const quantity = this.quantity.match(
(quantity) => quantity, (quantity) => quantity,
() => ItemQuantity.zero() () => ItemQuantity.zero()
); );
const unitAmount = this.unitAmount.match( const unit = this.unitAmount.match(
(unitAmount) => unitAmount, (unitAmount) => unitAmount,
() => ItemAmount.zero(curCode) () => ItemAmount.zero(this.currencyCode.code)
); );
return unit.multiply(qty);
return unitAmount.multiply(quantity);
} }
/** /**
@ -198,6 +190,11 @@ export class CustomerInvoiceItem
return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount()); return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount());
} }
/* importes individuales: iva / rec / ret */
public getIndividualTaxAmounts() {
return this._getIndividualTaxAmounts(this.getTaxableAmount());
}
/** /**
* @summary Calcula el importe total de impuestos aplicados al ítem. * @summary Calcula el importe total de impuestos aplicados al ítem.
* @returns Un `ItemAmount` con el total de impuestos. * @returns Un `ItemAmount` con el total de impuestos.
@ -217,14 +214,6 @@ export class CustomerInvoiceItem
return this._getTotalAmount(taxableAmount, taxesAmount); return this._getTotalAmount(taxableAmount, taxesAmount);
} }
/**
* @summary Obtiene los importes de impuestos agrupados por tipo de impuesto.
* @returns Una colección con la base imponible y el importe de impuestos por cada tipo.
*/
public getTaxesAmountByTaxes(): ItemTaxTotal[] {
return this.taxes.getTaxesAmountByTaxes(this.getTaxableAmount());
}
/** /**
* @summary Devuelve todos los importes calculados del ítem en un único objeto. * @summary Devuelve todos los importes calculados del ítem en un único objeto.
* @returns Un objeto con las propiedades: * @returns Un objeto con las propiedades:

View File

@ -3,7 +3,6 @@ import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils"; import { Collection } from "@repo/rdx-utils";
import { ItemAmount } from "../../value-objects"; import { ItemAmount } from "../../value-objects";
import { ItemTaxes } from "../item-taxes";
import type { CustomerInvoiceItem } from "./customer-invoice-item"; import type { CustomerInvoiceItem } from "./customer-invoice-item";
@ -18,10 +17,9 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
private _currencyCode!: CurrencyCode; private _currencyCode!: CurrencyCode;
constructor(props: CustomerInvoiceItemsProps) { constructor(props: CustomerInvoiceItemsProps) {
const { items = [], languageCode, currencyCode } = props; super(props.items ?? []);
super(items); this._languageCode = props.languageCode;
this._languageCode = languageCode; this._currencyCode = props.currencyCode;
this._currencyCode = currencyCode;
} }
public static create(props: CustomerInvoiceItemsProps): CustomerInvoiceItems { public static create(props: CustomerInvoiceItemsProps): CustomerInvoiceItems {
@ -105,65 +103,83 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
); );
} }
/** /* totales de iva/rec/ret a nivel factura */
* @summary Agrupa los importes imponibles e impuestos por tipo de impuesto. public getAggregatedTaxesByType() {
* @returns Un array con objetos que contienen: let iva = ItemAmount.zero(this._currencyCode.code);
* - `tax`: El tipo de impuesto. let rec = ItemAmount.zero(this._currencyCode.code);
* - `taxableAmount`: El total de base imponible asociada a ese impuesto. let retention = ItemAmount.zero(this._currencyCode.code);
* - `taxesAmount`: El importe total de impuestos calculado.
* @remarks
* Los impuestos se agrupan por su `tax.code` (código fiscal).
* Si existen varios impuestos con el mismo código, sus importes se agregan.
* En caso de conflicto de datos en impuestos con mismo código, prevalece
* el primer `Tax` encontrado.
*/
public getTaxesAmountByTaxes(): Array<{
tax: Tax;
taxableAmount: ItemAmount;
taxesAmount: ItemAmount;
}> {
const getTaxCode = (tax: Tax): string => tax.code; // clave estable para Map
const currencyCode = this._currencyCode.code;
// Mapeamos por clave (tax code), pero también guardamos el Tax original
const resultMap = new Map<
string,
{ tax: Tax; taxableAmount: ItemAmount; taxesAmount: ItemAmount }
>();
for (const item of this.getAll()) { for (const item of this.getAll()) {
for (const { taxableAmount, tax, taxesAmount } of item.getTaxesAmountByTaxes()) { const { ivaAmount, recAmount, retentionAmount } = item.getIndividualTaxAmounts();
const key = getTaxCode(tax);
const current = resultMap.get(key) ?? {
tax,
taxableAmount: ItemAmount.zero(currencyCode),
taxesAmount: ItemAmount.zero(currencyCode),
};
resultMap.set(key, { iva = iva.add(ivaAmount);
tax: current.tax, rec = rec.add(recAmount);
taxableAmount: current.taxableAmount.add(taxableAmount), retention = retention.add(retentionAmount);
taxesAmount: current.taxesAmount.add(taxesAmount),
});
}
} }
return Array.from(resultMap.values()); return { iva, rec, retention };
} }
/** /* agrupación por código fiscal → usado para customer_invoice_taxes */
* @summary Obtiene la lista de impuestos únicos aplicados en todos los ítems. public getAggregatedTaxesByCode() {
* @returns Un objeto `ItemTaxes` que contiene todos los impuestos distintos. const map = new Map<string, { tax: Tax; taxable: ItemAmount; total: ItemAmount }>();
* @remarks
* Los impuestos se deduplican por su código (`tax.code`).
* Si existen varios impuestos con el mismo código, el último encontrado
* sobrescribirá los anteriores sin generar error.
*/
public getTaxes(): ItemTaxes {
const taxes = this.getAll()
.flatMap((item) => item.taxes.getAll())
.reduce((map, tax) => map.set(tax.code, tax), new Map<string, Tax>());
return ItemTaxes.create([...taxes.values()]); for (const item of this.getAll()) {
const taxable = item.getTaxableAmount();
const { ivaAmount, recAmount, retentionAmount } = item.getIndividualTaxAmounts();
item.taxes.iva.match(
(iva) => {
const prev = map.get(iva.code) ?? {
taxable: ItemAmount.zero(taxable.currencyCode),
total: ItemAmount.zero(taxable.currencyCode),
};
map.set(iva.code, {
tax: iva,
taxable: prev.taxable.add(taxable),
total: prev.total.add(ivaAmount),
});
},
() => {
//
}
);
item.taxes.rec.match(
(rec) => {
const prev = map.get(rec.code) ?? {
taxable: ItemAmount.zero(taxable.currencyCode),
total: ItemAmount.zero(taxable.currencyCode),
};
map.set(rec.code, {
tax: rec,
taxable: prev.taxable.add(taxable),
total: prev.total.add(recAmount),
});
},
() => {
//
}
);
item.taxes.retention.match(
(retention) => {
const prev = map.get(retention.code) ?? {
taxable: ItemAmount.zero(taxable.currencyCode),
total: ItemAmount.zero(taxable.currencyCode),
};
map.set(retention.code, {
tax: retention,
taxable: prev.taxable.add(taxable),
total: prev.total.add(retentionAmount),
});
},
() => {
//
}
);
}
return map;
} }
} }

View File

@ -1,5 +1,4 @@
export * from "./customer-invoice-items"; export * from "./customer-invoice-items";
export * from "./invoice-payment-method"; export * from "./invoice-payment-method";
export * from "./invoice-taxes"; export * from "./invoice-taxes";
export * from "./item-taxes";
export * from "./verifactu-record"; export * from "./verifactu-record";

View File

@ -1,7 +1,8 @@
import { Tax } from "@erp/core/api"; import type { Tax } from "@erp/core/api";
import { DomainEntity, UniqueID } from "@repo/rdx-ddd"; import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { InvoiceAmount } from "../../value-objects/invoice-amount";
import type { InvoiceAmount } from "../../value-objects/invoice-amount";
export interface InvoiceTaxProps { export interface InvoiceTaxProps {
tax: Tax; tax: Tax;

View File

@ -1,4 +1,5 @@
import { Tax, Taxes } from "@erp/core/api"; import { type Tax, Taxes } from "@erp/core/api";
import { InvoiceAmount } from "../../value-objects"; import { InvoiceAmount } from "../../value-objects";
export type InvoiceTaxTotal = { export type InvoiceTaxTotal = {

View File

@ -1 +0,0 @@
export * from "./item-taxes";

View File

@ -1,44 +0,0 @@
import { type Tax, Taxes } from "@erp/core/api";
import { ItemAmount } from "../../value-objects";
export type ItemTaxTotal = {
tax: Tax;
taxableAmount: ItemAmount;
taxesAmount: ItemAmount;
};
export class ItemTaxes extends Taxes {
constructor(items: Tax[] = [], totalItems: number | null = null) {
super(items, totalItems);
}
public getTaxesAmount(taxableAmount: ItemAmount): ItemAmount {
return this.getAll().reduce(
(total, tax) => total.add(taxableAmount.percentage(tax.percentage)),
ItemAmount.zero(taxableAmount.currencyCode)
);
}
public getTaxesAmountByTaxCode(taxCode: string, taxableAmount: ItemAmount): ItemAmount {
const currencyCode = taxableAmount.currencyCode;
return this.filter((itemTax) => itemTax.code === taxCode).reduce((totalAmount, itemTax) => {
return taxableAmount.percentage(itemTax.percentage).add(totalAmount);
}, ItemAmount.zero(currencyCode));
}
public getTaxesAmountByTaxes(taxableAmount: ItemAmount): ItemTaxTotal[] {
return this.getAll().map((taxItem) => ({
taxableAmount,
tax: taxItem,
taxesAmount: this.getTaxesAmountByTaxCode(taxItem.code, taxableAmount),
}));
}
public getCodesToString(): string {
return this.getAll()
.map((taxItem) => taxItem.code)
.join(", ");
}
}

View File

@ -1,6 +1,7 @@
import { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import type { Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoiceNumber, CustomerInvoiceSerie } from "../value-objects";
import type { CustomerInvoiceNumber, CustomerInvoiceSerie } from "../value-objects";
/** /**
* Servicio de dominio que define cómo se genera el siguiente número de factura. * Servicio de dominio que define cómo se genera el siguiente número de factura.

View File

@ -1,5 +1,6 @@
import { CompositeSpecification } from "@repo/rdx-ddd"; import { CompositeSpecification } from "@repo/rdx-ddd";
import { CustomerInvoice } from "../aggregates";
import type { CustomerInvoice } from "../aggregates";
import { INVOICE_STATUS } from "../value-objects"; import { INVOICE_STATUS } from "../value-objects";
export class ProformaCanTranstionToIssuedSpecification extends CompositeSpecification<CustomerInvoice> { export class ProformaCanTranstionToIssuedSpecification extends CompositeSpecification<CustomerInvoice> {

View File

@ -8,4 +8,5 @@ export * from "./invoice-recipient";
export * from "./item-amount"; export * from "./item-amount";
export * from "./item-discount"; export * from "./item-discount";
export * from "./item-quantity"; export * from "./item-quantity";
export * from "./item-tax-group";
export * from "./verifactu-status"; export * from "./verifactu-status";

View File

@ -1,4 +1,4 @@
import { MoneyValue, MoneyValueProps, Percentage, Quantity } from "@repo/rdx-ddd"; import { MoneyValue, type MoneyValueProps, type Percentage, type Quantity } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
type InvoiceAmountProps = Pick<MoneyValueProps, "value" | "currency_code">; type InvoiceAmountProps = Pick<MoneyValueProps, "value" | "currency_code">;

View File

@ -1,4 +1,4 @@
import { MoneyValue, MoneyValueProps, Percentage, Quantity } from "@repo/rdx-ddd"; import { MoneyValue, type MoneyValueProps, type Percentage, type Quantity } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
type ItemAmountProps = Pick<MoneyValueProps, "value" | "currency_code">; type ItemAmountProps = Pick<MoneyValueProps, "value" | "currency_code">;

View File

@ -1,5 +1,5 @@
import { Percentage, PercentageProps } from "@repo/rdx-ddd"; import { Percentage, type PercentageProps } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import type { Result } from "@repo/rdx-utils";
type ItemDiscountProps = Pick<PercentageProps, "value">; type ItemDiscountProps = Pick<PercentageProps, "value">;

View File

@ -1,4 +1,4 @@
import { Quantity, QuantityProps } from "@repo/rdx-ddd"; import { Quantity, type QuantityProps } from "@repo/rdx-ddd";
type ItemQuantityProps = Pick<QuantityProps, "value">; type ItemQuantityProps = Pick<QuantityProps, "value">;

View File

@ -0,0 +1,87 @@
import type { Tax } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { ItemAmount } from ".";
export interface ItemTaxGroupProps {
iva: Maybe<Tax>; // si existe
rec: Maybe<Tax>; // si existe
retention: Maybe<Tax>; // si existe
}
export class ItemTaxGroup extends ValueObject<ItemTaxGroupProps> {
static create(props: ItemTaxGroupProps) {
return Result.ok(new ItemTaxGroup(props));
}
calculateAmounts(taxableAmount: ItemAmount) {
const ivaAmount = this.props.iva.match(
(iva) => taxableAmount.percentage(iva.percentage),
() => ItemAmount.zero(taxableAmount.currencyCode)
);
const recAmount = this.props.rec.match(
(rec) => taxableAmount.percentage(rec.percentage),
() => ItemAmount.zero(taxableAmount.currencyCode)
);
const retentionAmount = this.props.retention.match(
(retention) => taxableAmount.percentage(retention.percentage).multiply(-1),
() => ItemAmount.zero(taxableAmount.currencyCode)
);
return { ivaAmount, recAmount, retentionAmount };
}
get iva(): Maybe<Tax> {
return this.props.iva;
}
get rec(): Maybe<Tax> {
return this.props.rec;
}
get retention(): Maybe<Tax> {
return this.props.retention;
}
public getCodesArray(): string[] {
const codes: string[] = [];
this.props.iva.match(
(iva) => codes.push(iva.code),
() => {
//
}
);
this.props.rec.match(
(rec) => codes.push(rec.code),
() => {
//
}
);
this.props.retention.match(
(retention) => codes.push(retention.code),
() => {
//
}
);
return codes;
}
public getCodesToString(): string {
return this.getCodesArray().join(", ");
}
getProps() {
return this.props;
}
toPrimitive() {
return this.getProps();
}
}

View File

@ -1,7 +1,9 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { import {
type ISequelizeDomainMapper, type ISequelizeDomainMapper,
type MapperParamsType, type MapperParamsType,
SequelizeDomainMapper, SequelizeDomainMapper,
Tax,
} from "@erp/core/api"; } from "@erp/core/api";
import { import {
UniqueID, UniqueID,
@ -22,15 +24,13 @@ import {
ItemAmount, ItemAmount,
ItemDiscount, ItemDiscount,
ItemQuantity, ItemQuantity,
ItemTaxes, ItemTaxGroup,
} from "../../../domain"; } from "../../../domain";
import type { import type {
CustomerInvoiceItemCreationAttributes, CustomerInvoiceItemCreationAttributes,
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
} from "../../sequelize"; } from "../../sequelize";
import { ItemTaxesDomainMapper } from "./item-taxes.mapper";
export interface ICustomerInvoiceItemDomainMapper export interface ICustomerInvoiceItemDomainMapper
extends ISequelizeDomainMapper< extends ISequelizeDomainMapper<
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
@ -46,11 +46,19 @@ export class CustomerInvoiceItemDomainMapper
> >
implements ICustomerInvoiceItemDomainMapper implements ICustomerInvoiceItemDomainMapper
{ {
private _taxesMapper: ItemTaxesDomainMapper; private _taxCatalog!: JsonTaxCatalogProvider;
constructor(params: MapperParamsType) { constructor(params: MapperParamsType) {
super(); super();
this._taxesMapper = new ItemTaxesDomainMapper(params); const { taxCatalog } = params as {
taxCatalog: JsonTaxCatalogProvider;
};
if (!taxCatalog) {
throw new Error('taxCatalog not defined ("CustomerInvoiceItemDomainMapper")');
}
this._taxCatalog = taxCatalog;
} }
private mapAttributesToDomain( private mapAttributesToDomain(
@ -97,6 +105,26 @@ export class CustomerInvoiceItemDomainMapper
errors errors
); );
const iva = extractOrPushError(
maybeFromNullableVO(source.iva_code, (code) => Tax.createFromCode(code, this._taxCatalog)),
`items[${index}].iva_code`,
errors
);
const rec = extractOrPushError(
maybeFromNullableVO(source.rec_code, (code) => Tax.createFromCode(code, this._taxCatalog)),
`items[${index}].rec_code`,
errors
);
const retention = extractOrPushError(
maybeFromNullableVO(source.retention_code, (code) =>
Tax.createFromCode(code, this._taxCatalog)
),
`items[${index}].retention_code`,
errors
);
return { return {
itemId, itemId,
@ -106,6 +134,12 @@ export class CustomerInvoiceItemDomainMapper
quantity, quantity,
unitAmount, unitAmount,
discountPercentage, discountPercentage,
taxes: ItemTaxGroup.create({
iva: iva!,
rec: rec!,
retention: retention!,
}).data,
}; };
} }
@ -122,23 +156,6 @@ export class CustomerInvoiceItemDomainMapper
// 1) Valores escalares (atributos generales) // 1) Valores escalares (atributos generales)
const attributes = this.mapAttributesToDomain(source, params); const attributes = this.mapAttributesToDomain(source, params);
// 2) Taxes (colección a nivel de item/línea)
const taxesResults = this._taxesMapper.mapToDomainCollection(
source.taxes,
source.taxes.length,
{
attributes,
...params,
}
);
if (taxesResults.isFailure) {
errors.push({
path: "taxes",
message: taxesResults.error.message,
});
}
// Si hubo errores de mapeo, devolvemos colección de validación // Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) { if (errors.length > 0) {
return Result.fail( return Result.fail(
@ -146,9 +163,7 @@ export class CustomerInvoiceItemDomainMapper
); );
} }
const taxes = ItemTaxes.create(taxesResults.data.getAll()); // 2) Construcción del elemento de dominio
// 3) Construcción del elemento de dominio
const createResult = CustomerInvoiceItem.create( const createResult = CustomerInvoiceItem.create(
{ {
languageCode: attributes.languageCode!, languageCode: attributes.languageCode!,
@ -157,7 +172,7 @@ export class CustomerInvoiceItemDomainMapper
quantity: attributes.quantity!, quantity: attributes.quantity!,
unitAmount: attributes.unitAmount!, unitAmount: attributes.unitAmount!,
discountPercentage: attributes.discountPercentage!, discountPercentage: attributes.discountPercentage!,
taxes, taxes: attributes.taxes!,
}, },
attributes.itemId attributes.itemId
); );
@ -183,18 +198,6 @@ export class CustomerInvoiceItemDomainMapper
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
}; };
const taxesResults = this._taxesMapper.mapToPersistenceArray(source.taxes, {
...params,
parent: source,
});
if (taxesResults.isFailure) {
errors.push({
path: "taxes",
message: taxesResults.error.message,
});
}
const allAmounts = source.getAllAmounts(); const allAmounts = source.getAllAmounts();
return Result.ok({ return Result.ok({
@ -235,7 +238,9 @@ export class CustomerInvoiceItemDomainMapper
total_amount_value: allAmounts.totalAmount.value, total_amount_value: allAmounts.totalAmount.value,
total_amount_scale: allAmounts.totalAmount.scale, total_amount_scale: allAmounts.totalAmount.scale,
taxes: taxesResults.data, iva_code: toNullable(source.taxes.iva, (t) => t.code),
rec_code: toNullable(source.taxes.rec, (t) => t.code),
retention_code: toNullable(source.taxes.retention, (t) => t.code),
}); });
} }
} }

View File

@ -220,14 +220,6 @@ export class CustomerInvoiceDomainMapper
...params, ...params,
}); });
/*if (recipientResult.isFailure) {
errors.push({
path: "recipient",
message: recipientResult.error.message,
});
}*/
// 3) Verifactu (snapshot en la factura o include) // 3) Verifactu (snapshot en la factura o include)
const verifactuResult = this._verifactuMapper.mapToDomain(source.verifactu, { const verifactuResult = this._verifactuMapper.mapToDomain(source.verifactu, {
errors, errors,
@ -235,14 +227,6 @@ export class CustomerInvoiceDomainMapper
...params, ...params,
}); });
/*if (verifactuResult.isFailure) {
errors.push({
path: "verifactu",
message: verifactuResult.error.message,
});
}*/
// 4) Items (colección) // 4) Items (colección)
const itemsResults = this._itemsMapper.mapToDomainCollection( const itemsResults = this._itemsMapper.mapToDomainCollection(
source.items, source.items,
@ -254,16 +238,6 @@ export class CustomerInvoiceDomainMapper
} }
); );
/*if (itemsResults.isFailure) {
errors.push({
path: "items",
message: itemsResults.error.message,
});
}*/
// Nota: los impuestos a nivel factura (tabla customer_invoice_taxes) se derivan de los items.
// El agregado expone un getter `taxes` (derivado). No se incluye en las props.
// 5) Si hubo errores de mapeo, devolvemos colección de validación // 5) Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) { if (errors.length > 0) {
return Result.fail( return Result.fail(
@ -273,9 +247,6 @@ export class CustomerInvoiceDomainMapper
// 6) Construcción del agregado (Dominio) // 6) Construcción del agregado (Dominio)
const verifactu = verifactuResult.data;
const recipient = recipientResult.data;
const items = CustomerInvoiceItems.create({ const items = CustomerInvoiceItems.create({
languageCode: attributes.languageCode!, languageCode: attributes.languageCode!,
currencyCode: attributes.currencyCode!, currencyCode: attributes.currencyCode!,
@ -294,7 +265,7 @@ export class CustomerInvoiceDomainMapper
operationDate: attributes.operationDate!, operationDate: attributes.operationDate!,
customerId: attributes.customerId!, customerId: attributes.customerId!,
recipient: recipient, recipient: recipientResult.data,
reference: attributes.reference!, reference: attributes.reference!,
description: attributes.description!, description: attributes.description!,
@ -308,7 +279,7 @@ export class CustomerInvoiceDomainMapper
paymentMethod: attributes.paymentMethod!, paymentMethod: attributes.paymentMethod!,
items, items,
verifactu, verifactu: verifactuResult.data,
}; };
const createResult = CustomerInvoice.create(invoiceProps, attributes.invoiceId); const createResult = CustomerInvoice.create(invoiceProps, attributes.invoiceId);

View File

@ -1,11 +1,26 @@
import { JsonTaxCatalogProvider } from "@erp/core"; import type { JsonTaxCatalogProvider } from "@erp/core";
import { MapperParamsType, SequelizeDomainMapper, Tax } from "@erp/core/api"; import { type MapperParamsType, SequelizeDomainMapper, Tax } from "@erp/core/api";
import { UniqueID, ValidationErrorDetail } from "@repo/rdx-ddd"; import { UniqueID, type ValidationErrorDetail } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { CustomerInvoice, CustomerInvoiceItemProps, ItemAmount } from "../../../domain";
import { CustomerInvoiceTaxCreationAttributes, CustomerInvoiceTaxModel } from "../../sequelize";
import { type CustomerInvoice, type CustomerInvoiceItemProps, ItemAmount } from "../../../domain";
import type {
CustomerInvoiceTaxCreationAttributes,
CustomerInvoiceTaxModel,
} from "../../sequelize";
/**
* Mapper para customer_invoice_taxes
*
* Domina estructuras:
* {
* tax: Tax
* taxableAmount: ItemAmount
* taxesAmount: ItemAmount
* }
*
* Cada fila = un impuesto agregado en toda la factura.
*/
export class TaxesDomainMapper extends SequelizeDomainMapper< export class TaxesDomainMapper extends SequelizeDomainMapper<
CustomerInvoiceTaxModel, CustomerInvoiceTaxModel,
CustomerInvoiceTaxCreationAttributes, CustomerInvoiceTaxCreationAttributes,
@ -41,15 +56,17 @@ export class TaxesDomainMapper extends SequelizeDomainMapper<
attributes: Partial<CustomerInvoiceItemProps>; attributes: Partial<CustomerInvoiceItemProps>;
}; };
const currency_code = attributes.currencyCode!.code;
return Result.ok({ return Result.ok({
taxableAmount: ItemAmount.create({ taxableAmount: ItemAmount.create({
value: source.taxable_amount_value, value: source.taxable_amount_value,
currency_code: attributes.currencyCode!.code, currency_code,
}).data, }).data,
tax: Tax.createFromCode(source.tax_code, this._taxCatalog).data, tax: Tax.createFromCode(source.tax_code, this._taxCatalog).data,
taxesAmount: ItemAmount.create({ taxesAmount: ItemAmount.create({
value: source.taxes_amount_value, value: source.taxes_amount_value,
currency_code: attributes.currencyCode!.code, currency_code,
}).data, }).data,
}); });
} }
@ -67,8 +84,6 @@ export class TaxesDomainMapper extends SequelizeDomainMapper<
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
}; };
source;
return Result.ok({ return Result.ok({
tax_id: UniqueID.generateNewID().toPrimitive(), tax_id: UniqueID.generateNewID().toPrimitive(),
invoice_id: parent.id.toPrimitive(), invoice_id: parent.id.toPrimitive(),

View File

@ -1,112 +0,0 @@
import { JsonTaxCatalogProvider } from "@erp/core";
import {
ISequelizeDomainMapper,
MapperParamsType,
SequelizeDomainMapper,
Tax,
} from "@erp/core/api";
import {
extractOrPushError,
UniqueID,
ValidationErrorCollection,
ValidationErrorDetail,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceItem } from "../../../domain";
import {
CustomerInvoiceItemTaxCreationAttributes,
CustomerInvoiceItemTaxModel,
} from "../../sequelize";
export interface IItemTaxesDomainMapper
extends ISequelizeDomainMapper<
CustomerInvoiceItemTaxModel,
CustomerInvoiceItemTaxCreationAttributes,
Tax
> {}
export class ItemTaxesDomainMapper
extends SequelizeDomainMapper<
CustomerInvoiceItemTaxModel,
CustomerInvoiceItemTaxCreationAttributes,
Tax
>
implements IItemTaxesDomainMapper
{
private _taxCatalog!: JsonTaxCatalogProvider;
constructor(params: MapperParamsType) {
super();
const { taxCatalog } = params as {
taxCatalog: JsonTaxCatalogProvider;
};
if (!taxCatalog) {
throw new Error('taxCatalog not defined ("ItemTaxesMapper")');
}
this._taxCatalog = taxCatalog;
}
public mapToDomain(
source: CustomerInvoiceItemTaxModel,
params?: MapperParamsType
): Result<Tax, Error> {
const { errors, index } = params as {
index: number;
errors: ValidationErrorDetail[];
};
const tax = extractOrPushError(
Tax.createFromCode(source.tax_code, this._taxCatalog),
`taxes[${index}].tax_code`,
errors
);
// Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Invoice item tax mapping failed [mapToDomain]", errors)
);
}
// Creación del objeto de dominio
const createResult = Tax.create(tax!);
if (createResult.isFailure) {
return Result.fail(
new ValidationErrorCollection("Invoice item tax creation failed", [
{ path: `taxes[${index}]`, message: createResult.error.message },
])
);
}
return createResult;
}
public mapToPersistence(
source: Tax,
params?: MapperParamsType
): Result<CustomerInvoiceItemTaxCreationAttributes, Error> {
const { errors, parent } = params as {
parent: CustomerInvoiceItem;
errors: ValidationErrorDetail[];
};
const taxableAmount = parent.getTaxableAmount();
const taxAmount = taxableAmount.percentage(source.percentage);
return Result.ok({
tax_id: UniqueID.generateNewID().toPrimitive(),
item_id: parent.id.toPrimitive(),
tax_code: source.code,
taxable_amount_value: taxableAmount.value,
taxable_amount_scale: taxableAmount.scale,
taxes_amount_value: taxAmount.value,
taxes_amount_scale: taxAmount.scale,
});
}
}

View File

@ -22,6 +22,7 @@ import {
CustomerInvoiceStatus, CustomerInvoiceStatus,
InvoiceAmount, InvoiceAmount,
type InvoiceRecipient, type InvoiceRecipient,
ItemAmount,
type VerifactuRecord, type VerifactuRecord,
} from "../../../domain"; } from "../../../domain";
import type { CustomerInvoiceModel } from "../../sequelize"; import type { CustomerInvoiceModel } from "../../sequelize";
@ -50,7 +51,11 @@ export type CustomerInvoiceListDTO = {
languageCode: LanguageCode; languageCode: LanguageCode;
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
taxes: string; taxes: {
tax_code: string;
taxable_amount: InvoiceAmount;
taxes_amount: InvoiceAmount;
}[];
discountPercentage: Percentage; discountPercentage: Percentage;
@ -103,7 +108,23 @@ export class CustomerInvoiceListMapper
} }
// 3) Taxes // 3) Taxes
const taxes = raw.taxes.map((tax) => tax.tax_code).join(", "); const taxes = raw.taxes.map((tax) => {
const taxableAmount = ItemAmount.create({
value: tax.taxable_amount_value || 0,
currency_code: attributes.currencyCode!.code,
}).data;
const taxesAmount = ItemAmount.create({
value: tax.taxes_amount_value || 0,
currency_code: attributes.currencyCode!.code,
}).data;
return {
tax_code: tax.tax_code,
taxable_amount: taxableAmount,
taxes_amount: taxesAmount,
};
});
// 4) Verifactu record // 4) Verifactu record
let verifactu: Maybe<VerifactuRecord> = Maybe.none(); let verifactu: Maybe<VerifactuRecord> = Maybe.none();

View File

@ -22,7 +22,6 @@ import type {
import { CustomerInvoiceModel } from "./models/customer-invoice.model"; import { CustomerInvoiceModel } from "./models/customer-invoice.model";
import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model"; import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model";
import { CustomerInvoiceItemTaxModel } from "./models/customer-invoice-item-tax.model";
import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model"; import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model";
import { VerifactuRecordModel } from "./models/verifactu-record.model"; import { VerifactuRecordModel } from "./models/verifactu-record.model";
@ -102,12 +101,7 @@ export class CustomerInvoiceRepository
// 3. Inserta items + sus taxes // 3. Inserta items + sus taxes
if (Array.isArray(items) && items.length > 0) { if (Array.isArray(items) && items.length > 0) {
for (const item of items) { for (const item of items) {
const { taxes: itemTaxes, ...itemData } = item; await CustomerInvoiceItemModel.create(item, { transaction });
await CustomerInvoiceItemModel.create(itemData, { transaction });
if (Array.isArray(itemTaxes) && itemTaxes.length > 0) {
await CustomerInvoiceItemTaxModel.bulkCreate(itemTaxes, { transaction });
}
} }
} }
@ -171,12 +165,7 @@ export class CustomerInvoiceRepository
// 4. Inserta items + sus taxes // 4. Inserta items + sus taxes
if (Array.isArray(items) && items.length > 0) { if (Array.isArray(items) && items.length > 0) {
for (const item of items) { for (const item of items) {
const { taxes: itemTaxes, ...itemData } = item; await CustomerInvoiceItemModel.create(item, { transaction });
await CustomerInvoiceItemModel.create(itemData, { transaction });
if (Array.isArray(itemTaxes) && itemTaxes.length > 0) {
await CustomerInvoiceItemTaxModel.bulkCreate(itemTaxes, { transaction });
}
} }
} }
@ -276,13 +265,6 @@ export class CustomerInvoiceRepository
model: CustomerInvoiceItemModel, model: CustomerInvoiceItemModel,
as: "items", as: "items",
required: false, required: false,
include: [
{
model: CustomerInvoiceItemTaxModel,
as: "taxes",
required: false,
},
],
}, },
{ {
model: CustomerInvoiceTaxModel, model: CustomerInvoiceTaxModel,
@ -371,13 +353,6 @@ export class CustomerInvoiceRepository
model: CustomerInvoiceItemModel, model: CustomerInvoiceItemModel,
as: "items", as: "items",
required: false, required: false,
include: [
{
model: CustomerInvoiceItemTaxModel,
as: "taxes",
required: false,
},
],
}, },
{ {
model: CustomerInvoiceTaxModel, model: CustomerInvoiceTaxModel,
@ -484,7 +459,6 @@ export class CustomerInvoiceRepository
as: "taxes", as: "taxes",
required: false, required: false,
separate: true, // => query aparte, devuelve siempre array separate: true, // => query aparte, devuelve siempre array
attributes: ["tax_id", "tax_code"],
}, },
]; ];
@ -601,7 +575,6 @@ export class CustomerInvoiceRepository
as: "taxes", as: "taxes",
required: false, required: false,
separate: true, // => query aparte, devuelve siempre array separate: true, // => query aparte, devuelve siempre array
attributes: ["tax_id", "tax_code"],
}, },
]; ];

View File

@ -1,6 +1,5 @@
import customerInvoiceModelInit from "./models/customer-invoice.model"; import customerInvoiceModelInit from "./models/customer-invoice.model";
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model"; import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model";
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model"; import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
import verifactuRecordModelInit from "./models/verifactu-record.model"; import verifactuRecordModelInit from "./models/verifactu-record.model";
@ -13,7 +12,6 @@ export const models = [
customerInvoiceItemModelInit, customerInvoiceItemModelInit,
customerInvoiceTaxesModelInit, customerInvoiceTaxesModelInit,
customerInvoiceItemTaxesModelInit,
verifactuRecordModelInit, verifactuRecordModelInit,
]; ];

View File

@ -1,125 +0,0 @@
import {
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type NonAttribute,
type Sequelize,
} from "sequelize";
import type { CustomerInvoiceItem } from "../../../domain";
export type CustomerInvoiceItemTaxCreationAttributes = InferCreationAttributes<
CustomerInvoiceItemTaxModel,
{ omit: "item" }
>;
export class CustomerInvoiceItemTaxModel extends Model<
InferAttributes<CustomerInvoiceItemTaxModel>,
InferCreationAttributes<CustomerInvoiceItemTaxModel>
> {
declare tax_id: string;
declare item_id: string;
declare tax_code: string; //"iva_21"
// Taxable amount (base imponible) // 100,00 €
declare taxable_amount_value: number;
declare taxable_amount_scale: number;
// Total tax amount / taxes total // 21,00 €
declare taxes_amount_value: number;
declare taxes_amount_scale: number;
// Relaciones
declare item: NonAttribute<CustomerInvoiceItem>;
static associate(database: Sequelize) {
const models = database.models;
const requiredModels = ["CustomerInvoiceItemModel"];
// Comprobamos que los modelos existan
for (const name of requiredModels) {
if (!models[name]) {
throw new Error(`[CustomerInvoiceItemTaxModel.associate] Missing model: ${name}`);
}
}
const { CustomerInvoiceItemModel } = models;
CustomerInvoiceItemTaxModel.belongsTo(CustomerInvoiceItemModel, {
as: "item",
targetKey: "item_id",
foreignKey: "item_id",
onDelete: "CASCADE",
onUpdate: "CASCADE",
});
}
static hooks(_database: Sequelize) {
//
}
}
export default (database: Sequelize) => {
CustomerInvoiceItemTaxModel.init(
{
tax_id: {
type: DataTypes.UUID,
primaryKey: true,
},
item_id: {
type: DataTypes.UUID,
allowNull: false,
},
tax_code: {
type: new DataTypes.STRING(),
allowNull: false,
},
taxable_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
taxable_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
taxes_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: false,
defaultValue: 0,
},
taxes_amount_scale: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 4,
},
},
{
sequelize: database,
modelName: "CustomerInvoiceItemTaxModel",
tableName: "customer_invoice_item_taxes",
underscored: true,
indexes: [],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return CustomerInvoiceItemTaxModel;
};

View File

@ -9,21 +9,15 @@ import {
} from "sequelize"; } from "sequelize";
import type { CustomerInvoiceModel } from "./customer-invoice.model"; import type { CustomerInvoiceModel } from "./customer-invoice.model";
import type {
CustomerInvoiceItemTaxCreationAttributes,
CustomerInvoiceItemTaxModel,
} from "./customer-invoice-item-tax.model";
export type CustomerInvoiceItemCreationAttributes = InferCreationAttributes< export type CustomerInvoiceItemCreationAttributes = InferCreationAttributes<
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
{ omit: "invoice" | "taxes" } { omit: "invoice" }
> & { >;
taxes?: CustomerInvoiceItemTaxCreationAttributes[];
};
export class CustomerInvoiceItemModel extends Model< export class CustomerInvoiceItemModel extends Model<
InferAttributes<CustomerInvoiceItemModel>, InferAttributes<CustomerInvoiceItemModel>,
InferCreationAttributes<CustomerInvoiceItemModel, { omit: "invoice" | "taxes" }> InferCreationAttributes<CustomerInvoiceItemModel, { omit: "invoice" }>
> { > {
declare item_id: string; declare item_id: string;
declare invoice_id: string; declare invoice_id: string;
@ -54,6 +48,39 @@ export class CustomerInvoiceItemModel extends Model<
declare taxable_amount_value: CreationOptional<number | null>; declare taxable_amount_value: CreationOptional<number | null>;
declare taxable_amount_scale: number; declare taxable_amount_scale: number;
// Código de impuestos
// IVA percentage
declare iva_code: CreationOptional<string | null>;
declare iva_percentage_value: CreationOptional<number | null>;
declare iva_percentage_scale: number;
// IVA amount
declare iva_amount_value: CreationOptional<number | null>;
declare iva_amount_scale: number;
// Recargo de equivalencia percentage
declare rec_code: CreationOptional<string | null>;
declare rec_percentage_value: CreationOptional<number | null>;
declare rec_percentage_scale: number;
// Recargo de equivalencia amount
declare rec_amount_value: CreationOptional<number | null>;
declare rec_amount_scale: number;
// Retention percentage
declare retention_code: CreationOptional<string | null>;
declare retention_percentage_value: CreationOptional<number | null>;
declare retention_percentage_scale: number;
// Retention amount
declare retention_amount_value: CreationOptional<number | null>;
declare retention_amount_scale: number;
// Total taxes amount / taxes total // Total taxes amount / taxes total
declare taxes_amount_value: CreationOptional<number | null>; declare taxes_amount_value: CreationOptional<number | null>;
declare taxes_amount_scale: number; declare taxes_amount_scale: number;
@ -64,15 +91,10 @@ export class CustomerInvoiceItemModel extends Model<
// Relaciones // Relaciones
declare invoice: NonAttribute<CustomerInvoiceModel>; declare invoice: NonAttribute<CustomerInvoiceModel>;
declare taxes: NonAttribute<CustomerInvoiceItemTaxModel[]>;
static associate(database: Sequelize) { static associate(database: Sequelize) {
const models = database.models; const models = database.models;
const requiredModels = [ const requiredModels = ["CustomerInvoiceModel", "CustomerInvoiceItemModel"];
"CustomerInvoiceModel",
"CustomerInvoiceItemModel",
"CustomerInvoiceItemTaxModel",
];
// Comprobamos que los modelos existan // Comprobamos que los modelos existan
for (const name of requiredModels) { for (const name of requiredModels) {
@ -81,7 +103,7 @@ export class CustomerInvoiceItemModel extends Model<
} }
} }
const { CustomerInvoiceModel, CustomerInvoiceItemModel, CustomerInvoiceItemTaxModel } = models; const { CustomerInvoiceModel, CustomerInvoiceItemModel } = models;
CustomerInvoiceItemModel.belongsTo(CustomerInvoiceModel, { CustomerInvoiceItemModel.belongsTo(CustomerInvoiceModel, {
as: "invoice", as: "invoice",
@ -90,15 +112,6 @@ export class CustomerInvoiceItemModel extends Model<
onDelete: "CASCADE", onDelete: "CASCADE",
onUpdate: "CASCADE", onUpdate: "CASCADE",
}); });
CustomerInvoiceItemModel.hasMany(CustomerInvoiceItemTaxModel, {
as: "taxes",
foreignKey: "item_id",
sourceKey: "item_id",
constraints: true,
onDelete: "CASCADE",
onUpdate: "CASCADE",
});
} }
} }
@ -199,6 +212,93 @@ export default (database: Sequelize) => {
defaultValue: 4, defaultValue: 4,
}, },
// IVA %
iva_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
iva_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
defaultValue: null,
},
iva_percentage_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 2,
},
iva_amount_value: {
type: DataTypes.BIGINT,
allowNull: true,
defaultValue: null,
},
iva_amount_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 4,
},
// REC %
rec_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
rec_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
defaultValue: null,
},
rec_percentage_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 2,
},
rec_amount_value: {
type: DataTypes.BIGINT,
allowNull: true,
defaultValue: null,
},
rec_amount_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 4,
},
// Retención %
retention_code: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
retention_percentage_value: {
type: DataTypes.SMALLINT,
allowNull: true,
defaultValue: null,
},
retention_percentage_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 2,
},
retention_amount_value: {
type: DataTypes.BIGINT,
allowNull: true,
defaultValue: null,
},
retention_amount_scale: {
type: DataTypes.SMALLINT,
allowNull: false,
defaultValue: 4,
},
taxes_amount_value: { taxes_amount_value: {
type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes type: new DataTypes.BIGINT(), // importante: evita problemas de precisión con valores grandes
allowNull: true, allowNull: true,
@ -230,7 +330,7 @@ export default (database: Sequelize) => {
underscored: true, underscored: true,
indexes: [], indexes: [{ fields: ["invoice_id"] }, { fields: ["invoice_id", "position"] }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -49,9 +49,10 @@ export class CustomerInvoiceTaxModel extends Model<
CustomerInvoiceTaxModel.belongsTo(CustomerInvoiceModel, { CustomerInvoiceTaxModel.belongsTo(CustomerInvoiceModel, {
as: "invoice", as: "invoice",
targetKey: "id",
foreignKey: "invoice_id", foreignKey: "invoice_id",
targetKey: "id",
onDelete: "CASCADE", onDelete: "CASCADE",
onUpdate: "CASCADE",
}); });
} }
@ -74,7 +75,7 @@ export default (database: Sequelize) => {
}, },
tax_code: { tax_code: {
type: new DataTypes.STRING(), type: new DataTypes.STRING(40), // Sugerido por IA
allowNull: false, allowNull: false,
}, },
@ -113,7 +114,11 @@ export default (database: Sequelize) => {
{ {
name: "invoice_id_idx", name: "invoice_id_idx",
fields: ["invoice_id"], fields: ["invoice_id"],
unique: false, },
{
name: "invoice_tax_code_unique",
fields: ["invoice_id", "tax_code"],
unique: true, // cada impuesto aparece como máximo una vez
}, },
], ],

View File

@ -1,5 +1,4 @@
export * from "./customer-invoice.model"; export * from "./customer-invoice.model";
export * from "./customer-invoice-item.model"; export * from "./customer-invoice-item.model";
export * from "./customer-invoice-item-tax.model";
export * from "./customer-invoice-tax.model"; export * from "./customer-invoice-tax.model";
export * from "./verifactu-record.model"; export * from "./verifactu-record.model";

View File

@ -38,7 +38,13 @@ export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
country: z.string(), country: z.string(),
}), }),
taxes: z.string(), taxes: z.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema, discount_percentage: PercentageSchema,

View File

@ -38,7 +38,13 @@ export const ListProformasResponseSchema = createPaginatedListSchema(
country: z.string(), country: z.string(),
}), }),
taxes: z.string(), taxes: z.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema, discount_percentage: PercentageSchema,

View File

@ -11,7 +11,6 @@ import type {
*/ */
export const IssuedInvoiceSummaryDtoAdapter = { export const IssuedInvoiceSummaryDtoAdapter = {
fromDto(pageDto: IssuedInvoiceSummaryPage, context?: unknown): IssuedInvoiceSummaryPageData { fromDto(pageDto: IssuedInvoiceSummaryPage, context?: unknown): IssuedInvoiceSummaryPageData {
console.log(pageDto);
return { return {
...pageDto, ...pageDto,
items: pageDto.items.map( items: pageDto.items.map(

View File

@ -89,7 +89,7 @@ export function useIssuedInvoicesGridColumns(
cell: ({ row }) => { cell: ({ row }) => {
const { verifactu } = row.original; const { verifactu } = row.original;
const isPending = verifactu.status === "Pendiente"; const isPending = verifactu.status === "Pendiente";
console.log(verifactu.status);
return ( return (
<> <>
{isPending ? ( {isPending ? (
@ -331,12 +331,11 @@ export function useIssuedInvoicesGridColumns(
return ( return (
<ButtonGroup> <ButtonGroup>
{/* Descargar en PDF */} {/* Descargar en PDF */}
{/* Descargar en PDF */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className="size-8" className={"size-8"}
disabled={isPDFLoading || !isCompleted} disabled={isPDFLoading || !isCompleted}
onClick={(e) => { onClick={(e) => {
stop(e); stop(e);
@ -346,9 +345,9 @@ export function useIssuedInvoicesGridColumns(
variant="ghost" variant="ghost"
> >
{isPDFLoading ? ( {isPDFLoading ? (
<Spinner className="size-4" /> <Spinner className="size-4 cursor-progress" />
) : ( ) : (
<FileDownIcon className="size-4" /> <FileDownIcon className="size-4 cursor-pointer" />
)} )}
<span className="sr-only">Descargar PDF</span> <span className="sr-only">Descargar PDF</span>
</Button> </Button>