feat(companies): implement update current company use case and associated controllers

- Add UpdateCurrentCompanyUseCase to handle company updates.
- Create update-current-company.controller.ts to manage update requests.
- Implement validation and mapping for update requests using Zod.
- Add tests for the update use case and controller to ensure functionality.
- Introduce error handling and feedback state management for company details settings.
- Create utility functions for error normalization and feedback state resolution.
- Implement tests for error handling and feedback state utilities.
- Ensure nullable fields are handled correctly in DTOs and forms.
This commit is contained in:
David Arranz 2026-08-17 15:49:11 +02:00
parent cf07de4836
commit a4f82decc6
72 changed files with 3928 additions and 253 deletions

37
apps/server/.env.test Normal file
View File

@ -0,0 +1,37 @@
NODE_ENV=test
PORT=3002
API_BASE_PATH=/api/v1
FRONTEND_URL=http://localhost:5173
TRUST_PROXY=0
TIMEZONE=Europe/Madrid
DB_DIALECT=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=uecko_erp_http_test
DB_USER=rodax
DB_PASS=rodax
DB_LOGGING=false
DB_SSL=false
DB_SYNC_MODE=force
WARMUP_TIMEOUT_MS=10000
WARMUP_STRICT=false
JWT_SECRET=supersecretkeysupersecretkeysupersecretkey
JWT_ACCESS_EXPIRATION=1h
JWT_REFRESH_EXPIRATION=7d
TEMPLATES_PATH=/home/rodax/Documentos/uecko-erp/modules/
DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/documents
SIGNED_DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/signed-documents
FASTREPORT_BIN=/home/rodax/Documentos/uecko-erp/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator
SIGNING_SERVICE_URL=http://127.0.0.1:8000/documents/sign
SIGNING_SERVICE_METHOD=POST
SIGNING_SERVICE_TIMEOUT_MS=15000
SIGNING_SERVICE_MAX_RETRIES=2
COMPANY_CERTIFICATES_PATH=/home/rodax/Documentos/uecko-erp/apps/server/src/config/company-certificates.json

View File

@ -0,0 +1,37 @@
NODE_ENV=test
PORT=3002
API_BASE_PATH=/api/v1
FRONTEND_URL=http://localhost:5173
TRUST_PROXY=0
TIMEZONE=Europe/Madrid
DB_DIALECT=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=uecko_erp_http_test
DB_USER=rodax
DB_PASS=rodax
DB_LOGGING=false
DB_SSL=false
DB_SYNC_MODE=force
WARMUP_TIMEOUT_MS=10000
WARMUP_STRICT=false
JWT_SECRET=supersecretkeysupersecretkeysupersecretkey
JWT_ACCESS_EXPIRATION=1h
JWT_REFRESH_EXPIRATION=7d
TEMPLATES_PATH=/home/rodax/Documentos/uecko-erp/modules/
DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/documents
SIGNED_DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/signed-documents
FASTREPORT_BIN=/home/rodax/Documentos/uecko-erp/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator
SIGNING_SERVICE_URL=http://127.0.0.1:8000/documents/sign
SIGNING_SERVICE_METHOD=POST
SIGNING_SERVICE_TIMEOUT_MS=15000
SIGNING_SERVICE_MAX_RETRIES=2
COMPANY_CERTIFICATES_PATH=/home/rodax/Documentos/uecko-erp/apps/server/src/config/company-certificates.json

View File

@ -7,6 +7,8 @@
"start": "NODE_ENV=production node --env-file=.env.production dist/index.js",
"dev": "node --import=tsx --watch src/index.ts",
"clean": "rimraf .turbo node_modules dist",
"test": "pnpm run test:http",
"test:http": "find ./src/testing/http -name '*.test.ts' -print0 | xargs -0 node --import tsx --test",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
@ -76,4 +78,4 @@
"engines": {
"node": ">=22"
}
}
}

View File

@ -152,3 +152,13 @@ export const Config = (): ConfigType => {
}
return _config;
};
/**
* Reset explícito para tests de integración.
*
* Evita que varias suites reutilicen configuración cacheada cuando cargan
* `.env.test` con overrides distintos o reinician la base de datos.
*/
export const resetConfigForTests = (): void => {
_config = null;
};

View File

@ -1,2 +1,3 @@
export * from "./model-loader";
export * from "./module-loader";
export * from "./service-registry";

View File

@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { IModuleServer, ModuleParams } from "@erp/core/api";
import { resetModelsRegistry } from "./model-loader";
import { initModules, registerModule, resetModuleRegistry } from "./module-loader";
import { listServices, resetServiceRegistry } from "./service-registry";
type FakeDatabase = {
sync: () => Promise<void>;
models: Record<string, unknown>;
};
function buildModuleParams(): ModuleParams & {
config: { database: { syncMode: string } };
database: FakeDatabase;
logger: Console;
} {
return {
getService: () => {
throw new Error("Base params getService should not be called directly in tests.");
},
config: {
database: {
syncMode: "none",
},
},
database: {
sync: async () => undefined,
models: {},
},
logger: console,
};
}
function resetModuleTestState(): void {
resetServiceRegistry();
resetModuleRegistry();
resetModelsRegistry();
}
test("module-loader permite dependencias externas declaradas", async (t) => {
resetModuleTestState();
t.after(resetModuleTestState);
let receivedService = "";
const catalogsModule: IModuleServer = {
name: "catalogs",
version: "1.0.0",
setup: () => ({
services: {
general: {
name: "catalogs-service",
},
},
}),
};
const companiesModule: IModuleServer = {
name: "companies",
version: "1.0.0",
dependencies: ["catalogs"],
start: ({ getService }) => {
const service = getService<{ name: string }>("catalogs:general");
receivedService = service.name;
},
};
registerModule(catalogsModule);
registerModule(companiesModule);
await initModules(buildModuleParams());
assert.equal(receivedService, "catalogs-service");
assert.deepEqual(listServices().sort(), ["catalogs:general"]);
});
test("module-loader falla cuando una dependencia usada no está declarada", async (t) => {
resetModuleTestState();
t.after(resetModuleTestState);
registerModule({
name: "catalogs",
version: "1.0.0",
setup: () => ({
services: {
general: {
name: "catalogs-service",
},
},
}),
});
registerModule({
name: "companies",
version: "1.0.0",
start: ({ getService }) => {
getService("catalogs:general");
},
});
await assert.rejects(
() => initModules(buildModuleParams()),
/without declaring dependency on "catalogs"/i
);
});
test("module-loader sigue detectando ciclos reales entre módulos", async (t) => {
resetModuleTestState();
t.after(resetModuleTestState);
registerModule({
name: "companies",
version: "1.0.0",
dependencies: ["catalogs"],
});
registerModule({
name: "catalogs",
version: "1.0.0",
dependencies: ["companies"],
});
await assert.rejects(() => initModules(buildModuleParams()), /cyclic dependency detected/i);
});
test("module-loader rechaza la autoreferencia explícita por nombre de módulo", async (t) => {
resetModuleTestState();
t.after(resetModuleTestState);
registerModule({
name: "companies",
version: "1.0.0",
setup: () => ({
services: {
general: {
name: "companies-service",
},
},
}),
start: ({ getService }) => {
getService("companies:general");
},
});
await assert.rejects(
() => initModules(buildModuleParams()),
/without declaring dependency on "companies"/i
);
});

View File

@ -1,4 +1,11 @@
import type { IModuleServer, ModuleParams } from "@erp/core/api";
import type {
IModuleServer,
ModuleParams,
ModuleSetupResult,
SetupParams,
StartParams,
WarmupParams,
} from "@erp/core/api";
import { logger } from "../logger";
@ -37,6 +44,21 @@ export function registerModule(pkg: IModuleServer) {
logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" });
}
/**
* Reset del estado global del loader para entornos de test.
*
* En producción el proceso bootstrappea una sola vez. En tests HTTP necesitamos
* poder recrear app + módulos + servicios varias veces dentro del mismo proceso.
*/
export function resetModuleRegistry(): void {
registeredModules.clear();
setupDone.clear();
visiting.clear();
setupOrder.length = 0;
internalByModule.clear();
usedDependenciesByModule.clear();
}
/**
- Inicializa todos los módulos registrados (resolviendo dependencias),
- luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
@ -59,15 +81,15 @@ export async function initModules(params: ModuleParams) {
// - start() ≈ init() en el contrato nuevo
// - setup() ≈ registerDependencies() en el contrato nuevo
await withPhase(name, "start", async () => {
const startParams: StartParams = {
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices,
};
// Aquí YA se permite getService/getInternal
await Promise.resolve(
pkg.start?.({
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices, // opcional: útil en debugging
} as any)
);
await Promise.resolve(pkg.start?.(startParams));
});
}
@ -105,36 +127,40 @@ async function setupModule(name: string, params: ModuleParams, stack: string[])
// 2) setup phase (contrato nuevo) = registerDependencies (en tu código actual)
const pkgApi = await withPhase(name, "setup", async () => {
return await Promise.resolve(
pkg.setup?.({
...params,
// En setup se permite getService para wiring.
getService: makeGetService(name, pkg),
listServices,
} as any)
);
const setupParams: SetupParams = {
...params,
getService: makeGetService(name, pkg),
listServices,
};
return await Promise.resolve(pkg.setup?.(setupParams));
});
const moduleSetupResult: ModuleSetupResult | undefined = pkgApi;
// 3) internal store
if (pkgApi?.internal) {
internalByModule.set(name, pkgApi.internal as Record<string, unknown>);
if (moduleSetupResult?.internal) {
internalByModule.set(name, moduleSetupResult.internal);
} else {
internalByModule.set(name, {});
}
// 4) models
if (pkgApi?.models) {
if (moduleSetupResult?.models) {
const models = moduleSetupResult.models;
await withPhase(name, "registerModels", async () => {
await Promise.resolve(registerModels(pkgApi.models!, params, { moduleName: name }));
await Promise.resolve(registerModels(models, params, { moduleName: name }));
});
}
// 5) services (namespaced)
if (pkgApi?.services) {
await withPhase(name, "registerServices", async () => {
validateModuleServices(name, pkgApi.services!);
if (moduleSetupResult?.services) {
const services = moduleSetupResult.services;
for (const [serviceKey, serviceApi] of Object.entries(pkgApi.services!)) {
await withPhase(name, "registerServices", async () => {
validateModuleServices(name, services);
for (const [serviceKey, serviceApi] of Object.entries(services)) {
const fullName = buildServiceName(name, serviceKey);
registerService(fullName, serviceApi);
}
@ -249,24 +275,19 @@ async function warmupModules(params: ModuleParams) {
const pkg = registeredModules.get(name);
if (!pkg?.warmup) continue;
// biome-ignore lint/suspicious/noExplicitAny: <Para que funione>
const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise<void> | void);
const maybeWarmup = pkg.warmup;
if (typeof maybeWarmup !== "function") continue;
try {
const warmupParams: WarmupParams = {
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices,
};
await withPhase(name, "warmup", () =>
withTimeout(
Promise.resolve(
maybeWarmup({
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices,
})
),
WARMUP_TIMEOUT_MS,
`${name}.warmup`
)
withTimeout(Promise.resolve(maybeWarmup(warmupParams)), WARMUP_TIMEOUT_MS, `${name}.warmup`)
);
} catch (error) {
if (WARMUP_STRICT) {

View File

@ -67,3 +67,15 @@ export function hasService(name: string): boolean {
export function listServices(): string[] {
return Object.keys(services);
}
/**
* Limpia el registry entre arranques de test.
*
* El runtime productivo arranca una sola vez, pero los tests HTTP necesitan
* reinicializar módulos y servicios sin contaminar el siguiente caso.
*/
export function resetServiceRegistry(): void {
for (const name of Object.keys(services)) {
delete services[name];
}
}

View File

@ -0,0 +1,468 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import {
buildCompanyHeaders,
createTestAccount,
createTestCompany,
createTestMembership,
createTestPaymentTerm,
findCompanyRow,
loginByApi,
} from "./http-test-fixtures.ts";
import { createHttpTestRuntime } from "./http-test-runtime.ts";
type ProblemDetails = {
title?: string;
detail?: string;
status?: number;
errors?: Array<{ field?: string; message?: string }>;
};
type LoginResponseBody = {
access_token: string;
};
type CompanyResponseBody = {
id: string;
legal_name: string;
trade_name: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
default_payment_term_id: string | null;
status: "active" | "disabled";
};
test("GET /api/v1/companies/current devuelve la company activa del miembro autenticado", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createTestAccount(runtime.database, {
email: `account-${randomUUID()}@example.com`,
password: "Secret123!",
});
const company = await createTestCompany(runtime.database, {
legalName: "Acme Legal SL",
tradeName: "Acme Trade",
tin: "B12345678",
email: "billing@acme.test",
phone: "+34910000000",
website: "https://acme.test",
});
await createTestMembership(runtime.database, {
accountId: account.id,
companyId: company.id,
});
const loginResponse = await loginByApi({
request: runtime.request,
email: account.email,
password: account.password,
});
assert.equal(loginResponse.status, 200);
const response = await runtime.request("/api/v1/companies/current", {
headers: buildCompanyHeaders({
accessToken: getLoginBody(loginResponse.body).access_token,
companyId: company.id,
}),
});
assert.equal(response.status, 200);
assert.deepEqual(response.body, {
id: company.id,
legal_name: "Acme Legal SL",
trade_name: "Acme Trade",
tin: "B12345678",
slug: company.slug,
email: "billing@acme.test",
phone: "+34910000000",
website: "https://acme.test",
default_payment_term_id: null,
status: "active",
} satisfies CompanyResponseBody);
});
test("GET /api/v1/companies/current devuelve 401 sin autenticación", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const response = await runtime.request("/api/v1/companies/current", {
headers: buildCompanyHeaders({
companyId: randomUUID(),
}),
});
assert.equal(response.status, 401);
assert.equal(getProblemDetails(response.body).detail, "Unauthorized");
});
test("GET /api/v1/companies/current devuelve 403 cuando falta X-Company-Id", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime);
const response = await runtime.request("/api/v1/companies/current", {
headers: {
authorization: `Bearer ${session.accessToken}`,
},
});
assert.equal(response.status, 403);
assert.equal(getProblemDetails(response.body).detail, "Company context required");
});
test("GET /api/v1/companies/current devuelve 403 cuando la membership no está activa", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime, {
membershipStatus: "disabled",
});
const response = await runtime.request("/api/v1/companies/current", {
headers: buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
});
assert.equal(response.status, 403);
assert.equal(getProblemDetails(response.body).detail, "Company context required");
});
test("PATCH /api/v1/companies/current actualiza un subconjunto de campos e ignora claves ajenas al schema", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime, {
company: {
legalName: "Original Legal Name",
tradeName: "Original Trade Name",
tin: "B12345678",
email: "old@example.com",
phone: "+34911111111",
website: "https://old.example.com",
slug: "original-slug",
},
});
const response = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
"content-type": "application/json",
},
body: JSON.stringify({
legal_name: "Updated Legal Name",
email: "new@example.com",
unexpected_flag: true,
slug: "should-not-change",
status: "disabled",
}),
});
assert.equal(response.status, 200);
assert.deepEqual(response.body, {
id: session.company.id,
legal_name: "Updated Legal Name",
trade_name: "Original Trade Name",
tin: "B12345678",
slug: "original-slug",
email: "new@example.com",
phone: "+34911111111",
website: "https://old.example.com",
default_payment_term_id: null,
status: "active",
} satisfies CompanyResponseBody);
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
assert.ok(persistedCompany);
assert.equal(persistedCompany.legal_name, "Updated Legal Name");
assert.equal(persistedCompany.email, "new@example.com");
assert.equal(persistedCompany.trade_name, "Original Trade Name");
assert.equal(persistedCompany.slug, "original-slug");
assert.equal(persistedCompany.status, "active");
});
test("PATCH /api/v1/companies/current permite limpiar trade_name con null explícito", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime, {
company: {
legalName: "Acme Legal SL",
tradeName: "Acme Trade",
},
});
const response = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
"content-type": "application/json",
},
body: JSON.stringify({
trade_name: null,
}),
});
assert.equal(response.status, 200);
assert.equal(getCompanyBody(response.body).trade_name, null);
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
assert.ok(persistedCompany);
assert.equal(persistedCompany.trade_name, null);
});
test("PATCH /api/v1/companies/current asigna y desasigna default_payment_term_id dentro de la misma company", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime);
const paymentTerm = await createTestPaymentTerm(runtime.database, {
companyId: session.company.id,
name: "30 días",
});
const assignResponse = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
"content-type": "application/json",
},
body: JSON.stringify({
default_payment_term_id: paymentTerm.id,
}),
});
assert.equal(assignResponse.status, 200);
assert.equal(getCompanyBody(assignResponse.body).default_payment_term_id, paymentTerm.id);
const unassignResponse = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
"content-type": "application/json",
},
body: JSON.stringify({
default_payment_term_id: null,
}),
});
assert.equal(unassignResponse.status, 200);
assert.equal(getCompanyBody(unassignResponse.body).default_payment_term_id, null);
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
assert.ok(persistedCompany);
assert.equal(persistedCompany.default_payment_term_id, null);
});
test("PATCH /api/v1/companies/current devuelve 422 cuando default_payment_term_id pertenece a otra company", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime, {
company: {
legalName: "Original Legal Name",
},
});
const otherCompany = await createTestCompany(runtime.database, {
legalName: "Other Company SL",
});
const otherCompanyPaymentTerm = await createTestPaymentTerm(runtime.database, {
companyId: otherCompany.id,
name: "60 días",
});
const response = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
"content-type": "application/json",
},
body: JSON.stringify({
legal_name: "Should Not Persist",
default_payment_term_id: otherCompanyPaymentTerm.id,
}),
});
assert.equal(response.status, 422);
assert.equal(
getProblemDetails(response.body).detail,
"Default payment term is not assignable to the active company."
);
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
assert.ok(persistedCompany);
assert.equal(persistedCompany.legal_name, "Original Legal Name");
assert.equal(persistedCompany.default_payment_term_id, null);
});
test("PATCH /api/v1/companies/current devuelve 422 cuando default_payment_term_id está inactivo", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime, {
company: {
legalName: "Original Legal Name",
},
});
const inactivePaymentTerm = await createTestPaymentTerm(runtime.database, {
companyId: session.company.id,
name: "90 días",
isActive: false,
});
const response = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
"content-type": "application/json",
},
body: JSON.stringify({
legal_name: "Should Not Persist",
default_payment_term_id: inactivePaymentTerm.id,
}),
});
assert.equal(response.status, 422);
assert.equal(
getProblemDetails(response.body).detail,
"Default payment term is not assignable to the active company."
);
const persistedCompany = await findCompanyRow(runtime.database, session.company.id);
assert.ok(persistedCompany);
assert.equal(persistedCompany.legal_name, "Original Legal Name");
assert.equal(persistedCompany.default_payment_term_id, null);
});
test("PATCH /api/v1/companies/current devuelve 422 cuando el email es inválido", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const session = await createAuthenticatedSession(runtime);
const response = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({
accessToken: session.accessToken,
companyId: session.company.id,
}),
"content-type": "application/json",
},
body: JSON.stringify({
email: "not-an-email",
}),
});
assert.equal(response.status, 422);
const body = getProblemDetails(response.body);
assert.equal(body.title, "Validation Failed");
assert.ok(Array.isArray(body.errors));
assert.ok(body.errors.some((error) => error.field === "email"));
});
async function createAuthenticatedSession(
runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>,
options?: {
membershipStatus?: "active" | "disabled" | "invited";
company?: {
legalName?: string;
tradeName?: string | null;
tin?: string | null;
slug?: string;
email?: string | null;
phone?: string | null;
website?: string | null;
status?: "active" | "disabled";
};
}
) {
const account = await createTestAccount(runtime.database, {
email: `account-${randomUUID()}@example.com`,
password: "Secret123!",
});
const company = await createTestCompany(runtime.database, {
legalName: options?.company?.legalName ?? "Acme Legal SL",
tradeName: options?.company?.tradeName,
tin: options?.company?.tin ?? null,
slug: options?.company?.slug,
email: options?.company?.email ?? null,
phone: options?.company?.phone ?? null,
website: options?.company?.website ?? null,
status: options?.company?.status ?? "active",
});
await createTestMembership(runtime.database, {
accountId: account.id,
companyId: company.id,
status: options?.membershipStatus ?? "active",
});
const loginResponse = await loginByApi({
request: runtime.request,
email: account.email,
password: account.password,
});
assert.equal(loginResponse.status, 200);
return {
account,
company,
accessToken: getLoginBody(loginResponse.body).access_token,
};
}
function getProblemDetails(body: unknown): ProblemDetails {
assert.equal(typeof body, "object");
assert.notEqual(body, null);
return body as ProblemDetails;
}
function getLoginBody(body: unknown): LoginResponseBody {
assert.equal(typeof body, "object");
assert.notEqual(body, null);
return body as LoginResponseBody;
}
function getCompanyBody(body: unknown): CompanyResponseBody {
assert.equal(typeof body, "object");
assert.notEqual(body, null);
return body as CompanyResponseBody;
}

View File

@ -0,0 +1,233 @@
import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt";
import type { QueryInterface, Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
type CreateAccountParams = {
id?: string;
email: string;
password: string;
name?: string;
status?: "active" | "disabled";
languageCode?: string;
};
type CreateCompanyParams = {
id?: string;
legalName: string;
tradeName?: string | null;
tin?: string | null;
slug?: string;
email?: string | null;
phone?: string | null;
website?: string | null;
defaultPaymentTermId?: string | null;
status?: "active" | "disabled";
};
type CreateMembershipParams = {
id?: string;
accountId: string;
companyId: string;
status?: "active" | "disabled" | "invited";
};
type CreatePaymentTermParams = {
id?: string;
companyId: string;
name: string;
description?: string | null;
isActive?: boolean;
isSystem?: boolean;
dueRules?: Array<{
dueDays: number;
percentageValue: number;
percentageScale?: number;
}>;
};
export async function createTestAccount(database: Sequelize, params: CreateAccountParams) {
const queryInterface = database.getQueryInterface();
const now = new Date();
const id = params.id ?? randomUUID();
const passwordHash = await bcrypt.hash(params.password, 10);
await queryInterface.bulkInsert("identity_accounts", [
{
id,
email: params.email,
password_hash: passwordHash,
name: params.name ?? "Test Account",
avatar_url: null,
language_code: params.languageCode ?? "es",
status: params.status ?? "active",
created_at: now,
updated_at: now,
},
]);
return {
id,
email: params.email,
password: params.password,
};
}
export async function createTestCompany(database: Sequelize, params: CreateCompanyParams) {
const queryInterface = database.getQueryInterface();
const now = new Date();
const id = params.id ?? randomUUID();
await queryInterface.bulkInsert("companies", [
{
id,
legal_name: params.legalName,
trade_name: params.tradeName ?? null,
tin: params.tin ?? null,
slug: params.slug ?? `company-${id.slice(0, 8)}`,
email: params.email ?? null,
phone: params.phone ?? null,
website: params.website ?? null,
default_payment_term_id: params.defaultPaymentTermId ?? null,
status: params.status ?? "active",
created_at: now,
updated_at: now,
},
]);
return {
id,
slug: params.slug ?? `company-${id.slice(0, 8)}`,
};
}
export async function createTestMembership(database: Sequelize, params: CreateMembershipParams) {
const queryInterface = database.getQueryInterface();
const now = new Date();
const id = params.id ?? randomUUID();
await queryInterface.bulkInsert("identity_company_memberships", [
{
id,
account_id: params.accountId,
company_id: params.companyId,
status: params.status ?? "active",
created_at: now,
updated_at: now,
},
]);
return {
id,
};
}
export async function createTestPaymentTerm(database: Sequelize, params: CreatePaymentTermParams) {
const queryInterface = database.getQueryInterface();
const now = new Date();
const id = params.id ?? randomUUID();
const dueRules = params.dueRules ?? [
{
dueDays: 30,
percentageValue: 10000,
percentageScale: 2,
},
];
await queryInterface.bulkInsert("payment_terms", [
{
id,
company_id: params.companyId,
name: params.name,
description: params.description ?? null,
is_active: params.isActive ?? true,
is_system: params.isSystem ?? false,
created_at: now,
updated_at: now,
deleted_at: null,
},
]);
await queryInterface.bulkInsert(
"payment_term_due_rules",
dueRules.map((rule) => ({
due_id: randomUUID(),
payment_term_id: id,
due_days: rule.dueDays,
percentage_value: rule.percentageValue,
percentage_scale: rule.percentageScale ?? 2,
created_at: now,
updated_at: now,
}))
);
return {
id,
};
}
export async function findCompanyRow(database: Sequelize, companyId: string) {
const rows = await database.query<{
id: string;
legal_name: string;
trade_name: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
default_payment_term_id: string | null;
status: string;
}>("SELECT * FROM companies WHERE id = :companyId", {
replacements: { companyId },
type: QueryTypes.SELECT,
});
return rows[0] ?? null;
}
export async function loginByApi(params: {
request: (
path: string,
init?: RequestInit
) => Promise<{
status: number;
headers: Headers;
body: unknown;
}>;
email: string;
password: string;
}) {
return params.request("/api/v1/identity/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
email: params.email,
password: params.password,
}),
});
}
export function buildCompanyHeaders(params: {
accessToken?: string;
companyId?: string;
}): Record<string, string> {
const headers: Record<string, string> = {};
if (params.accessToken) {
headers.authorization = `Bearer ${params.accessToken}`;
}
if (params.companyId) {
headers["x-company-id"] = params.companyId;
}
return headers;
}
export function getQueryInterface(database: Sequelize): QueryInterface {
return database.getQueryInterface();
}

View File

@ -0,0 +1,222 @@
import { randomUUID } from "node:crypto";
import { type Server, createServer } from "node:http";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import mysql from "mysql2/promise";
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
import { createApp } from "@/app.ts";
import { closeDatabase, tryConnectToDatabase } from "@/config/database.ts";
import type { ConfigType } from "@/config/index.ts";
import { initConfig, resetConfigForTests } from "@/config/index.ts";
import { registerHealthRoutes } from "@/health.ts";
import { logger } from "@/lib/logger.ts";
import {
initModules,
resetModelsRegistry,
resetModuleRegistry,
resetServiceRegistry,
} from "@/lib/modules";
import { registerModules } from "@/register-modules.ts";
const currentDir = dirname(fileURLToPath(import.meta.url));
const TEST_ENV_FILE = resolve(currentDir, "../../../.env.test");
const FORBIDDEN_DATABASE_NAMES = new Set(["uecko_erp", "uecko_erp_sync", "rodax_db"]);
export interface IHttpTestRuntime {
readonly baseUrl: string;
readonly config: ConfigType;
readonly database: Sequelize;
request(
path: string,
init?: RequestInit
): Promise<{
status: number;
headers: Headers;
body: unknown;
}>;
stop(): Promise<void>;
}
/**
* Crea un runtime HTTP real contra MariaDB de test.
*
* La infraestructura reutiliza:
* - Express real
* - middlewares reales
* - módulos reales
* - Sequelize real
* - login real
*/
export async function createHttpTestRuntime(): Promise<IHttpTestRuntime> {
loadTestEnvironment();
assignUniqueTestDatabaseName();
resetServerRuntimeForTests();
z.config(z.locales.es());
const config = initConfig(logger);
assertSafeTestConfig(config);
await recreateTestDatabase(config);
const database = await tryConnectToDatabase(config);
const app = createApp(config);
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: true }));
let server: Server | null = null;
try {
registerModules();
await initModules({
app,
config,
database,
logger,
});
server = createServer(app);
await listen(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("HTTP test server did not expose an ephemeral TCP port.");
}
const baseUrl = `http://127.0.0.1:${address.port}`;
return {
baseUrl,
config,
database,
request: async (path: string, init?: RequestInit) => {
const response = await fetch(`${baseUrl}${path}`, init);
const contentType = response.headers.get("content-type") ?? "";
const body = contentType.includes("json") ? await response.json() : await response.text();
return {
status: response.status,
headers: response.headers,
body,
};
},
stop: async () => {
if (server) {
await closeServer(server);
}
await closeDatabase(database);
resetServerRuntimeForTests();
},
};
} catch (error) {
if (server) {
await closeServer(server);
}
await closeDatabase(database);
resetServerRuntimeForTests();
throw error;
}
}
function loadTestEnvironment(): void {
const result = dotenv.config({
path: TEST_ENV_FILE,
override: true,
});
if (result.error) {
throw new Error(`Unable to load test environment file: ${TEST_ENV_FILE}`);
}
}
function resetServerRuntimeForTests(): void {
resetConfigForTests();
resetServiceRegistry();
resetModuleRegistry();
resetModelsRegistry();
}
function assignUniqueTestDatabaseName(): void {
const configuredName = process.env.DB_NAME?.trim();
if (!configuredName) {
throw new Error("HTTP integration tests require DB_NAME in .env.test.");
}
process.env.DB_NAME = `${configuredName}_${randomUUID().replaceAll("-", "")}`;
}
function assertSafeTestConfig(config: ConfigType): void {
if (!config.flags.isTest) {
throw new Error("HTTP integration tests require NODE_ENV=test.");
}
const databaseName = config.database.name.trim().toLowerCase();
if (!databaseName.includes("test")) {
throw new Error(
`Refusing to run HTTP integration tests against DB "${config.database.name}" because it does not look like a test database.`
);
}
if (FORBIDDEN_DATABASE_NAMES.has(databaseName)) {
throw new Error(
`Refusing to run HTTP integration tests against forbidden DB "${config.database.name}".`
);
}
}
async function recreateTestDatabase(config: ConfigType): Promise<void> {
const databaseName = config.database.name.trim();
if (!/^[a-zA-Z0-9_]+$/.test(databaseName)) {
throw new Error(`Unsafe test database name "${databaseName}".`);
}
const connection = await mysql.createConnection({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
multipleStatements: false,
});
try {
await connection.query(`DROP DATABASE IF EXISTS \`${databaseName}\``);
await connection.query(
`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
);
} finally {
await connection.end();
}
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.listen(0, "127.0.0.1", (error?: Error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}

View File

@ -52,6 +52,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
- [README de `modules/companies`](../modules/companies/README.md)
- [README de `modules/auth`](../modules/auth/README.md)
- [API pública de selección y alta rápida de clientes](./customers/customer-selection-and-quick-create.md)
- [Tests HTTP backend](./testing/backend-http-integration.md)
#### Guías y estándares

View File

@ -62,6 +62,7 @@ slug
email
phone
website
default_payment_term_id
status
created_at
updated_at
@ -134,6 +135,22 @@ GET /companies/available
Authorization: Bearer <access_token>
```
Para settings de empresa activa, los endpoints vigentes son:
```http
GET /companies/current
PATCH /companies/current
```
Esos endpoints resuelven la empresa exclusivamente desde:
```txt
X-Company-Id
+ requireIdentityTenant(params)
```
No desde `companyId` enviado por body, query o params arbitrarios.
Tampoco debe documentarse ni introducirse `GET /account/companies` como variante válida.
## Dependencias permitidas
@ -176,3 +193,22 @@ membership active en identity
+
company active en companies
```
## Autorización V1 de edición de company
En la V1 actual:
```txt
cualquier miembro activo de la company
puede editar sus datos generales
```
Esto aplica a:
```http
PATCH /companies/current
```
No debe documentarse como permiso granular real.
`identity:companies:update` puede existir como código futuro del catálogo de permisos, pero no representa enforcement runtime hoy.

View File

@ -39,6 +39,32 @@ POST /companies/:company_id/enable
POST /companies/:company_id/disable
```
### Backend tenant-scoped en `modules/companies`
`modules/companies` también expone endpoints tenant-scoped para operar sobre la company activa o endurecer endpoints legacy.
Esto aplica a:
```http
GET /companies/current
PATCH /companies/current
GET /companies/:company_id
PATCH /companies/:company_id
POST /companies/:company_id/enable
POST /companies/:company_id/disable
```
Política V1 actual para `PATCH /companies/current`:
```txt
usuario autenticado
+ X-Company-Id válido
+ membership activa
+ company activa
```
No existe todavía enforcement real de `identity:companies:update`.
## Frontend actual
El runtime nuevo ya usa `@erp/identity/client`.

View File

@ -51,6 +51,15 @@ Actualmente ya valida:
6. company `active` en `companies`
7. solo entonces rellena `req.user.companyId`
Esto significa que hoy el contexto tenant garantiza:
```txt
membership activa
+ company activa
```
pero no enforcement de permisos granulares por operación.
## Semántica esperada de errores
```txt
@ -88,6 +97,7 @@ No añadir:
Actualmente usan `requireIdentityTenant(params)`:
- `modules/companies` para `GET /companies/current`, `PATCH /companies/current` y endpoints company-scoped legacy
- `modules/catalogs`
- `modules/customers`
- `modules/customer-invoices`
@ -106,6 +116,28 @@ El access token no debe incluir:
El contexto tenant se resuelve por request con `X-Company-Id`.
## Caso actual: company settings
`/settings/company/details` opera siempre sobre la company activa.
Endpoints:
```http
GET /companies/current
PATCH /companies/current
```
Política V1:
```txt
usuario autenticado
+ X-Company-Id válido
+ membership activa en esa company
+ company activa
```
No se aplica todavía un permiso específico como `identity:companies:update`.
## Document context
Si un flujo documental necesita `companySlug`, debe resolverlo desde `companies`, por ejemplo:

View File

@ -58,6 +58,16 @@ Ejemplos previstos:
Una configuración situada bajo `/settings` debe operar sobre la empresa activa resuelta por la infraestructura global de identidad.
Caso ya implementado:
```txt
/settings/company/details
-> GET /companies/current
-> PATCH /companies/current
```
La pantalla no selecciona el target mediante `companyId`.
### 3.2 `/account`
`/account` queda reservado para configuración personal del usuario.
@ -168,6 +178,99 @@ reglas de negocio
UI específica
```
En `company/details`, los campos editables actuales son:
```txt
legal_name
trade_name
tin
email
phone
website
default_payment_term_id
```
La UI actual de esta pantalla se organiza en:
```txt
Datos generales
→ legal_name
→ trade_name
→ tin
Datos de contacto
→ email
→ phone
→ website
Condiciones comerciales
→ default_payment_term_id
```
Semántica de guardado esperada para esta pantalla:
```txt
PATCH parcial
→ solo viajan los campos dirty
campo omitido
→ no modificar
campo nullable enviado como null
→ limpiar valor
```
La pantalla sigue operando exclusivamente contra:
```txt
GET /companies/current
PATCH /companies/current
```
sin usar `companyId` como selector del target.
Tras un guardado correcto, la respuesta del servidor debe seguir siendo la fuente única de verdad:
```txt
response
→ set/reset del formulario
→ sincronización de active company visible
```
Política de errores consolidada para esta pantalla:
```txt
error inicial sin datos requeridos
→ PageErrorState
error de refetch con datos previos
→ mantener contenido
→ ErrorAlert inline
cancelled
→ no elevar a pantalla bloqueante
422 de backend con path mapeable
→ error de campo en RHF
error general de mutation
→ feedback local
```
Fuera de alcance del formulario actual:
```txt
slug
status
fiscalidad avanzada
VeriFactu
document series
payment methods
branding
bank accounts
defaultPaymentMethodId
```
El hecho de que una Page aparezca dentro de `/settings` no cambia su arquitectura interna.
Flujo obligatorio:

View File

@ -0,0 +1,130 @@
# Tests HTTP backend
Esta guía pone en marcha un entorno real de integración HTTP para el backend del ERP usando:
- Express real
- módulos reales
- middleware real de auth y tenant
- MariaDB real
- Sequelize real
## Estado actual
La suite vive en:
```txt
apps/server/src/testing/http
```
Script disponible:
```bash
cd /home/rodax/Documentos/uecko-erp
pnpm --filter @erp/factuges-server test:http
```
## Entorno requerido
El runtime carga:
```txt
apps/server/.env.test
```
Contrato mínimo:
- `NODE_ENV=test`
- `DB_NAME` debe contener `test`
- la base se recrea en cada arranque de runtime
- el usuario configurado debe poder hacer `CREATE DATABASE` y `DROP DATABASE`
Para evitar colisiones entre suites o casos concurrentes:
- el runtime genera un nombre de base efímero a partir de `DB_NAME`
- cada test trabaja sobre una base distinta
## Runtime reutilizable
Pieza principal:
```txt
apps/server/src/testing/http/http-test-runtime.ts
```
Responsabilidades:
- cargar `.env.test`
- resetear registries globales del server
- validar configuración segura de test
- recrear la base de datos
- arrancar la app real en un puerto efímero
- exponer `request()` y `stop()`
## Fixtures actuales
Helpers disponibles:
```txt
apps/server/src/testing/http/http-test-fixtures.ts
```
Cobertura inicial:
- cuentas de identity
- companies
- memberships account-company
- payment terms
- login real por API
- consulta directa a la fila persistida de `companies`
## Suite piloto actual
Archivo:
```txt
apps/server/src/testing/http/companies-current.http.test.ts
```
Cobertura inicial confirmada:
- `GET /api/v1/companies/current`
- `PATCH /api/v1/companies/current`
- 401 sin auth
- 403 sin `X-Company-Id`
- 403 con membership inactiva
- update parcial
- limpieza explícita con `null`
- asignación y desasignación de `default_payment_term_id`
- `default_payment_term_id` cross-company => `422`
- `default_payment_term_id` inactivo => `422`
- rollback transaccional cuando falla la asignación de `default_payment_term_id`
- 422 por payload inválido
## Convenciones prácticas
- usar login real en vez de fabricar JWTs
- sembrar solo los datos mínimos del caso
- cerrar siempre el runtime con `stop()`
- no compartir base de datos entre tests
- validar tanto respuesta HTTP como persistencia cuando la mutación lo requiera
## Contrato de `default_payment_term_id`
Semántica actual:
```txt
omitido -> conservar
null -> desvincular
UUID activo de la misma company -> asignar
UUID no asignable -> 422
```
Se considera no asignable:
- un `PaymentTerm` de otra company
- un `PaymentTerm` inactivo
Además, el `PATCH /companies/current` es transaccional:
- si falla `default_payment_term_id`
- no se persisten otros cambios del mismo request

View File

@ -129,7 +129,7 @@
}
},
"paymentTerms": {
"title": "Plazos de pago",
"title": "Vencimientos",
"description": "Gestiona las plantillas de vencimientos disponibles para la empresa.",
"toolbar": {
"searchPlaceholder": "Buscar por nombre o descripción…",
@ -139,8 +139,8 @@
"active": "Activos",
"inactive": "Inactivos"
},
"totalCount_one": "{{count}} plazo en total",
"totalCount_other": "{{count}} plazos en total"
"totalCount_one": "{{count}} vencimiento en total",
"totalCount_other": "{{count}} vencimientos en total"
},
"list": {
"rulesLabel": "Vencimientos"
@ -159,13 +159,13 @@
},
"fields": {
"name": "Nombre",
"nameDescription": "Nombre visible del plazo de pago.",
"nameDescription": "Nombre visible del vencimiento.",
"namePlaceholder": "Ej. 30/60/90",
"description": "Descripción",
"descriptionDescription": "Información adicional opcional para uso interno.",
"descriptionPlaceholder": "Añade una descripción opcional",
"active": "Activo",
"activeDescription": "Controla si el plazo de pago aparece como disponible.",
"activeDescription": "Controla si el vencimiento aparece como disponible.",
"dueDays": "Días",
"dueDaysDescription": "Días desde la fecha base del documento.",
"dueDaysPlaceholder": "0",
@ -199,46 +199,46 @@
"system": "Sistema"
},
"create": {
"title": "Nuevo plazo de pago",
"title": "Nuevo vencimiento",
"description": "Crea una nueva plantilla de vencimientos para esta empresa."
},
"edit": {
"title": "Editar plazo de pago",
"description": "Actualiza la información y los vencimientos del plazo de pago seleccionado."
"title": "Editar vencimiento",
"description": "Actualiza la información y los vencimientos del elemento seleccionado."
},
"delete": {
"title": "Eliminar plazo de pago",
"description": "Este plazo dejará de estar disponible. Si está siendo utilizado, la operación no se completará.",
"errorTitle": "No se pudo eliminar el plazo de pago",
"inUse": "No se puede eliminar este plazo de pago porque está siendo utilizado."
"title": "Eliminar vencimiento",
"description": "Este vencimiento dejará de estar disponible. Si está siendo utilizado, la operación no se completará.",
"errorTitle": "No se pudo eliminar el vencimiento",
"inUse": "No se puede eliminar este vencimiento porque está siendo utilizado."
},
"empty": {
"title": "No hay plazos de pago configurados.",
"description": "Cuando existan plazos de pago disponibles aparecerán aquí."
"title": "No hay vencimientos configurados.",
"description": "Cuando existan vencimientos disponibles aparecerán aquí."
},
"emptyFiltered": {
"title": "No se encontraron plazos con los criterios actuales.",
"description": "Prueba con otra búsqueda o limpia el filtro para volver a ver todos los plazos.",
"title": "No se encontraron vencimientos con los criterios actuales.",
"description": "Prueba con otra búsqueda o limpia el filtro para volver a ver todos los vencimientos.",
"clear": "Limpiar búsqueda y filtro"
},
"messages": {
"createdTitle": "Plazo de pago creado",
"created": "El plazo de pago se ha creado correctamente.",
"updatedTitle": "Plazo de pago actualizado",
"createdTitle": "Vencimiento creado",
"created": "El vencimiento se ha creado correctamente.",
"updatedTitle": "Vencimiento actualizado",
"updated": "Los cambios se han guardado correctamente.",
"enabledTitle": "Plazo de pago activado",
"enabled": "El plazo vuelve a estar disponible.",
"disabledTitle": "Plazo de pago desactivado",
"disabled": "El plazo ha dejado de estar disponible.",
"deletedTitle": "Plazo de pago eliminado",
"deleted": "El plazo de pago ha dejado de estar disponible.",
"enabledTitle": "Vencimiento activado",
"enabled": "El vencimiento vuelve a estar disponible.",
"disabledTitle": "Vencimiento desactivado",
"disabled": "El vencimiento ha dejado de estar disponible.",
"deletedTitle": "Vencimiento eliminado",
"deleted": "El vencimiento ha dejado de estar disponible.",
"validationTitle": "Revisa los campos",
"validationDescription": "Hay errores de validación en el formulario."
},
"protections": {
"systemEdit": "Los plazos del sistema no se pueden editar.",
"systemDisable": "Los plazos del sistema no se pueden desactivar.",
"systemDelete": "Los plazos del sistema no se pueden eliminar."
"systemEdit": "Los vencimientos del sistema no se pueden editar.",
"systemDisable": "Los vencimientos del sistema no se pueden desactivar.",
"systemDelete": "Los vencimientos del sistema no se pueden eliminar."
},
"form": {
"rules": {
@ -259,7 +259,7 @@
},
"forbidden": {
"title": "No tienes acceso a esta configuración",
"description": "No dispones de permisos para consultar los plazos de pago."
"description": "No dispones de permisos para consultar los vencimientos."
},
"notFound": {
"title": "No se pudo cargar la configuración",
@ -279,11 +279,11 @@
},
"server": {
"title": "No se pudo cargar la configuración",
"description": "Se produjo un error interno al obtener los plazos de pago."
"description": "Se produjo un error interno al obtener los vencimientos."
},
"unknown": {
"title": "No se pudo cargar la configuración",
"description": "Se produjo un error inesperado al obtener los plazos de pago."
"description": "Se produjo un error inesperado al obtener los vencimientos."
}
}
},

View File

@ -8,6 +8,7 @@ Gestiona la entidad `Company` funcional del ERP:
- datos legales
- datos operativos básicos
- `defaultPaymentTermId`
- `slug`
- `status`
@ -29,6 +30,7 @@ slug
email
phone
website
default_payment_term_id
status
created_at
updated_at
@ -63,12 +65,228 @@ No debe asumir:
## Dependencias
`modules/companies` depende de `identity`.
`modules/companies` depende de `identity` y `catalogs`.
Uso actual:
- sus endpoints usan `requireIdentityAuthenticated(params)`
- `GET /companies/available` no usa `requireIdentityTenant(params)`
- `GET /companies/current` y `PATCH /companies/current` usan auth de `identity` + `requireCompanyContext(...)` cableado con `companyAccessChecker` de `identity` y `publicFinder` propio de `companies`
- los endpoints legacy por `:company_id` siguen tenant-scoped y comparan `req.user.companyId` con `:company_id`
## Endpoints relevantes
```http
POST /companies
GET /companies/available
GET /companies/current
PATCH /companies/current
GET /companies/:company_id
PATCH /companies/:company_id
POST /companies/:company_id/enable
POST /companies/:company_id/disable
```
### Settings de empresa activa
`/settings/company/details` consume:
```http
GET /companies/current
PATCH /companies/current
```
Resolución real del target:
```txt
Authorization
+ X-Company-Id
+ requireCompanyContext(...)
=> req.user.companyId
```
El cliente no selecciona la empresa a editar mediante `companyId` en URL, body ni query.
## Política V1 de autorización
En la V1 cualquier miembro activo de la compañía puede editar sus datos generales mediante:
```http
PATCH /companies/current
```
Política real:
```txt
usuario autenticado
+ X-Company-Id válido
+ membership activa en esa company
+ company activa
```
No existe todavía enforcement real de RBAC ni permisos granulares en runtime.
`identity:companies:update` puede existir como código futuro de catálogo, pero no representa autorización aplicada hoy.
## Campos editables actuales
`PATCH /companies/current` admite solo:
```txt
legal_name
trade_name
tin
email
phone
website
default_payment_term_id
```
En frontend, la feature:
```txt
/settings/company/details
```
modela esos mismos campos como ficha editable de la company activa.
La UI actual agrupa la edición en tres secciones:
```txt
Datos generales
→ legalName
→ tradeName
→ tin
Datos de contacto
→ email
→ phone
→ website
Condiciones comerciales
→ defaultPaymentTermId
```
Semántica PATCH esperada en la pantalla:
```txt
propiedad omitida -> conservar valor actual
propiedad presente con valor -> actualizar
propiedad nullable presente con null -> limpiar
```
Flujo de guardado esperado en frontend:
```txt
GET /companies/current
→ hydrata formulario
editar campos dirty
→ construir PATCH parcial
PATCH /companies/current
→ respuesta servidor como SSOT
→ updateActiveCompany(...)
→ reset del formulario con la respuesta
```
Política de errores frontend en esta pantalla:
```txt
fallo inicial sin datos utilizables
→ PageErrorState
error de refetch con datos previos
→ mantener formulario
→ ErrorAlert inline
cancelled
→ sin pantalla bloqueante
422 con paths mapeables
→ errores de campo en RHF
error general de guardado
→ toast
→ sin reset del formulario
```
La UI no debe seleccionar el target mediante `companyId`.
Fuera de alcance de esta API:
```txt
slug
status
fiscalidad avanzada
VeriFactu
document series
payment methods
branding
bank accounts
defaultPaymentMethodId
```
## `defaultPaymentTermId`
Contrato actual:
```txt
omitido -> mantener
null -> desvincular
UUID -> validar dentro de la company activa
```
La referencia es:
```txt
nullable
company-scoped
validada mediante catalogs
```
Semántica HTTP actual:
```txt
UUID activo de la misma company -> 200
UUID de otra company -> 422
UUID inactivo -> 422
```
El error de asignación es deliberadamente opaco:
```txt
Default payment term is not assignable to the active company.
```
No expone si el problema concreto es:
- inexistente
- cross-company
- inactivo
El PATCH es transaccional:
- si falla `default_payment_term_id`
- no se persisten otros cambios del mismo request
## Limitaciones V1 de `Company Details`
Fuera del alcance funcional actual:
- RBAC granular
- address
- country
- language
- currency
- timezone
- branding
- logo
- bank accounts
- fiscalidad avanzada
- VeriFactu
- series documentales
- `defaultPaymentMethodId`
## Reglas

View File

@ -2,6 +2,8 @@ import type { IPaymentTermPublicFinder } from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { CompanyDefaultPaymentTermNotAssignableError } from "../../../domain";
export interface ICompanyPaymentTermResolver {
ensureAssignable(params: {
companyId: UniqueID;
@ -43,11 +45,19 @@ export class CompanyPaymentTermResolver implements ICompanyPaymentTermResolver {
}
if (result.data.isNone()) {
return Result.fail(new Error("Default payment term not found in current company."));
return Result.fail(
new CompanyDefaultPaymentTermNotAssignableError(
"Default payment term is not assignable to the active company."
)
);
}
if (!result.data.unwrap().isActive) {
return Result.fail(new Error("Default payment term must be active."));
return Result.fail(
new CompanyDefaultPaymentTermNotAssignableError(
"Default payment term is not assignable to the active company."
)
);
}
return Result.ok(undefined);

View File

@ -0,0 +1,63 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO } from "../../../../common";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyFinder } from "../services";
export class GetCurrentCompanyUseCase {
public constructor(
private readonly deps: {
finder: ICompanyFinder;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
) {}
public execute(params: { companyId: string }): Promise<Result<CompanyResponseDTO, Error>> {
const companyIdResult = UniqueID.create(params.companyId);
if (companyIdResult.isFailure) {
return Promise.resolve(Result.fail(companyIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const companyResult = await this.deps.finder.findCompanyById(companyIdResult.data, transaction);
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(companyResult.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
id: model.id,
legal_name: model.legalName,
trade_name: model.tradeName,
tin: model.tin,
slug: model.slug,
email: model.email,
phone: model.phone,
website: model.website,
default_payment_term_id: model.defaultPaymentTermId,
status: model.status,
};
}

View File

@ -3,4 +3,6 @@ export * from "./disable-company.use-case";
export * from "./enable-company.use-case";
export * from "./find-available-companies-for-account.use-case";
export * from "./get-company-by-id.use-case";
export * from "./get-current-company.use-case";
export * from "./update-current-company.use-case";
export * from "./update-company.use-case";

View File

@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ITransactionManager } from "@erp/core/api";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDate,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
import { CompanyPublicModelMapper } from "../public";
import { UpdateCurrentCompanyUseCase } from "./update-current-company.use-case";
const buildTransactionManager = (): ITransactionManager => ({
start: async () => undefined,
getTransaction: () => undefined,
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
commit: async () => undefined,
rollback: async () => undefined,
});
const buildCompany = () => {
const now = UtcDate.createFromISO("2026-08-16");
const id = UniqueID.generateNewID();
const legalName = Name.create("Rodax Labs");
const tradeName = Name.create("Rodax");
const slug = CompanySlug.create("rodax-labs");
const email = EmailAddress.create("hello@rodax.test");
const phone = PhoneNumber.create("+34911111111");
const website = URLAddress.create("https://rodax.test");
const tin = TextValue.create("B12345678");
assert.equal(now.isSuccess, true);
assert.equal(legalName.isSuccess, true);
assert.equal(tradeName.isSuccess, true);
assert.equal(slug.isSuccess, true);
assert.equal(email.isSuccess, true);
assert.equal(phone.isSuccess, true);
assert.equal(website.isSuccess, true);
assert.equal(tin.isSuccess, true);
const companyResult = Company.create(
{
legalName: legalName.data,
tradeName: Maybe.some(tradeName.data),
tin: Maybe.some(tin.data),
slug: slug.data,
email: Maybe.some(email.data),
phone: Maybe.some(phone.data),
website: Maybe.some(website.data),
defaultPaymentTermId: Maybe.some(UniqueID.generateNewID()),
status: CompanyStatus.createActive(),
createdAt: now.data,
updatedAt: now.data,
},
id
);
assert.equal(companyResult.isSuccess, true);
return companyResult.data;
};
test("update current company permite limpiar el default payment term con null", async () => {
const company = buildCompany();
let receivedDefaultPaymentTermId: Maybe<UniqueID> | undefined;
let receivedId: Maybe<UniqueID> | undefined;
const useCase = new UpdateCurrentCompanyUseCase({
updater: {
update: async ({ patchProps }) => {
receivedDefaultPaymentTermId = patchProps.defaultPaymentTermId;
const updateResult = company.update(patchProps);
assert.equal(updateResult.isSuccess, true);
return Result.ok(company);
},
},
paymentTermResolver: {
ensureAssignable: async ({ id }) => {
receivedId = id;
return Result.ok(undefined);
},
},
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
companyId: company.id.toPrimitive(),
dto: {
default_payment_term_id: null,
},
});
assert.equal(result.isSuccess, true);
if (receivedDefaultPaymentTermId === undefined) {
throw new Error("Expected defaultPaymentTermId patch to be captured.");
}
assert.equal(receivedDefaultPaymentTermId.isNone(), true);
assert.equal(receivedId?.isNone(), true);
assert.equal(result.data.default_payment_term_id, null);
});
test("update current company no permite cambiar el slug", async () => {
const company = buildCompany();
let receivedPatch: Record<string, unknown> | null = null;
const useCase = new UpdateCurrentCompanyUseCase({
updater: {
update: async ({ patchProps }) => {
receivedPatch = patchProps;
return Result.ok(company);
},
},
paymentTermResolver: {
ensureAssignable: async () => Result.ok(undefined),
},
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
companyId: company.id.toPrimitive(),
dto: {
// @ts-expect-error Verifica que el contrato HTTP no admita slug.
slug: "otro-slug",
},
});
assert.equal("slug" in (receivedPatch ?? {}), false);
assert.equal(result.isSuccess, true);
assert.equal(result.data.slug, company.slug.toPrimitive());
});

View File

@ -0,0 +1,270 @@
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 type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../common";
import type { CompanyPatchProps } from "../../../domain";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
export class UpdateCurrentCompanyUseCase {
public constructor(
private readonly deps: {
updater: ICompanyUpdater;
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) => {
try {
if (defaultPaymentTermIdResult.data !== undefined) {
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
companyId: companyIdResult.data,
id: defaultPaymentTermIdResult.data,
transaction,
});
if (ensureResult.isFailure) {
return Result.fail(ensureResult.error);
}
}
const updateResult = await this.deps.updater.update({
id: companyIdResult.data,
patchProps: patchPropsResult.data,
transaction,
});
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok(
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.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),
];
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 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));
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
id: model.id,
legal_name: model.legalName,
trade_name: model.tradeName,
tin: model.tin,
slug: model.slug,
email: model.email,
phone: model.phone,
website: model.website,
default_payment_term_id: model.defaultPaymentTermId,
status: model.status,
};
}

View File

@ -16,6 +16,14 @@ export class CompanySlugAlreadyExistsError extends DomainError {
public readonly code = "COMPANY_SLUG_ALREADY_EXISTS" as const;
}
export const isCompanySlugAlreadyExistsError = (
export const isCompanySlugAlreadyExistsError = (e: unknown): e is CompanySlugAlreadyExistsError =>
e instanceof CompanySlugAlreadyExistsError;
export class CompanyDefaultPaymentTermNotAssignableError extends DomainError {
public readonly code = "COMPANY_DEFAULT_PAYMENT_TERM_NOT_ASSIGNABLE" as const;
}
export const isCompanyDefaultPaymentTermNotAssignableError = (
e: unknown
): e is CompanySlugAlreadyExistsError => e instanceof CompanySlugAlreadyExistsError;
): e is CompanyDefaultPaymentTermNotAssignableError =>
e instanceof CompanyDefaultPaymentTermNotAssignableError;

View File

@ -15,8 +15,10 @@ import {
EnableCompanyUseCase,
FindAvailableCompaniesForAccountUseCase,
GetCompanyByIdUseCase,
GetCurrentCompanyUseCase,
type ICompanyPublicServices,
UpdateCompanyUseCase,
UpdateCurrentCompanyUseCase,
buildCompanyPublicServices,
} from "../../application";
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
@ -28,8 +30,10 @@ export type CompaniesInternalDeps = {
useCases: {
createCompany: () => CreateCompanyUseCase;
getCompanyById: () => GetCompanyByIdUseCase;
getCurrentCompany: () => GetCurrentCompanyUseCase;
findAvailableCompaniesForAccount: () => FindAvailableCompaniesForAccountUseCase;
updateCompany: () => UpdateCompanyUseCase;
updateCurrentCompany: () => UpdateCurrentCompanyUseCase;
enableCompany: () => EnableCompanyUseCase;
disableCompany: () => DisableCompanyUseCase;
};
@ -71,6 +75,12 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
responseMapper,
transactionManager,
}),
getCurrentCompany: () =>
new GetCurrentCompanyUseCase({
finder,
responseMapper,
transactionManager,
}),
findAvailableCompaniesForAccount: () =>
new FindAvailableCompaniesForAccountUseCase({
identityServices,
@ -85,6 +95,13 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
responseMapper,
transactionManager,
}),
updateCurrentCompany: () =>
new UpdateCurrentCompanyUseCase({
updater,
paymentTermResolver,
responseMapper,
transactionManager,
}),
enableCompany: () =>
new EnableCompanyUseCase({
finder,

View File

@ -8,9 +8,11 @@ import {
import {
type CompanyCannotBeUpdatedError,
type CompanyDefaultPaymentTermNotAssignableError,
type CompanyNotFoundError,
type CompanySlugAlreadyExistsError,
isCompanyCannotBeUpdatedError,
isCompanyDefaultPaymentTermNotAssignableError,
isCompanyNotFoundError,
isCompanySlugAlreadyExistsError,
} from "../../domain";
@ -40,7 +42,18 @@ const companyCannotBeUpdatedRule: ErrorToApiRule = {
),
};
const companyDefaultPaymentTermNotAssignableRule: ErrorToApiRule = {
priority: 120,
matches: isCompanyDefaultPaymentTermNotAssignableError,
build: (error) =>
new ValidationApiError(
(error as CompanyDefaultPaymentTermNotAssignableError).message ||
"Default payment term is not assignable to the active company."
),
};
export const companiesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(companyNotFoundRule)
.register(companySlugAlreadyExistsRule)
.register(companyDefaultPaymentTermNotAssignableRule)
.register(companyCannotBeUpdatedRule);

View File

@ -1,5 +1,6 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import { requireIdentityAuthenticated } from "@erp/identity/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";
@ -9,20 +10,29 @@ import {
GetCompanyByIdRequestSchema,
UpdateCompanyByIdParamsRequestSchema,
UpdateCompanyRequestSchema,
UpdateCurrentCompanyRequestSchema,
} from "../../../common";
import type { CompaniesInternalDeps } from "../di";
import {
CreateCompanyController,
DisableCompanyController,
EnableCompanyController,
FindAvailableCompaniesController,
GetCompanyByIdController,
GetCurrentCompanyController,
UpdateCompanyController,
UpdateCurrentCompanyController,
} from "./controllers";
export const companiesRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
const { app, config, getInternal, getService } = params;
const deps = getInternal<CompaniesInternalDeps>("companies");
const identityServices = getService<IIdentityPublicServices>("identity:general");
const requireCurrentCompanyContext = requireCompanyContext({
companyAccessChecker: identityServices.companyAccess,
companyFinder: deps.publicFinder,
});
const router = Router({ mergeParams: true });
@ -44,8 +54,28 @@ export const companiesRouter = (params: StartParams) => {
return controller.execute(req, res, next);
});
router.get(
"/current",
requireCurrentCompanyContext,
(req: Request, res: Response, next: NextFunction) => {
const controller = new GetCurrentCompanyController(deps.useCases.getCurrentCompany());
return controller.execute(req, res, next);
}
);
router.patch(
"/current",
requireCurrentCompanyContext,
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany());
return controller.execute(req, res, next);
}
);
router.get(
"/:company_id",
requireCurrentCompanyContext,
validateRequest(GetCompanyByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById());
@ -55,6 +85,7 @@ export const companiesRouter = (params: StartParams) => {
router.patch(
"/:company_id",
requireCurrentCompanyContext,
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
validateRequest(UpdateCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
@ -65,6 +96,7 @@ export const companiesRouter = (params: StartParams) => {
router.post(
"/:company_id/enable",
requireCurrentCompanyContext,
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new EnableCompanyController(deps.useCases.enableCompany());
@ -74,6 +106,7 @@ export const companiesRouter = (params: StartParams) => {
router.post(
"/:company_id/disable",
requireCurrentCompanyContext,
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new DisableCompanyController(deps.useCases.disableCompany());

View File

@ -1,4 +1,8 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import {
ExpressController,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { DisableCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
@ -7,15 +11,20 @@ export class DisableCompanyController extends ExpressController {
public constructor(private readonly useCase: DisableCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
}
protected async executeImpl() {
const tenantId = this.getTenantId();
const { company_id } = this.req.params;
if (!company_id) {
if (!company_id || !tenantId) {
return this.invalidInputError("Company ID missing");
}
if (tenantId.toPrimitive() !== company_id) {
return this.forbiddenError("Company context does not match requested company.");
}
const result = await this.useCase.execute({ company_id });
return result.match(

View File

@ -1,4 +1,8 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import {
ExpressController,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { EnableCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
@ -7,15 +11,20 @@ export class EnableCompanyController extends ExpressController {
public constructor(private readonly useCase: EnableCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
}
protected async executeImpl() {
const tenantId = this.getTenantId();
const { company_id } = this.req.params;
if (!company_id) {
if (!company_id || !tenantId) {
return this.invalidInputError("Company ID missing");
}
if (tenantId.toPrimitive() !== company_id) {
return this.forbiddenError("Company context does not match requested company.");
}
const result = await this.useCase.execute({ company_id });
return result.match(

View File

@ -1,4 +1,8 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import {
ExpressController,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { GetCompanyByIdUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
@ -7,15 +11,20 @@ export class GetCompanyByIdController extends ExpressController {
public constructor(private readonly useCase: GetCompanyByIdUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
}
protected async executeImpl() {
const tenantId = this.getTenantId();
const { company_id } = this.req.params;
if (!company_id) {
if (!company_id || !tenantId) {
return this.invalidInputError("Company ID missing");
}
if (tenantId.toPrimitive() !== company_id) {
return this.forbiddenError("Company context does not match requested company.");
}
const result = await this.useCase.execute({ company_id });
return result.match(

View File

@ -0,0 +1,30 @@
import {
ExpressController,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { GetCurrentCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class GetCurrentCompanyController extends ExpressController {
public constructor(private readonly useCase: GetCurrentCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Company context required");
}
const result = await this.useCase.execute({ companyId: companyId.toPrimitive() });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -3,4 +3,6 @@ export * from "./disable-company.controller";
export * from "./enable-company.controller";
export * from "./find-available-companies.controller";
export * from "./get-company-by-id.controller";
export * from "./get-current-company.controller";
export * from "./update-company.controller";
export * from "./update-current-company.controller";

View File

@ -1,4 +1,8 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import {
ExpressController,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { UpdateCompanyRequestDTO } from "../../../../common";
import type { UpdateCompanyUseCase } from "../../../application";
@ -8,15 +12,20 @@ export class UpdateCompanyController extends ExpressController {
public constructor(private readonly useCase: UpdateCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
}
protected async executeImpl() {
const tenantId = this.getTenantId();
const { company_id } = this.req.params;
if (!company_id) {
if (!company_id || !tenantId) {
return this.invalidInputError("Company ID missing");
}
if (tenantId.toPrimitive() !== company_id) {
return this.forbiddenError("Company context does not match requested company.");
}
const dto = this.req.body as UpdateCompanyRequestDTO;
const result = await this.useCase.execute({ company_id, dto });

View File

@ -0,0 +1,35 @@
import {
ExpressController,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { UpdateCurrentCompanyRequestDTO } from "../../../../common";
import type { UpdateCurrentCompanyUseCase } from "../../../application";
import { companiesApiErrorMapper } from "../companies-api-error-mapper";
export class UpdateCurrentCompanyController extends ExpressController {
public constructor(private readonly useCase: UpdateCurrentCompanyUseCase) {
super();
this.errorMapper = companiesApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Company context required");
}
const dto = this.req.body as UpdateCurrentCompanyRequestDTO;
const result = await this.useCase.execute({
companyId: companyId.toPrimitive(),
dto,
});
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,4 +1,5 @@
export * from "./change-company-status.request.dto";
export * from "./create-company.request.dto";
export * from "./get-company-by-id.request.dto";
export * from "./update-current-company.request.dto";
export * from "./update-company.request.dto";

View File

@ -0,0 +1,14 @@
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
import { z } from "zod/v4";
export const UpdateCurrentCompanyRequestSchema = z.object({
legal_name: z.string().min(1).optional(),
trade_name: z.string().nullable().optional(),
tin: TinSchema.nullable().optional(),
email: EmailSchema.nullable().optional(),
phone: z.string().nullable().optional(),
website: WebsiteSchema.nullable().optional(),
default_payment_term_id: z.uuid().nullable().optional(),
});
export type UpdateCurrentCompanyRequestDTO = z.infer<typeof UpdateCurrentCompanyRequestSchema>;

View File

@ -2,11 +2,54 @@
"settings": {
"companyDetails": {
"title": "Company details",
"description": "Configure the active company general data and its default payment term.",
"loading": "Loading active company details...",
"description": "Configure the active company general data, contact details, and default payment term.",
"sections": {
"general": {
"title": "General details",
"description": "Update the primary identification data of the active company.",
"fields": {
"legalName": {
"label": "Legal name",
"description": "This is the main legal name of the company.",
"placeholder": "Enter the legal name"
},
"tradeName": {
"label": "Trade name",
"description": "Use it when the company operates under a name different from its legal name.",
"placeholder": "Enter the trade name"
},
"tin": {
"label": "TIN/VAT",
"description": "Tax identification of the active company.",
"placeholder": "Enter the TIN/VAT"
}
}
},
"contact": {
"title": "Contact details",
"description": "Keep the visible contact channels of the active company up to date.",
"fields": {
"email": {
"label": "Email",
"description": "Primary company contact email.",
"placeholder": "name@company.com"
},
"phone": {
"label": "Phone",
"description": "Primary company contact phone number.",
"placeholder": "Enter the phone number"
},
"website": {
"label": "Website",
"description": "Public website of the company.",
"placeholder": "https://company.com"
}
}
},
"paymentTerm": {
"title": "Commercial settings",
"description": "This term will only be used as the initial value for future configurations.",
"title": "Commercial terms",
"description": "This term will be used as the initial value in future configurations for the active company.",
"field": {
"label": "Default payment term",
"description": "It must belong to the active company and stay active for new assignments.",
@ -26,7 +69,7 @@
"saving": "Saving company settings.",
"success": {
"title": "Settings updated",
"message": "The default payment term was saved successfully."
"message": "The company details were saved successfully."
},
"error": {
"title": "Could not save",
@ -42,10 +85,38 @@
}
},
"pageError": {
"title": "Could not load the active company",
"description": "Reload the page to try again.",
"retry": "Retry",
"retrying": "Retrying..."
"actions": {
"retry": "Retry",
"retrying": "Retrying..."
},
"forbidden": {
"title": "You do not have access to the active company",
"description": "You do not have permission to view this configuration."
},
"notFound": {
"title": "The active company was not found",
"description": "The selected company no longer exists or is not available."
},
"network": {
"title": "Could not connect to the server",
"description": "Check your connection and try again."
},
"timeout": {
"title": "The request is taking too long",
"description": "The server did not respond within the expected time."
},
"server": {
"title": "Could not load the active company",
"description": "An internal error occurred while retrieving the company settings."
},
"unavailable": {
"title": "Service temporarily unavailable",
"description": "The companies service is not available at the moment."
},
"unknown": {
"title": "Could not load the active company",
"description": "An unexpected error occurred while loading the company settings."
}
}
}
}

View File

@ -2,16 +2,59 @@
"settings": {
"companyDetails": {
"title": "Datos de empresa",
"description": "Configura los datos generales de la empresa activa y su plazo de pago por defecto.",
"loading": "Cargando datos de la empresa activa...",
"description": "Configura los datos generales, de contacto y el vencimiento por defecto de la empresa activa.",
"sections": {
"general": {
"title": "Datos generales",
"description": "Actualiza la identificación principal de la empresa activa.",
"fields": {
"legalName": {
"label": "Razón social",
"description": "Es el nombre legal principal de la empresa.",
"placeholder": "Introduce la razón social"
},
"tradeName": {
"label": "Nombre comercial",
"description": "Se usa cuando la empresa opera con una denominación distinta de la razón social.",
"placeholder": "Introduce el nombre comercial"
},
"tin": {
"label": "NIF/CIF/TIN",
"description": "Identificador fiscal de la empresa activa.",
"placeholder": "Introduce el NIF/CIF/TIN"
}
}
},
"contact": {
"title": "Datos de contacto",
"description": "Mantén actualizados los canales de contacto visibles de la empresa activa.",
"fields": {
"email": {
"label": "Email",
"description": "Correo principal de contacto de la empresa.",
"placeholder": "nombre@empresa.com"
},
"phone": {
"label": "Teléfono",
"description": "Teléfono principal de contacto de la empresa.",
"placeholder": "Introduce el teléfono"
},
"website": {
"label": "Web",
"description": "Sitio web público de la empresa.",
"placeholder": "https://empresa.com"
}
}
},
"paymentTerm": {
"title": "Configuración comercial",
"description": "Este plazo solo se usará como valor inicial en futuras configuraciones.",
"title": "Condiciones comerciales",
"description": "Este vencimiento se usará como valor inicial en futuras configuraciones de la empresa activa.",
"field": {
"label": "Plazo de pago por defecto",
"label": "Vencimiento por defecto",
"description": "Debe pertenecer a la empresa activa y estar activo para nuevas asignaciones.",
"placeholder": "Selecciona un plazo de pago",
"none": "Sin plazo por defecto",
"placeholder": "Selecciona un vencimiento",
"none": "Sin vencimiento por defecto",
"currentInactive": "{{name}} (inactivo actual)"
}
}
@ -26,7 +69,7 @@
"saving": "Guardando configuración de empresa.",
"success": {
"title": "Configuración actualizada",
"message": "El plazo de pago por defecto se ha guardado correctamente."
"message": "Los datos de la empresa se han guardado correctamente."
},
"error": {
"title": "No se pudo guardar",
@ -42,10 +85,38 @@
}
},
"pageError": {
"title": "No se pudo cargar la empresa activa",
"description": "Recarga la pantalla para volver a intentarlo.",
"retry": "Reintentar",
"retrying": "Reintentando..."
"actions": {
"retry": "Reintentar",
"retrying": "Reintentando..."
},
"forbidden": {
"title": "No tienes acceso a la empresa activa",
"description": "No dispones de permisos para consultar esta configuración."
},
"notFound": {
"title": "No se encontró la empresa activa",
"description": "La empresa seleccionada ya no existe o no está disponible."
},
"network": {
"title": "No se pudo conectar con el servidor",
"description": "Comprueba tu conexión e inténtalo de nuevo."
},
"timeout": {
"title": "La carga está tardando demasiado",
"description": "El servidor no respondió dentro del tiempo esperado."
},
"server": {
"title": "No se pudo cargar la empresa activa",
"description": "Se produjo un error interno al recuperar la configuración de empresa."
},
"unavailable": {
"title": "Servicio temporalmente no disponible",
"description": "El servicio de empresas no está disponible en este momento."
},
"unknown": {
"title": "No se pudo cargar la empresa activa",
"description": "Ha ocurrido un error inesperado al cargar la configuración de empresa."
}
}
}
}

View File

@ -1,48 +1,42 @@
import { applyValidationErrorCollection } from "@erp/core";
import { formHasAnyDirty } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import {
getPaymentTermOptions,
usePaymentTermDetailQuery,
usePaymentTermListQuery,
} from "@erp/catalogs/client/payment-terms";
import { applyValidationErrorCollection } from "@erp/core";
import { formHasAnyDirty, getUserErrorMessage } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import { useIdentityAuthSession } from "@erp/identity/client";
import { isValidationErrorCollection } from "@repo/rdx-ddd";
import type { SelectFieldItem } from "@repo/rdx-ui/components";
import {
focusFirstInputFormError,
getFirstFormErrorMessage,
preventEnterKeySubmitForm,
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@repo/rdx-ui/helpers";
import type { SelectFieldItem } from "@repo/rdx-ui/components";
import { isValidationErrorCollection, type ValidationErrorCollection } from "@repo/rdx-ddd";
import { useEffect, useId, useMemo, type SubmitEvent } from "react";
import { type SubmitEvent, useEffect, useId, useMemo } from "react";
import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { CompanySettingsFormSchema, type CompanySettingsForm } from "../entities";
import { type CompanySettingsForm, CompanySettingsFormSchema } from "../entities";
import { useCompanySettingsQuery, useCompanySettingsUpdateMutation } from "../shared/hooks";
import { buildCompanySettingsForm, buildUpdateCompanySettingsParams } from "../utils";
const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => {
if (isValidationErrorCollection(error)) {
return error;
}
if (error instanceof Error) {
return error;
}
return new Error("Unknown error");
};
import {
buildCompanyDetailsSettingsPageErrorViewModel,
buildCompanySettingsForm,
buildUpdateCompanySettingsParams,
normalizeCompanyDetailsSubmitError,
resolveCompanyDetailsSettingsFeedbackState,
} from "../utils";
export const useCompanyDetailsSettingsController = () => {
const { t } = useTranslation();
const { activeCompany } = useIdentityAuthSession();
const companyId = activeCompany?.id;
const { activeCompany, updateActiveCompany } = useIdentityAuthSession();
const formId = useId();
const companyQuery = useCompanySettingsQuery(companyId);
const companyQuery = useCompanySettingsQuery();
const mutation = useCompanySettingsUpdateMutation();
const paymentTermsQuery = usePaymentTermListQuery({
@ -72,7 +66,13 @@ export const useCompanyDetailsSettingsController = () => {
const initialValues = useMemo<CompanySettingsForm>(() => {
if (!company) {
return {
defaultPaymentTermId: null,
legalName: "",
tradeName: "",
tin: "",
email: "",
phone: "",
website: "",
defaultPaymentTermId: "",
};
}
@ -85,15 +85,47 @@ export const useCompanyDetailsSettingsController = () => {
disabled: mutation.isPending,
});
const isLoading =
companyQuery.isLoading ||
paymentTermsQuery.isLoading ||
(needsCurrentPaymentTerm && currentPaymentTermQuery.isLoading);
const isRetrying =
(companyQuery.isFetching && !companyQuery.isLoading) ||
(paymentTermsQuery.isFetching && !paymentTermsQuery.isLoading) ||
(needsCurrentPaymentTerm &&
currentPaymentTermQuery.isFetching &&
!currentPaymentTermQuery.isLoading);
const loadError =
companyQuery.error ??
paymentTermsQuery.error ??
(needsCurrentPaymentTerm ? currentPaymentTermQuery.error : null);
const hasRequiredData = Boolean(company) &&
Boolean(paymentTermsQuery.data) &&
(!needsCurrentPaymentTerm || Boolean(currentPaymentTermQuery.data));
const feedbackState = resolveCompanyDetailsSettingsFeedbackState({
isLoading,
isLoadError: Boolean(loadError),
hasRequiredData,
error: loadError,
});
useEffect(() => {
if (!company) {
return;
}
if (form.formState.isDirty) {
return;
}
form.reset(buildCompanySettingsForm(company), {
keepDirty: false,
});
}, [company, form]);
}, [company, form, form.formState.isDirty]);
const paymentTermOptions = useMemo<SelectFieldItem[]>(() => {
const options = getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
@ -107,18 +139,28 @@ export const useCompanyDetailsSettingsController = () => {
});
}
return [
{
value: "",
label: t("settings.companyDetails.sections.paymentTerm.field.none"),
},
...options,
];
return options;
}, [currentPaymentTermQuery.data, needsCurrentPaymentTerm, paymentTermsQuery.data?.items, t]);
const pageError = useMemo(() => {
if (!feedbackState.hasBlockingError) {
return null;
}
return buildCompanyDetailsSettingsPageErrorViewModel(loadError, (key) => t(key));
}, [feedbackState.hasBlockingError, loadError, t]);
const inlineLoadError = useMemo(() => {
if (!feedbackState.hasInlineError) {
return null;
}
return buildCompanyDetailsSettingsPageErrorViewModel(loadError, (key) => t(key));
}, [feedbackState.hasInlineError, loadError, t]);
const submitHandler = form.handleSubmit(
async (formData) => {
if (!companyId) {
if (!activeCompany?.id) {
return;
}
@ -133,11 +175,17 @@ export const useCompanyDetailsSettingsController = () => {
try {
const updated = await mutation.mutateAsync(
buildUpdateCompanySettingsParams({
companyId,
form: formData,
dirtyFields: form.formState.dirtyFields,
})
);
updateActiveCompany({
id: updated.id,
legalName: updated.legalName,
tradeName: updated.tradeName,
});
form.reset(buildCompanySettingsForm(updated), {
keepDirty: false,
});
@ -147,7 +195,7 @@ export const useCompanyDetailsSettingsController = () => {
t("settings.companyDetails.feedback.success.message")
);
} catch (error: unknown) {
const normalizedError = normalizeSubmitError(error);
const normalizedError = normalizeCompanyDetailsSubmitError(error);
if (isValidationErrorCollection(normalizedError)) {
applyValidationErrorCollection(form, normalizedError);
@ -159,10 +207,8 @@ export const useCompanyDetailsSettingsController = () => {
return;
}
showErrorToast(
t("settings.companyDetails.feedback.error.title"),
normalizedError.message || t("settings.companyDetails.feedback.error.message")
);
const userMessage = getUserErrorMessage(normalizedError);
showErrorToast(userMessage.title, userMessage.description);
}
},
(errors: FieldErrors<CompanySettingsForm>) => {
@ -186,7 +232,11 @@ export const useCompanyDetailsSettingsController = () => {
companyQuery,
form,
formId,
inlineLoadError,
isInitialLoading: feedbackState.isInitialLoading,
onFormKeyDown: preventEnterKeySubmitForm,
onSubmit,
pageError,
paymentTermOptions,
resetForm: () => {
if (!company) {
@ -197,16 +247,9 @@ export const useCompanyDetailsSettingsController = () => {
keepDirty: false,
});
},
isLoading:
companyQuery.isLoading ||
paymentTermsQuery.isLoading ||
(needsCurrentPaymentTerm && currentPaymentTermQuery.isLoading),
isRetrying: companyQuery.isFetching && !companyQuery.isLoading,
isLoading,
isRetrying,
isSaving: mutation.isPending,
pageError:
companyQuery.error ??
paymentTermsQuery.error ??
(needsCurrentPaymentTerm ? currentPaymentTermQuery.error : null),
retry: async () => {
await Promise.all([
companyQuery.refetch(),

View File

@ -0,0 +1,9 @@
import type { PageErrorStateVariant } from "@erp/core/components";
export interface CompanyDetailsSettingsPageErrorViewModel {
variant: PageErrorStateVariant;
title: string;
description: string;
canRetry: boolean;
reference?: string;
}

View File

@ -1,7 +1,14 @@
import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core";
import { z } from "zod/v4";
export const CompanySettingsFormSchema = z.object({
defaultPaymentTermId: z.union([z.uuid(), z.literal(""), z.null()]),
legalName: z.string().min(1),
tradeName: z.string(),
tin: TinSchema.or(z.literal("")),
email: EmailSchema.or(z.literal("")),
phone: z.string(),
website: WebsiteSchema.or(z.literal("")),
defaultPaymentTermId: z.string(),
});
export type CompanySettingsForm = z.infer<typeof CompanySettingsFormSchema>;

View File

@ -1 +1,2 @@
export * from "./company-details-settings-page-error-view-model.entity";
export * from "./company-settings-form.schema";

View File

@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { CompanyResponseDTO } from "../../../../../common";
import { CompanySettingsAdapter } from "./company-settings.adapter";
test("preserva null en los campos nullable del DTO", () => {
const dto: CompanyResponseDTO = {
id: "11111111-1111-4111-8111-111111111111",
legal_name: "Acme Legal SL",
trade_name: null,
tin: null,
slug: "acme-legal-sl",
email: null,
phone: null,
website: null,
default_payment_term_id: null,
status: "active",
};
assert.deepEqual(CompanySettingsAdapter.fromDto(dto), {
id: "11111111-1111-4111-8111-111111111111",
legalName: "Acme Legal SL",
tradeName: null,
tin: null,
email: null,
phone: null,
website: null,
defaultPaymentTermId: null,
});
});

View File

@ -7,6 +7,10 @@ export const CompanySettingsAdapter = {
id: dto.id,
legalName: dto.legal_name,
tradeName: dto.trade_name,
tin: dto.tin,
email: dto.email,
phone: dto.phone,
website: dto.website,
defaultPaymentTermId: dto.default_payment_term_id,
};
},

View File

@ -2,16 +2,14 @@ import type { IDataSource } from "@erp/core/client";
import type { CompanyResponseDTO } from "../../../../../common";
export interface GetCompanySettingsParams {
companyId: string;
signal?: AbortSignal;
}
export const getCompanySettings = (
dataSource: IDataSource,
params: GetCompanySettingsParams
params?: { signal?: AbortSignal }
): Promise<CompanyResponseDTO> => {
return dataSource.getOne<CompanyResponseDTO>("companies", params.companyId, {
signal: params.signal,
return dataSource.custom<Record<string, never>, CompanyResponseDTO>({
path: "companies/current",
method: "get",
signal: params?.signal,
data: {},
});
};

View File

@ -1,19 +1,14 @@
import type { IDataSource } from "@erp/core/client";
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../../common";
export interface UpdateCompanySettingsParams {
companyId: string;
data: UpdateCompanyRequestDTO;
}
import type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../../common";
export const updateCompanySettings = (
dataSource: IDataSource,
params: UpdateCompanySettingsParams
params: { data: UpdateCurrentCompanyRequestDTO }
): Promise<CompanyResponseDTO> => {
return dataSource.updateOne<UpdateCompanyRequestDTO, CompanyResponseDTO>(
"companies",
params.companyId,
params.data
);
return dataSource.custom<UpdateCurrentCompanyRequestDTO, CompanyResponseDTO>({
path: "companies/current",
method: "patch",
data: params.data,
});
};

View File

@ -2,5 +2,9 @@ export interface CompanySettings {
id: string;
legalName: string;
tradeName: string | null;
tin: string | null;
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
}

View File

@ -1,5 +1,4 @@
export const COMPANY_SETTINGS_QUERY_KEY = (companyId?: string) =>
["companies", "settings", "detail", { companyId }] as const;
export const COMPANY_SETTINGS_QUERY_KEY = () => ["companies", "settings", "current"] as const;
export const UPDATE_COMPANY_SETTINGS_MUTATION_KEY = [
"companies",

View File

@ -7,19 +7,14 @@ import type { CompanySettings } from "../entities";
import { COMPANY_SETTINGS_QUERY_KEY } from "./keys";
export const useCompanySettingsQuery = (
companyId?: string
): UseQueryResult<CompanySettings, DefaultError> => {
export const useCompanySettingsQuery = (): UseQueryResult<CompanySettings, DefaultError> => {
const dataSource = useDataSource();
return useQuery<CompanySettings, DefaultError>({
queryKey: COMPANY_SETTINGS_QUERY_KEY(companyId),
enabled: Boolean(companyId),
queryKey: COMPANY_SETTINGS_QUERY_KEY(),
enabled: true,
queryFn: async ({ signal }) => {
const dto = await getCompanySettings(dataSource, {
companyId: companyId as string,
signal,
});
const dto = await getCompanySettings(dataSource, { signal });
return CompanySettingsAdapter.fromDto(dto);
},

View File

@ -2,9 +2,11 @@ import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCompanyRequestSchema } from "../../../../../common";
import {
UpdateCurrentCompanyRequestSchema,
type UpdateCurrentCompanyRequestDTO,
} from "../../../../../common";
import { CompanySettingsAdapter } from "../adapters";
import type { UpdateCompanySettingsParams } from "../api";
import { updateCompanySettings } from "../api";
import type { CompanySettings } from "../entities";
@ -20,10 +22,10 @@ export const useCompanySettingsUpdateMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<CompanySettings, DefaultError, UpdateCompanySettingsParams>({
return useMutation<CompanySettings, DefaultError, { data: UpdateCurrentCompanyRequestDTO }>({
mutationKey: UPDATE_COMPANY_SETTINGS_MUTATION_KEY,
mutationFn: async (params) => {
const result = UpdateCompanyRequestSchema.safeParse(params.data);
const result = UpdateCurrentCompanyRequestSchema.safeParse(params.data);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error.issues));
@ -33,7 +35,7 @@ export const useCompanySettingsUpdateMutation = () => {
return CompanySettingsAdapter.fromDto(dto);
},
onSuccess: async (company) => {
queryClient.setQueryData(COMPANY_SETTINGS_QUERY_KEY(company.id), company);
queryClient.setQueryData(COMPANY_SETTINGS_QUERY_KEY(), company);
},
});
};

View File

@ -0,0 +1,288 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
let capturedSubmitProps: Record<string, unknown> | undefined;
let capturedResetProps: Record<string, unknown> | undefined;
let capturedPageErrorProps: Record<string, unknown> | undefined;
const capturedTextFields: Array<Record<string, unknown>> = [];
const capturedSelectFields: Array<Record<string, unknown>> = [];
const buildControllerState = () => ({
form: {
formState: {
isDirty: true,
isSubmitting: false,
},
},
formId: "company-details-form",
inlineLoadError: null,
companyQuery: {
isError: false,
},
isInitialLoading: false,
isLoading: false,
isRetrying: false,
isSaving: false,
onFormKeyDown: () => undefined,
pageError: null,
onSubmit: () => undefined,
resetForm: () => undefined,
retry: () => undefined,
paymentTermOptions: [
{
value: "22222222-2222-4222-8222-222222222222",
label: "30 dias",
},
],
});
let controllerState = buildControllerState();
const resetCapturedProps = () => {
capturedSubmitProps = undefined;
capturedResetProps = undefined;
capturedPageErrorProps = undefined;
capturedTextFields.length = 0;
capturedSelectFields.length = 0;
};
mock.module("@erp/core/components", {
namedExports: {
ErrorAlert: ({ title, message }: { title: string; message?: string }) => (
<div data-inline-error-title={title}>{message}</div>
),
PageErrorState: (props: Record<string, unknown>) => {
capturedPageErrorProps = props;
return <div data-page-error={String(props.variant)}>{String(props.title)}</div>;
},
},
});
mock.module("@erp/core/hooks", {
namedExports: {
UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children,
},
});
mock.module("@repo/rdx-ui/components", {
namedExports: {
AppContent: ({ children }: { children?: ReactNode }) => children ?? null,
CancelActionButton: (props: Record<string, unknown>) => {
capturedResetProps = props;
return null;
},
RdxEditorSection: ({
children,
title,
}: {
children: ReactNode;
title: string;
}) => <section data-title={title}>{children}</section>,
RdxEditorStickyFooter: () => null,
RdxFormGrid: ({ children }: { children: ReactNode }) => <div>{children}</div>,
RhfSubmitActionButton: (props: Record<string, unknown>) => {
capturedSubmitProps = props;
return null;
},
SelectField: (props: Record<string, unknown>) => {
capturedSelectFields.push(props);
return <div data-field={String(props.name)}>{String(props.label)}</div>;
},
TextField: (props: Record<string, unknown>) => {
capturedTextFields.push(props);
return <div data-field={String(props.name)}>{String(props.label)}</div>;
},
},
});
mock.module("react-hook-form", {
namedExports: {
FormProvider: ({ children }: { children: ReactNode }) => children,
},
});
mock.module("../../../../i18n", {
namedExports: {
useTranslation: () => ({
t: (key: string) =>
(
{
"settings.companyDetails.title": "Datos de empresa",
"settings.companyDetails.loading": "Cargando datos de la empresa activa...",
"settings.companyDetails.description":
"Configura los datos generales, de contacto y el vencimiento por defecto de la empresa activa.",
"settings.companyDetails.sections.general.title": "Datos generales",
"settings.companyDetails.sections.general.description":
"Actualiza la identificacion principal de la empresa activa.",
"settings.companyDetails.sections.general.fields.legalName.label": "Razon social",
"settings.companyDetails.sections.general.fields.legalName.description":
"Es el nombre legal principal de la empresa.",
"settings.companyDetails.sections.general.fields.legalName.placeholder":
"Introduce la razon social",
"settings.companyDetails.sections.general.fields.tradeName.label": "Nombre comercial",
"settings.companyDetails.sections.general.fields.tradeName.description":
"Se usa cuando la empresa opera con una denominacion distinta de la razon social.",
"settings.companyDetails.sections.general.fields.tradeName.placeholder":
"Introduce el nombre comercial",
"settings.companyDetails.sections.general.fields.tin.label": "NIF/CIF/TIN",
"settings.companyDetails.sections.general.fields.tin.description":
"Identificador fiscal de la empresa activa.",
"settings.companyDetails.sections.general.fields.tin.placeholder":
"Introduce el NIF/CIF/TIN",
"settings.companyDetails.sections.contact.title": "Datos de contacto",
"settings.companyDetails.sections.contact.description":
"Manten actualizados los canales de contacto visibles de la empresa activa.",
"settings.companyDetails.sections.contact.fields.email.label": "Email",
"settings.companyDetails.sections.contact.fields.email.description":
"Correo principal de contacto de la empresa.",
"settings.companyDetails.sections.contact.fields.email.placeholder":
"nombre@empresa.com",
"settings.companyDetails.sections.contact.fields.phone.label": "Telefono",
"settings.companyDetails.sections.contact.fields.phone.description":
"Telefono principal de contacto de la empresa.",
"settings.companyDetails.sections.contact.fields.phone.placeholder":
"Introduce el telefono",
"settings.companyDetails.sections.contact.fields.website.label": "Web",
"settings.companyDetails.sections.contact.fields.website.description":
"Sitio web publico de la empresa.",
"settings.companyDetails.sections.contact.fields.website.placeholder":
"https://empresa.com",
"settings.companyDetails.sections.paymentTerm.title": "Condiciones comerciales",
"settings.companyDetails.sections.paymentTerm.description":
"Este vencimiento se usara como valor inicial en futuras configuraciones de la empresa activa.",
"settings.companyDetails.sections.paymentTerm.field.label": "Vencimiento por defecto",
"settings.companyDetails.sections.paymentTerm.field.description":
"Debe pertenecer a la empresa activa y estar activo para nuevas asignaciones.",
"settings.companyDetails.sections.paymentTerm.field.placeholder":
"Selecciona un vencimiento",
"settings.companyDetails.sections.paymentTerm.field.none": "Sin vencimiento por defecto",
"settings.companyDetails.actions.save": "Guardar cambios",
"settings.companyDetails.actions.saving": "Guardando...",
"settings.companyDetails.actions.reset": "Restablecer",
"settings.companyDetails.feedback.idle": "Sin cambios pendientes.",
"settings.companyDetails.feedback.saving": "Guardando configuracion de empresa.",
"settings.companyDetails.pageError.actions.retry": "Reintentar",
"settings.companyDetails.pageError.actions.retrying": "Reintentando...",
} as Record<string, string>
)[key] ?? key,
}),
},
});
mock.module("../../controllers", {
namedExports: {
useCompanyDetailsSettingsController: () => controllerState,
},
});
test("renderiza las tres secciones, los siete campos y el footer con el estado esperado", async () => {
controllerState = buildControllerState();
resetCapturedProps();
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
const html = renderToStaticMarkup(<CompanyDetailsSettingsPage />);
assert.match(html, /Datos generales/);
assert.match(html, /Datos de contacto/);
assert.match(html, /Condiciones comerciales/);
assert.deepEqual(
capturedTextFields.map((field) => field.name),
["legalName", "tradeName", "tin", "email", "phone", "website"]
);
assert.equal(capturedTextFields[0]?.required, true);
assert.equal(capturedTextFields[3]?.typePreset, "email");
assert.equal(capturedTextFields[4]?.typePreset, "phone");
assert.equal(capturedTextFields[5]?.typePreset, "url");
assert.equal(capturedSelectFields.length, 1);
assert.equal(capturedSelectFields[0]?.name, "defaultPaymentTermId");
assert.equal(capturedSelectFields[0]?.clearLabel, "Sin vencimiento por defecto");
assert.equal(capturedSubmitProps?.disabled, false);
assert.equal(capturedResetProps?.disabled, false);
});
test("muestra un estado de carga accesible mientras carga por primera vez", async () => {
controllerState = {
...buildControllerState(),
isInitialLoading: true,
isLoading: true,
};
resetCapturedProps();
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
const html = renderToStaticMarkup(<CompanyDetailsSettingsPage />);
assert.match(html, /Cargando datos de la empresa activa/);
assert.equal(capturedTextFields.length, 0);
assert.equal(capturedPageErrorProps, undefined);
});
test("renderiza PageErrorState con retry cuando existe error bloqueante recuperable", async () => {
controllerState = {
...buildControllerState(),
isRetrying: true,
pageError: {
variant: "network",
title: "No se pudo conectar con el servidor",
description: "Comprueba tu conexion e intentalo de nuevo.",
canRetry: true,
reference: "corr-123",
},
};
resetCapturedProps();
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
const html = renderToStaticMarkup(<CompanyDetailsSettingsPage />);
assert.match(html, /No se pudo conectar con el servidor/);
assert.equal(capturedPageErrorProps?.variant, "network");
assert.equal(
(capturedPageErrorProps?.primaryAction as { label?: string } | undefined)?.label,
"Reintentando..."
);
assert.equal(capturedPageErrorProps?.reference, "corr-123");
});
test("no muestra retry cuando el error bloqueante no permite reintento", async () => {
controllerState = {
...buildControllerState(),
pageError: {
variant: "forbidden",
title: "No tienes acceso a la empresa activa",
description: "No dispones de permisos para consultar esta configuracion.",
canRetry: false,
},
};
resetCapturedProps();
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
renderToStaticMarkup(<CompanyDetailsSettingsPage />);
assert.equal(capturedPageErrorProps?.variant, "forbidden");
assert.equal(capturedPageErrorProps?.primaryAction, undefined);
});
test("deshabilita las acciones durante el guardado", async () => {
controllerState = {
...buildControllerState(),
isSaving: true,
form: {
formState: {
isDirty: true,
isSubmitting: true,
},
},
};
resetCapturedProps();
const { CompanyDetailsSettingsPage } = await import("./company-details-settings-page.tsx");
renderToStaticMarkup(<CompanyDetailsSettingsPage />);
assert.equal(capturedSubmitProps?.disabled, true);
assert.equal(capturedSubmitProps?.isBusy, true);
assert.equal(capturedResetProps?.disabled, true);
});

View File

@ -8,36 +8,55 @@ import {
RdxFormGrid,
RhfSubmitActionButton,
SelectField,
TextField,
} from "@repo/rdx-ui/components";
import { LandmarkIcon } from "lucide-react";
import { ContactIcon, LandmarkIcon, ScrollTextIcon } from "lucide-react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import { useCompanyDetailsSettingsController } from "../../controllers";
import type { CompanySettingsForm } from "../../entities";
export const CompanyDetailsSettingsPage = () => {
const { t } = useTranslation();
const ctrl = useCompanyDetailsSettingsController();
if (ctrl.isLoading) {
return <AppContent className="py-2" />;
if (ctrl.isInitialLoading) {
return (
<AppContent className="py-2">
<div
aria-busy="true"
className="flex min-h-64 items-center justify-center text-sm text-muted-foreground"
role="status"
>
{t("settings.companyDetails.loading")}
</div>
</AppContent>
);
}
if (ctrl.pageError) {
return (
<AppContent className="py-2">
<PageErrorState
description={t("settings.companyDetails.pageError.description")}
primaryAction={{
label: ctrl.isRetrying
? t("settings.companyDetails.pageError.retrying")
: t("settings.companyDetails.pageError.retry"),
onClick: ctrl.retry,
isLoading: ctrl.isRetrying,
}}
title={t("settings.companyDetails.pageError.title")}
variant="server"
/>
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={ctrl.pageError.description}
primaryAction={
ctrl.pageError.canRetry
? {
label: ctrl.isRetrying
? t("settings.companyDetails.pageError.actions.retrying")
: t("settings.companyDetails.pageError.actions.retry"),
onClick: ctrl.retry,
isLoading: ctrl.isRetrying,
}
: undefined
}
reference={ctrl.pageError.reference}
title={ctrl.pageError.title}
variant={ctrl.pageError.variant}
/>
</div>
</AppContent>
);
}
@ -55,14 +74,110 @@ export const CompanyDetailsSettingsPage = () => {
</p>
</div>
{ctrl.companyQuery.isError ? (
{ctrl.inlineLoadError ? (
<ErrorAlert
message={(ctrl.companyQuery.error as Error)?.message ?? undefined}
title={t("settings.companyDetails.feedback.error.title")}
message={ctrl.inlineLoadError.description}
title={ctrl.inlineLoadError.title}
/>
) : null}
<form className="flex flex-col gap-6" id={ctrl.formId} noValidate onSubmit={ctrl.onSubmit}>
<form
className="flex flex-col gap-6"
id={ctrl.formId}
noValidate
onKeyDown={ctrl.onFormKeyDown}
onSubmit={ctrl.onSubmit}
>
<RdxEditorSection
description={t("settings.companyDetails.sections.general.description")}
icon={<ScrollTextIcon className="size-5" />}
title={t("settings.companyDetails.sections.general.title")}
variant="panel"
>
<RdxFormGrid className="md:grid-cols-2">
<TextField<CompanySettingsForm>
className="col-span-full"
description={t(
"settings.companyDetails.sections.general.fields.legalName.description"
)}
disabled={ctrl.isSaving}
inputClassName="bg-background"
label={t("settings.companyDetails.sections.general.fields.legalName.label")}
name="legalName"
placeholder={t(
"settings.companyDetails.sections.general.fields.legalName.placeholder"
)}
required
/>
<TextField<CompanySettingsForm>
description={t(
"settings.companyDetails.sections.general.fields.tradeName.description"
)}
disabled={ctrl.isSaving}
inputClassName="bg-background"
label={t("settings.companyDetails.sections.general.fields.tradeName.label")}
name="tradeName"
placeholder={t(
"settings.companyDetails.sections.general.fields.tradeName.placeholder"
)}
/>
<TextField<CompanySettingsForm>
description={t("settings.companyDetails.sections.general.fields.tin.description")}
disabled={ctrl.isSaving}
inputClassName="bg-background"
label={t("settings.companyDetails.sections.general.fields.tin.label")}
name="tin"
placeholder={t("settings.companyDetails.sections.general.fields.tin.placeholder")}
/>
</RdxFormGrid>
</RdxEditorSection>
<RdxEditorSection
description={t("settings.companyDetails.sections.contact.description")}
icon={<ContactIcon className="size-5" />}
title={t("settings.companyDetails.sections.contact.title")}
variant="panel"
>
<RdxFormGrid className="md:grid-cols-2">
<TextField<CompanySettingsForm>
description={t("settings.companyDetails.sections.contact.fields.email.description")}
disabled={ctrl.isSaving}
inputClassName="bg-background"
label={t("settings.companyDetails.sections.contact.fields.email.label")}
name="email"
placeholder={t("settings.companyDetails.sections.contact.fields.email.placeholder")}
typePreset="email"
/>
<TextField<CompanySettingsForm>
description={t("settings.companyDetails.sections.contact.fields.phone.description")}
disabled={ctrl.isSaving}
inputClassName="bg-background"
label={t("settings.companyDetails.sections.contact.fields.phone.label")}
name="phone"
placeholder={t("settings.companyDetails.sections.contact.fields.phone.placeholder")}
typePreset="phone"
/>
<TextField<CompanySettingsForm>
className="col-span-full"
description={t(
"settings.companyDetails.sections.contact.fields.website.description"
)}
disabled={ctrl.isSaving}
inputClassName="bg-background"
label={t("settings.companyDetails.sections.contact.fields.website.label")}
name="website"
placeholder={t(
"settings.companyDetails.sections.contact.fields.website.placeholder"
)}
typePreset="url"
/>
</RdxFormGrid>
</RdxEditorSection>
<RdxEditorSection
description={t("settings.companyDetails.sections.paymentTerm.description")}
icon={<LandmarkIcon className="size-5" />}
@ -70,12 +185,14 @@ export const CompanyDetailsSettingsPage = () => {
variant="panel"
>
<RdxFormGrid className="md:grid-cols-2">
<SelectField
<SelectField<CompanySettingsForm>
clearable
clearLabel={t("settings.companyDetails.sections.paymentTerm.field.none")}
className="col-span-full"
description={t("settings.companyDetails.sections.paymentTerm.field.description")}
disabled={ctrl.isSaving}
emptyValue=""
inputClassName="bg-background"
items={ctrl.paymentTermOptions}
label={t("settings.companyDetails.sections.paymentTerm.field.label")}
name="defaultPaymentTermId"
@ -95,7 +212,7 @@ export const CompanyDetailsSettingsPage = () => {
primary={
<RhfSubmitActionButton
busyLabel={t("settings.companyDetails.actions.saving")}
disabled={ctrl.isSaving}
disabled={ctrl.isSaving || !ctrl.form.formState.isDirty}
formId={ctrl.formId}
isBusy={ctrl.form.formState.isSubmitting}
label={t("settings.companyDetails.actions.save")}

View File

@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ApiClientError } from "@erp/core/client";
import { buildCompanyDetailsSettingsPageErrorViewModel } from "./build-company-details-settings-page-error-view-model";
const translate = (key: string) =>
({
"settings.companyDetails.pageError.notFound.title": "No se encontro la empresa activa",
"settings.companyDetails.pageError.notFound.description":
"La empresa seleccionada ya no existe o no esta disponible.",
"settings.companyDetails.pageError.forbidden.title":
"No tienes acceso a la empresa activa",
"settings.companyDetails.pageError.forbidden.description":
"No dispones de permisos para consultar esta configuracion.",
"settings.companyDetails.pageError.network.title":
"No se pudo conectar con el servidor",
"settings.companyDetails.pageError.network.description":
"Comprueba tu conexion e intentalo de nuevo.",
"settings.companyDetails.pageError.timeout.title": "La carga esta tardando demasiado",
"settings.companyDetails.pageError.timeout.description":
"El servidor no respondio dentro del tiempo esperado.",
"settings.companyDetails.pageError.server.title": "No se pudo cargar la empresa activa",
"settings.companyDetails.pageError.server.description":
"Se produjo un error interno al recuperar la configuracion de empresa.",
"settings.companyDetails.pageError.unavailable.title":
"Servicio temporalmente no disponible",
"settings.companyDetails.pageError.unavailable.description":
"El servicio de empresas no esta disponible en este momento.",
"settings.companyDetails.pageError.unknown.title": "No se pudo cargar la empresa activa",
"settings.companyDetails.pageError.unknown.description":
"Ha ocurrido un error inesperado al cargar la configuracion de empresa.",
})[key] ?? key;
test("mapea notFound real a notFound sin retry", () => {
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
new ApiClientError({ kind: "notFound", message: "technical", correlationId: "corr-404" }),
translate
);
assert.equal(viewModel?.variant, "notFound");
assert.equal(viewModel?.canRetry, false);
assert.equal(viewModel?.reference, "corr-404");
});
test("mapea forbidden sin retry", () => {
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
new ApiClientError({ kind: "forbidden", message: "technical" }),
translate
);
assert.equal(viewModel?.variant, "forbidden");
assert.equal(viewModel?.canRetry, false);
});
test("mapea network, timeout, server, unavailable y unknown con retry", () => {
for (const kind of ["network", "timeout", "server", "unavailable", "unknown"] as const) {
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
new ApiClientError({ kind, message: "technical" }),
translate
);
assert.equal(viewModel?.variant, kind);
assert.equal(viewModel?.canRetry, true);
}
});
test("ignora cancelled", () => {
const viewModel = buildCompanyDetailsSettingsPageErrorViewModel(
new ApiClientError({ kind: "cancelled", message: "technical" }),
translate
);
assert.equal(viewModel, null);
});

View File

@ -0,0 +1,53 @@
import { isApiClientError } from "@erp/core/client";
import type { CompanyDetailsSettingsPageErrorViewModel } from "../entities";
interface Translate {
(key: string): string;
}
const buildErrorVariant = (
error: unknown
): CompanyDetailsSettingsPageErrorViewModel["variant"] | "cancelled" => {
if (!isApiClientError(error)) {
return "unknown";
}
switch (error.kind) {
case "cancelled":
return "cancelled";
case "notFound":
return "notFound";
case "forbidden":
return "forbidden";
case "network":
return "network";
case "timeout":
return "timeout";
case "server":
return "server";
case "unavailable":
return "unavailable";
default:
return "unknown";
}
};
export const buildCompanyDetailsSettingsPageErrorViewModel = (
error: unknown,
translate: Translate
): CompanyDetailsSettingsPageErrorViewModel | null => {
const variant = buildErrorVariant(error);
if (variant === "cancelled") {
return null;
}
return {
variant,
title: translate(`settings.companyDetails.pageError.${variant}.title`),
description: translate(`settings.companyDetails.pageError.${variant}.description`),
canRetry: !["forbidden", "notFound"].includes(variant),
reference: isApiClientError(error) ? error.correlationId : undefined,
};
};

View File

@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { CompanySettings } from "../shared/entities";
import { buildCompanySettingsForm } from "./build-company-settings-form";
test('convierte null del Entity a "" en el Form para campos nullable', () => {
const company: CompanySettings = {
id: "11111111-1111-4111-8111-111111111111",
legalName: "Acme Legal SL",
tradeName: null,
tin: null,
email: null,
phone: null,
website: null,
defaultPaymentTermId: null,
};
assert.deepEqual(buildCompanySettingsForm(company), {
legalName: "Acme Legal SL",
tradeName: "",
tin: "",
email: "",
phone: "",
website: "",
defaultPaymentTermId: "",
});
});

View File

@ -2,5 +2,11 @@ import type { CompanySettingsForm } from "../entities";
import type { CompanySettings } from "../shared/entities";
export const buildCompanySettingsForm = (company: CompanySettings): CompanySettingsForm => ({
defaultPaymentTermId: company.defaultPaymentTermId,
legalName: company.legalName,
tradeName: company.tradeName ?? "",
tin: company.tin ?? "",
email: company.email ?? "",
phone: company.phone ?? "",
website: company.website ?? "",
defaultPaymentTermId: company.defaultPaymentTermId ?? "",
});

View File

@ -3,43 +3,187 @@ import test from "node:test";
import { buildUpdateCompanySettingsParams } from "./build-update-company-settings-params";
test("normaliza vacio a null", () => {
const buildForm = () => ({
legalName: "Acme Legal SL",
tradeName: "Acme Trade",
tin: "B12345678",
email: "info@acme.test",
phone: "+34 900 000 000",
website: "https://acme.test",
defaultPaymentTermId: "22222222-2222-4222-8222-222222222222",
});
test("devuelve PATCH vacío sin dirty fields", () => {
const result = buildUpdateCompanySettingsParams({
form: buildForm(),
dirtyFields: {},
});
assert.deepEqual(result, { data: {} });
});
test("envía solo legal_name cuando legalName es el único campo dirty", () => {
const result = buildUpdateCompanySettingsParams({
companyId: "11111111-1111-4111-8111-111111111111",
form: {
defaultPaymentTermId: "",
...buildForm(),
legalName: " New Legal Name ",
},
dirtyFields: {
legalName: true,
},
});
assert.deepEqual(result, {
data: {
legal_name: "New Legal Name",
},
});
});
test("omite un campo nullable no dirty aunque tenga valor en el form", () => {
const result = buildUpdateCompanySettingsParams({
form: buildForm(),
dirtyFields: {
legalName: true,
},
});
assert.equal("trade_name" in result.data, false);
assert.equal("tin" in result.data, false);
assert.equal("email" in result.data, false);
assert.equal("phone" in result.data, false);
assert.equal("website" in result.data, false);
assert.equal("default_payment_term_id" in result.data, false);
});
test("convierte tradeName vacío dirty a null", () => {
const result = buildUpdateCompanySettingsParams({
form: {
...buildForm(),
tradeName: "",
},
dirtyFields: {
tradeName: true,
},
});
assert.deepEqual(result, {
data: {
trade_name: null,
},
});
});
test("convierte tin vacío dirty a null", () => {
const result = buildUpdateCompanySettingsParams({
form: {
...buildForm(),
tin: "",
},
dirtyFields: {
tin: true,
},
});
assert.deepEqual(result, {
data: {
tin: null,
},
});
});
test("convierte email vacío dirty a null", () => {
const result = buildUpdateCompanySettingsParams({
form: {
...buildForm(),
email: "",
},
dirtyFields: {
email: true,
},
});
assert.deepEqual(result, {
data: {
email: null,
},
});
});
test("convierte phone vacío dirty a null", () => {
const result = buildUpdateCompanySettingsParams({
form: {
...buildForm(),
phone: "",
},
dirtyFields: {
phone: true,
},
});
assert.deepEqual(result, {
data: {
phone: null,
},
});
});
test("convierte website vacío dirty a null", () => {
const result = buildUpdateCompanySettingsParams({
form: {
...buildForm(),
website: "",
},
dirtyFields: {
website: true,
},
});
assert.deepEqual(result, {
data: {
website: null,
},
});
});
test("convierte defaultPaymentTermId vacío dirty a null", () => {
const result = buildUpdateCompanySettingsParams({
form: {
...buildForm(),
defaultPaymentTermId: "",
},
dirtyFields: {
defaultPaymentTermId: true,
},
});
assert.deepEqual(result, {
companyId: "11111111-1111-4111-8111-111111111111",
data: {
default_payment_term_id: null,
},
});
});
test("conserva null explicito", () => {
test("envía varios campos dirty y omite el resto", () => {
const result = buildUpdateCompanySettingsParams({
companyId: "11111111-1111-4111-8111-111111111111",
form: {
defaultPaymentTermId: null,
...buildForm(),
legalName: " Acme New Legal ",
phone: " +34 911 111 111 ",
website: "",
},
dirtyFields: {
legalName: true,
phone: true,
website: true,
},
});
assert.equal(result.data.default_payment_term_id, null);
});
test("envia la seleccion valida", () => {
const result = buildUpdateCompanySettingsParams({
companyId: "11111111-1111-4111-8111-111111111111",
form: {
defaultPaymentTermId: "22222222-2222-4222-8222-222222222222",
assert.deepEqual(result, {
data: {
legal_name: "Acme New Legal",
phone: "+34 911 111 111",
website: null,
},
});
assert.equal(
result.data.default_payment_term_id,
"22222222-2222-4222-8222-222222222222"
);
});

View File

@ -1,20 +1,54 @@
import type { UpdateCompanyRequestDTO } from "../../../../../common";
import type { UpdateCurrentCompanyRequestDTO } from "../../../../common";
import type { CompanySettingsForm } from "../entities";
import type { FieldNamesMarkedBoolean } from "react-hook-form";
export const buildUpdateCompanySettingsParams = (params: {
companyId: string;
form: CompanySettingsForm;
dirtyFields: FieldNamesMarkedBoolean<CompanySettingsForm>;
}): {
companyId: string;
data: UpdateCompanyRequestDTO;
data: UpdateCurrentCompanyRequestDTO;
} => {
const data: UpdateCurrentCompanyRequestDTO = {};
if (params.dirtyFields.legalName) {
data.legal_name = toRequiredText(params.form.legalName);
}
if (params.dirtyFields.tradeName) {
data.trade_name = toNullableText(params.form.tradeName);
}
if (params.dirtyFields.tin) {
data.tin = toNullableText(params.form.tin);
}
if (params.dirtyFields.email) {
data.email = toNullableText(params.form.email);
}
if (params.dirtyFields.phone) {
data.phone = toNullableText(params.form.phone);
}
if (params.dirtyFields.website) {
data.website = toNullableText(params.form.website);
}
if (params.dirtyFields.defaultPaymentTermId) {
data.default_payment_term_id = toNullableText(params.form.defaultPaymentTermId);
}
return {
companyId: params.companyId,
data: {
default_payment_term_id:
params.form.defaultPaymentTermId == null || params.form.defaultPaymentTermId === ""
? null
: params.form.defaultPaymentTermId,
},
data,
};
};
const toRequiredText = (value: string): string => {
return value.trim();
};
const toNullableText = (value: string): string | null => {
const trimmedValue = value.trim();
return trimmedValue === "" ? null : trimmedValue;
};

View File

@ -1,2 +1,5 @@
export * from "./build-company-details-settings-page-error-view-model";
export * from "./build-company-settings-form";
export * from "./build-update-company-settings-params";
export * from "./normalize-company-details-submit-error";
export * from "./resolve-company-details-settings-feedback-state";

View File

@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ApiClientError } from "@erp/core/client";
import { ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
import { normalizeCompanyDetailsSubmitError } from "./normalize-company-details-submit-error";
test("mantiene ValidationErrorCollection sin cambios", () => {
const error = new ValidationErrorCollection("Validation failed", [
{
path: "legalName",
message: "Required",
},
]);
assert.equal(normalizeCompanyDetailsSubmitError(error), error);
});
test("convierte ApiClientError validation a ValidationErrorCollection mapeando paths snake_case", () => {
const error = new ApiClientError({
kind: "validation",
message: "technical",
issues: [
{
path: ["legal_name"],
message: "Required",
},
{
path: ["default_payment_term_id"],
message: "Not assignable",
},
],
});
const normalized = normalizeCompanyDetailsSubmitError(error);
assert.equal(isValidationErrorCollection(normalized), true);
assert.deepEqual(
(normalized as ValidationErrorCollection).details.map((detail) => detail.path),
["legalName", "defaultPaymentTermId"]
);
});
test("mantiene ApiClientError no validation como error general", () => {
const error = new ApiClientError({
kind: "server",
message: "technical",
});
assert.equal(normalizeCompanyDetailsSubmitError(error), error);
});
test("normaliza valores desconocidos a Error", () => {
const normalized = normalizeCompanyDetailsSubmitError("boom");
assert.equal(normalized instanceof Error, true);
assert.equal(normalized.message, "Unknown error");
});

View File

@ -0,0 +1,72 @@
import { isApiClientError } from "@erp/core/client";
import {
ValidationErrorCollection,
type ValidationErrorDetail,
isValidationErrorCollection,
} from "@repo/rdx-ddd";
const COMPANY_SETTINGS_FIELD_PATHS: Record<string, string> = {
legal_name: "legalName",
trade_name: "tradeName",
tin: "tin",
email: "email",
phone: "phone",
website: "website",
default_payment_term_id: "defaultPaymentTermId",
};
const mapCompanySettingsValidationPath = (path: readonly (string | number)[] | undefined) => {
if (!path || path.length === 0) {
return undefined;
}
const normalizedPath = path.map(String).join(".");
return COMPANY_SETTINGS_FIELD_PATHS[normalizedPath] ?? normalizedPath;
};
const toValidationErrorCollection = (error: unknown): ValidationErrorCollection | null => {
if (!isApiClientError(error) || error.kind !== "validation") {
return null;
}
const details = error.issues.reduce<ValidationErrorDetail[]>((acc, issue) => {
const message = issue.message?.trim();
if (!message) {
return acc;
}
acc.push({
path: mapCompanySettingsValidationPath(issue.path),
message,
});
return acc;
}, []);
if (details.length === 0) {
return null;
}
return new ValidationErrorCollection("Validation failed", details);
};
export const normalizeCompanyDetailsSubmitError = (
error: unknown
): Error | ValidationErrorCollection => {
if (isValidationErrorCollection(error)) {
return error;
}
const validationError = toValidationErrorCollection(error);
if (validationError) {
return validationError;
}
if (error instanceof Error) {
return error;
}
return new Error("Unknown error");
};

View File

@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ApiClientError } from "@erp/core/client";
import { resolveCompanyDetailsSettingsFeedbackState } from "./resolve-company-details-settings-feedback-state";
test("mantiene loading inicial mientras carga", () => {
const state = resolveCompanyDetailsSettingsFeedbackState({
isLoading: true,
isLoadError: false,
hasRequiredData: false,
error: null,
});
assert.equal(state.isInitialLoading, true);
assert.equal(state.hasBlockingError, false);
assert.equal(state.hasInlineError, false);
});
test("marca error bloqueante cuando falla sin datos requeridos", () => {
const state = resolveCompanyDetailsSettingsFeedbackState({
isLoading: false,
isLoadError: true,
hasRequiredData: false,
error: new ApiClientError({ kind: "server", message: "technical" }),
});
assert.equal(state.hasBlockingError, true);
assert.equal(state.hasInlineError, false);
});
test("mantiene error inline cuando falla un refetch con datos previos utilizables", () => {
const state = resolveCompanyDetailsSettingsFeedbackState({
isLoading: false,
isLoadError: true,
hasRequiredData: true,
error: new ApiClientError({ kind: "network", message: "technical" }),
});
assert.equal(state.isInitialLoading, false);
assert.equal(state.hasBlockingError, false);
assert.equal(state.hasInlineError, true);
});
test("no muestra pantalla bloqueante para cancelled sin datos", () => {
const state = resolveCompanyDetailsSettingsFeedbackState({
isLoading: false,
isLoadError: true,
hasRequiredData: false,
error: new ApiClientError({ kind: "cancelled", message: "technical" }),
});
assert.equal(state.isInitialLoading, true);
assert.equal(state.hasBlockingError, false);
assert.equal(state.hasInlineError, false);
});

View File

@ -0,0 +1,29 @@
import { isApiClientError } from "@erp/core/client";
interface ResolveCompanyDetailsSettingsFeedbackStateParams {
isLoading: boolean;
isLoadError: boolean;
hasRequiredData: boolean;
error: unknown;
}
export interface CompanyDetailsSettingsFeedbackState {
isInitialLoading: boolean;
hasBlockingError: boolean;
hasInlineError: boolean;
}
export const resolveCompanyDetailsSettingsFeedbackState = ({
isLoading,
isLoadError,
hasRequiredData,
error,
}: ResolveCompanyDetailsSettingsFeedbackStateParams): CompanyDetailsSettingsFeedbackState => {
const isCancelled = isApiClientError(error) && error.kind === "cancelled";
return {
isInitialLoading: isLoading || (isLoadError && isCancelled && !hasRequiredData),
hasBlockingError: isLoadError && !isCancelled && !hasRequiredData,
hasInlineError: isLoadError && !isCancelled && hasRequiredData,
};
};

View File

@ -92,7 +92,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
queryFn: async ({ signal }) => {
const dto = await dataSource.getOne<CompanyResponseDTO>(
"companies",
activeCompany?.id ?? "",
"current",
{
signal,
}

View File

@ -99,7 +99,7 @@ export const useUpdateProformaCustomerChangeController = (
queryKey: buildCompanySettingsQueryKey(companyId),
enabled: Boolean(companyId),
queryFn: async ({ signal }) => {
const dto = await dataSource.getOne<CompanyResponseDTO>("companies", companyId as string, {
const dto = await dataSource.getOne<CompanyResponseDTO>("companies", "current", {
signal,
});

View File

@ -64,6 +64,10 @@ Las restricciones detalladas sobre claims y contexto tenant se documentan en `te
Los contratos públicos de `requireIdentityAuthenticated(params)` y `requireIdentityTenant(params)` se documentan en `tenant-context.md`.
En la V1 actual, `requireIdentityTenant(params)` y `companyAccess` resuelven membership activa y company activa.
No debe interpretarse como enforcement de permisos granulares o RBAC.
Código backend nuevo no debe usar:
- `@erp/auth/api`
@ -90,6 +94,9 @@ Responsabilidad:
- `identity` decide qué `companyId` puede usar una cuenta según memberships activas.
- no devuelve la ficha completa de empresa.
- no decide todavía permisos granulares por operación.
Esto representa membership activa y accesibilidad tenant, no permisos por operación.
## Tenant context
@ -100,6 +107,14 @@ Responsabilidad:
La validación esperada, la semántica de errores y el shape mínimo de `req.user` se mantienen en `tenant-context.md`.
Ejemplo actual relevante:
```http
PATCH /companies/current
```
usa membership activa y contexto tenant válido, no `identity:companies:update`.
## Reglas de mantenimiento
- Backend nuevo: usar `@erp/identity/api`.

View File

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

View File

@ -1,8 +1,8 @@
import type {
IIdentityAuthPublicServices,
IIdentityCompanyAccessChecker,
IIdentityTenantCompanyFinder,
IIdentityPublicServices,
IIdentityTenantCompanyFinder,
} from "../../application";
import {
authenticateUser,

View File

@ -70,6 +70,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
const companyMembershipAccessRepository = new SequelizeCompanyMembershipAccessRepository(
database
);
const companyAccessChecker = new IdentityCompanyAccessChecker(companyMembershipAccessRepository);
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
@ -132,7 +133,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
},
},
companyAccess: {
checker: new IdentityCompanyAccessChecker(companyMembershipAccessRepository),
checker: companyAccessChecker,
},
};
};

View File

@ -38,6 +38,11 @@ export interface IdentityAuthSessionContextValue {
refreshAccessToken: () => Promise<string | null>;
selectActiveCompany: (companyId: string) => ActiveCompany | null;
changeActiveCompany: (companyId: string) => Promise<ActiveCompany | null>;
updateActiveCompany: (company: {
id: string;
legalName: string;
tradeName: string | null;
}) => void;
getAuthenticatedRedirectPath: (params?: {
returnTo?: string | null;
fallbackPath?: string;
@ -243,6 +248,37 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
[selectActiveCompany]
);
const updateActiveCompany = useCallback(
(company: { id: string; legalName: string; tradeName: string | null }) => {
setCompanySession((currentSession) => {
const availableCompanies = currentSession.availableCompanies.map((item) =>
item.id === company.id
? {
...item,
legalName: company.legalName,
tradeName: company.tradeName,
}
: item
);
const activeCompany =
currentSession.activeCompany?.id === company.id
? {
...currentSession.activeCompany,
legalName: company.legalName,
tradeName: company.tradeName,
}
: currentSession.activeCompany;
return {
availableCompanies,
activeCompany,
};
});
},
[]
);
const getAuthenticatedRedirectPath = useCallback(
(params?: { returnTo?: string | null; fallbackPath?: string }) => {
return resolveAuthenticatedRedirectPath({
@ -289,6 +325,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
refreshAccessToken,
selectActiveCompany,
changeActiveCompany,
updateActiveCompany,
getAuthenticatedRedirectPath,
clearSession,
}),
@ -307,6 +344,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac
refreshSessionMutation.isPending,
restoreSession,
selectActiveCompany,
updateActiveCompany,
session,
]
);

View File

@ -15,6 +15,12 @@
"^lint"
]
},
"test": {
"cache": false,
"dependsOn": [
"^test"
]
},
"dev": {
"cache": false,
"persistent": true
@ -46,4 +52,4 @@
"cache": false
}
}
}
}