merge conflict

This commit is contained in:
Marshal
2026-08-12 11:24:22 +00:00
27 changed files with 1076 additions and 560 deletions

View File

@@ -1,5 +1,7 @@
import { Logger } from "@nestjs/common";
import type { Repository } from "typeorm";
import {
BaseRepository,
RequestLogMiddleware,
getLogContext,
logCtx,
@@ -47,10 +49,13 @@ describe("logCtx", () => {
describe("RequestLogMiddleware", () => {
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[] = [];
jest
.spyOn(Logger.prototype, "warn")
.mockImplementation((m) => lines.push(String(m)));
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
lines.push(String(chunk));
return true;
});
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
const listeners: Record<string, () => void> = {};
@@ -82,7 +87,11 @@ describe("RequestLogMiddleware", () => {
listeners.close(); // aborts/close after finish must not double-log
expect(lines).toHaveLength(1);
expect(lines[0].endsWith("\n")).toBe(true);
expect(lines[0].startsWith("{")).toBe(true);
expect(JSON.parse(lines[0])).toMatchObject({
level: "warn",
logger: "request",
type: "http_request",
requestId: "req-42",
method: "POST",
@@ -100,3 +109,38 @@ describe("RequestLogMiddleware", () => {
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,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
@@ -951,6 +952,24 @@ export class BillingService {
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;
return {
result: updated,
@@ -1320,6 +1339,21 @@ export class BillingService {
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
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
@@ -1443,7 +1477,24 @@ export class BillingService {
// first on DESC, which would hand back an unissued invoice.
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[] = [
...OPEN_STATUSES,
@@ -1453,6 +1504,12 @@ export class BillingService {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with
// 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) {
this.logger.error(
`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 { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
authorId?: string,
): Promise<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(
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
);

View File

@@ -9,7 +9,7 @@ import {
NotFoundException,
} from '@nestjs/common';
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 { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
@@ -2115,6 +2115,21 @@ export class BookingsService {
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) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {

View File

@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
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 { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -1084,6 +1084,19 @@ export class ContractTransitionService {
): Promise<void> {
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
// omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied.

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import { YardCountry } from '@edr/types';
//
@@ -820,6 +820,17 @@ export class ContractsService {
contract.status = 'EXPIRED';
}
}
// 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) {
contract.files = await Promise.all(

View File

@@ -1,6 +1,7 @@
// otp.service.ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { randomInt } from "node:crypto";
import { OtpRepository } from "./otp.repository";
@@ -317,6 +318,13 @@ export class OtpService {
}`;
if (result === "ok") this.logger.log(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 { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
import { logCtx } from "@edr/api-common";
import {
InitiatePaymentRequest,
PaymentIntentSnapshot,
@@ -111,6 +112,16 @@ export class PaymentClientService {
body?: unknown,
): Promise<T> {
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 {
const response = await firstValueFrom(
this.http.request<T>({
@@ -122,9 +133,11 @@ export class PaymentClientService {
: {},
}),
);
trace({ status: response.status });
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
trace({ status: err.response.status });
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
@@ -136,6 +149,7 @@ export class PaymentClientService {
}
const message =
err instanceof Error && err.message ? err.message : String(err);
trace({ unreachable: true, error: message });
this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`,
);

View File

@@ -7,6 +7,7 @@ import {
Logger,
NotFoundException,
} from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
@@ -214,8 +215,16 @@ export class PaymentService {
PaymentReferenceType.SHIPMENT,
referenceId,
);
logCtx(
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
{ path: "payment.reconcile" },
);
return { paid: result.paid, unverifiable: result.unverifiable };
} catch (err) {
logCtx(
{ referenceId, unverifiable: true, error: (err as Error).message },
{ path: "payment.reconcile" },
);
this.logger.warn(
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
);
@@ -224,6 +233,18 @@ export class PaymentService {
}
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 {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// 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);
logCtx(
{
intentId: intent.id,
providerStatus: snapshot.status,
providerTxnId: snapshot.providerTxnId,
merchantOrderId: snapshot.merchantOrderId,
immediateSuccess,
},
{ path: "payment.initiate" },
);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
@@ -428,6 +460,17 @@ export class PaymentService {
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) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
@@ -460,6 +503,16 @@ export class PaymentService {
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
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") {
// 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
@@ -471,6 +524,7 @@ export class PaymentService {
opts.paidAt ?? intent.paidAt ?? undefined,
);
}
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
return { alreadyFinalized: true };
}
@@ -507,6 +561,15 @@ export class PaymentService {
referenceId: string,
): Promise<{ acknowledged: boolean }> {
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") {
return { acknowledged: false };
}
@@ -533,6 +596,16 @@ export class PaymentService {
}): Promise<void> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
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;
await this.paymentRepo.update(

View File

@@ -15,21 +15,21 @@ export class StampSettingsController {
constructor(private readonly service: StampSettingsService) {}
@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" })
get() {
return this.service.getView();
}
@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" })
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
}
@Delete()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Clear the company stamp (invoices fall back to the plain seal)",
})

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
@@ -165,10 +166,13 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
* actually produce is a backoffice decision, edited in the file-settings editor.
*/
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
// First on purpose: only the first field of a new set is seeded, and this is
// the paper that distinguishes a co-operative from every other company.
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
fileKey: "cooperative_registration_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate",
helpText:
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -177,10 +181,9 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
displayOrder: 1,
},
{
fileKey: "cooperative_registration_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate",
helpText:
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -662,16 +665,15 @@ export class FileUploadSettingsSeeder {
async run() {
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
// Seed only into an empty table: any existing rows (including
// soft-deleted ones, which would still conflict on the unique `code`)
// mean the data is admin-managed, so leave it untouched.
const existing = await settingRepository.count({ withDeleted: true });
if (existing > 0) {
this.logger.log(
`file_upload_settings already has ${existing} rows — skipping seed`,
);
return;
}
// Seed per CODE, not "only into an empty table". An existing row is
// admin-managed and never touched — including a soft-deleted one, which
// means the set was removed on purpose (and would still conflict on the
// unique `code`). What the table-wide check got wrong is the other half: a
// set added to this file after the first boot could never reach a database
// that already held the others, so it existed in code and nowhere else.
const existingCodes = new Set(
(await settingRepository.find({ withDeleted: true })).map((s) => s.code),
);
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
@@ -701,20 +703,41 @@ export class FileUploadSettingsSeeder {
})),
];
// Insert setting rows only — no FileUploadField rows. Fields start empty
// and are configured from the backoffice file-settings editor; the field
// definitions above are kept as reference defaults.
await settingRepository.insert(
allSettings.map((documentSetting) => ({
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
})),
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
if (missing.length === 0) {
this.logger.log("file upload settings up to date — nothing to seed");
return;
}
const inserted = await settingRepository.save(
missing.map((documentSetting) =>
settingRepository.create({
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
}),
),
);
// A brand-new set gets exactly ONE field: its first reference default. The
// rest of the list above stays documentation — what a set actually asks for
// is a backoffice decision, edited in the file-settings editor. Seeding one
// means a set is never born empty (an empty set silently requires nothing),
// while leaving the admin a single row to extend rather than a list to prune.
const fieldRepository = this.dataSource.getRepository(FileUploadField);
const firstFields = inserted.flatMap((setting) => {
const reference = missing.find((s) => s.code === setting.code)?.fields[0];
return reference
? [fieldRepository.create({ ...reference, settingId: setting.id })]
: [];
});
if (firstFields.length > 0) await fieldRepository.save(firstFields);
this.logger.log(
`Seeded ${allSettings.length} file upload settings with empty fields`,
`Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing
.map((s) => s.code)
.join(", ")}`,
);
}
}

View File

@@ -1190,22 +1190,28 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm(
"b4b00002-0001-4000-8000-000000000001",
"edr_freight_app:settings:stamp:view",
"View stamp settings",
"View the company stamp",
),
perm(
"b4b00002-0001-4000-8000-000000000002",
"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(
"b4b00003-0001-4000-8000-000000000001",
"edr_freight_app:settings:invoice_stamp:view",
"View invoice stamp settings",
"edr_freight_app:settings:teeter:view",
"View own approval teeter and signature",
),
perm(
"b4b00003-0001-4000-8000-000000000002",
"edr_freight_app:settings:invoice_stamp:manage",
"Manage invoice stamp settings",
"edr_freight_app:settings:teeter:manage",
"Manage own approval teeter and signature",
),
perm(
"b4c00001-0001-4000-8000-000000000001",
@@ -1907,15 +1913,18 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
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: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
// The per-officer approval teeter (ማህተም) + signature — genuinely per-person,
// and NOT the company seal above. Retired: `invoiceStamp`, which used to
// gate the company stamp before the two were untangled.
teeter: {
view: "edr_freight_app:settings:teeter:view",
manage: "edr_freight_app:settings:teeter:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",