Verifactu

This commit is contained in:
David Arranz 2025-11-17 19:12:52 +01:00
parent 89e22de2bd
commit 4c4afe2b3a
14 changed files with 583 additions and 49 deletions

View File

@ -1,7 +1,9 @@
import { Collection, Result, ResultCollection } from "@repo/rdx-utils"; import { Collection, Result, ResultCollection } from "@repo/rdx-utils";
import { Model } from "sequelize"; import type { Model } from "sequelize";
import { MapperParamsType } from "../../../domain";
import { ISequelizeDomainMapper } from "./sequelize-mapper.interface"; import type { MapperParamsType } from "../../../domain";
import type { ISequelizeDomainMapper } from "./sequelize-mapper.interface";
export abstract class SequelizeDomainMapper<TModel extends Model, TModelAttributes, TEntity> export abstract class SequelizeDomainMapper<TModel extends Model, TModelAttributes, TEntity>
implements ISequelizeDomainMapper<TModel, TModelAttributes, TEntity> implements ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>

View File

@ -1,4 +1,4 @@
import { DomainMapperWithBulk, IQueryMapperWithBulk } from "../../../domain"; import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "../../../domain";
export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity> export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {} extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}

View File

@ -55,6 +55,7 @@ export class IssueProformaUseCase {
proformaId, proformaId,
transaction transaction
); );
if (proformaResult.isFailure) return Result.fail(proformaResult.error); if (proformaResult.isFailure) return Result.fail(proformaResult.error);
const proforma = proformaResult.data; const proforma = proformaResult.data;
@ -64,6 +65,7 @@ export class IssueProformaUseCase {
proforma.series, proforma.series,
transaction transaction
); );
if (nextNumberResult.isFailure) return Result.fail(nextNumberResult.error); if (nextNumberResult.isFailure) return Result.fail(nextNumberResult.error);
/** 4. Crear factura definitiva (dominio) */ /** 4. Crear factura definitiva (dominio) */
@ -71,6 +73,7 @@ export class IssueProformaUseCase {
issueNumber: nextNumberResult.data, issueNumber: nextNumberResult.data,
issueDate: UtcDate.today(), issueDate: UtcDate.today(),
}); });
if (issuedInvoiceOrError.isFailure) return Result.fail(issuedInvoiceOrError.error); if (issuedInvoiceOrError.isFailure) return Result.fail(issuedInvoiceOrError.error);
/** 5. Guardar la nueva factura */ /** 5. Guardar la nueva factura */

View File

@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.css"
referrerpolicy="no-referrer" />
<title>Factura F26200</title>
<style>
body {
font-family: Tahoma, sans-serif;
margin: 40px;
color: #333;
font-size: 11pt;
line-height: 1.6;
}
header {
font-family: Tahoma, sans-serif;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
margin-bottom: 0;
padding-bottom: 0;
}
.accent-color {
background-color: #F08119;
}
.company-info,
.invoice-meta {
width: 48%;
}
.invoice-meta {
text-align: right;
}
h1 {
font-size: 20px;
margin-bottom: 5px;
}
.contact {
font-size: 14px;
margin-top: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0px;
margin-bottom: 15px;
}
table th,
table td {
border-top: 0px solid;
border-left: 1px solid #000;
border-right: 1px solid #000;
border-bottom: 0px solid;
padding: 3px 10px;
text-align: left;
vertical-align: top;
}
table th {
margin-bottom: 10px;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
text-align: center;
background-color: #e7e0df;
color: #ff0014;
}
.totals {
margin-top: 20px;
width: 100%;
}
.totals td {
padding: 5px 10px;
}
.totals td.label {
text-align: right;
font-weight: bold;
}
footer {
margin-top: 40px;
font-size: 10px;
}
.highlight {
background-color: #eef;
}
.accent-color {
background-color: #F08119;
}
.info-box {
border: 2px solid black;
border-radius: 12px;
padding: 3px 3px;
display: inline-block;
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<header>
<aside class="flex items-start mb-4 w-full">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="w-[70%] flex flex-col items-start text-left">
<img src="https://rodax-software.com/images/logo_acana.jpg" alt="Logo Acana" class="block h-24 w-auto mb-1" />
<div class="p-3 not-italic text-xs leading-tight" style="font-size: 8pt;">
<p>Aliso Design S.L. B86913910</p>
<p>C/ La Fundición, 27. Pol. Santa Ana</p>
<p>Rivas Vaciamadrid 28522 Madrid</p>
<p>Telf: 91 301 65 57 / 91 301 65 58</p>
<p><a href="mailto:info@acanainteriorismo.com"
class="hover:underline">info@acanainteriorismo.com</a>&nbsp;-&nbsp;<a
href="https://www.acanainteriorismo.com" target="_blank" rel="noopener"
class="hover:underline">www.acanainteriorismo.com</a></p>
</div>
<div class="flex w-full">
<div class="info-box" style="border: 2px solid black; border-radius: 12px; padding: 10px 20px;">
<p>Factura nº:<strong>&nbsp;{{series}}{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="p-3 ml-9">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
<p>{{recipient.postal_code}}&nbsp;&nbsp;{{recipient.city}}&nbsp;&nbsp;{{recipient.province}}</p>
</div>
</div>
</div>
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
<div class="ml-auto flex flex-col items-end text-right">
<img src="https://rodax-software.com/images/factura_acana.jpg" alt="Factura"
class="block h-14 w-auto md:h-8 mb-1" />
</div>
</aside>
</header>
<main id="main">
<section id="details" class="border-b border-black ">
<!-- Tu tabla -->
<table class="table-header">
<thead>
<tr>
<th class="py-2">Concepto</th>
<th class="py-2">Ud.</th>
<th class="py-2">Imp.</th>
<th class="py-2">&nbsp;</th>
<th class="py-2">Imp.&nbsp;total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{description}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
{{/each}}
</tbody>
</table>
</section>
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
<div class="grow relative pt-10 self-start">
{{#if payment_method}}
<div class="">
<p class=" text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
</div>
{{else}}
<!-- Empty payment method-->
{{/if}}
{{#if notes}}
<div class="pt-4">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
</div>
{{else}}
<!-- Empty notes-->
{{/if}}
</div>
<div class="relative pt-10 grow">
<table class=" table-header min-w-full bg-transparent">
<tbody>
{{#if discount_percentage}}
<tr>
<td></td>
<td class="px-4 text-right">Importe&nbsp;neto</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{subtotal_amount}}</td>
</tr>
<tr>
<td></td>
<td class="px-4 text-right">Descuento&nbsp;{{discount_percentage}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{discount_amount.value}}</td>
</tr>
{{else}}
<!-- dto 0-->
{{/if}}
<tr>
<td></td>
<td class="px-4 text-right">Base&nbsp;imponible</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td>
</tr>
{{#each taxes}}
<tr>
<td></td>
<td class="px-4 text-right">{{tax_name}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td>
</tr>
{{/each}}
<tr class="">
<td></td>
<td class="px-4 text-right accent-color">
Total&nbsp;factura
</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right accent-color">
{{total_amount}}</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<footer id="footer" class="mt-4">
<aside>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja M-572991
CIF: B86913910</p>
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más información,
y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la dirección CALLE LA
FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
</aside>
</footer>
</body>
</html>

View File

@ -7,6 +7,8 @@ export interface VerifactuRecordProps {
estado: VerifactuRecordEstado; estado: VerifactuRecordEstado;
url: Maybe<URLAddress>; url: Maybe<URLAddress>;
qrCode: Maybe<string>; qrCode: Maybe<string>;
uuid: Maybe<string>;
operacion: Maybe<string>;
} }
export class VerifactuRecord extends DomainEntity<VerifactuRecordProps> { export class VerifactuRecord extends DomainEntity<VerifactuRecordProps> {
@ -32,6 +34,14 @@ export class VerifactuRecord extends DomainEntity<VerifactuRecordProps> {
return this.props.qrCode; return this.props.qrCode;
} }
get uuid(): Maybe<string> {
return this.props.uuid;
}
get operacion(): Maybe<string> {
return this.props.operacion;
}
getProps(): VerifactuRecordProps { getProps(): VerifactuRecordProps {
return this.props; return this.props;
} }
@ -45,6 +55,8 @@ export class VerifactuRecord extends DomainEntity<VerifactuRecordProps> {
status: this.estado.toString(), status: this.estado.toString(),
url: toEmptyString(this.url, (value) => value.toString()), url: toEmptyString(this.url, (value) => value.toString()),
qr_code: toEmptyString(this.qrCode, (value) => value.toString()), qr_code: toEmptyString(this.qrCode, (value) => value.toString()),
uuid: toEmptyString(this.uuid, (value) => value.toString()),
operacion: toEmptyString(this.operacion, (value) => value.toString()),
}; };
} }
} }

View File

@ -1,13 +1,18 @@
import type { UtcDate } from "@repo/rdx-ddd"; import { UniqueID, type UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoice } from "../aggregates"; import { CustomerInvoice } from "../aggregates";
import { VerifactuRecord } from "../entities";
import { EntityIsNotProformaError, ProformaCannotBeConvertedToInvoiceError } from "../errors"; import { EntityIsNotProformaError, ProformaCannotBeConvertedToInvoiceError } from "../errors";
import { import {
CustomerInvoiceIsProformaSpecification, CustomerInvoiceIsProformaSpecification,
ProformaCanTranstionToIssuedSpecification, ProformaCanTranstionToIssuedSpecification,
} from "../specs"; } from "../specs";
import { type CustomerInvoiceNumber, CustomerInvoiceStatus } from "../value-objects"; import {
type CustomerInvoiceNumber,
CustomerInvoiceStatus,
VerifactuRecordEstado,
} from "../value-objects";
/** /**
* Servicio de dominio que encapsula la lógica de emisión de factura definitiva desde una proforma. * Servicio de dominio que encapsula la lógica de emisión de factura definitiva desde una proforma.
@ -43,6 +48,23 @@ export class IssueCustomerInvoiceDomainService {
return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proforma.id.toString())); return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proforma.id.toString()));
} }
const verifactuRecordOrError = VerifactuRecord.create(
{
estado: VerifactuRecordEstado.createPendiente(),
qrCode: Maybe.none(),
url: Maybe.none(),
uuid: Maybe.none(),
operacion: Maybe.none(),
},
UniqueID.generateNewID()
);
if (verifactuRecordOrError.isFailure) {
return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proforma.id.toString()));
}
const verifactuRecord = verifactuRecordOrError.data;
/** 3. Generar la nueva factura definitiva (inmutable) */ /** 3. Generar la nueva factura definitiva (inmutable) */
const proformaProps = proforma.getProps(); const proformaProps = proforma.getProps();
const newInvoiceOrError = CustomerInvoice.create({ const newInvoiceOrError = CustomerInvoice.create({
@ -53,6 +75,7 @@ export class IssueCustomerInvoiceDomainService {
invoiceNumber: issueNumber, invoiceNumber: issueNumber,
invoiceDate: issueDate, invoiceDate: issueDate,
description: proformaProps.description.isNone() ? Maybe.some(".") : proformaProps.description, description: proformaProps.description.isNone() ? Maybe.some(".") : proformaProps.description,
verifactu: Maybe.some(verifactuRecord),
}); });
if (newInvoiceOrError.isFailure) { if (newInvoiceOrError.isFailure) {

View File

@ -1,25 +1,34 @@
import { ISequelizeDomainMapper, MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import { import {
type ISequelizeDomainMapper,
type MapperParamsType,
SequelizeDomainMapper,
} from "@erp/core/api";
import {
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError, extractOrPushError,
maybeFromNullableVO, maybeFromNullableVO,
toNullable, toNullable,
UniqueID,
ValidationErrorCollection,
ValidationErrorDetail,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { import {
CustomerInvoice, type CustomerInvoice,
CustomerInvoiceItem, CustomerInvoiceItem,
CustomerInvoiceItemDescription, CustomerInvoiceItemDescription,
CustomerInvoiceItemProps, type CustomerInvoiceItemProps,
CustomerInvoiceProps, type CustomerInvoiceProps,
ItemAmount, ItemAmount,
ItemDiscount, ItemDiscount,
ItemQuantity, ItemQuantity,
ItemTaxes, ItemTaxes,
} from "../../../domain"; } from "../../../domain";
import { CustomerInvoiceItemCreationAttributes, CustomerInvoiceItemModel } from "../../sequelize"; import type {
CustomerInvoiceItemCreationAttributes,
CustomerInvoiceItemModel,
} from "../../sequelize";
import { ItemTaxesDomainMapper } from "./item-taxes.mapper"; import { ItemTaxesDomainMapper } from "./item-taxes.mapper";
export interface ICustomerInvoiceItemDomainMapper export interface ICustomerInvoiceItemDomainMapper

View File

@ -1,31 +1,38 @@
import { ISequelizeDomainMapper, MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import {
type ISequelizeDomainMapper,
type MapperParamsType,
SequelizeDomainMapper,
} from "@erp/core/api";
import { import {
CurrencyCode, CurrencyCode,
extractOrPushError,
LanguageCode, LanguageCode,
maybeFromNullableVO,
Percentage, Percentage,
TextValue, TextValue,
toNullable,
UniqueID, UniqueID,
UtcDate, UtcDate,
ValidationErrorCollection, ValidationErrorCollection,
ValidationErrorDetail, type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
toNullable,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Collection, isNullishOrEmpty, Maybe, Result } from "@repo/rdx-utils"; import { Collection, Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import { import {
CustomerInvoice, CustomerInvoice,
CustomerInvoiceItems, CustomerInvoiceItems,
CustomerInvoiceNumber, CustomerInvoiceNumber,
CustomerInvoiceProps, type CustomerInvoiceProps,
CustomerInvoiceSerie, CustomerInvoiceSerie,
CustomerInvoiceStatus, CustomerInvoiceStatus,
InvoicePaymentMethod, InvoicePaymentMethod,
} from "../../../domain"; } from "../../../domain";
import { CustomerInvoiceCreationAttributes, CustomerInvoiceModel } from "../../sequelize"; import type { CustomerInvoiceCreationAttributes, CustomerInvoiceModel } from "../../sequelize";
import { CustomerInvoiceItemDomainMapper } from "./customer-invoice-item.mapper"; import { CustomerInvoiceItemDomainMapper } from "./customer-invoice-item.mapper";
import { InvoiceRecipientDomainMapper } from "./invoice-recipient.mapper"; import { InvoiceRecipientDomainMapper } from "./invoice-recipient.mapper";
import { TaxesDomainMapper } from "./invoice-taxes.mapper"; import { TaxesDomainMapper } from "./invoice-taxes.mapper";
import { CustomerInvoiceVerifactuDomainMapper } from "./invoice-verifactu.mapper";
export interface ICustomerInvoiceDomainMapper export interface ICustomerInvoiceDomainMapper
extends ISequelizeDomainMapper< extends ISequelizeDomainMapper<
@ -45,6 +52,7 @@ export class CustomerInvoiceDomainMapper
private _itemsMapper: CustomerInvoiceItemDomainMapper; private _itemsMapper: CustomerInvoiceItemDomainMapper;
private _recipientMapper: InvoiceRecipientDomainMapper; private _recipientMapper: InvoiceRecipientDomainMapper;
private _taxesMapper: TaxesDomainMapper; private _taxesMapper: TaxesDomainMapper;
private _verifactuMapper: CustomerInvoiceVerifactuDomainMapper;
constructor(params: MapperParamsType) { constructor(params: MapperParamsType) {
super(); super();
@ -52,6 +60,7 @@ export class CustomerInvoiceDomainMapper
this._itemsMapper = new CustomerInvoiceItemDomainMapper(params); // Instanciar el mapper de items this._itemsMapper = new CustomerInvoiceItemDomainMapper(params); // Instanciar el mapper de items
this._recipientMapper = new InvoiceRecipientDomainMapper(); this._recipientMapper = new InvoiceRecipientDomainMapper();
this._taxesMapper = new TaxesDomainMapper(params); this._taxesMapper = new TaxesDomainMapper(params);
this._verifactuMapper = new CustomerInvoiceVerifactuDomainMapper();
} }
private _mapAttributesToDomain(source: CustomerInvoiceModel, params?: MapperParamsType) { private _mapAttributesToDomain(source: CustomerInvoiceModel, params?: MapperParamsType) {
@ -219,7 +228,22 @@ export class CustomerInvoiceDomainMapper
}); });
}*/ }*/
// 3) Items (colección) // 3) Verifactu (snapshot en la factura o include)
const verifactuResult = this._verifactuMapper.mapToDomain(source.verifactu, {
errors,
attributes,
...params,
});
/*if (verifactuResult.isFailure) {
errors.push({
path: "verifactu",
message: verifactuResult.error.message,
});
}*/
// 4) Items (colección)
const itemsResults = this._itemsMapper.mapToDomainCollection( const itemsResults = this._itemsMapper.mapToDomainCollection(
source.items, source.items,
source.items.length, source.items.length,
@ -249,6 +273,7 @@ export class CustomerInvoiceDomainMapper
// 6) Construcción del agregado (Dominio) // 6) Construcción del agregado (Dominio)
const verifactu = verifactuResult.data;
const recipient = recipientResult.data; const recipient = recipientResult.data;
const items = CustomerInvoiceItems.create({ const items = CustomerInvoiceItems.create({
@ -283,6 +308,7 @@ export class CustomerInvoiceDomainMapper
paymentMethod: attributes.paymentMethod!, paymentMethod: attributes.paymentMethod!,
items, items,
verifactu,
}; };
const createResult = CustomerInvoice.create(invoiceProps, attributes.invoiceId); const createResult = CustomerInvoice.create(invoiceProps, attributes.invoiceId);
@ -342,7 +368,14 @@ export class CustomerInvoiceDomainMapper
...params, ...params,
}); });
// 4) Si hubo errores de mapeo, devolvemos colección de validación // 4) Verifactu
const verifactuResult = this._verifactuMapper.mapToPersistence(source.verifactu, {
errors,
parent: source,
...params,
});
// 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(
new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors) new ValidationErrorCollection("Customer invoice mapping to persistence failed", errors)
@ -351,6 +384,7 @@ export class CustomerInvoiceDomainMapper
const items = itemsResult.data; const items = itemsResult.data;
const taxes = taxesResult.data; const taxes = taxesResult.data;
const verifactu = verifactuResult.data;
const allAmounts = source.getAllAmounts(); // Da los totales ya calculados const allAmounts = source.getAllAmounts(); // Da los totales ya calculados
@ -404,6 +438,7 @@ export class CustomerInvoiceDomainMapper
taxes, taxes,
items, items,
verifactu,
}; };
return Result.ok<CustomerInvoiceCreationAttributes>( return Result.ok<CustomerInvoiceCreationAttributes>(

View File

@ -1,21 +1,22 @@
import { MapperParamsType } from "@erp/core/api"; import type { MapperParamsType } from "@erp/core/api";
import { import {
City, City,
Country, Country,
extractOrPushError,
maybeFromNullableVO,
Name, Name,
PostalCode, PostalCode,
Province, Province,
Street, Street,
TINNumber, TINNumber,
toNullable,
ValidationErrorCollection, ValidationErrorCollection,
ValidationErrorDetail, type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
toNullable,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoice, CustomerInvoiceProps, InvoiceRecipient } from "../../../domain";
import { CustomerInvoiceModel } from "../../sequelize"; import { type CustomerInvoice, type CustomerInvoiceProps, InvoiceRecipient } from "../../../domain";
import type { CustomerInvoiceModel } from "../../sequelize";
export class InvoiceRecipientDomainMapper { export class InvoiceRecipientDomainMapper {
public mapToDomain( public mapToDomain(
@ -133,7 +134,7 @@ export class InvoiceRecipientDomainMapper {
const { isProforma, hasRecipient } = parent; const { isProforma, hasRecipient } = parent;
// Validación: facturas emitidas deben tener destinatario. // Validación: facturas emitidas deben tener destinatario.
if (!isProforma && !hasRecipient) { if (!(isProforma || hasRecipient)) {
errors.push({ errors.push({
path: "recipient", path: "recipient",
message: "[CustomerInvoiceDomainMapper] Issued customer invoice without recipient data", message: "[CustomerInvoiceDomainMapper] Issued customer invoice without recipient data",

View File

@ -0,0 +1,142 @@
import type { MapperParamsType } from "@erp/core/api";
import { type ISequelizeDomainMapper, SequelizeDomainMapper } from "@erp/core/api";
import {
URLAddress,
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
toNullable,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type CustomerInvoice,
type CustomerInvoiceProps,
VerifactuRecord,
VerifactuRecordEstado,
} from "../../../domain";
import type { VerifactuRecordCreationAttributes, VerifactuRecordModel } from "../../sequelize";
export interface ICustomerInvoiceVerifactuDomainMapper
extends ISequelizeDomainMapper<
VerifactuRecordModel,
VerifactuRecordCreationAttributes,
Maybe<VerifactuRecord>
> {}
export class CustomerInvoiceVerifactuDomainMapper
extends SequelizeDomainMapper<
VerifactuRecordModel,
VerifactuRecordCreationAttributes,
Maybe<VerifactuRecord>
>
implements ICustomerInvoiceVerifactuDomainMapper
{
public mapToDomain(
source: VerifactuRecordModel,
params?: MapperParamsType
): Result<Maybe<VerifactuRecord>, Error> {
const { errors, attributes } = params as {
errors: ValidationErrorDetail[];
attributes: Partial<CustomerInvoiceProps>;
};
if (!source) {
return Result.ok(Maybe.none());
}
const recordId = extractOrPushError(UniqueID.create(source.id), "id", errors);
const estado = extractOrPushError(
VerifactuRecordEstado.create(source.estado),
"estado",
errors
);
const qr = extractOrPushError(
maybeFromNullableVO(source.qr, (value) => Result.ok(String(value))),
"qr",
errors
);
const url = extractOrPushError(
maybeFromNullableVO(source.url, (value) => URLAddress.create(value)),
"url",
errors
);
const uuid = extractOrPushError(
maybeFromNullableVO(source.uuid, (value) => Result.ok(String(value))),
"uuid",
errors
);
const operacion = extractOrPushError(
maybeFromNullableVO(source.operacion, (value) => Result.ok(String(value))),
"operacion",
errors
);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Verifactu record mapping failed [mapToDTO]", errors)
);
}
const createResult = VerifactuRecord.create(
{
estado: estado!,
qrCode: qr!,
url: url!,
uuid: uuid!,
operacion: operacion!,
},
recordId!
);
if (createResult.isFailure) {
return Result.fail(
new ValidationErrorCollection("Invoice verifactu entity creation failed", [
{ path: "verifactu", message: createResult.error.message },
])
);
}
return Result.ok(Maybe.some(createResult.data));
}
mapToPersistence(
source: Maybe<VerifactuRecord>,
params?: MapperParamsType
): Result<VerifactuRecordCreationAttributes, Error> {
const { errors, parent } = params as {
parent: CustomerInvoice;
errors: ValidationErrorDetail[];
};
if (source.isNone()) {
return Result.ok({
id: UniqueID.generateNewID().toPrimitive(),
invoice_id: parent.id.toPrimitive(),
estado: VerifactuRecordEstado.createPendiente().toPrimitive(),
qr: null,
url: null,
uuid: null,
operacion: null,
});
}
const verifactu = source.unwrap();
return Result.ok({
id: verifactu.id.toPrimitive(),
invoice_id: parent.id.toPrimitive(),
estado: verifactu.estado.toPrimitive(),
qr: toNullable(verifactu.qrCode, (v) => v),
url: toNullable(verifactu.url, (v) => v.toPrimitive()),
uuid: toNullable(verifactu.uuid, (v) => v),
operacion: toNullable(verifactu.operacion, (v) => v),
});
}
}

View File

@ -46,6 +46,18 @@ export class VerifactuRecordListMapper
errors errors
); );
const uuid = extractOrPushError(
maybeFromNullableVO(raw.uuid, (value) => Result.ok(String(value))),
"uuid",
errors
);
const operacion = extractOrPushError(
maybeFromNullableVO(raw.operacion, (value) => Result.ok(String(value))),
"operacion",
errors
);
if (errors.length > 0) { if (errors.length > 0) {
return Result.fail( return Result.fail(
new ValidationErrorCollection("Verifactu record mapping failed [mapToDTO]", errors) new ValidationErrorCollection("Verifactu record mapping failed [mapToDTO]", errors)
@ -57,6 +69,8 @@ export class VerifactuRecordListMapper
estado: estado!, estado: estado!,
qrCode: qr!, qrCode: qr!,
url: url!, url: url!,
uuid: uuid!,
operacion: operacion!,
}, },
recordId! recordId!
); );

View File

@ -17,14 +17,18 @@ import type {
CustomerInvoiceTaxCreationAttributes, CustomerInvoiceTaxCreationAttributes,
CustomerInvoiceTaxModel, CustomerInvoiceTaxModel,
} from "./customer-invoice-tax.model"; } from "./customer-invoice-tax.model";
import type { VerifactuRecordModel } from "./verifactu-record.model"; import type {
VerifactuRecordCreationAttributes,
VerifactuRecordModel,
} from "./verifactu-record.model";
export type CustomerInvoiceCreationAttributes = InferCreationAttributes< export type CustomerInvoiceCreationAttributes = InferCreationAttributes<
CustomerInvoiceModel, CustomerInvoiceModel,
{ omit: "items" | "taxes" | "current_customer" } { omit: "items" | "taxes" | "current_customer" | "verifactu" }
> & { > & {
items?: CustomerInvoiceItemCreationAttributes[]; items?: CustomerInvoiceItemCreationAttributes[];
taxes?: CustomerInvoiceTaxCreationAttributes[]; taxes?: CustomerInvoiceTaxCreationAttributes[];
verifactu?: VerifactuRecordCreationAttributes;
}; };
export class CustomerInvoiceModel extends Model< export class CustomerInvoiceModel extends Model<

View File

@ -1,4 +1,5 @@
import { import {
type CreationOptional,
DataTypes, DataTypes,
type InferAttributes, type InferAttributes,
type InferCreationAttributes, type InferCreationAttributes,
@ -6,7 +7,10 @@ import {
type Sequelize, type Sequelize,
} from "sequelize"; } from "sequelize";
export type VerifactuRecordCreationAttributes = InferCreationAttributes<VerifactuRecordModel>; export type VerifactuRecordCreationAttributes = InferCreationAttributes<
VerifactuRecordModel,
{ omit: "url" | "qr" | "uuid" | "operacion" }
>;
export class VerifactuRecordModel extends Model< export class VerifactuRecordModel extends Model<
InferAttributes<VerifactuRecordModel>, InferAttributes<VerifactuRecordModel>,
@ -14,13 +18,12 @@ export class VerifactuRecordModel extends Model<
> { > {
declare id: string; declare id: string;
declare invoice_id: string; declare invoice_id: string;
declare estado: string; declare estado: string;
declare url: string;
declare qr: Blob;
declare uuid: string; declare url: CreationOptional<string>;
declare operacion: string; declare qr: CreationOptional<Blob>;
declare uuid: CreationOptional<string>;
declare operacion: CreationOptional<string>;
static associate(database: Sequelize) { static associate(database: Sequelize) {
const models = database.models; const models = database.models;
@ -64,29 +67,31 @@ export default (database: Sequelize) => {
estado: { estado: {
type: new DataTypes.TEXT(), type: new DataTypes.TEXT(),
allowNull: true, allowNull: false,
defaultValue: null,
}, },
url: { url: {
type: new DataTypes.TEXT(), type: new DataTypes.TEXT(),
allowNull: false, allowNull: false,
defaultValue: "",
}, },
qr: { qr: {
type: new DataTypes.BLOB(), type: new DataTypes.BLOB(),
allowNull: false, allowNull: false,
defaultValue: "",
}, },
uuid: { uuid: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
operacion: { operacion: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: false,
defaultValue: null, defaultValue: "",
}, },
}, },
{ {

View File

@ -28,11 +28,11 @@
"proformas": { "proformas": {
"status": { "status": {
"all": "Todas", "all": "Todas",
"draft": "Borradores", "draft": "Borrador",
"sent": "Enviadas", "sent": "Enviada",
"approved": "Aprovadas", "approved": "Aprobada",
"rejected": "Rechazadas", "rejected": "Rechazada",
"issued": "Emitidas" "issued": "Emitida"
} }
}, },
"issued_invoices": { "issued_invoices": {