ERP-ARCH-01-tenant-authorization-cache

This commit is contained in:
David Arranz 2026-10-16 19:00:01 +02:00
parent da362fef64
commit 3d8256b694
213 changed files with 4202 additions and 646 deletions

View File

@ -1,6 +1,7 @@
import http from "node:http";
import os from "node:os";
import type { ICompanyPublicServices } from "@erp/companies/api";
import {
AsyncLocalStorageOperationContextStore,
AuditChangeModel,
@ -275,6 +276,11 @@ process.on("uncaughtException", async (error: Error) => {
},
});
await startModules({
tenantCompanyFinder: getServiceScoped<ICompanyPublicServices>(
"server-infrastructure",
["companies"],
"companies:general"
).finder,
config,
app,
database,

View File

@ -46,7 +46,7 @@ test("crea el grafo mínimo y nunca persiste la contraseña en claro", async ()
}) as never,
}
);
assert.equal(queries.length, 6);
assert.equal(queries.length, 7);
assert.equal(result.email, "e2e@example.test");
assert.equal(result.companySlug, "e2e-company");
const account = queries[0];
@ -126,7 +126,8 @@ for (const [name, failureAt] of [
["company", 2],
["membership", 3],
["role", 4],
["onboarding", 6],
["system-owner", 6],
["onboarding", 7],
] as const) {
test(`revierte la transacción si falla ${name}`, async () => {
const previous = process.env.NODE_ENV;
@ -174,7 +175,7 @@ function dependenciesWithFailure(failureAt: number, events: string[]) {
events.push(`query:${position}`);
if (position === failureAt) {
throw new Error(
`${["account", "company", "membership", "role", "membership-role", "onboarding"][position - 1]} failed`
`${["account", "company", "membership", "role", "membership-role", "system-owner", "onboarding"][position - 1]} failed`
);
}
},

View File

@ -91,6 +91,9 @@ async function createBootstrapData(
"INSERT INTO identity_company_membership_roles (membership_id, role_id) VALUES (:membershipId, :roleId)",
{ membershipId, roleId }
);
await query("INSERT INTO identity_system_owner (id, account_id) VALUES (1, :accountId)", {
accountId,
});
await query(
"INSERT INTO initial_onboardings (id, account_id, status, company_id, created_at, updated_at, completed_at) VALUES (:id, :accountId, 'completed', :companyId, NOW(), NOW(), NOW())",
{ id: onboardingId, accountId, companyId }

View File

@ -0,0 +1,355 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import type { IModuleServer } from "@erp/core/api";
import { getServiceScoped } from "@erp/core/api";
import type { IAccountCreateProps, IIdentityPublicServices } from "@erp/identity/api";
import {
AccountStatus,
BcryptPasswordHasher,
requireIdentityAuthenticated,
requireIdentityTenant,
requirePermission,
} from "@erp/identity/api";
import { EmailAddress, LanguageCode, Name, UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import type { Request, Response } from "express";
import companiesModule from "../../../../../modules/companies/src/api/index";
import { collectModulePermissions } from "../../../../../modules/core/src/api/modules/module-permissions";
import { CompanyAdminRoleEnsurer } from "../../../../../modules/identity/src/api/application/roles/services/company-admin-role-ensurer";
import identityModule from "../../../../../modules/identity/src/api/index";
import { synchronizeCompanyAdminRoles } from "../../../../../modules/identity/src/api/infrastructure/di/synchronize-company-admin-roles";
import { RoleModel } from "../../../../../modules/identity/src/api/infrastructure/persistence/sequelize/models";
import { SequelizeRoleRepository } from "../../../../../modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository";
import {
buildCompanyHeaders,
createTestAccount,
createTestCompany,
createTestMembership,
grantTestPermissions,
loginByApi,
} from "./http-test-fixtures";
import { createHttpTestRuntime } from "./http-test-runtime";
const futureModule: IModuleServer = {
name: "future",
version: "1",
dependencies: ["identity", "companies"],
start(params) {
const identity = params.getService<IIdentityPublicServices>("identity:general");
params.app.get(
"/api/v1/__test/future/company",
...requireIdentityTenant(params),
requirePermission(identity.authorization, "future:stock:read"),
(_req: Request, res: Response) => res.json({ allowed: true })
);
params.app.get(
"/api/v1/__test/future/system",
...requireIdentityAuthenticated(params),
requirePermission(identity.authorization, "future:system:configure", "SYSTEM"),
(_req: Request, res: Response) => res.json({ allowed: true })
);
params.app.get(
"/api/v1/__test/future/unknown",
...requireIdentityAuthenticated(params),
requirePermission(identity.authorization, "future:system:unknown", "SYSTEM"),
(_req: Request, res: Response) => res.json({ allowed: true })
);
},
permissions: [
{ code: "future:stock:read", module: "future", scope: "COMPANY", description: "Read stock" },
{
code: "future:system:configure",
module: "future",
scope: "SYSTEM",
description: "Configure system",
},
],
};
test("bootstrap, scopes, onboarding, administradores existentes y nuevos con MariaDB real", async (t) => {
const runtime = await createHttpTestRuntime({ testModules: [futureModule] });
t.after(() => runtime.stop());
const identity = getServiceScoped<IIdentityPublicServices>(
"policy-test",
["identity"],
"identity:general"
);
const password = "TemporaryTestPassword123!";
const hash = await new BcryptPasswordHasher({ rounds: 4 }).hash(password);
assert.ok(hash.isSuccess);
const props: IAccountCreateProps = {
email: EmailAddress.create(`owner-${randomUUID()}@example.test`).data,
name: Name.create("System owner").data,
passwordHash: hash.data,
avatarUrl: Maybe.none(),
languageCode: LanguageCode.create("es").data,
status: AccountStatus.createActive(),
createdAt: UtcDateTime.now(),
updatedAt: UtcDateTime.now(),
};
const bootstrapped = await identity.systemBootstrap.create(props);
assert.ok(
bootstrapped.isSuccess,
bootstrapped.isFailure ? bootstrapped.error.message : undefined
);
const ownerId = bootstrapped.data.id;
assert.equal(
(
await identity.authorization.canSystem({
accountId: ownerId,
permissionCode: "future:system:configure",
})
).data,
true
);
// Nunca se promueve una segunda cuenta ni se reemplaza el titular al repetir.
const second = await identity.systemBootstrap.create({
...props,
email: EmailAddress.create(`second-${randomUUID()}@example.test`).data,
});
assert.ok(second.isFailure);
const login = await loginByApi({
request: runtime.request,
email: props.email.toPrimitive(),
password,
});
assert.equal(login.status, 200);
assert.ok(login.body && typeof login.body === "object" && "access_token" in login.body);
const token = String(login.body.access_token);
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
assert.equal(
(await runtime.request("/api/v1/onboarding/initial", { method: "POST", headers })).status,
200
);
const onboarding = await runtime.request("/api/v1/onboarding/initial/complete", {
method: "POST",
headers,
body: JSON.stringify({
legal_name: "First company",
tin: "B12345678",
address: {
street: "Street",
city: "City",
postal_code: "28001",
province: "Madrid",
country_code: "ES",
},
}),
});
assert.equal(onboarding.status, 200);
const other = await createTestCompany(runtime.database, {
legalName: "Global target without membership",
});
assert.equal((await runtime.request("/api/v1/__test/future/system", { headers })).status, 200);
assert.equal((await runtime.request("/api/v1/__test/future/unknown", { headers })).status, 403);
const disabled = await createTestCompany(runtime.database, {
legalName: "Disabled target",
status: "disabled",
});
assert.equal(
(
await runtime.request("/api/v1/__test/future/company", {
headers: buildCompanyHeaders({ accessToken: token, companyId: disabled.id }),
})
).status,
403
);
for (const companyId of [disabled.id, randomUUID()]) {
assert.equal(
(
await runtime.request("/api/v1/catalogs/payment-methods", {
headers: buildCompanyHeaders({ accessToken: token, companyId }),
})
).status,
403
);
}
const companyHeaders = buildCompanyHeaders({ accessToken: token, companyId: other.id });
assert.equal(
(await runtime.request("/api/v1/catalogs/payment-methods", { headers: companyHeaders })).status,
200
);
assert.equal(
(await runtime.request("/api/v1/__test/future/company", { headers: companyHeaders })).status,
200
);
assert.equal(
(await runtime.request(`/api/v1/companies/${other.id}`, { headers: companyHeaders })).status,
200
);
assert.equal((await runtime.request(`/api/v1/companies/${other.id}`, { headers })).status, 403);
assert.equal(
(
await runtime.request(`/api/v1/companies/${other.id}`, {
headers: buildCompanyHeaders({ accessToken: token, companyId: randomUUID() }),
})
).status,
403
);
assert.equal(
(
await identity.authorization.can({
accountId: ownerId,
companyId: UniqueID.create(other.id).data,
permissionCode: "future:stock:read",
})
).data,
true
);
assert.equal(
(
await identity.authorization.can({
accountId: ownerId,
companyId: UniqueID.create(other.id).data,
permissionCode: "future:system:configure",
})
).data,
false
);
const createdId = randomUUID();
assert.equal(
(
await runtime.request("/api/v1/companies", {
method: "POST",
headers,
body: JSON.stringify({
id: createdId,
legal_name: "Created globally",
slug: `global-${randomUUID()}`,
}),
})
).status,
201
);
const available = await runtime.request("/api/v1/companies/available", { headers });
assert.equal(available.status, 200);
assert.ok(JSON.stringify(available.body).includes(other.id));
const ordinary = await createTestAccount(runtime.database, {
email: `${randomUUID()}@example.test`,
password,
});
const membership = await createTestMembership(runtime.database, {
accountId: ordinary.id,
companyId: other.id,
});
await grantTestPermissions(runtime.database, {
companyId: other.id,
membershipId: membership.id,
permissionCodes: ["identity:roles:read"],
});
const definitions = collectModulePermissions([identityModule, companiesModule, futureModule]);
const adminRoles = new CompanyAdminRoleEnsurer(new SequelizeRoleRepository(), definitions);
// Un rol admin histórico incompleto y un rol normal ya persistidos antes del mantenimiento.
await runtime.database.getQueryInterface().bulkInsert("identity_roles", [
{
id: randomUUID(),
company_id: other.id,
code: "admin",
name: "Administrator",
is_system: true,
permission_codes: JSON.stringify(["identity:roles:read", "custom:reports:read"]),
created_at: new Date(),
updated_at: new Date(),
},
]);
const normalBefore = await RoleModel.findAll({
where: { company_id: other.id, is_system: false },
raw: true,
});
const warmup = { database: runtime.database, getInternal: () => ({ adminRoles }) };
await synchronizeCompanyAdminRoles(warmup as never);
await synchronizeCompanyAdminRoles(warmup as never);
assert.deepEqual(
await RoleModel.findAll({ where: { company_id: other.id, is_system: false }, raw: true }),
normalBefore
);
const existing = await RoleModel.findOne({ where: { company_id: other.id, code: "admin" } });
assert.ok(existing);
const codes =
typeof existing.permission_codes === "string"
? JSON.parse(existing.permission_codes)
: existing.permission_codes;
assert.ok(Array.isArray(codes));
assert.ok(codes.includes("future:stock:read"));
assert.ok(codes.includes("custom:reports:read"));
assert.equal(codes.includes("future:system:configure"), false);
assert.equal(new Set(codes).size, codes.length);
// El superadministrador puede designar administradores usando el servicio público transaccional.
await runtime.database.transaction(async (transaction) => {
const assigned = await identity.initialCompanyAdminAssigner.assign({
accountId: UniqueID.create(ordinary.id).data,
companyId: UniqueID.create(other.id).data,
transaction,
});
assert.ok(assigned.isSuccess);
});
assert.equal(
(
await identity.authorization.can({
accountId: UniqueID.create(ordinary.id).data,
companyId: UniqueID.create(other.id).data,
permissionCode: "future:stock:read",
})
).data,
true
);
assert.equal(
(
await identity.authorization.canSystem({
accountId: UniqueID.create(ordinary.id).data,
permissionCode: "future:system:configure",
})
).data,
false
);
const ordinaryLogin = await loginByApi({
request: runtime.request,
email: ordinary.email,
password,
});
assert.ok(
ordinaryLogin.body &&
typeof ordinaryLogin.body === "object" &&
"access_token" in ordinaryLogin.body
);
const ordinaryHeaders = {
authorization: `Bearer ${String(ordinaryLogin.body.access_token)}`,
"content-type": "application/json",
};
assert.equal(
(await runtime.request("/api/v1/__test/future/system", { headers: ordinaryHeaders })).status,
403
);
assert.equal(
(
await runtime.request("/api/v1/__test/future/company", {
headers: { ...ordinaryHeaders, "x-company-id": other.id },
})
).status,
200
);
assert.equal(
(
await runtime.request("/api/v1/companies", {
method: "POST",
headers: ordinaryHeaders,
body: JSON.stringify({ id: randomUUID(), legal_name: "Denied", slug: "denied" }),
})
).status,
403
);
assert.equal(
(
await runtime.request("/api/v1/onboarding/initial", {
method: "POST",
headers: ordinaryHeaders,
})
).status,
403
);
});

View File

@ -9,6 +9,7 @@ import {
createTestMembership,
createTestPaymentTerm,
findCompanyRow,
grantTestPermissions,
loginByApi,
} from "./http-test-fixtures.ts";
import { createHttpTestRuntime } from "./http-test-runtime.ts";
@ -54,10 +55,15 @@ test("GET /api/v1/companies/current devuelve la company activa del miembro auten
website: "https://acme.test",
});
await createTestMembership(runtime.database, {
const membership = await createTestMembership(runtime.database, {
accountId: account.id,
companyId: company.id,
});
await grantTestPermissions(runtime.database, {
membershipId: membership.id,
companyId: company.id,
permissionCodes: ["identity:companies:update"],
});
const loginResponse = await loginByApi({
request: runtime.request,

View File

@ -24,6 +24,7 @@ import {
createTestCompany,
createTestMembership,
createTestPaymentTerm,
grantTestPermissions,
loginByApi,
} from "./http-test-fixtures";
import { createHttpTestRuntime } from "./http-test-runtime";
@ -36,6 +37,9 @@ test("companies registra create, updates, no-ops y rollback con MariaDB", async
email: `company-audit-${randomUUID()}@example.test`,
password: "Password123!",
});
await runtime.database
.getQueryInterface()
.bulkInsert("identity_system_owner", [{ id: 1, account_id: account.id }]);
const loginResponse = await loginByApi({
request: runtime.request,
email: account.email,
@ -76,10 +80,16 @@ test("companies registra create, updates, no-ops y rollback con MariaDB", async
legalName: "Original company",
email: "old@example.test",
});
await createTestMembership(runtime.database, {
const membership = await createTestMembership(runtime.database, {
accountId: account.id,
companyId: company.id,
});
await grantTestPermissions(runtime.database, {
membershipId: membership.id,
companyId: company.id,
permissionCodes: ["identity:companies:update"],
});
const paymentTerm = await createTestPaymentTerm(runtime.database, {
companyId: company.id,
name: "30 days",

View File

@ -0,0 +1 @@
{}

View File

@ -269,3 +269,31 @@ export function buildCompanyHeaders(params: {
export function getQueryInterface(database: Sequelize): QueryInterface {
return database.getQueryInterface();
}
/** Concede exclusivamente los permisos indicados al membership de esta fixture. */
export async function grantTestPermissions(
database: Sequelize,
params: { membershipId: string; companyId: string; permissionCodes: readonly string[] }
) {
const roleId = randomUUID();
const now = new Date();
const queryInterface = database.getQueryInterface();
await queryInterface.bulkInsert("identity_roles", [
{
id: roleId,
company_id: params.companyId,
code: `test-${roleId}`,
name: "Test permissions",
is_system: false,
permission_codes: JSON.stringify(params.permissionCodes),
created_at: now,
updated_at: now,
},
]);
await queryInterface.bulkInsert("identity_company_membership_roles", [
{
membership_id: params.membershipId,
role_id: roleId,
},
]);
}

View File

@ -3,6 +3,7 @@ import { type Server, createServer } from "node:http";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ICompanyPublicServices } from "@erp/companies/api";
import {
AsyncLocalStorageOperationContextStore,
type IModuleServer,
@ -127,6 +128,11 @@ export async function createHttpTestRuntime(
},
});
await startModules({
tenantCompanyFinder: getServiceScoped<ICompanyPublicServices>(
"server-infrastructure",
["companies"],
"companies:general"
).finder,
app,
config,
database,
@ -214,6 +220,17 @@ function loadTestEnvironment(): void {
if (result.error) {
throw new Error(`Unable to load test environment file: ${TEST_ENV_FILE}`);
}
// Overrides exclusivos de integración: permiten una instancia temporal aislada sin editar .env.
for (const key of ["HOST", "PORT", "USER", "PASS", "NAME"]) {
const override = process.env[`HTTP_TEST_DB_${key}`];
if (override !== undefined) {
process.env[`DB_${key}`] = override;
}
}
// La suite no necesita certificados reales ni rutas locales del entorno de desarrollo.
process.env.COMPANY_CERTIFICATES_PATH = resolve(currentDir, "fixtures/company-certificates.json");
// El esquema se inicializa en la BD efímera recién creada; nunca se usa sync force.
process.env.DB_SYNC_MODE = "alter";
}
function resetServerRuntimeForTests(): void {

View File

@ -0,0 +1,257 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { RecurringProforma } from "../../../../../modules/customer-invoices/src/api/domain";
import { RecurringProformaModel } from "../../../../../modules/customer-invoices/src/api/infrastructure/common";
import { SequelizeRecurringProformaRepository } from "../../../../../modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository";
import {
SequelizeCustomerDomainMapper,
SequelizeCustomerSummaryMapper,
} from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/mappers";
import { CustomerModel } from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model";
import { CustomerRepository } from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository";
import type { DocumentSeriesCreateProps } from "../../../../../modules/document-series/src/api/domain";
import {
DocumentSeries,
DocumentSeriesCode,
DocumentSeriesNumber,
DocumentType,
} from "../../../../../modules/document-series/src/api/domain";
import { DocumentSeriesModel } from "../../../../../modules/document-series/src/api/infrastructure/common";
import { SequelizeDocumentSeriesDomainMapper } from "../../../../../modules/document-series/src/api/infrastructure/persistence/sequelize/mappers";
import { SequelizeDocumentSeriesRepository } from "../../../../../modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository";
import {
buildCompanyHeaders,
createTestAccount,
createTestCompany,
createTestMembership,
loginByApi,
} from "./http-test-fixtures";
import { createHttpTestRuntime } from "./http-test-runtime";
test("ORM real: Customer update, Series save y Recurring save preservan tenant y transacción", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(() => runtime.stop());
const a = await createTestCompany(runtime.database, { legalName: "A" });
const b = await createTestCompany(runtime.database, { legalName: "B" });
const companyId = UniqueID.create(a.id).data;
const otherCompanyId = UniqueID.create(b.id).data;
const account = await createTestAccount(runtime.database, {
email: `${randomUUID()}@example.test`,
password: "TestPassword123!",
});
await createTestMembership(runtime.database, { accountId: account.id, companyId: a.id });
const login = await loginByApi({
request: runtime.request,
email: account.email,
password: account.password,
});
assert.ok(login.body && typeof login.body === "object" && "access_token" in login.body);
const id = UniqueID.generateNewID();
const created = await runtime.request("/api/v1/customers", {
method: "POST",
headers: {
...buildCompanyHeaders({ accessToken: String(login.body.access_token), companyId: a.id }),
"content-type": "application/json",
},
body: JSON.stringify({
id: id.toPrimitive(),
is_company: true,
name: "Original",
address: { country: "ES" },
uses_retention: false,
language_code: "es",
currency_code: "EUR",
}),
});
assert.equal(created.status, 201);
const customers = new CustomerRepository(
new SequelizeCustomerDomainMapper(),
new SequelizeCustomerSummaryMapper(),
runtime.database
);
const loaded = await customers.getByIdInCompany(companyId, id);
assert.ok(loaded.isSuccess);
// El agregado válido de A nunca puede escribirse con contexto explícito B.
assert.ok(loaded.data.update({ name: Name.create("Allowed customer").data }).isSuccess);
const wrong = await runtime.database.transaction((transaction) =>
customers.update(otherCompanyId, loaded.data, transaction)
);
assert.ok(wrong.isFailure);
const originalRow = await CustomerModel.findByPk(id.toPrimitive());
assert.ok(originalRow);
const foreignAggregate = new SequelizeCustomerDomainMapper().mapToDomain(
CustomerModel.build({ ...originalRow.get(), company_id: b.id })
);
assert.ok(foreignAggregate.isSuccess);
const deniedAtSql = await runtime.database.transaction((transaction) =>
customers.update(otherCompanyId, foreignAggregate.data, transaction)
);
assert.ok(deniedAtSql.isFailure);
assert.ok(deniedAtSql.error.name.includes("NotFound"));
assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.name, "Original");
const missing = await customers.getByIdInCompany(otherCompanyId, id);
assert.ok(missing.isFailure);
assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.company_id, a.id);
await assert.rejects(
runtime.database.transaction(async (transaction) => {
const updated = await customers.update(companyId, loaded.data, transaction);
assert.ok(updated.isSuccess);
assert.equal(
(await CustomerModel.findByPk(id.toPrimitive(), { transaction }))?.name,
"Allowed customer"
);
// El mismo update sin cambios debe funcionar también con driver real.
assert.ok((await customers.update(companyId, loaded.data, transaction)).isSuccess);
throw new Error("forced rollback");
}),
/forced rollback/
);
assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.name, "Original");
assert.ok(
(
await runtime.database.transaction((transaction) =>
customers.update(companyId, loaded.data, transaction)
)
).isSuccess
);
assert.equal((await CustomerModel.findByPk(id.toPrimitive()))?.name, "Allowed customer");
const seriesProps: DocumentSeriesCreateProps = {
companyId,
branchId: Maybe.none(),
documentType: DocumentType.proforma(),
code: DocumentSeriesCode.create("PF").data,
name: Name.create("Original series").data,
description: Maybe.none(),
prefix: Maybe.none(),
suffix: Maybe.none(),
nextNumber: DocumentSeriesNumber.create(1).data,
padding: 4,
validFrom: Maybe.none(),
validTo: Maybe.none(),
isDefault: false,
isActive: true,
hasAssignedNumbers: false,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: Maybe.none(),
};
const series = DocumentSeries.create(seriesProps).data;
const seriesRepo = new SequelizeDocumentSeriesRepository(
new SequelizeDocumentSeriesDomainMapper(),
runtime.database
);
assert.ok(
(await runtime.database.transaction((transaction) => seriesRepo.save(series, transaction)))
.isSuccess
);
const foreignSeries = DocumentSeries.create(
{ ...seriesProps, companyId: otherCompanyId, name: Name.create("Foreign change").data },
series.id
).data;
assert.ok(
(
await runtime.database.transaction((transaction) =>
seriesRepo.save(foreignSeries, transaction)
)
).isFailure
);
assert.equal((await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.company_id, a.id);
assert.equal(
(await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.name,
"Original series"
);
const changedSeries = DocumentSeries.create(
{ ...seriesProps, name: Name.create("Allowed series").data },
series.id
).data;
await assert.rejects(
runtime.database.transaction(async (transaction) => {
assert.ok((await seriesRepo.save(changedSeries, transaction)).isSuccess);
assert.equal(
(await DocumentSeriesModel.findByPk(series.id.toPrimitive(), { transaction }))?.name,
"Allowed series"
);
throw new Error("forced rollback");
}),
/forced rollback/
);
assert.equal(
(await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.name,
"Original series"
);
assert.ok(
(
await runtime.database.transaction((transaction) =>
seriesRepo.save(changedSeries, transaction)
)
).isSuccess
);
assert.equal(
(await DocumentSeriesModel.findByPk(series.id.toPrimitive()))?.name,
"Allowed series"
);
const recurring = RecurringProforma.create({ companyId, name: "Original recurring" }).data;
const recurringRepo = new SequelizeRecurringProformaRepository();
assert.ok(
(
await runtime.database.transaction((transaction) =>
recurringRepo.save(recurring, transaction)
)
).isSuccess
);
const foreignRecurring = RecurringProforma.create(
{ companyId: otherCompanyId, name: "Foreign recurring" },
recurring.id
).data;
assert.ok(
(
await runtime.database.transaction((transaction) =>
recurringRepo.save(foreignRecurring, transaction)
)
).isFailure
);
assert.equal(
(await RecurringProformaModel.findByPk(recurring.id.toPrimitive()))?.company_id,
a.id
);
const changedRecurring = RecurringProforma.create(
{ companyId, name: "Allowed recurring" },
recurring.id
).data;
await assert.rejects(
runtime.database.transaction(async (transaction) => {
assert.ok((await recurringRepo.save(changedRecurring, transaction)).isSuccess);
assert.equal(
(await RecurringProformaModel.findByPk(recurring.id.toPrimitive(), { transaction }))?.name,
"Allowed recurring"
);
throw new Error("forced rollback");
}),
/forced rollback/
);
assert.equal(
(await RecurringProformaModel.findByPk(recurring.id.toPrimitive()))?.name,
"Original recurring"
);
assert.ok(
(
await runtime.database.transaction((transaction) =>
recurringRepo.save(changedRecurring, transaction)
)
).isSuccess
);
assert.equal(
(await RecurringProformaModel.findByPk(recurring.id.toPrimitive()))?.name,
"Allowed recurring"
);
});

View File

@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { getServiceScoped } from "@erp/core/api";
import type { IIdentityPublicServices } from "@erp/identity/api";
import { UniqueID } from "@repo/rdx-ddd";
import {
buildCompanyHeaders,
createTestAccount,
createTestCompany,
createTestMembership,
findCompanyRow,
grantTestPermissions,
loginByApi,
} from "./http-test-fixtures.ts";
import { createHttpTestRuntime } from "./http-test-runtime.ts";
test("Companies aplica auth, membership, permiso y scope a lectura, actualización y baja", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(() => runtime.stop());
const account = await createTestAccount(runtime.database, {
email: `${randomUUID()}@example.com`,
password: "TestPassword123!",
});
const company = await createTestCompany(runtime.database, { legalName: "Company A" });
const other = await createTestCompany(runtime.database, { legalName: "Company B" });
const outsider = await createTestCompany(runtime.database, { legalName: "Company C" });
const membership = await createTestMembership(runtime.database, {
accountId: account.id,
companyId: company.id,
});
const otherMembership = await createTestMembership(runtime.database, {
accountId: account.id,
companyId: other.id,
});
// Memberships activas válidas: rol explícito sin el permiso de Companies que se va a probar.
for (const [membershipId, companyId] of [
[membership.id, company.id],
[otherMembership.id, other.id],
]) {
await grantTestPermissions(runtime.database, {
membershipId,
companyId,
permissionCodes: ["identity:permissions:read"],
});
}
const login = await loginByApi({
request: runtime.request,
email: account.email,
password: account.password,
});
assert.equal(login.status, 200);
assert.ok(login.body && typeof login.body === "object" && "access_token" in login.body);
assert.equal(typeof login.body.access_token, "string");
const token = String(login.body.access_token);
const headers = {
...buildCompanyHeaders({ accessToken: token, companyId: company.id }),
"content-type": "application/json",
};
const identity = getServiceScoped<IIdentityPublicServices>(
"authorization-test",
["identity"],
"identity:general"
);
const direct = await identity.authorization.can({
accountId: UniqueID.create(account.id).data,
companyId: UniqueID.create(company.id).data,
permissionCode: "companies:companies:read",
});
assert.equal(direct.isSuccess, true, direct.isFailure ? direct.error.message : undefined);
assert.equal(direct.data, false);
assert.equal((await runtime.request(`/api/v1/companies/${company.id}`)).status, 401);
assert.equal((await runtime.request(`/api/v1/companies/${company.id}`, { headers })).status, 403);
assert.equal(
(
await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers,
body: JSON.stringify({ legal_name: "Denied" }),
})
).status,
403
);
assert.equal(
(await runtime.request(`/api/v1/companies/${company.id}/disable`, { method: "POST", headers }))
.status,
403
);
assert.equal((await findCompanyRow(runtime.database, company.id))?.legal_name, "Company A");
await grantTestPermissions(runtime.database, {
membershipId: membership.id,
companyId: company.id,
permissionCodes: ["companies:companies:read", "identity:companies:update"],
});
assert.equal((await runtime.request(`/api/v1/companies/${company.id}`, { headers })).status, 200);
assert.equal(
(
await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers,
body: JSON.stringify({ legal_name: "Allowed" }),
})
).status,
200
);
assert.equal(
(
await runtime.request(`/api/v1/companies/${other.id}`, {
headers: buildCompanyHeaders({ accessToken: token, companyId: other.id }),
})
).status,
403
);
assert.equal(
(
await runtime.request(`/api/v1/companies/${outsider.id}`, {
headers: buildCompanyHeaders({ accessToken: token, companyId: outsider.id }),
})
).status,
403
);
assert.equal((await runtime.request(`/api/v1/companies/${other.id}`, { headers })).status, 403);
assert.equal((await findCompanyRow(runtime.database, company.id))?.legal_name, "Allowed");
assert.equal(
(await runtime.request(`/api/v1/companies/${company.id}/disable`, { method: "POST", headers }))
.status,
200
);
});

View File

@ -12,7 +12,7 @@ import { Toaster, TooltipProvider } from "@repo/shadcn-ui/components";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import DineroFactory from "dinero.js";
import { Suspense } from "react";
import { Suspense, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { I18nextProvider } from "react-i18next";
import { RouterProvider } from "react-router-dom";
@ -23,33 +23,38 @@ import { getAppRouter } from "./routes";
export const App = () => {
DineroFactory.globalLocale = "es-ES";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time
},
},
});
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time
},
},
})
);
console.log("import.meta.env.VITE_API_SERVER_URL => ", import.meta.env.VITE_API_SERVER_URL);
const axiosInstance = createAxiosInstance({
baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken: () => authTokenStorage.getAccessToken(),
getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(),
onRefreshSession: () => getIdentityAuthSessionRuntimeHandlers().refreshAccessToken(),
onAuthError: () => {
getIdentityAuthSessionRuntimeHandlers().clearSession();
const [axiosInstance] = useState(() =>
createAxiosInstance({
baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken: () => authTokenStorage.getAccessToken(),
getActiveCompanyId: () => activeCompanyStorage.getActiveCompanyId(),
onRefreshSession: () => getIdentityAuthSessionRuntimeHandlers().refreshAccessToken(),
onAuthError: () => {
getIdentityAuthSessionRuntimeHandlers().clearSession();
if (window.location.pathname !== "/login") {
window.location.replace("/login");
}
},
});
if (window.location.pathname !== "/login") {
window.location.replace("/login");
}
},
})
);
const dataSource = createAxiosDataSource(axiosInstance);
const appRouter = getAppRouter();
const [dataSource] = useState(() => createAxiosDataSource(axiosInstance));
const [appRouter] = useState(() => getAppRouter());
return (
<I18nextProvider i18n={i18n}>

View File

@ -1,13 +1,15 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { useQuery } from "@tanstack/react-query";
import { getHomeCustomers } from "../api/home.api";
export const useHomeCustomersTotalQuery = () => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
return useQuery({
queryKey: ["home", "customers", "total"],
queryKey: companyQueryKey(queryScope, ["home", "customers", "total"]),
queryFn: async ({ signal }) => {
const response = await getHomeCustomers(dataSource, {
signal,

View File

@ -1,5 +1,6 @@
import { MoneyDTOHelper } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { MoneyHelper } from "@repo/rdx-utils";
import { useQuery } from "@tanstack/react-query";
import { endOfMonth, format, startOfMonth } from "date-fns";
@ -10,12 +11,19 @@ import type { HomeMonthlyBilling } from "../entities/home.entity";
const MONTHLY_BILLING_PAGE_SIZE = 100;
export const useHomeMonthlyBillingQuery = () => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const monthStart = format(startOfMonth(new Date()), "yyyy-MM-dd");
const monthEnd = format(endOfMonth(new Date()), "yyyy-MM-dd");
return useQuery<HomeMonthlyBilling>({
queryKey: ["home", "issued-invoices", "monthly-billing", monthStart, monthEnd],
queryKey: companyQueryKey(queryScope, [
"home",
"issued-invoices",
"monthly-billing",
monthStart,
monthEnd,
]),
queryFn: async ({ signal }) => {
const response = await getHomeIssuedInvoices(dataSource, {
signal,

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { useQuery } from "@tanstack/react-query";
import { getHomeProformas } from "../api/home.api";
@ -18,10 +19,11 @@ export interface HomeProformasOverview {
}
export const useHomeProformasOverviewQuery = () => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
return useQuery<HomeProformasOverview>({
queryKey: ["home", "proformas", "overview"],
queryKey: companyQueryKey(queryScope, ["home", "proformas", "overview"]),
queryFn: async ({ signal }) => {
const response = await getHomeProformas(dataSource, {
signal,

View File

@ -1,5 +1,6 @@
import { MoneyDTOHelper } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { useQueries } from "@tanstack/react-query";
import { getHomeIssuedInvoices, getHomeProformas } from "../api/home.api";
@ -8,12 +9,13 @@ import type { HomeRecentActivityItem } from "../entities/home.entity";
const RECENT_ITEMS_LIMIT = 5;
export const useHomeRecentActivityQuery = () => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const [recentProformasQuery, recentIssuedInvoicesQuery] = useQueries({
queries: [
{
queryKey: ["home", "proformas", "recent"],
queryKey: companyQueryKey(queryScope, ["home", "proformas", "recent"]),
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const response = await getHomeProformas(dataSource, {
signal,
@ -42,7 +44,7 @@ export const useHomeRecentActivityQuery = () => {
},
},
{
queryKey: ["home", "issued-invoices", "recent"],
queryKey: companyQueryKey(queryScope, ["home", "issued-invoices", "recent"]),
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const response = await getHomeIssuedInvoices(dataSource, {
signal,

View File

@ -37,6 +37,7 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
isChangingCompany && "cursor-wait opacity-80",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
data-testid="company-switcher-trigger"
disabled={isDisabled}
type="button"
>
@ -72,9 +73,10 @@ export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
return (
<DropdownMenuItem
className="gap-3 py-2"
data-testid={`company-option-${company.id}`}
disabled={company.isSelected || isChangingCompany}
key={company.id}
onSelect={() => handleSelectCompany(company.id)}
onClick={() => handleSelectCompany(company.id)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-foreground">
{company.initials}

View File

@ -1,7 +1,7 @@
import type React from "react";
import { Outlet } from "react-router-dom";
import { AppMain } from "./app-main";
import { CompanyPageOutlet } from "./company-page-outlet";
interface AppFullscreenLayoutFrameProps {
children: React.ReactNode;
@ -18,7 +18,7 @@ export const AppFullscreenLayoutFrame = ({ children }: AppFullscreenLayoutFrameP
export const AppFullscreenLayout = () => {
return (
<AppFullscreenLayoutFrame>
<Outlet />
<CompanyPageOutlet />
</AppFullscreenLayoutFrame>
);
};

View File

@ -1,10 +1,10 @@
import { SidebarInset, SidebarProvider } from "@repo/shadcn-ui/components";
import type * as React from "react";
import { Outlet } from "react-router-dom";
import { AppMain } from "./app-main";
import { AppSidebar } from "./app-sidebar";
import { AppTopbar } from "./app-topbar";
import { CompanyPageOutlet } from "./company-page-outlet";
interface AppSidebarLayoutFrameProps {
children: React.ReactNode;
@ -43,7 +43,7 @@ export const AppSidebarLayoutFrame = ({ children }: AppSidebarLayoutFrameProps)
export const AppSidebarLayout = () => {
return (
<AppSidebarLayoutFrame>
<Outlet />
<CompanyPageOutlet />
</AppSidebarLayoutFrame>
);
};

View File

@ -0,0 +1,14 @@
import { useCompanyQueryScope } from "@erp/core/hooks";
import { useIdentityAuthSession } from "@erp/identity/client";
import { LoadingOverlay } from "@repo/rdx-ui/components";
import { Outlet } from "react-router-dom";
/** Reinicia formularios/selecciones empresariales sin reiniciar navegación, tema ni toasts. */
export function CompanyPageOutlet() {
const scope = useCompanyQueryScope();
const { isLoading } = useIdentityAuthSession();
if (isLoading) {
return <LoadingOverlay />;
}
return <Outlet key={`${scope?.sessionId ?? "none"}:${scope?.companyId ?? "none"}`} />;
}

View File

@ -1,6 +1,5 @@
import { useIdentityAuthSession } from "@erp/identity/client";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
function resolveCompanyDisplayName(legalName: string, tradeName: string | null) {
@ -37,7 +36,6 @@ function resolveErrorMessage(error: unknown) {
}
export function useAppCompanySwitcherController() {
const queryClient = useQueryClient();
const { activeCompany, availableCompanies, changeActiveCompany } = useIdentityAuthSession();
const [isChangingCompany, setIsChangingCompany] = useState(false);
@ -67,7 +65,7 @@ export function useAppCompanySwitcherController() {
};
});
const handleSelectCompany = async (companyId: string) => {
const selectCompany = async (companyId: string) => {
if (isChangingCompany || companyId === activeCompany?.id) {
return;
}
@ -82,21 +80,21 @@ export function useAppCompanySwitcherController() {
return;
}
await queryClient.cancelQueries();
queryClient.removeQueries({ type: "inactive" });
await queryClient.resetQueries({ type: "active" });
showSuccessToast(
"Active company updated",
`${resolveCompanyDisplayName(nextActiveCompany.legalName, nextActiveCompany.tradeName)} is now active.`
);
} catch (error) {
showErrorToast("Unable to switch company", resolveErrorMessage(error));
} finally {
setIsChangingCompany(false);
}
};
const handleSelectCompany = (companyId: string): void => {
selectCompany(companyId).catch((error: unknown) => {
showErrorToast("Unable to switch company", resolveErrorMessage(error));
});
};
return {
activeCompany: activeCompanyView,
companies: companyOptions,

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useIdentityAuthSession } from "@erp/identity/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -7,27 +8,31 @@ import {
getInitialOnboardingStatus,
provisionInitialOnboarding,
} from "../api/initial-onboarding.api";
export const initialOnboardingStatusKey = ["onboarding", "initial"] as const;
export const initialOnboardingStatusKey = (sessionId: string | null) =>
["onboarding", sessionId, "initial"] as const;
export const useInitialOnboardingStatus = () => {
const { querySessionId } = useIdentityAuthSession();
const ds = useDataSource();
return useQuery({
queryKey: initialOnboardingStatusKey,
queryKey: initialOnboardingStatusKey(querySessionId),
queryFn: ({ signal }) => getInitialOnboardingStatus(ds, signal),
});
};
export const useProvisionInitialOnboarding = () => {
const { querySessionId } = useIdentityAuthSession();
const ds = useDataSource();
const qc = useQueryClient();
return useMutation({
mutationFn: () => provisionInitialOnboarding(ds),
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v),
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey(querySessionId), v),
});
};
export const useCompleteInitialOnboarding = () => {
const { querySessionId } = useIdentityAuthSession();
const ds = useDataSource();
const qc = useQueryClient();
return useMutation({
mutationFn: (v: InitialCompanyInput) => completeInitialOnboarding(ds, v),
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v),
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey(querySessionId), v),
});
};

View File

@ -38,3 +38,17 @@ Los builders de salida materializan contratos definidos por schemas Zod. Son det
- [DTOs y schemas](./dto-guidelines.md)
- [Límites entre módulos](./module-boundaries.md)
- [Pruebas backend](./testing-guidelines.md)
## Alcance empresarial de persistencia
Toda mutación de entidad empresarial debe expresar el alcance en el puerto y en la condición
efectiva de escritura, no solo en una lectura previa. El patrón actualizado de Customers/catálogos/
proformas es `update(companyId, aggregate, transaction)` con `WHERE id AND company_id`.
No incluir `company_id` entre campos reasignables. Los puertos `save(aggregate, transaction)`
de series/recurrencias llevan el alcance inmutable en el agregado: tampoco pueden hacer un upsert
por PK que traslade la raíz a otra empresa. La lectura interna de colisión no autoriza la escritura.
Cero filas exige distinguir ausencia de no-op usando únicamente el mismo alcance y transacción;
no revelar existencia en otra empresa. Comprobar la raíz antes de sustituir sus hijos. Conservar
resolvers de pertenencia, invariantes y auditoría. Companies es la propia raíz empresarial: su PK
es la empresa y no necesita una columna `company_id` artificial.

View File

@ -24,46 +24,16 @@ Estado:
- sin helpers locales de auth
- usan el middleware tenant canónico documentado en `tenant-context.md`
### Backend autenticado sin tenant
### Companies y autorización por operación
`modules/companies` usa el middleware autenticado sin tenant documentado en `tenant-context.md`.
Las operaciones globales no requieren tenant: `POST /companies` exige autorización SYSTEM y
`GET /companies/available` exige autenticación; el Super-administrador ve todas las empresas activas.
Las consultas/configuración de empresa y endpoints por ID son tenant-scoped.
Esto aplica a:
```http
POST /companies
GET /companies/available
GET /companies/:company_id
PATCH /companies/:company_id
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`.
La matriz vigente de permisos, sus excepciones y el registro extensible están en
[tenant-context.md](./tenant-context.md). La política V1 de edición solo por membership ha sido
sustituida en las operaciones Companies allí enumeradas. Los demás módulos conservan sus políticas
hasta una adopción explícita del guard; no se afirma cobertura granular universal.
## Frontend actual
@ -77,9 +47,9 @@ Los detalles del flujo implementado, headers y refresh automático se mantienen
Sigue pendiente revisar o retirar consumidores legacy residuales antes de eliminarlo por completo.
### `modules/supplier`
### `modules/suppliers`
Queda fuera de esta migración por ahora.
Ya usa el contexto tenant canónico; no tiene aún permisos granulares por operación.
## Búsquedas útiles de control

View File

@ -47,18 +47,19 @@ Actualmente ya valida:
2. account autenticable
3. `X-Company-Id` presente
4. `X-Company-Id` con formato UUID válido
5. membership `active` en `identity_company_memberships`
5. membership `active` en `identity_company_memberships` o titularidad global de Super-administrador
6. company `active` en `companies`
7. solo entonces rellena `req.user.companyId`
Esto significa que hoy el contexto tenant garantiza:
```txt
membership activa
(membership activa o Super-administrador activo)
+ company activa
```
pero no enforcement de permisos granulares por operación.
La autorización granular se aplica después, mediante `requirePermission(authorization, code)`.
El tenant por sí solo no concede permisos de operación.
## Semántica esperada de errores
@ -70,8 +71,8 @@ sin X-Company-Id -> 403
X-Company-Id inválido -> 400
company inexistente -> 403
company disabled -> 403
membership inexistente -> 403
membership disabled/invited -> 403
membership inexistente en una cuenta ordinaria -> 403
membership disabled/invited en una cuenta ordinaria -> 403
membership active + company active -> entra al controller
```
@ -103,7 +104,7 @@ Actualmente usan `requireIdentityTenant(params)`:
- `modules/customer-invoices`
- `modules/factuges`
`modules/supplier` queda fuera de esta migración por ahora.
`modules/suppliers` también usa el contexto tenant canónico.
## Qué no debe ir en el token
@ -127,16 +128,22 @@ GET /companies/current
PATCH /companies/current
```
Política V1:
Política actual de lectura `GET /companies/current`: autenticación, membership y empresa activas.
No se añade un permiso a esta consulta usada por selección/configuración actual.
```txt
usuario autenticado
+ X-Company-Id válido
+ membership activa en esa company
+ company activa
```
La edición `PATCH /companies/current` y `PATCH /companies/:company_id`, así como
`POST /companies/:company_id/enable` y `/disable`, requieren además
`identity:companies:update`. Antes bastaba el contexto empresarial.
No se aplica todavía un permiso específico como `identity:companies:update`.
`GET /companies/:company_id` requiere `companies:companies:read`. Antes bastaba el tenant.
El parámetro sigue comparándose con la empresa autenticada; el permiso de A no autoriza B.
Las operaciones globales de cuenta y consulta de empresas disponibles siguen autenticadas.
`POST /companies` exige `companies:companies:create` (SYSTEM); su transacción crea la empresa
y asigna al actor su administrador inicial con todos los COMPANY actuales.
Los POST de onboarding inicial exigen `identity:system:initial-onboarding` (SYSTEM).
La lectura de estado del onboarding sigue autenticada. No se introduce autorización granular
en todas las operaciones de todos los módulos de una vez.
## Document context
@ -145,3 +152,71 @@ Si un flujo documental necesita `companySlug`, debe resolverlo desde `companies`
```txt
companyId -> companies:general.finder.findById(...) -> slug
```
## Autorización y registro de permisos
La raíz de cada módulo declara `permissions: PermissionDefinition[]` en su manifest backend.
El runtime compone y valida códigos, namespace y duplicados **antes de cualquier setup**.
Cada definición declara `scope: "SYSTEM" | "COMPANY"`; un scope ausente o inválido impide el setup.
Registrar un COMPANY amplía el rol reservado Administradores durante el mantenimiento de arranque
y en nuevas altas. Los demás roles conservan sus permisos explícitos. Identity no importa los módulos contribuyentes.
La raíz del consumidor resuelve `identity:general` y suministra `authorization` a sus rutas.
La API pública neutral es `authorization.can({ accountId, companyId, permissionCode, transaction? })`,
que devuelve `Result<boolean, Error>`. Código desconocido o no asignado deniega; fallos técnicos no conceden.
El guard `requirePermission(authorization, code)` se instala después de auth/tenant y solo toma
cuenta/empresa de `req.user`. Falta de autenticación devuelve 401; tenant/permiso ausente 403;
fallo técnico 500 con mensaje seguro. No depende de permisos frontend.
`CompanyAdminRoleEnsurer` obtiene todos los COMPANY del catálogo completo suministrado por el runtime.
Identifica el rol reservado por `code = admin` e `isSystem = true`, con nombre de alta `Administradores`.
Añade los códigos ausentes, conserva adicionales y fechas cuando no cambia nada, y nunca incorpora SYSTEM.
El nombre histórico `Administrator` puede conservarse en filas existentes: no identifica privilegios.
Los roles normales no se modifican. Poseer un código SYSTEM dentro de un JSON de rol empresarial tampoco
concede autorización global: `canSystem` solo consulta la capacidad global persistida.
El warmup obligatorio de Identity enumera únicamente roles `admin` reservados, los relee con bloqueo
y aplica la unión en una transacción por empresa. Se ejecuta tras preparar el esquema y antes de servir HTTP;
si falla o expira, impide arrancar, aunque los warmups opcionales no sean estrictos. Se puede repetir.
No hay escrituras al resolver permisos ni sincronización durante requests.
### Super-administrador y bootstrap
Identity persiste una titularidad global en `identity_system_owner`, fila `id = 1`, vinculada a una cuenta activa.
No se deriva de email, nombre, fecha de alta ni rol empresarial. `systemBootstrap.create(props)` es la
entrada confiable de composición que crea la primera cuenta y reserva la titularidad en la misma transacción:
bloquea el singleton y rechaza un almacén que ya contiene cuentas. No existe un endpoint público de promoción.
El bootstrap recibe Value Objects, incluido el hash generado por el hasher de Identity; no recibe contraseñas por HTTP.
El usuario creado puede autenticarse y completar el onboarding inicial existente.
La cuenta global dispone de todos los permisos registrados según su scope, también los futuros.
`authorization.canSystem({ accountId, permissionCode, transaction? })` exige SYSTEM y no requiere tenant.
`authorization.can({ accountId, companyId, permissionCode, transaction? })` exige COMPANY y se invoca después
de validar el tenant; el permiso global no permite omitir `X-Company-Id` ni usar una empresa inexistente/inactiva.
El selector de empresas disponibles incluye todas las activas para el Super-administrador.
Las cuentas ordinarias reciben únicamente sus empresas con membership activa.
Los catálogos que usan `requireIdentityTenantMembership` también comprueban empresa activa: el host suministra
`tenantCompanyFinder` durante `startModules`, una vez construidos los módulos. Usa el finder público de Companies,
sin imports de su infraestructura ni dependencia circular de setup Catalogs → Companies → Catalogs.
Un host que no suministre ese puerto no puede iniciar esas rutas. El helper conserva su nombre por compatibilidad.
### Bases existentes
Con `DB_SYNC_MODE=none`, aplicar expresamente `modules/identity/sql/001-system-owner.sql` antes de iniciar
esta versión. La DDL es idempotente y no promueve cuentas. En una base existente debe identificarse y
acreditarse la cuenta inicial antes de establecer expresamente su titularidad mediante un procedimiento
administrativo revisado. No se ha ejecutado esa asignación ni ninguna DDL sobre datos reales.
No se elige automáticamente al primer admin ni a la cuenta más antigua.
Los roles Administradores existentes sí reciben los COMPANY en el arranque, conservando sus permisos adicionales.
Patrón de composición (referencia real: `companies.di.ts` y `companies.routes.ts`):
```ts
// Composition root: resolver una sola vez y suministrar el contrato público al router.
const identity = params.getService<IIdentityPublicServices>("identity:general");
// Route, después de autenticación y tenant:
router.patch("/current", tenant, requirePermission(identity.authorization, permissionCode), handler);
```
No reconstruir resolver, roles o repositories de Identity desde otro módulo.

View File

@ -153,3 +153,37 @@ El estado remoto permanece en TanStack Query. No se duplica en estado local salv
- No introducir acceso a red en UI ni reglas de negocio en Pages.
- Mantener routing, sesión, tenant y sistema visual como responsabilidades separadas.
- Cambios de API visual pública deben sincronizar implementación, [UI system](./ui-system.md), UI Kit y tests.
## Caché empresarial y cambios de sesión
Los fragmentos de claves de cada recurso se componen en consultas, invalidaciones y escrituras:
```ts
import { companyQueryKey } from "@erp/core/query";
import { useCompanyQueryScope } from "@erp/core/hooks";
const scope = useCompanyQueryScope();
const key = companyQueryKey(scope, LIST_CUSTOMERS_QUERY_KEY(criteria));
```
La clave efectiva contiene empresa y sesión local, además del fragmento del recurso.
Los fragmentos/prefijos de las factories no son claves empresariales completas por sí solos.
La paginación usa `keepPreviousCompanyData`: nunca conserva placeholder de otra empresa/sesión.
Las estrategias de caché reciben el scope capturado por el hook; un callback tardío nunca debe
reconstruirlo consultando la empresa activa nueva. Las claves enumeradas por la caché ya están
compuestas y no deben envolverse de nuevo. Audit conserva sus filtros empresariales explícitos
y añade el mismo aislamiento de sesión. Cuenta y onboarding usan identidad de sesión sin empresa;
son operaciones globales legítimas, no consultas empresariales.
`IdentityAuthSessionProvider` centraliza selección/cambio, cancelación antes de cambiar header,
limpieza y activación. `selectActiveCompany` y `changeActiveCompany` son asíncronas y usan el mismo
recorrido. Cierre/reemplazo de cuenta destruye caché e incrementa la generación local de sesión;
restore/refresh tardíos no pueden restablecer una sesión cerrada. No se modifican claims JWT.
`CompanyPageOutlet` remonta solo el contenido empresarial al cambiar empresa/sesión: elimina
formularios, IDs y selección anterior sin reiniciar sidebar, tema ni toasts. El selector superior
no mantiene una segunda estrategia de limpieza. QueryClient, axios/datasource y router son estables
durante el montaje de App mediante inicialización diferida en useState.
No basta limpiar un listado ni esconder permisos en React. Backend valida auth/tenant/permiso y
persistencia scoped; frontend evita reutilizar resultados de otra empresa/cuenta.

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { useIdentityAuthSession } from "@erp/identity/client";
import { type DefaultError, useQuery } from "@tanstack/react-query";
@ -8,12 +9,13 @@ import type { AuditEntryDetail } from "../entities/audit-entry-detail.entity";
import { auditQueryKeys } from "./audit-query-keys";
export const useAuditEntry = (id: string | null) => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const { activeCompany } = useIdentityAuthSession();
const companyId = activeCompany?.id ?? "none";
return useQuery<AuditEntryDetail, DefaultError>({
queryKey: auditQueryKeys.detail(companyId, id ?? "none"),
queryKey: companyQueryKey(queryScope, auditQueryKeys.detail(companyId, id ?? "none")),
queryFn: ({ signal }) => {
if (!id) {
throw new Error("auditEntryId is required");

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
import { useIdentityAuthSession } from "@erp/identity/client";
import { type DefaultError, useQuery } from "@tanstack/react-query";
@ -17,18 +18,23 @@ export const useAuditList = (
params: AuditSearchParams | null,
options: UseAuditListOptions = {}
) => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const { activeCompany } = useIdentityAuthSession();
const companyId = activeCompany?.id ?? "none";
return useQuery<AuditList, DefaultError>({
queryKey: auditQueryKeys.list(companyId, params ?? { pageNumber: 0, pageSize: 50 }),
queryKey: companyQueryKey(
queryScope,
auditQueryKeys.list(companyId, params ?? { pageNumber: 0, pageSize: 50 })
),
queryFn: async ({ signal }) => {
const search = options.getSearchParams?.() ?? params ?? { pageNumber: 0, pageSize: 50 };
return mapAuditList(await searchAudit(dataSource, { search, signal }));
},
enabled: activeCompany !== null && params !== null,
placeholderData: (previousData) => previousData,
placeholderData: (previousData, previousQuery) =>
keepPreviousCompanyData(queryScope, previousData, previousQuery),
refetchInterval: options.refetchInterval,
});
};

View File

@ -7,7 +7,11 @@ import type { PaymentMethodSummary } from "../models";
export interface IPaymentMethodRepository {
create(paymentMethod: PaymentMethod, transaction?: unknown): Promise<Result<void, Error>>;
update(paymentMethod: PaymentMethod, transaction?: unknown): Promise<Result<void, Error>>;
update(
companyId: UniqueID,
paymentMethod: PaymentMethod,
transaction?: unknown
): Promise<Result<void, Error>>;
existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,

View File

@ -42,7 +42,7 @@ class InMemoryPaymentMethodRepository implements IPaymentMethodRepository {
return Result.ok();
}
async update(paymentMethod: PaymentMethod): Promise<Result<void, Error>> {
async update(_companyId: UniqueID, paymentMethod: PaymentMethod): Promise<Result<void, Error>> {
this.items.set(paymentMethod.id.toString(), paymentMethod);
return Result.ok();
}

View File

@ -29,7 +29,11 @@ export class PaymentMethodDisabler implements IPaymentMethodDisabler {
return Result.ok(paymentMethod);
}
const persistenceResult = await this.repository.update(paymentMethod, transaction);
const persistenceResult = await this.repository.update(
paymentMethod.companyId,
paymentMethod,
transaction
);
if (persistenceResult.isFailure) {
return Result.fail(persistenceResult.error);

View File

@ -29,7 +29,11 @@ export class PaymentMethodEnabler implements IPaymentMethodEnabler {
return Result.ok(paymentMethod);
}
const persistenceResult = await this.repository.update(paymentMethod, transaction);
const persistenceResult = await this.repository.update(
paymentMethod.companyId,
paymentMethod,
transaction
);
if (persistenceResult.isFailure) {
return Result.fail(persistenceResult.error);

View File

@ -43,7 +43,11 @@ export class PaymentMethodStatusChanger implements IPaymentMethodStatusChanger {
return Result.ok(paymentMethod);
}
const persistenceResult = await this.repository.update(paymentMethod, transaction);
const persistenceResult = await this.repository.update(
paymentMethod.companyId,
paymentMethod,
transaction
);
if (persistenceResult.isFailure) {
return Result.fail(persistenceResult.error);

View File

@ -58,7 +58,7 @@ export class PaymentMethodUpdater implements IPaymentMethodUpdater {
}
// Persistir cambios
const saveResult = await this.repository.update(paymentMethod, transaction);
const saveResult = await this.repository.update(companyId, paymentMethod, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);

View File

@ -72,7 +72,11 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS
}
if (unmarkResult.data) {
const saveCurrentResult = await this.repository.update(currentDefault, transaction);
const saveCurrentResult = await this.repository.update(
companyId,
currentDefault,
transaction
);
if (saveCurrentResult.isFailure) {
return Result.fail(saveCurrentResult.error);
}
@ -95,7 +99,11 @@ export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodS
}
if (markResult.data) {
const savePaymentMethodResult = await this.repository.update(paymentMethod, transaction);
const savePaymentMethodResult = await this.repository.update(
companyId,
paymentMethod,
transaction
);
if (savePaymentMethodResult.isFailure) {
return Result.fail(savePaymentMethodResult.error);
}

View File

@ -15,8 +15,10 @@ import {
import type { TaxDefinitionSummary } from "../models";
import type { ITaxDefinitionRepository } from "../repositories";
const valueOf = <T>(result: Result<T, Error>): T => {
if (result.isFailure) throw result.error;
const unwrapResult = <T>(result: Result<T, Error>): T => {
if (result.isFailure) {
throw result.error;
}
return result.data;
};
@ -30,32 +32,32 @@ export const buildTaxDefinition = (params?: {
const companyId = params?.companyId ?? UniqueID.generateNewID();
const allowedSurchargeCodes = params?.allowedSurchargeCodes;
return valueOf(
return unwrapResult(
TaxDefinition.create(
{
companyId,
code: valueOf(TaxDefinitionCode.create("iva_21")),
name: valueOf(TaxDefinitionName.create("IVA general")),
description: Maybe.some(valueOf(TextValue.create("IVA aplicable"))),
rate: valueOf(TaxRate.create({ value: 2100, scale: 2 })),
taxFamily: valueOf(TaxFamily.create("iva")),
calculationBehavior: valueOf(TaxCalculationBehavior.create("additive")),
jurisdictionCountryCode: valueOf(CountryCode.create("ES")),
jurisdictionRegionCode: Maybe.some(valueOf(CountryRegionCode.create("ES-M"))),
taxScope: valueOf(TaxScope.create("domestic")),
invoiceNote: Maybe.some(valueOf(TextValue.create("Factura con IVA"))),
code: unwrapResult(TaxDefinitionCode.create("iva_21")),
name: unwrapResult(TaxDefinitionName.create("IVA general")),
description: Maybe.some(unwrapResult(TextValue.create("IVA aplicable"))),
rate: unwrapResult(TaxRate.create({ value: 2100, scale: 2 })),
taxFamily: unwrapResult(TaxFamily.create("iva")),
calculationBehavior: unwrapResult(TaxCalculationBehavior.create("additive")),
jurisdictionCountryCode: unwrapResult(CountryCode.create("ES")),
jurisdictionRegionCode: Maybe.some(unwrapResult(CountryRegionCode.create("ES-M"))),
taxScope: unwrapResult(TaxScope.create("domestic")),
invoiceNote: Maybe.some(unwrapResult(TextValue.create("Factura con IVA"))),
allowedSurchargeCodes:
allowedSurchargeCodes === null
? Maybe.none()
: Maybe.some(
(allowedSurchargeCodes ?? ["rec_5_2", "rec_1_4"]).map((code) =>
valueOf(TaxDefinitionCode.create(code))
unwrapResult(TaxDefinitionCode.create(code))
)
),
isSystem: params?.isSystem ?? false,
isActive: params?.isActive ?? true,
validFrom: Maybe.some(valueOf(UtcDate.createFromISO("2026-01-01"))),
validTo: Maybe.some(valueOf(UtcDate.createFromISO("2026-12-31"))),
validFrom: Maybe.some(unwrapResult(UtcDate.createFromISO("2026-01-01"))),
validTo: Maybe.some(unwrapResult(UtcDate.createFromISO("2026-12-31"))),
},
params?.id ?? UniqueID.generateNewID()
)
@ -73,8 +75,11 @@ export class InMemoryTaxDefinitionRepository implements ITaxDefinitionRepository
return Result.ok();
}
async update(taxDefinition: TaxDefinition): Promise<Result<void, Error>> {
async update(companyId: UniqueID, taxDefinition: TaxDefinition): Promise<Result<void, Error>> {
this.updateCalls += 1;
if (!taxDefinition.companyId.equals(companyId)) {
return Result.fail(new Error("Not found"));
}
this.items.set(taxDefinition.id.toString(), taxDefinition);
return Result.ok();
}
@ -82,7 +87,9 @@ export class InMemoryTaxDefinitionRepository implements ITaxDefinitionRepository
async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<boolean, Error>> {
this.deleteCalls += 1;
const found = await this.getByIdInCompany(companyId, id);
if (found.isFailure) return Result.fail(found.error);
if (found.isFailure) {
return Result.fail(found.error);
}
this.deleted.add(id.toString());
return Result.ok(true);
}

View File

@ -7,7 +7,11 @@ import type { TaxDefinitionSummary } from "../../tax-definitions/models";
export interface ITaxDefinitionRepository {
create(taxDefinition: TaxDefinition, transaction?: unknown): Promise<Result<void, Error>>;
update(taxDefinition: TaxDefinition, transaction?: unknown): Promise<Result<void, Error>>;
update(
companyId: UniqueID,
taxDefinition: TaxDefinition,
transaction?: unknown
): Promise<Result<void, Error>>;
deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,

View File

@ -1,11 +1,15 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { ITaxDefinitionRepository } from "../repositories";
import type { TaxDefinition } from "../../../domain";
import type { ITaxDefinitionRepository } from "../repositories";
export interface ITaxDefinitionStatusChanger {
enable(companyId: UniqueID, id: UniqueID, transaction?: unknown): Promise<Result<TaxDefinition, Error>>;
enable(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<TaxDefinition, Error>>;
disable(
companyId: UniqueID,
id: UniqueID,
@ -29,7 +33,7 @@ export class TaxDefinitionStatusChanger implements ITaxDefinitionStatusChanger {
}
if (changeResult.data) {
const saveResult = await this.repository.update(taxDefinition, transaction);
const saveResult = await this.repository.update(companyId, taxDefinition, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
@ -51,7 +55,7 @@ export class TaxDefinitionStatusChanger implements ITaxDefinitionStatusChanger {
}
if (changeResult.data) {
const saveResult = await this.repository.update(taxDefinition, transaction);
const saveResult = await this.repository.update(companyId, taxDefinition, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}

View File

@ -34,7 +34,7 @@ export class TaxDefinitionUpdater implements ITaxDefinitionUpdater {
}
if (updateResult.data) {
const saveResult = await this.repository.update(taxDefinition, transaction);
const saveResult = await this.repository.update(companyId, taxDefinition, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}

View File

@ -7,7 +7,11 @@ import type { TaxRegimeSummary } from "../models";
export interface ITaxRegimeRepository {
create(taxRegime: TaxRegime, transaction?: unknown): Promise<Result<void, Error>>;
update(taxRegime: TaxRegime, transaction?: unknown): Promise<Result<void, Error>>;
update(
companyId: UniqueID,
taxRegime: TaxRegime,
transaction?: unknown
): Promise<Result<void, Error>>;
deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,

View File

@ -32,7 +32,11 @@ export class TaxRegimeStatusChanger implements ITaxRegimeStatusChanger {
return Result.ok(taxRegime);
}
const persistenceResult = await this.repository.update(taxRegime, transaction);
const persistenceResult = await this.repository.update(
taxRegime.companyId,
taxRegime,
transaction
);
if (persistenceResult.isFailure) {
return Result.fail(persistenceResult.error);
}

View File

@ -42,7 +42,7 @@ export class TaxRegimeUpdater implements ITaxRegimeUpdater {
}
// Persistir cambios
const saveResult = await this.repository.update(taxRegime, transaction);
const saveResult = await this.repository.update(companyId, taxRegime, transaction);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}

View File

@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { PaymentMethodModel } from "../models";
import { SequelizePaymentMethodRepository } from "./sequelize-payment-method.repository";
const companyId = UniqueID.generateNewID();
const otherCompanyId = UniqueID.generateNewID();
const id = UniqueID.generateNewID().toPrimitive();
for (const outcome of ["allowed", "foreign", "unchanged"] as const) {
const allowed = outcome !== "foreign";
test(`escritura empresarial: resultado=${outcome}`, async (t) => {
const requestedCompany = allowed ? companyId : otherCompanyId;
const transaction = {};
let modified = false;
const repository = new SequelizePaymentMethodRepository(
{
mapToPersistence: () =>
Result.ok({
id,
company_id: requestedCompany.toPrimitive(),
name: "Updated",
items: [],
taxes: [],
}),
} as never,
{} as never,
{} as never
);
t.mock.method(
PaymentMethodModel,
"update",
async (
_payload: unknown,
options: { where: { id: string; company_id: string }; transaction?: unknown }
) => {
assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() });
assert.equal(options.transaction, transaction);
if (options.where.company_id !== companyId.toPrimitive()) {
return [0];
}
modified = true;
return [outcome === "unchanged" ? 0 : 1];
}
);
t.mock.method(
PaymentMethodModel,
"findOne",
async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => {
assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() });
assert.equal(options.transaction, transaction);
return outcome === "unchanged" ? { id: "existing" } : null;
}
);
const result = await repository.update(requestedCompany, {} as never, transaction as never);
assert.equal(result.isSuccess, allowed);
assert.equal(modified, allowed);
if (!allowed) {
assert.equal(result.error.name.includes("NotFound"), true);
}
});
}

View File

@ -1,9 +1,4 @@
import {
EntityNotFoundError,
InfrastructureRepositoryError,
SequelizeRepository,
translateSequelizeError,
} from "@erp/core/api";
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
@ -61,6 +56,7 @@ export class SequelizePaymentMethodRepository
* @returns Result<void, Error>
*/
async update(
companyId: UniqueID,
paymentMethod: PaymentMethod,
transaction?: Transaction
): Promise<Result<void, Error>> {
@ -70,17 +66,26 @@ export class SequelizePaymentMethodRepository
return Result.fail(dtoResult.error);
}
const { id, ...payload } = dtoResult.data;
const { id, company_id, ...payload } = dtoResult.data;
if (company_id !== companyId.toPrimitive()) {
return Result.fail(new EntityNotFoundError("PaymentMethod", "id", id));
}
const [affected] = await PaymentMethodModel.update(payload, {
where: { id },
where: { id, company_id: companyId.toPrimitive() },
transaction,
individualHooks: true,
});
if (affected === 0) {
return Result.fail(
new InfrastructureRepositoryError("Concurrency conflict or payment method not found")
);
const exists = await PaymentMethodModel.findOne({
attributes: ["id"],
lock: Boolean(transaction),
where: { id, company_id: companyId.toPrimitive() },
transaction,
});
if (!exists) {
return Result.fail(new EntityNotFoundError("PaymentMethod", "id", id));
}
}
return Result.ok();

View File

@ -1,9 +1,4 @@
import {
EntityNotFoundError,
InfrastructureRepositoryError,
SequelizeRepository,
translateSequelizeError,
} from "@erp/core/api";
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
@ -47,6 +42,7 @@ export class SequelizeTaxDefinitionRepository
}
async update(
companyId: UniqueID,
taxDefinition: TaxDefinition,
transaction?: Transaction
): Promise<Result<void, Error>> {
@ -56,17 +52,26 @@ export class SequelizeTaxDefinitionRepository
return Result.fail(dtoResult.error);
}
const { id, ...payload } = dtoResult.data as any;
const { id, company_id, ...payload } = dtoResult.data;
if (company_id !== companyId.toPrimitive()) {
return Result.fail(new EntityNotFoundError("TaxDefinition", "id", id));
}
const [affected] = await TaxDefinitionModel.update(payload, {
where: { id },
where: { id, company_id: companyId.toPrimitive() },
transaction,
individualHooks: true,
});
if (affected === 0) {
return Result.fail(
new InfrastructureRepositoryError("Concurrency conflict or tax definition not found")
);
const exists = await TaxDefinitionModel.findOne({
attributes: ["id"],
lock: Boolean(transaction),
where: { id, company_id: companyId.toPrimitive() },
transaction,
});
if (!exists) {
return Result.fail(new EntityNotFoundError("TaxDefinition", "id", id));
}
}
return Result.ok();

View File

@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { TaxDefinitionModel } from "../models";
import { SequelizeTaxDefinitionRepository } from "./sequelize-tax-definition.repository";
const companyId = UniqueID.generateNewID();
const otherCompanyId = UniqueID.generateNewID();
const id = UniqueID.generateNewID().toPrimitive();
for (const outcome of ["allowed", "foreign", "unchanged"] as const) {
const allowed = outcome !== "foreign";
test(`escritura empresarial: resultado=${outcome}`, async (t) => {
const requestedCompany = allowed ? companyId : otherCompanyId;
const transaction = {};
let modified = false;
const repository = new SequelizeTaxDefinitionRepository(
{
mapToPersistence: () =>
Result.ok({
id,
company_id: requestedCompany.toPrimitive(),
name: "Updated",
items: [],
taxes: [],
}),
} as never,
{} as never,
{} as never
);
t.mock.method(
TaxDefinitionModel,
"update",
async (
_payload: unknown,
options: { where: { id: string; company_id: string }; transaction?: unknown }
) => {
assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() });
assert.equal(options.transaction, transaction);
if (options.where.company_id !== companyId.toPrimitive()) {
return [0];
}
modified = true;
return [outcome === "unchanged" ? 0 : 1];
}
);
t.mock.method(
TaxDefinitionModel,
"findOne",
async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => {
assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() });
assert.equal(options.transaction, transaction);
return outcome === "unchanged" ? { id: "existing" } : null;
}
);
const result = await repository.update(requestedCompany, {} as never, transaction as never);
assert.equal(result.isSuccess, allowed);
assert.equal(modified, allowed);
if (!allowed) {
assert.equal(result.error.name.includes("NotFound"), true);
}
});
}

View File

@ -1,9 +1,4 @@
import {
EntityNotFoundError,
InfrastructureRepositoryError,
SequelizeRepository,
translateSequelizeError,
} from "@erp/core/api";
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
@ -55,24 +50,37 @@ export class SequelizeTaxRegimeRepository
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async update(taxRegime: TaxRegime, transaction?: Transaction): Promise<Result<void, Error>> {
async update(
companyId: UniqueID,
taxRegime: TaxRegime,
transaction?: Transaction
): Promise<Result<void, Error>> {
try {
const dtoResult = this.domainMapper.mapToPersistence(taxRegime);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const { id, ...payload } = dtoResult.data;
const { id, company_id, ...payload } = dtoResult.data;
if (company_id !== companyId.toPrimitive()) {
return Result.fail(new EntityNotFoundError("TaxRegime", "id", id));
}
const [affected] = await TaxRegimeModel.update(payload, {
where: { id },
where: { id, company_id: companyId.toPrimitive() },
transaction,
individualHooks: true,
});
if (affected === 0) {
return Result.fail(
new InfrastructureRepositoryError("Concurrency conflict or payment method not found")
);
const exists = await TaxRegimeModel.findOne({
attributes: ["id"],
lock: Boolean(transaction),
where: { id, company_id: companyId.toPrimitive() },
transaction,
});
if (!exists) {
return Result.fail(new EntityNotFoundError("TaxRegime", "id", id));
}
}
return Result.ok();

View File

@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { TaxRegimeModel } from "../models";
import { SequelizeTaxRegimeRepository } from "./sequelize-tax-regime.repository";
const companyId = UniqueID.generateNewID();
const otherCompanyId = UniqueID.generateNewID();
const id = UniqueID.generateNewID().toPrimitive();
for (const outcome of ["allowed", "foreign", "unchanged"] as const) {
const allowed = outcome !== "foreign";
test(`escritura empresarial: resultado=${outcome}`, async (t) => {
const requestedCompany = allowed ? companyId : otherCompanyId;
const transaction = {};
let modified = false;
const repository = new SequelizeTaxRegimeRepository(
{
mapToPersistence: () =>
Result.ok({
id,
company_id: requestedCompany.toPrimitive(),
name: "Updated",
items: [],
taxes: [],
}),
} as never,
{} as never,
{} as never
);
t.mock.method(
TaxRegimeModel,
"update",
async (
_payload: unknown,
options: { where: { id: string; company_id: string }; transaction?: unknown }
) => {
assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() });
assert.equal(options.transaction, transaction);
if (options.where.company_id !== companyId.toPrimitive()) {
return [0];
}
modified = true;
return [outcome === "unchanged" ? 0 : 1];
}
);
t.mock.method(
TaxRegimeModel,
"findOne",
async (options: { where: { id: string; company_id: string }; transaction?: unknown }) => {
assert.deepEqual(options.where, { id, company_id: requestedCompany.toPrimitive() });
assert.equal(options.transaction, transaction);
return outcome === "unchanged" ? { id: "existing" } : null;
}
);
const result = await repository.update(requestedCompany, {} as never, transaction as never);
assert.equal(result.isSuccess, allowed);
assert.equal(modified, allowed);
if (!allowed) {
assert.equal(result.error.name.includes("NotFound"), true);
}
});
}

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
@ -15,6 +16,7 @@ import {
import { toValidationErrors } from "./to-validation-errors";
export const usePaymentMethodCreateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -35,9 +37,12 @@ export const usePaymentMethodCreateMutation = () => {
return GetPaymentMethodByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentMethod) => {
queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod);
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)),
paymentMethod
);
await queryClient.invalidateQueries({
queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX),
});
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { type DeletePaymentMethodParams, deletePaymentMethod } from "../api";
@ -10,6 +11,7 @@ import {
} from "./keys";
export const usePaymentMethodDeleteMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -20,8 +22,12 @@ export const usePaymentMethodDeleteMutation = () => {
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({ queryKey: PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX),
}),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX),
}),
]);
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { GetPaymentMethodByIdAdapter } from "../adapters";
@ -11,11 +12,12 @@ export const usePaymentMethodDetailQuery = (
paymentMethodId?: string,
options?: { enabled?: boolean }
): UseQueryResult<PaymentMethod, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && Boolean(paymentMethodId);
return useQuery<PaymentMethod, DefaultError>({
queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethodId),
queryKey: companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethodId)),
enabled,
queryFn: async ({ signal }) => {
const dto = await getPaymentMethodById(dataSource, {

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetPaymentMethodByIdAdapter } from "../adapters";
@ -12,6 +13,7 @@ import {
} from "./keys";
export const usePaymentMethodDisableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -22,8 +24,13 @@ export const usePaymentMethodDisableMutation = () => {
return GetPaymentMethodByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentMethod) => {
queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod);
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)),
paymentMethod
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetPaymentMethodByIdAdapter } from "../adapters";
@ -12,6 +13,7 @@ import {
} from "./keys";
export const usePaymentMethodEnableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -22,8 +24,13 @@ export const usePaymentMethodEnableMutation = () => {
return GetPaymentMethodByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentMethod) => {
queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod);
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)),
paymentMethod
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,5 +1,6 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { ListPaymentMethodsAdapter } from "../adapters";
@ -16,17 +17,19 @@ export interface PaymentMethodsListQueryOptions {
export const usePaymentMethodsListQuery = (
options?: PaymentMethodsListQueryOptions
): UseQueryResult<PaymentMethodList, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<PaymentMethodList, DefaultError>({
queryKey: LIST_PAYMENT_METHODS_QUERY_KEY(criteria),
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY(criteria)),
queryFn: async ({ signal }) => {
const dto = await getListPaymentMethodsByCriteria(dataSource, { signal, criteria });
return ListPaymentMethodsAdapter.fromDto(dto);
},
enabled,
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria
placeholderData: (previousData, previousQuery) =>
keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria
});
};

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetPaymentMethodByIdAdapter } from "../adapters";
@ -12,6 +13,7 @@ import {
} from "./keys";
export const usePaymentMethodSetDefaultMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -22,10 +24,17 @@ export const usePaymentMethodSetDefaultMutation = () => {
return GetPaymentMethodByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentMethod) => {
queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod);
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)),
paymentMethod
);
await Promise.all([
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({ queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethod.id) }),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX),
}),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)),
}),
]);
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
@ -15,6 +16,7 @@ import {
import { toValidationErrors } from "./to-validation-errors";
export const usePaymentMethodUpdateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -35,9 +37,12 @@ export const usePaymentMethodUpdateMutation = () => {
return GetPaymentMethodByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentMethod) => {
queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod);
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_METHOD_QUERY_KEY(paymentMethod.id)),
paymentMethod
);
await queryClient.invalidateQueries({
queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX),
});
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
@ -15,6 +16,7 @@ import {
import { toValidationErrors } from "./to-validation-errors";
export const usePaymentTermCreateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -35,9 +37,12 @@ export const usePaymentTermCreateMutation = () => {
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)),
paymentTerm
);
await queryClient.invalidateQueries({
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX),
});
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { type DeletePaymentTermParams, deletePaymentTerm } from "../api";
@ -10,6 +11,7 @@ import {
} from "./keys";
export const usePaymentTermDeleteMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -20,8 +22,12 @@ export const usePaymentTermDeleteMutation = () => {
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({ queryKey: PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX),
}),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX),
}),
]);
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { GetPaymentTermByIdAdapter } from "../adapters";
@ -11,11 +12,12 @@ export const usePaymentTermDetailQuery = (
paymentTermId?: string,
options?: { enabled?: boolean }
): UseQueryResult<PaymentTerm, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && Boolean(paymentTermId);
return useQuery<PaymentTerm, DefaultError>({
queryKey: PAYMENT_TERM_QUERY_KEY(paymentTermId),
queryKey: companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTermId)),
enabled,
queryFn: async ({ signal }) => {
const dto = await getPaymentTermById(dataSource, {

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetPaymentTermByIdAdapter } from "../adapters";
@ -12,6 +13,7 @@ import {
} from "./keys";
export const usePaymentTermDisableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -22,8 +24,13 @@ export const usePaymentTermDisableMutation = () => {
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)),
paymentTerm
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetPaymentTermByIdAdapter } from "../adapters";
@ -12,6 +13,7 @@ import {
} from "./keys";
export const usePaymentTermEnableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -22,8 +24,13 @@ export const usePaymentTermEnableMutation = () => {
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)),
paymentTerm
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,5 +1,6 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { ListPaymentTermsAdapter } from "../adapters";
@ -16,17 +17,19 @@ export interface PaymentTermsListQueryOptions {
export const usePaymentTermListQuery = (
options?: PaymentTermsListQueryOptions
): UseQueryResult<PaymentTermList, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<PaymentTermList, DefaultError>({
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY(criteria),
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY(criteria)),
queryFn: async ({ signal }) => {
const dto = await getListPaymentTermsByCriteria(dataSource, { signal, criteria });
return ListPaymentTermsAdapter.fromDto(dto);
},
enabled,
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria
placeholderData: (previousData, previousQuery) =>
keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria
});
};

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
@ -15,6 +16,7 @@ import {
import { toValidationErrors } from "./to-validation-errors";
export const usePaymentTermUpdateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -35,9 +37,12 @@ export const usePaymentTermUpdateMutation = () => {
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
queryClient.setQueryData(
companyQueryKey(queryScope, PAYMENT_TERM_QUERY_KEY(paymentTerm.id)),
paymentTerm
);
await queryClient.invalidateQueries({
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
queryKey: companyQueryKey(queryScope, LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX),
});
},
});

View File

@ -1,27 +1,47 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateTaxDefinitionRequestSchema } from "../../../../common";
import { GetTaxDefinitionByIdAdapter } from "../adapters";
import { createTaxDefinition, type CreateTaxDefinitionParams } from "../api";
import { type CreateTaxDefinitionParams, createTaxDefinition } from "../api";
import type { TaxDefinition } from "../entities";
import { CREATE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys";
import {
CREATE_TAX_DEFINITION_MUTATION_KEY,
LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX,
TAX_DEFINITION_QUERY_KEY,
} from "./keys";
import { toValidationErrors } from "./to-validation-errors";
export const useTaxDefinitionCreateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<TaxDefinition, DefaultError, CreateTaxDefinitionParams>({
mutationKey: CREATE_TAX_DEFINITION_MUTATION_KEY,
mutationFn: async (params) => {
if (!params.id) throw new Error("taxDefinitionId is required");
if (!params.id) {
throw new Error("taxDefinitionId is required");
}
const validation = CreateTaxDefinitionRequestSchema.safeParse(params.data);
if (!validation.success) throw new ValidationErrorCollection("Validation failed", toValidationErrors(validation.error));
if (!validation.success) {
throw new ValidationErrorCollection(
"Validation failed",
toValidationErrors(validation.error)
);
}
return GetTaxDefinitionByIdAdapter.fromDto(await createTaxDefinition(dataSource, params));
},
onSuccess: async (taxDefinition) => {
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)),
taxDefinition
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,17 +1,31 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteTaxDefinitionById, type DeleteTaxDefinitionByIdParams } from "../api";
import { DELETE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys";
import { type DeleteTaxDefinitionByIdParams, deleteTaxDefinitionById } from "../api";
import {
DELETE_TAX_DEFINITION_MUTATION_KEY,
LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX,
TAX_DEFINITION_QUERY_KEY,
} from "./keys";
export const useTaxDefinitionDeleteMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<void, DefaultError, DeleteTaxDefinitionByIdParams>({
mutationKey: DELETE_TAX_DEFINITION_MUTATION_KEY,
mutationFn: async (params) => { await deleteTaxDefinitionById(dataSource, params); },
mutationFn: async (params) => {
await deleteTaxDefinitionById(dataSource, params);
},
onSuccess: async (_result, params) => {
queryClient.removeQueries({ queryKey: TAX_DEFINITION_QUERY_KEY(params.taxDefinitionId) });
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
queryClient.removeQueries({
queryKey: companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(params.taxDefinitionId)),
});
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,16 +1,26 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { GetTaxDefinitionByIdAdapter } from "../adapters";
import { getTaxDefinitionById } from "../api";
import type { TaxDefinition } from "../entities";
import { TAX_DEFINITION_QUERY_KEY } from "./keys";
export const useTaxDefinitionDetailQuery = (taxDefinitionId?: string, options?: { enabled?: boolean }): UseQueryResult<TaxDefinition, DefaultError> => {
export const useTaxDefinitionDetailQuery = (
taxDefinitionId?: string,
options?: { enabled?: boolean }
): UseQueryResult<TaxDefinition, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && Boolean(taxDefinitionId);
return useQuery<TaxDefinition, DefaultError>({
queryKey: TAX_DEFINITION_QUERY_KEY(taxDefinitionId),
queryKey: companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinitionId)),
enabled,
queryFn: async ({ signal }) => GetTaxDefinitionByIdAdapter.fromDto(await getTaxDefinitionById(dataSource, { taxDefinitionId: taxDefinitionId!, signal })),
queryFn: async ({ signal }) =>
GetTaxDefinitionByIdAdapter.fromDto(
await getTaxDefinitionById(dataSource, { taxDefinitionId: taxDefinitionId!, signal })
),
});
};

View File

@ -1,19 +1,33 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetTaxDefinitionByIdAdapter } from "../adapters";
import { disableTaxDefinitionById, type DisableTaxDefinitionByIdParams } from "../api";
import { type DisableTaxDefinitionByIdParams, disableTaxDefinitionById } from "../api";
import type { TaxDefinition } from "../entities";
import { DISABLE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys";
import {
DISABLE_TAX_DEFINITION_MUTATION_KEY,
LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX,
TAX_DEFINITION_QUERY_KEY,
} from "./keys";
export const useTaxDefinitionDisableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<TaxDefinition, DefaultError, DisableTaxDefinitionByIdParams>({
mutationKey: DISABLE_TAX_DEFINITION_MUTATION_KEY,
mutationFn: async (params) => GetTaxDefinitionByIdAdapter.fromDto(await disableTaxDefinitionById(dataSource, params)),
mutationFn: async (params) =>
GetTaxDefinitionByIdAdapter.fromDto(await disableTaxDefinitionById(dataSource, params)),
onSuccess: async (taxDefinition) => {
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)),
taxDefinition
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,19 +1,33 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetTaxDefinitionByIdAdapter } from "../adapters";
import { enableTaxDefinitionById, type EnableTaxDefinitionByIdParams } from "../api";
import { type EnableTaxDefinitionByIdParams, enableTaxDefinitionById } from "../api";
import type { TaxDefinition } from "../entities";
import { ENABLE_TAX_DEFINITION_MUTATION_KEY, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY } from "./keys";
import {
ENABLE_TAX_DEFINITION_MUTATION_KEY,
LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX,
TAX_DEFINITION_QUERY_KEY,
} from "./keys";
export const useTaxDefinitionEnableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<TaxDefinition, DefaultError, EnableTaxDefinitionByIdParams>({
mutationKey: ENABLE_TAX_DEFINITION_MUTATION_KEY,
mutationFn: async (params) => GetTaxDefinitionByIdAdapter.fromDto(await enableTaxDefinitionById(dataSource, params)),
mutationFn: async (params) =>
GetTaxDefinitionByIdAdapter.fromDto(await enableTaxDefinitionById(dataSource, params)),
onSuccess: async (taxDefinition) => {
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)),
taxDefinition
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,20 +1,31 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { ListTaxDefinitionsAdapter } from "../adapters";
import { listTaxDefinitions } from "../api";
import type { TaxDefinitionList } from "../entities";
import { LIST_TAX_DEFINITIONS_QUERY_KEY } from "./keys";
export interface TaxDefinitionListQueryOptions { enabled?: boolean; criteria?: Partial<CriteriaDTO>; }
export interface TaxDefinitionListQueryOptions {
enabled?: boolean;
criteria?: Partial<CriteriaDTO>;
}
export const useTaxDefinitionListQuery = (options?: TaxDefinitionListQueryOptions): UseQueryResult<TaxDefinitionList, DefaultError> => {
export const useTaxDefinitionListQuery = (
options?: TaxDefinitionListQueryOptions
): UseQueryResult<TaxDefinitionList, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const criteria = options?.criteria ?? {};
return useQuery<TaxDefinitionList, DefaultError>({
queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY(criteria),
queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY(criteria)),
enabled: options?.enabled ?? true,
queryFn: async ({ signal }) => ListTaxDefinitionsAdapter.fromDto(await listTaxDefinitions(dataSource, { signal, criteria })),
placeholderData: (previousData) => previousData,
queryFn: async ({ signal }) =>
ListTaxDefinitionsAdapter.fromDto(await listTaxDefinitions(dataSource, { signal, criteria })),
placeholderData: (previousData, previousQuery) =>
keepPreviousCompanyData(queryScope, previousData, previousQuery),
});
};

View File

@ -1,27 +1,47 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateTaxDefinitionByIdRequestSchema } from "../../../../common";
import { GetTaxDefinitionByIdAdapter } from "../adapters";
import { type UpdateTaxDefinitionByIdParams, updateTaxDefinitionById } from "../api";
import type { TaxDefinition } from "../entities";
import { LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX, TAX_DEFINITION_QUERY_KEY, UPDATE_TAX_DEFINITION_MUTATION_KEY } from "./keys";
import {
LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX,
TAX_DEFINITION_QUERY_KEY,
UPDATE_TAX_DEFINITION_MUTATION_KEY,
} from "./keys";
import { toValidationErrors } from "./to-validation-errors";
export const useTaxDefinitionUpdateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<TaxDefinition, DefaultError, UpdateTaxDefinitionByIdParams>({
mutationKey: UPDATE_TAX_DEFINITION_MUTATION_KEY,
mutationFn: async (params) => {
if (!params.taxDefinitionId) throw new Error("taxDefinitionId is required");
if (!params.taxDefinitionId) {
throw new Error("taxDefinitionId is required");
}
const validation = UpdateTaxDefinitionByIdRequestSchema.safeParse(params.data);
if (!validation.success) throw new ValidationErrorCollection("Validation failed", toValidationErrors(validation.error));
if (!validation.success) {
throw new ValidationErrorCollection(
"Validation failed",
toValidationErrors(validation.error)
);
}
return GetTaxDefinitionByIdAdapter.fromDto(await updateTaxDefinitionById(dataSource, params));
},
onSuccess: async (taxDefinition) => {
queryClient.setQueryData(TAX_DEFINITION_QUERY_KEY(taxDefinition.id), taxDefinition);
await queryClient.invalidateQueries({ queryKey: LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_DEFINITION_QUERY_KEY(taxDefinition.id)),
taxDefinition
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_DEFINITIONS_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
@ -15,6 +16,7 @@ import {
import { toValidationErrors } from "./to-validation-errors";
export const useTaxRegimeCreateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -35,9 +37,12 @@ export const useTaxRegimeCreateMutation = () => {
return GetTaxRegimeByIdAdapter.fromDto(dto);
},
onSuccess: async (taxRegime) => {
queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime);
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)),
taxRegime
);
await queryClient.invalidateQueries({
queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX,
queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX),
});
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { type DeleteTaxRegimeParams, deleteTaxRegime } from "../api";
@ -10,6 +11,7 @@ import {
} from "./keys";
export const useTaxRegimeDeleteMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -20,8 +22,12 @@ export const useTaxRegimeDeleteMutation = () => {
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({ queryKey: TAX_REGIMES_DETAIL_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX),
}),
queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, TAX_REGIMES_DETAIL_QUERY_KEY_PREFIX),
}),
]);
},
});

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { GetTaxRegimeByIdAdapter } from "../adapters";
@ -11,11 +12,12 @@ export const useTaxRegimeDetailQuery = (
taxRegimeId?: string,
options?: { enabled?: boolean }
): UseQueryResult<TaxRegime, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && Boolean(taxRegimeId);
return useQuery<TaxRegime, DefaultError>({
queryKey: TAX_REGIME_QUERY_KEY(taxRegimeId),
queryKey: companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegimeId)),
enabled,
queryFn: async ({ signal }) => {
const dto = await getTaxRegimeById(dataSource, {

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetTaxRegimeByIdAdapter } from "../adapters";
@ -12,6 +13,7 @@ import {
} from "./keys";
export const useTaxRegimeDisableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -22,8 +24,13 @@ export const useTaxRegimeDisableMutation = () => {
return GetTaxRegimeByIdAdapter.fromDto(dto);
},
onSuccess: async (taxRegime) => {
queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime);
await queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)),
taxRegime
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetTaxRegimeByIdAdapter } from "../adapters";
@ -12,6 +13,7 @@ import {
} from "./keys";
export const useTaxRegimeEnableMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -22,8 +24,13 @@ export const useTaxRegimeEnableMutation = () => {
return GetTaxRegimeByIdAdapter.fromDto(dto);
},
onSuccess: async (taxRegime) => {
queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime);
await queryClient.invalidateQueries({ queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX });
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)),
taxRegime
);
await queryClient.invalidateQueries({
queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX),
});
},
});
};

View File

@ -1,5 +1,6 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey, keepPreviousCompanyData } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { ListTaxRegimesAdapter } from "../adapters";
@ -16,17 +17,19 @@ export interface TaxRegimesListQueryOptions {
export const useTaxRegimesListQuery = (
options?: TaxRegimesListQueryOptions
): UseQueryResult<TaxRegimeList, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<TaxRegimeList, DefaultError>({
queryKey: LIST_TAX_REGIMES_QUERY_KEY(criteria),
queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY(criteria)),
queryFn: async ({ signal }) => {
const dto = await getListTaxRegimesByCriteria(dataSource, { signal, criteria });
return ListTaxRegimesAdapter.fromDto(dto);
},
enabled,
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria
placeholderData: (previousData, previousQuery) =>
keepPreviousCompanyData(queryScope, previousData, previousQuery), // Mantiene la página anterior durante refetch por cambio de criteria
});
};

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
@ -15,6 +16,7 @@ import {
import { toValidationErrors } from "./to-validation-errors";
export const useTaxRegimeUpdateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -35,9 +37,12 @@ export const useTaxRegimeUpdateMutation = () => {
return GetTaxRegimeByIdAdapter.fromDto(dto);
},
onSuccess: async (taxRegime) => {
queryClient.setQueryData(TAX_REGIME_QUERY_KEY(taxRegime.id), taxRegime);
queryClient.setQueryData(
companyQueryKey(queryScope, TAX_REGIME_QUERY_KEY(taxRegime.id)),
taxRegime
);
await queryClient.invalidateQueries({
queryKey: LIST_TAX_REGIMES_QUERY_KEY_PREFIX,
queryKey: companyQueryKey(queryScope, LIST_TAX_REGIMES_QUERY_KEY_PREFIX),
});
},
});

View File

@ -13,6 +13,7 @@ export interface ICompanyRepository {
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
findActiveByIds(params: {
allCompanies?: boolean;
companyIds: Collection<UniqueID>;
transaction?: unknown;
}): Promise<Result<Collection<Company>, Error>>;

View File

@ -77,6 +77,7 @@ test("create devuelve la company sin default payment term", async () => {
const company = buildCompany();
const useCase = new CreateCompanyUseCase({
adminAssigner: { assign: async () => Result.ok() },
inputMapper: new CreateCompanyInputMapper(),
creator: {
create: async () => Result.ok(company),
@ -86,6 +87,7 @@ test("create devuelve la company sin default payment term", async () => {
});
const result = await useCase.execute({
accountId: UniqueID.generateNewID(),
dto: {
id: company.id.toPrimitive(),
legal_name: company.legalName.toPrimitive(),
@ -118,12 +120,14 @@ test("create serializes the company public address", async () => {
true
);
const useCase = new CreateCompanyUseCase({
adminAssigner: { assign: async () => Result.ok() },
inputMapper: new CreateCompanyInputMapper(),
creator: { create: async () => Result.ok(company) },
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
accountId: UniqueID.generateNewID(),
dto: {
id: company.id.toPrimitive(),
legal_name: company.legalName.toPrimitive(),
@ -150,6 +154,7 @@ test("create maps a complete request address to Company props", async () => {
const company = buildCompany();
let capturedAddress: typeof company.address | undefined;
const useCase = new CreateCompanyUseCase({
adminAssigner: { assign: async () => Result.ok() },
inputMapper: new CreateCompanyInputMapper(),
creator: {
create: async ({ props }) => {
@ -161,6 +166,7 @@ test("create maps a complete request address to Company props", async () => {
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({
accountId: UniqueID.generateNewID(),
dto: {
id: company.id.toPrimitive(),
legal_name: company.legalName.toPrimitive(),
@ -191,6 +197,7 @@ test("create returns an input mapper failure without calling the creator", async
let creatorCalled = false;
const expectedError = new Error("invalid input");
const useCase = new CreateCompanyUseCase({
adminAssigner: { assign: async () => Result.ok() },
inputMapper: { map: () => Result.fail(expectedError) },
creator: {
create: async () => {
@ -201,7 +208,10 @@ test("create returns an input mapper failure without calling the creator", async
responseMapper: new CompanyPublicModelMapper(),
transactionManager: buildTransactionManager(),
});
const result = await useCase.execute({ dto: {} as never });
const result = await useCase.execute({
accountId: UniqueID.generateNewID(),
dto: {} as never,
});
assert.equal(result.error, expectedError);
assert.equal(creatorCalled, false);
});

View File

@ -1,4 +1,6 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IInitialCompanyAdminAssigner } from "@erp/identity/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common";
@ -10,6 +12,7 @@ export class CreateCompanyUseCase {
public constructor(
private readonly deps: {
creator: ICompanyCreator;
adminAssigner: IInitialCompanyAdminAssigner;
inputMapper: ICreateCompanyInputMapper;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
@ -17,6 +20,7 @@ export class CreateCompanyUseCase {
) {}
public execute(params: {
dto: CreateCompanyRequestDTO;
accountId: UniqueID;
}): Promise<Result<CompanyResponseDTO, Error>> {
const input = this.deps.inputMapper.map(params.dto);
if (input.isFailure) {
@ -29,9 +33,18 @@ export class CreateCompanyUseCase {
props: input.data.props,
transaction,
});
return result.isFailure
? Result.fail(result.error)
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
if (result.isFailure) {
return Result.fail(result.error);
}
const admin = await this.deps.adminAssigner.assign({
accountId: params.accountId,
companyId: result.data.id,
transaction,
});
if (admin.isFailure) {
return Result.fail(admin.error);
}
return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
} catch (error: unknown) {
return Result.fail(error as Error);
}

View File

@ -4,13 +4,14 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { AvailableCompaniesResponseDTO, AvailableCompanyDTO } from "../../../../common";
import { CompaniesPermissionCodes } from "../../../../common";
import type { ICompanyRepository } from "../contracts";
import type { CompanyPublicModelMapper } from "../public";
export class FindAvailableCompaniesForAccountUseCase {
public constructor(
private readonly deps: {
identityServices: Pick<IIdentityPublicServices, "companyAccess">;
identityServices: Pick<IIdentityPublicServices, "companyAccess" | "authorization">;
repository: ICompanyRepository;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
@ -37,7 +38,16 @@ export class FindAvailableCompaniesForAccountUseCase {
return Result.fail(accessibleCompanyIdsResult.error);
}
const global = await this.deps.identityServices.authorization.canSystem({
accountId: accountIdResult.data,
permissionCode: CompaniesPermissionCodes.Create,
transaction,
});
if (global.isFailure) {
return Result.fail(global.error);
}
const companiesResult = await this.deps.repository.findActiveByIds({
...(global.data ? { allCompanies: true } : {}),
companyIds: accessibleCompanyIdsResult.data,
transaction,
});

View File

@ -29,6 +29,7 @@ import { UpdateCurrentCompanyInputMapper } from "../../application/companies/map
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
export type CompaniesInternalDeps = {
authorization: IIdentityPublicServices["authorization"];
companyAccess: IIdentityPublicServices["companyAccess"];
publicFinder: CompanyPublicFinder;
useCases: {
@ -76,10 +77,12 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDepen
return {
internal: {
authorization: identityServices.authorization,
companyAccess: identityServices.companyAccess,
publicFinder,
useCases: {
createCompany: new CreateCompanyUseCase({
adminAssigner: identityServices.initialCompanyAdminAssigner,
creator,
inputMapper: createCompanyInputMapper,
responseMapper,
@ -96,7 +99,10 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDepen
transactionManager,
}),
findAvailableCompaniesForAccount: new FindAvailableCompaniesForAccountUseCase({
identityServices: { companyAccess: identityServices.companyAccess },
identityServices: {
companyAccess: identityServices.companyAccess,
authorization: identityServices.authorization,
},
repository,
responseMapper,
transactionManager,

View File

@ -1,10 +1,16 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import { requireCompanyContext, requireIdentityAuthenticated } from "@erp/identity/api";
import {
requireCompanyContext,
requireIdentityAuthenticated,
requirePermission,
} from "@erp/identity/api";
import { IdentityPermissionCodes } from "@erp/identity/common";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
import {
ChangeCompanyStatusRequestSchema,
CompaniesPermissionCodes,
CreateCompanyRequestSchema,
GetCompanyByIdRequestSchema,
UpdateCompanyByIdParamsRequestSchema,
@ -39,6 +45,7 @@ export const companiesRouter = (params: StartParams) => {
router.post(
"/",
requirePermission(deps.authorization, CompaniesPermissionCodes.Create, "SYSTEM"),
validateRequest(CreateCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new CreateCompanyController(deps.useCases.createCompany);
@ -65,6 +72,7 @@ export const companiesRouter = (params: StartParams) => {
router.patch(
"/current",
requireCurrentCompanyContext,
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany);
@ -75,6 +83,7 @@ export const companiesRouter = (params: StartParams) => {
router.get(
"/:company_id",
requireCurrentCompanyContext,
requirePermission(deps.authorization, CompaniesPermissionCodes.Read),
validateRequest(GetCompanyByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById);
@ -85,6 +94,7 @@ export const companiesRouter = (params: StartParams) => {
router.patch(
"/:company_id",
requireCurrentCompanyContext,
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
validateRequest(UpdateCompanyRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
@ -96,6 +106,7 @@ export const companiesRouter = (params: StartParams) => {
router.post(
"/:company_id/enable",
requireCurrentCompanyContext,
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new EnableCompanyController(deps.useCases.enableCompany);
@ -106,6 +117,7 @@ export const companiesRouter = (params: StartParams) => {
router.post(
"/:company_id/disable",
requireCurrentCompanyContext,
requirePermission(deps.authorization, IdentityPermissionCodes.CompaniesUpdate),
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new DisableCompanyController(deps.useCases.disableCompany);

View File

@ -1,4 +1,5 @@
import { ExpressController } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import type { CreateCompanyRequestDTO } from "../../../../common";
import type { CreateCompanyUseCase } from "../../../application";
@ -12,7 +13,11 @@ export class CreateCompanyController extends ExpressController {
protected async executeImpl() {
const dto = this.req.body as CreateCompanyRequestDTO;
const result = await this.useCase.execute({ dto });
const accountId = this.getUser<{ userId?: UniqueID }>()?.userId;
if (!accountId) {
return this.unauthorizedError("Authentication required");
}
const result = await this.useCase.execute({ dto, accountId });
return result.match(
(data) => this.created(data),

View File

@ -133,6 +133,7 @@ export class SequelizeCompanyRepository
}
public async findActiveByIds(params: {
allCompanies?: boolean;
companyIds: Collection<UniqueID>;
transaction?: unknown;
}): Promise<Result<Collection<Company>, Error>> {
@ -141,13 +142,13 @@ export class SequelizeCompanyRepository
...new Set(params.companyIds.getAll().map((companyId) => companyId.toPrimitive())),
];
if (ids.length === 0) {
if (ids.length === 0 && !params.allCompanies) {
return Result.ok(new Collection([]));
}
const rows = await CompanyModel.findAll({
where: {
id: { [Op.in]: ids },
...(params.allCompanies ? {} : { id: { [Op.in]: ids } }),
status: COMPANY_STATUS.ACTIVE,
},
transaction: asTransaction(params.transaction),

View File

@ -1,5 +1,7 @@
import type { IModuleServer } from "@erp/core/api";
import { companiesPermissionDefinitions } from "../common/permissions";
import type { ICompanyPublicServices } from "./application";
import { buildCompaniesDependencies } from "./infrastructure/di";
import { models } from "./infrastructure/persistence/sequelize";
@ -14,6 +16,7 @@ export const companiesModule: IModuleServer = {
name: "companies",
version: "1.0.0",
dependencies: ["identity", "catalogs"],
permissions: companiesPermissionDefinitions,
async setup(params) {
const { internal, publicServices } = buildCompaniesDependencies(params);
const general: ICompanyPublicServices = publicServices;

View File

@ -1 +1,2 @@
export * from "./dto";
export * from "./permissions";

View File

@ -0,0 +1,21 @@
import type { PermissionDefinition } from "@erp/core";
export const CompaniesPermissionCodes = {
Create: "companies:companies:create",
Read: "companies:companies:read",
} as const;
export const companiesPermissionDefinitions = [
{
code: CompaniesPermissionCodes.Create,
module: "companies",
scope: "SYSTEM",
description: "Create companies globally.",
},
{
code: CompaniesPermissionCodes.Read,
scope: "COMPANY",
module: "companies",
description: "Read a company through its explicit ID endpoint.",
},
] satisfies PermissionDefinition[];

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { CompanySettingsAdapter } from "../adapters";
@ -8,10 +9,11 @@ import type { CompanySettings } from "../entities";
import { COMPANY_SETTINGS_QUERY_KEY } from "./keys";
export const useCompanySettingsQuery = (): UseQueryResult<CompanySettings, DefaultError> => {
const queryScope = useCompanyQueryScope();
const dataSource = useDataSource();
return useQuery<CompanySettings, DefaultError>({
queryKey: COMPANY_SETTINGS_QUERY_KEY(),
queryKey: companyQueryKey(queryScope, COMPANY_SETTINGS_QUERY_KEY()),
enabled: true,
queryFn: async ({ signal }) => {
const dto = await getCompanySettings(dataSource, { signal });

View File

@ -1,4 +1,5 @@
import { useDataSource } from "@erp/core/hooks";
import { useCompanyQueryScope, useDataSource } from "@erp/core/hooks";
import { companyQueryKey } from "@erp/core/query";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
@ -19,6 +20,7 @@ const toValidationErrors = (issues: { path: PropertyKey[]; message: string }[])
}));
export const useCompanySettingsUpdateMutation = () => {
const queryScope = useCompanyQueryScope();
const queryClient = useQueryClient();
const dataSource = useDataSource();
@ -38,7 +40,7 @@ export const useCompanySettingsUpdateMutation = () => {
return CompanySettingsAdapter.fromDto(dto);
},
onSuccess: async (company) => {
queryClient.setQueryData(COMPANY_SETTINGS_QUERY_KEY(), company);
queryClient.setQueryData(companyQueryKey(queryScope, COMPANY_SETTINGS_QUERY_KEY()), company);
},
});
};

View File

@ -63,6 +63,7 @@ mock.module("@erp/core/components", {
mock.module("@erp/core/hooks", {
namedExports: {
useCompanyQueryScope: () => null,
UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children,
},
});

View File

@ -18,7 +18,8 @@
"./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css",
"./components": "./src/web/components/index.ts",
"./hooks": "./src/web/hooks/index.ts"
"./hooks": "./src/web/hooks/index.ts",
"./query": "./src/web/lib/query/index.ts"
},
"peerDependencies": {
"react": "^19.2.5",
@ -59,4 +60,4 @@
"sequelize": "^6.37.8",
"zod": "^4.3.6"
}
}
}

View File

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { collectModulePermissions } from "./module-permissions";
const definition = {
code: "companies:companies:read",
module: "companies",
scope: "COMPANY" as const,
description: "Read",
};
test("compone permisos de módulos sin dependencia de Identity hacia sus consumidores", () => {
const result = collectModulePermissions([
{ name: "identity" },
{ name: "companies", permissions: [definition] },
]);
assert.deepEqual(result, [definition]);
assert.equal(Object.isFrozen(result), true);
});
test("rechaza duplicados, namespace ajeno y códigos inválidos antes del setup", () => {
assert.throws(() =>
collectModulePermissions([{ name: "companies", permissions: [definition, definition] }])
);
assert.throws(() => collectModulePermissions([{ name: "customers", permissions: [definition] }]));
assert.throws(() =>
collectModulePermissions([
{ name: "companies", permissions: [{ ...definition, code: "unknown" }] },
])
);
});
test("scope inválido impide el arranque", () => {
assert.throws(() =>
collectModulePermissions([
{ name: "companies", permissions: [{ ...definition, scope: "GLOBAL" as never }] },
])
);
});

View File

@ -0,0 +1,26 @@
import type { PermissionDefinition } from "../../common";
/** Compone declaraciones antes del setup; un duplicado o namespace ajeno impide arrancar. */
export function collectModulePermissions(
modules: readonly { name: string; permissions?: readonly PermissionDefinition[] }[]
): readonly PermissionDefinition[] {
const definitions = new Map<string, PermissionDefinition>();
for (const module of modules) {
for (const definition of module.permissions ?? []) {
if (
definition.module !== module.name ||
!definition.code.startsWith(`${module.name}:`) ||
!/^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$/.test(definition.code) ||
!["SYSTEM", "COMPANY"].includes(definition.scope) ||
!definition.description.trim()
) {
throw new Error(`Invalid permission declaration in module "${module.name}"`);
}
if (definitions.has(definition.code)) {
throw new Error(`Duplicate permission declaration: ${definition.code}`);
}
definitions.set(definition.code, Object.freeze({ ...definition }));
}
}
return Object.freeze([...definitions.values()]);
}

View File

@ -83,3 +83,43 @@ test("initModules conserva force antes del warmup", async () => {
"warmup",
]);
});
test("compone permisos de todos los manifests antes de construir Identity", async () => {
const declarations = [
{
code: "companies:companies:read",
module: "companies",
scope: "COMPANY" as const,
description: "Read",
},
];
let received: unknown;
registerModule({
name: "identity",
version: "1",
setup: (context) => {
received = context.permissionDefinitions;
return {};
},
});
registerFixture();
registerModule({ name: "companies", version: "1", permissions: declarations });
await initializeModuleStructure(params("none"));
assert.deepEqual(received, declarations);
});
test("un warmup de seguridad obligatorio impide arrancar aunque strict sea false", async () => {
registerFixture();
registerModule({
name: "security",
version: "1",
warmupRequired: true,
warmup: () => {
throw new Error("Permission synchronization failed");
},
});
await assert.rejects(
initModules(Object.assign(params("none"), { warmup: { timeoutMs: 1000, strict: false } })),
/Permission synchronization failed/
);
});

View File

@ -1,6 +1,7 @@
import type { ILogger } from "@repo/rdx-logger";
import type { Model, ModelStatic, Sequelize } from "sequelize";
import { collectModulePermissions } from "./module-permissions";
import type { IModuleServer } from "./module-server.interface";
import type { ModulesRuntime } from "./modules-runtime";
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
@ -54,8 +55,9 @@ export async function initModules(
export async function initializeModuleStructure(
params: ModuleParams & { logger: ILogger; database: Sequelize }
): Promise<ModulesRuntime> {
const permissionDefinitions = collectModulePermissions([...modules.values()]);
for (const name of modules.keys()) {
await setupModule(name, params, []);
await setupModule(name, { ...params, permissionDefinitions }, []);
}
await initializeModels(params.database, params.logger);
validateDependencies();
@ -70,7 +72,9 @@ export async function initializeModuleStructure(
throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
}
return { customerInvoices: customerInvoices.proformas.recurringProformas };
return {
customerInvoices: { recurringProformas: customerInvoices.proformas.recurringProformas },
};
}
/** Ejecuta la fase opcional de conexión al runtime, que el server usa para HTTP. */
@ -199,7 +203,7 @@ async function warmupModules(
`${name}.warmup`
);
} catch (error) {
if (params.warmup.strict) {
if (params.warmup.strict || module.warmupRequired) {
throw error;
}
params.logger.warn("Module warmup failed but continuing", {

View File

@ -1,6 +1,6 @@
//Contrato para los Modules backend (Node.js)
import type { ModuleMetadata } from "../../common";
import type { ModuleMetadata, PermissionDefinition } from "../../common";
import type { ModuleSetupResult, SetupParams, StartParams, WarmupParams } from "./types";
@ -15,6 +15,9 @@ export interface IModuleServer extends ModuleMetadata {
*/
dependencies?: string[];
/** Permisos declarados por el módulo; registrarlos no los concede a ningún rol. */
permissions?: readonly PermissionDefinition[];
/**
* Fase de construcción del módulo.
* Aquí se construye el dominio y se decide qué se expone.
@ -30,5 +33,8 @@ export interface IModuleServer extends ModuleMetadata {
/**
* Warmup opcional (IO, precargas, validaciones externas).
*/
/** Un mantenimiento de seguridad obligatorio debe impedir el arranque si falla. */
warmupRequired?: boolean;
warmup?: (params: WarmupParams) => Promise<void> | void;
}

View File

@ -1,6 +1,9 @@
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { Model, ModelStatic, Sequelize } from "sequelize";
import type { PermissionDefinition } from "../../common";
import type { IOperationContextStore } from "../infrastructure/context";
import type { ISequelizeTransactionContext } from "../infrastructure/persistence/sequelize";
@ -10,6 +13,17 @@ export interface SequelizeModel extends Model {
}
export type ModuleParams = {
/** Puerto suministrado por el host tras setup para validar tenant sin ciclos entre módulos. */
tenantCompanyFinder?: {
existsActiveById(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
/** Catálogo compuesto por el runtime antes del setup de cualquier módulo. */
permissionDefinitions?: readonly PermissionDefinition[];
/**
* Acceso a servicios expuestos por otros módulos.
* Todas las dependencias ya están resueltas.

View File

@ -2,6 +2,7 @@ export type PermissionCode = string;
export type PermissionDefinition = {
code: PermissionCode;
scope: "SYSTEM" | "COMPANY";
module: string;
description: string;
};

View File

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import type { CompanyQueryScope } from "../lib/query/company-query-key";
const CompanyQueryScopeContext = createContext<CompanyQueryScope | null>(null);
export function CompanyQueryScopeProvider({
scope,
children,
}: {
scope: CompanyQueryScope | null;
children: ReactNode;
}) {
return (
<CompanyQueryScopeContext.Provider value={scope}>{children}</CompanyQueryScopeContext.Provider>
);
}
export function useCompanyQueryScope() {
return useContext(CompanyQueryScopeContext);
}

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