Merge pull request #1255 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-12 13:23:29 +03:00
committed by GitHub
21 changed files with 916 additions and 484 deletions

View File

@@ -1,5 +1,7 @@
import { Logger } from "@nestjs/common"; import { Logger } from "@nestjs/common";
import type { Repository } from "typeorm";
import { import {
BaseRepository,
RequestLogMiddleware, RequestLogMiddleware,
getLogContext, getLogContext,
logCtx, logCtx,
@@ -47,10 +49,13 @@ describe("logCtx", () => {
describe("RequestLogMiddleware", () => { describe("RequestLogMiddleware", () => {
it("emits one canonical JSON line carrying the collected context", () => { it("emits one canonical JSON line carrying the collected context", () => {
// Raw stdout, not the Nest logger — the line must be parsable JSON with no
// "[Nest] … LOG [request]" prefix in front of it.
const lines: string[] = []; const lines: string[] = [];
jest jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
.spyOn(Logger.prototype, "warn") lines.push(String(chunk));
.mockImplementation((m) => lines.push(String(m))); return true;
});
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
const listeners: Record<string, () => void> = {}; const listeners: Record<string, () => void> = {};
@@ -82,7 +87,11 @@ describe("RequestLogMiddleware", () => {
listeners.close(); // aborts/close after finish must not double-log listeners.close(); // aborts/close after finish must not double-log
expect(lines).toHaveLength(1); expect(lines).toHaveLength(1);
expect(lines[0].endsWith("\n")).toBe(true);
expect(lines[0].startsWith("{")).toBe(true);
expect(JSON.parse(lines[0])).toMatchObject({ expect(JSON.parse(lines[0])).toMatchObject({
level: "warn",
logger: "request",
type: "http_request", type: "http_request",
requestId: "req-42", requestId: "req-42",
method: "POST", method: "POST",
@@ -100,3 +109,38 @@ describe("RequestLogMiddleware", () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
}); });
describe("BaseRepository write trail", () => {
class TestRepo extends BaseRepository<{ id: string; status?: string }> {
constructor(repo: Repository<{ id: string; status?: string }>) {
super(repo);
}
}
const typeormRepo = {
metadata: { tableName: "booking" },
create: (data: unknown) => data,
save: async (data: unknown) => data,
update: async () => undefined,
findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
softDelete: async () => undefined,
delete: async () => undefined,
} as unknown as Repository<{ id: string; status?: string }>;
it("records creates, status changes and deletes without any service opting in", async () => {
const ctx = await runWithLogContext({}, async () => {
const repo = new TestRepo(typeormRepo);
await repo.create({ id: "b-1" });
await repo.update("b-1", { status: "SUBMITTED" });
await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
await repo.softDelete("b-1");
return getLogContext();
});
expect(ctx).toEqual({
db: { created: { booking: 1 }, updated: { booking: 2 } },
statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
deleted: [{ entity: "booking", id: "b-1" }],
});
});
});

View File

@@ -8,6 +8,7 @@ import {
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter"; import { EventEmitter2 } from "@nestjs/event-emitter";
import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm"; import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
@@ -951,6 +952,24 @@ export class BillingService {
await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
// Every invoice status move in the app funnels through here — money
// changing state is the single most-asked question in support.
logCtx(
{
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source,
sourceId: invoice.sourceId,
from: invoice.status,
to: status,
event,
amount: Number(invoice.totalAmount),
currency: invoice.currency,
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
},
{ path: "invoiceTransitions", mode: "push" },
);
const updated = { ...invoice, ...extra, status } as Invoice; const updated = { ...invoice, ...extra, status } as Invoice;
return { return {
result: updated, result: updated,
@@ -1320,6 +1339,21 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance."); throw new BadRequestException("Invoice has no outstanding balance.");
} }
logCtx(
{
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source,
sourceId: invoice.sourceId,
companyId: invoice.companyId,
amountDue,
currency: invoice.currency,
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
},
{ path: "payment.payInvoice" },
);
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here). // required up front (the payment service rejects it otherwise, as a 502 here).
if ( if (
@@ -1443,7 +1477,24 @@ export class BillingService {
// first on DESC, which would hand back an unissued invoice. // first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } }, order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
}); });
if (!invoice) return null; if (!invoice) {
logCtx(
{ paymentId, outcome: "no-invoice-for-payment" },
{ path: "payment.settleInvoice" },
);
return null;
}
logCtx(
{
paymentId,
providerTxnId,
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
invoiceStatus: invoice.status,
},
{ path: "payment.settleInvoice" },
);
const settleable: Freight.InvoiceStatus[] = [ const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES, ...OPEN_STATUSES,
@@ -1453,6 +1504,12 @@ export class BillingService {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled // Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with // inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly. // nowhere to land — that needs a person, so say so loudly.
logCtx(
invoice.status === Freight.InvoiceStatus.Paid
? "already-paid"
: "captured-with-nowhere-to-land",
{ path: "payment.settleInvoice.outcome", mode: "set" },
);
if (invoice.status !== Freight.InvoiceStatus.Paid) { if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error( this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` + `Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +

View File

@@ -1,4 +1,4 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository, logCtx } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types'; import { SchedulingStatus } from '@edr/types';
import { ConflictException, Injectable } from '@nestjs/common'; import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
authorId?: string, authorId?: string,
): Promise<BookingReviewNote> { ): Promise<BookingReviewNote> {
const repo = this.dataSource.getRepository(BookingReviewNote); const repo = this.dataSource.getRepository(BookingReviewNote);
// Every rejection/cancellation/change-request reason in the booking flow is
// written through here — the "why" behind the status change on the same log
// line as the status change itself.
logCtx(
{ bookingId, type, note, authorId },
{ path: 'reviewNotes', mode: 'push' },
);
return repo.save( return repo.save(
repo.create({ bookingId, note, type, authorId: authorId ?? null }), repo.create({ bookingId, note, type, authorId: authorId ?? null }),
); );

View File

@@ -9,7 +9,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types'; import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
@@ -2115,6 +2115,21 @@ export class BookingsService {
throw new NotFoundException(`Booking ${id} not found`); throw new NotFoundException(`Booking ${id} not found`);
} }
// Nearly every booking flow loads the booking through here, so this one
// call puts the human-searchable reference + entry state on the request log
// line for all of them. Entry state only — the write trail (statusChanges)
// shows where it ended up.
logCtx(
{
id: booking.id,
reference: booking.reference,
statusAtEntry: booking.status,
companyId: booking.companyId,
contractId: booking.contractId ?? undefined,
},
{ path: "booking" },
);
if (booking.files && booking.files.length > 0) { if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all( booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => { booking.files.map(async (file: FileRecord) => {

View File

@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
): Promise<void> { ): Promise<void> {
const role = dto.role as ContractSignerRole; const role = dto.role as ContractSignerRole;
// Both sign() and counterSign() land here — who signed what, and whether the
// ink came from the request or the signer's saved profile signature.
logCtx(
{
contractId: contract.id,
reference: contract.reference,
role,
signerUserId: options.signerUserId,
usedDrawnImage: Boolean(dto.signatureImageBase64),
},
{ path: "contractSignatures", mode: "push" },
);
// Resolve the signature image. The client may send a freshly-drawn image, or // Resolve the signature image. The client may send a freshly-drawn image, or
// omit it to reuse the signer's saved profile signature. Fall back to the // omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied. // saved one whenever no image is supplied.

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import { YardCountry } from '@edr/types'; import { YardCountry } from '@edr/types';
// //
@@ -811,6 +811,18 @@ export class ContractsService {
throw new NotFoundException(`Contract ${id} not found`); throw new NotFoundException(`Contract ${id} not found`);
} }
// Entry state for every contract flow (submit, approve, sign, suspend…) —
// see the equivalent in BookingsService.findById.
logCtx(
{
id: contract.id,
reference: contract.reference,
statusAtEntry: contract.status,
companyId: contract.companyId,
},
{ path: "contract" },
);
if (contract.files && contract.files.length > 0) { if (contract.files && contract.files.length > 0) {
contract.files = await Promise.all( contract.files = await Promise.all(
contract.files.map(async (file: FileRecord) => { contract.files.map(async (file: FileRecord) => {

View File

@@ -1,6 +1,7 @@
// otp.service.ts // otp.service.ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { randomInt } from "node:crypto"; import { randomInt } from "node:crypto";
import { OtpRepository } from "./otp.repository"; import { OtpRepository } from "./otp.repository";
@@ -317,6 +318,13 @@ export class OtpService {
}`; }`;
if (result === "ok") this.logger.log(line); if (result === "ok") this.logger.log(line);
else this.logger.warn(line); else this.logger.warn(line);
// Outcome only — the target is a phone number / email address and stays out
// of the canonical line. Channels are safe and say which one was used.
logCtx(
{ mode, result, channels: channelsOf(target) },
{ path: "otp.verify", mode: "push" },
);
} }
/** /**

View File

@@ -7,6 +7,7 @@ import {
import { HttpService } from "@nestjs/axios"; import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { logCtx } from "@edr/api-common";
import { import {
InitiatePaymentRequest, InitiatePaymentRequest,
PaymentIntentSnapshot, PaymentIntentSnapshot,
@@ -111,6 +112,16 @@ export class PaymentClientService {
body?: unknown, body?: unknown,
): Promise<T> { ): Promise<T> {
const url = `${this.baseUrl}${path}`; const url = `${this.baseUrl}${path}`;
// Every hop to the payment service lands on the request log line: which
// call, how slow, and what it answered. A settle that never happened is
// almost always one of these coming back 4xx/5xx or timing out.
const startedAt = Date.now();
const trace = (extra: Record<string, unknown>) =>
logCtx(
{ method, path, ms: Date.now() - startedAt, ...extra },
{ path: "outbound.payment", mode: "push" },
);
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.request<T>({ this.http.request<T>({
@@ -122,9 +133,11 @@ export class PaymentClientService {
: {}, : {},
}), }),
); );
trace({ status: response.status });
return response.data; return response.data;
} catch (err) { } catch (err) {
if (err instanceof AxiosError && err.response) { if (err instanceof AxiosError && err.response) {
trace({ status: err.response.status });
if (err.response.status === 404) throw err; if (err.response.status === 404) throw err;
const detail = const detail =
(err.response.data as { message?: string | string[] })?.message ?? (err.response.data as { message?: string | string[] })?.message ??
@@ -136,6 +149,7 @@ export class PaymentClientService {
} }
const message = const message =
err instanceof Error && err.message ? err.message : String(err); err instanceof Error && err.message ? err.message : String(err);
trace({ unreachable: true, error: message });
this.logger.error( this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`, `payment service unreachable (${method} ${path}): ${message}`,
); );

View File

@@ -7,6 +7,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentEntity } from "./entities/payment.entity"; import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository"; import { PaymentRepository } from "./payment.repository";
@@ -214,8 +215,16 @@ export class PaymentService {
PaymentReferenceType.SHIPMENT, PaymentReferenceType.SHIPMENT,
referenceId, referenceId,
); );
logCtx(
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
{ path: "payment.reconcile" },
);
return { paid: result.paid, unverifiable: result.unverifiable }; return { paid: result.paid, unverifiable: result.unverifiable };
} catch (err) { } catch (err) {
logCtx(
{ referenceId, unverifiable: true, error: (err as Error).message },
{ path: "payment.reconcile" },
);
this.logger.warn( this.logger.warn(
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`, `Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
); );
@@ -224,6 +233,18 @@ export class PaymentService {
} }
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> { async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
logCtx(
{
referenceId: input.referenceId,
source: input.source,
orderRef: input.orderRef,
method: input.method,
amountMinor: input.amountMinor,
currency: input.currency,
platform: input.platform,
},
{ path: "payment.initiate" },
);
try { try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL; const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
@@ -268,6 +289,17 @@ export class PaymentService {
const intent = await this.upsertIntent(input, snapshot); const intent = await this.upsertIntent(input, snapshot);
logCtx(
{
intentId: intent.id,
providerStatus: snapshot.status,
providerTxnId: snapshot.providerTxnId,
merchantOrderId: snapshot.merchantOrderId,
immediateSuccess,
},
{ path: "payment.initiate" },
);
if (immediateSuccess) { if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles // Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice), // inline once it has stored intentId on the invoice (see payInvoice),
@@ -428,6 +460,17 @@ export class PaymentService {
otp, otp,
); );
logCtx(
{
intentId: local.id,
refId: local.refId,
gatewayIntentId: snapshot.intentId,
providerStatus: confirmed.status,
failureCode: confirmed.failureCode,
},
{ path: "payment.confirmOtp" },
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, { await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId, providerTxnId: confirmed.providerTxnId,
@@ -460,6 +503,16 @@ export class PaymentService {
): Promise<{ alreadyFinalized: boolean }> { ): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId }); const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found"); if (!intent) throw new NotFoundException("PaymentIntent not found");
logCtx(
{
intentId,
refId: intent.refId,
priorStatus: intent.status,
providerTxnId: opts.providerTxnId,
notifyBilling: opts.notify !== false,
},
{ path: "payment.settle" },
);
if (intent.status === "success") { if (intent.status === "success") {
// Still notify billing: a prior delivery may have flipped the intent to // Still notify billing: a prior delivery may have flipped the intent to
// success and then died before the invoice settled (the two steps are not // success and then died before the invoice settled (the two steps are not
@@ -471,6 +524,7 @@ export class PaymentService {
opts.paidAt ?? intent.paidAt ?? undefined, opts.paidAt ?? intent.paidAt ?? undefined,
); );
} }
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
return { alreadyFinalized: true }; return { alreadyFinalized: true };
} }
@@ -507,6 +561,15 @@ export class PaymentService {
referenceId: string, referenceId: string,
): Promise<{ acknowledged: boolean }> { ): Promise<{ acknowledged: boolean }> {
const intent = await this.paymentRepo.findOneBy({ refId: referenceId }); const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
logCtx(
{
referenceId,
intentId: intent?.id,
intentStatus: intent?.status ?? "none",
method: intent?.method,
},
{ path: "payment.successRedirect" },
);
if (!intent || intent.method === "cbe-bill") { if (!intent || intent.method === "cbe-bill") {
return { acknowledged: false }; return { acknowledged: false };
} }
@@ -533,6 +596,16 @@ export class PaymentService {
}): Promise<void> { }): Promise<void> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found"); if (!intent) throw new NotFoundException("PaymentIntent not found");
logCtx(
{
intentId: intent.id,
refId: intent.refId,
priorStatus: intent.status,
failureCode: input.failureCode,
failureMessage: input.failureMessage,
},
{ path: "payment.failed" },
);
if (intent.status === "success" || intent.status === "canceled") return; if (intent.status === "success" || intent.status === "canceled") return;
await this.paymentRepo.update( await this.paymentRepo.update(

View File

@@ -15,21 +15,21 @@ export class StampSettingsController {
constructor(private readonly service: StampSettingsService) {} constructor(private readonly service: StampSettingsService) {}
@Get() @Get()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin]) @BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" }) @ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
get() { get() {
return this.service.getView(); return this.service.getView();
} }
@Put() @Put()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) @BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the company stamp" }) @ApiOperation({ summary: "Replace the company stamp" })
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) { update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null); return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
} }
@Delete() @Delete()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) @BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({ @ApiOperation({
summary: "Clear the company stamp (invoices fall back to the plain seal)", summary: "Clear the company stamp (invoices fall back to the plain seal)",
}) })

View File

@@ -1190,22 +1190,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm( perm(
"b4b00002-0001-4000-8000-000000000001", "b4b00002-0001-4000-8000-000000000001",
"edr_freight_app:settings:stamp:view", "edr_freight_app:settings:stamp:view",
"View stamp settings", "View the company stamp",
), ),
perm( perm(
"b4b00002-0001-4000-8000-000000000002", "b4b00002-0001-4000-8000-000000000002",
"edr_freight_app:settings:stamp:manage", "edr_freight_app:settings:stamp:manage",
"Manage stamp settings", "Manage the company stamp",
), ),
// The per-officer approval teeter (ማህተም) — an individual's own stamp +
// signature, not the company seal. It used to ride on settings:stamp:*, which
// now gates the ONE company stamp; this key was split out when the two were
// untangled. `settings:invoice_stamp:*` retired at the same time: it gated the
// company stamp before the fold and is deliberately left orphaned in any DB
// that already seeded it (the seeder upserts by key and never deletes).
perm( perm(
"b4b00003-0001-4000-8000-000000000001", "b4b00003-0001-4000-8000-000000000001",
"edr_freight_app:settings:invoice_stamp:view", "edr_freight_app:settings:teeter:view",
"View invoice stamp settings", "View own approval teeter and signature",
), ),
perm( perm(
"b4b00003-0001-4000-8000-000000000002", "b4b00003-0001-4000-8000-000000000002",
"edr_freight_app:settings:invoice_stamp:manage", "edr_freight_app:settings:teeter:manage",
"Manage invoice stamp settings", "Manage own approval teeter and signature",
), ),
perm( perm(
"b4c00001-0001-4000-8000-000000000001", "b4c00001-0001-4000-8000-000000000001",
@@ -1907,15 +1913,18 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view", view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage", manage: "edr_freight_app:settings:dropdown:manage",
}, },
// The ONE company stamp/seal, applied to every generated document
// (invoices, receipts, warehouse papers, the EDR side of contracts).
stamp: { stamp: {
view: "edr_freight_app:settings:stamp:view", view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage", manage: "edr_freight_app:settings:stamp:manage",
}, },
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate // The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// from `stamp` above, which is the per-employee approval-record teeter. // and NOT the company seal above. Retired: `invoiceStamp`, which used to
invoiceStamp: { // gate the company stamp before the two were untangled.
view: "edr_freight_app:settings:invoice_stamp:view", teeter: {
manage: "edr_freight_app:settings:invoice_stamp:manage", view: "edr_freight_app:settings:teeter:view",
manage: "edr_freight_app:settings:teeter:manage",
}, },
exchangeRate: { exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",

View File

@@ -51,8 +51,7 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage"; import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature"; import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage";
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -786,23 +785,27 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
{/*
The ONE company stamp, for every generated document. The per-officer
teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings
now lives at /user-management/teeter-and-signature — it is a different
thing (an individual's approval stamp), and pairing the two here was
the duplication.
*/}
<Route <Route
path="stamp-settings" path="stamp-settings"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}> <RequirePermission
<StampSettings /> permission={FREIGHT_PERMS.settings.stamp.view}
>
<CompanyStampSettingsPage />
</RequirePermission> </RequirePermission>
} }
/> />
{/* Old URL kept alive so existing links/bookmarks do not 404. */}
<Route <Route
path="invoice-stamp-settings" path="invoice-stamp-settings"
element={ element={<Navigate to="/dashboard/stamp-settings" replace />}
<RequirePermission
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
>
<InvoiceStampSettingsPage />
</RequirePermission>
}
/> />
<Route <Route
path="contract-templates" path="contract-templates"

View File

@@ -0,0 +1,112 @@
import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core";
import type { ReactNode } from "react";
export interface PersonField {
label: string;
value?: string | null;
}
export interface PersonCardProps {
/** OWNER / POA / CONTACT PERSON — the person's role, not their name. */
title: string;
icon?: ReactNode;
/**
* Identity state. `undefined` = this person has no identity check at all
* (contact person), so no badge is rendered rather than a misleading "not
* verified" one.
*/
verified?: boolean;
/** Extra pills after the verification badge (e.g. "Verifies for this company"). */
badges?: ReactNode;
/** Rendered between the header and the fields — alerts, match warnings. */
notice?: ReactNode;
fields: PersonField[];
/** Shown when the API returned nothing for every field. */
emptyMessage: string;
/** Attachments or anything else that belongs to this person. */
children?: ReactNode;
}
/**
* One person in the customer's people column: owner, power of attorney, contact
* person. Empty fields are dropped rather than rendered as "—", so a field the
* API stops sending simply disappears instead of leaving a dead row behind.
*/
export function PersonCard({
title,
icon,
verified,
badges,
notice,
fields,
emptyMessage,
children,
}: PersonCardProps) {
const filled = fields.filter(
(f) => f.value != null && String(f.value).trim(),
);
return (
<Card>
<Stack gap="sm">
<Group gap={8} wrap="wrap">
{icon}
<Text
size="xs"
fw={700}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.06em" }}
>
{title}
</Text>
{verified !== undefined &&
(verified ? (
<Badge size="xs" color="edr-green" variant="light">
Fayda verified
</Badge>
) : (
<Badge size="xs" color="gray" variant="light">
Not verified
</Badge>
))}
{badges}
</Group>
{notice}
{filled.length > 0 ? (
<Stack gap="xs">
{filled.map((f) => (
<Stack key={f.label} gap={0}>
<Text size="xs" c="edr-muted">
{f.label}
</Text>
<Text
size="sm"
c="edr-text"
style={{ wordBreak: "break-word" }}
>
{f.value}
</Text>
</Stack>
))}
</Stack>
) : (
<Text size="sm" c="dimmed">
{emptyMessage}
</Text>
)}
{children && (
<>
<Divider />
{children}
</>
)}
</Stack>
</Card>
);
}
export default PersonCard;

View File

@@ -24,4 +24,9 @@ export {
type ResetPasswordActionProps, type ResetPasswordActionProps,
} from "./ResetPasswordAction"; } from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export {
PersonCard,
type PersonCardProps,
type PersonField,
} from "./PersonCard";
export { TableCard, type TableCardProps } from "./TableCard"; export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -19,6 +19,7 @@ import {
PackageOpen, PackageOpen,
Paperclip, Paperclip,
Receipt, Receipt,
Stamp,
ScrollText, ScrollText,
Send, Send,
Settings, Settings,
@@ -486,17 +487,14 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
permission: FREIGHT_PERMS.settings.dropdown.view, permission: FREIGHT_PERMS.settings.dropdown.view,
}, },
{ {
label: "Stamp settings", // One entry, one stamp. The former "Stamp settings" entry here pointed
// at the per-officer teeter (ማህተም), not a company seal — it moved to
// /user-management/teeter-and-signature.
label: "Company stamp",
href: "/dashboard/stamp-settings", href: "/dashboard/stamp-settings",
icon: <FileSignature />, icon: <Stamp />,
permission: FREIGHT_PERMS.settings.stamp.view, permission: FREIGHT_PERMS.settings.stamp.view,
}, },
{
label: "Invoice stamp",
href: "/dashboard/invoice-stamp-settings",
icon: <Receipt />,
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
},
{ {
label: "Contract templates", label: "Contract templates",
href: "/dashboard/contract-templates", href: "/dashboard/contract-templates",

View File

@@ -328,15 +328,18 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view", view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage", manage: "edr_freight_app:settings:dropdown:manage",
}, },
// The ONE company stamp/seal, applied to every generated document
// (invoices, receipts, warehouse papers, the EDR side of contracts).
stamp: { stamp: {
view: "edr_freight_app:settings:stamp:view", view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage", manage: "edr_freight_app:settings:stamp:manage",
}, },
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate // The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// from `stamp` above, which is the per-employee approval-record teeter. // and NOT the company seal above. Retired: `invoiceStamp`, which used to
invoiceStamp: { // gate the company stamp before the two were untangled.
view: "edr_freight_app:settings:invoice_stamp:view", teeter: {
manage: "edr_freight_app:settings:invoice_stamp:manage", view: "edr_freight_app:settings:teeter:view",
manage: "edr_freight_app:settings:teeter:manage",
}, },
exchangeRate: { exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",

View File

@@ -8,7 +8,7 @@ import {
Card, Card,
Center, Center,
Container, Container,
Divider, Grid,
Group, Group,
Loader, Loader,
SimpleGrid, SimpleGrid,
@@ -21,6 +21,7 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Banknote, Banknote,
Contact,
Download, Download,
Eye, Eye,
FileText, FileText,
@@ -32,6 +33,8 @@ import {
FilePen, FilePen,
Paperclip, Paperclip,
Receipt, Receipt,
UserCheck,
UserRound,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -46,6 +49,7 @@ import {
CompanyTypeBadge, CompanyTypeBadge,
InvoiceStatusBadge, InvoiceStatusBadge,
PaymentStatusBadge, PaymentStatusBadge,
PersonCard,
ProfileApprovalActions, ProfileApprovalActions,
ProfileChips, ProfileChips,
ProfileStatusBadge, ProfileStatusBadge,
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
variant="subtle" variant="subtle"
color="gray" color="gray"
aria-label={`View ${f.name}`} aria-label={`View ${f.name}`}
onClick={() => void fetchViewableFile(f.id, f.name).then(view)} onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
> >
<Eye size={14} /> <Eye size={14} />
</ActionIcon> </ActionIcon>
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
type="button" type="button"
size="xs" size="xs"
lineClamp={1} lineClamp={1}
onClick={() => void fetchViewableFile(f.id, f.name).then(view)} onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
style={{ style={{
maxWidth: 170, maxWidth: 170,
textAlign: "left", textAlign: "left",
@@ -615,7 +623,10 @@ export default function CustomerDetailPage() {
[], [],
); );
const licenseProfiles = (company?.companyProfiles ?? []).filter( /** Never read `company.companyProfiles` directly — an endpoint that stops
* loading the relation would otherwise crash the whole page. */
const profiles = company?.companyProfiles ?? [];
const licenseProfiles = profiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0, (p) => p.licenseFiles && p.licenseFiles.length > 0,
); );
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
[documents], [documents],
); );
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE); const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
const poaFields = [ const hasPoaDetails = [
{ label: "PoA name", value: company?.poaName }, company?.poaName,
{ label: "PoA email", value: company?.poaEmail }, company?.poaEmail,
{ label: "PoA phone", value: company?.poaPhone }, company?.poaPhone,
{ label: "PoA location", value: company?.poaLocation }, company?.poaLocation,
{ label: "PoA address", value: company?.poaAddress }, company?.poaAddress,
]; ].some((v) => v?.trim());
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so // Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces. // this page can never disagree with the rule the API actually enforces.
const identityState = company?.identity; const identityState = company?.identity;
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim()); const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details // A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional. // and DARS delegation paper both — is mandatory rather than optional.
const poaMandatory = (company?.companyProfiles ?? []).some( const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
(p) => p.type === "freight_forwarder",
);
const delegationMissing = const delegationMissing =
company?.identity?.poaDeclared === "yes" && poaLive.length === 0; company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
]} ]}
backTo="/dashboard/customers" backTo="/dashboard/customers"
title={company.name} title={company.name}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" subtitle={`TIN ${company.tin}${
}`} company.country ? ` · ${company.country}` : ""
}`}
meta={ meta={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} /> <CompanyTypeBadge type={company.type} />
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
items={[ items={[
{ {
label: "Profiles", label: "Profiles",
value: company.companyProfiles.length, value: profiles.length,
icon: IdCard, icon: IdCard,
color: "edr-green", color: "edr-green",
}, },
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
: "Pending approval", : "Pending approval",
value: stillOnboarding value: stillOnboarding
? "—" ? "—"
: company.companyProfiles.filter( : profiles.filter((p) => p.status === "pending").length,
(p) => p.status === "pending",
).length,
icon: IdCard, icon: IdCard,
color: "yellow", color: "yellow",
}, },
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
]} ]}
/> />
<Card> <Grid gap="lg" align="flex-start">
<Stack gap="lg"> {/* Company facts — the wide column. People live in the narrow one
<Text fw={600} c="edr-text"> beside it, so nothing about a person is stated twice. */}
Company information <Grid.Col span={{ base: 12, lg: 8 }}>
</Text> <Stack gap="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg"> <Card>
<InfoField label="TIN" value={company.tin} /> <Stack gap="lg">
<InfoField label="VAT number" value={company.vatNumber} /> <Text fw={600} c="edr-text">
<InfoField label="FAN number" value={company.fanNumber} /> Company information
<InfoField </Text>
label="Submitted on" <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
value={formatDate(company.createdAt)} <InfoField label="TIN" value={company.tin} />
/>
<InfoField
label="Approved on"
value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/>
<InfoField
label="Owner identity"
value={
ownerIdentity?.verified
? "Fayda verified"
: ownerIdentity?.passportNumber
? `Passport ${ownerIdentity.passportNumber}`
: "Not verified"
}
/>
<InfoField label="Country" value={company.country} />
<InfoField
label="Nationality"
value={
company.nationality
? humanize(company.nationality)
: undefined
}
/>
{/* Why this company's registration was typed rather than
fetched, and why it carries no business licence. */}
<InfoField
label="Registration"
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} />
<InfoField label="Phone" value={company.phone} />
<Box />
<InfoField
label="Contact person"
value={company.contactPersonName}
/>
<InfoField
label="Contact phone"
value={company.contactPersonPhone}
/>
<Box />
<InfoField label="Owner" value={company.ownerName} />
<InfoField label="Owner email" value={company.ownerEmail} />
<InfoField label="Owner phone" value={company.ownerPhone} />
</SimpleGrid>
</Stack>
</Card>
<Card>
<Stack gap="lg">
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
eTrade registration
</Text>
{hasEtradeRecord ? (
<Badge size="sm" color="edr-green" variant="light">
Verified with eTrade
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
No eTrade record
</Badge>
)}
</Group>
{hasEtradeRecord && (
<ActionIcon
variant="default"
aria-label="Download TIN record"
onClick={() => downloadTinRecord(company)}
>
<Download size={16} />
</ActionIcon>
)}
</Group>
{hasEtradeRecord ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField
label="License number"
value={company.licenceNumber}
/>
<InfoField label="Status" value={company.statusDescription} />
<InfoField
label="Date registered"
value={company.dateRegistered}
/>
<InfoField label="Renewed from" value={company.renewedFrom} />
<InfoField label="Renewal date" value={company.renewalDate} />
<InfoField label="Renewed to" value={company.renewedTo} />
<InfoField label="Region" value={company.region} />
<InfoField label="Zone" value={company.zone} />
<InfoField label="Woreda" value={company.woreda} />
<InfoField label="Kebele" value={company.kebele} />
<InfoField label="House No" value={company.houseNo} />
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No eTrade registration record on file for this customer's
TIN.
</Text>
)}
</Stack>
</Card>
<Card>
<Stack gap="lg">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
Owner identity
</Text>
{identityState?.subject === "owner" && (
<Badge size="sm" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{ownerIdentity?.verified ? (
<Badge size="sm" color="edr-green" variant="light">
Fayda verified
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
Not verified
</Badge>
)}
</Group>
{/* THE check: is the owner the company put forward the person
the eTrade licence actually names? Advisory — eTrade and
Fayda transliterate Amharic names differently, so this is a
prompt to look, not a verdict. */}
{identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>, but this
company recorded <strong>{company.ownerName}</strong>.
</Alert>
) : identityState?.ownerMatchesEtrade === true ? (
<Badge
size="sm"
color="edr-green"
variant="light"
style={{ alignSelf: "flex-start" }}
>
Matches the eTrade licence
</Badge>
) : company.cooperative ? (
<Text size="xs" c="dimmed">
A co-operative union or farm holds no trade licence, so
there is no eTrade record to check the owner against.
</Text>
) : (
<Text size="xs" c="dimmed">
No eTrade manager name on file to compare against.
</Text>
)}
{ownerIdentity?.verified ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="Name" value={ownerIdentity.name} />
<InfoField label="Phone" value={ownerIdentity.phone} />
<InfoField label="Email" value={ownerIdentity.email} />
<InfoField label="Address" value={ownerIdentity.address} />
<InfoField
label="Verified at"
value={formatDate(ownerIdentity.verifiedAt)}
/>
<InfoField
label="Birthdate"
value={ownerIdentity.birthdate}
/>
<InfoField label="Gender" value={ownerIdentity.gender} />
<InfoField
label="Passport number"
value={ownerIdentity.passportNumber}
/>
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
{ownerIdentity?.passportNumber
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
: "The company owner has not verified their identity with Fayda."}
</Text>
)}
</Stack>
</Card>
<Card>
<Stack gap="lg">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
Power of Attorney
</Text>
{poaMandatory && (
<Badge size="xs" color="blue" variant="light">
Required for freight forwarder
</Badge>
)}
</Group>
{delegationMissing ? (
<Badge size="sm" color="red" variant="light">
DARS delegation paper missing
</Badge>
) : poaLive.length > 0 ? (
<Badge size="sm" color="edr-green" variant="light">
DARS delegation paper on file
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
Not provided
</Badge>
)}
</Group>
{hasPoaDetails ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{poaFields.map((f) => (
<InfoField
key={f.label}
label={f.label}
value={f.value}
/>
))}
<InfoField
label="PoA Fayda"
value={
poaIdentity?.verified ? "Verified" : "Not verified"
}
/>
{poaIdentity?.verified && (
<>
<InfoField <InfoField
label="PoA verified at" label="VAT number"
value={formatDate(poaIdentity.verifiedAt)} value={company.vatNumber}
/>
<InfoField label="Country" value={company.country} />
<InfoField
label="Nationality"
value={
company.nationality
? humanize(company.nationality)
: undefined
}
/>
{/* Why this company's registration was typed rather
than fetched, and why it carries no licence. */}
<InfoField
label="Registration"
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Email" value={company.email} />
<InfoField label="Phone" value={company.phone} />
<InfoField label="Website" value={company.website} />
<InfoField
label="Submitted on"
value={formatDate(company.createdAt)}
/> />
<InfoField <InfoField
label="PoA birthdate" label="Approved on"
value={poaIdentity.birthdate} value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/> />
<InfoField label="PoA gender" value={poaIdentity.gender} /> </SimpleGrid>
</> </Stack>
)} </Card>
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No Power of Attorney representative recorded for this
customer.
</Text>
)}
<Divider /> <Card>
<Stack gap="lg">
<Stack gap="sm"> <Group gap="xs" wrap="nowrap" justify="space-between">
<Text <Group gap="xs" wrap="nowrap">
size="xs" <Text fw={600} c="edr-text">
fw={600} eTrade registration
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
DARS delegation paper
</Text>
{documentsQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading documents
</Text>
</Group>
) : documentsQuery.isError ? (
<Group gap="sm">
<Text size="sm" c="red">
Failed to load documents.
</Text>
<Anchor
component="button"
type="button"
size="xs"
onClick={() => void documentsQuery.refetch()}
>
Retry
</Anchor>
</Group>
) : poaDocuments.length === 0 ? (
<Text size="sm" c="dimmed">
No DARS delegation paper uploaded.
</Text>
) : (
poaDocuments.map((doc) => (
<Group key={doc.id} justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Paperclip
size={14}
className="shrink-0 text-edr-muted"
/>
<Anchor
component="button"
type="button"
size="sm"
lineClamp={1}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(view)
}
>
{doc.name}
</Anchor>
<Text size="xs" c="dimmed" className="shrink-0">
{formatBytes(doc.size)} ·{" "}
{formatDate(doc.uploadedAt)}
</Text> </Text>
{doc.code === POA_DELEGATION_PENDING_CODE && ( {hasEtradeRecord ? (
<Badge <Badge size="sm" color="edr-green" variant="light">
size="xs" Verified with eTrade
color="yellow" </Badge>
variant="light" ) : (
className="shrink-0" <Badge size="sm" color="gray" variant="light">
> No eTrade record
Pending approval
</Badge> </Badge>
)} )}
</Group> </Group>
<Group gap={4} wrap="nowrap"> {hasEtradeRecord && (
<ActionIcon <ActionIcon
variant="subtle" variant="default"
color="gray" aria-label="Download TIN record"
aria-label={`Preview ${doc.name}`} onClick={() => downloadTinRecord(company)}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(view)
}
>
<Eye size={16} />
</ActionIcon>
<ActionIcon
component="button"
type="button"
onClick={() =>
void downloadBookingFile(doc.id, doc.name)
}
variant="subtle"
color="gray"
aria-label={`Download ${doc.name}`}
> >
<Download size={16} /> <Download size={16} />
</ActionIcon> </ActionIcon>
</Group> )}
</Group> </Group>
)) {hasEtradeRecord ? (
)} <SimpleGrid
</Stack> cols={{ base: 1, sm: 2, lg: 3 }}
</Stack> spacing="lg"
</Card> >
<InfoField
label="License number"
value={company.licenceNumber}
/>
<InfoField
label="Status"
value={company.statusDescription}
/>
<InfoField
label="Date registered"
value={company.dateRegistered}
/>
<InfoField
label="Renewed from"
value={company.renewedFrom}
/>
<InfoField
label="Renewal date"
value={company.renewalDate}
/>
<InfoField
label="Renewed to"
value={company.renewedTo}
/>
<InfoField label="Region" value={company.region} />
<InfoField label="Zone" value={company.zone} />
<InfoField label="Woreda" value={company.woreda} />
<InfoField label="Kebele" value={company.kebele} />
<InfoField label="House No" value={company.houseNo} />
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No eTrade registration record on file for this
customer's TIN.
</Text>
)}
</Stack>
</Card>
<Card> <Card>
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between"> <Group justify="space-between">
<Text fw={600} c="edr-text"> <Text fw={600} c="edr-text">
Role profiles Role profiles
</Text> </Text>
<ProfileChips profiles={company.companyProfiles} /> <ProfileChips profiles={profiles} />
</Group> </Group>
<Box style={{ overflowX: "auto" }} w="100%"> {/* Narrower than the old full-width layout — the table
<Box miw={1040}> shares the row with the people column now. */}
<DataTable <Box style={{ overflowX: "auto" }} w="100%">
columns={profileColumns} <Box miw={760}>
data={company.companyProfiles} <DataTable
status="success" columns={profileColumns}
emptyMessage="No profiles registered." data={profiles}
containerClassName="border-0 shadow-none bg-transparent" status="success"
/> emptyMessage="No profiles registered."
</Box> containerClassName="border-0 shadow-none bg-transparent"
</Box> />
</Stack> </Box>
</Card> </Box>
</Stack>
</Card>
</Stack>
</Grid.Col>
{/* People: owner, then power of attorney, then contact person —
the order a reviewer checks them in. */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
<PersonCard
title="Owner"
icon={<UserRound size={15} className="text-edr-muted" />}
verified={Boolean(ownerIdentity?.verified)}
badges={
<>
{identityState?.subject === "owner" && (
<Badge size="xs" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{identityState?.ownerMatchesEtrade === true && (
<Badge size="xs" color="edr-green" variant="light">
Matches eTrade licence
</Badge>
)}
</>
}
notice={
/* THE check: is the owner the company put forward the
person the eTrade licence actually names? Advisory —
eTrade and Fayda transliterate Amharic names
differently, so this is a prompt to look, not a
verdict. */
identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
p="xs"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
<Text size="xs">
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>,
but this company recorded{" "}
<strong>{company.ownerName ?? "nobody"}</strong>.
</Text>
</Alert>
) : !ownerIdentity?.verified &&
ownerIdentity?.passportNumber ? (
<Text size="xs" c="dimmed">
Identified by passport rather than Fayda.
</Text>
) : null
}
fields={[
{
label: "Name",
value: company.ownerName ?? ownerIdentity?.name,
},
{
label: "Email",
value: company.ownerEmail ?? ownerIdentity?.email,
},
{
label: "Phone",
value: company.ownerPhone ?? ownerIdentity?.phone,
},
{
label: "Passport number",
value: ownerIdentity?.passportNumber,
},
{ label: "Address", value: ownerIdentity?.address },
{ label: "Birthdate", value: ownerIdentity?.birthdate },
{ label: "Gender", value: ownerIdentity?.gender },
{
label: "Verified on",
value: ownerIdentity?.verifiedAt
? formatDate(ownerIdentity.verifiedAt)
: null,
},
]}
emptyMessage="No owner recorded for this company."
/>
<PersonCard
title="Power of attorney"
icon={<UserCheck size={15} className="text-edr-muted" />}
verified={
// No PoA at all → no badge, rather than a "not verified"
// that reads as a problem where none exists.
hasPoaDetails || identityState?.poaDeclared === "yes"
? Boolean(poaIdentity?.verified)
: undefined
}
badges={
<>
{identityState?.subject === "poa" && (
<Badge size="xs" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{poaMandatory && (
<Badge size="xs" color="blue" variant="light">
Required for freight forwarder
</Badge>
)}
{delegationMissing && (
<Badge size="xs" color="red" variant="light">
Delegation paper missing
</Badge>
)}
</>
}
fields={[
{ label: "Name", value: company.poaName },
{ label: "Email", value: company.poaEmail },
{ label: "Phone", value: company.poaPhone },
{ label: "Location", value: company.poaLocation },
{ label: "Address", value: company.poaAddress },
{ label: "Birthdate", value: poaIdentity?.birthdate },
{ label: "Gender", value: poaIdentity?.gender },
{
label: "Verified on",
value: poaIdentity?.verifiedAt
? formatDate(poaIdentity.verifiedAt)
: null,
},
]}
emptyMessage="No representative recorded for this customer."
>
<Stack gap="xs">
<Text size="xs" c="edr-muted">
DARS delegation paper
</Text>
{documentsQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading
</Text>
</Group>
) : documentsQuery.isError ? (
<Group gap="sm">
<Text size="sm" c="red">
Failed to load documents.
</Text>
<Anchor
component="button"
type="button"
size="xs"
onClick={() => void documentsQuery.refetch()}
>
Retry
</Anchor>
</Group>
) : poaDocuments.length === 0 ? (
<Text size="sm" c="dimmed">
Not uploaded.
</Text>
) : (
poaDocuments.map((doc) => (
<Stack key={doc.id} gap={2}>
<Group gap={6} wrap="nowrap">
<Paperclip
size={13}
className="shrink-0 text-edr-muted"
/>
<Anchor
component="button"
type="button"
size="sm"
lineClamp={1}
style={{ flex: 1, textAlign: "left" }}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
}
>
{doc.name}
</Anchor>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
void fetchViewableFile(doc.id, doc.name).then(
view,
)
}
>
<Eye size={15} />
</ActionIcon>
<ActionIcon
size="sm"
component="button"
type="button"
variant="subtle"
color="gray"
aria-label={`Download ${doc.name}`}
onClick={() =>
void downloadBookingFile(doc.id, doc.name)
}
>
<Download size={15} />
</ActionIcon>
</Group>
<Group gap={6} pl={19} wrap="wrap">
<Text size="xs" c="dimmed">
{formatBytes(doc.size)} ·{" "}
{formatDate(doc.uploadedAt)}
</Text>
{doc.code === POA_DELEGATION_PENDING_CODE && (
<Badge size="xs" color="yellow" variant="light">
Pending approval
</Badge>
)}
</Group>
</Stack>
))
)}
</Stack>
</PersonCard>
<PersonCard
title="Contact person"
icon={<Contact size={15} className="text-edr-muted" />}
fields={[
{ label: "Name", value: company.contactPersonName },
{ label: "Phone", value: company.contactPersonPhone },
]}
emptyMessage="No contact person recorded."
/>
</Stack>
</Grid.Col>
</Grid>
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
error={ error={
bookingsQuery.isError bookingsQuery.isError
? { ? {
message: "Failed to load bookings.", message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(), onRetry: () => void bookingsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
error={ error={
documentsQuery.isError documentsQuery.isError
? { ? {
message: "Failed to load documents.", message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(), onRetry: () => void documentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
error={ error={
paymentsQuery.isError paymentsQuery.isError
? { ? {
message: "Failed to load payments.", message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(), onRetry: () => void paymentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
error={ error={
invoicesQuery.isError invoicesQuery.isError
? { ? {
message: "Failed to load invoices.", message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(), onRetry: () => void invoicesQuery.refetch(),
} }
: undefined : undefined
} }
pagination={{ pagination={{

View File

@@ -17,10 +17,16 @@ import {
} from "@/hooks/useStampSettings"; } from "@/hooks/useStampSettings";
/** /**
* The one company stamp/seal stamped onto every generated invoice/receipt * The ONE company stamp/seal, read by every document path server-side via
* PDF (InvoiceDocumentService). Single global image no per-employee choice. * StampSettingsService: invoices and receipts (InvoiceDocumentService),
* warehouse release + handover papers, and the EDR side of contract signature
* blocks. Single global image no per-employee choice, and staff never upload
* one when signing.
*
* Not to be confused with the per-officer teeter () at
* /user-management/teeter-and-signature, which is genuinely per-person.
*/ */
export default function InvoiceStampSettingsPage() { export default function CompanyStampSettingsPage() {
const { data, isLoading } = useStampSettingsQuery(); const { data, isLoading } = useStampSettingsQuery();
const setStamp = useSetStamp(); const setStamp = useSetStamp();
const clearStamp = useClearStamp(); const clearStamp = useClearStamp();
@@ -47,11 +53,13 @@ export default function InvoiceStampSettingsPage() {
<div className="p-4 w-full max-w-screen-sm mx-auto"> <div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700"> <Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader> <CardHeader>
<CardTitle>Invoice stamp</CardTitle> <CardTitle>Company stamp</CardTitle>
<CardDescription> <CardDescription>
Stamped onto every generated invoice and receipt PDF. Replacing it The single EDR seal, applied to every generated document invoices
here changes it everywhere at once there is no per-invoice or and receipts, warehouse release and handover papers, and the EDR
per-user choice. side of signed contracts. Replacing it here changes it everywhere at
once; there is no per-document, per-invoice or per-employee choice.
Staff do not upload their own.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@@ -3,7 +3,7 @@ import { Navigate, Outlet, Route } from "react-router-dom";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing"; import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
import { isSuperAdmin } from "@/lib/permissions"; import { FREIGHT_PERMS, isSuperAdmin } from "@/lib/permissions";
import { WithPermission } from "@/shared/hooks/useHas"; import { WithPermission } from "@/shared/hooks/useHas";
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers"; import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
import TemplatePage from "@/super-admin/components/templates/components/templates"; import TemplatePage from "@/super-admin/components/templates/components/templates";
@@ -44,6 +44,8 @@ import { SidebarProvider } from "@/shared/common/ui/sidebar";
import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext"; import { AuthProvider as UmAuthProvider } from "@/shared/context/AuthContext";
import { PermissionProvider } from "@/shared/context/PermissionContext"; import { PermissionProvider } from "@/shared/context/PermissionContext";
import UserManagementPage from "@/pages/UserManagementPage"; import UserManagementPage from "@/pages/UserManagementPage";
import { RequirePermission } from "@/components/auth/RequirePermission";
import UploadTeeterAndSignature from "@/record-management/components/Settings/uploadTeeterandSingature";
/** /**
* Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts * Provider shell for the vendored IAM UI. Feeds its Auth + Permission contexts
@@ -138,6 +140,29 @@ export function UserManagementRoutes(): ReactElement {
path="user-management/position-management" path="user-management/position-management"
element={<PositionManagementPage />} element={<PositionManagementPage />}
/> />
{/*
The per-officer teeter (ማህተም) + signature upload. This
is NOT the company stamp: it is the individual approval
stamp a record officer applies to records, locale-aware
(am/en) and genuinely per-person. It used to sit at
/dashboard/stamp-settings under Settings, next to the
single global company stamp, which read as duplication.
Gated by settings:teeter:*, split out of settings:stamp:*
when the two were untangled — settings:stamp:* now means
the company stamp, so anyone who held it for the teeter
needs the new key granted.
*/}
<Route
path="user-management/teeter-and-signature"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.teeter.view}
>
<UploadTeeterAndSignature />
</RequirePermission>
}
/>
<Route <Route
path="user-management/migrated-records-management" path="user-management/migrated-records-management"
element={<MigratedDataManagementPage />} element={<MigratedDataManagementPage />}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { Injectable, Logger, NestMiddleware } from "@nestjs/common"; import { Injectable, NestMiddleware } from "@nestjs/common";
import { import {
RequestLogContext, RequestLogContext,
@@ -58,9 +58,6 @@ const userId = (req: LoggedRequest): string | undefined => {
*/ */
@Injectable() @Injectable()
export class RequestLogMiddleware implements NestMiddleware { export class RequestLogMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
private readonly canonical = new Logger("request");
use(req: LoggedRequest, res: LoggedResponse, next: () => void): void { use(req: LoggedRequest, res: LoggedResponse, next: () => void): void {
const start = Date.now(); const start = Date.now();
const requestId = header(req, "x-request-id") ?? randomUUID(); const requestId = header(req, "x-request-id") ?? randomUUID();
@@ -86,10 +83,14 @@ export class RequestLogMiddleware implements NestMiddleware {
const status = res.statusCode; const status = res.statusCode;
const durationMs = Date.now() - start; const durationMs = Date.now() - start;
this.logger.log(`${req.method} ${url} ${status} ${durationMs}ms`);
const line = { const line = {
...ctx, ...ctx,
// Fields the Nest console prefix used to carry. They are IN the JSON
// now because this line is written raw (see below) — a log shipper
// needs level and time as parsable fields, not as console decoration.
time: new Date().toISOString(),
level: status >= 500 ? "error" : status >= 400 ? "warn" : "info",
logger: "request",
type: "http_request", type: "http_request",
requestId, requestId,
method: req.method, method: req.method,
@@ -113,6 +114,9 @@ export class RequestLogMiddleware implements NestMiddleware {
json = JSON.stringify(line); json = JSON.stringify(line);
} catch { } catch {
json = JSON.stringify({ json = JSON.stringify({
time: line.time,
level: line.level,
logger: "request",
type: "http_request", type: "http_request",
requestId, requestId,
method: req.method, method: req.method,
@@ -123,9 +127,11 @@ export class RequestLogMiddleware implements NestMiddleware {
}); });
} }
if (status >= 500) this.canonical.error(json); // Written raw, NOT through Nest's Logger: the console logger wraps every
else if (status >= 400) this.canonical.warn(json); // message in "[Nest] pid - date LEVEL [ctx] …", which makes the line
else this.canonical.log(json); // un-parsable as JSON. Same destination the Nest logger writes to
// (stdout, stderr for errors) — only the decoration is dropped.
(status >= 500 ? process.stderr : process.stdout).write(`${json}\n`);
}; };
res.on("finish", emit); res.on("finish", emit);

View File

@@ -7,9 +7,16 @@ import {
Repository, Repository,
} from "typeorm"; } from "typeorm";
import { logCtx } from "../logging/request-context";
export abstract class BaseRepository<T extends ObjectLiteral> { export abstract class BaseRepository<T extends ObjectLiteral> {
protected constructor(protected readonly repository: Repository<T>) {} protected constructor(protected readonly repository: Repository<T>) {}
/** Table name, for the write trail on the canonical request log line. */
private get table(): string {
return this.repository.metadata.tableName;
}
/** Find a single entity by its primary key. */ /** Find a single entity by its primary key. */
async findById( async findById(
id: string, id: string,
@@ -34,22 +41,43 @@ export abstract class BaseRepository<T extends ObjectLiteral> {
/** Create and persist a new entity. */ /** Create and persist a new entity. */
async create(data: DeepPartial<T>): Promise<T> { async create(data: DeepPartial<T>): Promise<T> {
const entity = this.repository.create(data); const entity = this.repository.create(data);
return this.repository.save(entity); const saved = await this.repository.save(entity);
logCtx(1, { path: `db.created.${this.table}`, mode: "count" });
return saved;
} }
/** Patch an entity in place and return the reloaded row. */ /**
* Patch an entity in place and return the reloaded row.
*
* Every domain status machine in this app (booking, contract, wagon,
* warehouse, transfer request…) lands here, so this is the one place that can
* record "what state did this request actually move" without every service
* remembering to log it.
*/
async update(id: string, data: DeepPartial<T>): Promise<T | null> { async update(id: string, data: DeepPartial<T>): Promise<T | null> {
await this.repository.update(id, data as never); await this.repository.update(id, data as never);
logCtx(1, { path: `db.updated.${this.table}`, mode: "count" });
if (data && typeof data === "object" && "status" in data) {
logCtx(
{ entity: this.table, id, status: (data as { status: unknown }).status },
{ path: "statusChanges", mode: "push" },
);
}
return this.findById(id); return this.findById(id);
} }
/** Soft-delete an entity by primary key (sets deleted_at). */ /** Soft-delete an entity by primary key (sets deleted_at). */
async softDelete(id: string): Promise<void> { async softDelete(id: string): Promise<void> {
await this.repository.softDelete(id); await this.repository.softDelete(id);
logCtx({ entity: this.table, id }, { path: "deleted", mode: "push" });
} }
/** Permanently delete an entity. Avoid in domain code; prefer softDelete. */ /** Permanently delete an entity. Avoid in domain code; prefer softDelete. */
async hardDelete(id: string): Promise<void> { async hardDelete(id: string): Promise<void> {
await this.repository.delete(id); await this.repository.delete(id);
logCtx(
{ entity: this.table, id, hard: true },
{ path: "deleted", mode: "push" },
);
} }
} }