This commit is contained in:
David Arranz 2025-02-25 18:27:07 +01:00
parent 0171f51c56
commit 7d1c441d34
9 changed files with 301 additions and 7 deletions

View File

@ -8,4 +8,5 @@ export * from "./quantity";
export * from "./slug"; export * from "./slug";
export * from "./tin-number"; export * from "./tin-number";
export * from "./unique-id"; export * from "./unique-id";
export * from "./utc-date";
export * from "./value-object"; export * from "./value-object";

View File

@ -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);
});
});

View File

@ -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<IUtcDateProps> {
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<UtcDate, Error> {
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();
}
}

View File

@ -1,9 +1,32 @@
import { AggregateRoot, UniqueID } from "@common/domain"; import { AggregateRoot, UniqueID, UtcDate } from "@common/domain";
import { Result } from "@common/helpers"; 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 export class CustomerInvoice
extends AggregateRoot<ICustomerInvoiceProps> extends AggregateRoot<ICustomerInvoiceProps>
@ -22,4 +45,28 @@ export class CustomerInvoice
return Result.ok(invoice); 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;
}
} }

View File

@ -0,0 +1 @@
export * from "./invoice-status";

View File

@ -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");
});
});

View File

@ -0,0 +1,56 @@
import { ValueObject } from "@common/domain";
import { Result } from "@common/helpers";
interface IInvoiceStatusProps {
value: string;
}
export class InvoiceStatus extends ValueObject<IInvoiceStatusProps> {
private static readonly ALLOWED_STATUSES = [
"borrador",
"enviada",
"aceptada",
"registrada",
"rechazada",
"cerrada",
"error",
];
private static readonly TRANSITIONS: Record<string, string[]> = {
borrador: ["enviada"],
enviada: ["aceptada", "registrada", "rechazada", "error"],
aceptada: ["registrada"],
registrada: ["cerrada"],
rechazada: ["cerrada"],
error: ["borrador"],
cerrada: [],
};
static create(value: string): Result<InvoiceStatus, Error> {
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<InvoiceStatus, Error> {
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();
}
}

View File

@ -1,3 +1,4 @@
import { UniqueID, UtcDate } from "@common/domain";
import { Result } from "@common/helpers"; import { Result } from "@common/helpers";
import { import {
ISequelizeMapper, ISequelizeMapper,
@ -5,6 +6,7 @@ import {
SequelizeMapper, SequelizeMapper,
} from "@common/infrastructure/sequelize/sequelize-mapper"; } from "@common/infrastructure/sequelize/sequelize-mapper";
import { CustomerInvoice } from "@contexts/customer-billing/domain"; import { CustomerInvoice } from "@contexts/customer-billing/domain";
import { InvoiceStatus } from "@contexts/customer-billing/domain/value-objetcs";
import { import {
CustomerInvoiceCreationAttributes, CustomerInvoiceCreationAttributes,
CustomerInvoiceModel, CustomerInvoiceModel,
@ -25,8 +27,27 @@ export class CustomerInvoiceMapper
source: CustomerInvoiceModel, source: CustomerInvoiceModel,
params?: MapperParamsType params?: MapperParamsType
): Result<CustomerInvoice, Error> { ): Result<CustomerInvoice, Error> {
/*const idOrError = UniqueID.create(source.id); const idOrError = UniqueID.create(source.id);
const tinOrError = TINNumber.create(source.tin); 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 emailOrError = EmailAddress.create(source.email);
const phoneOrError = PhoneNumber.create(source.phone); const phoneOrError = PhoneNumber.create(source.phone);
const faxOrError = PhoneNumber.createNullable(source.fax); const faxOrError = PhoneNumber.createNullable(source.fax);
@ -77,6 +98,10 @@ export class CustomerInvoiceMapper
source: CustomerInvoice, source: CustomerInvoice,
params?: MapperParamsType params?: MapperParamsType
): Result<CustomerInvoiceCreationAttributes, Error> { ): Result<CustomerInvoiceCreationAttributes, Error> {
return Result.ok({
id: source.id.toString(),
status: source.status.toString(),
});
/*return Result.ok({ /*return Result.ok({
id: source.id.toString(), id: source.id.toString(),
reference: source.reference, reference: source.reference,

View File

@ -48,7 +48,7 @@ export class CustomerInvoiceModel extends Model<
declare issue_date: string; declare issue_date: string;
declare invoice_number: string; declare invoice_number: string;
declare invoide_type: string; declare invoice_type: string;
declare lang_code: string; declare lang_code: string;
declare currency_code: string; declare currency_code: string;
@ -110,7 +110,7 @@ export default (sequelize: Sequelize) => {
allowNull: false, allowNull: false,
}, },
invoide_type: { invoice_type: {
type: new DataTypes.STRING(), type: new DataTypes.STRING(),
allowNull: false, allowNull: false,
}, },