From a4f82decc66fe713d4eadf25eb64f8069d468b74 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 17 Aug 2026 15:49:11 +0200 Subject: [PATCH] feat(companies): implement update current company use case and associated controllers - Add UpdateCurrentCompanyUseCase to handle company updates. - Create update-current-company.controller.ts to manage update requests. - Implement validation and mapping for update requests using Zod. - Add tests for the update use case and controller to ensure functionality. - Introduce error handling and feedback state management for company details settings. - Create utility functions for error normalization and feedback state resolution. - Implement tests for error handling and feedback state utilities. - Ensure nullable fields are handled correctly in DTOs and forms. --- apps/server/.env.test | 37 ++ apps/server/.env.test.example | 37 ++ apps/server/package.json | 4 +- apps/server/src/config/config.ts | 10 + apps/server/src/lib/modules/index.ts | 1 + .../src/lib/modules/module-loader.test.ts | 152 ++++++ apps/server/src/lib/modules/module-loader.ts | 99 ++-- .../src/lib/modules/service-registry.ts | 12 + .../http/companies-current.http.test.ts | 468 ++++++++++++++++++ .../src/testing/http/http-test-fixtures.ts | 233 +++++++++ .../src/testing/http/http-test-runtime.ts | 222 +++++++++ docs/README.md | 1 + docs/architecture/identity-and-companies.md | 36 ++ .../architecture/identity-migration-status.md | 26 + docs/architecture/tenant-context.md | 32 ++ .../frontend-erp-settings-system-design.md | 103 ++++ docs/testing/backend-http-integration.md | 130 +++++ modules/catalogs/src/common/locales/es.json | 62 +-- modules/companies/README.md | 220 +++++++- .../services/company-payment-term-resolver.ts | 14 +- .../use-cases/get-current-company.use-case.ts | 63 +++ .../application/companies/use-cases/index.ts | 2 + .../update-current-company.use-case.test.ts | 141 ++++++ .../update-current-company.use-case.ts | 270 ++++++++++ .../src/api/domain/companies/errors.ts | 12 +- .../src/api/infrastructure/di/companies.di.ts | 17 + .../express/companies-api-error-mapper.ts | 13 + .../express/companies.routes.ts | 37 +- .../controllers/disable-company.controller.ts | 15 +- .../controllers/enable-company.controller.ts | 15 +- .../get-company-by-id.controller.ts | 15 +- .../get-current-company.controller.ts | 30 ++ .../express/controllers/index.ts | 2 + .../controllers/update-company.controller.ts | 15 +- .../update-current-company.controller.ts | 35 ++ .../companies/src/common/dto/request/index.ts | 1 + .../update-current-company.request.dto.ts | 14 + modules/companies/src/common/locales/en.json | 87 +++- modules/companies/src/common/locales/es.json | 93 +++- ...use-company-details-settings-controller.ts | 139 ++++-- ...s-settings-page-error-view-model.entity.ts | 9 + .../entities/company-settings-form.schema.ts | 9 +- .../company-details/entities/index.ts | 1 + .../adapters/company-settings.adapter.test.ts | 32 ++ .../adapters/company-settings.adapter.ts | 4 + .../shared/api/get-company-settings.api.ts | 14 +- .../shared/api/update-company-settings.api.ts | 19 +- .../entities/company-settings.entity.ts | 4 + .../company-details/shared/hooks/keys.ts | 3 +- .../hooks/use-company-settings-query.ts | 13 +- .../use-company-settings-update-mutation.ts | 12 +- .../company-details-settings-page.test.tsx | 288 +++++++++++ .../pages/company-details-settings-page.tsx | 159 +++++- ...ils-settings-page-error-view-model.test.ts | 76 +++ ...-details-settings-page-error-view-model.ts | 53 ++ .../utils/build-company-settings-form.test.ts | 29 ++ .../utils/build-company-settings-form.ts | 8 +- ...ild-update-company-settings-params.test.ts | 184 ++++++- .../build-update-company-settings-params.ts | 56 ++- .../settings/company-details/utils/index.ts | 3 + ...alize-company-details-submit-error.test.ts | 59 +++ .../normalize-company-details-submit-error.ts | 72 +++ ...ny-details-settings-feedback-state.test.ts | 57 +++ ...company-details-settings-feedback-state.ts | 29 ++ .../use-create-proforma-controller.ts | 2 +- ...ate-proforma-customer-change-controller.ts | 2 +- modules/identity/README.md | 15 + .../identity-public-services.interface.ts | 3 +- .../di/identity-public-services.ts | 2 +- .../src/api/infrastructure/di/identity.di.ts | 3 +- .../context/identity-auth-session.context.tsx | 38 ++ turbo.json | 8 +- 72 files changed, 3928 insertions(+), 253 deletions(-) create mode 100644 apps/server/.env.test create mode 100644 apps/server/.env.test.example create mode 100644 apps/server/src/lib/modules/module-loader.test.ts create mode 100644 apps/server/src/testing/http/companies-current.http.test.ts create mode 100644 apps/server/src/testing/http/http-test-fixtures.ts create mode 100644 apps/server/src/testing/http/http-test-runtime.ts create mode 100644 docs/testing/backend-http-integration.md create mode 100644 modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts create mode 100644 modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/get-current-company.controller.ts create mode 100644 modules/companies/src/api/infrastructure/express/controllers/update-current-company.controller.ts create mode 100644 modules/companies/src/common/dto/request/update-current-company.request.dto.ts create mode 100644 modules/companies/src/web/settings/company-details/entities/company-details-settings-page-error-view-model.entity.ts create mode 100644 modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts create mode 100644 modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.test.tsx create mode 100644 modules/companies/src/web/settings/company-details/utils/build-company-details-settings-page-error-view-model.test.ts create mode 100644 modules/companies/src/web/settings/company-details/utils/build-company-details-settings-page-error-view-model.ts create mode 100644 modules/companies/src/web/settings/company-details/utils/build-company-settings-form.test.ts create mode 100644 modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.test.ts create mode 100644 modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.ts create mode 100644 modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.test.ts create mode 100644 modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.ts 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; + }) =>
{children}
, + 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} -
+ + } + title={t("settings.companyDetails.sections.general.title")} + variant="panel" + > + + + className="col-span-full" + description={t( + "settings.companyDetails.sections.general.fields.legalName.description" + )} + disabled={ctrl.isSaving} + inputClassName="bg-background" + label={t("settings.companyDetails.sections.general.fields.legalName.label")} + name="legalName" + placeholder={t( + "settings.companyDetails.sections.general.fields.legalName.placeholder" + )} + required + /> + + + description={t( + "settings.companyDetails.sections.general.fields.tradeName.description" + )} + disabled={ctrl.isSaving} + inputClassName="bg-background" + label={t("settings.companyDetails.sections.general.fields.tradeName.label")} + name="tradeName" + placeholder={t( + "settings.companyDetails.sections.general.fields.tradeName.placeholder" + )} + /> + + + description={t("settings.companyDetails.sections.general.fields.tin.description")} + disabled={ctrl.isSaving} + inputClassName="bg-background" + label={t("settings.companyDetails.sections.general.fields.tin.label")} + name="tin" + placeholder={t("settings.companyDetails.sections.general.fields.tin.placeholder")} + /> + + + + } + title={t("settings.companyDetails.sections.contact.title")} + variant="panel" + > + + + description={t("settings.companyDetails.sections.contact.fields.email.description")} + disabled={ctrl.isSaving} + inputClassName="bg-background" + label={t("settings.companyDetails.sections.contact.fields.email.label")} + name="email" + placeholder={t("settings.companyDetails.sections.contact.fields.email.placeholder")} + typePreset="email" + /> + + + description={t("settings.companyDetails.sections.contact.fields.phone.description")} + disabled={ctrl.isSaving} + inputClassName="bg-background" + label={t("settings.companyDetails.sections.contact.fields.phone.label")} + name="phone" + placeholder={t("settings.companyDetails.sections.contact.fields.phone.placeholder")} + typePreset="phone" + /> + + + className="col-span-full" + description={t( + "settings.companyDetails.sections.contact.fields.website.description" + )} + disabled={ctrl.isSaving} + inputClassName="bg-background" + label={t("settings.companyDetails.sections.contact.fields.website.label")} + name="website" + placeholder={t( + "settings.companyDetails.sections.contact.fields.website.placeholder" + )} + typePreset="url" + /> + + + } @@ -70,12 +185,14 @@ export const CompanyDetailsSettingsPage = () => { variant="panel" > - clearable + clearLabel={t("settings.companyDetails.sections.paymentTerm.field.none")} className="col-span-full" description={t("settings.companyDetails.sections.paymentTerm.field.description")} disabled={ctrl.isSaving} emptyValue="" + inputClassName="bg-background" items={ctrl.paymentTermOptions} label={t("settings.companyDetails.sections.paymentTerm.field.label")} name="defaultPaymentTermId" @@ -95,7 +212,7 @@ export const CompanyDetailsSettingsPage = () => { primary={ + ({ + "settings.companyDetails.pageError.notFound.title": "No se encontro la empresa activa", + "settings.companyDetails.pageError.notFound.description": + "La empresa seleccionada ya no existe o no esta disponible.", + "settings.companyDetails.pageError.forbidden.title": + "No tienes acceso a la empresa activa", + "settings.companyDetails.pageError.forbidden.description": + "No dispones de permisos para consultar esta configuracion.", + "settings.companyDetails.pageError.network.title": + "No se pudo conectar con el servidor", + "settings.companyDetails.pageError.network.description": + "Comprueba tu conexion e intentalo de nuevo.", + "settings.companyDetails.pageError.timeout.title": "La carga esta tardando demasiado", + "settings.companyDetails.pageError.timeout.description": + "El servidor no respondio dentro del tiempo esperado.", + "settings.companyDetails.pageError.server.title": "No se pudo cargar la empresa activa", + "settings.companyDetails.pageError.server.description": + "Se produjo un error interno al recuperar la configuracion de empresa.", + "settings.companyDetails.pageError.unavailable.title": + "Servicio temporalmente no disponible", + "settings.companyDetails.pageError.unavailable.description": + "El servicio de empresas no esta disponible en este momento.", + "settings.companyDetails.pageError.unknown.title": "No se pudo cargar la empresa activa", + "settings.companyDetails.pageError.unknown.description": + "Ha ocurrido un error inesperado al cargar la configuracion de empresa.", + })[key] ?? key; + +test("mapea notFound real a notFound sin retry", () => { + const viewModel = buildCompanyDetailsSettingsPageErrorViewModel( + new ApiClientError({ kind: "notFound", message: "technical", correlationId: "corr-404" }), + translate + ); + + assert.equal(viewModel?.variant, "notFound"); + assert.equal(viewModel?.canRetry, false); + assert.equal(viewModel?.reference, "corr-404"); +}); + +test("mapea forbidden sin retry", () => { + const viewModel = buildCompanyDetailsSettingsPageErrorViewModel( + new ApiClientError({ kind: "forbidden", message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, "forbidden"); + assert.equal(viewModel?.canRetry, false); +}); + +test("mapea network, timeout, server, unavailable y unknown con retry", () => { + for (const kind of ["network", "timeout", "server", "unavailable", "unknown"] as const) { + const viewModel = buildCompanyDetailsSettingsPageErrorViewModel( + new ApiClientError({ kind, message: "technical" }), + translate + ); + + assert.equal(viewModel?.variant, kind); + assert.equal(viewModel?.canRetry, true); + } +}); + +test("ignora cancelled", () => { + const viewModel = buildCompanyDetailsSettingsPageErrorViewModel( + new ApiClientError({ kind: "cancelled", message: "technical" }), + translate + ); + + assert.equal(viewModel, null); +}); diff --git a/modules/companies/src/web/settings/company-details/utils/build-company-details-settings-page-error-view-model.ts b/modules/companies/src/web/settings/company-details/utils/build-company-details-settings-page-error-view-model.ts new file mode 100644 index 00000000..f27b19a1 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/build-company-details-settings-page-error-view-model.ts @@ -0,0 +1,53 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { CompanyDetailsSettingsPageErrorViewModel } from "../entities"; + +interface Translate { + (key: string): string; +} + +const buildErrorVariant = ( + error: unknown +): CompanyDetailsSettingsPageErrorViewModel["variant"] | "cancelled" => { + if (!isApiClientError(error)) { + return "unknown"; + } + + switch (error.kind) { + case "cancelled": + return "cancelled"; + case "notFound": + return "notFound"; + case "forbidden": + return "forbidden"; + case "network": + return "network"; + case "timeout": + return "timeout"; + case "server": + return "server"; + case "unavailable": + return "unavailable"; + default: + return "unknown"; + } +}; + +export const buildCompanyDetailsSettingsPageErrorViewModel = ( + error: unknown, + translate: Translate +): CompanyDetailsSettingsPageErrorViewModel | null => { + const variant = buildErrorVariant(error); + + if (variant === "cancelled") { + return null; + } + + return { + variant, + title: translate(`settings.companyDetails.pageError.${variant}.title`), + description: translate(`settings.companyDetails.pageError.${variant}.description`), + canRetry: !["forbidden", "notFound"].includes(variant), + reference: isApiClientError(error) ? error.correlationId : undefined, + }; +}; diff --git a/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.test.ts b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.test.ts new file mode 100644 index 00000000..b3f16331 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CompanySettings } from "../shared/entities"; + +import { buildCompanySettingsForm } from "./build-company-settings-form"; + +test('convierte null del Entity a "" en el Form para campos nullable', () => { + const company: CompanySettings = { + id: "11111111-1111-4111-8111-111111111111", + legalName: "Acme Legal SL", + tradeName: null, + tin: null, + email: null, + phone: null, + website: null, + defaultPaymentTermId: null, + }; + + assert.deepEqual(buildCompanySettingsForm(company), { + legalName: "Acme Legal SL", + tradeName: "", + tin: "", + email: "", + phone: "", + website: "", + defaultPaymentTermId: "", + }); +}); diff --git a/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts index d24bccef..3900a620 100644 --- a/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts +++ b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts @@ -2,5 +2,11 @@ import type { CompanySettingsForm } from "../entities"; import type { CompanySettings } from "../shared/entities"; export const buildCompanySettingsForm = (company: CompanySettings): CompanySettingsForm => ({ - defaultPaymentTermId: company.defaultPaymentTermId, + legalName: company.legalName, + tradeName: company.tradeName ?? "", + tin: company.tin ?? "", + email: company.email ?? "", + phone: company.phone ?? "", + website: company.website ?? "", + defaultPaymentTermId: company.defaultPaymentTermId ?? "", }); diff --git a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts index 6c04ef03..9ba9792a 100644 --- a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts +++ b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts @@ -3,43 +3,187 @@ import test from "node:test"; import { buildUpdateCompanySettingsParams } from "./build-update-company-settings-params"; -test("normaliza vacio a null", () => { +const buildForm = () => ({ + legalName: "Acme Legal SL", + tradeName: "Acme Trade", + tin: "B12345678", + email: "info@acme.test", + phone: "+34 900 000 000", + website: "https://acme.test", + defaultPaymentTermId: "22222222-2222-4222-8222-222222222222", +}); + +test("devuelve PATCH vacío sin dirty fields", () => { + const result = buildUpdateCompanySettingsParams({ + form: buildForm(), + dirtyFields: {}, + }); + + assert.deepEqual(result, { data: {} }); +}); + +test("envía solo legal_name cuando legalName es el único campo dirty", () => { const result = buildUpdateCompanySettingsParams({ - companyId: "11111111-1111-4111-8111-111111111111", form: { - defaultPaymentTermId: "", + ...buildForm(), + legalName: " New Legal Name ", + }, + dirtyFields: { + legalName: true, + }, + }); + + assert.deepEqual(result, { + data: { + legal_name: "New Legal Name", + }, + }); +}); + +test("omite un campo nullable no dirty aunque tenga valor en el form", () => { + const result = buildUpdateCompanySettingsParams({ + form: buildForm(), + dirtyFields: { + legalName: true, + }, + }); + + assert.equal("trade_name" in result.data, false); + assert.equal("tin" in result.data, false); + assert.equal("email" in result.data, false); + assert.equal("phone" in result.data, false); + assert.equal("website" in result.data, false); + assert.equal("default_payment_term_id" in result.data, false); +}); + +test("convierte tradeName vacío dirty a null", () => { + const result = buildUpdateCompanySettingsParams({ + form: { + ...buildForm(), + tradeName: "", + }, + dirtyFields: { + tradeName: true, + }, + }); + + assert.deepEqual(result, { + data: { + trade_name: null, + }, + }); +}); + +test("convierte tin vacío dirty a null", () => { + const result = buildUpdateCompanySettingsParams({ + form: { + ...buildForm(), + tin: "", + }, + dirtyFields: { + tin: true, + }, + }); + + assert.deepEqual(result, { + data: { + tin: null, + }, + }); +}); + +test("convierte email vacío dirty a null", () => { + const result = buildUpdateCompanySettingsParams({ + form: { + ...buildForm(), + email: "", + }, + dirtyFields: { + email: true, + }, + }); + + assert.deepEqual(result, { + data: { + email: null, + }, + }); +}); + +test("convierte phone vacío dirty a null", () => { + const result = buildUpdateCompanySettingsParams({ + form: { + ...buildForm(), + phone: "", + }, + dirtyFields: { + phone: true, + }, + }); + + assert.deepEqual(result, { + data: { + phone: null, + }, + }); +}); + +test("convierte website vacío dirty a null", () => { + const result = buildUpdateCompanySettingsParams({ + form: { + ...buildForm(), + website: "", + }, + dirtyFields: { + website: true, + }, + }); + + assert.deepEqual(result, { + data: { + website: null, + }, + }); +}); + +test("convierte defaultPaymentTermId vacío dirty a null", () => { + const result = buildUpdateCompanySettingsParams({ + form: { + ...buildForm(), + defaultPaymentTermId: "", + }, + dirtyFields: { + defaultPaymentTermId: true, }, }); assert.deepEqual(result, { - companyId: "11111111-1111-4111-8111-111111111111", data: { default_payment_term_id: null, }, }); }); -test("conserva null explicito", () => { +test("envía varios campos dirty y omite el resto", () => { const result = buildUpdateCompanySettingsParams({ - companyId: "11111111-1111-4111-8111-111111111111", form: { - defaultPaymentTermId: null, + ...buildForm(), + legalName: " Acme New Legal ", + phone: " +34 911 111 111 ", + website: "", + }, + dirtyFields: { + legalName: true, + phone: true, + website: true, }, }); - assert.equal(result.data.default_payment_term_id, null); -}); - -test("envia la seleccion valida", () => { - const result = buildUpdateCompanySettingsParams({ - companyId: "11111111-1111-4111-8111-111111111111", - form: { - defaultPaymentTermId: "22222222-2222-4222-8222-222222222222", + assert.deepEqual(result, { + data: { + legal_name: "Acme New Legal", + phone: "+34 911 111 111", + website: null, }, }); - - assert.equal( - result.data.default_payment_term_id, - "22222222-2222-4222-8222-222222222222" - ); }); diff --git a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts index 1df9ab06..c921531c 100644 --- a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts +++ b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts @@ -1,20 +1,54 @@ -import type { UpdateCompanyRequestDTO } from "../../../../../common"; +import type { UpdateCurrentCompanyRequestDTO } from "../../../../common"; import type { CompanySettingsForm } from "../entities"; +import type { FieldNamesMarkedBoolean } from "react-hook-form"; export const buildUpdateCompanySettingsParams = (params: { - companyId: string; form: CompanySettingsForm; + dirtyFields: FieldNamesMarkedBoolean; }): { - companyId: string; - data: UpdateCompanyRequestDTO; + data: UpdateCurrentCompanyRequestDTO; } => { + const data: UpdateCurrentCompanyRequestDTO = {}; + + if (params.dirtyFields.legalName) { + data.legal_name = toRequiredText(params.form.legalName); + } + + if (params.dirtyFields.tradeName) { + data.trade_name = toNullableText(params.form.tradeName); + } + + if (params.dirtyFields.tin) { + data.tin = toNullableText(params.form.tin); + } + + if (params.dirtyFields.email) { + data.email = toNullableText(params.form.email); + } + + if (params.dirtyFields.phone) { + data.phone = toNullableText(params.form.phone); + } + + if (params.dirtyFields.website) { + data.website = toNullableText(params.form.website); + } + + if (params.dirtyFields.defaultPaymentTermId) { + data.default_payment_term_id = toNullableText(params.form.defaultPaymentTermId); + } + return { - companyId: params.companyId, - data: { - default_payment_term_id: - params.form.defaultPaymentTermId == null || params.form.defaultPaymentTermId === "" - ? null - : params.form.defaultPaymentTermId, - }, + data, }; }; + +const toRequiredText = (value: string): string => { + return value.trim(); +}; + +const toNullableText = (value: string): string | null => { + const trimmedValue = value.trim(); + + return trimmedValue === "" ? null : trimmedValue; +}; diff --git a/modules/companies/src/web/settings/company-details/utils/index.ts b/modules/companies/src/web/settings/company-details/utils/index.ts index 64d9c204..b007580f 100644 --- a/modules/companies/src/web/settings/company-details/utils/index.ts +++ b/modules/companies/src/web/settings/company-details/utils/index.ts @@ -1,2 +1,5 @@ +export * from "./build-company-details-settings-page-error-view-model"; export * from "./build-company-settings-form"; export * from "./build-update-company-settings-params"; +export * from "./normalize-company-details-submit-error"; +export * from "./resolve-company-details-settings-feedback-state"; diff --git a/modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.test.ts b/modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.test.ts new file mode 100644 index 00000000..0fc352eb --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; +import { ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; + +import { normalizeCompanyDetailsSubmitError } from "./normalize-company-details-submit-error"; + +test("mantiene ValidationErrorCollection sin cambios", () => { + const error = new ValidationErrorCollection("Validation failed", [ + { + path: "legalName", + message: "Required", + }, + ]); + + assert.equal(normalizeCompanyDetailsSubmitError(error), error); +}); + +test("convierte ApiClientError validation a ValidationErrorCollection mapeando paths snake_case", () => { + const error = new ApiClientError({ + kind: "validation", + message: "technical", + issues: [ + { + path: ["legal_name"], + message: "Required", + }, + { + path: ["default_payment_term_id"], + message: "Not assignable", + }, + ], + }); + + const normalized = normalizeCompanyDetailsSubmitError(error); + + assert.equal(isValidationErrorCollection(normalized), true); + assert.deepEqual( + (normalized as ValidationErrorCollection).details.map((detail) => detail.path), + ["legalName", "defaultPaymentTermId"] + ); +}); + +test("mantiene ApiClientError no validation como error general", () => { + const error = new ApiClientError({ + kind: "server", + message: "technical", + }); + + assert.equal(normalizeCompanyDetailsSubmitError(error), error); +}); + +test("normaliza valores desconocidos a Error", () => { + const normalized = normalizeCompanyDetailsSubmitError("boom"); + + assert.equal(normalized instanceof Error, true); + assert.equal(normalized.message, "Unknown error"); +}); diff --git a/modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.ts b/modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.ts new file mode 100644 index 00000000..c73e3c91 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/normalize-company-details-submit-error.ts @@ -0,0 +1,72 @@ +import { isApiClientError } from "@erp/core/client"; +import { + ValidationErrorCollection, + type ValidationErrorDetail, + isValidationErrorCollection, +} from "@repo/rdx-ddd"; + +const COMPANY_SETTINGS_FIELD_PATHS: Record = { + legal_name: "legalName", + trade_name: "tradeName", + tin: "tin", + email: "email", + phone: "phone", + website: "website", + default_payment_term_id: "defaultPaymentTermId", +}; + +const mapCompanySettingsValidationPath = (path: readonly (string | number)[] | undefined) => { + if (!path || path.length === 0) { + return undefined; + } + + const normalizedPath = path.map(String).join("."); + return COMPANY_SETTINGS_FIELD_PATHS[normalizedPath] ?? normalizedPath; +}; + +const toValidationErrorCollection = (error: unknown): ValidationErrorCollection | null => { + if (!isApiClientError(error) || error.kind !== "validation") { + return null; + } + + const details = error.issues.reduce((acc, issue) => { + const message = issue.message?.trim(); + + if (!message) { + return acc; + } + + acc.push({ + path: mapCompanySettingsValidationPath(issue.path), + message, + }); + + return acc; + }, []); + + if (details.length === 0) { + return null; + } + + return new ValidationErrorCollection("Validation failed", details); +}; + +export const normalizeCompanyDetailsSubmitError = ( + error: unknown +): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error; + } + + const validationError = toValidationErrorCollection(error); + + if (validationError) { + return validationError; + } + + if (error instanceof Error) { + return error; + } + + return new Error("Unknown error"); +}; diff --git a/modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.test.ts b/modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.test.ts new file mode 100644 index 00000000..18439e39 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ApiClientError } from "@erp/core/client"; + +import { resolveCompanyDetailsSettingsFeedbackState } from "./resolve-company-details-settings-feedback-state"; + +test("mantiene loading inicial mientras carga", () => { + const state = resolveCompanyDetailsSettingsFeedbackState({ + isLoading: true, + isLoadError: false, + hasRequiredData: false, + error: null, + }); + + assert.equal(state.isInitialLoading, true); + assert.equal(state.hasBlockingError, false); + assert.equal(state.hasInlineError, false); +}); + +test("marca error bloqueante cuando falla sin datos requeridos", () => { + const state = resolveCompanyDetailsSettingsFeedbackState({ + isLoading: false, + isLoadError: true, + hasRequiredData: false, + error: new ApiClientError({ kind: "server", message: "technical" }), + }); + + assert.equal(state.hasBlockingError, true); + assert.equal(state.hasInlineError, false); +}); + +test("mantiene error inline cuando falla un refetch con datos previos utilizables", () => { + const state = resolveCompanyDetailsSettingsFeedbackState({ + isLoading: false, + isLoadError: true, + hasRequiredData: true, + error: new ApiClientError({ kind: "network", message: "technical" }), + }); + + assert.equal(state.isInitialLoading, false); + assert.equal(state.hasBlockingError, false); + assert.equal(state.hasInlineError, true); +}); + +test("no muestra pantalla bloqueante para cancelled sin datos", () => { + const state = resolveCompanyDetailsSettingsFeedbackState({ + isLoading: false, + isLoadError: true, + hasRequiredData: false, + error: new ApiClientError({ kind: "cancelled", message: "technical" }), + }); + + assert.equal(state.isInitialLoading, true); + assert.equal(state.hasBlockingError, false); + assert.equal(state.hasInlineError, false); +}); diff --git a/modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.ts b/modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.ts new file mode 100644 index 00000000..8f66428d --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/resolve-company-details-settings-feedback-state.ts @@ -0,0 +1,29 @@ +import { isApiClientError } from "@erp/core/client"; + +interface ResolveCompanyDetailsSettingsFeedbackStateParams { + isLoading: boolean; + isLoadError: boolean; + hasRequiredData: boolean; + error: unknown; +} + +export interface CompanyDetailsSettingsFeedbackState { + isInitialLoading: boolean; + hasBlockingError: boolean; + hasInlineError: boolean; +} + +export const resolveCompanyDetailsSettingsFeedbackState = ({ + isLoading, + isLoadError, + hasRequiredData, + error, +}: ResolveCompanyDetailsSettingsFeedbackStateParams): CompanyDetailsSettingsFeedbackState => { + const isCancelled = isApiClientError(error) && error.kind === "cancelled"; + + return { + isInitialLoading: isLoading || (isLoadError && isCancelled && !hasRequiredData), + hasBlockingError: isLoadError && !isCancelled && !hasRequiredData, + hasInlineError: isLoadError && !isCancelled && hasRequiredData, + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts index 215574f5..d26a3bde 100644 --- a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts @@ -92,7 +92,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle queryFn: async ({ signal }) => { const dto = await dataSource.getOne( "companies", - activeCompany?.id ?? "", + "current", { signal, } diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts index 07511d03..c5554841 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts @@ -99,7 +99,7 @@ export const useUpdateProformaCustomerChangeController = ( queryKey: buildCompanySettingsQueryKey(companyId), enabled: Boolean(companyId), queryFn: async ({ signal }) => { - const dto = await dataSource.getOne("companies", companyId as string, { + const dto = await dataSource.getOne("companies", "current", { signal, }); diff --git a/modules/identity/README.md b/modules/identity/README.md index 19189d55..5eee7447 100644 --- a/modules/identity/README.md +++ b/modules/identity/README.md @@ -64,6 +64,10 @@ Las restricciones detalladas sobre claims y contexto tenant se documentan en `te Los contratos públicos de `requireIdentityAuthenticated(params)` y `requireIdentityTenant(params)` se documentan en `tenant-context.md`. +En la V1 actual, `requireIdentityTenant(params)` y `companyAccess` resuelven membership activa y company activa. + +No debe interpretarse como enforcement de permisos granulares o RBAC. + Código backend nuevo no debe usar: - `@erp/auth/api` @@ -90,6 +94,9 @@ Responsabilidad: - `identity` decide qué `companyId` puede usar una cuenta según memberships activas. - no devuelve la ficha completa de empresa. +- no decide todavía permisos granulares por operación. + +Esto representa membership activa y accesibilidad tenant, no permisos por operación. ## Tenant context @@ -100,6 +107,14 @@ Responsabilidad: La validación esperada, la semántica de errores y el shape mínimo de `req.user` se mantienen en `tenant-context.md`. +Ejemplo actual relevante: + +```http +PATCH /companies/current +``` + +usa membership activa y contexto tenant válido, no `identity:companies:update`. + ## Reglas de mantenimiento - Backend nuevo: usar `@erp/identity/api`. diff --git a/modules/identity/src/api/application/services/identity-public-services.interface.ts b/modules/identity/src/api/application/services/identity-public-services.interface.ts index f227c603..40c37fa5 100644 --- a/modules/identity/src/api/application/services/identity-public-services.interface.ts +++ b/modules/identity/src/api/application/services/identity-public-services.interface.ts @@ -1,6 +1,7 @@ -import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface"; import type { IIdentityCompanyAccessChecker } from "../company-memberships"; +import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface"; + export interface IIdentityPublicServices { auth: IIdentityAuthPublicServices; companyAccess: IIdentityCompanyAccessChecker; diff --git a/modules/identity/src/api/infrastructure/di/identity-public-services.ts b/modules/identity/src/api/infrastructure/di/identity-public-services.ts index ddd819b7..13b809b4 100644 --- a/modules/identity/src/api/infrastructure/di/identity-public-services.ts +++ b/modules/identity/src/api/infrastructure/di/identity-public-services.ts @@ -1,8 +1,8 @@ import type { IIdentityAuthPublicServices, IIdentityCompanyAccessChecker, - IIdentityTenantCompanyFinder, IIdentityPublicServices, + IIdentityTenantCompanyFinder, } from "../../application"; import { authenticateUser, diff --git a/modules/identity/src/api/infrastructure/di/identity.di.ts b/modules/identity/src/api/infrastructure/di/identity.di.ts index d430b9d1..1e14c5f8 100644 --- a/modules/identity/src/api/infrastructure/di/identity.di.ts +++ b/modules/identity/src/api/infrastructure/di/identity.di.ts @@ -70,6 +70,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna const companyMembershipAccessRepository = new SequelizeCompanyMembershipAccessRepository( database ); + const companyAccessChecker = new IdentityCompanyAccessChecker(companyMembershipAccessRepository); const passwordHasher = new BcryptPasswordHasher({ rounds: 10 }); const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({ @@ -132,7 +133,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna }, }, companyAccess: { - checker: new IdentityCompanyAccessChecker(companyMembershipAccessRepository), + checker: companyAccessChecker, }, }; }; diff --git a/modules/identity/src/web/context/identity-auth-session.context.tsx b/modules/identity/src/web/context/identity-auth-session.context.tsx index 1602f002..e949a576 100644 --- a/modules/identity/src/web/context/identity-auth-session.context.tsx +++ b/modules/identity/src/web/context/identity-auth-session.context.tsx @@ -38,6 +38,11 @@ export interface IdentityAuthSessionContextValue { refreshAccessToken: () => Promise; selectActiveCompany: (companyId: string) => ActiveCompany | null; changeActiveCompany: (companyId: string) => Promise; + updateActiveCompany: (company: { + id: string; + legalName: string; + tradeName: string | null; + }) => void; getAuthenticatedRedirectPath: (params?: { returnTo?: string | null; fallbackPath?: string; @@ -243,6 +248,37 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac [selectActiveCompany] ); + const updateActiveCompany = useCallback( + (company: { id: string; legalName: string; tradeName: string | null }) => { + setCompanySession((currentSession) => { + const availableCompanies = currentSession.availableCompanies.map((item) => + item.id === company.id + ? { + ...item, + legalName: company.legalName, + tradeName: company.tradeName, + } + : item + ); + + const activeCompany = + currentSession.activeCompany?.id === company.id + ? { + ...currentSession.activeCompany, + legalName: company.legalName, + tradeName: company.tradeName, + } + : currentSession.activeCompany; + + return { + availableCompanies, + activeCompany, + }; + }); + }, + [] + ); + const getAuthenticatedRedirectPath = useCallback( (params?: { returnTo?: string | null; fallbackPath?: string }) => { return resolveAuthenticatedRedirectPath({ @@ -289,6 +325,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshAccessToken, selectActiveCompany, changeActiveCompany, + updateActiveCompany, getAuthenticatedRedirectPath, clearSession, }), @@ -307,6 +344,7 @@ export function IdentityAuthSessionProvider({ children }: { children: React.Reac refreshSessionMutation.isPending, restoreSession, selectActiveCompany, + updateActiveCompany, session, ] ); diff --git a/turbo.json b/turbo.json index fa7f8810..2aa0aa5e 100644 --- a/turbo.json +++ b/turbo.json @@ -15,6 +15,12 @@ "^lint" ] }, + "test": { + "cache": false, + "dependsOn": [ + "^test" + ] + }, "dev": { "cache": false, "persistent": true @@ -46,4 +52,4 @@ "cache": false } } -} \ No newline at end of file +}