feat: Add initial company creator and onboarding repository

- Implemented InitialCompanyCreator service for creating new companies with necessary details and validations.
- Added IInitialOnboardingRepository interface for onboarding data persistence.
- Created SequelizeInitialOnboardingRepository for managing onboarding data with Sequelize ORM.
- Enhanced InitialOnboarding aggregate to include createdAt and updatedAt timestamps.
- Updated tests for initial company creation and onboarding provisioning to ensure proper functionality and error handling.
- Introduced new tests for the Sequelize repository to validate data retrieval and persistence logic.
This commit is contained in:
David Arranz 2026-09-07 18:55:41 +02:00
parent 38fb6299b7
commit 0cbb6fc175
16 changed files with 766 additions and 7 deletions

View File

@ -1,2 +1,3 @@
export * from "./company-public-finder";
export * from "./company-public-services";
export * from "./initial-company-creator";

View File

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

View File

@ -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<Result<InitialCompanyPublicModel, Error>>;
}
export class InitialCompanyCreator implements IInitialCompanyCreator {
public constructor(private readonly creator: ICompanyCreator) {}
public async create(
params: CreateInitialCompanyParams
): Promise<Result<InitialCompanyPublicModel, Error>> {
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(),
});
}
}

View File

@ -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<Result<Maybe<InitialOnboarding>, Error>>;
/** Persiste el estado actual del agregado, tanto pending como completed. */
save(initialOnboarding: InitialOnboarding, transaction?: unknown): Promise<Result<void, Error>>;
}

View File

@ -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<Result<Maybe<InitialOnboarding>, 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<Result<void, Error>> {
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<void>((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);
});

View File

@ -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<Result<InitialOnboarding, Error>> {
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,
})
);
}
}

View File

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

View File

@ -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<UniqueID>;
createdAt: UtcDateTime;
updatedAt: UtcDateTime;
completedAt: Maybe<UtcDateTime>;
}
export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
@ -20,11 +22,14 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
}
static createPending(accountId: UniqueID): Result<InitialOnboarding, Error> {
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<InitialOnboardingProps> {
return Result.ok(undefined);
}
get createdAt(): UtcDateTime {
return this.props.createdAt;
}
get updatedAt(): UtcDateTime {
return this.props.updatedAt;
}
get accountId() {
return this.props.accountId;
}

View File

@ -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];

View File

@ -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<InferAttributes<InitialOnboardingModel>> = {}) {
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<InferAttributes<InitialOnboardingModel>> = {}) {
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);

View File

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

View File

@ -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",

View File

@ -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<InitialOnboardingModel>;
@ -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<Date>;
declare updated_at: CreationOptional<Date>;
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",

View File

@ -0,0 +1 @@
export * from "./sequelize-initial-onboarding.repository";

View File

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

View File

@ -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<Result<Maybe<InitialOnboarding>, Error>> {
try {
const row = await InitialOnboardingModel.findOne({
where: { account_id: accountId.toPrimitive() },
transaction: transaction as Transaction | undefined,
});
if (!row) return Result.ok(Maybe.none<InitialOnboarding>());
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<Result<void, Error>> {
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));
}
}
}