diff --git a/modules/companies/src/api/application/companies/public/services/index.ts b/modules/companies/src/api/application/companies/public/services/index.ts index b36e6fea..dbf3343d 100644 --- a/modules/companies/src/api/application/companies/public/services/index.ts +++ b/modules/companies/src/api/application/companies/public/services/index.ts @@ -1,2 +1,3 @@ export * from "./company-public-finder"; export * from "./company-public-services"; +export * from "./initial-company-creator"; diff --git a/modules/companies/src/api/application/companies/public/services/initial-company-creator.test.ts b/modules/companies/src/api/application/companies/public/services/initial-company-creator.test.ts new file mode 100644 index 00000000..10a6c98a --- /dev/null +++ b/modules/companies/src/api/application/companies/public/services/initial-company-creator.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DuplicateEntityError } from "@erp/core/api"; +import type { IAudit } from "@repo/rdx-audit"; +import { + City, + CountryCode, + Name, + PostalAddress, + PostalCode, + Province, + Street, + TextValue, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import type { Company } from "../../../../domain"; +import { CompanySlug } from "../../../../domain"; +import type { ICompanyRepository } from "../../contracts"; +import { CompanyCreator } from "../../services/company-creator"; + +import { InitialCompanyCreator } from "./initial-company-creator"; + +const address = () => + PostalAddress.create({ + street: Maybe.some(Street.create("Calle Mayor 1").data), + street2: Maybe.none(), + city: Maybe.some(City.create("Madrid").data), + postalCode: Maybe.some(PostalCode.create("28001").data), + province: Maybe.some(Province.create("Madrid").data), + countryCode: Maybe.some(CountryCode.create("ES").data), + }).data; +const input = () => ({ + legalName: Name.create("Rodax").data, + tin: TextValue.create("B12345678").data, + address: address(), +}); + +function setup(error?: Error, duplicate = false) { + const saved: Company[] = []; + const transactions: unknown[] = []; + const repository: ICompanyRepository = { + save: async (company, transaction) => { + transactions.push(transaction); + if (error) return Result.fail(error); + saved.push(company); + return Result.ok(); + }, + update: async () => Result.ok(), + findById: async () => Result.ok(Maybe.none()), + findBySlug: async (_slug, transaction) => { + transactions.push(transaction); + return Result.ok(duplicate ? Maybe.some(saved[0]) : Maybe.none()); + }, + existsActiveById: async () => Result.ok(false), + findActiveByIds: async () => Result.ok(new Collection([])), + }; + const audit: IAudit = { + record: async () => Result.ok(), + recordEvent: async () => Result.ok(), + recordMutation: async () => Result.ok(), + }; + return { + capability: new InitialCompanyCreator(new CompanyCreator(repository, audit)), + saved, + transactions, + }; +} + +test("crea con id, slug, status y fechas internos y persiste dirección completa", async (t) => { + const now = UtcDate.createFromISO("2026-09-07").data; + t.mock.method(UtcDate, "today", () => now); + const { capability, saved } = setup(); + const params = input(); + const result = await capability.create(params); + assert.equal(result.isSuccess, true); + const company = saved[0]; + assert.equal(UniqueID.create(result.data.companyId).isSuccess, true); + assert.equal(result.data.companyId, company.id.toPrimitive()); + assert.equal(result.data.slug, `company-${company.id.toPrimitive()}`); + assert.equal(CompanySlug.create(result.data.slug).isSuccess, true); + assert.equal(company.status.isActive(), true); + assert.equal(company.createdAt, now); + assert.equal(company.updatedAt, now); + assert.equal(company.address.unwrap(), params.address); + assert.equal(company.tin.unwrap(), params.tin); + assert.deepEqual(Object.keys(result.data).sort(), ["companyId", "slug"]); +}); + +test("nombres iguales generan IDs y slugs distintos", async () => { + const { capability } = setup(); + const first = await capability.create(input()); + const second = await capability.create(input()); + assert.equal(first.isSuccess, true); + assert.equal(second.isSuccess, true); + assert.notEqual(first.data.companyId, second.data.companyId); + assert.notEqual(first.data.slug, second.data.slug); +}); + +for (const field of ["street", "city", "postalCode", "province", "countryCode"] as const) { + test(`rechaza dirección sin ${field} antes de persistir`, async () => { + const { capability, saved, transactions } = setup(); + const incomplete = PostalAddress.create({ + ...address().getProps(), + [field]: Maybe.none(), + }).data; + const result = await capability.create({ ...input(), address: incomplete }); + assert.equal(result.isFailure, true); + assert.equal(saved.length, 0); + assert.equal(transactions.length, 0); + }); +} + +test("propaga transaction al chequeo de slug y save", async () => { + const { capability, transactions } = setup(); + const transaction = {}; + const result = await capability.create({ ...input(), transaction }); + assert.equal(result.isSuccess, true); + assert.deepEqual(transactions, [transaction, transaction]); +}); + +for (const error of [ + new Error("persistence failed"), + new DuplicateEntityError("Company", "slug", "duplicate"), +]) { + test(`propaga error repository ${error.name}`, async () => { + const { capability } = setup(error); + const result = await capability.create(input()); + assert.equal(result.isFailure, true); + assert.equal(result.error, error); + }); +} + +test("conflicto de slug conserva el rechazo de CompanyCreator", async () => { + const { capability, saved } = setup(undefined, true); + const first = setup(); + await first.capability.create(input()); + saved.push(first.saved[0]); + const result = await capability.create(input()); + assert.equal(result.isFailure, true); + assert.equal(saved.length, 1); +}); diff --git a/modules/companies/src/api/application/companies/public/services/initial-company-creator.ts b/modules/companies/src/api/application/companies/public/services/initial-company-creator.ts new file mode 100644 index 00000000..f31a4f85 --- /dev/null +++ b/modules/companies/src/api/application/companies/public/services/initial-company-creator.ts @@ -0,0 +1,84 @@ +import type { + EmailAddress, + Name, + PhoneNumber, + PostalAddress, + TextValue, + URLAddress, +} from "@repo/rdx-ddd"; +import { UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { CompanySlug, CompanyStatus } from "../../../../domain"; +import type { ICompanyCreator } from "../../services/company-creator"; + +export interface CreateInitialCompanyParams { + legalName: Name; + tradeName?: Name; + tin: TextValue; + email?: EmailAddress; + phone?: PhoneNumber; + website?: URLAddress; + address: PostalAddress; + transaction?: unknown; +} + +export interface InitialCompanyPublicModel { + companyId: string; + slug: string; +} + +export interface IInitialCompanyCreator { + create(params: CreateInitialCompanyParams): Promise>; +} + +export class InitialCompanyCreator implements IInitialCompanyCreator { + public constructor(private readonly creator: ICompanyCreator) {} + + public async create( + params: CreateInitialCompanyParams + ): Promise> { + const address = params.address; + if ( + !address || + [ + address.street, + address.city, + address.postalCode, + address.province, + address.countryCode, + ].some((part) => part.isNone()) + ) { + return Result.fail(new Error("Initial company requires a complete postal address.")); + } + + const id = UniqueID.generateNewID(); + // El ID interno mantiene el slug estable y distingue empresas con el mismo nombre. + const slug = CompanySlug.create(`company-${id.toPrimitive()}`); + if (slug.isFailure) return Result.fail(slug.error); + const now = UtcDate.today(); + const created = await this.creator.create({ + id, + transaction: params.transaction, + props: { + legalName: params.legalName, + tradeName: Maybe.fromNullable(params.tradeName), + tin: Maybe.some(params.tin), + email: Maybe.fromNullable(params.email), + phone: Maybe.fromNullable(params.phone), + website: Maybe.fromNullable(params.website), + address: Maybe.some(address), + slug: slug.data, + status: CompanyStatus.createActive(), + defaultPaymentTermId: Maybe.none(), + createdAt: now, + updatedAt: now, + }, + }); + if (created.isFailure) return Result.fail(created.error); + return Result.ok({ + companyId: created.data.id.toPrimitive(), + slug: created.data.slug.toPrimitive(), + }); + } +} diff --git a/modules/onboarding/src/api/application/repositories/initial-onboarding-repository.interface.ts b/modules/onboarding/src/api/application/repositories/initial-onboarding-repository.interface.ts new file mode 100644 index 00000000..d464577a --- /dev/null +++ b/modules/onboarding/src/api/application/repositories/initial-onboarding-repository.interface.ts @@ -0,0 +1,15 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe, Result } from "@repo/rdx-utils"; + +import type { InitialOnboarding } from "../../domain"; + +export interface IInitialOnboardingRepository { + /** Devuelve ausencia explícita si la cuenta no tiene onboarding; no es un error. */ + findByAccountId( + accountId: UniqueID, + transaction?: unknown + ): Promise, Error>>; + + /** Persiste el estado actual del agregado, tanto pending como completed. */ + save(initialOnboarding: InitialOnboarding, transaction?: unknown): Promise>; +} diff --git a/modules/onboarding/src/api/application/services/initial-onboarding-provisioner.test.ts b/modules/onboarding/src/api/application/services/initial-onboarding-provisioner.test.ts new file mode 100644 index 00000000..17276d48 --- /dev/null +++ b/modules/onboarding/src/api/application/services/initial-onboarding-provisioner.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DuplicateEntityError } from "@erp/core/api"; +import { UniqueID, UtcDateTime } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { InitialOnboarding } from "../../domain"; +import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface"; + +import { InitialOnboardingProvisioner } from "./initial-onboarding-provisioner"; + +const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data; +const duplicate = () => + new DuplicateEntityError("InitialOnboarding", "accountId", accountId.toPrimitive()); + +class FakeRepository implements IInitialOnboardingRepository { + public stored: InitialOnboarding | undefined; + public reads = 0; + public writes = 0; + public readError: Error | undefined; + public writeError: Error | undefined; + public winner: InitialOnboarding | undefined; + public rereadError: Error | undefined; + public async findByAccountId(id: UniqueID): Promise, Error>> { + assert.ok(id.equals(accountId)); + this.reads++; + if (this.readError) return Result.fail(this.readError); + if (this.reads > 1 && this.rereadError) return Result.fail(this.rereadError); + return Result.ok(Maybe.fromNullable(this.stored)); + } + public async save(value: InitialOnboarding): Promise> { + this.writes++; + if (this.winner) this.stored = this.winner; + if (this.writeError) return Result.fail(this.writeError); + this.stored = value; + return Result.ok(); + } +} +function existing(completed = false) { + const value = InitialOnboarding.createPending(accountId).data; + if (completed) { + const timestamp = UtcDateTime.createFromISO("2026-01-02T10:30:00.123Z").data; + assert.equal( + value.complete({ + companyId: UniqueID.generateNewID(), + completedAt: timestamp, + updatedAt: timestamp, + }).isSuccess, + true + ); + } + return value; +} + +test("inexistente crea y guarda pending mediante el agregado", async () => { + const repository = new FakeRepository(); + const result = await new InitialOnboardingProvisioner(repository).provision(accountId); + assert.equal(result.isSuccess, true); + assert.equal(result.data, repository.stored); + assert.equal(result.data.status, "pending"); + assert.ok(result.data.accountId.equals(accountId)); + assert.ok(result.data.createdAt.equals(result.data.updatedAt)); + assert.ok(result.data.companyId.isNone()); + assert.ok(result.data.completedAt.isNone()); + assert.equal(repository.writes, 1); +}); + +for (const completed of [false, true]) { + test(`existente ${completed ? "completed" : "pending"} conserva identidad y estado sin save`, async () => { + const repository = new FakeRepository(); + const value = existing(completed); + repository.stored = value; + const before = [value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt]; + const result = await new InitialOnboardingProvisioner(repository).provision(accountId); + assert.equal(result.data, value); + assert.equal(result.data.status, completed ? "completed" : "pending"); + assert.deepEqual( + [value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt], + before + ); + assert.equal(repository.writes, 0); + }); +} + +test("error de find inicial se propaga sin save", async () => { + const repository = new FakeRepository(); + repository.readError = new Error("read failed"); + const result = await new InitialOnboardingProvisioner(repository).provision(accountId); + assert.equal(result.isFailure, true); + assert.equal(result.error, repository.readError); + assert.equal(repository.writes, 0); +}); + +test("error de save no duplicate se propaga sin releer", async () => { + const repository = new FakeRepository(); + repository.writeError = new Error("write failed"); + const result = await new InitialOnboardingProvisioner(repository).provision(accountId); + assert.equal(result.isFailure, true); + assert.equal(result.error, repository.writeError); + assert.equal(repository.reads, 1); +}); + +for (const completed of [false, true]) { + test(`duplicate devuelve ganador ${completed ? "completed" : "pending"} tras una relectura`, async () => { + const repository = new FakeRepository(); + repository.writeError = duplicate(); + repository.winner = existing(completed); + const result = await new InitialOnboardingProvisioner(repository).provision(accountId); + assert.equal(result.isSuccess, true); + assert.equal(result.data, repository.winner); + assert.equal(repository.reads, 2); + assert.equal(repository.writes, 1); + }); +} + +test("duplicate y relectura fallida propaga el error de lectura", async () => { + const repository = new FakeRepository(); + repository.writeError = duplicate(); + repository.rereadError = new Error("reread failed"); + const result = await new InitialOnboardingProvisioner(repository).provision(accountId); + assert.equal(result.isFailure, true); + assert.equal(result.error, repository.rereadError); + assert.equal(repository.reads, 2); +}); + +test("duplicate y relectura none retorna error explícito con causa", async () => { + const repository = new FakeRepository(); + repository.writeError = duplicate(); + const result = await new InitialOnboardingProvisioner(repository).provision(accountId); + assert.equal(result.isFailure, true); + assert.equal( + result.error.message, + "Initial onboarding duplicate without a persisted onboarding for the account." + ); + assert.equal(result.error.cause, repository.writeError); + assert.equal(repository.reads, 2); + assert.equal(repository.writes, 1); +}); + +test("provision repetido devuelve el mismo agregado y timestamps", async () => { + const repository = new FakeRepository(); + const provisioner = new InitialOnboardingProvisioner(repository); + const first = await provisioner.provision(accountId); + const second = await provisioner.provision(accountId); + const third = await provisioner.provision(accountId); + assert.equal(first.isSuccess, true); + assert.equal(second.data, first.data); + assert.equal(third.data, first.data); + assert.equal(repository.writes, 1); +}); + +test("dos provision simultáneos con barrera devuelven un único ganador", async () => { + let release: () => void = () => { + throw new Error("Barrier not initialized"); + }; + const barrier = new Promise((resolve) => { + release = resolve; + }); + let reads = 0; + let writes = 0; + let stored: InitialOnboarding | undefined; + const repository: IInitialOnboardingRepository = { + async findByAccountId(id) { + assert.ok(id.equals(accountId)); + reads++; + if (reads <= 2) { + if (reads === 2) release(); + await barrier; + return Result.ok(Maybe.none()); + } + return Result.ok(Maybe.fromNullable(stored)); + }, + async save(value) { + writes++; + if (stored) return Result.fail(duplicate()); + stored = value; + return Result.ok(); + }, + }; + const provisioner = new InitialOnboardingProvisioner(repository); + const [first, second] = await Promise.all([ + provisioner.provision(accountId), + provisioner.provision(accountId), + ]); + assert.equal(first.isSuccess, true); + assert.equal(second.isSuccess, true); + assert.equal(first.data, stored); + assert.equal(second.data, stored); + assert.ok(first.data.id.equals(second.data.id)); + assert.equal(writes, 2); + assert.equal(reads, 3); +}); diff --git a/modules/onboarding/src/api/application/services/initial-onboarding-provisioner.ts b/modules/onboarding/src/api/application/services/initial-onboarding-provisioner.ts new file mode 100644 index 00000000..4822956c --- /dev/null +++ b/modules/onboarding/src/api/application/services/initial-onboarding-provisioner.ts @@ -0,0 +1,33 @@ +import { isDuplicateEntityError } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { InitialOnboarding } from "../../domain"; +import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface"; + +export class InitialOnboardingProvisioner { + public constructor(private readonly repository: IInitialOnboardingRepository) {} + + /** Asegura que existe onboarding; conserva los estados pending y completed existentes. */ + public async provision(accountId: UniqueID): Promise> { + const current = await this.repository.findByAccountId(accountId); + if (current.isFailure) return Result.fail(current.error); + if (current.data.isSome()) return Result.ok(current.data.unwrap()); + + const created = InitialOnboarding.createPending(accountId); + if (created.isFailure) return Result.fail(created.error); + const saved = await this.repository.save(created.data); + if (saved.isSuccess) return Result.ok(created.data); + if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error); + + // UNIQUE es la garantía entre procesos. Solo recuperamos el ganador confirmado por cuenta. + const winner = await this.repository.findByAccountId(accountId); + if (winner.isFailure) return Result.fail(winner.error); + if (winner.data.isSome()) return Result.ok(winner.data.unwrap()); + return Result.fail( + new Error("Initial onboarding duplicate without a persisted onboarding for the account.", { + cause: saved.error, + }) + ); + } +} diff --git a/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.test.ts b/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.test.ts index 624fd44b..60ee9375 100644 --- a/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.test.ts +++ b/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.test.ts @@ -30,6 +30,8 @@ test("creates pending", () => { check(r.data.companyId.isNone()); check(r.data.completedAt.isNone()); check(r.data.accountId.equals(accountId)); + check(r.data.createdAt instanceof UtcDateTime); + check(r.data.updatedAt.equals(r.data.createdAt)); }); test("rehydrates valid states", () => { check(InitialOnboarding.rehydrate(props("pending"), UniqueID.generateNewID()).isSuccess); @@ -64,7 +66,11 @@ test("rejects invalid state combinations", () => { test("completes once", () => { const o = InitialOnboarding.createPending(UniqueID.generateNewID()).data; const id = UniqueID.generateNewID(); - check(o.complete({ companyId: id, completedAt: date(), updatedAt: date() }).isSuccess); + const createdAt = o.createdAt; + const updatedAt = UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data; + check(o.complete({ companyId: id, completedAt: date(), updatedAt }).isSuccess); + check(o.createdAt.equals(createdAt)); + check(o.updatedAt.equals(updatedAt)); check(o.status === "completed"); check(o.companyId.unwrap().equals(id)); check(o.completedAt.isSome()); diff --git a/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.ts b/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.ts index 714d4080..0303dd0a 100644 --- a/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.ts +++ b/modules/onboarding/src/api/domain/initial-onboarding/initial-onboarding.aggregate.ts @@ -1,4 +1,4 @@ -import { AggregateRoot, UniqueID, type UtcDateTime } from "@repo/rdx-ddd"; +import { AggregateRoot, UniqueID, UtcDateTime } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; export type InitialOnboardingStatus = "pending" | "completed"; @@ -6,6 +6,8 @@ export interface InitialOnboardingProps { accountId: UniqueID; status: InitialOnboardingStatus; companyId: Maybe; + createdAt: UtcDateTime; + updatedAt: UtcDateTime; completedAt: Maybe; } export class InitialOnboarding extends AggregateRoot { @@ -20,11 +22,14 @@ export class InitialOnboarding extends AggregateRoot { } static createPending(accountId: UniqueID): Result { + const now = UtcDateTime.now(); return InitialOnboarding.rehydrate( { accountId, status: "pending", companyId: Maybe.none(), + createdAt: now, + updatedAt: now, completedAt: Maybe.none(), }, UniqueID.generateNewID() @@ -61,6 +66,14 @@ export class InitialOnboarding extends AggregateRoot { return Result.ok(undefined); } + get createdAt(): UtcDateTime { + return this.props.createdAt; + } + + get updatedAt(): UtcDateTime { + return this.props.updatedAt; + } + get accountId() { return this.props.accountId; } diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/index.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/index.ts index a7c9624f..f04136fc 100644 --- a/modules/onboarding/src/api/infrastructure/persistence/sequelize/index.ts +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/index.ts @@ -2,5 +2,6 @@ import initialOnboardingModelInit from "./models/sequelize-initial-onboarding.mo export * from "./mappers"; export * from "./models"; +export * from "./repositories"; export const models = [initialOnboardingModelInit]; diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.test.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.test.ts index 14614100..6e33f1ba 100644 --- a/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.test.ts +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.test.ts @@ -20,6 +20,8 @@ const mapper = new SequelizeInitialOnboardingDomainMapper(); const id = "11111111-1111-4111-8111-111111111111"; const accountId = "22222222-2222-4222-8222-222222222222"; const companyId = "33333333-3333-4333-8333-333333333333"; +const createdAt = "2026-01-01T10:00:00.123Z"; +const updatedAt = "2026-01-02T11:00:00.456Z"; const completedAt = "2026-01-02T10:30:00.789Z"; function aggregate(status: InitialOnboardingStatus) { @@ -30,6 +32,8 @@ function aggregate(status: InitialOnboardingStatus) { companyId: status === "completed" ? Maybe.some(UniqueID.create(companyId).data) : Maybe.none(), + createdAt: UtcDateTime.createFromISO(createdAt).data, + updatedAt: UtcDateTime.createFromISO(updatedAt).data, completedAt: status === "completed" ? Maybe.some(UtcDateTime.createFromISO(completedAt).data) @@ -48,6 +52,8 @@ function row(patch: Partial> = {}) { account_id: accountId, status: "pending", company_id: null, + created_at: new Date(createdAt), + updated_at: new Date(updatedAt), completed_at: null, ...patch, }, @@ -57,13 +63,18 @@ function row(patch: Partial> = {}) { for (const status of ["pending", "completed"] as const) { test(`toPersistence ${status}`, () => { - const result = mapper.mapToPersistence(aggregate(status)); + const source = aggregate(status); + const result = mapper.mapToPersistence(source); assert.equal(result.isSuccess, true); + assert.deepEqual(result.data.created_at, source.createdAt.toDate()); + assert.deepEqual(result.data.updated_at, source.updatedAt.toDate()); assert.deepEqual(result.data, { id, account_id: accountId, status, company_id: status === "completed" ? companyId : null, + created_at: new Date(createdAt), + updated_at: new Date(updatedAt), completed_at: status === "completed" ? completedAt : null, }); }); @@ -73,6 +84,8 @@ for (const status of ["pending", "completed"] as const) { row({ status, company_id: status === "completed" ? companyId : null, + created_at: new Date(createdAt), + updated_at: new Date(updatedAt), completed_at: status === "completed" ? completedAt : null, }) ); @@ -83,6 +96,8 @@ for (const status of ["pending", "completed"] as const) { assert.equal(value.id.toPrimitive(), id); assert.equal(value.accountId.toPrimitive(), accountId); assert.equal(value.status, status); + assert.equal(value.createdAt.toISOString(), createdAt); + assert.equal(value.updatedAt.toISOString(), updatedAt); assert.equal(value.companyId.isSome(), status === "completed"); assert.equal(value.completedAt.isSome(), status === "completed"); if (status === "completed") { @@ -113,10 +128,14 @@ test("toDomain accepts Sequelize Date timestamps and preserves UTC milliseconds" row({ status: "completed", company_id: companyId, + created_at: new Date(createdAt), + updated_at: new Date(updatedAt), completed_at: new Date(completedAt), }) ); assert.equal(result.isSuccess, true); + assert.equal(result.data.createdAt.toISOString(), createdAt); + assert.equal(result.data.updatedAt.toISOString(), updatedAt); assert.equal(result.data.completedAt.unwrap().toISOString(), completedAt); }); @@ -133,8 +152,8 @@ test("toDomain propagates invalid identifiers and timestamps as failures", () => { id: "invalid" }, { account_id: "invalid" }, { status: "completed", company_id: "invalid", completed_at: completedAt }, - { created_at: "invalid" }, - { updated_at: "invalid" }, + { created_at: new Date(Number.NaN) }, + { updated_at: new Date(Number.NaN) }, { status: "completed", company_id: companyId, completed_at: "invalid" }, ]) { assert.equal(mapper.mapToDomain(row(patch)).isFailure, true); diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.ts index 67edb158..3cc6e191 100644 --- a/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.ts +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/mappers/sequelize-initial-onboarding-domain.mapper.ts @@ -32,6 +32,10 @@ export class SequelizeInitialOnboardingDomainMapper { if (accountId.isFailure) return Result.fail(accountId.error); const companyId = maybeFromNullableResult(source.company_id, (value) => UniqueID.create(value)); if (companyId.isFailure) return Result.fail(companyId.error); + const createdAt = toUtcDateTime(source.created_at); + if (createdAt.isFailure) return Result.fail(createdAt.error); + const updatedAt = toUtcDateTime(source.updated_at); + if (updatedAt.isFailure) return Result.fail(updatedAt.error); const completedAt = maybeFromNullableResult(source.completed_at, (value) => toUtcDateTime(value) ); @@ -42,6 +46,8 @@ export class SequelizeInitialOnboardingDomainMapper { accountId: accountId.data, status: source.status, companyId: companyId.data, + createdAt: createdAt.data, + updatedAt: updatedAt.data, completedAt: completedAt.data, }, id.data @@ -56,6 +62,8 @@ export class SequelizeInitialOnboardingDomainMapper { account_id: source.accountId.toPrimitive(), status: source.status, company_id: maybeToNullable(source.companyId, (value) => value.toPrimitive()), + created_at: source.createdAt.toDate(), + updated_at: source.updatedAt.toDate(), completed_at: maybeToNullable(source.completedAt, (value) => value.toISOString()), }); } diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.test.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.test.ts index 83fb3bf0..71714422 100644 --- a/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.test.ts +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.test.ts @@ -12,7 +12,9 @@ initialOnboardingModelInit(database); test("model declares table, nullability, UUIDs, timestamps and unique account without cross-module FKs", () => { assert.equal(InitialOnboardingModel.getTableName(), "initial_onboardings"); - assert.equal(InitialOnboardingModel.options.timestamps, false); + assert.equal(InitialOnboardingModel.options.timestamps, true); + assert.equal(InitialOnboardingModel.options.createdAt, "created_at"); + assert.equal(InitialOnboardingModel.options.updatedAt, "updated_at"); const attributes = InitialOnboardingModel.getAttributes(); assert.deepEqual(Object.keys(attributes).sort(), [ "account_id", diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.ts index 3be56a82..90517c58 100644 --- a/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.ts +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model.ts @@ -1,4 +1,9 @@ -import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import type { + CreationOptional, + InferAttributes, + InferCreationAttributes, + Sequelize, +} from "sequelize"; import { DataTypes, Model } from "sequelize"; export type InitialOnboardingCreationAttributes = InferCreationAttributes; @@ -11,6 +16,8 @@ export class InitialOnboardingModel extends Model< declare account_id: string; declare status: string; declare company_id: string | null; + declare created_at: CreationOptional; + declare updated_at: CreationOptional; declare completed_at: Date | string | null; } @@ -26,6 +33,8 @@ export default (database: Sequelize) => { validate: { isIn: [["pending", "completed"]] }, }, company_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null }, + created_at: { type: DataTypes.DATE, allowNull: false }, + updated_at: { type: DataTypes.DATE, allowNull: false }, completed_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, }, { @@ -36,6 +45,8 @@ export default (database: Sequelize) => { collate: "utf8mb4_unicode_ci", underscored: true, timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", indexes: [ { name: "uq_initial_onboardings_account_id", diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/index.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/index.ts new file mode 100644 index 00000000..957e40ee --- /dev/null +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/index.ts @@ -0,0 +1 @@ +export * from "./sequelize-initial-onboarding.repository"; diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/sequelize-initial-onboarding.repository.test.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/sequelize-initial-onboarding.repository.test.ts new file mode 100644 index 00000000..6a82d8a8 --- /dev/null +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/sequelize-initial-onboarding.repository.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DuplicateEntityError } from "@erp/core/api"; +import { UniqueID, UtcDateTime } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import { Sequelize, UniqueConstraintError } from "sequelize"; + +import { InitialOnboarding } from "../../../../domain"; +import { SequelizeInitialOnboardingDomainMapper } from "../mappers"; +import initialOnboardingModelInit, { + InitialOnboardingModel, +} from "../models/sequelize-initial-onboarding.model"; + +import { SequelizeInitialOnboardingRepository } from "./sequelize-initial-onboarding.repository"; + +initialOnboardingModelInit( + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) +); +const mapper = new SequelizeInitialOnboardingDomainMapper(); +const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data; +function aggregate(completed = false) { + return InitialOnboarding.rehydrate( + { + accountId, + status: completed ? "completed" : "pending", + companyId: completed + ? Maybe.some(UniqueID.create("33333333-3333-4333-8333-333333333333").data) + : Maybe.none(), + createdAt: UtcDateTime.createFromISO("2026-01-01T10:00:00.123Z").data, + updatedAt: UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data, + completedAt: completed + ? Maybe.some(UtcDateTime.createFromISO("2026-01-02T10:30:00.789Z").data) + : Maybe.none(), + }, + UniqueID.create("11111111-1111-4111-8111-111111111111").data + ).data; +} +function row(completed = false) { + return InitialOnboardingModel.build(mapper.mapToPersistence(aggregate(completed)).data, { + raw: true, + }); +} + +test("find existente usa filtro account_id, mapper y transaction", async (t) => { + const transaction = {}; + const model = row(); + const find = t.mock.method(InitialOnboardingModel, "findOne", async () => model); + const map = t.mock.method(mapper, "mapToDomain"); + const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId( + accountId, + transaction + ); + assert.equal(result.isSuccess, true); + assert.equal(result.data.isSome(), true); + assert.equal(result.data.unwrap().id.toPrimitive(), model.id); + assert.deepEqual(find.mock.calls[0].arguments[0], { + where: { account_id: accountId.toPrimitive() }, + transaction, + }); + assert.equal(map.mock.calls[0].arguments[0], model); +}); + +test("find ausente devuelve Maybe.none sin llamar al mapper", async (t) => { + t.mock.method(InitialOnboardingModel, "findOne", async () => null); + const map = t.mock.method(mapper, "mapToDomain"); + const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId); + assert.equal(result.isSuccess, true); + assert.equal(result.data.isNone(), true); + assert.equal(map.mock.callCount(), 0); +}); + +for (const completed of [false, true]) { + test(`save ${completed ? "completed" : "pending"} crea usando mapper y conserva timestamps y transaction`, async (t) => { + const source = aggregate(completed); + const expected = mapper.mapToPersistence(source).data; + const transaction = {}; + const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null); + const create = t.mock.method(InitialOnboardingModel, "create", async () => row(completed)); + const map = t.mock.method(mapper, "mapToPersistence"); + const result = await new SequelizeInitialOnboardingRepository(mapper).save(source, transaction); + assert.equal(result.isSuccess, true); + assert.equal(map.mock.calls[0].arguments[0], source); + assert.deepEqual(find.mock.calls[0].arguments, [source.id.toPrimitive(), { transaction }]); + assert.deepEqual(create.mock.calls[0].arguments, [expected, { transaction, silent: true }]); + }); +} + +test("save repetido actualiza por id sin volver a insertar", async (t) => { + const source = aggregate(true); + const transaction = {}; + let stored: InitialOnboardingModel | null = null; + t.mock.method(InitialOnboardingModel, "findByPk", async () => stored); + const create = t.mock.method(InitialOnboardingModel, "create", async () => { + stored = row(true); + return stored; + }); + const update = t.mock.method(InitialOnboardingModel, "update", async () => [1]); + const repository = new SequelizeInitialOnboardingRepository(); + assert.equal((await repository.save(source, transaction)).isSuccess, true); + assert.equal((await repository.save(source, transaction)).isSuccess, true); + assert.equal(create.mock.callCount(), 1); + assert.equal(update.mock.callCount(), 1); + const { id, ...values } = mapper.mapToPersistence(source).data; + assert.deepEqual(update.mock.calls[0].arguments, [ + values, + { transaction, silent: true, where: { id } }, + ]); +}); + +test("read error retorna Result.fail", async (t) => { + const error = new Error("read failed"); + t.mock.method(InitialOnboardingModel, "findOne", async () => { + throw error; + }); + const result = await new SequelizeInitialOnboardingRepository().findByAccountId(accountId); + assert.equal(result.isFailure, true); + assert.equal(result.error, error); +}); + +for (const existing of [false, true]) { + test(`write error en ${existing ? "update" : "create"} retorna Result.fail`, async (t) => { + const error = new Error("write failed"); + t.mock.method(InitialOnboardingModel, "findByPk", async () => (existing ? row() : null)); + t.mock.method(InitialOnboardingModel, existing ? "update" : "create", async () => { + throw error; + }); + const result = await new SequelizeInitialOnboardingRepository().save(aggregate()); + assert.equal(result.isFailure, true); + assert.equal(result.error, error); + }); +} + +test("unique account_id retorna fallo y no reintenta ni relee", async (t) => { + const error = new UniqueConstraintError({ message: "duplicate account_id" }); + const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null); + const reread = t.mock.method(InitialOnboardingModel, "findOne", async () => row()); + const create = t.mock.method(InitialOnboardingModel, "create", async () => { + throw error; + }); + const result = await new SequelizeInitialOnboardingRepository().save(aggregate()); + assert.equal(result.isFailure, true); + assert.ok(result.error instanceof DuplicateEntityError); + assert.equal(result.error.cause, error); + assert.equal(find.mock.callCount(), 1); + assert.equal(create.mock.callCount(), 1); + assert.equal(reread.mock.callCount(), 0); +}); + +test("fallo de mapper en lectura se propaga", async (t) => { + const error = new Error("mapping failed"); + t.mock.method(InitialOnboardingModel, "findOne", async () => row()); + t.mock.method(mapper, "mapToDomain", () => Result.fail(error)); + const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId); + assert.equal(result.isFailure, true); + assert.equal(result.error, error); +}); + +test("fallo de mapper en escritura evita consultar o escribir", async (t) => { + const error = new Error("mapping failed"); + t.mock.method(mapper, "mapToPersistence", () => Result.fail(error)); + const find = t.mock.method(InitialOnboardingModel, "findByPk"); + const create = t.mock.method(InitialOnboardingModel, "create"); + const result = await new SequelizeInitialOnboardingRepository(mapper).save(aggregate()); + assert.equal(result.isFailure, true); + assert.equal(result.error, error); + assert.equal(find.mock.callCount(), 0); + assert.equal(create.mock.callCount(), 0); +}); diff --git a/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/sequelize-initial-onboarding.repository.ts b/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/sequelize-initial-onboarding.repository.ts new file mode 100644 index 00000000..8422c179 --- /dev/null +++ b/modules/onboarding/src/api/infrastructure/persistence/sequelize/repositories/sequelize-initial-onboarding.repository.ts @@ -0,0 +1,58 @@ +import { translateSequelizeError } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import type { Transaction } from "sequelize"; + +import type { IInitialOnboardingRepository } from "../../../../application/repositories/initial-onboarding-repository.interface"; +import type { InitialOnboarding } from "../../../../domain"; +import { SequelizeInitialOnboardingDomainMapper } from "../mappers"; +import { InitialOnboardingModel } from "../models"; + +export class SequelizeInitialOnboardingRepository implements IInitialOnboardingRepository { + public constructor( + private readonly domainMapper = new SequelizeInitialOnboardingDomainMapper() + ) {} + + public async findByAccountId( + accountId: UniqueID, + transaction?: unknown + ): Promise, Error>> { + try { + const row = await InitialOnboardingModel.findOne({ + where: { account_id: accountId.toPrimitive() }, + transaction: transaction as Transaction | undefined, + }); + if (!row) return Result.ok(Maybe.none()); + return this.domainMapper.mapToDomain(row).map((value) => Maybe.some(value)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async save( + initialOnboarding: InitialOnboarding, + transaction?: unknown + ): Promise> { + try { + const persistence = this.domainMapper.mapToPersistence(initialOnboarding); + if (persistence.isFailure) return Result.fail(persistence.error); + + const sqlTransaction = transaction as Transaction | undefined; + const { id, ...values } = persistence.data; + // Comprobar por id evita que un upsert MySQL absorba una colisión de account_id. + const existing = await InitialOnboardingModel.findByPk(id, { + transaction: sqlTransaction, + }); + // El agregado proporciona los timestamps; Sequelize no debe reemplazar updated_at. + const options = { transaction: sqlTransaction, silent: true }; + if (existing) { + await InitialOnboardingModel.update(values, { ...options, where: { id } }); + } else { + await InitialOnboardingModel.create(persistence.data, options); + } + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } +}