From 7d1c441d34cdc2f87617df062af7474dfed3f7ec Mon Sep 17 00:00:00 2001 From: david Date: Tue, 25 Feb 2025 18:27:07 +0100 Subject: [PATCH] . --- .../src/common/domain/value-objects/index.ts | 1 + .../domain/value-objects/utc-date.spec.ts | 41 ++++++++++++ .../common/domain/value-objects/utc-date.ts | 67 +++++++++++++++++++ .../customer-invoice/customer-invoice.ts | 53 ++++++++++++++- .../domain/value-objetcs/index.ts | 1 + .../value-objetcs/invoice-status.spec.ts | 56 ++++++++++++++++ .../domain/value-objetcs/invoice-status.ts | 56 ++++++++++++++++ .../mappers/customer-invoice.mapper.ts | 29 +++++++- .../sequelize/customer-invoice.model.ts | 4 +- 9 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/common/domain/value-objects/utc-date.spec.ts create mode 100644 apps/server/src/common/domain/value-objects/utc-date.ts create mode 100644 apps/server/src/contexts/customer-billing/domain/value-objetcs/index.ts create mode 100644 apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.spec.ts create mode 100644 apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.ts diff --git a/apps/server/src/common/domain/value-objects/index.ts b/apps/server/src/common/domain/value-objects/index.ts index d3dca809..5d341ab4 100644 --- a/apps/server/src/common/domain/value-objects/index.ts +++ b/apps/server/src/common/domain/value-objects/index.ts @@ -8,4 +8,5 @@ export * from "./quantity"; export * from "./slug"; export * from "./tin-number"; export * from "./unique-id"; +export * from "./utc-date"; export * from "./value-object"; diff --git a/apps/server/src/common/domain/value-objects/utc-date.spec.ts b/apps/server/src/common/domain/value-objects/utc-date.spec.ts new file mode 100644 index 00000000..4eb2b57e --- /dev/null +++ b/apps/server/src/common/domain/value-objects/utc-date.spec.ts @@ -0,0 +1,41 @@ +import { UtcDate } from "./utc-date"; + +describe("UtcDate Value Object con Zod y Result", () => { + test("Debe crear una instancia con fecha y hora en UTC", () => { + const result = UtcDate.create("2025-01-06T19:36:18Z"); + expect(result.isSuccess).toBe(true); + expect(result.data?.toISOString()).toBe("2025-01-06T19:36:18.000Z"); + expect(result.data?.toDateString()).toBe("2025-01-06"); + }); + + test("Debe crear una instancia con solo fecha (sin hora)", () => { + const result = UtcDate.create("2020-11-12"); + expect(result.isSuccess).toBe(true); + expect(result.data?.toISOString()).toBe("2020-11-12T00:00:00.000Z"); // Normalizado con 00:00 UTC + expect(result.data?.toDateString()).toBe("2020-11-12"); + }); + + test("Debe devolver un Result.fail para formatos inválidos", () => { + expect(UtcDate.create("2020-07-32").isFailure).toBe(true); + expect(UtcDate.create("invalid-date").isFailure).toBe(true); + expect(UtcDate.create("2020/11/12").isFailure).toBe(true); + }); + + test("Debe comparar correctamente dos fechas idénticas", () => { + const date1 = UtcDate.create("2023-12-31T23:59:59Z").data!; + const date2 = UtcDate.create("2023-12-31T23:59:59Z").data!; + expect(date1.equals(date2)).toBe(true); + }); + + test("Debe comparar correctamente dos fechas diferentes", () => { + const date1 = UtcDate.create("2023-12-31T23:59:59Z").data!; + const date2 = UtcDate.create("2023-12-30T23:59:59Z").data!; + expect(date1.equals(date2)).toBe(false); + }); + + test("Debe manejar fechas sin hora correctamente en equals", () => { + const date1 = UtcDate.create("2020-11-12").data!; + const date2 = UtcDate.create("2020-11-12T00:00:00Z").data!; + expect(date1.equals(date2)).toBe(true); + }); +}); diff --git a/apps/server/src/common/domain/value-objects/utc-date.ts b/apps/server/src/common/domain/value-objects/utc-date.ts new file mode 100644 index 00000000..c6472588 --- /dev/null +++ b/apps/server/src/common/domain/value-objects/utc-date.ts @@ -0,0 +1,67 @@ +import { Result } from "@common/helpers"; +import { z } from "zod"; +import { ValueObject } from "./value-object"; + +interface IUtcDateProps { + value: string; +} + +export class UtcDate extends ValueObject { + private readonly date!: Date; + + private constructor(props: IUtcDateProps) { + super(props); + const { value: dateString } = props; + this.date = Object.freeze(new Date(dateString)); + } + + static validate(dateString: string) { + const dateStr = z.union([ + z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "Invalid ISO 8601 format"), + z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid YYYY-MM-DD format"), + ]); + + const dateStrToDate = dateStr.pipe(z.coerce.date()); + + return dateStrToDate.safeParse(dateString); + } + + /** + * Crea una instancia de UtcDate a partir de un string en formato UTC. + * @param dateString Fecha en formato UTC (con o sin hora) + * @returns UtcDate si es válida, Error en caso contrario. + */ + static create(dateString: string): Result { + const dateIsValid = UtcDate.validate(dateString); + if (!dateIsValid.success) { + return Result.fail(new Error(`Invalid UTC date format: ${dateString}`)); + } + + return Result.ok(new UtcDate({ value: dateString })); + } + + getValue(): string { + return this.props.value; + } + + /** + * Devuelve la fecha en formato UTC sin hora (YYYY-MM-DD). + */ + toDateString(): string { + return this.date.toISOString().split("T")[0]; + } + + /** + * Devuelve la fecha en formato UTC con hora (ISO 8601). + */ + toISOString(): string { + return this.date.toISOString(); + } + + /** + * Compara si dos instancias de UtcDate son iguales. + */ + equals(other: UtcDate): boolean { + return this.toISOString() === other.toISOString(); + } +} diff --git a/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/customer-invoice.ts b/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/customer-invoice.ts index 9266cfd3..fc0a8142 100644 --- a/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/customer-invoice.ts +++ b/apps/server/src/contexts/customer-billing/domain/aggregates/customer-invoice/customer-invoice.ts @@ -1,9 +1,32 @@ -import { AggregateRoot, UniqueID } from "@common/domain"; +import { AggregateRoot, UniqueID, UtcDate } from "@common/domain"; import { Result } from "@common/helpers"; +import { CustomerInvoiceItem } from "../../entities"; +import { InvoiceStatus } from "../../value-objetcs"; +import { Customer } from "../customer/customer"; -export interface ICustomerInvoiceProps {} +export interface ICustomerInvoiceProps { + status: InvoiceStatus; + issueDate: UtcDate; + invoiceNumber: string; + invoiceType: string; -export interface ICustomerInvoice {} + customer: Customer; + items: CustomerInvoiceItem[]; +} + +export interface ICustomerInvoice { + id: UniqueID; + status: InvoiceStatus; + issueDate: UtcDate; + invoiceNumber: string; + invoiceType: string; + + customer: Customer; + items: CustomerInvoiceItem[]; + + //send(); + //accept(); +} export class CustomerInvoice extends AggregateRoot @@ -22,4 +45,28 @@ export class CustomerInvoice return Result.ok(invoice); } + + get status(): InvoiceStatus { + return this.props.status; + } + + get issueDate(): UtcDate { + return this.props.issueDate; + } + + get invoiceNumber(): string { + return this.props.invoiceNumber; + } + + get invoiceType(): string { + return this.props.invoiceType; + } + + get customer(): Customer { + return this.props.customer; + } + + get items(): CustomerInvoiceItem[] { + return this.props.items; + } } diff --git a/apps/server/src/contexts/customer-billing/domain/value-objetcs/index.ts b/apps/server/src/contexts/customer-billing/domain/value-objetcs/index.ts new file mode 100644 index 00000000..992c7ed2 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/value-objetcs/index.ts @@ -0,0 +1 @@ +export * from "./invoice-status"; diff --git a/apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.spec.ts b/apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.spec.ts new file mode 100644 index 00000000..11470190 --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.spec.ts @@ -0,0 +1,56 @@ +import { InvoiceStatus } from "./invoice-status"; + +// Pruebas para InvoiceStatus +describe("InvoiceStatus", () => { + test("Debe crear estados válidos", () => { + const result1 = InvoiceStatus.create("borrador"); + const result2 = InvoiceStatus.create("enviada"); + const result3 = InvoiceStatus.create("cerrada"); + + expect(result1.isSuccess).toBe(true); + expect(result1.data.getValue()).toBe("borrador"); + expect(result1.data.toString()).toBe("borrador"); + + expect(result2.isSuccess).toBe(true); + expect(result2.data.getValue()).toBe("enviada"); + expect(result2.data.toString()).toBe("enviada"); + + expect(result3.isSuccess).toBe(true); + expect(result3.data.getValue()).toBe("cerrada"); + expect(result3.data.toString()).toBe("cerrada"); + }); + + test("Debe lanzar error al crear un estado inválido", () => { + const result = InvoiceStatus.create("inexistente"); + expect(result.isFailure).toBe(true); + }); + + test("Debe permitir transiciones válidas #1", () => { + const result = InvoiceStatus.create("borrador"); + expect(result.isSuccess).toBe(true); + expect(result.data.canTransitionTo("enviada")).toBe(true); + }); + + test("Debe permitir transiciones válidas #2", () => { + const result = InvoiceStatus.create("borrador"); + expect(result.isSuccess).toBe(true); + + const new_state = result.data.transitionTo("enviada"); + expect(new_state.isSuccess).toBe(true); + expect(new_state.data.toString()).toBe("enviada"); + }); + + test("Debe impedir transiciones inválidas", () => { + const result = InvoiceStatus.create("cerrada"); + expect(result.isSuccess).toBe(true); + expect(result.data.canTransitionTo("enviada")).toBe(false); + }); + + test("Debe lanzar error al intentar transición inválida", () => { + const result = InvoiceStatus.create("borrador"); + expect(result.isSuccess).toBe(true); + const new_state = result.data.transitionTo("cerrada"); + expect(new_state.isSuccess).toBe(false); + expect(new_state.error.message).toContain("Transición no permitida de borrador a cerrada"); + }); +}); diff --git a/apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.ts b/apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.ts new file mode 100644 index 00000000..d26596cb --- /dev/null +++ b/apps/server/src/contexts/customer-billing/domain/value-objetcs/invoice-status.ts @@ -0,0 +1,56 @@ +import { ValueObject } from "@common/domain"; +import { Result } from "@common/helpers"; + +interface IInvoiceStatusProps { + value: string; +} + +export class InvoiceStatus extends ValueObject { + private static readonly ALLOWED_STATUSES = [ + "borrador", + "enviada", + "aceptada", + "registrada", + "rechazada", + "cerrada", + "error", + ]; + + private static readonly TRANSITIONS: Record = { + borrador: ["enviada"], + enviada: ["aceptada", "registrada", "rechazada", "error"], + aceptada: ["registrada"], + registrada: ["cerrada"], + rechazada: ["cerrada"], + error: ["borrador"], + cerrada: [], + }; + + static create(value: string): Result { + if (!this.ALLOWED_STATUSES.includes(value)) { + return Result.fail(new Error(`Estado de factura no válido: ${value}`)); + } + return Result.ok(new InvoiceStatus({ value })); + } + + getValue(): string { + return this.props.value; + } + + canTransitionTo(nextStatus: string): boolean { + return InvoiceStatus.TRANSITIONS[this.props.value].includes(nextStatus); + } + + transitionTo(nextStatus: string): Result { + if (!this.canTransitionTo(nextStatus)) { + return Result.fail( + new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`) + ); + } + return InvoiceStatus.create(nextStatus); + } + + toString(): string { + return this.getValue(); + } +} diff --git a/apps/server/src/contexts/customer-billing/infraestructure/mappers/customer-invoice.mapper.ts b/apps/server/src/contexts/customer-billing/infraestructure/mappers/customer-invoice.mapper.ts index 686d52b5..ff38a0ec 100644 --- a/apps/server/src/contexts/customer-billing/infraestructure/mappers/customer-invoice.mapper.ts +++ b/apps/server/src/contexts/customer-billing/infraestructure/mappers/customer-invoice.mapper.ts @@ -1,3 +1,4 @@ +import { UniqueID, UtcDate } from "@common/domain"; import { Result } from "@common/helpers"; import { ISequelizeMapper, @@ -5,6 +6,7 @@ import { SequelizeMapper, } from "@common/infrastructure/sequelize/sequelize-mapper"; import { CustomerInvoice } from "@contexts/customer-billing/domain"; +import { InvoiceStatus } from "@contexts/customer-billing/domain/value-objetcs"; import { CustomerInvoiceCreationAttributes, CustomerInvoiceModel, @@ -25,8 +27,27 @@ export class CustomerInvoiceMapper source: CustomerInvoiceModel, params?: MapperParamsType ): Result { - /*const idOrError = UniqueID.create(source.id); - const tinOrError = TINNumber.create(source.tin); + const idOrError = UniqueID.create(source.id); + const statusOrError = InvoiceStatus.create(source.status); + const issueDateOrError = UtcDate.create(source.issue_date); + + const result = Result.combine([idOrError, statusOrError]); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return CustomerInvoice.create( + { + status: statusOrError.data, + issueDate: issueDateOrError.data, + invoiceNumber: source.invoice_number, + invoiceType: source.invoice_type, + }, + idOrError.data + ); + + /*const tinOrError = TINNumber.create(source.tin); const emailOrError = EmailAddress.create(source.email); const phoneOrError = PhoneNumber.create(source.phone); const faxOrError = PhoneNumber.createNullable(source.fax); @@ -77,6 +98,10 @@ export class CustomerInvoiceMapper source: CustomerInvoice, params?: MapperParamsType ): Result { + return Result.ok({ + id: source.id.toString(), + status: source.status.toString(), + }); /*return Result.ok({ id: source.id.toString(), reference: source.reference, diff --git a/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice.model.ts b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice.model.ts index 7e0f7f2a..913313b9 100644 --- a/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice.model.ts +++ b/apps/server/src/contexts/customer-billing/infraestructure/sequelize/customer-invoice.model.ts @@ -48,7 +48,7 @@ export class CustomerInvoiceModel extends Model< declare issue_date: string; declare invoice_number: string; - declare invoide_type: string; + declare invoice_type: string; declare lang_code: string; declare currency_code: string; @@ -110,7 +110,7 @@ export default (sequelize: Sequelize) => { allowNull: false, }, - invoide_type: { + invoice_type: { type: new DataTypes.STRING(), allowNull: false, },