feat(audit): implement customer audit functionality and integrate with services
This commit is contained in:
parent
71b79cc4b1
commit
0bcccd5e80
314
apps/server/src/testing/http/customer-audit.integration.test.ts
Normal file
314
apps/server/src/testing/http/customer-audit.integration.test.ts
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { type IModuleServer, SequelizeTransactionManager } from "@erp/core/api";
|
||||||
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
import { Country, CurrencyCode, LanguageCode, Name, UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
|
import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize";
|
||||||
|
|
||||||
|
import { CustomerStatus } from "../../../../../modules/customers/src/api/domain";
|
||||||
|
import { CustomerModel } from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model";
|
||||||
|
import { requireIdentityTenantMembership } from "../../../../../modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCompanyHeaders,
|
||||||
|
createTestAccount,
|
||||||
|
createTestCompany,
|
||||||
|
createTestMembership,
|
||||||
|
loginByApi,
|
||||||
|
} from "./http-test-fixtures";
|
||||||
|
import { createHttpTestRuntime } from "./http-test-runtime";
|
||||||
|
|
||||||
|
const publicCustomerCreateProbe: IModuleServer = {
|
||||||
|
name: "customer-public-create-probe",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: ["customers", "identity"],
|
||||||
|
start: (params) => {
|
||||||
|
const customerServices = params.getService<ICustomerPublicServices>("customers:general");
|
||||||
|
|
||||||
|
params.app.post(
|
||||||
|
"/api/v1/__test/customers/public-create",
|
||||||
|
...requireIdentityTenantMembership(params),
|
||||||
|
async (request: Request, response: Response) => {
|
||||||
|
const companyIdResult = UniqueID.create(request.header("x-company-id") ?? "");
|
||||||
|
if (companyIdResult.isFailure) {
|
||||||
|
response.status(400).json({ success: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionManager = new SequelizeTransactionManager(
|
||||||
|
params.database,
|
||||||
|
params.transactionContext
|
||||||
|
);
|
||||||
|
const customerId = UniqueID.generateNewID();
|
||||||
|
try {
|
||||||
|
const result = await transactionManager.complete(async (transaction) => {
|
||||||
|
const customerResult = await customerServices.createCustomer(
|
||||||
|
customerId,
|
||||||
|
{
|
||||||
|
companyId: companyIdResult.data,
|
||||||
|
status: CustomerStatus.createActive(),
|
||||||
|
reference: Maybe.none(),
|
||||||
|
isCompany: true,
|
||||||
|
name: Name.create("FactuGES customer").data,
|
||||||
|
tradeName: Maybe.none(),
|
||||||
|
tin: Maybe.none(),
|
||||||
|
address: {
|
||||||
|
street: Maybe.none(),
|
||||||
|
street2: Maybe.none(),
|
||||||
|
city: Maybe.none(),
|
||||||
|
province: Maybe.none(),
|
||||||
|
postalCode: Maybe.none(),
|
||||||
|
country: Maybe.some(Country.create("ES").data),
|
||||||
|
},
|
||||||
|
emailPrimary: Maybe.none(),
|
||||||
|
emailSecondary: Maybe.none(),
|
||||||
|
phonePrimary: Maybe.none(),
|
||||||
|
phoneSecondary: Maybe.none(),
|
||||||
|
mobilePrimary: Maybe.none(),
|
||||||
|
mobileSecondary: Maybe.none(),
|
||||||
|
fax: Maybe.none(),
|
||||||
|
website: Maybe.none(),
|
||||||
|
legalRecord: Maybe.none(),
|
||||||
|
paymentMethodId: Maybe.none(),
|
||||||
|
paymentTermId: Maybe.none(),
|
||||||
|
taxRegimeCode: Maybe.none(),
|
||||||
|
usesEquivalenceSurcharge: false,
|
||||||
|
usesRetention: false,
|
||||||
|
languageCode: LanguageCode.create("es").data,
|
||||||
|
currencyCode: CurrencyCode.create("EUR").data,
|
||||||
|
},
|
||||||
|
{ companyId: companyIdResult.data, transaction }
|
||||||
|
);
|
||||||
|
if (customerResult.isFailure) {
|
||||||
|
return customerResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.query.rollback === "true") {
|
||||||
|
return Result.fail(new Error("forced public customer rollback"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok({ customerId: customerId.toPrimitive() });
|
||||||
|
});
|
||||||
|
|
||||||
|
response
|
||||||
|
.status(result.isSuccess ? 201 : 409)
|
||||||
|
.json(result.isSuccess ? result.data : { success: false });
|
||||||
|
} catch {
|
||||||
|
response.status(409).json({ success: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test("customers registra create, update y el create público en MariaDB", async (t) => {
|
||||||
|
const runtime = await createHttpTestRuntime({ testModules: [publicCustomerCreateProbe] });
|
||||||
|
t.after(async () => runtime.stop());
|
||||||
|
|
||||||
|
const account = await createTestAccount(runtime.database, {
|
||||||
|
email: `customer-audit-${randomUUID()}@example.test`,
|
||||||
|
password: "Password123!",
|
||||||
|
});
|
||||||
|
const company = await createTestCompany(runtime.database, {
|
||||||
|
legalName: "Customer audit company",
|
||||||
|
});
|
||||||
|
await createTestMembership(runtime.database, {
|
||||||
|
accountId: account.id,
|
||||||
|
companyId: company.id,
|
||||||
|
});
|
||||||
|
const loginResponse = await loginByApi({
|
||||||
|
request: runtime.request,
|
||||||
|
email: account.email,
|
||||||
|
password: account.password,
|
||||||
|
});
|
||||||
|
assert.equal(loginResponse.status, 200);
|
||||||
|
const accessToken = (loginResponse.body as { access_token: string }).access_token;
|
||||||
|
const customerId = randomUUID();
|
||||||
|
|
||||||
|
const createResponse = await runtime.request("/api/v1/customers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-correlation-id": "customer-create-correlation",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: customerId,
|
||||||
|
is_company: true,
|
||||||
|
name: "Acme",
|
||||||
|
tin: "B12345678",
|
||||||
|
address: { country: "ES", postal_code: "28001" },
|
||||||
|
contact: { email_primary: "contact@acme.test" },
|
||||||
|
uses_retention: false,
|
||||||
|
language_code: "es",
|
||||||
|
currency_code: "EUR",
|
||||||
|
payment_term_id: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(createResponse.status, 201);
|
||||||
|
|
||||||
|
const createdEvent = await AuditEventModel.findOne({
|
||||||
|
where: { action: "customer.created", target_id: customerId },
|
||||||
|
});
|
||||||
|
assert.ok(createdEvent);
|
||||||
|
assert.equal(createdEvent.source_type, "application");
|
||||||
|
assert.equal(createdEvent.source_id, "uecko-erp");
|
||||||
|
assert.equal(createdEvent.origin, "http");
|
||||||
|
assert.equal(createdEvent.operation_id, createResponse.headers.get("x-request-id"));
|
||||||
|
assert.equal(createdEvent.correlation_id, "customer-create-correlation");
|
||||||
|
assert.equal(createdEvent.actor_type, "user");
|
||||||
|
assert.equal(createdEvent.actor_id, account.id);
|
||||||
|
assert.equal(createdEvent.scope_type, "company");
|
||||||
|
assert.equal(createdEvent.scope_id, company.id);
|
||||||
|
assert.equal(createdEvent.target_type, "customer");
|
||||||
|
assert.equal(createdEvent.target_label, "Acme");
|
||||||
|
|
||||||
|
const updateResponse = await runtime.request(`/api/v1/customers/${customerId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-correlation-id": "customer-update-correlation",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Acme updated",
|
||||||
|
uses_retention: true,
|
||||||
|
address: { postal_code: "08001" },
|
||||||
|
contact: { email_primary: "billing@acme.test" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(updateResponse.status, 200);
|
||||||
|
|
||||||
|
const updatedEvent = await AuditEventModel.findOne({
|
||||||
|
where: { action: "customer.updated", target_id: customerId },
|
||||||
|
});
|
||||||
|
assert.ok(updatedEvent);
|
||||||
|
assert.equal(updatedEvent.target_label, "Acme updated");
|
||||||
|
assert.equal(updatedEvent.operation_id, updateResponse.headers.get("x-request-id"));
|
||||||
|
assert.equal(updatedEvent.correlation_id, "customer-update-correlation");
|
||||||
|
const updatedChanges = await AuditChangeModel.findAll({
|
||||||
|
where: { audit_event_id: updatedEvent.id },
|
||||||
|
order: [["position", "ASC"]],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
updatedChanges.map((change) => ({
|
||||||
|
path: change.path,
|
||||||
|
kind: change.kind,
|
||||||
|
before: change.before_value,
|
||||||
|
after: change.after_value,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ path: "address.postalCode", kind: "change", before: "28001", after: "08001" },
|
||||||
|
{
|
||||||
|
path: "emailPrimary",
|
||||||
|
kind: "change",
|
||||||
|
before: "contact@acme.test",
|
||||||
|
after: "billing@acme.test",
|
||||||
|
},
|
||||||
|
{ path: "name", kind: "change", before: "Acme", after: "Acme updated" },
|
||||||
|
{ path: "usesRetention", kind: "change", before: false, after: true },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const auditEventsBeforeNoop = await AuditEventModel.count({
|
||||||
|
where: { action: "customer.updated", target_id: customerId },
|
||||||
|
});
|
||||||
|
const noopResponse = await runtime.request(`/api/v1/customers/${customerId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
assert.equal(noopResponse.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
await AuditEventModel.count({ where: { action: "customer.updated", target_id: customerId } }),
|
||||||
|
auditEventsBeforeNoop
|
||||||
|
);
|
||||||
|
|
||||||
|
const auditEventsBeforeIdenticalUpdate = await AuditEventModel.count({
|
||||||
|
where: { action: "customer.updated", target_id: customerId },
|
||||||
|
});
|
||||||
|
const identicalUpdateResponse = await runtime.request(`/api/v1/customers/${customerId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Acme updated",
|
||||||
|
uses_retention: true,
|
||||||
|
language_code: "es",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(identicalUpdateResponse.status, 200);
|
||||||
|
const unchangedCustomer = await CustomerModel.findByPk(customerId);
|
||||||
|
assert.ok(unchangedCustomer);
|
||||||
|
assert.equal(unchangedCustomer.name, "Acme updated");
|
||||||
|
assert.equal(unchangedCustomer.uses_retention, true);
|
||||||
|
assert.equal(unchangedCustomer.language_code, "es");
|
||||||
|
assert.equal(
|
||||||
|
await AuditEventModel.count({ where: { action: "customer.updated", target_id: customerId } }),
|
||||||
|
auditEventsBeforeIdenticalUpdate
|
||||||
|
);
|
||||||
|
|
||||||
|
const auditEventsBeforeNotFound = await AuditEventModel.count();
|
||||||
|
const notFoundResponse = await runtime.request(`/api/v1/customers/${randomUUID()}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: "Missing" }),
|
||||||
|
});
|
||||||
|
assert.equal(notFoundResponse.status, 404);
|
||||||
|
assert.equal(await AuditEventModel.count(), auditEventsBeforeNotFound);
|
||||||
|
|
||||||
|
const publicCreateResponse = await runtime.request("/api/v1/__test/customers/public-create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"x-correlation-id": "customer-public-create-correlation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(publicCreateResponse.status, 201);
|
||||||
|
const publicCustomerId = (publicCreateResponse.body as { customerId: string }).customerId;
|
||||||
|
assert.ok(await CustomerModel.findByPk(publicCustomerId));
|
||||||
|
const publicEvent = await AuditEventModel.findOne({
|
||||||
|
where: { action: "customer.created", target_id: publicCustomerId },
|
||||||
|
});
|
||||||
|
assert.ok(publicEvent);
|
||||||
|
assert.equal(publicEvent.correlation_id, "customer-public-create-correlation");
|
||||||
|
assert.equal(publicEvent.scope_id, company.id);
|
||||||
|
assert.equal(publicEvent.actor_id, account.id);
|
||||||
|
|
||||||
|
const auditChangesBeforeRollback = await AuditChangeModel.count();
|
||||||
|
const rollbackResponse = await runtime.request(
|
||||||
|
"/api/v1/__test/customers/public-create?rollback=true",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"x-correlation-id": "customer-public-rollback-correlation",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(rollbackResponse.status, 409);
|
||||||
|
assert.equal(
|
||||||
|
await CustomerModel.count({ where: { company_id: company.id, name: "FactuGES customer" } }),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await AuditEventModel.count({
|
||||||
|
where: { correlation_id: "customer-public-rollback-correlation" },
|
||||||
|
}),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(await AuditChangeModel.count(), auditChangesBeforeRollback);
|
||||||
|
});
|
||||||
@ -41,6 +41,7 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@repo/i18next": "workspace:*",
|
"@repo/i18next": "workspace:*",
|
||||||
"@repo/rdx-criteria": "workspace:*",
|
"@repo/rdx-criteria": "workspace:*",
|
||||||
|
"@repo/rdx-audit": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-ui": "workspace:*",
|
"@repo/rdx-ui": "workspace:*",
|
||||||
|
|||||||
@ -0,0 +1,110 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
City,
|
||||||
|
Country,
|
||||||
|
CurrencyCode,
|
||||||
|
EmailAddress,
|
||||||
|
LanguageCode,
|
||||||
|
Name,
|
||||||
|
PhoneNumber,
|
||||||
|
PostalCode,
|
||||||
|
Province,
|
||||||
|
Street,
|
||||||
|
TINNumber,
|
||||||
|
URLAddress,
|
||||||
|
UniqueID,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { Customer, CustomerStatus } from "../../domain";
|
||||||
|
|
||||||
|
import { toCustomerAuditState, toCustomerAuditTarget } from "./customer-audit";
|
||||||
|
|
||||||
|
const unwrap = <T>(result: { data: T; isSuccess: boolean }): T => {
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCustomer = () =>
|
||||||
|
unwrap(
|
||||||
|
Customer.create(
|
||||||
|
{
|
||||||
|
companyId: UniqueID.generateNewID(),
|
||||||
|
status: CustomerStatus.createActive(),
|
||||||
|
reference: Maybe.some(unwrap(Name.create("C-001"))),
|
||||||
|
isCompany: true,
|
||||||
|
name: unwrap(Name.create("Acme")),
|
||||||
|
tradeName: Maybe.some(unwrap(Name.create("Acme Trading"))),
|
||||||
|
tin: Maybe.some(unwrap(TINNumber.create("B12345678"))),
|
||||||
|
address: {
|
||||||
|
street: Maybe.some(unwrap(Street.create("Main street 1"))),
|
||||||
|
street2: Maybe.none(),
|
||||||
|
city: Maybe.some(unwrap(City.create("Madrid"))),
|
||||||
|
province: Maybe.some(unwrap(Province.create("Madrid"))),
|
||||||
|
postalCode: Maybe.some(unwrap(PostalCode.create("28001"))),
|
||||||
|
country: Maybe.some(unwrap(Country.create("ES"))),
|
||||||
|
},
|
||||||
|
emailPrimary: Maybe.some(unwrap(EmailAddress.create("primary@example.test"))),
|
||||||
|
emailSecondary: Maybe.none(),
|
||||||
|
phonePrimary: Maybe.some(unwrap(PhoneNumber.create("+34910000000"))),
|
||||||
|
phoneSecondary: Maybe.none(),
|
||||||
|
mobilePrimary: Maybe.some(unwrap(PhoneNumber.create("+34600000000"))),
|
||||||
|
mobileSecondary: Maybe.none(),
|
||||||
|
fax: Maybe.some(unwrap(PhoneNumber.create("+34910000001"))),
|
||||||
|
website: Maybe.some(unwrap(URLAddress.create("https://example.test"))),
|
||||||
|
legalRecord: Maybe.none(),
|
||||||
|
paymentMethodId: Maybe.some(UniqueID.generateNewID()),
|
||||||
|
paymentTermId: Maybe.some(UniqueID.generateNewID()),
|
||||||
|
taxRegimeCode: Maybe.some("RE"),
|
||||||
|
usesEquivalenceSurcharge: true,
|
||||||
|
usesRetention: true,
|
||||||
|
languageCode: unwrap(LanguageCode.create("es")),
|
||||||
|
currencyCode: unwrap(CurrencyCode.create("EUR")),
|
||||||
|
},
|
||||||
|
UniqueID.generateNewID()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
test("proyecta Customer a un estado histórico portable completo sin legalRecord", () => {
|
||||||
|
const customer = buildCustomer();
|
||||||
|
|
||||||
|
assert.deepEqual(toCustomerAuditState(customer), {
|
||||||
|
status: "active",
|
||||||
|
reference: "C-001",
|
||||||
|
isCompany: true,
|
||||||
|
name: "Acme",
|
||||||
|
tradeName: "Acme Trading",
|
||||||
|
tin: "B12345678",
|
||||||
|
address: {
|
||||||
|
street: "Main street 1",
|
||||||
|
street2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
province: "Madrid",
|
||||||
|
postalCode: "28001",
|
||||||
|
country: "ES",
|
||||||
|
},
|
||||||
|
emailPrimary: "primary@example.test",
|
||||||
|
emailSecondary: null,
|
||||||
|
phonePrimary: "+34910000000",
|
||||||
|
phoneSecondary: null,
|
||||||
|
mobilePrimary: "+34600000000",
|
||||||
|
mobileSecondary: null,
|
||||||
|
fax: "+34910000001",
|
||||||
|
website: "https://example.test",
|
||||||
|
paymentMethodId: customer.paymentMethodId.unwrap().toPrimitive(),
|
||||||
|
paymentTermId: customer.paymentTermId.unwrap().toPrimitive(),
|
||||||
|
taxRegimeCode: "RE",
|
||||||
|
usesEquivalenceSurcharge: true,
|
||||||
|
usesRetention: true,
|
||||||
|
languageCode: "es",
|
||||||
|
currencyCode: "EUR",
|
||||||
|
});
|
||||||
|
assert.equal("legalRecord" in toCustomerAuditState(customer), false);
|
||||||
|
assert.deepEqual(toCustomerAuditTarget(customer), {
|
||||||
|
type: "customer",
|
||||||
|
id: customer.id.toPrimitive(),
|
||||||
|
label: "Acme",
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit";
|
||||||
|
|
||||||
|
import type { Customer } from "../../domain";
|
||||||
|
|
||||||
|
export const CUSTOMER_AUDIT_ACTIONS = {
|
||||||
|
CREATED: "customer.created",
|
||||||
|
UPDATED: "customer.updated",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type CustomerAuditState = Readonly<{
|
||||||
|
status: "active" | "inactive";
|
||||||
|
reference: string | null;
|
||||||
|
isCompany: boolean;
|
||||||
|
name: string;
|
||||||
|
tradeName: string | null;
|
||||||
|
tin: string | null;
|
||||||
|
address: Readonly<{
|
||||||
|
street: string | null;
|
||||||
|
street2: string | null;
|
||||||
|
city: string | null;
|
||||||
|
province: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
country: string | null;
|
||||||
|
}>;
|
||||||
|
emailPrimary: string | null;
|
||||||
|
emailSecondary: string | null;
|
||||||
|
phonePrimary: string | null;
|
||||||
|
phoneSecondary: string | null;
|
||||||
|
mobilePrimary: string | null;
|
||||||
|
mobileSecondary: string | null;
|
||||||
|
fax: string | null;
|
||||||
|
website: string | null;
|
||||||
|
paymentMethodId: string | null;
|
||||||
|
paymentTermId: string | null;
|
||||||
|
taxRegimeCode: string | null;
|
||||||
|
usesEquivalenceSurcharge: boolean;
|
||||||
|
usesRetention: boolean;
|
||||||
|
languageCode: string;
|
||||||
|
currencyCode: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const toNullablePrimitive = <T extends { toPrimitive(): string }>(value: {
|
||||||
|
match: <R>(some: (item: T) => R, none: () => R) => R;
|
||||||
|
}): string | null =>
|
||||||
|
value.match(
|
||||||
|
(item) => item.toPrimitive(),
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Proyección histórica explícita; `legalRecord` se excluye deliberadamente en v1. */
|
||||||
|
export const toCustomerAuditState = (customer: Customer): CustomerAuditState => {
|
||||||
|
const status: CustomerAuditState["status"] = customer.isActive ? "active" : "inactive";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
reference: toNullablePrimitive(customer.reference),
|
||||||
|
isCompany: customer.isCompany,
|
||||||
|
name: customer.name.toPrimitive(),
|
||||||
|
tradeName: toNullablePrimitive(customer.tradeName),
|
||||||
|
tin: toNullablePrimitive(customer.tin),
|
||||||
|
address: {
|
||||||
|
street: toNullablePrimitive(customer.address.getProps().street),
|
||||||
|
street2: toNullablePrimitive(customer.address.getProps().street2),
|
||||||
|
city: toNullablePrimitive(customer.address.getProps().city),
|
||||||
|
province: toNullablePrimitive(customer.address.getProps().province),
|
||||||
|
postalCode: toNullablePrimitive(customer.address.getProps().postalCode),
|
||||||
|
country: toNullablePrimitive(customer.address.getProps().country),
|
||||||
|
},
|
||||||
|
emailPrimary: toNullablePrimitive(customer.emailPrimary),
|
||||||
|
emailSecondary: toNullablePrimitive(customer.emailSecondary),
|
||||||
|
phonePrimary: toNullablePrimitive(customer.phonePrimary),
|
||||||
|
phoneSecondary: toNullablePrimitive(customer.phoneSecondary),
|
||||||
|
mobilePrimary: toNullablePrimitive(customer.mobilePrimary),
|
||||||
|
mobileSecondary: toNullablePrimitive(customer.mobileSecondary),
|
||||||
|
fax: toNullablePrimitive(customer.fax),
|
||||||
|
website: toNullablePrimitive(customer.website),
|
||||||
|
paymentMethodId: toNullablePrimitive(customer.paymentMethodId),
|
||||||
|
paymentTermId: toNullablePrimitive(customer.paymentTermId),
|
||||||
|
taxRegimeCode: customer.taxRegimeCode.match(
|
||||||
|
(code) => code,
|
||||||
|
() => null
|
||||||
|
),
|
||||||
|
usesEquivalenceSurcharge: customer.usesEquivalenceSurcharge,
|
||||||
|
usesRetention: customer.usesRetention,
|
||||||
|
languageCode: customer.languageCode.toPrimitive(),
|
||||||
|
currencyCode: customer.currencyCode.toPrimitive(),
|
||||||
|
} satisfies AuditState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toCustomerAuditTarget = (customer: Customer): AuditMutationTarget => ({
|
||||||
|
type: "customer",
|
||||||
|
id: customer.id.toPrimitive(),
|
||||||
|
label: customer.name.toPrimitive(),
|
||||||
|
});
|
||||||
@ -1,12 +1,16 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { ICustomerRepository } from "../repositories";
|
import type { ICustomerRepository } from "../repositories";
|
||||||
import { CustomerCreator, type ICustomerCreator } from "../services";
|
import { CustomerCreator, type ICustomerCreator } from "../services";
|
||||||
|
|
||||||
export const buildCustomerCreator = (params: {
|
export const buildCustomerCreator = (params: {
|
||||||
repository: ICustomerRepository;
|
repository: ICustomerRepository;
|
||||||
|
audit: IAudit;
|
||||||
}): ICustomerCreator => {
|
}): ICustomerCreator => {
|
||||||
const { repository } = params;
|
const { repository, audit } = params;
|
||||||
|
|
||||||
return new CustomerCreator({
|
return new CustomerCreator({
|
||||||
repository,
|
repository,
|
||||||
|
audit,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { ICustomerRepository } from "../repositories";
|
import type { ICustomerRepository } from "../repositories";
|
||||||
import { CustomerUpdater, type ICustomerUpdater } from "../services";
|
import { CustomerUpdater, type ICustomerUpdater } from "../services";
|
||||||
|
|
||||||
export const buildCustomerUpdater = (params: {
|
export const buildCustomerUpdater = (params: {
|
||||||
repository: ICustomerRepository;
|
repository: ICustomerRepository;
|
||||||
|
audit: IAudit;
|
||||||
}): ICustomerUpdater => {
|
}): ICustomerUpdater => {
|
||||||
const { repository } = params;
|
const { repository, audit } = params;
|
||||||
|
|
||||||
return new CustomerUpdater({
|
return new CustomerUpdater({
|
||||||
repository,
|
repository,
|
||||||
|
audit,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import { DuplicateEntityError } from "@erp/core/api";
|
import { DuplicateEntityError } from "@erp/core/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { Customer, type ICustomerCreateProps } from "../../domain";
|
import { Customer, type ICustomerCreateProps } from "../../domain";
|
||||||
|
import {
|
||||||
|
CUSTOMER_AUDIT_ACTIONS,
|
||||||
|
toCustomerAuditState,
|
||||||
|
toCustomerAuditTarget,
|
||||||
|
} from "../audit/customer-audit";
|
||||||
import type { ICustomerRepository } from "../repositories";
|
import type { ICustomerRepository } from "../repositories";
|
||||||
import { CustomerNotExistsInCompanySpecification } from "../specs";
|
import { CustomerNotExistsInCompanySpecification } from "../specs";
|
||||||
|
|
||||||
@ -17,13 +23,16 @@ export interface ICustomerCreator {
|
|||||||
|
|
||||||
type CustomerCreatorDeps = {
|
type CustomerCreatorDeps = {
|
||||||
repository: ICustomerRepository;
|
repository: ICustomerRepository;
|
||||||
|
audit: IAudit;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CustomerCreator implements ICustomerCreator {
|
export class CustomerCreator implements ICustomerCreator {
|
||||||
private readonly repository: ICustomerRepository;
|
private readonly repository: ICustomerRepository;
|
||||||
|
private readonly audit: IAudit;
|
||||||
|
|
||||||
constructor(deps: CustomerCreatorDeps) {
|
constructor(deps: CustomerCreatorDeps) {
|
||||||
this.repository = deps.repository;
|
this.repository = deps.repository;
|
||||||
|
this.audit = deps.audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(params: {
|
async create(params: {
|
||||||
@ -69,6 +78,16 @@ export class CustomerCreator implements ICustomerCreator {
|
|||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.audit.recordMutation({
|
||||||
|
action: CUSTOMER_AUDIT_ACTIONS.CREATED,
|
||||||
|
target: toCustomerAuditTarget(newCustomer),
|
||||||
|
before: {},
|
||||||
|
after: toCustomerAuditState(newCustomer),
|
||||||
|
});
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(newCustomer);
|
return Result.ok(newCustomer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { Customer, CustomerPatchProps } from "../../domain";
|
import type { Customer, CustomerPatchProps } from "../../domain";
|
||||||
|
import {
|
||||||
|
CUSTOMER_AUDIT_ACTIONS,
|
||||||
|
toCustomerAuditState,
|
||||||
|
toCustomerAuditTarget,
|
||||||
|
} from "../audit/customer-audit";
|
||||||
import type { ICustomerRepository } from "../repositories";
|
import type { ICustomerRepository } from "../repositories";
|
||||||
|
|
||||||
export interface ICustomerUpdater {
|
export interface ICustomerUpdater {
|
||||||
@ -15,13 +21,16 @@ export interface ICustomerUpdater {
|
|||||||
|
|
||||||
type CustomerUpdaterDeps = {
|
type CustomerUpdaterDeps = {
|
||||||
repository: ICustomerRepository;
|
repository: ICustomerRepository;
|
||||||
|
audit: IAudit;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CustomerUpdater implements ICustomerUpdater {
|
export class CustomerUpdater implements ICustomerUpdater {
|
||||||
private readonly repository: ICustomerRepository;
|
private readonly repository: ICustomerRepository;
|
||||||
|
private readonly audit: IAudit;
|
||||||
|
|
||||||
constructor(deps: CustomerUpdaterDeps) {
|
constructor(deps: CustomerUpdaterDeps) {
|
||||||
this.repository = deps.repository;
|
this.repository = deps.repository;
|
||||||
|
this.audit = deps.audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(params: {
|
async update(params: {
|
||||||
@ -40,6 +49,7 @@ export class CustomerUpdater implements ICustomerUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customer = existingResult.data;
|
const customer = existingResult.data;
|
||||||
|
const before = toCustomerAuditState(customer);
|
||||||
|
|
||||||
// Aplicar cambios en el agregado
|
// Aplicar cambios en el agregado
|
||||||
const updateResult = customer.update(props);
|
const updateResult = customer.update(props);
|
||||||
@ -48,6 +58,20 @@ export class CustomerUpdater implements ICustomerUpdater {
|
|||||||
return Result.fail(updateResult.error);
|
return Result.fail(updateResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(props).length === 0) {
|
||||||
|
const auditResult = await this.audit.recordMutation({
|
||||||
|
action: CUSTOMER_AUDIT_ACTIONS.UPDATED,
|
||||||
|
target: toCustomerAuditTarget(customer),
|
||||||
|
before,
|
||||||
|
after: toCustomerAuditState(customer),
|
||||||
|
});
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(customer);
|
||||||
|
}
|
||||||
|
|
||||||
// Persistir cambios
|
// Persistir cambios
|
||||||
const saveResult = await this.repository.update(customer, transaction);
|
const saveResult = await this.repository.update(customer, transaction);
|
||||||
|
|
||||||
@ -55,6 +79,16 @@ export class CustomerUpdater implements ICustomerUpdater {
|
|||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.audit.recordMutation({
|
||||||
|
action: CUSTOMER_AUDIT_ACTIONS.UPDATED,
|
||||||
|
target: toCustomerAuditTarget(customer),
|
||||||
|
before,
|
||||||
|
after: toCustomerAuditState(customer),
|
||||||
|
});
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(customer);
|
return Result.ok(customer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { SetupParams } from "@erp/core/api";
|
import type { ModuleParams } from "@erp/core/api";
|
||||||
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
|
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -16,18 +16,18 @@ import { buildCustomerRepository } from "./customer-repositories.di";
|
|||||||
import type { CustomersInternalDeps } from "./customers.di";
|
import type { CustomersInternalDeps } from "./customers.di";
|
||||||
|
|
||||||
export function buildCustomerPublicServices(
|
export function buildCustomerPublicServices(
|
||||||
params: SetupParams,
|
params: ModuleParams,
|
||||||
deps: CustomersInternalDeps
|
_deps: CustomersInternalDeps
|
||||||
): ICustomerPublicServices {
|
): ICustomerPublicServices {
|
||||||
const { database } = params;
|
const { database } = params;
|
||||||
const catalogs = resolveCustomerCatalogsDeps(params);
|
const _catalogs = resolveCustomerCatalogsDeps(params);
|
||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
const persistenceMappers = buildCustomerPersistenceMappers();
|
const persistenceMappers = buildCustomerPersistenceMappers();
|
||||||
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
|
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
|
||||||
|
|
||||||
const finder = buildCustomerFinder({ repository });
|
const finder = buildCustomerFinder({ repository });
|
||||||
const creator = buildCustomerCreator({ repository });
|
const creator = buildCustomerCreator({ repository, audit: params.audit });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
|
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
|
||||||
|
|||||||
@ -69,8 +69,8 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
|
|||||||
});
|
});
|
||||||
|
|
||||||
const finder = buildCustomerFinder({ repository });
|
const finder = buildCustomerFinder({ repository });
|
||||||
const creator = buildCustomerCreator({ repository });
|
const creator = buildCustomerCreator({ repository, audit: params.audit });
|
||||||
const updater = buildCustomerUpdater({ repository });
|
const updater = buildCustomerUpdater({ repository, audit: params.audit });
|
||||||
const defaultPaymentTermResolver = new CustomerDefaultPaymentTermResolver({
|
const defaultPaymentTermResolver = new CustomerDefaultPaymentTermResolver({
|
||||||
companyFinder: companies.company.finder,
|
companyFinder: companies.company.finder,
|
||||||
paymentResolver: catalogResolvers.paymentResolver,
|
paymentResolver: catalogResolvers.paymentResolver,
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||||
EntityNotFoundError,
|
|
||||||
InfrastructureRepositoryError,
|
|
||||||
SequelizeRepository,
|
|
||||||
translateSequelizeError,
|
|
||||||
} from "@erp/core/api";
|
|
||||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
||||||
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
|
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
|
||||||
import { type Collection, Result } from "@repo/rdx-utils";
|
import { type Collection, Result } from "@repo/rdx-utils";
|
||||||
@ -68,18 +63,14 @@ export class CustomerRepository
|
|||||||
|
|
||||||
const { id, ...updatePayload } = dtoResult.data;
|
const { id, ...updatePayload } = dtoResult.data;
|
||||||
|
|
||||||
const [affected] = await CustomerModel.update(updatePayload, {
|
await CustomerModel.update(updatePayload, {
|
||||||
where: { id /*, version */ },
|
where: { id /*, version */ },
|
||||||
transaction,
|
transaction,
|
||||||
//individualHooks: true,
|
//individualHooks: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (affected === 0) {
|
// CustomerUpdater ya comprobó existencia y scope antes de persistir.
|
||||||
return Result.fail(
|
// MariaDB devuelve 0 cuando el estado recibido es idéntico al almacenado.
|
||||||
new InfrastructureRepositoryError("Concurrency conflict or not found update customer")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
return Result.fail(translateSequelizeError(err));
|
return Result.fail(translateSequelizeError(err));
|
||||||
|
|||||||
@ -831,6 +831,9 @@ importers:
|
|||||||
'@repo/i18next':
|
'@repo/i18next':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
|
'@repo/rdx-audit':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/rdx-audit
|
||||||
'@repo/rdx-criteria':
|
'@repo/rdx-criteria':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-criteria
|
version: link:../../packages/rdx-criteria
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user