mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
Merge pull request #1124 from Tria-plc/freight/nati-2
Audit log and presistent cbe pnr
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
PORT=3001
|
||||
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
||||
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
||||
# applicationName option (package quirk). audit.controller.ts reads the same
|
||||
# var when filtering reads, so this can be anything as long as it's set.
|
||||
APPLICATION_NAME=freight-api
|
||||
# Also required for @tria-plc/auditlog: its producer (AuditClientModule)
|
||||
# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's
|
||||
# rmqUrl option ever runs, so only an env var reaches it — an in-code
|
||||
# override is too late. Without this, audit events are silently dropped
|
||||
# (no error, nothing published). Point it at whatever broker/vhost your
|
||||
# RabbitMQ actually has a user provisioned on.
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
|
||||
GT06_TCP_PORT=5023
|
||||
DB_HOST=localhost
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||
"@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
import { MezgebModule } from "@tria-plc/auditlog";
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
@@ -105,9 +106,14 @@ import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
|
||||
if (!process.env.APPLICATION_NAME) {
|
||||
process.env.APPLICATION_NAME = "freight";
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -155,6 +161,19 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
|
||||
// Must come after TypeOrmModule above so it picks up this app's DataSource.
|
||||
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
|
||||
// does: the dev broker only provisions the `edr` user on the `payment`
|
||||
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
|
||||
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
|
||||
MezgebModule.forRoot({
|
||||
applicationName: "freight-api",
|
||||
rmqUrl:
|
||||
process.env.RABBITMQ_URL ??
|
||||
process.env.PAYMENT_RABBITMQ_URL ??
|
||||
"amqp://localhost:5672",
|
||||
}),
|
||||
SharedAuthModule,
|
||||
IamModule.forRoot({
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
@@ -227,6 +246,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
VerifaydaModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
|
||||
@@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
|
||||
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
||||
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
||||
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
||||
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
|
||||
|
||||
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
|
||||
// the glob below only matches this app's own src/**/*.entity.ts.
|
||||
const auditEntities = [AuditLog, AuditLogCommand];
|
||||
|
||||
const iamEntities = [
|
||||
UnitSetting,
|
||||
@@ -177,7 +182,11 @@ export function buildDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
...buildConnectionOptions(),
|
||||
schema: "public",
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
entities: [
|
||||
__dirname + "/../**/*.entity.{ts,js}",
|
||||
...iamEntities,
|
||||
...auditEntities,
|
||||
],
|
||||
migrations: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ResponseTransformInterceptor,
|
||||
createValidationPipe,
|
||||
} from "@edr/api-common";
|
||||
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
@@ -160,6 +161,13 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||
|
||||
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
|
||||
// (app.module.ts) emits and persists them via the AuditLogController /
|
||||
// AuditLogCommandController @EventPattern handlers. Same queue config the
|
||||
// client side uses, reused from the package so the two never drift apart.
|
||||
app.connectMicroservice(getAuditLoggerConfig());
|
||||
await app.startAllMicroservices();
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("EDR Freight API")
|
||||
.setDescription("API for the EDR Freight Management application")
|
||||
|
||||
32
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal file
32
apps/edr-freight-api/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
@ApiTags("audit")
|
||||
@Controller("audit")
|
||||
@BookingStaff(FREIGHT_PERMS.audit.view)
|
||||
export class AuditController {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get("logs")
|
||||
@ApiOperation({ summary: "List freight-api audit log commands" })
|
||||
@ApiQuery({ name: "skip", type: Number, required: false })
|
||||
@ApiQuery({ name: "take", type: Number, required: false })
|
||||
list(@Query("skip") skip?: string, @Query("take") take?: string) {
|
||||
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
|
||||
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
|
||||
// — reading it here instead of a hardcoded literal means this can't
|
||||
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
|
||||
// actually is at runtime.
|
||||
const application =
|
||||
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
|
||||
return this.auditService.list(
|
||||
application,
|
||||
skip !== undefined ? parseInt(skip, 10) : undefined,
|
||||
take !== undefined ? parseInt(take, 10) : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal file
13
apps/edr-freight-api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { AuditLogCommand } from "@tria-plc/auditlog";
|
||||
|
||||
import { AuditController } from "./audit.controller";
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuditLogCommand])],
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
70
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal file
70
apps/edr-freight-api/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { AuditLogCommand } from "@tria-plc/auditlog";
|
||||
|
||||
import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware";
|
||||
|
||||
export interface AuditLogListResult {
|
||||
count: number;
|
||||
items: AuditLogCommand[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Own read path onto @tria-plc/auditlog's tables, gated by AuditController's
|
||||
* @BookingStaff — the package's own AuditLogCommandController (mounted at
|
||||
* /api/audit-log-commands) ships with no guards at all, so it can't be used
|
||||
* directly for a permission-gated UI. Query mirrors the package's
|
||||
* AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
@InjectRepository(AuditLogCommand)
|
||||
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
application: string,
|
||||
skip = 0,
|
||||
take = 10,
|
||||
): Promise<AuditLogListResult> {
|
||||
const [items, count] = await this.auditLogCommandRepository
|
||||
.createQueryBuilder("audit_log_commands")
|
||||
.leftJoinAndSelect("audit_log_commands.auditLog", "auditLog")
|
||||
.andWhere(
|
||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)",
|
||||
{ application },
|
||||
)
|
||||
.andWhere(
|
||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)",
|
||||
{ status: "Commit" },
|
||||
)
|
||||
// Backoffice-only view: portal (customer-facing) writes carry the same
|
||||
// request-header set by every axios call from that app — see
|
||||
// login-audience.middleware.ts. Rows with no linked auditLog (child/
|
||||
// event commands with no request context) stay visible; they aren't
|
||||
// attributable to any frontend, so they're not portal noise either.
|
||||
.andWhere(
|
||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)",
|
||||
{ clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" },
|
||||
)
|
||||
.select([
|
||||
"audit_log_commands.id",
|
||||
"audit_log_commands.createdAt",
|
||||
"audit_log_commands.deletedAt",
|
||||
"audit_log_commands.entityName",
|
||||
"audit_log_commands.queryMethod",
|
||||
"audit_log_commands.changes",
|
||||
"audit_log_commands.payload",
|
||||
"auditLog.id",
|
||||
"auditLog.user",
|
||||
])
|
||||
.addOrderBy("audit_log_commands.createdAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.getManyAndCount();
|
||||
|
||||
return { count, items };
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
@@ -1192,6 +1193,17 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// CBE_BILL: the bill reference IS the booking's PNR — the number the customer pays against
|
||||
// at any CBE channel. Persist it on the booking so it survives the initiate response and
|
||||
// shows on the booking/contract everywhere. The payment service reissues the same reference
|
||||
// while the bill stays open, so re-initiating overwrites with an identical value.
|
||||
const billReference = result.response.clientAction?.billReference;
|
||||
if (billReference && invoice.source === Freight.InvoiceSource.Booking) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update({ id: invoice.sourceId }, { pnrCode: billReference });
|
||||
}
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept for local demos only.
|
||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||
|
||||
@@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||
});
|
||||
|
||||
it("refuses to rename a verified person by hand", async () => {
|
||||
const { service } = makeService({
|
||||
it("stages nothing for a verified field an approved company resubmits", async () => {
|
||||
// Approving it could not move the live row — the verified value is written
|
||||
// back over it — so it must never reach a reviewer as a pending change.
|
||||
const { service, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
companyEmail: "someone-else@example.com",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||
expect(deps.changeRequestRepo.update).not.toHaveBeenCalled();
|
||||
expect(deps.companiesRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The verified value wins, and it wins by overwriting rather than by
|
||||
// rejecting: nobody types these fields, so a submission that disagrees is a
|
||||
// stale form echoing itself back, not an edit. Failing it would block a save
|
||||
// the customer never made — and leave them no way through, since re-verifying
|
||||
// returns the same value they are being 400'd for.
|
||||
it("overwrites a hand-renamed verified person with the verified name", async () => {
|
||||
const { service, ctx } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
).resolves.toBeDefined();
|
||||
expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName);
|
||||
});
|
||||
|
||||
// Fayda's email and phone claims are optional — a verification can prove the
|
||||
// person and return neither. Holding the company mirrors to "the owner is
|
||||
// verified" rather than to "the verification supplied this value" would
|
||||
// clobber the fallbacks the portal is built to send (account email, eTrade's
|
||||
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
|
||||
// shape: a sub, no contact details.
|
||||
it("keeps company contact details a Fayda verification never supplied", async () => {
|
||||
const { service, deps } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
companyEmail: "account@example.com",
|
||||
companyPhone: "+251911777777",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||
expect(patch.email).toBe("account@example.com");
|
||||
expect(patch.phone).toBe("+251911777777");
|
||||
});
|
||||
|
||||
it("overwrites company contact details the verification did supply", async () => {
|
||||
const { service, deps } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
ownerEmail: "abebe@example.com",
|
||||
ownerPhone: "+251911000000",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
companyEmail: "someone-else@example.com",
|
||||
companyPhone: "+251911999999",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||
expect(patch.email).toBe("abebe@example.com");
|
||||
expect(patch.phone).toBe("+251911000000");
|
||||
});
|
||||
|
||||
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
|
||||
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
||||
// onboarding, hidden by the portal's link card and unwritable at once.
|
||||
it("lets the GM's details be typed when the copied owner identity carried none", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
gmSameAsOwner: true,
|
||||
gmFaydaSub: "owner-sub",
|
||||
generalManagerName: "Abebe Bikila",
|
||||
generalManagerEmail: null,
|
||||
generalManagerPhone: null,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
generalManagerEmail: "gm@example.com",
|
||||
generalManagerPhone: "+251911888888",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
||||
|
||||
@@ -739,6 +739,39 @@ export class CompaniesService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UpdateProfileDto` keys this company's completed verifications own — the
|
||||
* ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value
|
||||
* whatever a request submits for them.
|
||||
*
|
||||
* A key only lands here once there is a verified value to hold it to: Fayda's
|
||||
* email and phone claims are optional, and a verification that returned
|
||||
* neither owns nothing to overwrite with.
|
||||
*
|
||||
* The map is the enforcement; this is the list used to keep those keys out of
|
||||
* a change request in the first place. If the two ever drift the map still
|
||||
* wins — the cost is a staged field that approving turns out not to move.
|
||||
*/
|
||||
private faydaOwnedKeys(company: Company): string[] {
|
||||
const attrs = company.attributes ?? {};
|
||||
const held = (key: string) => {
|
||||
const v = attrs[key];
|
||||
return v !== null && v !== undefined && v !== "";
|
||||
};
|
||||
|
||||
const keys: string[] = [];
|
||||
if (attrs.ownerFaydaSub) {
|
||||
// The Company-column mirrors of the owner's verified contact details.
|
||||
if (held("ownerEmail")) keys.push("companyEmail");
|
||||
if (held("ownerPhone")) keys.push("companyPhone");
|
||||
}
|
||||
for (const subject of IDENTITY_SUBJECTS) {
|
||||
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
||||
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
||||
@@ -829,49 +862,46 @@ export class CompaniesService {
|
||||
// lets the customer type them once verified) — lock them the same way
|
||||
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
||||
// verified owner to lock them to.
|
||||
//
|
||||
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
|
||||
// phone claims are optional, so a verification can prove the person while
|
||||
// supplying neither (see completeIdentityVerification's conditional
|
||||
// spreads). The portal falls back to the account email / eTrade's
|
||||
// registered phone in exactly that case and submits it on every save of
|
||||
// the company step — locking against an absent value would 400 that
|
||||
// forever, and re-verifying could never clear it because Fayda still has
|
||||
// nothing to return.
|
||||
if (attrUpdates.ownerFaydaSub) {
|
||||
if (
|
||||
dto.companyEmail !== undefined &&
|
||||
dto.companyEmail !== attrUpdates.ownerEmail
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
dto.companyPhone !== undefined &&
|
||||
normalizeE164(dto.companyPhone) !==
|
||||
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
||||
);
|
||||
}
|
||||
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
|
||||
companyUpdates.email = attrUpdates.ownerEmail;
|
||||
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
|
||||
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
|
||||
}
|
||||
|
||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||
// away, so the fields the verification owns are refused once it exists.
|
||||
// away, so the verification keeps these fields: a submission that disagrees
|
||||
// is overwritten with the verified value rather than rejected — the same
|
||||
// doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the
|
||||
// same reason. The customer never types these (the portal derives them, and
|
||||
// a stale form or a re-render can echo back something else entirely), so a
|
||||
// 400 punishes a save they never made while an overwrite lands the truth.
|
||||
for (const subject of IDENTITY_SUBJECTS) {
|
||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||
const incoming = (dto as Record<string, unknown>)[field];
|
||||
if (incoming === undefined) continue;
|
||||
// The verification itself is allowed to write them; anything else is
|
||||
// compared against what is already stored, not against the value this
|
||||
// same call just copied into the patch. Phones are compared normalized:
|
||||
// a form that re-renders +251911000000 as 0911000000 is echoing the
|
||||
// stored value back, not trying to change it.
|
||||
if ((dto as Record<string, unknown>)[field] === undefined) continue;
|
||||
// The verification itself is what writes them; it must not be undone by
|
||||
// the value this same call just copied into the patch.
|
||||
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
||||
const stored = company.attributes?.[field];
|
||||
const same = field.endsWith("Phone")
|
||||
? normalizeE164(String(incoming)) ===
|
||||
normalizeE164(String(stored ?? ""))
|
||||
: incoming === stored;
|
||||
if (!same) {
|
||||
throw new BadRequestException(
|
||||
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
|
||||
);
|
||||
}
|
||||
// A verification that supplied nothing for this field left no guarantee
|
||||
// to protect, so it stays typeable. Matters most for the GM —
|
||||
// `setGmSameAsOwner` copies `ownerEmail ?? null` onto
|
||||
// `generalManagerEmail` while setting `gmFaydaSub`, and
|
||||
// REQUIRED_COMPANY_INFO still demands that email, so holding a null
|
||||
// here makes it required, hidden by the portal's "same as owner" card,
|
||||
// and unwritable all at once.
|
||||
if (stored === null || stored === undefined || stored === "") continue;
|
||||
attrUpdates[field] = stored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,6 +1007,11 @@ export class CompaniesService {
|
||||
// for review with the live row left intact.
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const fields = this.pickDefined(dto);
|
||||
// Drop what the verifications own before anything is staged. Approving one
|
||||
// of these could not change the live row — mapProfileDtoToCompanyUpdates
|
||||
// writes the verified value back over it — so showing it to a reviewer
|
||||
// asks them to rule on a change that does not exist.
|
||||
for (const key of this.faydaOwnedKeys(company)) delete fields[key];
|
||||
const selfService: Record<string, any> = {};
|
||||
const staged: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
|
||||
@@ -366,6 +366,7 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
|
||||
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
||||
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
|
||||
perm('b4c00001-0001-4000-8000-000000000001', 'edr_freight_app:audit:view', 'View audit logs'),
|
||||
];
|
||||
|
||||
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
|
||||
@@ -700,6 +701,9 @@ export const FREIGHT_PERMS = {
|
||||
manage: 'edr_freight_app:settings:dropdown:manage',
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
view: 'edr_freight_app:audit:view',
|
||||
},
|
||||
staff: {
|
||||
roles: {
|
||||
view: 'edr_freight_app:staff:roles:view',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Dev server port. Default: 5283.
|
||||
PORT=5283
|
||||
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_BASE_API_URL=http://localhost:3001
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5183 --clearScreen false",
|
||||
"dev": "vite --clearScreen false",
|
||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5183",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileSignature,
|
||||
FileText,
|
||||
Hammer,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
@@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||
import ReportPage from "./pages/reports/ReportPage";
|
||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
import AuditLogsPage from "./pages/audit/AuditLogsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
import {
|
||||
@@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ScrollText />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
{
|
||||
label: "Audit logs",
|
||||
href: "/dashboard/audit-logs",
|
||||
icon: <History />,
|
||||
permission: FREIGHT_PERMS.audit.view,
|
||||
},
|
||||
{
|
||||
label: "Configuration",
|
||||
href: "/dashboard/configuration",
|
||||
@@ -1595,6 +1603,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="audit-logs"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
|
||||
<AuditLogsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract-templates"
|
||||
element={
|
||||
|
||||
@@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage dropdown options used across the platform",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/audit-logs",
|
||||
meta: {
|
||||
title: "Audit Logs",
|
||||
subtitle: "Request and entity-level activity recorded across the freight API",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/configuration/contract-validity-periods",
|
||||
meta: {
|
||||
|
||||
@@ -309,6 +309,10 @@ export const URL_CONSTANTS = {
|
||||
SUMMARY: "/payments/summary",
|
||||
},
|
||||
|
||||
AUDIT: {
|
||||
LOGS: "/audit/logs",
|
||||
},
|
||||
|
||||
LOCOMOTIVES: {
|
||||
BASE: "/locomotives",
|
||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||
|
||||
@@ -276,6 +276,9 @@ export const FREIGHT_PERMS = {
|
||||
manage: "edr_freight_app:settings:dropdown:manage",
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
view: "edr_freight_app:audit:view",
|
||||
},
|
||||
staff: {
|
||||
roles: {
|
||||
view: "edr_freight_app:staff:roles:view",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
AuditLogRow,
|
||||
AuditQueryMethod,
|
||||
AuditUser,
|
||||
LocalizedText,
|
||||
} from "@/services/audit.service";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
|
||||
INSERT: "Created",
|
||||
UPDATE: "Updated",
|
||||
DELETE: "Deleted",
|
||||
INSERT_CHILD: "Linked child",
|
||||
DELETE_CHILD: "Unlinked child",
|
||||
};
|
||||
|
||||
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
|
||||
INSERT: "edr-green",
|
||||
UPDATE: "yellow",
|
||||
DELETE: "red",
|
||||
INSERT_CHILD: "indigo",
|
||||
DELETE_CHILD: "gray",
|
||||
};
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// See LocalizedText: `name`/`title` lifted from a raw audited entity can be
|
||||
// a plain string or IAM's { am, en } — never render either directly.
|
||||
// "undefined undefined" is the producer's own broken template when no user
|
||||
// was attached at all (unauthenticated/customer flows, e.g. Fayda
|
||||
// verification) — filtered out here rather than shown as raw garbage.
|
||||
function localize(value: LocalizedText | null | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === "object") return value.en ?? value.am ?? undefined;
|
||||
if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatUser(user: AuditUser | null | undefined): string {
|
||||
return localize(user?.name) ?? user?.id ?? "—";
|
||||
}
|
||||
|
||||
function summarize(row: AuditLogRow): string {
|
||||
if (row.changes?.length) {
|
||||
return row.changes
|
||||
.slice(0, 2)
|
||||
.map((c) => c.field)
|
||||
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
|
||||
}
|
||||
if (row.payload) {
|
||||
return (
|
||||
localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—"
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
const tableHeader =
|
||||
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
|
||||
const filter = {
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
take: pagination.pageSize,
|
||||
};
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.audit.list.queryOptions({ input: { filter } }),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.count ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<AuditLogRow>[] = [
|
||||
{
|
||||
id: "time",
|
||||
header: () => <span className={tableHeader}>Time</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateTime(row.original.createdAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: () => <span className={tableHeader}>Action</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "entity",
|
||||
header: () => <span className={tableHeader}>Entity</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm text-foreground">
|
||||
{row.original.entityName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
header: () => <span className={tableHeader}>User</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-foreground">
|
||||
{formatUser(row.original.auditLog?.user)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
header: () => <span className={tableHeader}>Summary</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{summarize(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Audit Logs"
|
||||
subtitle="Request and entity-level activity recorded across the freight API."
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -163,6 +163,11 @@ import {
|
||||
type SaveLocomotivePayload,
|
||||
} from "./locomotives.service";
|
||||
import { overviewService } from "./overview.service";
|
||||
import {
|
||||
auditService,
|
||||
type AuditLogListFilter,
|
||||
type PaginatedAuditLogs,
|
||||
} from "./audit.service";
|
||||
import { reportsService } from "./reports.service";
|
||||
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||
import {
|
||||
@@ -2136,6 +2141,15 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
audit: {
|
||||
list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>(
|
||||
"audit",
|
||||
"list",
|
||||
({ filter }) => auditService.list(filter),
|
||||
({ filter }) => ["audit", "list", filter ?? {}],
|
||||
),
|
||||
},
|
||||
|
||||
signatures: {
|
||||
mySignature: endpoint<void, SavedSignature | null>(
|
||||
"me",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
const A = URL_CONSTANTS.AUDIT;
|
||||
|
||||
// Shape from @tria-plc/auditlog's AuditLogCommandController — see
|
||||
// local-packages/FRONTEND_GUIDE.md.
|
||||
export type AuditQueryMethod =
|
||||
| "INSERT"
|
||||
| "UPDATE"
|
||||
| "DELETE"
|
||||
| "INSERT_CHILD"
|
||||
| "DELETE_CHILD";
|
||||
|
||||
export interface AuditFieldChange {
|
||||
field: string;
|
||||
from: unknown;
|
||||
to: unknown;
|
||||
}
|
||||
|
||||
// IAM entities (users, orgs, positions, ...) name themselves bilingually —
|
||||
// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited
|
||||
// entity (auditLog.user, payload) can come back as either a plain string or
|
||||
// this shape; both `name` fields below reflect that.
|
||||
export type LocalizedText = string | { am?: string; en?: string };
|
||||
|
||||
// The vendored interceptor's own broken template produces a plain string
|
||||
// ("undefined undefined") when no user was attached at all (unauthenticated/
|
||||
// customer flows) — that's the non-bilingual string case for `name` here.
|
||||
export interface AuditUser {
|
||||
id?: string;
|
||||
name?: LocalizedText;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuditLogRow {
|
||||
id?: string;
|
||||
createdAt: string;
|
||||
deletedAt?: string | null;
|
||||
entityName: string;
|
||||
queryMethod: AuditQueryMethod;
|
||||
changes?: AuditFieldChange[] | null;
|
||||
payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null;
|
||||
auditLog?: { id?: string; user?: AuditUser | null };
|
||||
}
|
||||
|
||||
export interface AuditLogListFilter {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedAuditLogs {
|
||||
items: AuditLogRow[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const auditService = {
|
||||
list: async (filter?: AuditLogListFilter): Promise<PaginatedAuditLogs> => {
|
||||
const params: Record<string, number | undefined> = {
|
||||
skip: filter?.skip,
|
||||
take: filter?.take,
|
||||
};
|
||||
const response = await client.get<PaginatedAuditLogs>(A.LOGS, { params });
|
||||
const data = unwrap(response.data) as PaginatedAuditLogs;
|
||||
return { items: data.items ?? [], count: data.count ?? 0 };
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { loadEnv } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
@@ -10,7 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||
|
||||
export default defineConfig(() => {
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, "");
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
@@ -31,7 +34,7 @@ export default defineConfig(() => {
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
port: Number(env.PORT) || 5283,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000 --clearScreen false",
|
||||
"dev": "vite --clearScreen false",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { loadEnv } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
@@ -13,26 +14,30 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
||||
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
"@mantine/core": mantineCore,
|
||||
"@mantine/hooks": mantineHooks,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, "");
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
"@mantine/core": mantineCore,
|
||||
"@mantine/hooks": mantineHooks,
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||
},
|
||||
server: {
|
||||
port: Number(env.PORT) || 5273,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -24,7 +24,11 @@ describe("IntentsService CBE_BILL", () => {
|
||||
let repository: jest.Mocked<
|
||||
Pick<
|
||||
IntentsRepository,
|
||||
"create" | "findById" | "findByIdempotencyKey" | "update"
|
||||
| "create"
|
||||
| "findById"
|
||||
| "findByIdempotencyKey"
|
||||
| "findAllByReference"
|
||||
| "update"
|
||||
>
|
||||
>;
|
||||
let billReferenceService: { generate: jest.Mock };
|
||||
@@ -46,6 +50,7 @@ describe("IntentsService CBE_BILL", () => {
|
||||
create: jest.fn(async (data) => ({ id: "intent-1", ...data })),
|
||||
findById: jest.fn(),
|
||||
findByIdempotencyKey: jest.fn().mockResolvedValue(null),
|
||||
findAllByReference: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn(),
|
||||
} as never;
|
||||
billReferenceService = {
|
||||
@@ -79,6 +84,47 @@ describe("IntentsService CBE_BILL", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the open bill instead of minting a second reference", async () => {
|
||||
repository.findAllByReference.mockResolvedValue([
|
||||
{
|
||||
id: "intent-1",
|
||||
provider: ProviderMethod.CBE_BILL,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
billReference: "000100000015",
|
||||
amountMinor: 1500,
|
||||
currency: "ETB",
|
||||
clientAction: {
|
||||
type: "SHOW_BILL_REFERENCE",
|
||||
billReference: "000100000015",
|
||||
},
|
||||
},
|
||||
] as never);
|
||||
|
||||
const snapshot = await service.initiate(request);
|
||||
|
||||
expect(snapshot.billReference).toBe("000100000015");
|
||||
expect(billReferenceService.generate).not.toHaveBeenCalled();
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a new bill when the amount changed", async () => {
|
||||
repository.findAllByReference.mockResolvedValue([
|
||||
{
|
||||
id: "intent-1",
|
||||
provider: ProviderMethod.CBE_BILL,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
billReference: "000100000015",
|
||||
amountMinor: 900,
|
||||
currency: "ETB",
|
||||
},
|
||||
] as never);
|
||||
billReferenceService.generate.mockResolvedValue("000100000023");
|
||||
|
||||
const snapshot = await service.initiate(request);
|
||||
|
||||
expect(snapshot.billReference).toBe("000100000023");
|
||||
});
|
||||
|
||||
it("rejects non-ETB currency (plan D8)", async () => {
|
||||
await expect(
|
||||
service.initiate({ ...request, currency: "DJF" }),
|
||||
|
||||
@@ -163,6 +163,32 @@ export class IntentsService {
|
||||
);
|
||||
}
|
||||
|
||||
// The bill reference is issued ONCE per order: the domain app persists it (freight stores it
|
||||
// as the booking's PNR) and the payer may already have written it down, so re-initiating the
|
||||
// same open bill must hand back the same number. A different amount/currency means a
|
||||
// different debt — /cbe/payment verifies the debited amount against the intent — so that
|
||||
// case mints a fresh bill instead of silently repricing an outstanding one.
|
||||
const open = (
|
||||
await this.intentsRepository.findAllByReference(
|
||||
request.service,
|
||||
request.referenceType,
|
||||
request.referenceId,
|
||||
)
|
||||
).find(
|
||||
(i) =>
|
||||
i.provider === ProviderMethod.CBE_BILL &&
|
||||
i.status === ProviderPaymentStatus.REQUIRES_ACTION &&
|
||||
!!i.billReference &&
|
||||
i.amountMinor === request.amountMinor &&
|
||||
i.currency === request.currency,
|
||||
);
|
||||
if (open) {
|
||||
this.logger.log(
|
||||
`intent ${open.id} reused for ${request.service}/${request.referenceType}/${request.referenceId} via CBE_BILL (bill ${open.billReference})`,
|
||||
);
|
||||
return this.toSnapshot(open);
|
||||
}
|
||||
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const billReference = await this.billReferenceService.generate();
|
||||
// expiresAt is the BOOKING's payment deadline passed by the domain app — never a provider
|
||||
|
||||
Reference in New Issue
Block a user