feat(onboarding): implement initial onboarding use cases and controllers

- Add GetInitialOnboardingStatusUseCase to retrieve onboarding status.
- Implement CompleteInitialOnboardingUseCase for completing onboarding.
- Create controllers for handling onboarding requests in Express.
- Introduce new repository methods for managing onboarding data.
- Set up dependency injection for onboarding services and use cases.
- Add tests for onboarding use cases and repository methods.
- Define request DTOs for onboarding operations with validation.
- Update module composition to include onboarding functionalities.
This commit is contained in:
David Arranz 2026-09-08 23:41:23 +02:00
parent dac22372b9
commit bf1dd43c78
106 changed files with 3755 additions and 699 deletions

View File

@ -412,6 +412,18 @@ export interface IIdentityTenantCompanyFinder {
\- Mantener inmutabilidad en valores y estructuras cuando no exista necesidad de mutación.
**## Referencias arquitectónicas normativas**
Para reglas completas, consultar y no duplicar:
- [Arquitectura backend](docs/architecture/backend-architecture.md)
- [Inyección de dependencias](docs/architecture/dependency-injection.md)
- [Flujo HTTP y entrada a Application](docs/architecture/http-request-flow.md)
- [DTOs y schemas HTTP](docs/architecture/dto-guidelines.md)
- [Pruebas backend por capa](docs/architecture/testing-guidelines.md)
Las reglas resumidas siguientes son recordatorios operativos; las SSOT anteriores prevalecen.
**## Zod y DTOs**
Zod es la fuente única de verdad para los contratos HTTP.
@ -478,29 +490,15 @@ No se permite:
**### Flujo de entrada**
Respetar:
Para el flujo normal, usar:
\`\`\`text
RequestDTO
  -> RequestMapper
  -> Command
  -> PropsMapper
  -> Aggregate o Entity
RequestDTO -> UseCase({ dto, context }) -> InputMapper -> Value Objects / Props
\`\`\`
No mapear directamente:
\`\`\`text
DTO -> Aggregate
\`\`\`
`RequestMapper -> Command` es opcional y solo procede cuando existe adaptación HTTP propia, por ejemplo para combinar body, params o query. No crear uno solo para renombrar campos que Application volverá a transformar.
**### Casos de uso**
@ -866,4 +864,4 @@ En caso de conflicto, aplicar este orden:
5\. preferencias generales del agente.
Si una instrucción contradice la arquitectura o genera un riesgo relevante, señalarlo antes de introducir el cambio.
Si una instrucción contradice la arquitectura o genera un riesgo relevante, señalarlo antes de introducir el cambio.

View File

@ -46,6 +46,7 @@
"@erp/document-series": "workspace:*",
"@erp/identity": "workspace:*",
"@erp/factuges": "workspace:*",
"@erp/onboarding": "workspace:*",
"@erp/suppliers": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-audit": "workspace:*",

View File

@ -6,6 +6,7 @@ import customersAPIModule from "@erp/customers/api";
import documentSeriesAPIModule from "@erp/document-series/api";
import factuGESAPIModule from "@erp/factuges/api";
import identityAPIModule from "@erp/identity/api";
import onboardingAPIModule from "@erp/onboarding/api";
import suppliersAPIModule from "@erp/suppliers/api";
// Aquí hay que registrar los módulos que
@ -22,4 +23,5 @@ export const registerModules = () => {
registerModule(customerInvoicesAPIModule);
registerModule(factuGESAPIModule);
registerModule(suppliersAPIModule);
registerModule(onboardingAPIModule);
};

View File

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CreateCompanyInputMapper } from "./create-company-input.mapper";
const mapper = new CreateCompanyInputMapper();
const dto = {
id: "11111111-1111-4111-8111-111111111111",
legal_name: "Rodax Labs",
trade_name: "Rodax",
tin: "B12345678",
slug: "rodax-labs",
email: "hello@rodax.test",
phone: "+34911111111",
website: "https://rodax.test",
address: {
street: "Main 1",
street_2: null,
city: "Madrid",
postal_code: "28001",
province: "Madrid",
country_code: "ES",
},
};
test("maps complete create input and optional address", () => {
const result = mapper.map(dto);
assert.equal(result.isSuccess, true);
if (result.isFailure) return;
assert.equal(result.data.id.toPrimitive(), dto.id);
assert.equal(result.data.props.legalName.toPrimitive(), dto.legal_name);
assert.equal(result.data.props.tradeName.unwrap().toPrimitive(), dto.trade_name);
assert.equal(result.data.props.address.unwrap().city.unwrap().toPrimitive(), "Madrid");
assert.equal(result.data.props.defaultPaymentTermId.isNone(), true);
});
test("maps absent and null address to none", () => {
for (const address of [undefined, null]) {
const result = mapper.map({ ...dto, address });
assert.equal(result.isSuccess, true);
if (result.isSuccess) assert.equal(result.data.props.address.isNone(), true);
}
});
test("returns failure for invalid value object", () => {
const result = mapper.map({ ...dto, email: "invalid" });
assert.equal(result.isFailure, true);
});

View File

@ -0,0 +1,78 @@
import type { PostalAddress } from "@repo/rdx-ddd";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateCompanyRequestDTO } from "../../../../common";
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
import { toPostalAddress } from "./company-address.mapper";
export interface ICreateCompanyInputMapper {
map(dto: CreateCompanyRequestDTO): Result<CreateCompanyInput, Error>;
}
export type CreateCompanyInput = { id: UniqueID; props: ICompanyCreateProps };
export class CreateCompanyInputMapper implements ICreateCompanyInputMapper {
public map(dto: CreateCompanyRequestDTO): Result<CreateCompanyInput, Error> {
const idResult = UniqueID.create(dto.id);
if (idResult.isFailure) return Result.fail(idResult.error);
const legalNameResult = Name.create(dto.legal_name);
if (legalNameResult.isFailure) return Result.fail(legalNameResult.error);
const tradeNameResult = maybeFromNullableResult(dto.trade_name ?? null, (value) =>
Name.create(value)
);
if (tradeNameResult.isFailure) return Result.fail(tradeNameResult.error);
const tinResult = maybeFromNullableResult(dto.tin ?? null, (value) => TextValue.create(value));
if (tinResult.isFailure) return Result.fail(tinResult.error);
const slugResult = CompanySlug.create(dto.slug);
if (slugResult.isFailure) return Result.fail(slugResult.error);
const emailResult = maybeFromNullableResult(dto.email ?? null, (value) =>
EmailAddress.create(value)
);
if (emailResult.isFailure) return Result.fail(emailResult.error);
const phoneResult = maybeFromNullableResult(dto.phone ?? null, (value) =>
PhoneNumber.create(value)
);
if (phoneResult.isFailure) return Result.fail(phoneResult.error);
const websiteResult = maybeFromNullableResult(dto.website ?? null, (value) =>
URLAddress.create(value)
);
if (websiteResult.isFailure) return Result.fail(websiteResult.error);
const addressResult = this.mapAddress(dto);
if (addressResult.isFailure) return Result.fail(addressResult.error);
const now = UtcDateTime.now();
return Result.ok({
id: idResult.data,
props: {
legalName: legalNameResult.data,
tradeName: tradeNameResult.data,
tin: tinResult.data,
slug: slugResult.data,
email: emailResult.data,
phone: phoneResult.data,
website: websiteResult.data,
address: addressResult.data,
defaultPaymentTermId: Maybe.none(),
status: CompanyStatus.createActive(),
createdAt: now,
updatedAt: now,
},
});
}
private mapAddress(dto: CreateCompanyRequestDTO): Result<Maybe<PostalAddress>, Error> {
if (dto.address === undefined || dto.address === null) return Result.ok(Maybe.none());
const result = toPostalAddress(dto.address);
return result.isFailure ? Result.fail(result.error) : Result.ok(Maybe.some(result.data));
}
}

View File

@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UpdateCompanyInputMapper } from "./update-company-input.mapper";
const mapper = new UpdateCompanyInputMapper();
const address = {
street: "Main 1",
street_2: null,
city: "Madrid",
postal_code: "28001",
province: "Madrid",
country_code: "ES",
};
test("preserves omitted PATCH fields", () => {
const result = mapper.map({});
assert.equal(result.isSuccess, true);
if (result.isSuccess) {
assert.equal(result.data.patchProps.address, undefined);
assert.equal(result.data.defaultPaymentTermId, undefined);
}
});
test("maps nullable address and payment term PATCH states", () => {
const cleared = mapper.map({ address: null, default_payment_term_id: null });
assert.equal(cleared.isSuccess, true);
if (cleared.isSuccess) {
assert.equal(cleared.data.patchProps.address?.isNone(), true);
assert.equal(cleared.data.defaultPaymentTermId?.isNone(), true);
}
const value = mapper.map({
address,
default_payment_term_id: "11111111-1111-4111-8111-111111111111",
slug: "rodax",
});
assert.equal(value.isSuccess, true);
if (value.isSuccess) {
assert.equal(value.data.patchProps.address?.unwrap().city.unwrap().toPrimitive(), "Madrid");
assert.equal(
value.data.defaultPaymentTermId?.unwrap().toPrimitive(),
"11111111-1111-4111-8111-111111111111"
);
assert.equal(value.data.patchProps.slug?.toPrimitive(), "rodax");
}
});
test("returns failure for invalid slug", () => {
assert.equal(mapper.map({ slug: "" }).isFailure, true);
});

View File

@ -0,0 +1,124 @@
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { UpdateCompanyRequestDTO } from "../../../../common";
import { type CompanyPatchProps, CompanySlug } from "../../../domain";
import { toPostalAddress } from "./company-address.mapper";
export type UpdateCompanyInput = {
patchProps: CompanyPatchProps;
defaultPaymentTermId: Maybe<UniqueID> | undefined;
};
export interface IUpdateCompanyInputMapper {
map(dto: UpdateCompanyRequestDTO): Result<UpdateCompanyInput, Error>;
}
export class UpdateCompanyInputMapper implements IUpdateCompanyInputMapper {
public map(dto: UpdateCompanyRequestDTO): Result<UpdateCompanyInput, Error> {
const patchProps: CompanyPatchProps = { updatedAt: UtcDateTime.now() };
const fieldResults = [
this.mapLegalName(patchProps, dto),
this.mapTradeName(patchProps, dto),
this.mapTin(patchProps, dto),
this.mapSlug(patchProps, dto),
this.mapEmail(patchProps, dto),
this.mapPhone(patchProps, dto),
this.mapWebsite(patchProps, dto),
this.mapAddress(patchProps, dto),
this.mapPaymentTermPatch(patchProps, dto),
];
for (const result of fieldResults) if (result.isFailure) return Result.fail(result.error);
const paymentTermResult = this.mapPaymentTermId(dto);
return paymentTermResult.isFailure
? Result.fail(paymentTermResult.error)
: Result.ok({ patchProps, defaultPaymentTermId: paymentTermResult.data });
}
private mapLegalName(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.legal_name === undefined) return Result.ok(undefined);
const r = Name.create(d.legal_name);
if (r.isFailure) return Result.fail(r.error);
p.legalName = r.data;
return Result.ok(undefined);
}
private mapTradeName(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.trade_name === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.trade_name, Name.create);
if (r.isFailure) return Result.fail(r.error);
p.tradeName = r.data;
return Result.ok(undefined);
}
private mapTin(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.tin === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.tin, TextValue.create);
if (r.isFailure) return Result.fail(r.error);
p.tin = r.data;
return Result.ok(undefined);
}
private mapSlug(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.slug === undefined) return Result.ok(undefined);
const r = CompanySlug.create(d.slug);
if (r.isFailure) return Result.fail(r.error);
p.slug = r.data;
return Result.ok(undefined);
}
private mapEmail(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.email === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.email, EmailAddress.create);
if (r.isFailure) return Result.fail(r.error);
p.email = r.data;
return Result.ok(undefined);
}
private mapPhone(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.phone === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.phone, PhoneNumber.create);
if (r.isFailure) return Result.fail(r.error);
p.phone = r.data;
return Result.ok(undefined);
}
private mapWebsite(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.website === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.website, URLAddress.create);
if (r.isFailure) return Result.fail(r.error);
p.website = r.data;
return Result.ok(undefined);
}
private mapAddress(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
if (d.address === undefined) return Result.ok(undefined);
if (d.address === null) {
p.address = Maybe.none();
return Result.ok(undefined);
}
const r = toPostalAddress(d.address);
if (r.isFailure) return Result.fail(r.error);
p.address = Maybe.some(r.data);
return Result.ok(undefined);
}
private mapPaymentTermPatch(
p: CompanyPatchProps,
d: UpdateCompanyRequestDTO
): Result<void, Error> {
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.default_payment_term_id, UniqueID.create);
if (r.isFailure) return Result.fail(r.error);
p.defaultPaymentTermId = r.data;
return Result.ok(undefined);
}
private mapPaymentTermId(d: UpdateCompanyRequestDTO): Result<Maybe<UniqueID> | undefined, Error> {
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
if (d.default_payment_term_id === null) return Result.ok(Maybe.none());
const r = UniqueID.create(d.default_payment_term_id);
return r.isFailure ? Result.fail(r.error) : Result.ok(Maybe.some(r.data));
}
}

View File

@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UpdateCurrentCompanyInputMapper } from "./update-current-company-input.mapper";
const mapper = new UpdateCurrentCompanyInputMapper();
const address = {
street: "Main 1",
street_2: null,
city: "Madrid",
postal_code: "28001",
province: "Madrid",
country_code: "ES",
};
test("maps current-company PATCH tri-state without slug", () => {
const omitted = mapper.map({});
assert.equal(omitted.isSuccess, true);
if (omitted.isSuccess) assert.equal(omitted.data.patchProps.address, undefined);
const cleared = mapper.map({ address: null, default_payment_term_id: null });
assert.equal(cleared.isSuccess, true);
if (cleared.isSuccess) assert.equal(cleared.data.patchProps.address?.isNone(), true);
const value = mapper.map({
address,
default_payment_term_id: "11111111-1111-4111-8111-111111111111",
email: "hello@rodax.test",
});
assert.equal(value.isSuccess, true);
if (value.isSuccess) {
assert.equal(value.data.patchProps.email?.unwrap().toPrimitive(), "hello@rodax.test");
assert.equal(value.data.defaultPaymentTermId?.isSome(), true);
assert.equal("slug" in value.data.patchProps, false);
}
});
test("returns failure for invalid email", () => {
assert.equal(mapper.map({ email: "invalid" }).isFailure, true);
});

View File

@ -0,0 +1,115 @@
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { UpdateCurrentCompanyRequestDTO } from "../../../../common";
import type { CompanyPatchProps } from "../../../domain";
import { toPostalAddress } from "./company-address.mapper";
export type UpdateCurrentCompanyInput = {
patchProps: CompanyPatchProps;
defaultPaymentTermId: Maybe<UniqueID> | undefined;
};
export interface IUpdateCurrentCompanyInputMapper {
map(dto: UpdateCurrentCompanyRequestDTO): Result<UpdateCurrentCompanyInput, Error>;
}
export class UpdateCurrentCompanyInputMapper implements IUpdateCurrentCompanyInputMapper {
public map(dto: UpdateCurrentCompanyRequestDTO): Result<UpdateCurrentCompanyInput, Error> {
const patchProps: CompanyPatchProps = { updatedAt: UtcDateTime.now() };
const results = [
this.legalName(patchProps, dto),
this.tradeName(patchProps, dto),
this.tin(patchProps, dto),
this.email(patchProps, dto),
this.phone(patchProps, dto),
this.website(patchProps, dto),
this.address(patchProps, dto),
this.paymentTermPatch(patchProps, dto),
];
for (const result of results) if (result.isFailure) return Result.fail(result.error);
const paymentTerm = this.paymentTermId(dto);
return paymentTerm.isFailure
? Result.fail(paymentTerm.error)
: Result.ok({ patchProps, defaultPaymentTermId: paymentTerm.data });
}
private legalName(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
if (d.legal_name === undefined) return Result.ok(undefined);
const r = Name.create(d.legal_name);
if (r.isFailure) return Result.fail(r.error);
p.legalName = r.data;
return Result.ok(undefined);
}
private tradeName(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
if (d.trade_name === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.trade_name, Name.create);
if (r.isFailure) return Result.fail(r.error);
p.tradeName = r.data;
return Result.ok(undefined);
}
private tin(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
if (d.tin === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.tin, TextValue.create);
if (r.isFailure) return Result.fail(r.error);
p.tin = r.data;
return Result.ok(undefined);
}
private email(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
if (d.email === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.email, EmailAddress.create);
if (r.isFailure) return Result.fail(r.error);
p.email = r.data;
return Result.ok(undefined);
}
private phone(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
if (d.phone === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.phone, PhoneNumber.create);
if (r.isFailure) return Result.fail(r.error);
p.phone = r.data;
return Result.ok(undefined);
}
private website(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
if (d.website === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.website, URLAddress.create);
if (r.isFailure) return Result.fail(r.error);
p.website = r.data;
return Result.ok(undefined);
}
private address(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
if (d.address === undefined) return Result.ok(undefined);
if (d.address === null) {
p.address = Maybe.none();
return Result.ok(undefined);
}
const r = toPostalAddress(d.address);
if (r.isFailure) return Result.fail(r.error);
p.address = Maybe.some(r.data);
return Result.ok(undefined);
}
private paymentTermPatch(
p: CompanyPatchProps,
d: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
const r = maybeFromNullableResult(d.default_payment_term_id, UniqueID.create);
if (r.isFailure) return Result.fail(r.error);
p.defaultPaymentTermId = r.data;
return Result.ok(undefined);
}
private paymentTermId(
d: UpdateCurrentCompanyRequestDTO
): Result<Maybe<UniqueID> | undefined, Error> {
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
if (d.default_payment_term_id === null) return Result.ok(Maybe.none());
const r = UniqueID.create(d.default_payment_term_id);
return r.isFailure ? Result.fail(r.error) : Result.ok(Maybe.some(r.data));
}
}

View File

@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { City, CountryCode, Name, PostalAddress, PostalCode, Province, Street, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { City, CountryCode, Name, PostalAddress, PostalCode, Province, Street, UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { Company, CompanySlug, CompanyStatus } from "../../../../domain";
@ -9,7 +9,7 @@ import { CompanyPublicModelMapper } from "./company-public-model.mapper";
const unwrap = <T>(result: { data: T }): T => result.data;
const buildAddress = (street2 = Maybe.some(unwrap(Street.create("Suite 2")))) => unwrap(PostalAddress.create({ street: Maybe.some(unwrap(Street.create("Street 1"))), street2, city: Maybe.some(unwrap(City.create("Madrid"))), postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), province: Maybe.some(unwrap(Province.create("Madrid"))), countryCode: Maybe.some(unwrap(CountryCode.create("ES"))) }));
const buildCompany = (address = Maybe.none<PostalAddress>()) => unwrap(Company.create({ legalName: unwrap(Name.create("Rodax")), tradeName: Maybe.none(), tin: Maybe.none(), slug: unwrap(CompanySlug.create("rodax")), email: Maybe.none(), phone: Maybe.none(), website: Maybe.none(), address, defaultPaymentTermId: Maybe.none(), status: CompanyStatus.createActive(), createdAt: unwrap(UtcDate.createFromISO("2026-01-01")), updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")) }, UniqueID.generateNewID()));
const buildCompany = (address = Maybe.none<PostalAddress>()) => unwrap(Company.create({ legalName: unwrap(Name.create("Rodax")), tradeName: Maybe.none(), tin: Maybe.none(), slug: unwrap(CompanySlug.create("rodax")), email: Maybe.none(), phone: Maybe.none(), website: Maybe.none(), address, defaultPaymentTermId: Maybe.none(), status: CompanyStatus.createActive(), createdAt: unwrap(UtcDateTime.createFromISO("2026-01-01T00:00:00.000Z")), updatedAt: unwrap(UtcDateTime.createFromISO("2026-01-01T00:00:00.000Z")) }, UniqueID.generateNewID()));
const mapper = new CompanyPublicModelMapper();
test("maps Company address to public primitives", () => {

View File

@ -1,11 +1,16 @@
import type { ICompanyPublicFinder } from "./company-public-finder";
import type { IInitialCompanyCreator } from "./initial-company-creator";
export interface ICompanyPublicServices {
finder: ICompanyPublicFinder;
initialCompanyCreator: IInitialCompanyCreator;
}
export const buildCompanyPublicServices = (
finder: ICompanyPublicFinder
finder: ICompanyPublicFinder,
initialCompanyCreator: IInitialCompanyCreator
): ICompanyPublicServices => ({
finder,
initialCompanyCreator,
});

View File

@ -13,7 +13,7 @@ import {
Street,
TextValue,
UniqueID,
UtcDate,
UtcDateTime,
} from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
@ -71,8 +71,8 @@ function setup(error?: Error, duplicate = false) {
}
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 now = UtcDateTime.createFromISO("2026-09-07T00:00:00.000Z").data;
t.mock.method(UtcDateTime, "now", () => now);
const { capability, saved } = setup();
const params = input();
const result = await capability.create(params);

View File

@ -6,7 +6,7 @@ import type {
TextValue,
URLAddress,
} from "@repo/rdx-ddd";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanySlug, CompanyStatus } from "../../../../domain";
@ -56,7 +56,7 @@ export class InitialCompanyCreator implements IInitialCompanyCreator {
// 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 now = UtcDateTime.now();
const created = await this.creator.create({
id,
transaction: params.transaction,

View File

@ -9,7 +9,7 @@ import {
TextValue,
URLAddress,
UniqueID,
UtcDate,
UtcDateTime,
} from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
@ -22,7 +22,7 @@ import { CompanyStatusChanger } from "./company-status-changer";
import { CompanyUpdater } from "./company-updater";
const buildCompany = (status = CompanyStatus.createActive()) => {
const now = UtcDate.createFromISO("2026-08-16");
const now = UtcDateTime.createFromISO("2026-08-16T00:00:00.000Z");
const legalName = Name.create("Rodax Labs");
const slug = CompanySlug.create("rodax-labs");
const tin = TextValue.create("B12345678");
@ -167,7 +167,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
legalName: updatedName.data,
email: Maybe.some(updatedEmail.data),
defaultPaymentTermId: Maybe.some(defaultPaymentTermId),
updatedAt: UtcDate.today(),
updatedAt: UtcDateTime.now(),
},
});
assert.equal(updateResult.isSuccess, true);
@ -198,7 +198,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
const noopResult = await updater.update({
id: company.id,
patchProps: { updatedAt: UtcDate.today() },
patchProps: { updatedAt: UtcDateTime.now() },
});
assert.equal(noopResult.isSuccess, true);
assert.equal(counters.updates, 1);
@ -217,7 +217,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
id: failedAuditCompany.id,
patchProps: {
legalName: updatedName.data,
updatedAt: UtcDate.today(),
updatedAt: UtcDateTime.now(),
},
});
assert.equal(auditFailureResult.isFailure, true);
@ -236,7 +236,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
id: disabledCompany.id,
patchProps: {
legalName: updatedName.data,
updatedAt: UtcDate.today(),
updatedAt: UtcDateTime.now(),
},
});
assert.equal(disabledResult.isFailure, true);
@ -254,7 +254,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
const enableResult = await changer.changeStatus({
company,
action: "enable",
updatedAt: UtcDate.today(),
updatedAt: UtcDateTime.now(),
});
assert.equal(enableResult.isSuccess, true);
assert.equal(counters.updates, 1);
@ -266,7 +266,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
const noopResult = await changer.changeStatus({
company,
action: "enable",
updatedAt: UtcDate.today(),
updatedAt: UtcDateTime.now(),
});
assert.equal(noopResult.isSuccess, true);
assert.equal(counters.updates, 1);
@ -275,7 +275,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
const disableResult = await changer.changeStatus({
company,
action: "disable",
updatedAt: UtcDate.today(),
updatedAt: UtcDateTime.now(),
});
assert.equal(disableResult.isSuccess, true);
assert.equal(counters.updates, 2);
@ -287,7 +287,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
const disabledNoopResult = await changer.changeStatus({
company,
action: "disable",
updatedAt: UtcDate.today(),
updatedAt: UtcDateTime.now(),
});
assert.equal(disabledNoopResult.isSuccess, true);
assert.equal(counters.updates, 2);

View File

@ -20,6 +20,7 @@ import {
import { Maybe, Result } from "@repo/rdx-utils";
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
import { CreateCompanyInputMapper } from "../mappers/create-company-input.mapper";
import { CompanyPublicModelMapper } from "../public";
import { CreateCompanyUseCase } from "./create-company.use-case";
@ -76,6 +77,7 @@ test("create devuelve la company sin default payment term", async () => {
const company = buildCompany();
const useCase = new CreateCompanyUseCase({
inputMapper: new CreateCompanyInputMapper(),
creator: {
create: async () => Result.ok(company),
},
@ -116,6 +118,7 @@ test("create serializes the company public address", async () => {
true
);
const useCase = new CreateCompanyUseCase({
inputMapper: new CreateCompanyInputMapper(),
creator: { create: async () => Result.ok(company) },
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
@ -147,6 +150,7 @@ test("create maps a complete request address to Company props", async () => {
const company = buildCompany();
let capturedAddress: typeof company.address | undefined;
const useCase = new CreateCompanyUseCase({
inputMapper: new CreateCompanyInputMapper(),
creator: {
create: async ({ props }) => {
capturedAddress = props.address;
@ -182,3 +186,22 @@ test("create maps a complete request address to Company props", async () => {
assert.equal(capturedAddress?.unwrap().street2.isNone(), true);
assert.equal(capturedAddress?.unwrap().countryCode.unwrap().toPrimitive(), "ES");
});
test("create returns an input mapper failure without calling the creator", async () => {
let creatorCalled = false;
const expectedError = new Error("invalid input");
const useCase = new CreateCompanyUseCase({
inputMapper: { map: () => Result.fail(expectedError) },
creator: {
create: async () => {
creatorCalled = true;
return Result.fail(expectedError);
},
},
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({ dto: {} as never });
assert.equal(result.error, expectedError);
assert.equal(creatorCalled, false);
});

View File

@ -1,137 +1,38 @@
import type { ITransactionManager } from "@erp/core/api";
import type { PostalAddress } from "@repo/rdx-ddd";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common";
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
import { toPostalAddress } from "../mappers/company-address.mapper";
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
import type { ICreateCompanyInputMapper } from "../mappers/create-company-input.mapper";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyCreator } from "../services";
export class CreateCompanyUseCase {
public constructor(
private readonly deps: {
creator: ICompanyCreator;
inputMapper: ICreateCompanyInputMapper;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: {
dto: CreateCompanyRequestDTO;
}): Promise<Result<CompanyResponseDTO, Error>> {
const mappedResult = mapCreateCompanyProps(params.dto);
if (mappedResult.isFailure) {
return Promise.resolve(Result.fail(mappedResult.error));
}
const { id, props } = mappedResult.data;
return this.deps.transactionManager.complete(async (transaction: unknown) => {
const input = this.deps.inputMapper.map(params.dto);
if (input.isFailure) return Promise.resolve(Result.fail(input.error));
return this.deps.transactionManager.complete(async (transaction) => {
try {
const createResult = await this.deps.creator.create({ id, props, transaction });
if (createResult.isFailure) {
return Result.fail(createResult.error);
}
return Result.ok(
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(createResult.data))
);
const result = await this.deps.creator.create({
id: input.data.id,
props: input.data.props,
transaction,
});
return result.isFailure
? Result.fail(result.error)
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) {
const idResult = UniqueID.create(dto.id);
if (idResult.isFailure) {
return Result.fail(idResult.error);
}
const legalNameResult = Name.create(dto.legal_name);
if (legalNameResult.isFailure) {
return Result.fail(legalNameResult.error);
}
const tradeNameResult = maybeFromNullableResult(dto.trade_name ?? null, (value) =>
Name.create(value)
);
if (tradeNameResult.isFailure) {
return Result.fail(tradeNameResult.error);
}
const tinResult = maybeFromNullableResult(dto.tin ?? null, (value) => TextValue.create(value));
if (tinResult.isFailure) {
return Result.fail(tinResult.error);
}
const slugResult = CompanySlug.create(dto.slug);
if (slugResult.isFailure) {
return Result.fail(slugResult.error);
}
const emailResult = maybeFromNullableResult(dto.email ?? null, (value) =>
EmailAddress.create(value)
);
if (emailResult.isFailure) {
return Result.fail(emailResult.error);
}
const phoneResult = maybeFromNullableResult(dto.phone ?? null, (value) =>
PhoneNumber.create(value)
);
if (phoneResult.isFailure) {
return Result.fail(phoneResult.error);
}
const websiteResult = maybeFromNullableResult(dto.website ?? null, (value) =>
URLAddress.create(value)
);
if (websiteResult.isFailure) {
return Result.fail(websiteResult.error);
}
let address: Maybe<PostalAddress> = Maybe.none();
if (dto.address !== undefined && dto.address !== null) {
const addressResult = toPostalAddress(dto.address);
if (addressResult.isFailure) {
return Result.fail(addressResult.error);
}
address = Maybe.some(addressResult.data);
}
const now = UtcDateTime.now();
const props: ICompanyCreateProps = {
legalName: legalNameResult.data,
tradeName: tradeNameResult.data,
tin: tinResult.data,
slug: slugResult.data,
email: emailResult.data,
phone: phoneResult.data,
website: websiteResult.data,
address,
defaultPaymentTermId: Maybe.none(),
status: CompanyStatus.createActive(),
createdAt: now,
updatedAt: now,
};
return Result.ok({
id: idResult.data,
props,
});
}

View File

@ -1,5 +1,5 @@
import type { IIdentityPublicServices } from "@erp/identity/api";
import type { ITransactionManager } from "@erp/core/api";
import type { IIdentityPublicServices } from "@erp/identity/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
@ -10,14 +10,16 @@ import type { CompanyPublicModelMapper } from "../public";
export class FindAvailableCompaniesForAccountUseCase {
public constructor(
private readonly deps: {
identityServices: IIdentityPublicServices;
identityServices: Pick<IIdentityPublicServices, "companyAccess">;
repository: ICompanyRepository;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: { accountId: string }): Promise<Result<AvailableCompaniesResponseDTO, Error>> {
public execute(params: {
accountId: string;
}): Promise<Result<AvailableCompaniesResponseDTO, Error>> {
const accountIdResult = UniqueID.create(params.accountId);
if (accountIdResult.isFailure) {
return Promise.resolve(Result.fail(accountIdResult.error));

View File

@ -14,6 +14,7 @@ import {
import { Maybe, Result } from "@repo/rdx-utils";
import { Company, type CompanyPatchProps, CompanySlug, CompanyStatus } from "../../../domain";
import { UpdateCompanyInputMapper } from "../mappers/update-company-input.mapper";
import { CompanyPublicModelMapper } from "../public";
import { UpdateCompanyUseCase } from "./update-company.use-case";
@ -72,6 +73,7 @@ test("update permite limpiar el default payment term con null", async () => {
let receivedId: Maybe<UniqueID> | undefined;
const useCase = new UpdateCompanyUseCase({
inputMapper: new UpdateCompanyInputMapper(),
updater: {
update: async ({ patchProps }) => {
receivedPatch = patchProps;
@ -100,7 +102,7 @@ test("update permite limpiar el default payment term con null", async () => {
});
assert.equal(result.isSuccess, true);
assert.equal(receivedPatch?.defaultPaymentTermId?.isNone(), true);
assert.equal((receivedPatch as CompanyPatchProps | null)?.defaultPaymentTermId?.isNone(), true);
assert.equal(receivedId?.isNone(), true);
assert.equal(result.data.default_payment_term_id, null);
});
@ -109,6 +111,7 @@ test("update company maps the address tri-state", async () => {
const company = buildCompany();
const captured: Array<unknown> = [];
const useCase = new UpdateCompanyUseCase({
inputMapper: new UpdateCompanyInputMapper(),
updater: {
update: async ({ patchProps }) => {
captured.push(patchProps.address);
@ -150,3 +153,26 @@ test("update company maps the address tri-state", async () => {
"ES"
);
});
test("update returns an input mapper failure without calling services", async () => {
let updaterCalled = false;
const expectedError = new Error("invalid input");
const useCase = new UpdateCompanyUseCase({
inputMapper: { map: () => Result.fail(expectedError) },
updater: {
update: async () => {
updaterCalled = true;
return Result.fail(expectedError);
},
},
paymentTermResolver: { ensureAssignable: async () => Result.fail(expectedError) },
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
company_id: "11111111-1111-4111-8111-111111111111",
dto: {},
});
assert.equal(result.error, expectedError);
assert.equal(updaterCalled, false);
});

View File

@ -1,188 +1,51 @@
import type { ITransactionManager } from "@erp/core/api";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDateTime,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common";
import { type CompanyPatchProps, CompanySlug } from "../../../domain";
import { toPostalAddress } from "../mappers/company-address.mapper";
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
import type { IUpdateCompanyInputMapper } from "../mappers/update-company-input.mapper";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
export class UpdateCompanyUseCase {
public constructor(
private readonly deps: {
updater: ICompanyUpdater;
inputMapper: IUpdateCompanyInputMapper;
paymentTermResolver: ICompanyPaymentTermResolver;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: {
company_id: string;
dto: UpdateCompanyRequestDTO;
}): Promise<Result<CompanyResponseDTO, Error>> {
const companyIdResult = UniqueID.create(params.company_id);
if (companyIdResult.isFailure) {
return Promise.resolve(Result.fail(companyIdResult.error));
}
const patchPropsResult = mapUpdateCompanyProps(params.dto);
if (patchPropsResult.isFailure) {
return Promise.resolve(Result.fail(patchPropsResult.error));
}
const defaultPaymentTermIdResult = mapDefaultPaymentTermId(params.dto);
if (defaultPaymentTermIdResult.isFailure) {
return Promise.resolve(Result.fail(defaultPaymentTermIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
const companyId = UniqueID.create(params.company_id);
if (companyId.isFailure) return Promise.resolve(Result.fail(companyId.error));
const input = this.deps.inputMapper.map(params.dto);
if (input.isFailure) return Promise.resolve(Result.fail(input.error));
return this.deps.transactionManager.complete(async (transaction) => {
try {
if (defaultPaymentTermIdResult.data !== undefined) {
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
companyId: companyIdResult.data,
id: defaultPaymentTermIdResult.data,
if (input.data.defaultPaymentTermId !== undefined) {
const result = await this.deps.paymentTermResolver.ensureAssignable({
companyId: companyId.data,
id: input.data.defaultPaymentTermId,
transaction,
});
if (ensureResult.isFailure) {
return Result.fail(ensureResult.error);
}
if (result.isFailure) return Result.fail(result.error);
}
const updateResult = await this.deps.updater.update({
id: companyIdResult.data,
patchProps: patchPropsResult.data,
const result = await this.deps.updater.update({
id: companyId.data,
patchProps: input.data.patchProps,
transaction,
});
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok(
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))
);
return result.isFailure
? Result.fail(result.error)
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function mapUpdateCompanyProps(dto: UpdateCompanyRequestDTO): Result<CompanyPatchProps, Error> {
const patchProps: CompanyPatchProps = {
updatedAt: UtcDateTime.now(),
};
if (dto.legal_name !== undefined) {
const legalNameResult = Name.create(dto.legal_name);
if (legalNameResult.isFailure) {
return Result.fail(legalNameResult.error);
}
patchProps.legalName = legalNameResult.data;
}
if (dto.trade_name !== undefined) {
const tradeNameResult = maybeFromNullableResult(dto.trade_name, (value) => Name.create(value));
if (tradeNameResult.isFailure) {
return Result.fail(tradeNameResult.error);
}
patchProps.tradeName = tradeNameResult.data;
}
if (dto.tin !== undefined) {
const tinResult = maybeFromNullableResult(dto.tin, (value) => TextValue.create(value));
if (tinResult.isFailure) {
return Result.fail(tinResult.error);
}
patchProps.tin = tinResult.data;
}
if (dto.slug !== undefined) {
const slugResult = CompanySlug.create(dto.slug);
if (slugResult.isFailure) {
return Result.fail(slugResult.error);
}
patchProps.slug = slugResult.data;
}
if (dto.email !== undefined) {
const emailResult = maybeFromNullableResult(dto.email, (value) => EmailAddress.create(value));
if (emailResult.isFailure) {
return Result.fail(emailResult.error);
}
patchProps.email = emailResult.data;
}
if (dto.phone !== undefined) {
const phoneResult = maybeFromNullableResult(dto.phone, (value) => PhoneNumber.create(value));
if (phoneResult.isFailure) {
return Result.fail(phoneResult.error);
}
patchProps.phone = phoneResult.data;
}
if (dto.website !== undefined) {
const websiteResult = maybeFromNullableResult(dto.website, (value) => URLAddress.create(value));
if (websiteResult.isFailure) {
return Result.fail(websiteResult.error);
}
patchProps.website = websiteResult.data;
}
if (dto.address !== undefined) {
if (dto.address === null) {
patchProps.address = Maybe.none();
} else {
const addressResult = toPostalAddress(dto.address);
if (addressResult.isFailure) {
return Result.fail(addressResult.error);
}
patchProps.address = Maybe.some(addressResult.data);
}
}
if (dto.default_payment_term_id !== undefined) {
const defaultPaymentTermIdResult = maybeFromNullableResult(
dto.default_payment_term_id,
(value) => UniqueID.create(value)
);
if (defaultPaymentTermIdResult.isFailure) {
return Result.fail(defaultPaymentTermIdResult.error);
}
patchProps.defaultPaymentTermId = defaultPaymentTermIdResult.data;
}
return Result.ok(patchProps);
}
function mapDefaultPaymentTermId(
dto: UpdateCompanyRequestDTO
): Result<Maybe<UniqueID> | undefined, Error> {
if (dto.default_payment_term_id === undefined) {
return Result.ok(undefined);
}
if (dto.default_payment_term_id === null) {
return Result.ok(Maybe.none());
}
const defaultPaymentTermIdResult = UniqueID.create(dto.default_payment_term_id);
if (defaultPaymentTermIdResult.isFailure) {
return Result.fail(defaultPaymentTermIdResult.error);
}
return Result.ok(Maybe.some(defaultPaymentTermIdResult.data));
}

View File

@ -9,11 +9,12 @@ import {
TextValue,
URLAddress,
UniqueID,
UtcDate,
UtcDateTime,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
import { UpdateCurrentCompanyInputMapper } from "../mappers/update-current-company-input.mapper";
import { CompanyPublicModelMapper } from "../public";
import { UpdateCurrentCompanyUseCase } from "./update-current-company.use-case";
@ -27,7 +28,7 @@ const buildTransactionManager = (): ITransactionManager => ({
});
const buildCompany = () => {
const now = UtcDate.createFromISO("2026-08-16");
const now = UtcDateTime.createFromISO("2026-08-16T00:00:00.000Z");
const id = UniqueID.generateNewID();
const legalName = Name.create("Rodax Labs");
const tradeName = Name.create("Rodax");
@ -74,6 +75,7 @@ test("update current company permite limpiar el default payment term con null",
let receivedId: Maybe<UniqueID> | undefined;
const useCase = new UpdateCurrentCompanyUseCase({
inputMapper: new UpdateCurrentCompanyInputMapper(),
updater: {
update: async ({ patchProps }) => {
receivedDefaultPaymentTermId = patchProps.defaultPaymentTermId;
@ -115,6 +117,7 @@ test("update current company no permite cambiar el slug", async () => {
let receivedPatch: Record<string, unknown> | null = null;
const useCase = new UpdateCurrentCompanyUseCase({
inputMapper: new UpdateCurrentCompanyInputMapper(),
updater: {
update: async ({ patchProps }) => {
receivedPatch = patchProps;
@ -145,6 +148,7 @@ test("update current company preserves omitted address and maps null or a replac
const company = buildCompany();
const captured: Array<unknown> = [];
const useCase = new UpdateCurrentCompanyUseCase({
inputMapper: new UpdateCurrentCompanyInputMapper(),
updater: {
update: async ({ patchProps }) => {
captured.push(patchProps.address);
@ -186,3 +190,26 @@ test("update current company preserves omitted address and maps null or a replac
"ES"
);
});
test("update current returns an input mapper failure without calling services", async () => {
let updaterCalled = false;
const expectedError = new Error("invalid input");
const useCase = new UpdateCurrentCompanyUseCase({
inputMapper: { map: () => Result.fail(expectedError) },
updater: {
update: async () => {
updaterCalled = true;
return Result.fail(expectedError);
},
},
paymentTermResolver: { ensureAssignable: async () => Result.fail(expectedError) },
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
companyId: "11111111-1111-4111-8111-111111111111",
dto: {},
});
assert.equal(result.error, expectedError);
assert.equal(updaterCalled, false);
});

View File

@ -1,266 +1,51 @@
import type { ITransactionManager } from "@erp/core/api";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDate,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../common";
import type { CompanyPatchProps } from "../../../domain";
import { toPostalAddress } from "../mappers/company-address.mapper";
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
import type { IUpdateCurrentCompanyInputMapper } from "../mappers/update-current-company-input.mapper";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
export class UpdateCurrentCompanyUseCase {
public constructor(
private readonly deps: {
updater: ICompanyUpdater;
inputMapper: IUpdateCurrentCompanyInputMapper;
paymentTermResolver: ICompanyPaymentTermResolver;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: {
companyId: string;
dto: UpdateCurrentCompanyRequestDTO;
}): Promise<Result<CompanyResponseDTO, Error>> {
const companyIdResult = UniqueID.create(params.companyId);
if (companyIdResult.isFailure) {
return Promise.resolve(Result.fail(companyIdResult.error));
}
const patchPropsResult = mapUpdateCurrentCompanyProps(params.dto);
if (patchPropsResult.isFailure) {
return Promise.resolve(Result.fail(patchPropsResult.error));
}
const defaultPaymentTermIdResult = mapDefaultPaymentTermId(params.dto);
if (defaultPaymentTermIdResult.isFailure) {
return Promise.resolve(Result.fail(defaultPaymentTermIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
const companyId = UniqueID.create(params.companyId);
if (companyId.isFailure) return Promise.resolve(Result.fail(companyId.error));
const input = this.deps.inputMapper.map(params.dto);
if (input.isFailure) return Promise.resolve(Result.fail(input.error));
return this.deps.transactionManager.complete(async (transaction) => {
try {
if (defaultPaymentTermIdResult.data !== undefined) {
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
companyId: companyIdResult.data,
id: defaultPaymentTermIdResult.data,
if (input.data.defaultPaymentTermId !== undefined) {
const result = await this.deps.paymentTermResolver.ensureAssignable({
companyId: companyId.data,
id: input.data.defaultPaymentTermId,
transaction,
});
if (ensureResult.isFailure) {
return Result.fail(ensureResult.error);
}
if (result.isFailure) return Result.fail(result.error);
}
const updateResult = await this.deps.updater.update({
id: companyIdResult.data,
patchProps: patchPropsResult.data,
const result = await this.deps.updater.update({
id: companyId.data,
patchProps: input.data.patchProps,
transaction,
});
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok(
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))
);
return result.isFailure
? Result.fail(result.error)
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function mapUpdateCurrentCompanyProps(
dto: UpdateCurrentCompanyRequestDTO
): Result<CompanyPatchProps, Error> {
const patchProps: CompanyPatchProps = {
updatedAt: UtcDate.today(),
};
const assignments = [
assignLegalNamePatch(patchProps, dto),
assignTradeNamePatch(patchProps, dto),
assignTinPatch(patchProps, dto),
assignEmailPatch(patchProps, dto),
assignPhonePatch(patchProps, dto),
assignWebsitePatch(patchProps, dto),
assignDefaultPaymentTermPatch(patchProps, dto),
assignAddressPatch(patchProps, dto),
];
for (const assignment of assignments) {
if (assignment.isFailure) {
return Result.fail(assignment.error);
}
}
return Result.ok(patchProps);
}
function assignLegalNamePatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.legal_name === undefined) {
return Result.ok(undefined);
}
const result = Name.create(dto.legal_name);
if (result.isFailure) {
return Result.fail(result.error);
}
patchProps.legalName = result.data;
return Result.ok(undefined);
}
function assignTradeNamePatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.trade_name === undefined) {
return Result.ok(undefined);
}
const result = maybeFromNullableResult(dto.trade_name, (value) => Name.create(value));
if (result.isFailure) {
return Result.fail(result.error);
}
patchProps.tradeName = result.data;
return Result.ok(undefined);
}
function assignTinPatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.tin === undefined) {
return Result.ok(undefined);
}
const result = maybeFromNullableResult(dto.tin, (value) => TextValue.create(value));
if (result.isFailure) {
return Result.fail(result.error);
}
patchProps.tin = result.data;
return Result.ok(undefined);
}
function assignEmailPatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.email === undefined) {
return Result.ok(undefined);
}
const result = maybeFromNullableResult(dto.email, (value) => EmailAddress.create(value));
if (result.isFailure) {
return Result.fail(result.error);
}
patchProps.email = result.data;
return Result.ok(undefined);
}
function assignPhonePatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.phone === undefined) {
return Result.ok(undefined);
}
const result = maybeFromNullableResult(dto.phone, (value) => PhoneNumber.create(value));
if (result.isFailure) {
return Result.fail(result.error);
}
patchProps.phone = result.data;
return Result.ok(undefined);
}
function assignWebsitePatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.website === undefined) {
return Result.ok(undefined);
}
const result = maybeFromNullableResult(dto.website, (value) => URLAddress.create(value));
if (result.isFailure) {
return Result.fail(result.error);
}
patchProps.website = result.data;
return Result.ok(undefined);
}
function assignAddressPatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.address === undefined) {
return Result.ok(undefined);
}
if (dto.address === null) {
patchProps.address = Maybe.none();
return Result.ok(undefined);
}
const addressResult = toPostalAddress(dto.address);
if (addressResult.isFailure) {
return Result.fail(addressResult.error);
}
patchProps.address = Maybe.some(addressResult.data);
return Result.ok(undefined);
}
function assignDefaultPaymentTermPatch(
patchProps: CompanyPatchProps,
dto: UpdateCurrentCompanyRequestDTO
): Result<void, Error> {
if (dto.default_payment_term_id === undefined) {
return Result.ok(undefined);
}
const result = maybeFromNullableResult(dto.default_payment_term_id, (value) =>
UniqueID.create(value)
);
if (result.isFailure) {
return Result.fail(result.error);
}
patchProps.defaultPaymentTermId = result.data;
return Result.ok(undefined);
}
function mapDefaultPaymentTermId(
dto: UpdateCurrentCompanyRequestDTO
): Result<Maybe<UniqueID> | undefined, Error> {
if (dto.default_payment_term_id === undefined) {
return Result.ok(undefined);
}
if (dto.default_payment_term_id === null) {
return Result.ok(Maybe.none());
}
const defaultPaymentTermIdResult = UniqueID.create(dto.default_payment_term_id);
if (defaultPaymentTermIdResult.isFailure) {
return Result.fail(defaultPaymentTermIdResult.error);
}
return Result.ok(Maybe.some(defaultPaymentTermIdResult.data));
}

View File

@ -10,7 +10,7 @@ import {
Province,
Street,
UniqueID,
UtcDate,
UtcDateTime,
} from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
@ -41,8 +41,8 @@ const props = (value = Maybe.none<PostalAddress>()) => ({
address: value,
defaultPaymentTermId: Maybe.none(),
status: CompanyStatus.createActive(),
createdAt: unwrap(UtcDate.createFromISO("2026-01-01")),
updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")),
createdAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
});
const company = (value = Maybe.none<PostalAddress>()) =>
unwrap(Company.create(props(value), UniqueID.generateNewID()));
@ -65,7 +65,8 @@ test("Company address update preserves, sets, replaces, clears and compares prim
const initial = address("Street A");
const target = company(Maybe.some(initial));
assert.equal(
target.update({ updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")) }).data,
target.update({ updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")) })
.data,
false
);
const equivalent = address("Street A");
@ -73,26 +74,28 @@ test("Company address update preserves, sets, replaces, clears and compares prim
assert.equal(
target.update({
address: Maybe.some(equivalent),
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
}).data,
false
);
assert.equal(
target.update({
address: Maybe.some(address("Street B")),
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
}).data,
true
);
assert.equal(
target.update({ address: Maybe.none(), updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")) })
.data,
target.update({
address: Maybe.none(),
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
}).data,
true
);
assert.equal(
target.update({
address: Maybe.some(address()),
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
}).data,
true
);

View File

@ -7,7 +7,7 @@ import {
type TextValue,
type URLAddress,
type UniqueID,
type UtcDate,
type UtcDateTime,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
@ -26,8 +26,8 @@ export interface ICompanyCreateProps {
address: Maybe<PostalAddress>;
defaultPaymentTermId: Maybe<UniqueID>;
status: CompanyStatus;
createdAt: UtcDate;
updatedAt: UtcDate;
createdAt: UtcDateTime;
updatedAt: UtcDateTime;
}
export type CompanyPatchProps = Partial<{
@ -41,7 +41,7 @@ export type CompanyPatchProps = Partial<{
address: Maybe<PostalAddress>;
defaultPaymentTermId: Maybe<UniqueID>;
}> & {
updatedAt: UtcDate;
updatedAt: UtcDateTime;
};
export type CompanyInternalProps = ICompanyCreateProps;
@ -124,11 +124,11 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
return this.props.status;
}
public get createdAt(): UtcDate {
public get createdAt(): UtcDateTime {
return this.props.createdAt;
}
public get updatedAt(): UtcDate {
public get updatedAt(): UtcDateTime {
return this.props.updatedAt;
}
@ -204,7 +204,7 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
return Result.ok(true);
}
public disable(updatedAt: UtcDate): Result<boolean, Error> {
public disable(updatedAt: UtcDateTime): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.ok(false);
}
@ -214,7 +214,7 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
return Result.ok(true);
}
public enable(updatedAt: UtcDate): Result<boolean, Error> {
public enable(updatedAt: UtcDateTime): Result<boolean, Error> {
if (this.status.isActive()) {
return Result.ok(false);
}

View File

@ -1,5 +1,6 @@
import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
import type { IIdentityCompanyAccessChecker } from "@erp/identity/module";
import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import type { IIdentityPublicServices } from "@erp/identity/api";
import type { Sequelize } from "sequelize";
import {
@ -17,43 +18,49 @@ import {
GetCompanyByIdUseCase,
GetCurrentCompanyUseCase,
type ICompanyPublicServices,
InitialCompanyCreator,
UpdateCompanyUseCase,
UpdateCurrentCompanyUseCase,
buildCompanyPublicServices,
} from "../../application";
import { CreateCompanyInputMapper } from "../../application/companies/mappers/create-company-input.mapper";
import { UpdateCompanyInputMapper } from "../../application/companies/mappers/update-company-input.mapper";
import { UpdateCurrentCompanyInputMapper } from "../../application/companies/mappers/update-current-company-input.mapper";
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di";
export type CompaniesInternalDeps = {
repository: SequelizeCompanyRepository;
companyAccess: IIdentityPublicServices["companyAccess"];
publicFinder: CompanyPublicFinder;
useCases: {
createCompany: () => CreateCompanyUseCase;
getCompanyById: () => GetCompanyByIdUseCase;
getCurrentCompany: () => GetCurrentCompanyUseCase;
findAvailableCompaniesForAccount: () => FindAvailableCompaniesForAccountUseCase;
updateCompany: () => UpdateCompanyUseCase;
updateCurrentCompany: () => UpdateCurrentCompanyUseCase;
enableCompany: () => EnableCompanyUseCase;
disableCompany: () => DisableCompanyUseCase;
createCompany: CreateCompanyUseCase;
getCompanyById: GetCompanyByIdUseCase;
getCurrentCompany: GetCurrentCompanyUseCase;
findAvailableCompaniesForAccount: FindAvailableCompaniesForAccountUseCase;
updateCompany: UpdateCompanyUseCase;
updateCurrentCompany: UpdateCurrentCompanyUseCase;
enableCompany: EnableCompanyUseCase;
disableCompany: DisableCompanyUseCase;
};
};
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => {
const { audit, database, getService, transactionContext } = params;
export type CompaniesDependencies = {
internal: CompaniesInternalDeps;
publicServices: ICompanyPublicServices;
};
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDependencies => {
const { audit, database, transactionContext } = params;
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
const responseMapper = new CompanyPublicModelMapper();
const companyAccess = getService<{ companyAccess: IIdentityCompanyAccessChecker }>(
"identity:general"
).companyAccess;
const catalogsDeps = resolveCompanyCatalogsDeps(params);
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
const paymentTermServices =
params.getService<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
const finder = new CompanyFinder(repository);
const creator = new CompanyCreator(repository, audit);
const paymentTermResolver = new CompanyPaymentTermResolver({
paymentTermFinder: catalogsDeps.paymentTerm.finder,
paymentTermFinder: paymentTermServices.finder,
});
const updater = new CompanyUpdater({ finder, repository, audit });
const statusChanger = new CompanyStatusChanger(repository, audit);
@ -62,70 +69,66 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
mapper: responseMapper,
});
const initialCompanyCreator = new InitialCompanyCreator(creator);
const createCompanyInputMapper = new CreateCompanyInputMapper();
const updateCompanyInputMapper = new UpdateCompanyInputMapper();
const updateCurrentCompanyInputMapper = new UpdateCurrentCompanyInputMapper();
return {
repository,
publicFinder,
useCases: {
createCompany: () =>
new CreateCompanyUseCase({
internal: {
companyAccess: identityServices.companyAccess,
publicFinder,
useCases: {
createCompany: new CreateCompanyUseCase({
creator,
inputMapper: createCompanyInputMapper,
responseMapper,
transactionManager,
}),
getCompanyById: () =>
new GetCompanyByIdUseCase({
getCompanyById: new GetCompanyByIdUseCase({
finder,
responseMapper,
transactionManager,
}),
getCurrentCompany: () =>
new GetCurrentCompanyUseCase({
getCurrentCompany: new GetCurrentCompanyUseCase({
finder,
responseMapper,
transactionManager,
}),
findAvailableCompaniesForAccount: () =>
new FindAvailableCompaniesForAccountUseCase({
identityServices: { companyAccess },
findAvailableCompaniesForAccount: new FindAvailableCompaniesForAccountUseCase({
identityServices: { companyAccess: identityServices.companyAccess },
repository,
responseMapper,
transactionManager,
}),
updateCompany: () =>
new UpdateCompanyUseCase({
updateCompany: new UpdateCompanyUseCase({
updater,
inputMapper: updateCompanyInputMapper,
paymentTermResolver,
responseMapper,
transactionManager,
}),
updateCurrentCompany: () =>
new UpdateCurrentCompanyUseCase({
updateCurrentCompany: new UpdateCurrentCompanyUseCase({
updater,
inputMapper: updateCurrentCompanyInputMapper,
paymentTermResolver,
responseMapper,
transactionManager,
}),
enableCompany: () =>
new EnableCompanyUseCase({
enableCompany: new EnableCompanyUseCase({
finder,
changer: statusChanger,
responseMapper,
transactionManager,
}),
disableCompany: () =>
new DisableCompanyUseCase({
disableCompany: new DisableCompanyUseCase({
finder,
changer: statusChanger,
responseMapper,
transactionManager,
}),
},
},
publicServices: buildCompanyPublicServices(publicFinder, initialCompanyCreator),
};
};
export const buildCompaniesPublicServices = (
_params: SetupParams,
deps: CompaniesInternalDeps
): ICompanyPublicServices => {
return buildCompanyPublicServices(deps.publicFinder);
};

View File

@ -1,21 +0,0 @@
import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
import type { ModuleParams } from "@erp/core/api";
export function resolveCompanyCatalogsDeps(params: ModuleParams): {
paymentTerm: {
finder: CatalogsPublicServicesType["paymentTerms"]["finder"];
};
} {
const paymentTerm =
params.getService<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
if (!paymentTerm?.finder) {
throw new Error("Missing public service: catalogs:paymentTerms.finder");
}
return {
paymentTerm: {
finder: paymentTerm.finder,
},
};
}

View File

@ -1,5 +1,4 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import type { IIdentityPublicServices } from "@erp/identity/api";
import { requireCompanyContext, requireIdentityAuthenticated } from "@erp/identity/api";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
@ -26,11 +25,10 @@ import {
} from "./controllers";
export const companiesRouter = (params: StartParams) => {
const { app, config, getInternal, getService, operationContext } = params;
const { app, config, getInternal, operationContext } = params;
const deps = getInternal<CompaniesInternalDeps>("companies");
const identityServices = getService<IIdentityPublicServices>("identity:general");
const requireCurrentCompanyContext = requireCompanyContext({
companyAccessChecker: identityServices.companyAccess,
companyAccessChecker: deps.companyAccess,
companyFinder: deps.publicFinder,
operationContext,
});
@ -43,14 +41,14 @@ export const companiesRouter = (params: StartParams) => {
"/",
validateRequest(CreateCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new CreateCompanyController(deps.useCases.createCompany());
const controller = new CreateCompanyController(deps.useCases.createCompany);
return controller.execute(req, res, next);
}
);
router.get("/available", (req: Request, res: Response, next: NextFunction) => {
const controller = new FindAvailableCompaniesController(
deps.useCases.findAvailableCompaniesForAccount()
deps.useCases.findAvailableCompaniesForAccount
);
return controller.execute(req, res, next);
});
@ -59,7 +57,7 @@ export const companiesRouter = (params: StartParams) => {
"/current",
requireCurrentCompanyContext,
(req: Request, res: Response, next: NextFunction) => {
const controller = new GetCurrentCompanyController(deps.useCases.getCurrentCompany());
const controller = new GetCurrentCompanyController(deps.useCases.getCurrentCompany);
return controller.execute(req, res, next);
}
);
@ -69,7 +67,7 @@ export const companiesRouter = (params: StartParams) => {
requireCurrentCompanyContext,
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany());
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany);
return controller.execute(req, res, next);
}
);
@ -79,7 +77,7 @@ export const companiesRouter = (params: StartParams) => {
requireCurrentCompanyContext,
validateRequest(GetCompanyByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById());
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById);
return controller.execute(req, res, next);
}
);
@ -90,7 +88,7 @@ export const companiesRouter = (params: StartParams) => {
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
validateRequest(UpdateCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new UpdateCompanyController(deps.useCases.updateCompany());
const controller = new UpdateCompanyController(deps.useCases.updateCompany);
return controller.execute(req, res, next);
}
);
@ -100,7 +98,7 @@ export const companiesRouter = (params: StartParams) => {
requireCurrentCompanyContext,
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new EnableCompanyController(deps.useCases.enableCompany());
const controller = new EnableCompanyController(deps.useCases.enableCompany);
return controller.execute(req, res, next);
}
);
@ -110,7 +108,7 @@ export const companiesRouter = (params: StartParams) => {
requireCurrentCompanyContext,
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new DisableCompanyController(deps.useCases.disableCompany());
const controller = new DisableCompanyController(deps.useCases.disableCompany);
return controller.execute(req, res, next);
}
);

View File

@ -10,7 +10,7 @@ import {
Province,
Street,
UniqueID,
UtcDate,
UtcDateTime,
} from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
@ -47,8 +47,8 @@ const company = (address = Maybe.none<PostalAddress>()) =>
address,
defaultPaymentTermId: Maybe.none(),
status: CompanyStatus.createActive(),
createdAt: unwrap(UtcDate.createFromISO("2026-01-01")),
updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")),
createdAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
},
UniqueID.generateNewID()
)
@ -65,8 +65,8 @@ const row = (patch: Record<string, string | null> = {}) =>
website: null,
default_payment_term_id: null,
status: "active",
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
created_at: "2026-09-08T09:37:24.321Z",
updated_at: "2026-09-08T09:37:24.321Z",
address_street: null,
address_street_2: null,
address_city: null,

View File

@ -36,18 +36,27 @@ function mapAddress(source: CompanyModel): Result<Maybe<PostalAddress>, Error> {
source.address_province,
source.address_country_code,
];
if (values.every((value) => value === null) && source.address_street_2 === null)
if (values.every((value) => value === null) && source.address_street_2 === null) {
return Result.ok(Maybe.none());
if (values.some((value) => value === null))
}
if (values.some((value) => value === null)) {
return Result.fail(new Error("Company address persistence is partial."));
}
const street = Street.create(source.address_street as string);
const city = City.create(source.address_city as string);
const postalCode = PostalCode.create(source.address_postal_code as string);
const province = Province.create(source.address_province as string);
const countryCode = CountryCode.create(source.address_country_code as string);
const street2 = maybeFromNullableResult(source.address_street_2, (value) => Street.create(value));
for (const result of [street, city, postalCode, province, countryCode, street2])
if (result.isFailure) return Result.fail(result.error);
for (const result of [street, city, postalCode, province, countryCode, street2]) {
if (result.isFailure) {
return Result.fail(result.error);
}
}
const address = PostalAddress.create({
street: Maybe.some(street.data),
street2: street2.data,
@ -56,6 +65,7 @@ function mapAddress(source: CompanyModel): Result<Maybe<PostalAddress>, Error> {
province: Maybe.some(province.data),
countryCode: Maybe.some(countryCode.data),
});
return address.isFailure ? Result.fail(address.error) : Result.ok(Maybe.some(address.data));
}
@ -173,8 +183,8 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper<
value.toPrimitive()
),
status: source.status.toPrimitive(),
created_at: source.createdAt.toISOString(),
updated_at: source.updatedAt.toISOString(),
created_at: source.createdAt.toDate(),
updated_at: source.updatedAt.toDate(),
});
}
}

View File

@ -28,8 +28,9 @@ export class CompanyModel extends Model<
declare address_country_code: string | null;
declare default_payment_term_id: string | null;
declare status: string;
declare created_at: string;
declare updated_at: string;
declare created_at: Date;
declare updated_at: Date;
declare deleted_at: Date;
static associate(_database: Sequelize) {}
@ -101,6 +102,10 @@ export default (database: Sequelize) => {
type: DataTypes.DATE,
allowNull: false,
},
deleted_at: {
type: DataTypes.DATE,
allowNull: false,
},
},
{
sequelize: database,
@ -109,7 +114,13 @@ export default (database: Sequelize) => {
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
underscored: true,
timestamps: false,
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [
{
name: "uq_companies_slug",

View File

@ -1,17 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Result } from "@repo/rdx-utils";
import {
CompanyCreator,
CreateCompanyUseCase,
DisableCompanyUseCase,
EnableCompanyUseCase,
FindAvailableCompaniesForAccountUseCase,
GetCompanyByIdUseCase,
GetCurrentCompanyUseCase,
InitialCompanyCreator,
UpdateCompanyUseCase,
UpdateCurrentCompanyUseCase,
} from "./application";
import { companiesModule } from "./module";
test("companiesModule compone el finder público de compañías sin HTTP", async () => {
test("companiesModule compone el finder público de compañías sin HTTP", async (t) => {
const create = t.mock.method(CompanyCreator.prototype, "create", async () =>
Result.fail(new Error("not called during setup"))
);
const setup = await companiesModule.setup?.({
database: {},
transactionContext: {},
audit: { recordMutation: async () => ({ isFailure: false }) },
getService: () => ({ finder: {} }),
getService: (name: string) => {
if (name === "identity:general") return { companyAccess: {} };
if (name === "catalogs:paymentTerms") return { finder: {} };
throw new Error(`Unexpected public service: ${name}`);
},
logger: { info: () => undefined },
} as never);
const general = setup?.services?.general as { finder: unknown };
const general = setup?.services?.general as {
finder: unknown;
initialCompanyCreator: InitialCompanyCreator;
};
assert.equal(typeof general.finder, "object");
assert.ok(general.initialCompanyCreator instanceof InitialCompanyCreator);
assert.equal(typeof general.initialCompanyCreator.create, "function");
assert.equal(create.mock.callCount(), 0);
const internal = setup?.internal as {
useCases: {
createCompany: unknown;
getCompanyById: unknown;
getCurrentCompany: unknown;
findAvailableCompaniesForAccount: unknown;
updateCompany: unknown;
updateCurrentCompany: unknown;
enableCompany: unknown;
disableCompany: unknown;
};
};
assert.ok(internal.useCases.createCompany instanceof CreateCompanyUseCase);
assert.ok(internal.useCases.getCompanyById instanceof GetCompanyByIdUseCase);
assert.ok(internal.useCases.getCurrentCompany instanceof GetCurrentCompanyUseCase);
assert.ok(
internal.useCases.findAvailableCompaniesForAccount instanceof
FindAvailableCompaniesForAccountUseCase
);
assert.ok(internal.useCases.updateCompany instanceof UpdateCompanyUseCase);
assert.ok(internal.useCases.updateCurrentCompany instanceof UpdateCurrentCompanyUseCase);
assert.ok(internal.useCases.enableCompany instanceof EnableCompanyUseCase);
assert.ok(internal.useCases.disableCompany instanceof DisableCompanyUseCase);
assert.equal("repository" in internal, false);
assert.equal("initialCompanyCreator" in internal, false);
});

View File

@ -1,11 +1,11 @@
import type { IModuleServer } from "@erp/core/api";
import type { ICompanyPublicServices } from "./application";
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
import { buildCompaniesDependencies } from "./infrastructure/di";
import { models } from "./infrastructure/persistence/sequelize";
export type { ICompanyPublicServices } from "./application";
export * from "./infrastructure/persistence/sequelize";
export * from "./application/companies/public/services/initial-company-creator";
export type CompaniesPublicServicesType = ICompanyPublicServices;
@ -15,8 +15,8 @@ export const companiesModule: IModuleServer = {
version: "1.0.0",
dependencies: ["identity", "catalogs"],
async setup(params) {
const internal = buildCompaniesDependencies(params);
const general: ICompanyPublicServices = buildCompaniesPublicServices(params, internal);
const { internal, publicServices } = buildCompaniesDependencies(params);
const general: ICompanyPublicServices = publicServices;
return { models, services: { general }, internal };
},
};

View File

@ -1157,7 +1157,7 @@
"form_fields": {
"proformas": {
"status": {
"label": "Estado",
"label": "Status",
"placeholder": "",
"description": ""
},
@ -1223,6 +1223,11 @@
"clear_label": "No payment term",
"inactive": "Inactive"
},
"retention_percentage": {
"label": "Retention",
"placeholder": "Select a percentage",
"description": "Retention percentage"
},
"item": {
"quantity": {
"label": "Quantity",
@ -1405,4 +1410,4 @@
}
}
}
}
}

View File

@ -1215,6 +1215,11 @@
"clear_label": "Sin plazo de pago",
"inactive": "Inactivo"
},
"retention_percentage": {
"label": "Retención",
"placeholder": "Selecciona porcentaje",
"description": "Porcentaje de retención"
},
"item": {
"quantity": {
"label": "Cantidad",
@ -1397,4 +1402,4 @@
}
}
}
}
}

View File

@ -140,7 +140,7 @@ export const LineEditor = <TLine,>({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] font-semibold text-muted-foreground">#</TableHead>
<TableHead className="w-10 font-semibold text-muted-foreground">#</TableHead>
{columns.map((column) => (
<TableHead
@ -151,7 +151,7 @@ export const LineEditor = <TLine,>({
</TableHead>
))}
<TableHead className="w-[50px] font-semibold text-muted-foreground text-center">
<TableHead className="w-12.5 font-semibold text-muted-foreground text-center">
Acciones
</TableHead>
</TableRow>

View File

@ -74,7 +74,7 @@ export const ProformaUpdateTaxEditor = ({
readOnly={readOnly}
/>
<SelectField
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:*:data-[slot=field-label]:min-w-0 min-[480px]:*:data-[slot=field-label]:flex-auto min-[480px]:*:[[button]]:ml-auto min-[480px]:*:[[button]]:min-w-44 min-[480px]:*:[[button]]:flex-none *:data-[slot=field-description]:basis-full *:data-[slot=field-description]:w-full *:data-[slot=field-description]:pl-0 *:data-[slot=field-error]:basis-full *:data-[slot=field-error]:w-full *:data-[slot=field-error]:pl-0"
description={
taxCtrl.usesSingleTax
? t("form_fields.proformas.default_tax_percentage.description", "")
@ -133,7 +133,7 @@ export const ProformaUpdateTaxEditor = ({
readOnly={readOnly}
/>
<SelectField
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:*:data-[slot=field-label]:min-w-0 min-[480px]:*:data-[slot=field-label]:flex-auto min-[480px]:*:[[button]]:ml-auto min-[480px]:*:[[button]]:min-w-44 min-[480px]:*:[[button]]:flex-none *:data-[slot=field-description]:basis-full *:data-[slot=field-description]:w-full *:data-[slot=field-description]:pl-0 *:data-[slot=field-error]:basis-full *:data-[slot=field-error]:w-full *:data-[slot=field-error]:pl-0"
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
disabled={disabled || !taxCtrl.hasRetentionPercentage}
inputClassName="bg-background"
@ -147,8 +147,8 @@ export const ProformaUpdateTaxEditor = ({
}
}}
placeholder={t(
"form_fields.proformas.default_tax_percentage.placeholder",
"Selecciona IVA"
"form_fields.proformas.retention_percentage.placeholder",
"Selecciona porcentaje"
)}
readOnly={readOnly}
serialize={(value) => (typeof value === "number" ? String(value) : "")}

View File

@ -1,17 +1,16 @@
import {
DataTableColumnHeader,
InitialsAvatar,
RdxBadge,
type RdxRowAction,
RdxRowActions,
} from "@repo/rdx-ui/components";
import { Checkbox } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { Building2Icon, EyeIcon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react";
import { EyeIcon, PencilIcon, Trash2Icon } from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../../i18n";
import type { CustomerListRow } from "../../../../shared";
import { type CustomerListRow, CustomerTypeBadge } from "../../../../shared";
import { AddressCell, ContactCell } from "../../components";
type GridActionHandlers = {
@ -98,19 +97,11 @@ export function useCustomersGridColumns(
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{customer.tin}</span>
<RdxBadge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
{customer.isCompany ? (
<>
<Building2Icon className="size-3" />
{t("fields.customer_type.options.company")}
</>
) : (
<>
<UserIcon className="size-3" />
{t("fields.customer_type.options.individual")}
</>
)}
</RdxBadge>
<CustomerTypeBadge
companyLabel={t("fields.customer_type.options.company")}
individualLabel={t("fields.customer_type.options.individual")}
isCompany={customer.isCompany}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,30 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import { Building2Icon, UserIcon } from "lucide-react";
type CustomerTypeBadgeProps = {
isCompany: boolean;
companyLabel: string;
individualLabel: string;
};
export function CustomerTypeBadge({
isCompany,
companyLabel,
individualLabel,
}: CustomerTypeBadgeProps) {
return (
<RdxBadge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
{isCompany ? (
<>
<Building2Icon className="size-3" />
{companyLabel}
</>
) : (
<>
<UserIcon className="size-3" />
{individualLabel}
</>
)}
</RdxBadge>
);
}

View File

@ -1 +1,2 @@
export * from "./customer-status-badge";
export * from "./customer-type-badge";

View File

@ -1,3 +1,4 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { Company } from "../../../domain";

View File

@ -3,9 +3,13 @@ import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { CompanyMembership } from "../../../domain";
export interface ICompanyMembershipRepository {
/** La escritura de membership y roles requiere transacción externa; revertirla ante Result.fail. */
save(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
/** Requiere la misma transacción externa para datos y asignaciones; revertirla ante Result.fail. */
update(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
/** currentRead carga membership y roles desde el estado actual de la transacción. */
findByAccountIdAndCompanyId(params: {
currentRead?: boolean;
accountId: CompanyMembership["accountId"];
companyId: CompanyMembership["companyId"];
transaction?: unknown;

View File

@ -1,2 +1,3 @@
export * from "./company-access-checker";
export * from "./create-identity-company-access";
export * from "./initial-company-admin-assigner";

View File

@ -0,0 +1,262 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DuplicateEntityError } from "@erp/core/api";
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { IdentityPermissionCodes } from "../../../../common/permissions";
import { CompanyMembership, CompanyMembershipStatus, Role, RoleCode } from "../../../domain";
import { EffectivePermissionsResolver } from "../../permissions";
import type { IRoleRepository } from "../../roles/contracts";
import { CompanyAdminRoleEnsurer } from "../../roles/services";
import type { ICompanyMembershipRepository } from "../contracts";
import { InitialCompanyAdminAssigner } from "./initial-company-admin-assigner";
const accountId = UniqueID.generateNewID();
const companyId = UniqueID.generateNewID();
const adminId = UniqueID.generateNewID();
const otherId = UniqueID.generateNewID();
const transaction = {};
const params = { accountId, companyId, transaction };
function role(id = adminId) {
return Role.create(
{
companyId,
code: RoleCode.create(id.equals(adminId) ? "admin" : "other").data,
name: Name.create("Administrator").data,
isSystem: id.equals(adminId),
permissionCodes: id.equals(adminId) ? Object.values(IdentityPermissionCodes) : ["extra:read"],
createdAt: UtcDate.today(),
updatedAt: UtcDate.today(),
},
id
).data;
}
function member(ids = [adminId], status = CompanyMembershipStatus.createActive()) {
return CompanyMembership.create(
{
accountId,
companyId,
roleIds: ids,
status,
createdAt: UtcDate.today(),
updatedAt: UtcDate.today(),
},
UniqueID.generateNewID()
).data;
}
function setup(
initial?: CompanyMembership,
failure?: string,
winner?: CompanyMembership,
concurrent = false
) {
let state = initial;
let admin: Role | undefined = initial || winner || concurrent ? role() : undefined;
let saves = 0;
let updates = 0;
let roleSaves = 0;
let resolutions = 0;
const error = new Error(failure);
const calls: unknown[] = [];
let arrivals = 0;
let release: () => void = () => {};
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
const memberships: ICompanyMembershipRepository = {
findByAccountIdAndCompanyId: async (p) => {
calls.push(p.transaction);
if (concurrent && arrivals < 2) {
arrivals++;
if (arrivals === 2) release();
await barrier;
return Result.ok(Maybe.none<CompanyMembership>());
}
if (failure === "find") return Result.fail(error);
return Result.ok(state ? Maybe.some(state) : Maybe.none<CompanyMembership>());
},
save: async (m, tx) => {
calls.push(tx);
saves++;
if (failure === "save") return Result.fail(error);
if (failure === "unpersisted") return Result.ok();
if (failure === "duplicate") {
state = winner;
return Result.fail(
new DuplicateEntityError("Membership", "accountId", accountId.toPrimitive())
);
}
if (state)
return Result.fail(
new DuplicateEntityError("Membership", "accountId", accountId.toPrimitive())
);
state = m;
return Result.ok();
},
update: async (m, tx) => {
calls.push(tx);
updates++;
if (failure === "update") return Result.fail(error);
state = m;
return Result.ok();
},
findByAccountId: async () => Result.ok(new Collection<CompanyMembership>([])),
findByCompanyId: async () => Result.ok(new Collection<CompanyMembership>([])),
};
const roles: IRoleRepository = {
findByCodeInCompany: async (p) => {
calls.push(p.transaction);
return Result.ok(admin ? Maybe.some(admin) : Maybe.none<Role>());
},
save: async (r, tx) => {
calls.push(tx);
admin = r;
roleSaves++;
return Result.ok();
},
update: async () => Result.ok(),
delete: async () => Result.ok(),
findByIdInCompany: async () => Result.ok(Maybe.none<Role>()),
findByCompanyId: async () => Result.ok(new Collection<Role>([])),
findByIdsInCompany: async (p) => {
calls.push(p.transaction);
const values = p.roleIds.map((id) => (admin?.id.equals(id) ? admin : role(id)));
return Result.ok(new Collection(values));
},
};
const ensurer = new CompanyAdminRoleEnsurer(roles);
const resolver = new EffectivePermissionsResolver({ memberships, roles });
const service = new InitialCompanyAdminAssigner({
memberships,
adminRoles: {
ensure: async (p) => {
calls.push(p.transaction);
return failure === "ensure" ? Result.fail(error) : ensurer.ensure(p);
},
},
permissions: {
resolve: async (p) => {
resolutions++;
calls.push(p.transaction);
if (failure === "resolve") return Result.fail(error);
if (failure === "permissions") return Result.ok(new Collection<string>([]));
return resolver.resolve(p);
},
},
});
return {
service,
error,
calls,
get state() {
return state;
},
get admin() {
return admin;
},
get saves() {
return saves;
},
get updates() {
return updates;
},
get roleSaves() {
return roleSaves;
},
get resolutions() {
return resolutions;
},
};
}
test("crea membership activa, verifica permisos y es idempotente con estado real", async () => {
const s = setup();
assert.equal((await s.service.assign(params)).isSuccess, true);
assert.equal((await s.service.assign(params)).isSuccess, true);
assert.equal(s.saves, 1);
assert.equal(s.updates, 0);
assert.equal(s.roleSaves, 1);
assert.equal(s.resolutions, 2);
assert.ok(s.state?.status.isActive());
assert.deepEqual(
s.state?.roleIds.map((id) => id.toPrimitive()),
[s.admin?.id.toPrimitive()]
);
assert.ok(s.state?.id);
assert.equal(s.state.createdAt.toPrimitive(), UtcDate.today().toPrimitive());
for (const tx of s.calls) assert.equal(tx, transaction);
});
test("activa admin no escribe membership", async () => {
const s = setup(member());
assert.equal((await s.service.assign(params)).isSuccess, true);
assert.equal(s.saves + s.updates, 0);
});
for (const status of [
CompanyMembershipStatus.createActive(),
CompanyMembershipStatus.createDisabled(),
CompanyMembershipStatus.createInvited(),
]) {
test(`estado ${status.toPrimitive()} conserva otros roles y añade admin`, async () => {
const s = setup(member([otherId], status));
assert.equal((await s.service.assign(params)).isSuccess, true);
assert.equal(s.updates, 1);
assert.ok(s.state?.status.isActive());
assert.deepEqual(
s.state?.roleIds.map((id) => id.toPrimitive()),
[otherId.toPrimitive(), adminId.toPrimitive()]
);
});
}
test("reactiva membership sin roles mediante dominio", async () => {
const s = setup(member([], CompanyMembershipStatus.createDisabled()));
assert.equal((await s.service.assign(params)).isSuccess, true);
});
for (const failure of ["ensure", "find", "save", "update", "resolve"]) {
test(`propaga error en ${failure}`, async () => {
const s = setup(failure === "update" ? member([otherId]) : undefined, failure);
assert.equal((await s.service.assign(params)).error, s.error);
if (failure === "ensure" || failure === "find") assert.equal(s.saves + s.updates, 0);
});
}
test("permisos insuficientes impiden success", async () => {
assert.equal((await setup(undefined, "permissions").service.assign(params)).isFailure, true);
});
for (const hasAdmin of [true, false]) {
test(`duplicate relee ganador con admin=${hasAdmin}`, async () => {
const winner = member(hasAdmin ? [adminId, otherId] : [otherId]);
const s = setup(undefined, "duplicate", winner);
assert.equal((await s.service.assign(params)).isSuccess, true);
assert.equal(s.updates, hasAdmin ? 0 : 1);
assert.ok(s.state?.roleIds.some((id) => id.equals(otherId)));
assert.ok(s.state?.roleIds.some((id) => id.equals(s.admin?.id ?? adminId)));
});
}
test("duplicate sin ganador falla", async () => {
assert.equal((await setup(undefined, "duplicate").service.assign(params)).isFailure, true);
});
test("transacción ausente falla antes de I/O", async () => {
const s = setup();
assert.equal((await s.service.assign({ ...params, transaction: undefined })).isFailure, true);
assert.equal(s.calls.length, 0);
});
test("dos asignaciones concurrentes convergen mediante barrera determinista", async () => {
const s = setup(undefined, undefined, undefined, true);
const results = await Promise.all([s.service.assign(params), s.service.assign(params)]);
assert.ok(results.every((result) => result.isSuccess));
assert.equal(s.saves, 2);
assert.equal(s.roleSaves, 0);
assert.equal(s.resolutions, 2);
assert.deepEqual(
s.state?.roleIds.map((id) => id.toPrimitive()),
[adminId.toPrimitive()]
);
});
test("save nominal sin persistencia falla la postcondición", async () => {
const s = setup(undefined, "unpersisted");
assert.equal((await s.service.assign(params)).isFailure, true);
assert.equal(s.resolutions, 0);
});

View File

@ -0,0 +1,116 @@
import { isDuplicateEntityError } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CompanyMembership, CompanyMembershipStatus } from "../../../domain";
import type { IEffectivePermissionsResolver } from "../../permissions";
import type { ICompanyAdminRoleEnsurer } from "../../roles/services";
import type { ICompanyMembershipRepository } from "../contracts";
export interface AssignInitialCompanyAdminParams {
accountId: UniqueID;
companyId: UniqueID;
transaction: unknown;
}
export interface IInitialCompanyAdminAssigner {
assign(params: AssignInitialCompanyAdminParams): Promise<Result<void, Error>>;
}
/** Participa en la transacción externa; el caller debe revertirla ante cualquier fallo. */
export class InitialCompanyAdminAssigner implements IInitialCompanyAdminAssigner {
public constructor(
private readonly deps: {
memberships: ICompanyMembershipRepository;
adminRoles: ICompanyAdminRoleEnsurer;
permissions: IEffectivePermissionsResolver;
}
) {}
public async assign(params: AssignInitialCompanyAdminParams): Promise<Result<void, Error>> {
if (!params.transaction)
return Result.fail(new Error("Initial admin assignment requires an external transaction."));
const admin = await this.deps.adminRoles.ensure({
companyId: params.companyId,
transaction: params.transaction,
});
if (admin.isFailure) return Result.fail(admin.error);
const assigned = await this.ensureMembership(admin.data.id, params);
if (assigned.isFailure) return assigned;
const persisted = await this.deps.memberships.findByAccountIdAndCompanyId({
...params,
currentRead: true,
});
if (persisted.isFailure) return Result.fail(persisted.error);
if (persisted.data.isNone())
return Result.fail(new Error("Initial admin membership was not persisted."));
const membership = persisted.data.unwrap();
if (
!(membership.status.isActive() && membership.roleIds.some((id) => id.equals(admin.data.id)))
) {
return Result.fail(new Error("Initial admin membership postcondition failed."));
}
const effective = await this.deps.permissions.resolve({ ...params, currentRead: true });
if (effective.isFailure) return Result.fail(effective.error);
const permissions = new Set(effective.data.getAll());
if (admin.data.permissionCodes.some((code) => !permissions.has(code))) {
return Result.fail(new Error("Initial admin effective permissions postcondition failed."));
}
return Result.ok();
}
private async ensureMembership(
adminId: UniqueID,
params: AssignInitialCompanyAdminParams
): Promise<Result<void, Error>> {
const found = await this.deps.memberships.findByAccountIdAndCompanyId(params);
if (found.isFailure) return Result.fail(found.error);
if (found.data.isSome()) return this.complete(found.data.unwrap(), adminId, params);
const now = UtcDate.today();
const created = CompanyMembership.create(
{
accountId: params.accountId,
companyId: params.companyId,
roleIds: [adminId],
status: CompanyMembershipStatus.createActive(),
createdAt: now,
updatedAt: now,
},
UniqueID.generateNewID()
);
if (created.isFailure) return Result.fail(created.error);
const saved = await this.deps.memberships.save(created.data, params.transaction);
if (saved.isSuccess) return saved;
if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error);
const winner = await this.deps.memberships.findByAccountIdAndCompanyId({
...params,
currentRead: true,
});
if (winner.isFailure) return Result.fail(winner.error);
if (winner.data.isNone()) return Result.fail(saved.error);
return this.complete(winner.data.unwrap(), adminId, params);
}
private async complete(
membership: CompanyMembership,
adminId: UniqueID,
params: AssignInitialCompanyAdminParams
): Promise<Result<void, Error>> {
if (
!(
membership.accountId.equals(params.accountId) &&
membership.companyId.equals(params.companyId)
)
) {
return Result.fail(new Error("Membership does not match the requested account and company."));
}
const now = UtcDate.today();
const roles = membership.roleIds;
if (!roles.some((id) => id.equals(adminId))) roles.push(adminId);
const replaced = membership.replaceRoles({ roleIds: roles, updatedAt: now });
if (replaced.isFailure) return Result.fail(replaced.error);
const enabled = membership.enable(now);
if (enabled.isFailure) return Result.fail(enabled.error);
if (replaced.data || enabled.data)
return this.deps.memberships.update(membership, params.transaction);
return Result.ok();
}
}

View File

@ -1 +1,2 @@
export * from "./contracts";
export * from "./services/effective-permissions-resolver";

View File

@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { CompanyMembership, CompanyMembershipStatus, Role, RoleCode } from "../../../domain";
import type { ICompanyMembershipRepository } from "../../company-memberships/contracts";
import type { IRoleRepository } from "../../roles/contracts";
import { EffectivePermissionsResolver } from "./effective-permissions-resolver";
const accountId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
const companyId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
const firstId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
const secondId = UniqueID.create("44444444-4444-4444-8444-444444444444").data;
const date = UtcDate.createFromISO("2026-01-01").data;
function membership(roleIds = [firstId], status = CompanyMembershipStatus.createActive()) {
return CompanyMembership.rehydrate(
{
accountId,
companyId,
roleIds,
status,
createdAt: date,
updatedAt: date,
},
accountId
);
}
function role(id = firstId, permissionCodes = ["invoices:read"], company = companyId) {
return Role.create(
{
companyId: company,
code: RoleCode.create("billing-editor").data,
name: Name.create("Billing").data,
permissionCodes,
isSystem: false,
createdAt: date,
updatedAt: date,
},
id
).data;
}
function setup(
member: CompanyMembership | null = membership(),
assignedRoles = [role()],
membershipError?: Error,
roleError?: Error
) {
const membershipCalls: Parameters<
ICompanyMembershipRepository["findByAccountIdAndCompanyId"]
>[0][] = [];
const roleCalls: Parameters<IRoleRepository["findByIdsInCompany"]>[0][] = [];
const memberships: ICompanyMembershipRepository = {
save: async () => Result.ok(),
update: async () => Result.ok(),
findByAccountIdAndCompanyId: async (params) => {
membershipCalls.push(params);
return membershipError
? Result.fail(membershipError)
: Result.ok(member === null ? Maybe.none<CompanyMembership>() : Maybe.some(member));
},
findByAccountId: async () => Result.ok(new Collection<CompanyMembership>([])),
findByCompanyId: async () => Result.ok(new Collection<CompanyMembership>([])),
};
const roles: IRoleRepository = {
findByCodeInCompany: async () => Result.ok(Maybe.none<Role>()),
save: async () => Result.ok(),
update: async () => Result.ok(),
delete: async () => Result.ok(),
findByIdInCompany: async () => Result.ok(Maybe.none<Role>()),
findByCompanyId: async () => Result.ok(new Collection<Role>([])),
findByIdsInCompany: async (params) => {
roleCalls.push(params);
return roleError ? Result.fail(roleError) : Result.ok(new Collection(assignedRoles));
},
};
return {
resolver: new EffectivePermissionsResolver({ memberships, roles }),
membershipCalls,
roleCalls,
};
}
test("un rol devuelve sus permisos", async () => {
const result = await setup().resolver.resolve({ accountId, companyId });
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data.getAll(), ["invoices:read"]);
});
test("varios roles unen permisos sin duplicados y con orden estable", async () => {
const subject = setup(membership([firstId, secondId]), [
role(secondId, ["invoices:write", "invoices:read"]),
role(),
]);
const result = await subject.resolver.resolve({ accountId, companyId });
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data.getAll(), ["invoices:read", "invoices:write"]);
});
test("sin membership deniega sin consultar roles", async () => {
const subject = setup(null);
const result = await subject.resolver.resolve({ accountId, companyId });
assert.equal(result.isFailure, true);
assert.equal(subject.roleCalls.length, 0);
});
for (const [label, member] of [
["disabled", membership([firstId], CompanyMembershipStatus.createDisabled())],
["invited", membership([firstId], CompanyMembershipStatus.createInvited())],
["activa sin roles", membership([])],
["roles duplicados", membership([firstId, firstId])],
] as const) {
test(`membership ${label} propaga el error del dominio sin consultar roles`, async () => {
const subject = setup(member);
const result = await subject.resolver.resolve({ accountId, companyId });
assert.equal(result.isFailure, true);
assert.equal(result.error.constructor, member.canAccessCompany().error.constructor);
assert.equal(subject.roleCalls.length, 0);
});
}
for (const [label, assigned] of [
["ausentes", []],
["incompletos", [role()]],
["de otra compañía", [role(firstId, ["invoices:read"], accountId), role(secondId)]],
["duplicados", [role(), role()]],
["inesperados", [role(), role(accountId)]],
] as const) {
test(`roles ${label} fallan sin devolver permisos parciales`, async () => {
const result = await setup(membership([firstId, secondId]), [...assigned]).resolver.resolve({
accountId,
companyId,
});
assert.equal(result.isFailure, true);
});
}
test("membership de otro contexto falla antes de consultar roles", async () => {
const subject = setup();
const result = await subject.resolver.resolve({ accountId: secondId, companyId });
assert.equal(result.isFailure, true);
assert.equal(subject.roleCalls.length, 0);
});
test("propaga error del repositorio de memberships", async () => {
const error = new Error("membership failure");
const subject = setup(membership(), [role()], error);
const result = await subject.resolver.resolve({ accountId, companyId });
assert.equal(result.error, error);
assert.equal(subject.roleCalls.length, 0);
});
test("propaga error del repositorio de roles", async () => {
const error = new Error("role failure");
const result = await setup(membership(), [role()], undefined, error).resolver.resolve({
accountId,
companyId,
});
assert.equal(result.error, error);
});
test("propaga la misma transacción y el contexto a ambos repositorios", async () => {
const transaction = {};
const subject = setup();
await subject.resolver.resolve({ accountId, companyId, transaction });
assert.deepEqual(subject.membershipCalls, [{ accountId, companyId, transaction }]);
assert.deepEqual(subject.roleCalls, [{ roleIds: [firstId], companyId, transaction }]);
assert.equal(subject.membershipCalls[0].transaction, transaction);
assert.equal(subject.roleCalls[0].transaction, transaction);
});
test("lectura actual se propaga a membership y roles", async () => {
const subject = setup();
const transaction = {};
const result = await subject.resolver.resolve({
accountId,
companyId,
transaction,
currentRead: true,
});
assert.equal(result.isSuccess, true);
assert.equal(subject.membershipCalls[0].currentRead, true);
assert.equal(subject.roleCalls[0].currentRead, true);
});

View File

@ -0,0 +1,81 @@
import type { PermissionCode } from "@erp/core";
import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { CompanyMembershipCannotAccessCompanyError } from "../../../domain";
import type { ICompanyMembershipRepository } from "../../company-memberships/contracts";
import type { IRoleRepository } from "../../roles/contracts";
export interface ResolveEffectivePermissionsParams {
accountId: UniqueID;
companyId: UniqueID;
transaction?: unknown;
/** Comprueba el estado actual tras escrituras o conflictos concurrentes. */
currentRead?: boolean;
}
export interface IEffectivePermissionsResolver {
resolve(
params: ResolveEffectivePermissionsParams
): Promise<Result<Collection<PermissionCode>, Error>>;
}
export class EffectivePermissionsResolver implements IEffectivePermissionsResolver {
public constructor(
private readonly deps: {
memberships: ICompanyMembershipRepository;
roles: IRoleRepository;
}
) {}
public async resolve(
params: ResolveEffectivePermissionsParams
): Promise<Result<Collection<PermissionCode>, Error>> {
const found = await this.deps.memberships.findByAccountIdAndCompanyId(params);
if (found.isFailure) return Result.fail(found.error);
if (found.data.isNone()) {
return Result.fail(
new CompanyMembershipCannotAccessCompanyError("Company membership is required.")
);
}
const membership = found.data.unwrap();
if (
!(
membership.accountId.equals(params.accountId) &&
membership.companyId.equals(params.companyId)
)
) {
return Result.fail(new Error("Membership does not match the requested account and company."));
}
const access = membership.canAccessCompany();
if (access.isFailure) return Result.fail(access.error);
const roleIds = membership.roleIds;
const foundRoles = await this.deps.roles.findByIdsInCompany({
roleIds,
...(params.currentRead ? { currentRead: true } : {}),
companyId: params.companyId,
transaction: params.transaction,
});
if (foundRoles.isFailure) return Result.fail(foundRoles.error);
const roles = foundRoles.data.getAll();
const expected = new Set(roleIds.map((id) => id.toPrimitive()));
const received = new Set(roles.map((role) => role.id.toPrimitive()));
if (
roles.length !== expected.size ||
received.size !== expected.size ||
roles.some(
(role) => !(expected.has(role.id.toPrimitive()) && role.companyId.equals(params.companyId))
)
) {
return Result.fail(
new Error("Membership roles are missing, duplicated or outside the requested company.")
);
}
const permissions = new Set<PermissionCode>();
for (const role of roles) {
for (const permission of role.permissionCodes) permissions.add(permission);
}
return Result.ok(new Collection([...permissions].sort()));
}
}

View File

@ -4,6 +4,13 @@ import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Role } from "../../../domain";
export interface IRoleRepository {
/** currentRead evita releer un snapshot anterior tras un conflicto concurrente. */
findByCodeInCompany(params: {
companyId: Role["companyId"];
code: Role["code"];
transaction?: unknown;
currentRead?: boolean;
}): Promise<Result<Maybe<Role>, Error>>;
save(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
update(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
delete(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
@ -13,6 +20,7 @@ export interface IRoleRepository {
transaction?: unknown;
}): Promise<Result<Maybe<Role>, Error>>;
findByIdsInCompany(params: {
currentRead?: boolean;
roleIds: UniqueID[];
companyId: Role["companyId"];
transaction?: unknown;

View File

@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DuplicateEntityError } from "@erp/core/api";
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { IdentityPermissionCodes } from "../../../../common/permissions";
import { Role, RoleCode } from "../../../domain";
import type { IRoleRepository } from "../contracts";
import { CompanyAdminRoleEnsurer } from "./company-admin-role-ensurer";
const companyId = UniqueID.generateNewID();
function admin(
isSystem = true,
permissionCodes: string[] = Object.values(IdentityPermissionCodes)
) {
return Role.create(
{
companyId,
code: RoleCode.create("admin").data,
name: Name.create("Administrator").data,
isSystem,
permissionCodes,
createdAt: UtcDate.today(),
updatedAt: UtcDate.today(),
},
UniqueID.generateNewID()
).data;
}
function setup(
reads: Result<Maybe<Role>, Error>[] = [Result.ok(Maybe.none())],
saveResult: Result<void, Error> = Result.ok()
) {
const lookups: Parameters<IRoleRepository["findByCodeInCompany"]>[0][] = [];
const writes: { role: Role; transaction?: unknown }[] = [];
const repository: IRoleRepository = {
findByCodeInCompany: async (params) => {
lookups.push(params);
const result = reads.shift();
assert.ok(result);
return result;
},
save: async (role, transaction) => {
writes.push({ role, transaction });
return saveResult;
},
update: async () => {
assert.fail("No debe modificar roles");
},
delete: async () => {
assert.fail("No debe borrar roles");
},
findByIdInCompany: async () => Result.ok(Maybe.none()),
findByIdsInCompany: async () => Result.ok(new Collection<Role>([])),
findByCompanyId: async () => Result.ok(new Collection<Role>([])),
};
return { service: new CompanyAdminRoleEnsurer(repository), writes, lookups };
}
test("crea admin de sistema con SSOT exacta, ID y fechas internos", async () => {
const subject = setup();
const result = await subject.service.ensure({ companyId });
assert.equal(result.isSuccess, true);
const role = result.data;
assert.equal(role.code.toPrimitive(), "admin");
assert.equal(role.name.toPrimitive(), "Administrator");
assert.equal(role.isSystem, true);
assert.deepEqual(role.permissionCodes, Object.values(IdentityPermissionCodes));
assert.equal(UniqueID.create(role.id.toPrimitive()).isSuccess, true);
assert.equal(role.id.equals(companyId), false);
assert.equal(role.createdAt.toPrimitive(), UtcDate.today().toPrimitive());
assert.equal(role.updatedAt.toPrimitive(), role.createdAt.toPrimitive());
assert.equal(subject.writes[0].role, role);
});
test("existente válido es idempotente, independientemente del orden de permisos", async () => {
const existing = admin(true, Object.values(IdentityPermissionCodes).reverse());
const subject = setup([Result.ok(Maybe.some(existing)), Result.ok(Maybe.some(existing))]);
assert.equal((await subject.service.ensure({ companyId })).data, existing);
assert.equal((await subject.service.ensure({ companyId })).data, existing);
assert.equal(subject.writes.length, 0);
});
for (const [label, existing] of [
["no-system", admin(false)],
["permisos incorrectos", admin(true, ["identity:roles:read"])],
] as const) {
test(`existente ${label} falla sin mutar`, async () => {
const subject = setup([Result.ok(Maybe.some(existing))]);
assert.equal((await subject.service.ensure({ companyId })).isFailure, true);
assert.equal(subject.writes.length, 0);
});
}
const duplicate = () => new DuplicateEntityError("Role", "code", "admin");
for (const valid of [true, false]) {
test(`duplicate relee y valida ganador: válido=${valid}`, async () => {
const winner = admin(valid);
const subject = setup(
[Result.ok(Maybe.none()), Result.ok(Maybe.some(winner))],
Result.fail(duplicate())
);
const transaction = {};
const result = await subject.service.ensure({ companyId, transaction });
assert.equal(result.isSuccess, valid);
if (valid) assert.equal(result.data, winner);
assert.equal(subject.lookups.length, 2);
for (const lookup of subject.lookups) {
assert.equal(lookup.companyId, companyId);
assert.equal(lookup.code.toPrimitive(), "admin");
assert.equal(lookup.transaction, transaction);
}
assert.equal(subject.lookups[1].currentRead, true);
assert.equal(subject.writes[0].transaction, transaction);
});
}
test("duplicate sin ganador conserva error", async () => {
const error = duplicate();
const subject = setup([Result.ok(Maybe.none()), Result.ok(Maybe.none())], Result.fail(error));
assert.equal((await subject.service.ensure({ companyId })).error, error);
});
for (const phase of ["lookup", "save", "reread"]) {
test(`propaga error de repository en ${phase}`, async () => {
const error = new Error(phase);
const subject =
phase === "lookup"
? setup([Result.fail(error)])
: phase === "save"
? setup([Result.ok(Maybe.none())], Result.fail(error))
: setup([Result.ok(Maybe.none()), Result.fail(error)], Result.fail(duplicate()));
assert.equal((await subject.service.ensure({ companyId })).error, error);
});
}

View File

@ -0,0 +1,70 @@
import { isDuplicateEntityError } from "@erp/core/api";
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { IdentityPermissionCodes } from "../../../../common/permissions";
import { Role, RoleCode } from "../../../domain";
import type { IRoleRepository } from "../contracts";
export const COMPANY_ADMIN_ROLE_CODE = "admin";
export interface EnsureCompanyAdminRoleParams {
companyId: UniqueID;
transaction?: unknown;
}
export interface ICompanyAdminRoleEnsurer {
ensure(params: EnsureCompanyAdminRoleParams): Promise<Result<Role, Error>>;
}
/** Asegura el rol administrativo de Identity sin asignarlo ni modificar roles existentes. */
export class CompanyAdminRoleEnsurer implements ICompanyAdminRoleEnsurer {
public constructor(private readonly roles: IRoleRepository) {}
public async ensure(params: EnsureCompanyAdminRoleParams): Promise<Result<Role, Error>> {
const code = RoleCode.create(COMPANY_ADMIN_ROLE_CODE);
if (code.isFailure) return Result.fail(code.error);
const lookup = { ...params, code: code.data };
const found = await this.roles.findByCodeInCompany(lookup);
if (found.isFailure) return Result.fail(found.error);
if (found.data.isSome()) return this.validate(found.data.unwrap(), params.companyId);
const name = Name.create("Administrator");
if (name.isFailure) return Result.fail(name.error);
const now = UtcDate.today();
const created = Role.create(
{
companyId: params.companyId,
code: code.data,
name: name.data,
permissionCodes: Object.values(IdentityPermissionCodes),
isSystem: true,
createdAt: now,
updatedAt: now,
},
UniqueID.generateNewID()
);
if (created.isFailure) return Result.fail(created.error);
const saved = await this.roles.save(created.data, params.transaction);
if (saved.isSuccess) return created;
if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error);
const winner = await this.roles.findByCodeInCompany({ ...lookup, currentRead: true });
if (winner.isFailure) return Result.fail(winner.error);
if (winner.data.isNone()) return Result.fail(saved.error);
return this.validate(winner.data.unwrap(), params.companyId);
}
private validate(role: Role, companyId: UniqueID): Result<Role, Error> {
const expected = new Set<string>(Object.values(IdentityPermissionCodes));
const actual = role.permissionCodes;
if (
!role.companyId.equals(companyId) ||
role.code.toPrimitive() !== COMPANY_ADMIN_ROLE_CODE ||
!role.isSystem ||
actual.length !== expected.size ||
new Set(actual).size !== expected.size ||
actual.some((code) => !expected.has(code))
) {
return Result.fail(
new Error("Existing company admin role does not match the system definition.")
);
}
return Result.ok(role);
}
}

View File

@ -1,2 +1,3 @@
export * from "./company-admin-role-ensurer";
export * from "./role-permission-validator";
export * from "./unknown-permission-code.error";

View File

@ -1,8 +1,12 @@
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
import type {
IIdentityCompanyAccessChecker,
IInitialCompanyAdminAssigner,
} from "../company-memberships";
import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface";
export interface IIdentityPublicServices {
auth: IIdentityAuthPublicServices;
companyAccess: IIdentityCompanyAccessChecker;
initialCompanyAdminAssigner: IInitialCompanyAdminAssigner;
}

View File

@ -1,13 +1,20 @@
import type { IIdentityCompanyAccessChecker } from "../../application";
import type {
IIdentityCompanyAccessChecker,
IInitialCompanyAdminAssigner,
} from "../../application";
import type { IdentityInternalDeps } from "./identity.di";
export interface IIdentityNeutralPublicServices {
companyAccess: IIdentityCompanyAccessChecker;
initialCompanyAdminAssigner: IInitialCompanyAdminAssigner;
}
export function buildIdentityNeutralPublicServices(
deps: IdentityInternalDeps
): IIdentityNeutralPublicServices {
return { companyAccess: deps.companyAccess.checker };
return {
companyAccess: deps.companyAccess.checker,
initialCompanyAdminAssigner: deps.initialCompanyAdminAssigner,
};
}

View File

@ -1,8 +1,11 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import {
CompanyAdminRoleEnsurer,
EffectivePermissionsResolver,
type IIdentityCompanyAccessChecker,
IdentityCompanyAccessChecker,
InitialCompanyAdminAssigner,
} from "../../application";
import type { IAccountRepository } from "../../application/accounts";
import {
@ -31,8 +34,10 @@ import {
SequelizeAccountDomainMapper,
SequelizeAccountRepository,
SequelizeCompanyMembershipAccessRepository,
SequelizeCompanyMembershipRepository,
SequelizeRefreshTokenDomainMapper,
SequelizeRefreshTokenRepository,
SequelizeRoleRepository,
} from "../persistence";
import {
BcryptPasswordHasher,
@ -43,6 +48,7 @@ import {
} from "../security";
export type IdentityInternalDeps = {
initialCompanyAdminAssigner: InitialCompanyAdminAssigner;
auth: {
refreshTokenExpiration: string;
authenticateUserDependencies: {
@ -88,6 +94,16 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
);
const companyAccessChecker = new IdentityCompanyAccessChecker(companyMembershipAccessRepository);
const memberships = new SequelizeCompanyMembershipRepository();
const roles = new SequelizeRoleRepository();
const adminRoles = new CompanyAdminRoleEnsurer(roles);
const permissions = new EffectivePermissionsResolver({ memberships, roles });
const initialCompanyAdminAssigner = new InitialCompanyAdminAssigner({
memberships,
adminRoles,
permissions,
});
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
secret: config.auth.jwt.secret,
@ -111,6 +127,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
});
return {
initialCompanyAdminAssigner,
auth: {
refreshTokenExpiration: config.auth.jwt.refreshExpiration,
authenticateUserDependencies: {

View File

@ -1,9 +1,17 @@
import accountModelInit from "./models/sequelize-account.model";
import companyMembershipModelInit from "./models/sequelize-company-membership.model";
import membershipRoleModelInit from "./models/sequelize-company-membership-role.model";
import refreshTokenModelInit from "./models/sequelize-refresh-token.model";
import roleModelInit from "./models/sequelize-role.model";
export * from "./mappers";
export * from "./models";
export * from "./repositories";
export const models = [accountModelInit, refreshTokenModelInit, companyMembershipModelInit];
export const models = [
accountModelInit,
refreshTokenModelInit,
companyMembershipModelInit,
roleModelInit,
membershipRoleModelInit,
];

View File

@ -1,2 +1,4 @@
export * from "./sequelize-account-domain.mapper";
export * from "./sequelize-company-membership-domain.mapper";
export * from "./sequelize-refresh-token-domain.mapper";
export * from "./sequelize-role-domain.mapper";

View File

@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Sequelize } from "sequelize";
import { CompanyMembership, CompanyMembershipStatus } from "../../../../domain";
import { SequelizeCompanyMembershipDomainMapper } from "../mappers/sequelize-company-membership-domain.mapper";
import initMembership, {
CompanyMembershipModel,
} from "../models/sequelize-company-membership.model";
import initLinks, {
CompanyMembershipRoleModel,
} from "../models/sequelize-company-membership-role.model";
import initRole from "../models/sequelize-role.model";
const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false });
initMembership(database);
initRole(database);
initLinks(database);
const membershipId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
const companyId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
const a = "44444444-4444-4444-8444-444444444444";
const b = "55555555-5555-4555-8555-555555555555";
const c = "66666666-6666-4666-8666-666666666666";
function membership(ids: string[] = [a], active = true) {
return CompanyMembership.create(
{
accountId,
companyId,
roleIds: ids.map((id) => UniqueID.create(id).data),
status: active
? CompanyMembershipStatus.createActive()
: CompanyMembershipStatus.createDisabled(),
createdAt: UtcDate.createFromISO("2026-01-01").data,
updatedAt: UtcDate.createFromISO("2026-02-02").data,
},
membershipId
);
}
const mapper = new SequelizeCompanyMembershipDomainMapper();
function row(ids: string[] = [a], active = true) {
return CompanyMembershipModel.build(
mapper.mapToPersistence(membership(ids, active).data).data.membership,
{ raw: true }
);
}
test("join tiene PK compuesta única, FKs y política de borrado", () => {
assert.equal(CompanyMembershipRoleModel.getTableName(), "identity_company_membership_roles");
const attrs = CompanyMembershipRoleModel.getAttributes();
assert.deepEqual(Object.keys(attrs).sort(), ["membership_id", "role_id"]);
for (const attr of Object.values(attrs)) {
assert.equal(attr.primaryKey, true);
assert.equal(attr.allowNull, false);
}
assert.deepEqual(attrs.membership_id.references, {
model: "identity_company_memberships",
key: "id",
});
assert.deepEqual(attrs.role_id.references, { model: "identity_roles", key: "id" });
assert.equal(attrs.membership_id.onDelete, "CASCADE");
assert.equal(attrs.role_id.onDelete, "RESTRICT");
assert.equal(CompanyMembershipRoleModel.options.timestamps, false);
});
for (const ids of [[], [a], [a, b, c]]) {
test(`mapper preserva ${ids.length} roles y los campos principales`, () => {
const source = membership(ids, ids.length > 0).data;
const persisted = mapper.mapToPersistence(source).data;
const restored = mapper.mapToDomain(row(ids, ids.length > 0), persisted.roleIds);
assert.equal(restored.isSuccess, true);
assert.deepEqual(mapper.mapToPersistence(restored.data).data, persisted);
});
}
test("dominio y mapper rechazan roles duplicados y activa vacía", () => {
assert.equal(membership([a, a]).isFailure, true);
assert.equal(membership([]).isFailure, true);
assert.equal(mapper.mapToDomain(row(), [a, a]).isFailure, true);
assert.equal(mapper.mapToDomain(row(), []).isFailure, true);
});
test("replaceRoles conserva conjunto sin duplicados y actualiza fecha", () => {
const source = membership([a, b]).data;
const updatedAt = UtcDate.createFromISO("2026-03-03").data;
assert.equal(
source.replaceRoles({ roleIds: [b, c].map((id) => UniqueID.create(id).data), updatedAt })
.isSuccess,
true
);
assert.deepEqual(
source.roleIds.map((id) => id.toPrimitive()),
[b, c]
);
assert.ok(source.updatedAt.equals(updatedAt));
});

View File

@ -0,0 +1,81 @@
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CompanyMembership, CompanyMembershipStatus } from "../../../../domain";
import type { CompanyMembershipCreationAttributes, CompanyMembershipModel } from "../models";
function date(value: Date | string): Result<UtcDate, Error> {
return UtcDate.createFromISO(
value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10)
);
}
export class SequelizeCompanyMembershipDomainMapper {
public mapToDomain(
source: CompanyMembershipModel,
roleIds: readonly string[]
): Result<CompanyMembership, Error> {
try {
const id = UniqueID.create(source.id);
if (id.isFailure) return Result.fail(id.error);
const accountId = UniqueID.create(source.account_id);
if (accountId.isFailure) return Result.fail(accountId.error);
const companyId = UniqueID.create(source.company_id);
if (companyId.isFailure) return Result.fail(companyId.error);
const status = CompanyMembershipStatus.create(source.status);
if (status.isFailure) return Result.fail(status.error);
const createdAt = date(source.created_at);
if (createdAt.isFailure) return Result.fail(createdAt.error);
const updatedAt = date(source.updated_at);
if (updatedAt.isFailure) return Result.fail(updatedAt.error);
const roles: UniqueID[] = [];
for (const value of roleIds) {
const role = UniqueID.create(value);
if (role.isFailure) return Result.fail(role.error);
roles.push(role.data);
}
const membership = CompanyMembership.rehydrate(
{
accountId: accountId.data,
companyId: companyId.data,
status: status.data,
roleIds: roles,
createdAt: createdAt.data,
updatedAt: updatedAt.data,
},
id.data
);
// canAccessCompany valida roles antes de evaluar el estado de acceso.
const access = membership.canAccessCompany();
if (
access.isFailure &&
(status.data.isActive() || new Set(roleIds).size !== roleIds.length)
) {
return Result.fail(access.error);
}
return Result.ok(membership);
} catch (error: unknown) {
return Result.fail(error instanceof Error ? error : new Error(String(error)));
}
}
public mapToPersistence(source: CompanyMembership): Result<
{
membership: CompanyMembershipCreationAttributes;
roleIds: string[];
},
Error
> {
return Result.ok({
membership: {
id: source.id.toPrimitive(),
account_id: source.accountId.toPrimitive(),
company_id: source.companyId.toPrimitive(),
status: source.status.toPrimitive(),
created_at: source.createdAt.toISOString(),
updated_at: source.updatedAt.toISOString(),
},
roleIds: source.roleIds.map((id) => id.toPrimitive()),
});
}
}

View File

@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { DataTypes, Sequelize } from "sequelize";
import { Role, RoleCode } from "../../../../domain";
import initRole, { RoleModel } from "../models/sequelize-role.model";
import { SequelizeRoleDomainMapper } from "./sequelize-role-domain.mapper";
initRole(new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }));
const mapper = new SequelizeRoleDomainMapper();
const id = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
const companyId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
function role(isSystem = false) {
return Role.create(
{
companyId,
code: RoleCode.create("billing-editor").data,
name: Name.create("Billing").data,
permissionCodes: ["invoices:read", "invoices:write"],
isSystem,
createdAt: UtcDate.createFromISO("2026-01-01").data,
updatedAt: UtcDate.createFromISO("2026-02-02").data,
},
id
).data;
}
test("modelo declara columnas, JSON y código único por compañía", () => {
assert.equal(RoleModel.getTableName(), "identity_roles");
const attributes = RoleModel.getAttributes();
assert.deepEqual(Object.keys(attributes).sort(), [
"code",
"company_id",
"created_at",
"id",
"is_system",
"name",
"permission_codes",
"updated_at",
]);
assert.ok(attributes.permission_codes.type instanceof DataTypes.JSON);
for (const value of Object.values(attributes)) assert.equal(value.allowNull, false);
assert.equal(RoleModel.options.timestamps, false);
const unique = RoleModel.options.indexes?.find((index) => index.unique);
assert.deepEqual(unique?.fields, ["company_id", "code"]);
});
for (const system of [false, true]) {
test(`mapper preserva todos los campos con isSystem=${system}`, () => {
const original = role(system);
const persisted = mapper.mapToPersistence(original);
assert.equal(persisted.isSuccess, true);
assert.deepEqual(persisted.data, {
id: id.toPrimitive(),
company_id: companyId.toPrimitive(),
code: "billing-editor",
name: "Billing",
is_system: system,
permission_codes: ["invoices:read", "invoices:write"],
created_at: new Date("2026-01-01T00:00:00.000Z"),
updated_at: new Date("2026-02-02T00:00:00.000Z"),
});
const restored = mapper.mapToDomain(RoleModel.build(persisted.data, { raw: true }));
assert.equal(restored.isSuccess, true);
assert.deepEqual(mapper.mapToPersistence(restored.data).data, persisted.data);
assert.ok(restored.data.createdAt.equals(original.createdAt));
assert.ok(restored.data.updatedAt.equals(original.updatedAt));
assert.equal(restored.data.isSystem, system);
});
}
test("mapper admite JSON textual del driver y copia los permisos", () => {
const values = mapper.mapToPersistence(role()).data;
const row = RoleModel.build(
{ ...values, permission_codes: JSON.stringify(["invoices:read", "invoices:write"]) },
{ raw: true }
);
const restored = mapper.mapToDomain(row);
assert.equal(restored.isSuccess, true);
const permissions = restored.data.permissionCodes;
permissions.push("other:read");
assert.deepEqual(restored.data.permissionCodes, ["invoices:read", "invoices:write"]);
});
test("Role.create rechaza permisos vacíos sin cambiar sus invariantes", () => {
const source = role();
assert.equal(
Role.create(
{
companyId,
code: source.code,
name: source.name,
permissionCodes: [],
isSystem: false,
createdAt: source.createdAt,
updatedAt: source.updatedAt,
},
id
).isFailure,
true
);
});
test("mapper rechaza JSON de forma inválida", () => {
for (const permissions of ["{", {}, [1], null]) {
const row = RoleModel.build(
{ ...mapper.mapToPersistence(role()).data, permission_codes: permissions },
{ raw: true }
);
assert.equal(mapper.mapToDomain(row).isFailure, true);
}
});

View File

@ -0,0 +1,72 @@
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Role, RoleCode } from "../../../../domain";
import type { RoleCreationAttributes, RoleModel } from "../models";
function toUtcDate(value: Date | string): Result<UtcDate, Error> {
return UtcDate.createFromISO(
value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10)
);
}
export class SequelizeRoleDomainMapper {
public mapToDomain(source: RoleModel): Result<Role, Error> {
try {
const id = UniqueID.create(source.id);
if (id.isFailure) return Result.fail(id.error);
const companyId = UniqueID.create(source.company_id);
if (companyId.isFailure) return Result.fail(companyId.error);
const code = RoleCode.create(source.code);
if (code.isFailure) return Result.fail(code.error);
const name = Name.create(source.name);
if (name.isFailure) return Result.fail(name.error);
const createdAt = toUtcDate(source.created_at);
if (createdAt.isFailure) return Result.fail(createdAt.error);
const updatedAt = toUtcDate(source.updated_at);
if (updatedAt.isFailure) return Result.fail(updatedAt.error);
// MariaDB puede entregar JSON como texto. Solo se comprueba su forma de persistencia.
const permissions: unknown =
typeof source.permission_codes === "string"
? JSON.parse(source.permission_codes)
: source.permission_codes;
if (
!(
Array.isArray(permissions) &&
permissions.every((value): value is string => typeof value === "string")
)
) {
return Result.fail(new Error("Invalid role permission codes persistence."));
}
return Result.ok(
Role.rehydrate(
{
companyId: companyId.data,
code: code.data,
name: name.data,
isSystem: source.is_system,
permissionCodes: [...permissions],
createdAt: createdAt.data,
updatedAt: updatedAt.data,
},
id.data
)
);
} catch (error: unknown) {
return Result.fail(error instanceof Error ? error : new Error(String(error)));
}
}
public mapToPersistence(source: Role): Result<RoleCreationAttributes, Error> {
return Result.ok({
id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(),
code: source.code.toPrimitive(),
name: source.name.toPrimitive(),
is_system: source.isSystem,
permission_codes: source.permissionCodes,
created_at: source.createdAt.toDate(),
updated_at: source.updatedAt.toDate(),
});
}
}

View File

@ -1,3 +1,5 @@
export * from "./sequelize-account.model";
export * from "./sequelize-company-membership.model";
export * from "./sequelize-company-membership-role.model";
export * from "./sequelize-refresh-token.model";
export * from "./sequelize-role.model";

View File

@ -0,0 +1,43 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model } from "sequelize";
export class CompanyMembershipRoleModel extends Model<
InferAttributes<CompanyMembershipRoleModel>,
InferCreationAttributes<CompanyMembershipRoleModel>
> {
declare membership_id: string;
declare role_id: string;
}
export default (database: Sequelize) => {
CompanyMembershipRoleModel.init(
{
membership_id: {
type: DataTypes.UUID,
primaryKey: true,
allowNull: false,
references: { model: "identity_company_memberships", key: "id" },
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
role_id: {
type: DataTypes.UUID,
primaryKey: true,
allowNull: false,
references: { model: "identity_roles", key: "id" },
onDelete: "RESTRICT",
onUpdate: "CASCADE",
},
},
{
sequelize: database,
modelName: "CompanyMembershipRoleModel",
tableName: "identity_company_membership_roles",
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
underscored: true,
timestamps: false,
}
);
return CompanyMembershipRoleModel;
};

View File

@ -0,0 +1,49 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model } from "sequelize";
export type RoleCreationAttributes = InferCreationAttributes<RoleModel>;
export class RoleModel extends Model<
InferAttributes<RoleModel>,
InferCreationAttributes<RoleModel>
> {
declare id: string;
declare company_id: string;
declare code: string;
declare name: string;
declare is_system: boolean;
declare permission_codes: unknown;
declare created_at: Date | string;
declare updated_at: Date | string;
}
export default (database: Sequelize) => {
RoleModel.init(
{
id: { type: DataTypes.UUID, primaryKey: true, allowNull: false },
company_id: { type: DataTypes.UUID, allowNull: false },
code: { type: DataTypes.STRING(100), allowNull: false },
name: { type: DataTypes.STRING, allowNull: false },
is_system: { type: DataTypes.BOOLEAN, allowNull: false },
permission_codes: { type: DataTypes.JSON, allowNull: false },
created_at: { type: DataTypes.DATE, allowNull: false },
updated_at: { type: DataTypes.DATE, allowNull: false },
},
{
sequelize: database,
modelName: "RoleModel",
tableName: "identity_roles",
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
underscored: true,
timestamps: false,
indexes: [
{
name: "uq_identity_roles_company_code",
fields: ["company_id", "code"],
unique: true,
},
],
}
);
return RoleModel;
};

View File

@ -1,3 +1,5 @@
export * from "./sequelize-account.repository";
export * from "./sequelize-company-membership.repository";
export * from "./sequelize-company-membership-access.repository";
export * from "./sequelize-refresh-token.repository";
export * from "./sequelize-role.repository";

View File

@ -0,0 +1,272 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Sequelize } from "sequelize";
import { CompanyMembership, CompanyMembershipStatus } from "../../../../domain";
import { SequelizeCompanyMembershipDomainMapper } from "../mappers/sequelize-company-membership-domain.mapper";
import initMembership, {
CompanyMembershipModel,
} from "../models/sequelize-company-membership.model";
import initLinks, {
CompanyMembershipRoleModel,
} from "../models/sequelize-company-membership-role.model";
import initRole, { RoleModel } from "../models/sequelize-role.model";
const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false });
initMembership(database);
initRole(database);
initLinks(database);
import type { TestContext } from "node:test";
import { Op } from "sequelize";
import { SequelizeCompanyMembershipRepository } from "./sequelize-company-membership.repository";
const membershipId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
const companyId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
const a = "44444444-4444-4444-8444-444444444444";
const b = "55555555-5555-4555-8555-555555555555";
const c = "66666666-6666-4666-8666-666666666666";
function membership(ids: string[] = [a], active = true) {
return CompanyMembership.create(
{
accountId,
companyId,
roleIds: ids.map((id) => UniqueID.create(id).data),
status: active
? CompanyMembershipStatus.createActive()
: CompanyMembershipStatus.createDisabled(),
createdAt: UtcDate.createFromISO("2026-01-01").data,
updatedAt: UtcDate.createFromISO("2026-02-02").data,
},
membershipId
);
}
const mapper = new SequelizeCompanyMembershipDomainMapper();
function row(ids: string[] = [a], active = true) {
return CompanyMembershipModel.build(
mapper.mapToPersistence(membership(ids, active).data).data.membership,
{ raw: true }
);
}
const repository = new SequelizeCompanyMembershipRepository();
const transaction = {};
function setup(t: TestContext, old: string[] | null = null) {
const roleFind = t.mock.method(RoleModel, "findAll", async () =>
[a, b, c].map((id) =>
RoleModel.build(
{
id,
company_id: companyId.toPrimitive(),
code: "role-code",
name: "Role",
is_system: false,
permission_codes: ["invoices:read"],
created_at: "2026-01-01",
updated_at: "2026-02-02",
},
{ raw: true }
)
)
);
const find = t.mock.method(CompanyMembershipModel, "findOne", async () =>
old === null ? null : row(old)
);
const all = t.mock.method(CompanyMembershipModel, "findAll", async () =>
old === null ? [] : [row(old)]
);
const links = t.mock.method(CompanyMembershipRoleModel, "findAll", async () =>
(old ?? []).map((roleId) =>
CompanyMembershipRoleModel.build({
membership_id: membershipId.toPrimitive(),
role_id: roleId,
})
)
);
const create = t.mock.method(CompanyMembershipModel, "create", async () => row());
const update = t.mock.method(CompanyMembershipModel, "update", async () => [1]);
const add = t.mock.method(CompanyMembershipRoleModel, "bulkCreate", async () => []);
const remove = t.mock.method(CompanyMembershipRoleModel, "destroy", async () => 1);
return { roleFind, find, all, links, create, update, add, remove };
}
test("find carga roleIds y propaga transaction", async (t) => {
const mocks = setup(t, [a, b]);
const result = await repository.findByAccountIdAndCompanyId({
accountId,
companyId,
transaction,
});
assert.equal(result.isSuccess, true);
assert.deepEqual(
result.data.unwrap().roleIds.map((id) => id.toPrimitive()),
[a, b]
);
assert.equal(mocks.find.mock.calls[0].arguments[0]?.transaction, transaction);
assert.equal(mocks.links.mock.calls[0].arguments[0]?.transaction, transaction);
});
test("find ausente devuelve none", async (t) => {
setup(t);
assert.equal(
(await repository.findByAccountIdAndCompanyId({ accountId, companyId })).data.isNone(),
true
);
});
test("listados cargan roles por account y company", async (t) => {
setup(t, [a]);
assert.equal(
(await repository.findByAccountId({ accountId, transaction })).data.getAll()[0].roleIds.length,
1
);
assert.equal(
(await repository.findByCompanyId({ companyId, transaction })).data.getAll()[0].roleIds.length,
1
);
});
test("save crea membership y roles con transacción en todas las operaciones", async (t) => {
const mocks = setup(t);
const result = await repository.save(membership([a, b, c]).data, transaction);
assert.equal(result.isSuccess, true);
assert.equal(mocks.create.mock.callCount(), 1);
assert.deepEqual(
mocks.add.mock.calls[0].arguments[0],
[a, b, c].map((roleId) => ({ membership_id: membershipId.toPrimitive(), role_id: roleId }))
);
for (const options of [
mocks.roleFind.mock.calls[0].arguments[0],
mocks.find.mock.calls[0].arguments[0],
mocks.create.mock.calls[0].arguments[1],
mocks.add.mock.calls[0].arguments[1],
])
assert.equal(options?.transaction, transaction);
});
test("save sincroniza A B a B C sin recrear B", async (t) => {
const mocks = setup(t, [a, b]);
mocks.roleFind.mock.mockImplementation(async () =>
[b, c].map((id) =>
RoleModel.build(
{
id,
company_id: companyId.toPrimitive(),
code: "role-code",
name: "Role",
is_system: false,
permission_codes: ["invoices:read"],
created_at: "2026-01-01",
updated_at: "2026-02-02",
},
{ raw: true }
)
)
);
assert.equal((await repository.save(membership([b, c]).data, transaction)).isSuccess, true);
assert.deepEqual(mocks.add.mock.calls[0].arguments[0], [
{ membership_id: membershipId.toPrimitive(), role_id: c },
]);
assert.deepEqual(mocks.remove.mock.calls[0].arguments[0]?.where, {
membership_id: membershipId.toPrimitive(),
role_id: { [Op.in]: [a] },
});
assert.equal(mocks.create.mock.callCount(), 0);
});
test("update conserva asignaciones sin duplicarlas", async (t) => {
const mocks = setup(t, [a, b, c]);
assert.equal((await repository.update(membership([a, b, c]).data, transaction)).isSuccess, true);
assert.equal(mocks.add.mock.callCount(), 0);
assert.equal(mocks.remove.mock.callCount(), 0);
});
for (const reason of ["inexistente", "otra company"]) {
test(`rol ${reason} falla antes de escribir`, async (t) => {
const mocks = setup(t);
mocks.roleFind.mock.mockImplementation(async () => []);
const result = await repository.save(membership([a]).data, transaction);
assert.equal(result.isFailure, true);
assert.deepEqual(mocks.roleFind.mock.calls[0].arguments[0]?.where, {
id: { [Op.in]: [a] },
company_id: companyId.toPrimitive(),
});
assert.equal(mocks.create.mock.callCount(), 0);
assert.equal(mocks.add.mock.callCount(), 0);
});
}
test("error leyendo join se propaga", async (t) => {
const mocks = setup(t, [a]);
const error = new Error("join read failed");
mocks.links.mock.mockImplementation(async () => {
throw error;
});
const result = await repository.findByAccountIdAndCompanyId({
accountId,
companyId,
transaction,
});
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
test("error escribiendo join retorna fallo para rollback externo", async (t) => {
const mocks = setup(t);
const error = new Error("join write failed");
mocks.add.mock.mockImplementation(async () => {
throw error;
});
const result = await repository.save(membership([a, b, c]).data, transaction);
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
test("sin transaction falla sin I/O", async (t) => {
const mocks = setup(t);
assert.equal((await repository.save(membership().data)).isFailure, true);
assert.equal((await repository.update(membership().data)).isFailure, true);
assert.equal(mocks.roleFind.mock.callCount(), 0);
assert.equal(mocks.create.mock.callCount(), 0);
});
test("update no crea membership ausente", async (t) => {
const mocks = setup(t);
assert.equal((await repository.update(membership([a, b, c]).data, transaction)).isFailure, true);
assert.equal(mocks.create.mock.callCount(), 0);
});
test("membership deshabilitada puede retirar todos sus roles", async (t) => {
const mocks = setup(t, [a]);
const result = await repository.update(membership([], false).data, transaction);
assert.equal(result.isSuccess, true);
assert.equal(mocks.roleFind.mock.callCount(), 0);
assert.equal(mocks.add.mock.callCount(), 0);
assert.deepEqual(mocks.remove.mock.calls[0].arguments[0]?.where, {
membership_id: membershipId.toPrimitive(),
role_id: { [Op.in]: [a] },
});
for (const options of [
mocks.find.mock.calls[0].arguments[0],
mocks.links.mock.calls[0].arguments[0],
mocks.update.mock.calls[0].arguments[1],
mocks.remove.mock.calls[0].arguments[0],
])
assert.equal(options?.transaction, transaction);
});
test("lectura actual bloquea membership y asignaciones en la misma transacción", async (t) => {
const find = t.mock.method(CompanyMembershipModel, "findOne", async () => row());
const links = t.mock.method(CompanyMembershipRoleModel, "findAll", async () => [
CompanyMembershipRoleModel.build({ membership_id: membershipId.toPrimitive(), role_id: a }),
]);
const result = await repository.findByAccountIdAndCompanyId({
accountId,
companyId,
transaction,
currentRead: true,
});
assert.equal(result.isSuccess, true);
for (const call of [find.mock.calls[0], links.mock.calls[0]]) {
assert.equal(call.arguments[0]?.lock, true);
assert.equal(call.arguments[0]?.transaction, transaction);
}
assert.deepEqual(
result.data.unwrap().roleIds.map((id) => id.toPrimitive()),
[a]
);
});

View File

@ -0,0 +1,170 @@
import { translateSequelizeError } from "@erp/core/api";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Transaction, WhereOptions } from "sequelize";
import { Op } from "sequelize";
import type { ICompanyMembershipRepository } from "../../../../application/company-memberships/contracts/company-membership-repository.interface";
import type { CompanyMembership } from "../../../../domain";
import { SequelizeCompanyMembershipDomainMapper } from "../mappers";
import { CompanyMembershipModel, CompanyMembershipRoleModel, RoleModel } from "../models";
export class SequelizeCompanyMembershipRepository implements ICompanyMembershipRepository {
public constructor(private readonly mapper = new SequelizeCompanyMembershipDomainMapper()) {}
/** Requiere transacción externa; el caller debe revertirla si recibe fallo. */
public save(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>> {
return this.persist(membership, transaction, false);
}
/** Requiere transacción externa; no crea una membership ausente. */
public update(
membership: CompanyMembership,
transaction?: unknown
): Promise<Result<void, Error>> {
return this.persist(membership, transaction, true);
}
private async persist(
source: CompanyMembership,
transaction: unknown,
updateOnly: boolean
): Promise<Result<void, Error>> {
if (!transaction)
return Result.fail(new Error("Company membership writes require an external transaction."));
try {
const mapped = this.mapper.mapToPersistence(source);
if (mapped.isFailure) return Result.fail(mapped.error);
const { membership, roleIds } = mapped.data;
const options = { transaction: transaction as Transaction };
const roles =
roleIds.length === 0
? []
: await RoleModel.findAll({
attributes: ["id"],
where: { id: { [Op.in]: roleIds }, company_id: membership.company_id },
...options,
lock: true,
});
if (new Set(roles.map((role) => role.id)).size !== roleIds.length) {
return Result.fail(
new Error("Membership roles must exist and belong to the same company.")
);
}
// Serializa cambios sobre la misma membership dentro de la transacción del caller.
const current = await CompanyMembershipModel.findOne({
where: { id: membership.id },
...options,
lock: true,
});
if (
current &&
(current.company_id !== membership.company_id ||
current.account_id !== membership.account_id)
) {
return Result.fail(new Error("Membership identity cannot change."));
}
if (!current && updateOnly)
return Result.fail(new Error("Company membership not found for update."));
const links = current
? await CompanyMembershipRoleModel.findAll({
where: { membership_id: membership.id },
...options,
lock: true,
})
: [];
if (current) {
await CompanyMembershipModel.update(membership, {
where: { id: membership.id, company_id: membership.company_id },
...options,
});
} else {
await CompanyMembershipModel.create(membership, options);
}
const old = new Set(links.map((link) => link.role_id));
const wanted = new Set(roleIds);
const removed = [...old].filter((id) => !wanted.has(id));
const added = roleIds.filter((id) => !old.has(id));
if (removed.length > 0)
await CompanyMembershipRoleModel.destroy({
where: { membership_id: membership.id, role_id: { [Op.in]: removed } },
...options,
});
if (added.length > 0)
await CompanyMembershipRoleModel.bulkCreate(
added.map((roleId) => ({ membership_id: membership.id, role_id: roleId })),
options
);
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findByAccountIdAndCompanyId(
params: Parameters<ICompanyMembershipRepository["findByAccountIdAndCompanyId"]>[0]
): Promise<Result<Maybe<CompanyMembership>, Error>> {
try {
const row = await CompanyMembershipModel.findOne({
where: {
account_id: params.accountId.toPrimitive(),
company_id: params.companyId.toPrimitive(),
},
transaction: params.transaction as Transaction | undefined,
...(params.currentRead && params.transaction ? { lock: true } : {}),
});
if (!row) return Result.ok(Maybe.none());
return (await this.hydrate(row, params.transaction, params.currentRead)).map(Maybe.some);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public findByCompanyId(
params: Parameters<ICompanyMembershipRepository["findByCompanyId"]>[0]
): Promise<Result<Collection<CompanyMembership>, Error>> {
return this.findMany({ company_id: params.companyId.toPrimitive() }, params.transaction);
}
public findByAccountId(
params: Parameters<ICompanyMembershipRepository["findByAccountId"]>[0]
): Promise<Result<Collection<CompanyMembership>, Error>> {
return this.findMany({ account_id: params.accountId.toPrimitive() }, params.transaction);
}
private async findMany(
where: WhereOptions,
transaction?: unknown
): Promise<Result<Collection<CompanyMembership>, Error>> {
try {
const rows = await CompanyMembershipModel.findAll({
where,
transaction: transaction as Transaction | undefined,
});
const memberships: CompanyMembership[] = [];
for (const row of rows) {
const result = await this.hydrate(row, transaction);
if (result.isFailure) return Result.fail(result.error);
memberships.push(result.data);
}
return Result.ok(new Collection(memberships));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
private async hydrate(
row: CompanyMembershipModel,
transaction?: unknown,
currentRead = false
): Promise<Result<CompanyMembership, Error>> {
const links = await CompanyMembershipRoleModel.findAll({
where: { membership_id: row.id },
...(currentRead && transaction ? { lock: true } : {}),
transaction: transaction as Transaction | undefined,
});
return this.mapper.mapToDomain(
row,
links.map((link) => link.role_id)
);
}
}

View File

@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DuplicateEntityError } from "@erp/core/api";
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Op, Sequelize, UniqueConstraintError } from "sequelize";
import { Role, RoleCode } from "../../../../domain";
import { SequelizeRoleDomainMapper } from "../mappers/sequelize-role-domain.mapper";
import initRole, { RoleModel } from "../models/sequelize-role.model";
import { SequelizeRoleRepository } from "./sequelize-role.repository";
initRole(new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }));
const mapper = new SequelizeRoleDomainMapper();
const repository = new SequelizeRoleRepository(mapper);
const id = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
const companyId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
function role(isSystem = false) {
return Role.create(
{
companyId,
code: RoleCode.create("billing-editor").data,
name: Name.create("Billing").data,
permissionCodes: ["invoices:read", "invoices:write"],
isSystem,
createdAt: UtcDate.createFromISO("2026-01-01").data,
updatedAt: UtcDate.createFromISO("2026-02-02").data,
},
id
).data;
}
const row = () => RoleModel.build(mapper.mapToPersistence(role()).data, { raw: true });
test("find existente filtra compañía e id y propaga transacción", async (t) => {
const transaction = {};
const find = t.mock.method(RoleModel, "findOne", async () => row());
const result = await repository.findByIdInCompany({ roleId: id, companyId, transaction });
assert.equal(result.isSuccess, true);
assert.ok(result.data.unwrap().id.equals(id));
assert.deepEqual(find.mock.calls[0].arguments[0], {
where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() },
transaction,
});
});
test("find ausente devuelve none", async (t) => {
t.mock.method(RoleModel, "findOne", async () => null);
const result = await repository.findByIdInCompany({ roleId: id, companyId });
assert.equal(result.isSuccess, true);
assert.equal(result.data.isNone(), true);
});
for (const exists of [false, true]) {
test(`save ${exists ? "update" : "create"} preserva payload y transaction`, async (t) => {
const transaction = {};
t.mock.method(RoleModel, "findOne", async () => (exists ? row() : null));
const write = t.mock.method(RoleModel, exists ? "update" : "create", async () =>
exists ? [1] : row()
);
const result = await repository.save(role(), transaction);
assert.equal(result.isSuccess, true);
assert.deepEqual(write.mock.calls[0].arguments[0], mapper.mapToPersistence(role()).data);
assert.equal(write.mock.calls[0].arguments[1]?.transaction, transaction);
});
}
test("update y delete limitan compañía y propagan transaction", async (t) => {
const transaction = {};
const update = t.mock.method(RoleModel, "update", async () => [1]);
const destroy = t.mock.method(RoleModel, "destroy", async () => 1);
assert.equal((await repository.update(role(), transaction)).isSuccess, true);
assert.equal((await repository.delete(role(), transaction)).isSuccess, true);
const options = {
where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() },
transaction,
};
assert.deepEqual(update.mock.calls[0].arguments[1], options);
assert.deepEqual(destroy.mock.calls[0].arguments[0], options);
});
test("consultas múltiples filtran compañía e ids y no consultan ids vacíos", async (t) => {
const transaction = {};
const find = t.mock.method(RoleModel, "findAll", async () => [row()]);
const many = await repository.findByIdsInCompany({ roleIds: [id], companyId, transaction });
assert.equal(many.data.getAll().length, 1);
assert.deepEqual(find.mock.calls[0].arguments[0], {
where: { id: { [Op.in]: [id.toPrimitive()] }, company_id: companyId.toPrimitive() },
transaction,
});
assert.equal(
(await repository.findByCompanyId({ companyId, transaction })).data.getAll().length,
1
);
assert.deepEqual(find.mock.calls[1].arguments[0], {
where: { company_id: companyId.toPrimitive() },
transaction,
});
assert.equal(
(await repository.findByIdsInCompany({ roleIds: [], companyId })).data.getAll().length,
0
);
assert.equal(find.mock.callCount(), 2);
});
test("read error se propaga", async (t) => {
const error = new Error("read failed");
t.mock.method(RoleModel, "findOne", async () => {
throw error;
});
const result = await repository.findByIdInCompany({ roleId: id, companyId });
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
test("write error se propaga", async (t) => {
const error = new Error("write failed");
t.mock.method(RoleModel, "findOne", async () => null);
t.mock.method(RoleModel, "create", async () => {
throw error;
});
const result = await repository.save(role());
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
test("duplicate code se traduce a DuplicateEntityError", async (t) => {
const error = new UniqueConstraintError({ message: "duplicate code" });
t.mock.method(RoleModel, "findOne", async () => null);
t.mock.method(RoleModel, "create", async () => {
throw error;
});
const result = await repository.save(role());
assert.equal(result.isFailure, true);
assert.ok(result.error instanceof DuplicateEntityError);
assert.equal(result.error.cause, error);
});
for (const currentRead of [false, true]) {
test(`find por código acota compañía y propaga lectura actual=${currentRead}`, async (t) => {
const transaction = {};
const find = t.mock.method(RoleModel, "findOne", async () => row());
const result = await repository.findByCodeInCompany({
companyId,
code: role().code,
transaction,
currentRead,
});
assert.equal(result.isSuccess, true);
assert.equal(result.data.unwrap().id.equals(id), true);
assert.deepEqual(find.mock.calls[0].arguments[0], {
where: { company_id: companyId.toPrimitive(), code: "billing-editor" },
transaction,
...(currentRead ? { lock: true } : {}),
});
});
}
test("find por código ausente devuelve none", async (t) => {
t.mock.method(RoleModel, "findOne", async () => null);
const result = await repository.findByCodeInCompany({ companyId, code: role().code });
assert.equal(result.data.isNone(), true);
});
test("find por código propaga error", async (t) => {
const error = new Error("lookup failed");
t.mock.method(RoleModel, "findOne", async () => {
throw error;
});
const result = await repository.findByCodeInCompany({ companyId, code: role().code });
assert.equal(result.error, error);
});
test("lectura actual de roles propaga bloqueo y transacción", async (t) => {
const transaction = {};
const find = t.mock.method(RoleModel, "findAll", async () => [row()]);
const result = await repository.findByIdsInCompany({
roleIds: [id],
companyId,
transaction,
currentRead: true,
});
assert.equal(result.isSuccess, true);
assert.equal(find.mock.calls[0].arguments[0]?.lock, true);
assert.equal(find.mock.calls[0].arguments[0]?.transaction, transaction);
});

View File

@ -0,0 +1,132 @@
import { translateSequelizeError } from "@erp/core/api";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Transaction, WhereOptions } from "sequelize";
import { Op } from "sequelize";
import type { IRoleRepository } from "../../../../application/roles/contracts/role-repository.interface";
import type { Role } from "../../../../domain";
import { SequelizeRoleDomainMapper } from "../mappers";
import { RoleModel } from "../models";
export class SequelizeRoleRepository implements IRoleRepository {
public constructor(private readonly mapper = new SequelizeRoleDomainMapper()) {}
public async findByCodeInCompany(
params: Parameters<IRoleRepository["findByCodeInCompany"]>[0]
): Promise<Result<Maybe<Role>, Error>> {
try {
const row = await RoleModel.findOne({
where: { company_id: params.companyId.toPrimitive(), code: params.code.toPrimitive() },
transaction: params.transaction as Transaction | undefined,
// Una lectura bloqueante ve el ganador incluso bajo REPEATABLE READ.
...(params.currentRead && params.transaction ? { lock: true } : {}),
});
if (!row) return Result.ok(Maybe.none());
return this.mapper.mapToDomain(row).map(Maybe.some);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async save(role: Role, transaction?: unknown): Promise<Result<void, Error>> {
try {
const mapped = this.mapper.mapToPersistence(role);
if (mapped.isFailure) return Result.fail(mapped.error);
const where = { id: role.id.toPrimitive(), company_id: role.companyId.toPrimitive() };
const options = { transaction: transaction as Transaction | undefined };
const existing = await RoleModel.findOne({ where, ...options });
if (existing) {
await RoleModel.update(mapped.data, { where, ...options });
} else {
await RoleModel.create(mapped.data, options);
}
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async update(role: Role, transaction?: unknown): Promise<Result<void, Error>> {
try {
const mapped = this.mapper.mapToPersistence(role);
if (mapped.isFailure) return Result.fail(mapped.error);
await RoleModel.update(mapped.data, {
where: { id: role.id.toPrimitive(), company_id: role.companyId.toPrimitive() },
transaction: transaction as Transaction | undefined,
});
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async delete(role: Role, transaction?: unknown): Promise<Result<void, Error>> {
try {
await RoleModel.destroy({
where: { id: role.id.toPrimitive(), company_id: role.companyId.toPrimitive() },
transaction: transaction as Transaction | undefined,
});
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findByIdInCompany(
params: Parameters<IRoleRepository["findByIdInCompany"]>[0]
): Promise<Result<Maybe<Role>, Error>> {
try {
const row = await RoleModel.findOne({
where: { id: params.roleId.toPrimitive(), company_id: params.companyId.toPrimitive() },
transaction: params.transaction as Transaction | undefined,
});
if (!row) return Result.ok(Maybe.none());
return this.mapper.mapToDomain(row).map(Maybe.some);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public findByIdsInCompany(
params: Parameters<IRoleRepository["findByIdsInCompany"]>[0]
): Promise<Result<Collection<Role>, Error>> {
if (params.roleIds.length === 0) return Promise.resolve(Result.ok(new Collection<Role>([])));
return this.findMany(
{
id: { [Op.in]: params.roleIds.map((id) => id.toPrimitive()) },
company_id: params.companyId.toPrimitive(),
},
params.transaction,
params.currentRead
);
}
public findByCompanyId(
params: Parameters<IRoleRepository["findByCompanyId"]>[0]
): Promise<Result<Collection<Role>, Error>> {
return this.findMany({ company_id: params.companyId.toPrimitive() }, params.transaction);
}
private async findMany(
where: WhereOptions,
transaction?: unknown,
currentRead = false
): Promise<Result<Collection<Role>, Error>> {
try {
const rows = await RoleModel.findAll({
...(currentRead && transaction ? { lock: true } : {}),
where,
transaction: transaction as Transaction | undefined,
});
const roles: Role[] = [];
for (const row of rows) {
const mapped = this.mapper.mapToDomain(row);
if (mapped.isFailure) return Result.fail(mapped.error);
roles.push(mapped.data);
}
return Result.ok(new Collection(roles));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
}

View File

@ -4,7 +4,9 @@ import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { createIdentityCompanyAccess } from "./module";
import { InitialCompanyAdminAssigner } from "./application";
import type { IIdentityNeutralPublicServices } from "./infrastructure/di/identity-public-services";
import { createIdentityCompanyAccess, identityModule } from "./module";
test("identity neutral composition propagates accessible company ids from memberships", async () => {
const accountId = UniqueID.generateNewID();
@ -24,3 +26,31 @@ test("identity neutral composition propagates accessible company ids from member
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data.getAll(), companyIds.getAll());
});
test("Identity publica assigner sin asignar ni abrir transacciones al componer", async (t) => {
const assign = t.mock.method(InitialCompanyAdminAssigner.prototype, "assign", async () =>
Result.ok()
);
let transactions = 0;
const setup = await identityModule.setup?.({
database: {
transaction: async () => {
transactions++;
throw new Error("Unexpected transaction");
},
},
transactionContext: {},
config: {
auth: {
jwt: { secret: "composition-test", accessExpiration: "15m", refreshExpiration: "7d" },
},
},
operationContext: {},
} as never);
const general = setup?.services?.general as IIdentityNeutralPublicServices;
assert.equal(typeof general.companyAccess.canAccessCompany, "function");
assert.ok(general.initialCompanyAdminAssigner instanceof InitialCompanyAdminAssigner);
assert.deepEqual(Object.keys(general).sort(), ["companyAccess", "initialCompanyAdminAssigner"]);
assert.equal(assign.mock.callCount(), 0);
assert.equal(transactions, 0);
});

View File

@ -6,13 +6,14 @@ import { models } from "./infrastructure/persistence/sequelize";
export type { IIdentityCompanyAccessChecker } from "./application";
export { createIdentityCompanyAccess } from "./application";
/** Manifest neutral de Identity: únicamente companyAccess. */
/** Manifest neutral de Identity: acceso a compañías y asignación inicial de administrador. */
export const identityModule: IModuleServer = {
name: "identity",
version: "1.0.0",
dependencies: [],
async setup(params) {
const internal = buildIdentityDependencies(params);
return {
models,
services: { general: buildIdentityNeutralPublicServices(internal) },

View File

@ -20,7 +20,7 @@ export function LoginPage() {
}
return (
<div className="relative overflow-hidden rounded-[2rem] border border-border/60 bg-gradient-to-br from-background via-background to-muted/50 p-2 shadow-2xl">
<div className="relative overflow-hidden rounded-4xl border border-border/60 bg-linear-to-br from-background via-background to-muted/50 p-2 shadow-2xl">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-8 top-0 h-40 rounded-full bg-primary/8 blur-3xl"

View File

@ -45,7 +45,8 @@
"react-router-dom": "^7.14.0",
"sequelize": "^6.37.8",
"use-debounce": "^10.1.1",
"zod": "^4.3.6"
"zod": "^4.3.6",
"@erp/companies": "workspace:*"
},
"devDependencies": {
"@types/express": "^4.17.21",

View File

@ -0,0 +1,4 @@
export * from "./mappers";
export * from "./repositories";
export * from "./services";
export * from "./use-cases";

View File

@ -0,0 +1,78 @@
import {
City,
CountryCode,
EmailAddress,
Name,
PhoneNumber,
PostalAddress,
PostalCode,
Province,
Street,
TextValue,
URLAddress,
UniqueID,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
import type { CompleteInitialOnboardingParams } from "../use-cases/complete-initial-onboarding.use-case";
export interface ICompleteInitialOnboardingInputMapper {
map(
dto: CompleteInitialOnboardingRequestDTO,
params: { accountId: string }
): Result<CompleteInitialOnboardingParams, Error>;
}
export class CompleteInitialOnboardingInputMapper implements ICompleteInitialOnboardingInputMapper {
public map(
dto: CompleteInitialOnboardingRequestDTO,
params: { accountId: string }
): Result<CompleteInitialOnboardingParams, Error> {
const accountId = UniqueID.create(params.accountId);
const legalName = Name.create(dto.legal_name);
const tin = TextValue.create(dto.tin);
const street = Street.create(dto.address.street);
const city = City.create(dto.address.city);
const postalCode = PostalCode.create(dto.address.postal_code);
const province = Province.create(dto.address.province);
const countryCode = CountryCode.create(dto.address.country_code);
const required = [accountId, legalName, tin, street, city, postalCode, province, countryCode];
const failed = required.find((value) => value.isFailure);
if (failed) return Result.fail(failed.error);
const street2 = dto.address.street_2 ? Street.create(dto.address.street_2) : undefined;
if (street2?.isFailure) return Result.fail(street2.error);
const address = PostalAddress.create({
street: Maybe.some(street.data),
street2: street2 ? Maybe.some(street2.data) : Maybe.none(),
city: Maybe.some(city.data),
postalCode: Maybe.some(postalCode.data),
province: Maybe.some(province.data),
countryCode: Maybe.some(countryCode.data),
});
if (address.isFailure) return Result.fail(address.error);
const tradeName = dto.trade_name ? Name.create(dto.trade_name) : undefined;
const email = dto.email ? EmailAddress.create(dto.email) : undefined;
const phone = dto.phone ? PhoneNumber.create(dto.phone) : undefined;
const website = dto.website ? URLAddress.create(dto.website) : undefined;
if (tradeName?.isFailure) return Result.fail(tradeName.error);
if (email?.isFailure) return Result.fail(email.error);
if (phone?.isFailure) return Result.fail(phone.error);
if (website?.isFailure) return Result.fail(website.error);
return Result.ok({
accountId: accountId.data,
company: {
legalName: legalName.data,
tradeName: tradeName?.data,
tin: tin.data,
email: email?.data,
phone: phone?.data,
website: website?.data,
address: address.data,
},
});
}
}

View File

@ -0,0 +1 @@
export * from "./complete-initial-onboarding-input.mapper";

View File

@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InitialOnboarding } from "../../domain";
import { GetInitialOnboardingStatusUseCase } from "./get-initial-onboarding-status.use-case";
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
const companyId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
const pending = InitialOnboarding.createPending(accountId).data;
const completed = InitialOnboarding.rehydrate(
{
accountId,
status: "completed",
companyId: Maybe.some(companyId),
createdAt: pending.createdAt,
updatedAt: pending.updatedAt,
completedAt: Maybe.some(pending.updatedAt),
},
pending.id
).data;
for (const [name, value, expected] of [
["none", Maybe.none(), { status: "not_provisioned", companyId: null }],
["pending", Maybe.some(pending), { status: "pending", companyId: null }],
["completed", Maybe.some(completed), { status: "completed", companyId: companyId.toPrimitive() }],
] as const)
test(name, async () => {
const repo = { findByAccountId: async () => Result.ok(value) } as never;
const result = await new GetInitialOnboardingStatusUseCase(repo).execute({ accountId });
assert.deepEqual(result.data, expected);
});
test("repository error fails without writing", async () => {
const error = new Error("read");
const repo = { findByAccountId: async () => Result.fail(error) } as never;
const result = await new GetInitialOnboardingStatusUseCase(repo).execute({ accountId });
assert.equal(result.error, error);
});

View File

@ -0,0 +1,29 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IInitialOnboardingRepository } from "./initial-onboarding-repository.interface";
export type InitialOnboardingStatusOutput = "not_provisioned" | "pending" | "completed";
export interface InitialOnboardingStatusModel {
status: InitialOnboardingStatusOutput;
companyId: string | null;
}
export interface GetInitialOnboardingStatusParams {
accountId: UniqueID;
}
export class GetInitialOnboardingStatusUseCase {
public constructor(private readonly repository: IInitialOnboardingRepository) {}
public async execute(
params: GetInitialOnboardingStatusParams
): Promise<Result<InitialOnboardingStatusModel, Error>> {
const found = await this.repository.findByAccountId(params.accountId);
if (found.isFailure) return Result.fail(found.error);
if (found.data.isNone()) return Result.ok({ status: "not_provisioned", companyId: null });
const onboarding = found.data.unwrap();
return Result.ok({
status: onboarding.status,
companyId: onboarding.companyId.isSome() ? onboarding.companyId.unwrap().toPrimitive() : null,
});
}
}

View File

@ -0,0 +1,2 @@
export * from "./get-initial-onboarding-status.use-case";
export * from "./initial-onboarding-repository.interface";

View File

@ -4,6 +4,12 @@ import type { Maybe, Result } from "@repo/rdx-utils";
import type { InitialOnboarding } from "../../domain";
export interface IInitialOnboardingRepository {
/** Bloquea el registro hasta finalizar la transacción externa obligatoria. */
findByAccountIdForUpdate(
accountId: UniqueID,
transaction: unknown
): Promise<Result<Maybe<InitialOnboarding>, Error>>;
/** Devuelve ausencia explícita si la cuenta no tiene onboarding; no es un error. */
findByAccountId(
accountId: UniqueID,

View File

@ -0,0 +1,2 @@
export * from "./initial-onboarding-provisioner";
export * from "./onboarding-public-services.interface";

View File

@ -15,6 +15,9 @@ const duplicate = () =>
new DuplicateEntityError("InitialOnboarding", "accountId", accountId.toPrimitive());
class FakeRepository implements IInitialOnboardingRepository {
async findByAccountIdForUpdate(): Promise<Result<Maybe<InitialOnboarding>, Error>> {
return Result.fail(new Error("Not used"));
}
public stored: InitialOnboarding | undefined;
public reads = 0;
public writes = 0;
@ -161,6 +164,7 @@ test("dos provision simultáneos con barrera devuelven un único ganador", async
let writes = 0;
let stored: InitialOnboarding | undefined;
const repository: IInitialOnboardingRepository = {
findByAccountIdForUpdate: async () => Result.fail(new Error("Not used")),
async findByAccountId(id) {
assert.ok(id.equals(accountId));
reads++;

View File

@ -22,6 +22,7 @@ export class InitialOnboardingProvisioner {
// 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(

View File

@ -0,0 +1 @@
export type IOnboardingPublicServices = Record<never, never>;

View File

@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InitialOnboarding } from "../../domain";
import { CompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper";
import { CompleteInitialOnboardingUseCase } from "./complete-initial-onboarding.use-case";
const accountId = UniqueID.generateNewID();
const companyId = UniqueID.generateNewID();
const input = {
accountId: accountId.toPrimitive(),
dto: {
legal_name: "Company",
tin: "B12345678",
address: {
street: "Street",
city: "City",
postal_code: "00000",
province: "Province",
country_code: "ES",
},
},
};
function setup(failure?: string, absent = false) {
let stored = absent ? undefined : InitialOnboarding.createPending(accountId).data;
const events: string[] = [];
const transaction = {};
const error = new Error(failure);
const manager: ITransactionManager = {
start: async () => {
events.push("begin");
if (failure === "begin") throw error;
},
getTransaction: () => transaction,
commit: async () => {
events.push("commit");
if (failure === "commit") throw error;
},
rollback: async () => {
events.push("rollback");
},
complete: async <T>(work: (tx: unknown) => Promise<T>): Promise<T> => {
await manager.start();
try {
const value = await work(transaction);
if (value instanceof Result && value.isFailure) throw value.error;
await manager.commit();
return value;
} catch (caught) {
await manager.rollback();
throw caught;
}
},
};
const useCase = new CompleteInitialOnboardingUseCase({
transactionManager: manager,
repository: {
findByAccountId: async () => {
assert.fail("Debe usar lock");
},
findByAccountIdForUpdate: async (id, tx) => {
events.push("lock");
assert.equal(tx, transaction);
assert.equal(id.toPrimitive(), accountId.toPrimitive());
if (failure === "read") return Result.fail(error);
return Result.ok(stored ? Maybe.some(stored) : Maybe.none<InitialOnboarding>());
},
save: async (value, tx) => {
events.push("save");
assert.equal(tx, transaction);
assert.equal(value.status, "completed");
assert.equal(value.companyId.unwrap().toPrimitive(), companyId.toPrimitive());
assert.equal(value.completedAt.unwrap().toPrimitive(), value.updatedAt.toPrimitive());
if (failure === "save") return Result.fail(error);
stored = value;
return Result.ok();
},
},
companyCreator: {
create: async (p) => {
events.push("company");
assert.equal(p.transaction, transaction);
if (failure === "throw") throw error;
if (failure === "company") return Result.fail(error);
return Result.ok({ companyId: companyId.toPrimitive(), slug: "internal" });
},
},
adminAssigner: {
assign: async (p) => {
events.push("admin");
assert.equal(p.transaction, transaction);
assert.equal(p.accountId.toPrimitive(), accountId.toPrimitive());
assert.equal(p.companyId.toPrimitive(), companyId.toPrimitive());
return failure === "admin" ? Result.fail(error) : Result.ok();
},
},
dtoMapper: new CompleteInitialOnboardingInputMapper(),
});
return {
useCase,
events,
error,
get stored() {
return stored;
},
};
}
test("pending ordena lock, Company, admin, completed/save y commit", async () => {
const s = setup();
const result = await s.useCase.execute(input);
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data, { companyId: companyId.toPrimitive() });
assert.deepEqual(s.events, ["begin", "lock", "company", "admin", "save", "commit"]);
});
test("segunda ejecución tras commit devuelve misma Company sin participantes", async () => {
const s = setup();
await s.useCase.execute(input);
s.events.length = 0;
const result = await s.useCase.execute({
...input,
dto: { ...input.dto, legal_name: "Changed" },
});
assert.equal(result.data.companyId, companyId.toPrimitive());
assert.deepEqual(s.events, ["begin", "lock", "commit"]);
});
test("completed existente ignora payload nuevo", async () => {
const s = setup();
const now = UtcDateTime.now();
s.stored?.complete({ companyId, completedAt: now, updatedAt: now });
assert.equal((await s.useCase.execute(input)).data.companyId, companyId.toPrimitive());
assert.deepEqual(s.events, ["begin", "lock", "commit"]);
});
test("ausente falla sin provisionar", async () => {
const s = setup(undefined, true);
assert.equal((await s.useCase.execute(input)).isFailure, true);
assert.deepEqual(s.events, ["begin", "lock", "rollback"]);
});
for (const failure of ["read", "company", "admin", "save", "throw", "commit", "begin"]) {
test(`fallo ${failure} retorna Result.fail y gestiona transacción`, async () => {
const s = setup(failure);
assert.equal((await s.useCase.execute(input)).error, s.error);
if (failure === "begin") assert.deepEqual(s.events, ["begin"]);
else assert.equal(s.events.at(-1), "rollback");
if (failure !== "commit") assert.equal(s.events.includes("commit"), false);
});
}
test("fallo del dominio no persiste ni confirma", async (t) => {
const s = setup();
assert.ok(s.stored);
t.mock.method(s.stored, "complete", () => Result.fail(s.error));
assert.equal((await s.useCase.execute(input)).isFailure, true);
assert.deepEqual(s.events, ["begin", "lock", "company", "admin", "rollback"]);
});

View File

@ -0,0 +1,101 @@
import type { CreateInitialCompanyParams, IInitialCompanyCreator } from "@erp/companies/api";
import type { ITransactionManager } from "@erp/core/api";
import type { IInitialCompanyAdminAssigner } from "@erp/identity/api";
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
import type { InitialOnboarding } from "../../domain";
import type { ICompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper";
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
export interface CompleteInitialOnboardingParams {
/** La futura capa HTTP debe obtenerlo del contexto autenticado. */
accountId: UniqueID;
company: Omit<CreateInitialCompanyParams, "transaction">;
}
export interface CompleteInitialOnboardingResult {
companyId: string;
}
export type CompleteInitialOnboardingUseCaseInput = {
dto: CompleteInitialOnboardingRequestDTO;
accountId: string;
};
/** Posee la transacción global; no provisiona elegibilidad ni modifica la sesión. */
export class CompleteInitialOnboardingUseCase {
public constructor(
private readonly deps: {
transactionManager: ITransactionManager;
repository: IInitialOnboardingRepository;
companyCreator: IInitialCompanyCreator;
adminAssigner: IInitialCompanyAdminAssigner;
dtoMapper: ICompleteInitialOnboardingInputMapper;
}
) {}
public async execute(
input: CompleteInitialOnboardingUseCaseInput
): Promise<Result<CompleteInitialOnboardingResult, Error>> {
const mappedResult = this.deps.dtoMapper.map(input.dto, { accountId: input.accountId });
if (mappedResult.isFailure) return Result.fail(mappedResult.error);
const params = mappedResult.data;
try {
return await this.deps.transactionManager.complete(async (transaction: unknown) => {
const found = await this.deps.repository.findByAccountIdForUpdate(
params.accountId,
transaction
);
if (found.isFailure) return Result.fail(found.error);
if (found.data.isNone())
return Result.fail(new Error("Initial onboarding has not been provisioned."));
const onboarding = found.data.unwrap();
if (!onboarding.accountId.equals(params.accountId))
return Result.fail(new Error("Initial onboarding account mismatch."));
if (onboarding.status === "completed") {
if (onboarding.companyId.isNone() || onboarding.completedAt.isNone())
return Result.fail(new Error("Invalid completed onboarding."));
return Result.ok({ companyId: onboarding.companyId.unwrap().toPrimitive() });
}
if (
onboarding.status !== "pending" ||
onboarding.companyId.isSome() ||
onboarding.completedAt.isSome()
) {
return Result.fail(new Error("Invalid pending onboarding."));
}
return this.completePending(params, onboarding, transaction);
});
} catch (error: unknown) {
return Result.fail(
error instanceof Error
? error
: new Error("Initial onboarding transaction failed.", { cause: error })
);
}
}
private async completePending(
params: CompleteInitialOnboardingParams,
onboarding: InitialOnboarding,
transaction: unknown
): Promise<Result<CompleteInitialOnboardingResult, Error>> {
const company = await this.deps.companyCreator.create({ ...params.company, transaction });
if (company.isFailure) return Result.fail(company.error);
const companyId = UniqueID.create(company.data.companyId);
if (companyId.isFailure) return Result.fail(companyId.error);
const admin = await this.deps.adminAssigner.assign({
accountId: params.accountId,
companyId: companyId.data,
transaction,
});
if (admin.isFailure) return Result.fail(admin.error);
const now = UtcDateTime.now();
const completed = onboarding.complete({
companyId: companyId.data,
completedAt: now,
updatedAt: now,
});
if (completed.isFailure) return Result.fail(completed.error);
const saved = await this.deps.repository.save(onboarding, transaction);
if (saved.isFailure) return Result.fail(saved.error);
return Result.ok({ companyId: companyId.data.toPrimitive() });
}
}

View File

@ -0,0 +1 @@
export * from './complete-initial-onboarding.use-case';

View File

@ -0,0 +1,25 @@
import type { IModuleServer } from "@erp/core/api";
import type { IOnboardingPublicServices } from "./application";
import { buildOnboardingDependencies } from "./infrastructure/di";
import { onboardingRouter } from "./infrastructure/express/onboarding.routes";
import { models } from "./infrastructure/persistence/sequelize";
export type { IOnboardingPublicServices } from "./application";
export * from "./application";
export * from "./infrastructure/persistence/sequelize";
export const onboardingAPIModule: IModuleServer = {
name: "onboarding",
version: "1.0.0",
dependencies: ["companies", "identity"],
setup(params) {
const internal = buildOnboardingDependencies(params);
const general: IOnboardingPublicServices = {};
return { models, services: { general }, internal };
},
start(params) {
onboardingRouter(params);
},
};
export default onboardingAPIModule;

View File

@ -0,0 +1 @@
export * from "./onboarding.di";

View File

@ -0,0 +1,8 @@
import type { IInitialOnboardingRepository } from "../../application/repositories/initial-onboarding-repository.interface";
import { InitialOnboardingProvisioner } from "../../application/services/initial-onboarding-provisioner";
export const buildOnboardingApplicationServices = (deps: {
repository: IInitialOnboardingRepository;
}) => ({
provisioner: new InitialOnboardingProvisioner(deps.repository),
});

View File

@ -0,0 +1,8 @@
import type { IInitialOnboardingRepository } from "../../application";
import { SequelizeInitialOnboardingRepository } from "../persistence/sequelize";
export type OnboardingPersistenceDependencies = { repository: IInitialOnboardingRepository };
export const buildOnboardingPersistence = (): OnboardingPersistenceDependencies => ({
repository: new SequelizeInitialOnboardingRepository(),
});

View File

@ -0,0 +1,27 @@
import type { IInitialCompanyCreator } from "@erp/companies/api";
import type { ITransactionManager } from "@erp/core/api";
import type { IInitialCompanyAdminAssigner } from "@erp/identity/api";
import { CompleteInitialOnboardingInputMapper } from "../../application/mappers/complete-initial-onboarding-input.mapper";
import { GetInitialOnboardingStatusUseCase } from "../../application/repositories/get-initial-onboarding-status.use-case";
import type { IInitialOnboardingRepository } from "../../application/repositories/initial-onboarding-repository.interface";
import { CompleteInitialOnboardingUseCase } from "../../application/use-cases/complete-initial-onboarding.use-case";
export const buildOnboardingUseCases = (deps: {
repository: IInitialOnboardingRepository;
transactionManager: ITransactionManager;
initialCompanyCreator: IInitialCompanyCreator;
initialCompanyAdminAssigner: IInitialCompanyAdminAssigner;
}) => {
const completeInitialOnboardingInputMapper = new CompleteInitialOnboardingInputMapper();
return {
getInitialOnboardingStatus: new GetInitialOnboardingStatusUseCase(deps.repository),
completeInitialOnboarding: new CompleteInitialOnboardingUseCase({
transactionManager: deps.transactionManager,
repository: deps.repository,
companyCreator: deps.initialCompanyCreator,
adminAssigner: deps.initialCompanyAdminAssigner,
dtoMapper: completeInitialOnboardingInputMapper,
}),
};
};

View File

@ -0,0 +1,25 @@
import type { IInitialCompanyAdminAssigner } from "@erp/identity/api";
import type { IInitialCompanyCreator } from "@erp/companies/api";
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import { buildOnboardingApplicationServices } from "./onboarding-application-services.di";
import { buildOnboardingPersistence } from "./onboarding-persistence.di";
import { buildOnboardingUseCases } from "./onboarding-use-cases.di";
import type { Sequelize } from "sequelize";
export type OnboardingInternalDeps = {
applicationServices: ReturnType<typeof buildOnboardingApplicationServices>;
useCases: ReturnType<typeof buildOnboardingUseCases>;
};
export const buildOnboardingDependencies = (params: ModuleParams): OnboardingInternalDeps => {
const persistence = buildOnboardingPersistence();
const companies = params.getService<{ initialCompanyCreator: IInitialCompanyCreator }>("companies:general");
const identity = params.getService<{ initialCompanyAdminAssigner: IInitialCompanyAdminAssigner }>("identity:general");
const useCases = buildOnboardingUseCases({
...persistence,
transactionManager: buildTransactionManager(params.database as Sequelize, params.transactionContext),
initialCompanyCreator: companies.initialCompanyCreator,
initialCompanyAdminAssigner: identity.initialCompanyAdminAssigner,
});
return { applicationServices: buildOnboardingApplicationServices(persistence), useCases };
};

View File

@ -0,0 +1,21 @@
import { ExpressController } from "@erp/core/api";
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
import type { CompleteInitialOnboardingUseCase } from "../../application";
export class CompleteInitialOnboardingController extends ExpressController {
constructor(private readonly useCase: CompleteInitialOnboardingUseCase) {
super();
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) return this.unauthorizedError("Unauthorized");
const dto = this.req.body as CompleteInitialOnboardingRequestDTO;
const result = await this.useCase.execute({ dto, accountId: user.userId });
return result.match(
(value) => this.ok({ company_id: value.companyId }),
(error) => this.handleError(error)
);
}
}

View File

@ -0,0 +1,22 @@
import { ExpressController } from "@erp/core/api";
import type { GetInitialOnboardingStatusUseCase } from "../../application";
export class GetInitialOnboardingStatusController extends ExpressController {
constructor(private readonly useCase: GetInitialOnboardingStatusUseCase) {
super();
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) {
return this.unauthorizedError("Unauthorized");
}
const result = await this.useCase.execute({ accountId: user.userId });
return result.match(
(value) => this.ok({ status: value.status, company_id: value.companyId }),
(error) => this.handleError(error)
);
}
}

View File

@ -0,0 +1 @@
export * from "./onboarding.routes";

View File

@ -0,0 +1,44 @@
import { type StartParams, requireAuthenticatedGuard, validateRequest } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import { CompleteInitialOnboardingRequestSchema } from "../../../common";
import type { OnboardingInternalDeps } from "../di";
import { CompleteInitialOnboardingController } from "./complete-initial-onboarding.controller";
import { GetInitialOnboardingStatusController } from "./get-initial-onboarding-status.controller";
export const onboardingRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
const deps = getInternal<OnboardingInternalDeps>("onboarding");
const router: Router = Router({ mergeParams: true });
// ----------------------------------------------
router.use(...requireIdentityTenant(params));
router.get(
"/initial",
requireAuthenticatedGuard(),
(req: Request, res: Response, next: NextFunction) => {
const controller = new GetInitialOnboardingStatusController(
deps.useCases.getInitialOnboardingStatus
);
return controller.execute(req, res, next);
}
);
router.post(
"/initial/complete",
requireAuthenticatedGuard(),
validateRequest(CompleteInitialOnboardingRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new CompleteInitialOnboardingController(
deps.useCases.completeInitialOnboarding
);
return controller.execute(req, res, next);
}
);
app.use(`${config.server.apiBasePath}/onboarding`, router);
};

View File

@ -0,0 +1,2 @@
export * from "./di";
export * from "./persistence/sequelize";

View File

@ -167,3 +167,47 @@ test("fallo de mapper en escritura evita consultar o escribir", async (t) => {
assert.equal(find.mock.callCount(), 0);
assert.equal(create.mock.callCount(), 0);
});
test("FOR UPDATE exige transaction sin consultar", async (t) => {
const find = t.mock.method(InitialOnboardingModel, "findOne", async () => null);
const result = await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(
accountId,
undefined
);
assert.equal(result.isFailure, true);
assert.equal(find.mock.callCount(), 0);
});
test("FOR UPDATE acota account y usa lock de escritura con mapper", async (t) => {
const transaction = {};
const find = t.mock.method(InitialOnboardingModel, "findOne", async () => row());
const result = await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(
accountId,
transaction
);
assert.equal(result.data.unwrap().accountId.toPrimitive(), accountId.toPrimitive());
assert.deepEqual(find.mock.calls[0].arguments[0], {
where: { account_id: accountId.toPrimitive() },
transaction,
lock: true,
});
});
test("FOR UPDATE ausencia", async (t) => {
t.mock.method(InitialOnboardingModel, "findOne", async () => null);
assert.equal(
(
await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(accountId, {})
).data.isNone(),
true
);
});
test("FOR UPDATE error traducido", async (t) => {
const error = new Error("read failure");
t.mock.method(InitialOnboardingModel, "findOne", async () => {
throw error;
});
assert.equal(
(await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(accountId, {}))
.error,
error
);
});

View File

@ -3,7 +3,7 @@ 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 { IInitialOnboardingRepository } from "../../../../application";
import type { InitialOnboarding } from "../../../../domain";
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
import { InitialOnboardingModel } from "../models";
@ -13,6 +13,24 @@ export class SequelizeInitialOnboardingRepository implements IInitialOnboardingR
private readonly domainMapper = new SequelizeInitialOnboardingDomainMapper()
) {}
public async findByAccountIdForUpdate(
accountId: UniqueID,
transaction: unknown
): Promise<Result<Maybe<InitialOnboarding>, Error>> {
if (!transaction) return Result.fail(new Error("Onboarding lock requires a transaction."));
try {
const row = await InitialOnboardingModel.findOne({
where: { account_id: accountId.toPrimitive() },
transaction: transaction as Transaction,
lock: true,
});
if (!row) return Result.ok(Maybe.none<InitialOnboarding>());
return this.domainMapper.mapToDomain(row).map(Maybe.some);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async findByAccountId(
accountId: UniqueID,
transaction?: unknown

View File

@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import initialOnboardingModelInit from "./infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model";
import { onboardingModule } from "./module";
test("onboarding composition builds public capabilities and registers its model", async () => {
let provisionCalls = 0;
const initialCompanyCreator = {
create: async () => {
throw new Error("not invoked");
},
};
const initialCompanyAdminAssigner = {
assign: async () => {
throw new Error("not invoked");
},
};
const result = await onboardingModule.setup?.({
database: {} as never,
transactionContext: {} as never,
getService: <T>(name: string) => {
if (name === "companies:general") return { initialCompanyCreator } as T;
if (name === "identity:general") return { initialCompanyAdminAssigner } as T;
throw new Error(`Unexpected service ${name}`);
},
} as never);
assert.ok(result?.services?.general);
assert.deepEqual(result.services.general, {});
assert.ok(result.internal);
assert.ok(result.models?.includes(initialOnboardingModelInit));
assert.equal(provisionCalls, 0);
});

View File

@ -0,0 +1,18 @@
import type { IModuleServer } from "@erp/core/api";
import type { IOnboardingPublicServices } from "./application/services/onboarding-public-services.interface";
import { buildOnboardingDependencies } from "./infrastructure/di";
import { models } from "./infrastructure/persistence/sequelize";
export type { IOnboardingPublicServices } from "./application/services/onboarding-public-services.interface";
export const onboardingModule: IModuleServer = {
name: "onboarding",
version: "1.0.0",
dependencies: ["companies", "identity"],
setup(params) {
const internal = buildOnboardingDependencies(params);
const general: IOnboardingPublicServices = {};
return { models, services: { general }, internal };
},
};

Some files were not shown because too many files have changed in this diff Show More