Merge pull request #1125 from Tria-plc/dev

deploy freight
This commit is contained in:
marshal
2026-08-05 17:26:24 +03:00
committed by GitHub
56 changed files with 1557 additions and 329 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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: [],
};
}

View File

@@ -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")

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Adds PAYMENT_PROCESSING to the invoice status enum: the customer completed
* provider checkout (success redirect) and settlement is awaiting the
* provider webhook.
*/
export class InvoicePaymentProcessingStatus3260000000000
implements MigrationInterface
{
name = "InvoicePaymentProcessingStatus3260000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PAYMENT_PROCESSING' AFTER 'PENDING'`,
);
}
public async down(): Promise<void> {
// Postgres cannot drop an enum value; PAYMENT_PROCESSING stays. Harmless.
}
}

View 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,
);
}
}

View 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 {}

View 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 };
}
}

View File

@@ -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";
@@ -52,6 +53,8 @@ const DEFAULT_DUE_DAYS = 14;
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
// Success-redirect ack; still unsettled, so it must stay payable/settleable.
Freight.InvoiceStatus.PaymentProcessing,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
@@ -924,6 +927,26 @@ export class BillingService {
});
if (!invoice) return null;
// Reconcile-before-expire, caller-proof: an invoice with a payment intent may
// have settled at the gateway without the webhook landing yet. `paid` — leave
// it open, the (re-emitted) payment.succeeded settles it. `unverifiable` —
// never expire on unknown; the caller's next sweep retries. Invoices with no
// intent (`paymentId` null) were never payable at a gateway and expire directly.
if (invoice.paymentId) {
const { paid, unverifiable } = await this.reconcilePayable(
invoice.sourceId,
);
if (paid || unverifiable) {
this.logger.warn(
`expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` +
(paid
? "gateway reconcile found a settled payment"
: "settlement unverifiable at the gateway"),
);
return null;
}
}
return this.transition(
invoice.id,
Freight.InvoiceStatus.Expired,
@@ -1028,6 +1051,40 @@ export class BillingService {
);
}
/**
* Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move
* the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from
* ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and
* is idempotent. Balance untouched: this is a display state, not a
* settlement; settleByPaymentId still performs the real transition.
*/
async markInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource.getRepository(Invoice).update(
{
paymentId,
status: In([
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
]),
},
{ status: Freight.InvoiceStatus.PaymentProcessing },
);
}
/**
* Counterpart of {@link markInvoicePaymentProcessing} for a failed intent:
* PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a
* retry. No-op from any other status.
*/
async revertInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource
.getRepository(Invoice)
.update(
{ paymentId, status: Freight.InvoiceStatus.PaymentProcessing },
{ status: Freight.InvoiceStatus.Pending },
);
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**
@@ -1136,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

View File

@@ -87,6 +87,33 @@ describe('BookingPricingService — domestic corridor', () => {
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
});
it('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => {
exchangeService.getRate.mockResolvedValue(162.2132);
const booking = {
id: 'b-1-frac',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 120,
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
// 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr
// rounding here billed with the integer part of the rate, in effect).
expect(result.lineItems[0].amount).toBe(681295.44);
});
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
const booking = {
id: 'b-1-usd',

View File

@@ -6,6 +6,7 @@ import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { round2 } from '../billing/invoice-settlement.util';
import { ExchangeService } from '@edr/api-common';
import {
AppliedCargoModifier,
@@ -205,14 +206,14 @@ export class BookingPricingService {
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
? Math.round(unitUsd * usdToEtb)
? round2(unitUsd * usdToEtb)
: unitUsd;
const convertedAmount = frozen
? isEtbBooking
? Math.round(unitAmount * quantity)
? round2(unitAmount * quantity)
: unitAmount * quantity
: isEtbBooking
? Math.round(usdAmount * usdToEtb)
? round2(usdAmount * usdToEtb)
: usdAmount;
const item: PriceLineItemDto = {
@@ -583,8 +584,8 @@ export class BookingPricingService {
} else {
const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd;
}
if (rate) usedRatesMap.set(rate.id, rate);
lines.push({
@@ -652,8 +653,8 @@ export class BookingPricingService {
);
} else {
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? round2(unitUsd * usdToEtb) : unitUsd;
}
lines.push({
code: rateType,
@@ -763,12 +764,12 @@ export class BookingPricingService {
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = isEtbBooking
? Math.round(unitAmount * quantity)
? round2(unitAmount * quantity)
: unitAmount * quantity;
} else {
const usdAmount = value * quantity;
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
amount = isEtbBooking ? round2(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? round2(value * usdToEtb) : value;
}
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
if (!(amount > 0)) continue;
@@ -971,7 +972,7 @@ export class BookingPricingService {
if (!(usdToEtb > 0)) return null;
const converted =
snap.currency === 'USD' && bookingCurrency === 'ETB'
? Math.round(unitPrice * usdToEtb)
? round2(unitPrice * usdToEtb)
: snap.currency === 'ETB' && bookingCurrency === 'USD'
? unitPrice / usdToEtb
: null;
@@ -1027,7 +1028,7 @@ export class BookingPricingService {
const currency = booking.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
const onLeg = liveRates.filter(
(r) =>

View File

@@ -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 () => {

View File

@@ -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)) {

View File

@@ -2,6 +2,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { round2 } from '../billing/invoice-settlement.util';
import { ExchangeService } from '@edr/api-common';
import { ContractsRepository } from './contracts.repository';
import { Contract } from './entities/contract.entity';
@@ -79,7 +80,7 @@ export class ContractPricingService {
const currency = contract.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
const lineItems: ContractUnitRateLineItem[] = [];
const baseType = this.baseRateType(contract);

View File

@@ -4,6 +4,7 @@ import {
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
} from "@nestjs/common";
@@ -84,6 +85,15 @@ export class PaymentController {
return this.paymentService.getIntentByBookingId(bookingId);
}
@Post("redirect-success/:bookingId")
@ApiOperation({
summary:
"Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)",
})
acknowledgeSuccessRedirect(@Param("bookingId") bookingId: string) {
return this.paymentService.acknowledgeSuccessRedirect(bookingId);
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })

View File

@@ -253,8 +253,10 @@ export class PaymentService {
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
returnUrl:
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
input.returnUrl ??
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
failureUrl:
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
@@ -492,6 +494,37 @@ export class PaymentService {
return { alreadyFinalized: false };
}
/**
* Success-redirect ack from the portal: the customer finished provider
* checkout, settlement webhook not (necessarily) in yet. Optimistic
* intermediate only — the webhook stays the source of truth. Never
* downgrades: only action-required → processing, and the invoice moves to
* PAYMENT_PROCESSING only from an open unpaid status. CBE_BILL is excluded
* (bank-counter flow, it has no redirect).
*/
async acknowledgeSuccessRedirect(
referenceId: string,
): Promise<{ acknowledged: boolean }> {
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
if (!intent || intent.method === "cbe-bill") {
return { acknowledged: false };
}
if (intent.status === "action-required") {
await this.paymentRepo.update(
{ id: intent.id, status: "action-required" },
{ status: "processing" },
);
}
// Even if the intent already advanced (e.g. webhook raced the redirect to
// "processing"), the invoice ack is idempotent and status-guarded.
if (intent.status === "action-required" || intent.status === "processing") {
await this.billing.markInvoicePaymentProcessing(intent.id);
return { acknowledged: true };
}
return { acknowledged: false };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
@@ -510,7 +543,9 @@ export class PaymentService {
},
);
// Invoice stays open for retry — nothing to settle. Logged only.
// Invoice stays open for retry — nothing to settle. A redirect-acked
// PAYMENT_PROCESSING invoice is put back to PENDING so it reads payable.
await this.billing.revertInvoicePaymentProcessing(intent.id);
this.logger.warn(
`Payment ${intent.id} failed for ${intent.refId}` +
(input.failureMessage ? `: ${input.failureMessage}` : ""),

View File

@@ -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',

View File

@@ -1,3 +1,6 @@
# Dev server port. Default: 5283.
PORT=5283
VITE_API_URL=http://localhost:3001
VITE_BASE_API_URL=http://localhost:3001

View File

@@ -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",

View File

@@ -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 {
@@ -115,6 +117,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
@@ -581,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",
@@ -597,6 +606,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin,
},
{
label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.admin,
},
],
},
{
@@ -1589,6 +1603,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="audit-logs"
element={
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
<AuditLogsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={
@@ -1628,6 +1650,16 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="configuration/exchange-rate"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<div className="p-4">
<ExchangeRateSettingsCard />
</div>
</RequirePermission>
}
/>
{/* <Route
path="configuration/contract-validity-periods"
element={

View File

@@ -249,6 +249,7 @@ const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
DRAFT: "gray",
ISSUED: "cyan",
PENDING: "yellow",
PAYMENT_PROCESSING: "indigo",
PARTIALLY_PAID: "orange",
PAID: "edr-green",
OVERDUE: "red",

View File

@@ -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: {

View File

@@ -14,6 +14,7 @@ import { openPdfBlob } from './pdf';
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PAYMENT_PROCESSING: 'indigo',
PARTIALLY_PAID: 'yellow',
PAID: 'edr-green',
CANCELLED: 'gray',

View File

@@ -309,6 +309,10 @@ export const URL_CONSTANTS = {
SUMMARY: "/payments/summary",
},
AUDIT: {
LOGS: "/audit/logs",
},
LOCOMOTIVES: {
BASE: "/locomotives",
BY_ID: (id: string) => `/locomotives/${id}`,

View File

@@ -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",

View File

@@ -33,7 +33,6 @@ import {
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { toast } from "sonner";
import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
export default function SettingsPage() {
const [createDialogOpen, setCreateDialogOpen] = useState(false);
@@ -78,8 +77,6 @@ export default function SettingsPage() {
return (
<div className="p-6 space-y-6">
<ExchangeRateSettingsCard />
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle className="text-xl font-semibold">

View File

@@ -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>
);
}

View File

@@ -183,6 +183,7 @@ export default function InvoicesPage() {
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}

View File

@@ -79,6 +79,7 @@ const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
PAID: { label: "Paid", color: "green" },
PARTIALLY_PAID: { label: "Partially Paid", color: "teal" },
PENDING: { label: "Pending", color: "yellow" },
PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" },
UNPAID: { label: "Unpaid", color: "yellow" },
OPEN: { label: "Open", color: "yellow" },
ISSUED: { label: "Issued", color: "blue" },

View File

@@ -86,6 +86,7 @@ const INVOICE_STATUS_META: Record<string, { label: string; color: string }> = {
PAID: { label: "Paid", color: "green" },
PARTIALLY_PAID: { label: "Partially Paid", color: "teal" },
PENDING: { label: "Pending", color: "yellow" },
PAYMENT_PROCESSING: { label: "Payment Processing", color: "indigo" },
UNPAID: { label: "Unpaid", color: "yellow" },
OPEN: { label: "Open", color: "yellow" },
ISSUED: { label: "Issued", color: "blue" },

View File

@@ -75,6 +75,7 @@ const CONTRACT_STATUSES = [
const INVOICE_STATUSES = [
"ISSUED",
"PENDING",
"PAYMENT_PROCESSING",
"PARTIALLY_PAID",
"PAID",
"OVERDUE",

View File

@@ -42,6 +42,7 @@ import { extractErrorMessage } from '@/components/warehouses/options';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PAYMENT_PROCESSING: 'indigo',
PARTIALLY_PAID: 'yellow',
PAID: 'edr-green',
CANCELLED: 'gray',

View File

@@ -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",

View File

@@ -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 };
},
};

View File

@@ -907,6 +907,7 @@ export interface AllocationCriteria {
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PAYMENT_PROCESSING',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',

View File

@@ -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: {

View File

@@ -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",

View File

@@ -194,6 +194,8 @@ export const URL_CONSTANTS = {
PAYMENTS: {
INITIATE: "/api/payments/initiate",
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
REDIRECT_SUCCESS: (bookingId: string) =>
`/api/payments/redirect-success/${bookingId}`,
CHECKOUT: "/api/payments/checkout",
},

View File

@@ -550,6 +550,7 @@ export const INVOICE_BADGE: Record<
[Freight.InvoiceStatus.Issued]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
[Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
[Freight.InvoiceStatus.PartiallyPaid]: { label: "Partially paid", bg: "edr-amber-soft", text: "edr-amber-text" },
[Freight.InvoiceStatus.PaymentProcessing]: { label: "Payment processing", bg: "edr-blue-soft", text: "edr-blue" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },

View File

@@ -21,6 +21,7 @@ const STATUS_STYLE: Record<
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
[Freight.InvoiceStatus.Issued]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
[Freight.InvoiceStatus.PaymentProcessing]: { label: "Payment processing", bg: "#EAF1FB", fg: "#2563EB" },
[Freight.InvoiceStatus.PartiallyPaid]: { label: "Partially paid", bg: "#FEF9E7", fg: "#A16207" },
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },

View File

@@ -41,6 +41,7 @@ const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PAYMENT_PROCESSING: "Payment processing",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",

View File

@@ -1,5 +1,12 @@
import { Group, Tabs } from "@mantine/core";
import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
import {
Clock,
CreditCard,
FileText,
LayoutGrid,
Package,
Truck,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -9,6 +16,7 @@ import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
import { CargoTab } from "./components/CargoTab";
import { DocumentsTab } from "./components/DocumentsTab";
import { CompanyInfoCard } from "./components/CompanyInfoCard";
import { ContainersCard } from "./components/ContainersCard";
@@ -199,6 +207,9 @@ export function ReadonlyBookingView({
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo
</Tabs.Tab>
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
Logistics
</Tabs.Tab>
@@ -254,6 +265,10 @@ export function ReadonlyBookingView({
</div>
</Tabs.Panel>
<Tabs.Panel value="cargo">
<CargoTab booking={booking} />
</Tabs.Panel>
<Tabs.Panel value="logistics">
<div className="flex flex-col gap-6">
<BodyGrid

View File

@@ -0,0 +1,465 @@
import { Box, Group, SimpleGrid, Table, Text } from "@mantine/core";
import {
AlertTriangle,
Box as BoxIcon,
Container,
Flame,
Package,
Scale,
Snowflake,
Undo2,
} from "lucide-react";
import type { ReactNode } from "react";
import type {
BookingContainerLineDetail,
BookingContainerUnitDetail,
BookingDetail,
} from "../booking-detail-types";
import {
commodityLabel,
fmtDate,
fmtWeight,
shippingLineLabel,
totalVgmTons,
} from "../utils";
import { CardTitle, SectionCard } from "./layout";
// ─── Shared bits ──────────────────────────────────────────────────────────────
function Flag({
icon,
label,
tone = "grey",
}: {
icon: ReactNode;
label: string;
tone?: "grey" | "amber" | "blue" | "green";
}) {
const palette = {
grey: { bg: "#F1F4F7", color: "#475569" },
amber: { bg: "#FFFBEB", color: "#92400E" },
blue: { bg: "#EAF1FE", color: "#1E40AF" },
green: { bg: "#E8F5EF", color: "#0A6F4D" },
}[tone];
return (
<Group
component="span"
gap={4}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: palette.bg,
padding: "3px 9px",
fontSize: 11,
fontWeight: 700,
color: palette.color,
}}
>
{icon}
{label}
</Group>
);
}
function StatTile({
icon,
label,
value,
sub,
}: {
icon: ReactNode;
label: string;
value: string;
sub?: string;
}) {
return (
<Box
p={14}
style={{
borderRadius: 12,
border: "1px solid #E6ECF2",
backgroundColor: "#FAFCFE",
}}
>
<Group gap={6} align="center" mb={6} c="#6B7C8E">
{icon}
<Text fz="11px" fw={700} tt="uppercase" style={{ letterSpacing: "0.05em" }}>
{label}
</Text>
</Group>
<Text fz={18} fw={800} c="#10202F" truncate>
{value}
</Text>
{sub && (
<Text fz={12} c="#9AA8B5" mt={2}>
{sub}
</Text>
)}
</Box>
);
}
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<Group justify="space-between" align="baseline" py={11} wrap="nowrap" style={{ borderBottom: "1px solid #F2F5F8" }}>
<Text fz={12.5} fw={600} c="#9AA8B5" style={{ flexShrink: 0 }}>
{label}
</Text>
<Text fz={13.5} fw={700} c="#10202F" ta="right">
{value}
</Text>
</Group>
);
}
const th = { color: "#9AA8B5", fontSize: 11 } as const;
// ─── Containers ───────────────────────────────────────────────────────────────
function lineTypeLabel(line: BookingContainerLineDetail): string {
const t = line.containerType;
if (t?.label) return t.label;
if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`;
return t?.code ?? "Container";
}
function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) {
return (
<Table.Tr>
<Table.Td>
<Text fz={13} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
{unit.containerNumber}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} c="#475569">
{unit.sealNumber || "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} c="#475569">
{Number(unit.vgmTons || 0) ? fmtWeight(Number(unit.vgmTons)) : "—"}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4} wrap="wrap">
{unit.isHazardous && (
<Flag tone="amber" icon={<AlertTriangle size={11} />} label="Hazardous" />
)}
{unit.isReefer && (
<Flag tone="blue" icon={<Snowflake size={11} />} label="Reefer" />
)}
{unit.isReturn && <Flag icon={<Undo2 size={11} />} label="Return" />}
{!unit.isHazardous && !unit.isReefer && !unit.isReturn && (
<Text fz={12} c="#9AA8B5">
</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text fz={13} c="#475569">
{unit.grnNumber || "—"}
</Text>
</Table.Td>
<Table.Td>
{unit.receivedToPort ? (
<Box>
<Text fz={12.5} fw={700} c="#0A6F4D">
Received
</Text>
{unit.receivedAt && (
<Text fz={11.5} c="#9AA8B5">
{fmtDate(unit.receivedAt)}
</Text>
)}
</Box>
) : (
<Text fz={12.5} fw={600} c="#9AA8B5">
Pending
</Text>
)}
</Table.Td>
</Table.Tr>
);
}
function ContainerLineCard({ line, index }: { line: BookingContainerLineDetail; index: number }) {
const units = line.units ?? [];
return (
<SectionCard>
<Group justify="space-between" align="flex-start" mb={4} wrap="wrap">
<Group gap={10} align="center">
<Box
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#E8F5EF",
color: "#0A6F4D",
}}
>
<Container size={18} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
{lineTypeLabel(line)}
</Text>
<Text fz={12} c="#9AA8B5">
Line {index + 1} · {line.quantity} unit{line.quantity !== 1 ? "s" : ""}
</Text>
</Box>
</Group>
<Group gap={4} wrap="wrap" justify="flex-end">
{line.isOverweight && (
<Flag
tone="amber"
icon={<AlertTriangle size={11} />}
label={
Number(line.overweightExcessTons || 0)
? `Overweight +${Number(line.overweightExcessTons)} t`
: "Overweight"
}
/>
)}
{!!line.hazardousQuantity && (
<Flag tone="amber" icon={<Flame size={11} />} label={`${line.hazardousQuantity} hazardous`} />
)}
{!!line.reeferQuantity && (
<Flag tone="blue" icon={<Snowflake size={11} />} label={`${line.reeferQuantity} reefer`} />
)}
{!!line.returnQuantity && (
<Flag icon={<Undo2 size={11} />} label={`${line.returnQuantity} return`} />
)}
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing={10} my="md">
<StatTile
icon={<BoxIcon size={13} />}
label="Quantity"
value={`${line.quantity}`}
sub={`container${line.quantity !== 1 ? "s" : ""}`}
/>
<StatTile
icon={<Scale size={13} />}
label="VGM / unit"
value={fmtWeight(Number(line.vgmPerUnitTons || 0))}
/>
<StatTile
icon={<Scale size={13} />}
label="Line total VGM"
value={fmtWeight(Number(line.totalVgmTons || 0))}
/>
</SimpleGrid>
{units.length > 0 && (
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="sm" miw={640}>
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Container no.</Table.Th>
<Table.Th style={th}>Seal no.</Table.Th>
<Table.Th style={th}>VGM</Table.Th>
<Table.Th style={th}>Flags</Table.Th>
<Table.Th style={th}>GRN</Table.Th>
<Table.Th style={th}>Port status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((u) => (
<UnitRow key={u.id} unit={u} />
))}
</Table.Tbody>
</Table>
</Box>
)}
{units.length === 0 && (
<Text fz={12.5} c="#9AA8B5">
Container numbers will appear here once the physical units are assigned.
</Text>
)}
</SectionCard>
);
}
// ─── Bulk ─────────────────────────────────────────────────────────────────────
function BulkCargoCard({ booking }: { booking: BookingDetail }) {
const unit = booking.cargoType?.unitOfMeasure;
const isPerItem = unit === "PER_ITEM";
// Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT and the
// real tonnage lives in bulkTotalWeightTons; PER_TON stores tons directly.
const quantity = Number(booking.cargoTotalWeightVgm || 0);
const tons = totalVgmTons(booking);
return (
<SectionCard>
<Group gap={10} align="center" mb="md">
<Box
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#E8F5EF",
color: "#0A6F4D",
}}
>
<Package size={18} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
{commodityLabel(booking)}
</Text>
<Text fz={12} c="#9AA8B5">
Bulk cargo{booking.cargoType?.code ? ` · ${booking.cargoType.code}` : ""}
</Text>
</Box>
</Group>
<SimpleGrid cols={{ base: 1, sm: isPerItem ? 3 : 2 }} spacing={10} mb="md">
{isPerItem && (
<StatTile
icon={<BoxIcon size={13} />}
label="Items"
value={quantity ? quantity.toLocaleString() : "—"}
sub="declared item count"
/>
)}
<StatTile
icon={<Scale size={13} />}
label="Total weight"
value={fmtWeight(tons)}
sub={isPerItem ? "actual tonnage" : "declared tonnage"}
/>
<StatTile
icon={<Package size={13} />}
label="Billing unit"
value={isPerItem ? "Per item" : "Per ton"}
/>
</SimpleGrid>
<Box>
<DetailRow label="Commodity" value={commodityLabel(booking)} />
{booking.cargoType?.code && (
<DetailRow label="Cargo type code" value={booking.cargoType.code} />
)}
{booking.cargoFreeText && booking.cargoType?.cargoTypeName && (
<DetailRow label="Cargo description" value={booking.cargoFreeText} />
)}
<DetailRow
label="Hazardous"
value={
Number(booking.bulkHazardousQuantity || 0)
? `${Number(booking.bulkHazardousQuantity).toLocaleString()} ${isPerItem ? "items" : "t"}`
: booking.isHazardous
? "Yes"
: "No"
}
/>
<DetailRow
label="Refrigerated"
value={
Number(booking.bulkReeferQuantity || 0)
? `${Number(booking.bulkReeferQuantity).toLocaleString()} ${isPerItem ? "items" : "t"}`
: booking.isRefrigerated
? "Yes"
: "No"
}
/>
<DetailRow
label="Equipment return"
value={booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"}
/>
<DetailRow label="Shipping line" value={shippingLineLabel(booking)} />
</Box>
</SectionCard>
);
}
// ─── Tab ──────────────────────────────────────────────────────────────────────
/**
* Dedicated cargo breakdown tab: bulk bookings get the full bulk declaration
* (commodity, unit of measure, item count vs tonnage, hazardous/reefer
* quantities); container bookings get one card per container line with its
* per-unit numbers, seals, VGM, GRN and port-arrival status.
*/
export function CargoTab({ booking }: { booking: BookingDetail }) {
const isBulk = booking.freightType === "BULK";
const lines = booking.bookingContainers ?? [];
const totalUnits = lines.reduce((s, c) => s + Number(c.quantity || 0), 0);
const totalVgm = totalVgmTons(booking);
const receivedCount = lines
.flatMap((l) => l.units ?? [])
.filter((u) => u.receivedToPort).length;
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<SectionCard>
<CardTitle>Cargo summary</CardTitle>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
<StatTile
icon={isBulk ? <Package size={13} /> : <Container size={13} />}
label="Freight type"
value={isBulk ? "Bulk" : "Container"}
/>
<StatTile
icon={<BoxIcon size={13} />}
label="Commodity"
value={commodityLabel(booking)}
/>
<StatTile
icon={<Scale size={13} />}
label="Total weight"
value={fmtWeight(totalVgm)}
/>
{isBulk ? (
<StatTile
icon={<Flame size={13} />}
label="Special handling"
value={
[
booking.isHazardous && "Hazardous",
booking.isRefrigerated && "Reefer",
]
.filter(Boolean)
.join(" · ") || "None"
}
/>
) : (
<StatTile
icon={<Container size={13} />}
label="Containers"
value={`${totalUnits}`}
sub={`${receivedCount} received at port`}
/>
)}
</SimpleGrid>
</SectionCard>
{isBulk ? (
<BulkCargoCard booking={booking} />
) : lines.length > 0 ? (
lines.map((line, i) => (
<ContainerLineCard key={line.id ?? i} line={line} index={i} />
))
) : (
<SectionCard>
<Text fz={13.5} c="#9AA8B5">
No container details recorded for this booking yet.
</Text>
</SectionCard>
)}
</div>
);
}

View File

@@ -292,7 +292,10 @@ export default function NewBookingPage() {
// physically carry the selected cargo/container type. Quantity is NOT part
// of this gate — an oversized booking is accepted and gets a partial split
// offer later. Only selectable days reach the UI; no capacity counts.
const gateContainerTypeIds = useMemo(() => {
// Computed per render on purpose — NOT useMemo. react-hook-form mutates the
// watched containers array in place on nested edits (containers.0.containerType),
// so a reference-based dep list never sees per-line changes.
const gateContainerTypeIds = (() => {
if (watchedCargoKind !== "container") return [];
const groups = referenceData?.containers ?? [];
const ids = new Set<string>();
@@ -304,7 +307,7 @@ export default function NewBookingPage() {
}
}
return [...ids];
}, [watchedCargoKind, watchedContainers, referenceData]);
})();
const gateCargoTypeId =
watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined;
const gateReady =

View File

@@ -120,6 +120,7 @@ const PAYMENT_COLORS: Record<string, string> = {
// Backend emits the long form on some flows; keep the short alias too.
VERIFICATION_IN_PROGRESS: "yellow",
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
PAYMENT_PROCESSING: "yellow",
OVERDUE: "red",
REFUNDED: "blue",
CANCELLED: "gray",
@@ -132,6 +133,7 @@ const PAYMENT_LABELS: Record<string, string> = {
PNR_GENERATED: "PNR generated",
VERIFICATION_IN_PROGRESS: "Verifying",
PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying",
PAYMENT_PROCESSING: "Payment processing",
OVERDUE: "Overdue",
REFUNDED: "Refunded",
CANCELLED: "Cancelled",

View File

@@ -1006,9 +1006,11 @@ function ScheduleStep({
const isContainer = contract.freightType === "CONTAINER";
const containerLines = form.watch("containers");
const cargoWeightTons = form.watch("cargoWeightTons");
const itemCount = form.watch("itemCount");
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
// Computed per render on purpose — NOT useMemo. react-hook-form mutates the
// watched containers array in place on nested edits (containers.0.quantity),
// so a reference-based dep list never sees manual quantity changes.
const cargoQuery = ((): Freight.AvailableDaysForCargoQuery | null => {
if (!route?.originYardId || !route?.destinationYardId) return null;
if (isContainer) {
const containers = (containerLines ?? [])
@@ -1036,17 +1038,7 @@ function ScheduleStep({
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
// Tonnage is the sizing input the day-feasibility endpoint takes, and
// PER_ITEM cargo now captures it too — itemCount stays in the deps so the
// query still refreshes when only the item count changes.
}, [
route,
isContainer,
containerLines,
cargoWeightTons,
itemCount,
contract.pricingBreakdown,
]);
})();
const isIntercity = contract.tradeDirection === "DOMESTIC";
const { data: availableDays, isLoading } = useQuery({

View File

@@ -8,10 +8,22 @@ import {
ThemeIcon,
} from "@mantine/core";
import { CheckCircle2, FileText, Home } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { paymentsService } from "@/services/payments.service";
export default function PaymentSuccessPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const bookingId = searchParams.get("bookingId");
// Fire-and-forget ack: payment → processing, invoice → PAYMENT_PROCESSING.
// The provider webhook remains the source of truth for the final PAID state.
useEffect(() => {
if (bookingId) {
paymentsService.acknowledgeSuccessRedirect(bookingId).catch(() => {});
}
}, [bookingId]);
return (
<Box

View File

@@ -107,6 +107,11 @@ export const paymentsService = {
return data.data ?? data;
},
/** Success-redirect ack: payment → processing, invoice → PAYMENT_PROCESSING. */
acknowledgeSuccessRedirect: async (bookingId: string): Promise<void> => {
await client.post(P.REDIRECT_SUCCESS(bookingId));
},
checkoutUrl: buildCheckoutUrl,
checkoutUrlForInvoice: buildCheckoutUrlForInvoice,
};

View File

@@ -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",
},
};
});

View File

@@ -107,6 +107,7 @@ export class CbeBillService {
consented_on: Math.floor(Date.now() / 1000),
};
}
/* ------------------------------------------------------------------ query */
@@ -168,6 +169,14 @@ export class CbeBillService {
async pay(dto: CbePaymentRequestDto): Promise<CbePaymentResponseDto> {
this.assertEnabled();
this.logger.log({
msg: "cbe.payment.request",
billId: dto.Bill_Id,
endToEndTxnId: dto.End_To_End_Txn_Id,
cbeTxnRef: dto.Cbe_Txn_Ref,
request: dto,
});
// §6.5 idempotency on CBE's per-attempt id, in order.
const prior = await this.cbeBillRepository.findByEndToEndTxnId(
dto.End_To_End_Txn_Id,

View File

@@ -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" }),

View File

@@ -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

Binary file not shown.

View File

@@ -161,6 +161,8 @@ export enum InvoiceStatus {
/** Issued and awaiting payment (alias of PENDING for fee invoices). */
Issued = "ISSUED",
Pending = "PENDING",
/** Customer completed provider checkout (success redirect); awaiting webhook confirmation. */
PaymentProcessing = "PAYMENT_PROCESSING",
/** Some, but not all, of the balance has been settled. */
PartiallyPaid = "PARTIALLY_PAID",
Paid = "PAID",

313
pnpm-lock.yaml generated
View File

@@ -99,6 +99,9 @@ importers:
'@tria-plc/api-common':
specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz
version: file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6)
'@tria-plc/auditlog':
specifier: file:../../local-packages/tria-plc-auditlog-1.1.2.tgz
version: file:local-packages/tria-plc-auditlog-1.1.2.tgz(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2))(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))))(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
'@tria-plc/iamapi-common':
specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz
version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(d0be280d95adfc1b38e59bdc80c5dec5)
@@ -595,7 +598,7 @@ importers:
version: 5.101.0(react@19.2.6)
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@vis.gl/react-google-maps':
specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -4647,6 +4650,19 @@ packages:
rxjs: ^7.8.0
typeorm: ^0.3.0
'@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz':
resolution: {integrity: sha512-3kRaAtETvM9wVRSbwd2jyrts9DCcSV9yFUDoEpxJfVQDsIGY2d9K0c+h1LAjfNNaogZ8eeBcroyCrnWjwBmkYA==, tarball: file:local-packages/tria-plc-auditlog-1.1.2.tgz}
version: 1.1.2
engines: {node: '>=18'}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/microservices': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^10.0.0 || ^11.0.0
'@nestjs/typeorm': ^10.0.0 || ^11.0.0
rxjs: ^7.0.0
typeorm: ^0.3.0
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz':
resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz}
version: 1.0.0
@@ -12298,11 +12314,11 @@ snapshots:
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -12337,7 +12353,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -12346,14 +12362,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12368,9 +12377,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -12385,13 +12394,13 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12544,18 +12553,6 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.7(supports-color@5.5.0)':
dependencies:
'@babel/code-frame': 7.29.7
@@ -12781,7 +12778,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
@@ -12947,7 +12944,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.15.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.2
@@ -13107,7 +13104,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -14304,7 +14301,7 @@ snapshots:
'@puppeteer/browsers@2.13.2':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@@ -16372,7 +16369,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@@ -16467,6 +16464,18 @@ snapshots:
- debug
- supports-color
'@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2))(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))))(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))':
dependencies:
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
amqp-connection-manager: 5.0.0(amqplib@0.10.9)
amqplib: 0.10.9
rxjs: 7.8.2
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
@@ -16659,130 +16668,6 @@ snapshots:
- utf-8-validate
- vite
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer': 4.5.1(react@19.2.6)
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
'@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
date-fns: 3.6.0
dayjs: 1.11.21
dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
i18next-browser-languagedetector: 8.2.1
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
- '@babel/core'
- '@emotion/is-prop-valid'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- bufferutil
- debug
- pdfjs-dist
- prop-types
- react-is
- react-native
- redux
- rolldown
- rollup
- supports-color
- typescript
- utf-8-validate
- vite
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -17161,7 +17046,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
@@ -17171,7 +17056,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -17190,7 +17075,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
@@ -17205,7 +17090,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
minimatch: 10.2.5
semver: 7.8.2
tinyglobby: 0.2.17
@@ -17494,7 +17379,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -17547,6 +17432,11 @@ snapshots:
amqplib: 0.10.9
promise-breaker: 6.0.0
amqp-connection-manager@5.0.0(amqplib@0.10.9):
dependencies:
amqplib: 0.10.9
promise-breaker: 6.0.0
amqp-connection-manager@5.0.0(amqplib@2.0.1):
dependencies:
amqplib: 2.0.1
@@ -18003,16 +17893,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
dependencies:
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
picomatch: 4.0.4
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- supports-color
babel-polyfill@6.26.0:
dependencies:
babel-runtime: 6.26.0
@@ -18166,7 +18046,7 @@ snapshots:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -19163,7 +19043,7 @@ snapshots:
engine.io-client@6.6.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3
ws: 8.20.1
xmlhttprequest-ssl: 2.1.2
@@ -19183,7 +19063,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3
ws: 8.21.0
transitivePeerDependencies:
@@ -19412,7 +19292,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
get-tsconfig: 4.14.0
is-bun-module: 2.0.0
@@ -19540,7 +19420,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
doctrine: 3.0.0
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -19770,7 +19650,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -19823,7 +19703,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@@ -19974,7 +19854,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -20220,7 +20100,7 @@ snapshots:
dependencies:
basic-ftp: 5.3.1
data-uri-to-buffer: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -20501,7 +20381,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -20514,14 +20394,14 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -20946,7 +20826,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2
source-map: 0.6.1
transitivePeerDependencies:
@@ -21592,7 +21472,7 @@ snapshots:
dependencies:
chalk: 5.6.2
commander: 13.1.0
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
execa: 8.0.1
lilconfig: 3.1.3
listr2: 8.3.3
@@ -22389,7 +22269,7 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
get-uri: 6.0.5
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
@@ -22727,7 +22607,7 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
@@ -22756,7 +22636,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
devtools-protocol: 0.0.1608973
typed-query-selector: 2.12.2
webdriver-bidi-protocol: 0.4.1
@@ -23009,15 +22889,6 @@ snapshots:
- '@babel/core'
- react-is
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- react-is
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies:
date-fns: 3.6.0
@@ -23591,7 +23462,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -23709,7 +23580,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -23925,7 +23796,7 @@ snapshots:
socket.io-adapter@2.5.8:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
@@ -23935,7 +23806,7 @@ snapshots:
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io-client: 6.6.5
socket.io-parser: 4.2.6
transitivePeerDependencies:
@@ -23946,7 +23817,7 @@ snapshots:
socket.io-parser@4.2.6:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -23955,7 +23826,7 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
engine.io: 6.6.9
socket.io-adapter: 2.5.8
socket.io-parser: 4.2.6
@@ -23967,7 +23838,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
socks: 2.8.9
transitivePeerDependencies:
- supports-color
@@ -24245,24 +24116,6 @@ snapshots:
transitivePeerDependencies:
- '@babel/core'
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@emotion/is-prop-valid': 1.4.0
'@emotion/stylis': 0.8.5
'@emotion/unitless': 0.7.5
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
css-to-react-native: 3.2.0
hoist-non-react-statics: 3.3.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 19.2.7
shallowequal: 1.1.0
supports-color: 5.5.0
transitivePeerDependencies:
- '@babel/core'
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -24288,7 +24141,7 @@ snapshots:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
fast-safe-stringify: 2.1.1
form-data: 4.0.5
formidable: 3.5.4
@@ -24797,7 +24650,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -24821,7 +24674,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
@@ -25124,7 +24977,7 @@ snapshots:
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -25142,7 +24995,7 @@ snapshots:
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -25189,7 +25042,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2
@@ -25225,7 +25078,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 1.1.2