Compare commits

..

2 Commits

Author SHA1 Message Date
0cbb6fc175 feat: Add initial company creator and onboarding repository
- Implemented InitialCompanyCreator service for creating new companies with necessary details and validations.
- Added IInitialOnboardingRepository interface for onboarding data persistence.
- Created SequelizeInitialOnboardingRepository for managing onboarding data with Sequelize ORM.
- Enhanced InitialOnboarding aggregate to include createdAt and updatedAt timestamps.
- Updated tests for initial company creation and onboarding provisioning to ensure proper functionality and error handling.
- Introduced new tests for the Sequelize repository to validate data retrieval and persistence logic.
2026-09-07 18:55:41 +02:00
38fb6299b7 refactor: ajustar importaciones y tipos en varios archivos para mejorar la claridad y la consistencia 2026-09-07 18:55:11 +02:00
28 changed files with 1026 additions and 83 deletions

View File

@ -1,8 +1,8 @@
import type { ISequelizeTransactionContext } from "@erp/core/api";
import type { AuditEntry, AuditRecordOptions, IAuditStore } from "@repo/rdx-audit";
import { AuditStoreError } from "@repo/rdx-audit";
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
import { type AuditRecordOptions, AuditStoreError } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils";
import type { ModelStatic } from "sequelize";
import type { ModelStatic, Transaction } from "sequelize";
import type { AuditChangeModel } from "./audit-change.model";
import type { AuditEventModel } from "./audit-event.model";
@ -19,8 +19,12 @@ export class SequelizeAuditStore implements IAuditStore {
private readonly transactionContext: ISequelizeTransactionContext
) {}
async append(entry: AuditEntry, options?: AuditRecordOptions): Promise<Result<void, AuditStoreError>> {
const transaction = options?.transaction ?? this.transactionContext.current();
async append(
entry: AuditEntry,
options?: AuditRecordOptions
): Promise<Result<void, AuditStoreError>> {
const transaction = (options?.transaction as Transaction) ?? this.transactionContext.current();
if (entry.kind === "mutation" && !transaction) {
return Result.fail(
new AuditStoreError("An active transaction is required for audit mutations.")

View File

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

View File

@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DuplicateEntityError } from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import {
City,
CountryCode,
Name,
PostalAddress,
PostalCode,
Province,
Street,
TextValue,
UniqueID,
UtcDate,
} from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Company } from "../../../../domain";
import { CompanySlug } from "../../../../domain";
import type { ICompanyRepository } from "../../contracts";
import { CompanyCreator } from "../../services/company-creator";
import { InitialCompanyCreator } from "./initial-company-creator";
const address = () =>
PostalAddress.create({
street: Maybe.some(Street.create("Calle Mayor 1").data),
street2: Maybe.none(),
city: Maybe.some(City.create("Madrid").data),
postalCode: Maybe.some(PostalCode.create("28001").data),
province: Maybe.some(Province.create("Madrid").data),
countryCode: Maybe.some(CountryCode.create("ES").data),
}).data;
const input = () => ({
legalName: Name.create("Rodax").data,
tin: TextValue.create("B12345678").data,
address: address(),
});
function setup(error?: Error, duplicate = false) {
const saved: Company[] = [];
const transactions: unknown[] = [];
const repository: ICompanyRepository = {
save: async (company, transaction) => {
transactions.push(transaction);
if (error) return Result.fail(error);
saved.push(company);
return Result.ok();
},
update: async () => Result.ok(),
findById: async () => Result.ok(Maybe.none()),
findBySlug: async (_slug, transaction) => {
transactions.push(transaction);
return Result.ok(duplicate ? Maybe.some(saved[0]) : Maybe.none());
},
existsActiveById: async () => Result.ok(false),
findActiveByIds: async () => Result.ok(new Collection([])),
};
const audit: IAudit = {
record: async () => Result.ok(),
recordEvent: async () => Result.ok(),
recordMutation: async () => Result.ok(),
};
return {
capability: new InitialCompanyCreator(new CompanyCreator(repository, audit)),
saved,
transactions,
};
}
test("crea con id, slug, status y fechas internos y persiste dirección completa", async (t) => {
const now = UtcDate.createFromISO("2026-09-07").data;
t.mock.method(UtcDate, "today", () => now);
const { capability, saved } = setup();
const params = input();
const result = await capability.create(params);
assert.equal(result.isSuccess, true);
const company = saved[0];
assert.equal(UniqueID.create(result.data.companyId).isSuccess, true);
assert.equal(result.data.companyId, company.id.toPrimitive());
assert.equal(result.data.slug, `company-${company.id.toPrimitive()}`);
assert.equal(CompanySlug.create(result.data.slug).isSuccess, true);
assert.equal(company.status.isActive(), true);
assert.equal(company.createdAt, now);
assert.equal(company.updatedAt, now);
assert.equal(company.address.unwrap(), params.address);
assert.equal(company.tin.unwrap(), params.tin);
assert.deepEqual(Object.keys(result.data).sort(), ["companyId", "slug"]);
});
test("nombres iguales generan IDs y slugs distintos", async () => {
const { capability } = setup();
const first = await capability.create(input());
const second = await capability.create(input());
assert.equal(first.isSuccess, true);
assert.equal(second.isSuccess, true);
assert.notEqual(first.data.companyId, second.data.companyId);
assert.notEqual(first.data.slug, second.data.slug);
});
for (const field of ["street", "city", "postalCode", "province", "countryCode"] as const) {
test(`rechaza dirección sin ${field} antes de persistir`, async () => {
const { capability, saved, transactions } = setup();
const incomplete = PostalAddress.create({
...address().getProps(),
[field]: Maybe.none(),
}).data;
const result = await capability.create({ ...input(), address: incomplete });
assert.equal(result.isFailure, true);
assert.equal(saved.length, 0);
assert.equal(transactions.length, 0);
});
}
test("propaga transaction al chequeo de slug y save", async () => {
const { capability, transactions } = setup();
const transaction = {};
const result = await capability.create({ ...input(), transaction });
assert.equal(result.isSuccess, true);
assert.deepEqual(transactions, [transaction, transaction]);
});
for (const error of [
new Error("persistence failed"),
new DuplicateEntityError("Company", "slug", "duplicate"),
]) {
test(`propaga error repository ${error.name}`, async () => {
const { capability } = setup(error);
const result = await capability.create(input());
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
}
test("conflicto de slug conserva el rechazo de CompanyCreator", async () => {
const { capability, saved } = setup(undefined, true);
const first = setup();
await first.capability.create(input());
saved.push(first.saved[0]);
const result = await capability.create(input());
assert.equal(result.isFailure, true);
assert.equal(saved.length, 1);
});

View File

@ -0,0 +1,84 @@
import type {
EmailAddress,
Name,
PhoneNumber,
PostalAddress,
TextValue,
URLAddress,
} from "@repo/rdx-ddd";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanySlug, CompanyStatus } from "../../../../domain";
import type { ICompanyCreator } from "../../services/company-creator";
export interface CreateInitialCompanyParams {
legalName: Name;
tradeName?: Name;
tin: TextValue;
email?: EmailAddress;
phone?: PhoneNumber;
website?: URLAddress;
address: PostalAddress;
transaction?: unknown;
}
export interface InitialCompanyPublicModel {
companyId: string;
slug: string;
}
export interface IInitialCompanyCreator {
create(params: CreateInitialCompanyParams): Promise<Result<InitialCompanyPublicModel, Error>>;
}
export class InitialCompanyCreator implements IInitialCompanyCreator {
public constructor(private readonly creator: ICompanyCreator) {}
public async create(
params: CreateInitialCompanyParams
): Promise<Result<InitialCompanyPublicModel, Error>> {
const address = params.address;
if (
!address ||
[
address.street,
address.city,
address.postalCode,
address.province,
address.countryCode,
].some((part) => part.isNone())
) {
return Result.fail(new Error("Initial company requires a complete postal address."));
}
const id = UniqueID.generateNewID();
// El ID interno mantiene el slug estable y distingue empresas con el mismo nombre.
const slug = CompanySlug.create(`company-${id.toPrimitive()}`);
if (slug.isFailure) return Result.fail(slug.error);
const now = UtcDate.today();
const created = await this.creator.create({
id,
transaction: params.transaction,
props: {
legalName: params.legalName,
tradeName: Maybe.fromNullable(params.tradeName),
tin: Maybe.some(params.tin),
email: Maybe.fromNullable(params.email),
phone: Maybe.fromNullable(params.phone),
website: Maybe.fromNullable(params.website),
address: Maybe.some(address),
slug: slug.data,
status: CompanyStatus.createActive(),
defaultPaymentTermId: Maybe.none(),
createdAt: now,
updatedAt: now,
},
});
if (created.isFailure) return Result.fail(created.error);
return Result.ok({
companyId: created.data.id.toPrimitive(),
slug: created.data.slug.toPrimitive(),
});
}
}

View File

@ -1,7 +1,7 @@
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
import type { AuditEntry, AuditRecordOptions, IAuditStore } from "@repo/rdx-audit";
import { AuditStoreError } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils";
import type { ModelStatic } from "sequelize";
import type { ModelStatic, Transaction } from "sequelize";
import type { ISequelizeTransactionContext } from "../../persistence/sequelize";
@ -18,7 +18,7 @@ export class SequelizeAuditStore implements IAuditStore {
entry: AuditEntry,
options?: AuditRecordOptions
): Promise<Result<void, AuditStoreError>> {
const transaction = options?.transaction ?? this.transactionContext.current();
const transaction = (options?.transaction as Transaction) ?? this.transactionContext.current();
if (entry.kind === "mutation" && !transaction)
return Result.fail(
new AuditStoreError("An active transaction is required for audit mutations.")

View File

@ -1,11 +1,5 @@
// core/api/infrastructure/documents/certificates/
// env-company-signing-context-store.ts
import type {
ICompanyCertificateContext,
ISigningContextResolver,
} from "@erp/core/api/application";
import type { ModuleParams } from "@erp/core/api/modules";
import type { ICompanyCertificateContext, ISigningContextResolver } from "../../../application";
import type { ModuleParams } from "../../../modules";
import type { ICompanySigningContextRecord } from "./company-signing-context-record.interface";

View File

@ -1,5 +1,4 @@
import type { IDocumentSigningService } from "@erp/core/api/application";
import type { ICompanyCertificateContext } from "@erp/core/api/application/documents/application-models/company-certificate-context.interface";
import type { ICompanyCertificateContext, IDocumentSigningService } from "../../../application";
import type { RestDocumentSigningConfig } from "./rest-document-signing-config";
import { SimpleCircuitBreaker } from "./simple-circuit-breaker";

View File

@ -1,4 +1,4 @@
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "@erp/core/api/application";
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "../../../../application";
export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}

View File

@ -1,4 +1,4 @@
export * from "./module-server.interface";
export * from "./module-runtime";
export * from "./module-server.interface";
export * from "./modules-runtime";
export * from "./types";

View File

@ -1,11 +1,11 @@
import type { ILogger } from "@repo/rdx-logger";
import type { ModelStatic, Sequelize } from "sequelize";
import type { Model, ModelStatic, Sequelize } from "sequelize";
import type { IModuleServer } from "./module-server.interface";
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
import type { ModulesRuntime } from "./modules-runtime";
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
type ModelInitializer = (database: Sequelize) => ModelStatic;
type ModelInitializer = (database: Sequelize) => ModelStatic<Model>;
type ModelEntry = { initializer: ModelInitializer; moduleName: string };
const modules = new Map<string, IModuleServer>();
@ -29,13 +29,28 @@ export function registerModels(initializers: ModelInitializer[], moduleName: str
}
/** Inicializa únicamente infraestructura backend: setup, modelos y warmups. */
export async function initModules(params: ModuleParams & { logger: ILogger; database: Sequelize; warmup: { timeoutMs: number; strict: boolean }; databaseSyncMode: string }): Promise<ModulesRuntime> {
export async function initModules(
params: ModuleParams & {
logger: ILogger;
database: Sequelize;
warmup: { timeoutMs: number; strict: boolean };
databaseSyncMode: string;
}
): Promise<ModulesRuntime> {
for (const name of modules.keys()) await setupModule(name, params, []);
await initializeModels(params.database, params.databaseSyncMode, params.logger);
await warmupModules(params);
validateDependencies();
const customerInvoices = internals.get("customer-invoices") as { proformas: { recurringProformas: ModulesRuntime["customerInvoices"]["recurringProformas"] } } | undefined;
if (!customerInvoices) throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
const customerInvoices = internals.get("customer-invoices") as
| {
proformas: { recurringProformas: ModulesRuntime["customerInvoices"]["recurringProformas"] };
}
| undefined;
if (!customerInvoices)
throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
return { customerInvoices: customerInvoices.proformas.recurringProformas };
}
@ -44,71 +59,228 @@ export async function startModules(params: ModuleParams & { logger: ILogger }):
for (const name of setupOrder) {
const module = modules.get(name);
if (!module?.start) continue;
await module.start({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as StartParams);
await module.start({
...params,
getService: makeGetService(name, module),
getInternal: makeGetInternal(name),
listServices,
} as StartParams);
}
validateDependencies();
}
async function setupModule(name: string, params: ModuleParams & { logger: ILogger }, stack: string[]): Promise<void> {
if (setupDone.has(name)) return;
if (visiting.has(name)) throw new Error(`Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
async function setupModule(
name: string,
params: ModuleParams & { logger: ILogger },
stack: string[]
): Promise<void> {
if (setupDone.has(name)) {
return;
}
if (visiting.has(name)) {
throw new Error(`Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
}
const module = modules.get(name);
if (!module) throw new Error(`Module "${name}" not found`);
if (!module) {
throw new Error(`Module "${name}" not found`);
}
visiting.add(name);
for (const dependency of module.dependencies ?? []) await setupModule(dependency, params, [...stack, name]);
const result = (await module.setup?.({ ...params, getService: makeGetService(name, module), listServices })) as ModuleSetupResult | undefined;
for (const dependency of module.dependencies ?? []) {
await setupModule(dependency, params, [...stack, name]);
}
const result = (await module.setup?.({
...params,
getService: makeGetService(name, module),
listServices,
})) as ModuleSetupResult | undefined;
internals.set(name, result?.internal ?? {});
if (result?.models) registerModels(result.models as ModelInitializer[], name);
for (const [key, service] of Object.entries(result?.services ?? {})) registerService(`${name}:${key}`, service);
if (result?.models) {
registerModels(result.models as ModelInitializer[], name);
}
for (const [key, service] of Object.entries(result?.services ?? {})) {
registerService(`${name}:${key}`, service);
}
setupDone.add(name);
setupOrder.push(name);
visiting.delete(name);
}
async function initializeModels(database: Sequelize, syncMode: string, logger: ILogger): Promise<void> {
if (modelsInitialized) return;
const registered = new Map<string, { model: ModelStatic; moduleName: string }>();
async function initializeModels(
database: Sequelize,
syncMode: string,
logger: ILogger
): Promise<void> {
if (modelsInitialized) {
return;
}
const registered = new Map<string, { model: ModelStatic<Model>; moduleName: string }>();
for (const entry of models) {
const model = entry.initializer(database);
if (!model?.name) throw new Error(`Invalid model initializer from "${entry.moduleName}"`);
if (registered.has(model.name)) throw new Error(`Model name collision: "${model.name}"`);
if (!model?.name) {
throw new Error(`Invalid model initializer from "${entry.moduleName}"`);
}
if (registered.has(model.name)) {
throw new Error(`Model name collision: "${model.name}"`);
}
registered.set(model.name, { model, moduleName: entry.moduleName });
}
for (const { model } of registered.values()) {
const associate = model as ModelStatic & { associate?: (sequelize: Sequelize) => void };
const associate = model as ModelStatic<Model> & { associate?: (sequelize: Sequelize) => void };
associate.associate?.(database);
}
if (syncMode === "alter") await database.sync({ force: false, alter: true });
if (syncMode === "force") await database.sync({ force: true, alter: false });
logger.info("Backend module models initialized", { label: "moduleRuntime", modelCount: registered.size });
if (syncMode === "alter") {
await database.sync({ force: false, alter: true });
}
if (syncMode === "force") {
await database.sync({ force: true, alter: false });
}
logger.info("Backend module models initialized", {
label: "moduleRuntime",
modelCount: registered.size,
});
modelsInitialized = true;
}
async function warmupModules(params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }): Promise<void> {
async function warmupModules(
params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }
): Promise<void> {
for (const name of setupOrder) {
const module = modules.get(name);
if (!module?.warmup) continue;
try {
await withTimeout(Promise.resolve(module.warmup({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as WarmupParams)), params.warmup.timeoutMs, `${name}.warmup`);
await withTimeout(
Promise.resolve(
module.warmup({
...params,
getService: makeGetService(name, module),
getInternal: makeGetInternal(name),
listServices,
} as WarmupParams)
),
params.warmup.timeoutMs,
`${name}.warmup`
);
} catch (error) {
if (params.warmup.strict) throw error;
params.logger.warn("Module warmup failed but continuing", { label: "moduleRuntime", module: name, error });
params.logger.warn("Module warmup failed but continuing", {
label: "moduleRuntime",
module: name,
error,
});
}
}
}
function registerService(name: string, service: unknown): void { if (services[name]) throw new Error(`Service "${name}" already registered`); services[name] = service; }
function makeGetService(moduleName: string, module: IModuleServer) { return <T>(name: string): T => { const [dependency, ...key] = name.split(":"); if (dependency !== "self") { if (!(module.dependencies ?? []).includes(dependency)) throw new Error(`Module "${moduleName}" tried to access service "${name}" without declaring dependency on "${dependency}"`); trackDependency(moduleName, dependency); return getService<T>(name); } return getService<T>(`${moduleName}:${key.join(":")}`); }; }
function makeGetInternal(moduleName: string) { return <T>(requestedModule: string, key?: string): T => { if (requestedModule !== moduleName) throw new Error(`Module "${moduleName}" attempted to access private internal state`); const internal = internals.get(moduleName) ?? {}; return (key ? internal[key] : internal) as T; }; }
function getService<T>(name: string): T { const service = services[name]; if (!service) throw new Error(`Service "${name}" not found`); return service as T; }
function trackDependency(moduleName: string, dependency: string): void { const used = usedDependencies.get(moduleName) ?? new Set<string>(); used.add(dependency); usedDependencies.set(moduleName, used); }
function validateDependencies(): void { for (const [name, module] of modules) { const declared = new Set(module.dependencies ?? []); const used = usedDependencies.get(name) ?? new Set<string>(); const undeclared = [...used].filter((dependency) => !declared.has(dependency)); if (undeclared.length) throw new Error(`Module "${name}" used undeclared dependencies: ${undeclared.join(", ")}`); } }
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { let timeout: NodeJS.Timeout | undefined; const timeoutPromise = new Promise<T>((_, reject) => { timeout = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms (${label})`)), timeoutMs); }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timeout) clearTimeout(timeout); }) as Promise<T>; }
export function listServices(): string[] { return Object.keys(services); }
export function getServiceScoped<T>(requesterModule: string, allowedDependencies: readonly string[], name: string): T {
function registerService(name: string, service: unknown): void {
if (services[name]) throw new Error(`Service "${name}" already registered`);
services[name] = service;
}
function makeGetService(moduleName: string, module: IModuleServer) {
return <T>(name: string): T => {
const [dependency, ...key] = name.split(":");
if (dependency !== "self") {
if (!(module.dependencies ?? []).includes(dependency))
throw new Error(
`Module "${moduleName}" tried to access service "${name}" without declaring dependency on "${dependency}"`
);
trackDependency(moduleName, dependency);
return getService<T>(name);
}
return getService<T>(`${moduleName}:${key.join(":")}`);
};
}
function makeGetInternal(moduleName: string) {
return <T>(requestedModule: string, key?: string): T => {
if (requestedModule !== moduleName)
throw new Error(`Module "${moduleName}" attempted to access private internal state`);
const internal = internals.get(moduleName) ?? {};
return (key ? internal[key] : internal) as T;
};
}
function getService<T>(name: string): T {
const service = services[name];
if (!service) throw new Error(`Service "${name}" not found`);
return service as T;
}
function trackDependency(moduleName: string, dependency: string): void {
const used = usedDependencies.get(moduleName) ?? new Set<string>();
used.add(dependency);
usedDependencies.set(moduleName, used);
}
function validateDependencies(): void {
for (const [name, module] of modules) {
const declared = new Set(module.dependencies ?? []);
const used = usedDependencies.get(name) ?? new Set<string>();
const undeclared = [...used].filter((dependency) => !declared.has(dependency));
if (undeclared.length)
throw new Error(`Module "${name}" used undeclared dependencies: ${undeclared.join(", ")}`);
}
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<T>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`Timeout after ${timeoutMs}ms (${label})`)),
timeoutMs
);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeout) clearTimeout(timeout);
}) as Promise<T>;
}
export function listServices(): string[] {
return Object.keys(services);
}
export function getServiceScoped<T>(
requesterModule: string,
allowedDependencies: readonly string[],
name: string
): T {
const [moduleName, ...key] = name.split(":");
if (moduleName === "self") return getService<T>(`${requesterModule}:${key.join(":")}`);
if (!allowedDependencies.includes(moduleName)) throw new Error(`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`);
if (!allowedDependencies.includes(moduleName))
throw new Error(
`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`
);
return getService<T>(name);
}
export function resetModuleRuntimeForTests(): void { modules.clear(); models.length = 0; setupOrder.length = 0; setupDone.clear(); visiting.clear(); internals.clear(); usedDependencies.clear(); for (const key of Object.keys(services)) delete services[key]; modelsInitialized = false; }
export function resetModuleRuntimeForTests(): void {
modules.clear();
models.length = 0;
setupOrder.length = 0;
setupDone.clear();
visiting.clear();
internals.clear();
usedDependencies.clear();
for (const key of Object.keys(services)) {
delete services[key];
}
modelsInitialized = false;
}

View File

@ -8,10 +8,17 @@ export interface RecurringProformaOccurrenceProcessorCapability {
}
export interface RecurringProformaDiscoveryCapability {
findDue(dueThrough: Date, limit: number): Promise<{
findDue(
dueThrough: Date,
limit: number
): Promise<{
isFailure: boolean;
error: Error;
data: readonly { recurringProformaId: UniqueID; companyId: UniqueID; nextOccurrenceDate: Date }[];
data: readonly {
recurringProformaId: UniqueID;
companyId: UniqueID;
nextOccurrenceDate: Date;
}[];
}>;
}

View File

@ -1,8 +1,6 @@
import Dinero, { type Currency } from "dinero.js";
import type { MoneyDTO } from "../dto";
type DineroPlain = { amount: number; precision: number; currency: string };
//type DineroPlain = { amount: number; precision: number; currency: string };
const isEmptyMoneyDTO = (dto?: MoneyDTO | null) =>
!dto || dto.value?.trim?.() === "" || dto.scale?.trim?.() === "";
@ -149,40 +147,40 @@ export const MoneyDTOHelper = {
/**
* Convierte un DTO a una instancia Dinero.js.
*/
function dineroFromDTO(dto: MoneyDTO, fallbackCurrency = "EUR"): Dinero.Dinero {
/*function dineroFromDTO(dto: MoneyDTO, fallbackCurrency = "EUR"): Dinero.Dinero {
const n = normalizeDTO(dto, fallbackCurrency);
return Dinero({
amount: Number.parseInt(n.value, 10),
precision: Number.parseInt(n.scale, 10),
currency: n.currency_code as Currency,
});
}
}*/
/**
* Convierte una instancia Dinero a un DTO.
*/
function dtoFromDinero(d: Dinero.Dinero): MoneyDTO {
/*function dtoFromDinero(d: Dinero.Dinero): MoneyDTO {
const { amount, precision, currency } = d.toObject() as DineroPlain;
return {
value: amount.toString(),
scale: precision.toString(),
currency_code: currency,
};
}
}*/
/**
* Suma una lista de MoneyDTO.
*/
function sumDTO(list: MoneyDTO[], fallbackCurrency = "EUR"): MoneyDTO {
/*function sumDTO(list: MoneyDTO[], fallbackCurrency = "EUR"): MoneyDTO {
if (list.length === 0) return { value: "0", scale: "2", currency_code: fallbackCurrency };
const sum = list.map((x) => dineroFromDTO(x, fallbackCurrency)).reduce((a, b) => a.add(b));
return dtoFromDinero(sum);
}
}*/
/**
* Multiplica un MoneyDTO por un número.
*/
function multiplyDTO(
/*function multiplyDTO(
dto: MoneyDTO,
multiplier: number,
rounding: Dinero.RoundingMode = "HALF_EVEN",
@ -190,12 +188,12 @@ function multiplyDTO(
): MoneyDTO {
const d = dineroFromDTO(dto, fallbackCurrency).multiply(multiplier, rounding);
return dtoFromDinero(d);
}
}*/
/**
* Calcula un porcentaje de un MoneyDTO.
*/
function percentageDTO(
/*function percentageDTO(
dto: MoneyDTO,
percent: number,
rounding: Dinero.RoundingMode = "HALF_EVEN",
@ -203,4 +201,4 @@ function percentageDTO(
): MoneyDTO {
const d = dineroFromDTO(dto, fallbackCurrency).percentage(percent, rounding);
return dtoFromDinero(d);
}
}*/

View File

@ -0,0 +1,15 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { InitialOnboarding } from "../../domain";
export interface IInitialOnboardingRepository {
/** Devuelve ausencia explícita si la cuenta no tiene onboarding; no es un error. */
findByAccountId(
accountId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<InitialOnboarding>, Error>>;
/** Persiste el estado actual del agregado, tanto pending como completed. */
save(initialOnboarding: InitialOnboarding, transaction?: unknown): Promise<Result<void, Error>>;
}

View File

@ -0,0 +1,193 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DuplicateEntityError } from "@erp/core/api";
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InitialOnboarding } from "../../domain";
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
import { InitialOnboardingProvisioner } from "./initial-onboarding-provisioner";
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
const duplicate = () =>
new DuplicateEntityError("InitialOnboarding", "accountId", accountId.toPrimitive());
class FakeRepository implements IInitialOnboardingRepository {
public stored: InitialOnboarding | undefined;
public reads = 0;
public writes = 0;
public readError: Error | undefined;
public writeError: Error | undefined;
public winner: InitialOnboarding | undefined;
public rereadError: Error | undefined;
public async findByAccountId(id: UniqueID): Promise<Result<Maybe<InitialOnboarding>, Error>> {
assert.ok(id.equals(accountId));
this.reads++;
if (this.readError) return Result.fail(this.readError);
if (this.reads > 1 && this.rereadError) return Result.fail(this.rereadError);
return Result.ok(Maybe.fromNullable(this.stored));
}
public async save(value: InitialOnboarding): Promise<Result<void, Error>> {
this.writes++;
if (this.winner) this.stored = this.winner;
if (this.writeError) return Result.fail(this.writeError);
this.stored = value;
return Result.ok();
}
}
function existing(completed = false) {
const value = InitialOnboarding.createPending(accountId).data;
if (completed) {
const timestamp = UtcDateTime.createFromISO("2026-01-02T10:30:00.123Z").data;
assert.equal(
value.complete({
companyId: UniqueID.generateNewID(),
completedAt: timestamp,
updatedAt: timestamp,
}).isSuccess,
true
);
}
return value;
}
test("inexistente crea y guarda pending mediante el agregado", async () => {
const repository = new FakeRepository();
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
assert.equal(result.isSuccess, true);
assert.equal(result.data, repository.stored);
assert.equal(result.data.status, "pending");
assert.ok(result.data.accountId.equals(accountId));
assert.ok(result.data.createdAt.equals(result.data.updatedAt));
assert.ok(result.data.companyId.isNone());
assert.ok(result.data.completedAt.isNone());
assert.equal(repository.writes, 1);
});
for (const completed of [false, true]) {
test(`existente ${completed ? "completed" : "pending"} conserva identidad y estado sin save`, async () => {
const repository = new FakeRepository();
const value = existing(completed);
repository.stored = value;
const before = [value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt];
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
assert.equal(result.data, value);
assert.equal(result.data.status, completed ? "completed" : "pending");
assert.deepEqual(
[value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt],
before
);
assert.equal(repository.writes, 0);
});
}
test("error de find inicial se propaga sin save", async () => {
const repository = new FakeRepository();
repository.readError = new Error("read failed");
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
assert.equal(result.isFailure, true);
assert.equal(result.error, repository.readError);
assert.equal(repository.writes, 0);
});
test("error de save no duplicate se propaga sin releer", async () => {
const repository = new FakeRepository();
repository.writeError = new Error("write failed");
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
assert.equal(result.isFailure, true);
assert.equal(result.error, repository.writeError);
assert.equal(repository.reads, 1);
});
for (const completed of [false, true]) {
test(`duplicate devuelve ganador ${completed ? "completed" : "pending"} tras una relectura`, async () => {
const repository = new FakeRepository();
repository.writeError = duplicate();
repository.winner = existing(completed);
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
assert.equal(result.isSuccess, true);
assert.equal(result.data, repository.winner);
assert.equal(repository.reads, 2);
assert.equal(repository.writes, 1);
});
}
test("duplicate y relectura fallida propaga el error de lectura", async () => {
const repository = new FakeRepository();
repository.writeError = duplicate();
repository.rereadError = new Error("reread failed");
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
assert.equal(result.isFailure, true);
assert.equal(result.error, repository.rereadError);
assert.equal(repository.reads, 2);
});
test("duplicate y relectura none retorna error explícito con causa", async () => {
const repository = new FakeRepository();
repository.writeError = duplicate();
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
assert.equal(result.isFailure, true);
assert.equal(
result.error.message,
"Initial onboarding duplicate without a persisted onboarding for the account."
);
assert.equal(result.error.cause, repository.writeError);
assert.equal(repository.reads, 2);
assert.equal(repository.writes, 1);
});
test("provision repetido devuelve el mismo agregado y timestamps", async () => {
const repository = new FakeRepository();
const provisioner = new InitialOnboardingProvisioner(repository);
const first = await provisioner.provision(accountId);
const second = await provisioner.provision(accountId);
const third = await provisioner.provision(accountId);
assert.equal(first.isSuccess, true);
assert.equal(second.data, first.data);
assert.equal(third.data, first.data);
assert.equal(repository.writes, 1);
});
test("dos provision simultáneos con barrera devuelven un único ganador", async () => {
let release: () => void = () => {
throw new Error("Barrier not initialized");
};
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
let reads = 0;
let writes = 0;
let stored: InitialOnboarding | undefined;
const repository: IInitialOnboardingRepository = {
async findByAccountId(id) {
assert.ok(id.equals(accountId));
reads++;
if (reads <= 2) {
if (reads === 2) release();
await barrier;
return Result.ok(Maybe.none());
}
return Result.ok(Maybe.fromNullable(stored));
},
async save(value) {
writes++;
if (stored) return Result.fail(duplicate());
stored = value;
return Result.ok();
},
};
const provisioner = new InitialOnboardingProvisioner(repository);
const [first, second] = await Promise.all([
provisioner.provision(accountId),
provisioner.provision(accountId),
]);
assert.equal(first.isSuccess, true);
assert.equal(second.isSuccess, true);
assert.equal(first.data, stored);
assert.equal(second.data, stored);
assert.ok(first.data.id.equals(second.data.id));
assert.equal(writes, 2);
assert.equal(reads, 3);
});

View File

@ -0,0 +1,33 @@
import { isDuplicateEntityError } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InitialOnboarding } from "../../domain";
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
export class InitialOnboardingProvisioner {
public constructor(private readonly repository: IInitialOnboardingRepository) {}
/** Asegura que existe onboarding; conserva los estados pending y completed existentes. */
public async provision(accountId: UniqueID): Promise<Result<InitialOnboarding, Error>> {
const current = await this.repository.findByAccountId(accountId);
if (current.isFailure) return Result.fail(current.error);
if (current.data.isSome()) return Result.ok(current.data.unwrap());
const created = InitialOnboarding.createPending(accountId);
if (created.isFailure) return Result.fail(created.error);
const saved = await this.repository.save(created.data);
if (saved.isSuccess) return Result.ok(created.data);
if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error);
// UNIQUE es la garantía entre procesos. Solo recuperamos el ganador confirmado por cuenta.
const winner = await this.repository.findByAccountId(accountId);
if (winner.isFailure) return Result.fail(winner.error);
if (winner.data.isSome()) return Result.ok(winner.data.unwrap());
return Result.fail(
new Error("Initial onboarding duplicate without a persisted onboarding for the account.", {
cause: saved.error,
})
);
}
}

View File

@ -30,6 +30,8 @@ test("creates pending", () => {
check(r.data.companyId.isNone());
check(r.data.completedAt.isNone());
check(r.data.accountId.equals(accountId));
check(r.data.createdAt instanceof UtcDateTime);
check(r.data.updatedAt.equals(r.data.createdAt));
});
test("rehydrates valid states", () => {
check(InitialOnboarding.rehydrate(props("pending"), UniqueID.generateNewID()).isSuccess);
@ -64,7 +66,11 @@ test("rejects invalid state combinations", () => {
test("completes once", () => {
const o = InitialOnboarding.createPending(UniqueID.generateNewID()).data;
const id = UniqueID.generateNewID();
check(o.complete({ companyId: id, completedAt: date(), updatedAt: date() }).isSuccess);
const createdAt = o.createdAt;
const updatedAt = UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data;
check(o.complete({ companyId: id, completedAt: date(), updatedAt }).isSuccess);
check(o.createdAt.equals(createdAt));
check(o.updatedAt.equals(updatedAt));
check(o.status === "completed");
check(o.companyId.unwrap().equals(id));
check(o.completedAt.isSome());

View File

@ -1,4 +1,4 @@
import { AggregateRoot, UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
import { AggregateRoot, UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
export type InitialOnboardingStatus = "pending" | "completed";
@ -6,6 +6,8 @@ export interface InitialOnboardingProps {
accountId: UniqueID;
status: InitialOnboardingStatus;
companyId: Maybe<UniqueID>;
createdAt: UtcDateTime;
updatedAt: UtcDateTime;
completedAt: Maybe<UtcDateTime>;
}
export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
@ -20,11 +22,14 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
}
static createPending(accountId: UniqueID): Result<InitialOnboarding, Error> {
const now = UtcDateTime.now();
return InitialOnboarding.rehydrate(
{
accountId,
status: "pending",
companyId: Maybe.none(),
createdAt: now,
updatedAt: now,
completedAt: Maybe.none(),
},
UniqueID.generateNewID()
@ -61,6 +66,14 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
return Result.ok(undefined);
}
get createdAt(): UtcDateTime {
return this.props.createdAt;
}
get updatedAt(): UtcDateTime {
return this.props.updatedAt;
}
get accountId() {
return this.props.accountId;
}

View File

@ -2,5 +2,6 @@ import initialOnboardingModelInit from "./models/sequelize-initial-onboarding.mo
export * from "./mappers";
export * from "./models";
export * from "./repositories";
export const models = [initialOnboardingModelInit];

View File

@ -20,6 +20,8 @@ const mapper = new SequelizeInitialOnboardingDomainMapper();
const id = "11111111-1111-4111-8111-111111111111";
const accountId = "22222222-2222-4222-8222-222222222222";
const companyId = "33333333-3333-4333-8333-333333333333";
const createdAt = "2026-01-01T10:00:00.123Z";
const updatedAt = "2026-01-02T11:00:00.456Z";
const completedAt = "2026-01-02T10:30:00.789Z";
function aggregate(status: InitialOnboardingStatus) {
@ -30,6 +32,8 @@ function aggregate(status: InitialOnboardingStatus) {
companyId:
status === "completed" ? Maybe.some(UniqueID.create(companyId).data) : Maybe.none(),
createdAt: UtcDateTime.createFromISO(createdAt).data,
updatedAt: UtcDateTime.createFromISO(updatedAt).data,
completedAt:
status === "completed"
? Maybe.some(UtcDateTime.createFromISO(completedAt).data)
@ -48,6 +52,8 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
account_id: accountId,
status: "pending",
company_id: null,
created_at: new Date(createdAt),
updated_at: new Date(updatedAt),
completed_at: null,
...patch,
},
@ -57,13 +63,18 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
for (const status of ["pending", "completed"] as const) {
test(`toPersistence ${status}`, () => {
const result = mapper.mapToPersistence(aggregate(status));
const source = aggregate(status);
const result = mapper.mapToPersistence(source);
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data.created_at, source.createdAt.toDate());
assert.deepEqual(result.data.updated_at, source.updatedAt.toDate());
assert.deepEqual(result.data, {
id,
account_id: accountId,
status,
company_id: status === "completed" ? companyId : null,
created_at: new Date(createdAt),
updated_at: new Date(updatedAt),
completed_at: status === "completed" ? completedAt : null,
});
});
@ -73,6 +84,8 @@ for (const status of ["pending", "completed"] as const) {
row({
status,
company_id: status === "completed" ? companyId : null,
created_at: new Date(createdAt),
updated_at: new Date(updatedAt),
completed_at: status === "completed" ? completedAt : null,
})
);
@ -83,6 +96,8 @@ for (const status of ["pending", "completed"] as const) {
assert.equal(value.id.toPrimitive(), id);
assert.equal(value.accountId.toPrimitive(), accountId);
assert.equal(value.status, status);
assert.equal(value.createdAt.toISOString(), createdAt);
assert.equal(value.updatedAt.toISOString(), updatedAt);
assert.equal(value.companyId.isSome(), status === "completed");
assert.equal(value.completedAt.isSome(), status === "completed");
if (status === "completed") {
@ -113,10 +128,14 @@ test("toDomain accepts Sequelize Date timestamps and preserves UTC milliseconds"
row({
status: "completed",
company_id: companyId,
created_at: new Date(createdAt),
updated_at: new Date(updatedAt),
completed_at: new Date(completedAt),
})
);
assert.equal(result.isSuccess, true);
assert.equal(result.data.createdAt.toISOString(), createdAt);
assert.equal(result.data.updatedAt.toISOString(), updatedAt);
assert.equal(result.data.completedAt.unwrap().toISOString(), completedAt);
});
@ -133,8 +152,8 @@ test("toDomain propagates invalid identifiers and timestamps as failures", () =>
{ id: "invalid" },
{ account_id: "invalid" },
{ status: "completed", company_id: "invalid", completed_at: completedAt },
{ created_at: "invalid" },
{ updated_at: "invalid" },
{ created_at: new Date(Number.NaN) },
{ updated_at: new Date(Number.NaN) },
{ status: "completed", company_id: companyId, completed_at: "invalid" },
]) {
assert.equal(mapper.mapToDomain(row(patch)).isFailure, true);

View File

@ -32,6 +32,10 @@ export class SequelizeInitialOnboardingDomainMapper {
if (accountId.isFailure) return Result.fail(accountId.error);
const companyId = maybeFromNullableResult(source.company_id, (value) => UniqueID.create(value));
if (companyId.isFailure) return Result.fail(companyId.error);
const createdAt = toUtcDateTime(source.created_at);
if (createdAt.isFailure) return Result.fail(createdAt.error);
const updatedAt = toUtcDateTime(source.updated_at);
if (updatedAt.isFailure) return Result.fail(updatedAt.error);
const completedAt = maybeFromNullableResult(source.completed_at, (value) =>
toUtcDateTime(value)
);
@ -42,6 +46,8 @@ export class SequelizeInitialOnboardingDomainMapper {
accountId: accountId.data,
status: source.status,
companyId: companyId.data,
createdAt: createdAt.data,
updatedAt: updatedAt.data,
completedAt: completedAt.data,
},
id.data
@ -56,6 +62,8 @@ export class SequelizeInitialOnboardingDomainMapper {
account_id: source.accountId.toPrimitive(),
status: source.status,
company_id: maybeToNullable(source.companyId, (value) => value.toPrimitive()),
created_at: source.createdAt.toDate(),
updated_at: source.updatedAt.toDate(),
completed_at: maybeToNullable(source.completedAt, (value) => value.toISOString()),
});
}

View File

@ -12,7 +12,9 @@ initialOnboardingModelInit(database);
test("model declares table, nullability, UUIDs, timestamps and unique account without cross-module FKs", () => {
assert.equal(InitialOnboardingModel.getTableName(), "initial_onboardings");
assert.equal(InitialOnboardingModel.options.timestamps, false);
assert.equal(InitialOnboardingModel.options.timestamps, true);
assert.equal(InitialOnboardingModel.options.createdAt, "created_at");
assert.equal(InitialOnboardingModel.options.updatedAt, "updated_at");
const attributes = InitialOnboardingModel.getAttributes();
assert.deepEqual(Object.keys(attributes).sort(), [
"account_id",

View File

@ -1,4 +1,9 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import type {
CreationOptional,
InferAttributes,
InferCreationAttributes,
Sequelize,
} from "sequelize";
import { DataTypes, Model } from "sequelize";
export type InitialOnboardingCreationAttributes = InferCreationAttributes<InitialOnboardingModel>;
@ -11,6 +16,8 @@ export class InitialOnboardingModel extends Model<
declare account_id: string;
declare status: string;
declare company_id: string | null;
declare created_at: CreationOptional<Date>;
declare updated_at: CreationOptional<Date>;
declare completed_at: Date | string | null;
}
@ -26,6 +33,8 @@ export default (database: Sequelize) => {
validate: { isIn: [["pending", "completed"]] },
},
company_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null },
created_at: { type: DataTypes.DATE, allowNull: false },
updated_at: { type: DataTypes.DATE, allowNull: false },
completed_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
},
{
@ -36,6 +45,8 @@ export default (database: Sequelize) => {
collate: "utf8mb4_unicode_ci",
underscored: true,
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{
name: "uq_initial_onboardings_account_id",

View File

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

View File

@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DuplicateEntityError } from "@erp/core/api";
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Sequelize, UniqueConstraintError } from "sequelize";
import { InitialOnboarding } from "../../../../domain";
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
import initialOnboardingModelInit, {
InitialOnboardingModel,
} from "../models/sequelize-initial-onboarding.model";
import { SequelizeInitialOnboardingRepository } from "./sequelize-initial-onboarding.repository";
initialOnboardingModelInit(
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
);
const mapper = new SequelizeInitialOnboardingDomainMapper();
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
function aggregate(completed = false) {
return InitialOnboarding.rehydrate(
{
accountId,
status: completed ? "completed" : "pending",
companyId: completed
? Maybe.some(UniqueID.create("33333333-3333-4333-8333-333333333333").data)
: Maybe.none(),
createdAt: UtcDateTime.createFromISO("2026-01-01T10:00:00.123Z").data,
updatedAt: UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data,
completedAt: completed
? Maybe.some(UtcDateTime.createFromISO("2026-01-02T10:30:00.789Z").data)
: Maybe.none(),
},
UniqueID.create("11111111-1111-4111-8111-111111111111").data
).data;
}
function row(completed = false) {
return InitialOnboardingModel.build(mapper.mapToPersistence(aggregate(completed)).data, {
raw: true,
});
}
test("find existente usa filtro account_id, mapper y transaction", async (t) => {
const transaction = {};
const model = row();
const find = t.mock.method(InitialOnboardingModel, "findOne", async () => model);
const map = t.mock.method(mapper, "mapToDomain");
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(
accountId,
transaction
);
assert.equal(result.isSuccess, true);
assert.equal(result.data.isSome(), true);
assert.equal(result.data.unwrap().id.toPrimitive(), model.id);
assert.deepEqual(find.mock.calls[0].arguments[0], {
where: { account_id: accountId.toPrimitive() },
transaction,
});
assert.equal(map.mock.calls[0].arguments[0], model);
});
test("find ausente devuelve Maybe.none sin llamar al mapper", async (t) => {
t.mock.method(InitialOnboardingModel, "findOne", async () => null);
const map = t.mock.method(mapper, "mapToDomain");
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId);
assert.equal(result.isSuccess, true);
assert.equal(result.data.isNone(), true);
assert.equal(map.mock.callCount(), 0);
});
for (const completed of [false, true]) {
test(`save ${completed ? "completed" : "pending"} crea usando mapper y conserva timestamps y transaction`, async (t) => {
const source = aggregate(completed);
const expected = mapper.mapToPersistence(source).data;
const transaction = {};
const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null);
const create = t.mock.method(InitialOnboardingModel, "create", async () => row(completed));
const map = t.mock.method(mapper, "mapToPersistence");
const result = await new SequelizeInitialOnboardingRepository(mapper).save(source, transaction);
assert.equal(result.isSuccess, true);
assert.equal(map.mock.calls[0].arguments[0], source);
assert.deepEqual(find.mock.calls[0].arguments, [source.id.toPrimitive(), { transaction }]);
assert.deepEqual(create.mock.calls[0].arguments, [expected, { transaction, silent: true }]);
});
}
test("save repetido actualiza por id sin volver a insertar", async (t) => {
const source = aggregate(true);
const transaction = {};
let stored: InitialOnboardingModel | null = null;
t.mock.method(InitialOnboardingModel, "findByPk", async () => stored);
const create = t.mock.method(InitialOnboardingModel, "create", async () => {
stored = row(true);
return stored;
});
const update = t.mock.method(InitialOnboardingModel, "update", async () => [1]);
const repository = new SequelizeInitialOnboardingRepository();
assert.equal((await repository.save(source, transaction)).isSuccess, true);
assert.equal((await repository.save(source, transaction)).isSuccess, true);
assert.equal(create.mock.callCount(), 1);
assert.equal(update.mock.callCount(), 1);
const { id, ...values } = mapper.mapToPersistence(source).data;
assert.deepEqual(update.mock.calls[0].arguments, [
values,
{ transaction, silent: true, where: { id } },
]);
});
test("read error retorna Result.fail", async (t) => {
const error = new Error("read failed");
t.mock.method(InitialOnboardingModel, "findOne", async () => {
throw error;
});
const result = await new SequelizeInitialOnboardingRepository().findByAccountId(accountId);
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
for (const existing of [false, true]) {
test(`write error en ${existing ? "update" : "create"} retorna Result.fail`, async (t) => {
const error = new Error("write failed");
t.mock.method(InitialOnboardingModel, "findByPk", async () => (existing ? row() : null));
t.mock.method(InitialOnboardingModel, existing ? "update" : "create", async () => {
throw error;
});
const result = await new SequelizeInitialOnboardingRepository().save(aggregate());
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
}
test("unique account_id retorna fallo y no reintenta ni relee", async (t) => {
const error = new UniqueConstraintError({ message: "duplicate account_id" });
const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null);
const reread = t.mock.method(InitialOnboardingModel, "findOne", async () => row());
const create = t.mock.method(InitialOnboardingModel, "create", async () => {
throw error;
});
const result = await new SequelizeInitialOnboardingRepository().save(aggregate());
assert.equal(result.isFailure, true);
assert.ok(result.error instanceof DuplicateEntityError);
assert.equal(result.error.cause, error);
assert.equal(find.mock.callCount(), 1);
assert.equal(create.mock.callCount(), 1);
assert.equal(reread.mock.callCount(), 0);
});
test("fallo de mapper en lectura se propaga", async (t) => {
const error = new Error("mapping failed");
t.mock.method(InitialOnboardingModel, "findOne", async () => row());
t.mock.method(mapper, "mapToDomain", () => Result.fail(error));
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId);
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
});
test("fallo de mapper en escritura evita consultar o escribir", async (t) => {
const error = new Error("mapping failed");
t.mock.method(mapper, "mapToPersistence", () => Result.fail(error));
const find = t.mock.method(InitialOnboardingModel, "findByPk");
const create = t.mock.method(InitialOnboardingModel, "create");
const result = await new SequelizeInitialOnboardingRepository(mapper).save(aggregate());
assert.equal(result.isFailure, true);
assert.equal(result.error, error);
assert.equal(find.mock.callCount(), 0);
assert.equal(create.mock.callCount(), 0);
});

View File

@ -0,0 +1,58 @@
import { translateSequelizeError } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { IInitialOnboardingRepository } from "../../../../application/repositories/initial-onboarding-repository.interface";
import type { InitialOnboarding } from "../../../../domain";
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
import { InitialOnboardingModel } from "../models";
export class SequelizeInitialOnboardingRepository implements IInitialOnboardingRepository {
public constructor(
private readonly domainMapper = new SequelizeInitialOnboardingDomainMapper()
) {}
public async findByAccountId(
accountId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<InitialOnboarding>, Error>> {
try {
const row = await InitialOnboardingModel.findOne({
where: { account_id: accountId.toPrimitive() },
transaction: transaction as Transaction | undefined,
});
if (!row) return Result.ok(Maybe.none<InitialOnboarding>());
return this.domainMapper.mapToDomain(row).map((value) => Maybe.some(value));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
public async save(
initialOnboarding: InitialOnboarding,
transaction?: unknown
): Promise<Result<void, Error>> {
try {
const persistence = this.domainMapper.mapToPersistence(initialOnboarding);
if (persistence.isFailure) return Result.fail(persistence.error);
const sqlTransaction = transaction as Transaction | undefined;
const { id, ...values } = persistence.data;
// Comprobar por id evita que un upsert MySQL absorba una colisión de account_id.
const existing = await InitialOnboardingModel.findByPk(id, {
transaction: sqlTransaction,
});
// El agregado proporciona los timestamps; Sequelize no debe reemplazar updated_at.
const options = { transaction: sqlTransaction, silent: true };
if (existing) {
await InitialOnboardingModel.update(values, { ...options, where: { id } });
} else {
await InitialOnboardingModel.create(persistence.data, options);
}
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
}

View File

@ -1,5 +1,4 @@
export { Audit, type AuditDependencies } from "./audit";
export type { AuditArray, AuditObject, AuditScalar, AuditState, AuditValue } from "./audit-state.types";
export type {
AuditActor,
AuditChange,
@ -11,6 +10,7 @@ export type {
AuditMutationEntry,
AuditMutationTarget,
AuditOperation,
AuditRecordOptions,
AuditScope,
AuditSource,
IAudit,
@ -22,6 +22,13 @@ export type {
RecordAuditMutationInput,
RecordAuditMutationShortcutInput,
} from "./audit.types";
export type {
AuditArray,
AuditObject,
AuditScalar,
AuditState,
AuditValue,
} from "./audit-state.types";
export {
AuditContextUnavailableError,
AuditError,

View File

@ -75,7 +75,7 @@ export class CriteriaFromUrlConverter {
});
return Object.values(tempFilters).filter(
(filter) =>
(filter): filter is FiltersPrimitives =>
filter.field !== undefined &&
filter.operator !== undefined &&
(filter.value !== undefined ||

View File

@ -1 +1,4 @@
export type IAggregateRootRepository = {};
// biome-ignore lint/correctness/noUnusedVariables: <se necesita para definir la interfaz, aunque no se use directamente>
export type IAggregateRootRepository<T> = {
// Define the methods for the aggregate root repository
};