diff --git a/apps/server/.env.test b/apps/server/.env.test
new file mode 100644
index 00000000..9db22e91
--- /dev/null
+++ b/apps/server/.env.test
@@ -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
diff --git a/apps/server/.env.test.example b/apps/server/.env.test.example
new file mode 100644
index 00000000..9db22e91
--- /dev/null
+++ b/apps/server/.env.test.example
@@ -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
diff --git a/apps/server/package.json b/apps/server/package.json
index 0a0c0764..e29df1fa 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -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"
}
-}
\ No newline at end of file
+}
diff --git a/apps/server/src/config/config.ts b/apps/server/src/config/config.ts
index 5315ee5a..5192fd08 100644
--- a/apps/server/src/config/config.ts
+++ b/apps/server/src/config/config.ts
@@ -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;
+};
diff --git a/apps/server/src/lib/modules/index.ts b/apps/server/src/lib/modules/index.ts
index 5cdba46e..09252715 100644
--- a/apps/server/src/lib/modules/index.ts
+++ b/apps/server/src/lib/modules/index.ts
@@ -1,2 +1,3 @@
+export * from "./model-loader";
export * from "./module-loader";
export * from "./service-registry";
diff --git a/apps/server/src/lib/modules/module-loader.test.ts b/apps/server/src/lib/modules/module-loader.test.ts
new file mode 100644
index 00000000..afc1b0e5
--- /dev/null
+++ b/apps/server/src/lib/modules/module-loader.test.ts
@@ -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;
+ models: Record;
+};
+
+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
+ );
+});
diff --git a/apps/server/src/lib/modules/module-loader.ts b/apps/server/src/lib/modules/module-loader.ts
index 8f6a2b40..7dac063e 100644
--- a/apps/server/src/lib/modules/module-loader.ts
+++ b/apps/server/src/lib/modules/module-loader.ts
@@ -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);
+ 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:
- const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise | 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) {
diff --git a/apps/server/src/lib/modules/service-registry.ts b/apps/server/src/lib/modules/service-registry.ts
index d766c4f3..9e00249a 100644
--- a/apps/server/src/lib/modules/service-registry.ts
+++ b/apps/server/src/lib/modules/service-registry.ts
@@ -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];
+ }
+}
diff --git a/apps/server/src/testing/http/companies-current.http.test.ts b/apps/server/src/testing/http/companies-current.http.test.ts
new file mode 100644
index 00000000..fc3ed724
--- /dev/null
+++ b/apps/server/src/testing/http/companies-current.http.test.ts
@@ -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>,
+ 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;
+}
diff --git a/apps/server/src/testing/http/http-test-fixtures.ts b/apps/server/src/testing/http/http-test-fixtures.ts
new file mode 100644
index 00000000..58a6ba70
--- /dev/null
+++ b/apps/server/src/testing/http/http-test-fixtures.ts
@@ -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 {
+ const headers: Record = {};
+
+ 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();
+}
diff --git a/apps/server/src/testing/http/http-test-runtime.ts b/apps/server/src/testing/http/http-test-runtime.ts
new file mode 100644
index 00000000..1cd0771e
--- /dev/null
+++ b/apps/server/src/testing/http/http-test-runtime.ts
@@ -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;
+}
+
+/**
+ * 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 {
+ 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 {
+ 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 {
+ await new Promise((resolve, reject) => {
+ server.listen(0, "127.0.0.1", (error?: Error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ resolve();
+ });
+ });
+}
+
+async function closeServer(server: Server): Promise {
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+
+ resolve();
+ });
+ });
+}
diff --git a/docs/README.md b/docs/README.md
index 048ef073..5b6ad42c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/architecture/identity-and-companies.md b/docs/architecture/identity-and-companies.md
index 01b9e8f8..ab619c2d 100644
--- a/docs/architecture/identity-and-companies.md
+++ b/docs/architecture/identity-and-companies.md
@@ -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
```
+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.
diff --git a/docs/architecture/identity-migration-status.md b/docs/architecture/identity-migration-status.md
index aaa7ff18..9f600a9c 100644
--- a/docs/architecture/identity-migration-status.md
+++ b/docs/architecture/identity-migration-status.md
@@ -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`.
diff --git a/docs/architecture/tenant-context.md b/docs/architecture/tenant-context.md
index 276e98c7..4236ec90 100644
--- a/docs/architecture/tenant-context.md
+++ b/docs/architecture/tenant-context.md
@@ -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:
diff --git a/docs/settings/frontend-erp-settings-system-design.md b/docs/settings/frontend-erp-settings-system-design.md
index dc7b799a..11d3d412 100644
--- a/docs/settings/frontend-erp-settings-system-design.md
+++ b/docs/settings/frontend-erp-settings-system-design.md
@@ -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:
diff --git a/docs/testing/backend-http-integration.md b/docs/testing/backend-http-integration.md
new file mode 100644
index 00000000..401a62a4
--- /dev/null
+++ b/docs/testing/backend-http-integration.md
@@ -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
diff --git a/modules/catalogs/src/common/locales/es.json b/modules/catalogs/src/common/locales/es.json
index 4e482b39..62dbe53f 100644
--- a/modules/catalogs/src/common/locales/es.json
+++ b/modules/catalogs/src/common/locales/es.json
@@ -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."
}
}
},
diff --git a/modules/companies/README.md b/modules/companies/README.md
index 5a3a5437..f6f3bbe8 100644
--- a/modules/companies/README.md
+++ b/modules/companies/README.md
@@ -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
diff --git a/modules/companies/src/api/application/companies/services/company-payment-term-resolver.ts b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.ts
index a8ff3742..ed6a520c 100644
--- a/modules/companies/src/api/application/companies/services/company-payment-term-resolver.ts
+++ b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.ts
@@ -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);
diff --git a/modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts
new file mode 100644
index 00000000..42b04bed
--- /dev/null
+++ b/modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts
@@ -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> {
+ 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,
+ };
+}
diff --git a/modules/companies/src/api/application/companies/use-cases/index.ts b/modules/companies/src/api/application/companies/use-cases/index.ts
index a94ddc38..a6865658 100644
--- a/modules/companies/src/api/application/companies/use-cases/index.ts
+++ b/modules/companies/src/api/application/companies/use-cases/index.ts
@@ -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";
diff --git a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts
new file mode 100644
index 00000000..3db2f2e6
--- /dev/null
+++ b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts
@@ -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 (work: (transaction: unknown) => Promise) => 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 | undefined;
+ let receivedId: Maybe | 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 | 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());
+});
diff --git a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts
new file mode 100644
index 00000000..aa7f7e63
--- /dev/null
+++ b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts
@@ -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> {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 | 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,
+ };
+}
diff --git a/modules/companies/src/api/domain/companies/errors.ts b/modules/companies/src/api/domain/companies/errors.ts
index 41c989b9..7ecb1950 100644
--- a/modules/companies/src/api/domain/companies/errors.ts
+++ b/modules/companies/src/api/domain/companies/errors.ts
@@ -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;
diff --git a/modules/companies/src/api/infrastructure/di/companies.di.ts b/modules/companies/src/api/infrastructure/di/companies.di.ts
index 3390f7b8..93e41249 100644
--- a/modules/companies/src/api/infrastructure/di/companies.di.ts
+++ b/modules/companies/src/api/infrastructure/di/companies.di.ts
@@ -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,
diff --git a/modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts b/modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts
index 1644aebd..530d5e17 100644
--- a/modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts
+++ b/modules/companies/src/api/infrastructure/express/companies-api-error-mapper.ts
@@ -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);
diff --git a/modules/companies/src/api/infrastructure/express/companies.routes.ts b/modules/companies/src/api/infrastructure/express/companies.routes.ts
index aa569058..540de480 100644
--- a/modules/companies/src/api/infrastructure/express/companies.routes.ts
+++ b/modules/companies/src/api/infrastructure/express/companies.routes.ts
@@ -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("companies");
+ const identityServices = getService("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());
diff --git a/modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts
index a29069af..efd13111 100644
--- a/modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts
+++ b/modules/companies/src/api/infrastructure/express/controllers/disable-company.controller.ts
@@ -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(
diff --git a/modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts
index 09878d70..f120c594 100644
--- a/modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts
+++ b/modules/companies/src/api/infrastructure/express/controllers/enable-company.controller.ts
@@ -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(
diff --git a/modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts
index 60dd7f2a..c49fea61 100644
--- a/modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts
+++ b/modules/companies/src/api/infrastructure/express/controllers/get-company-by-id.controller.ts
@@ -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(
diff --git a/modules/companies/src/api/infrastructure/express/controllers/get-current-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/get-current-company.controller.ts
new file mode 100644
index 00000000..d5136833
--- /dev/null
+++ b/modules/companies/src/api/infrastructure/express/controllers/get-current-company.controller.ts
@@ -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)
+ );
+ }
+}
diff --git a/modules/companies/src/api/infrastructure/express/controllers/index.ts b/modules/companies/src/api/infrastructure/express/controllers/index.ts
index 890f4382..706ccaeb 100644
--- a/modules/companies/src/api/infrastructure/express/controllers/index.ts
+++ b/modules/companies/src/api/infrastructure/express/controllers/index.ts
@@ -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";
diff --git a/modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts
index 625fd459..923d9f21 100644
--- a/modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts
+++ b/modules/companies/src/api/infrastructure/express/controllers/update-company.controller.ts
@@ -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 });
diff --git a/modules/companies/src/api/infrastructure/express/controllers/update-current-company.controller.ts b/modules/companies/src/api/infrastructure/express/controllers/update-current-company.controller.ts
new file mode 100644
index 00000000..21f22b71
--- /dev/null
+++ b/modules/companies/src/api/infrastructure/express/controllers/update-current-company.controller.ts
@@ -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)
+ );
+ }
+}
diff --git a/modules/companies/src/common/dto/request/index.ts b/modules/companies/src/common/dto/request/index.ts
index 163df428..2b147080 100644
--- a/modules/companies/src/common/dto/request/index.ts
+++ b/modules/companies/src/common/dto/request/index.ts
@@ -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";
diff --git a/modules/companies/src/common/dto/request/update-current-company.request.dto.ts b/modules/companies/src/common/dto/request/update-current-company.request.dto.ts
new file mode 100644
index 00000000..95059ba3
--- /dev/null
+++ b/modules/companies/src/common/dto/request/update-current-company.request.dto.ts
@@ -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;
diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json
index 83f4a625..66aad3af 100644
--- a/modules/companies/src/common/locales/en.json
+++ b/modules/companies/src/common/locales/en.json
@@ -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."
+ }
}
}
}
diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json
index 8352fe13..bfd68998 100644
--- a/modules/companies/src/common/locales/es.json
+++ b/modules/companies/src/common/locales/es.json
@@ -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."
+ }
}
}
}
diff --git a/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts b/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts
index 452091e6..e87836b0 100644
--- a/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts
+++ b/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts
@@ -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(() => {
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(() => {
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) => {
@@ -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(),
diff --git a/modules/companies/src/web/settings/company-details/entities/company-details-settings-page-error-view-model.entity.ts b/modules/companies/src/web/settings/company-details/entities/company-details-settings-page-error-view-model.entity.ts
new file mode 100644
index 00000000..ddd1a311
--- /dev/null
+++ b/modules/companies/src/web/settings/company-details/entities/company-details-settings-page-error-view-model.entity.ts
@@ -0,0 +1,9 @@
+import type { PageErrorStateVariant } from "@erp/core/components";
+
+export interface CompanyDetailsSettingsPageErrorViewModel {
+ variant: PageErrorStateVariant;
+ title: string;
+ description: string;
+ canRetry: boolean;
+ reference?: string;
+}
diff --git a/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts
index eaf184f8..875fd2a1 100644
--- a/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts
+++ b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts
@@ -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;
diff --git a/modules/companies/src/web/settings/company-details/entities/index.ts b/modules/companies/src/web/settings/company-details/entities/index.ts
index a89b3b6f..fffb822f 100644
--- a/modules/companies/src/web/settings/company-details/entities/index.ts
+++ b/modules/companies/src/web/settings/company-details/entities/index.ts
@@ -1 +1,2 @@
+export * from "./company-details-settings-page-error-view-model.entity";
export * from "./company-settings-form.schema";
diff --git a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts
new file mode 100644
index 00000000..6d2b968c
--- /dev/null
+++ b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts
@@ -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,
+ });
+});
diff --git a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts
index 68946093..6e0f967d 100644
--- a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts
+++ b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts
@@ -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,
};
},
diff --git a/modules/companies/src/web/settings/company-details/shared/api/get-company-settings.api.ts b/modules/companies/src/web/settings/company-details/shared/api/get-company-settings.api.ts
index 09c2b7b1..6907fc27 100644
--- a/modules/companies/src/web/settings/company-details/shared/api/get-company-settings.api.ts
+++ b/modules/companies/src/web/settings/company-details/shared/api/get-company-settings.api.ts
@@ -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 => {
- return dataSource.getOne("companies", params.companyId, {
- signal: params.signal,
+ return dataSource.custom, CompanyResponseDTO>({
+ path: "companies/current",
+ method: "get",
+ signal: params?.signal,
+ data: {},
});
};
diff --git a/modules/companies/src/web/settings/company-details/shared/api/update-company-settings.api.ts b/modules/companies/src/web/settings/company-details/shared/api/update-company-settings.api.ts
index 4f99dc94..cec36065 100644
--- a/modules/companies/src/web/settings/company-details/shared/api/update-company-settings.api.ts
+++ b/modules/companies/src/web/settings/company-details/shared/api/update-company-settings.api.ts
@@ -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 => {
- return dataSource.updateOne(
- "companies",
- params.companyId,
- params.data
- );
+ return dataSource.custom({
+ path: "companies/current",
+ method: "patch",
+ data: params.data,
+ });
};
diff --git a/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts b/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts
index 07f1914e..738f08c8 100644
--- a/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts
+++ b/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts
@@ -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;
}
diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/keys.ts b/modules/companies/src/web/settings/company-details/shared/hooks/keys.ts
index c439b65d..63e76f76 100644
--- a/modules/companies/src/web/settings/company-details/shared/hooks/keys.ts
+++ b/modules/companies/src/web/settings/company-details/shared/hooks/keys.ts
@@ -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",
diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts
index 1749323d..c3411264 100644
--- a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts
+++ b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts
@@ -7,19 +7,14 @@ import type { CompanySettings } from "../entities";
import { COMPANY_SETTINGS_QUERY_KEY } from "./keys";
-export const useCompanySettingsQuery = (
- companyId?: string
-): UseQueryResult => {
+export const useCompanySettingsQuery = (): UseQueryResult => {
const dataSource = useDataSource();
return useQuery({
- 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);
},
diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts
index 7abb6a6e..45b1eaff 100644
--- a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts
+++ b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts
@@ -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({
+ return useMutation({
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);
},
});
};
diff --git a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx
new file mode 100644
index 00000000..08490188
--- /dev/null
+++ b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx
@@ -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 | undefined;
+let capturedResetProps: Record | undefined;
+let capturedPageErrorProps: Record | undefined;
+const capturedTextFields: Array> = [];
+const capturedSelectFields: Array> = [];
+
+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 }) => (
+ {message}
+ ),
+ PageErrorState: (props: Record) => {
+ capturedPageErrorProps = props;
+ return {String(props.title)}
;
+ },
+ },
+});
+
+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) => {
+ capturedResetProps = props;
+ return null;
+ },
+ RdxEditorSection: ({
+ children,
+ title,
+ }: {
+ children: ReactNode;
+ title: string;
+ }) => ,
+ RdxEditorStickyFooter: () => null,
+ RdxFormGrid: ({ children }: { children: ReactNode }) => {children}
,
+ RhfSubmitActionButton: (props: Record) => {
+ capturedSubmitProps = props;
+ return null;
+ },
+ SelectField: (props: Record) => {
+ capturedSelectFields.push(props);
+ return {String(props.label)}
;
+ },
+ TextField: (props: Record) => {
+ capturedTextFields.push(props);
+ return {String(props.label)}
;
+ },
+ },
+});
+
+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
+ )[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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ assert.equal(capturedSubmitProps?.disabled, true);
+ assert.equal(capturedSubmitProps?.isBusy, true);
+ assert.equal(capturedResetProps?.disabled, true);
+});
diff --git a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx
index 4790aec2..e911fcb8 100644
--- a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx
+++ b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx
@@ -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 ;
+ if (ctrl.isInitialLoading) {
+ return (
+
+
+ {t("settings.companyDetails.loading")}
+
+
+ );
}
if (ctrl.pageError) {
return (
-
+
);
}
@@ -55,14 +74,110 @@ export const CompanyDetailsSettingsPage = () => {
- {ctrl.companyQuery.isError ? (
+ {ctrl.inlineLoadError ? (
) : null}
-