.
This commit is contained in:
parent
0171f51c56
commit
7d1c441d34
@ -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";
|
||||||
|
|||||||
41
apps/server/src/common/domain/value-objects/utc-date.spec.ts
Normal file
41
apps/server/src/common/domain/value-objects/utc-date.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/server/src/common/domain/value-objects/utc-date.ts
Normal file
67
apps/server/src/common/domain/value-objects/utc-date.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./invoice-status";
|
||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user