mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
resolve merge conflict
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { DataSourceOptions } from "typeorm";
|
||||
import { join, dirname } from "path";
|
||||
import {
|
||||
DefaultPosition,
|
||||
@@ -44,8 +45,30 @@ import {
|
||||
NotificationTemplate,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
||||
import { UnitSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-setting.entity";
|
||||
import { UnitDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-detail.entity";
|
||||
import { OrganizationDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-detail.entity";
|
||||
import { UnitCluster } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-cluster.entity";
|
||||
import { Location } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location.entity";
|
||||
import { LocationType } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location-type.entity";
|
||||
import { DelegationTerminationReason } from "@tria-plc/iamapi-common/entities/iam/organization-structure/delegation-termination-reason.entity";
|
||||
import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee-position-active-period.entity";
|
||||
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";
|
||||
|
||||
const iamEntities = [
|
||||
UnitSetting,
|
||||
UnitDetail,
|
||||
OrganizationDetail,
|
||||
UnitCluster,
|
||||
Location,
|
||||
LocationType,
|
||||
DelegationTerminationReason,
|
||||
EmployeePositionActivePeriod,
|
||||
UnitConfiguration,
|
||||
Site,
|
||||
SiteSetting,
|
||||
DefaultPosition,
|
||||
DefaultUnit,
|
||||
EmployeePosition,
|
||||
@@ -95,7 +118,7 @@ const iamMigrationsGlob = join(
|
||||
);
|
||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
export function buildDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
@@ -111,17 +134,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
// The search_path is instead applied per-connection via a pool `connect`
|
||||
// handler in app.module.ts (see setPoolSearchPath).
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
// IAM schema + tables must be created before freight migrations
|
||||
iamMigrationsGlob,
|
||||
freightMigrationsGlob,
|
||||
],
|
||||
migrationsRun: true,
|
||||
migrationsTransactionMode: "each",
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
logging:
|
||||
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: true,
|
||||
migrationsRun: false,
|
||||
}));
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
// apps/edr-freight-api/src/data-source.ts
|
||||
import 'dotenv/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||
import "dotenv/config";
|
||||
import { DataSource } from "typeorm";
|
||||
import { buildDataSourceOptions } from "./config/database.config";
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: Number(process.env.DB_PORT ?? 5433),
|
||||
username: process.env.DB_USER ?? 'postgres',
|
||||
password: process.env.DB_PASSWORD ?? '',
|
||||
database: process.env.DB_NAME ?? 'edr_freight',
|
||||
schema: 'freight', // default schema for entities without an explicit schema
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
synchronize: false,
|
||||
logging: process.env.TYPEORM_LOGGING === 'true',
|
||||
});
|
||||
export const AppDataSource = new DataSource(buildDataSourceOptions());
|
||||
|
||||
// Optional: call ensurePostgresSchemas before initializing
|
||||
// But you can also run it separately.
|
||||
export default AppDataSource;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface {
|
||||
name = "AddCbeBillPaymentMethod3050000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen
|
||||
// per the local convention (see 2460000000000-AddCacBankPaymentMethod).
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values directly.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Pending→Active is the only company-level approval event; `updatedAt` can't
|
||||
* stand in for it since any field edit bumps that too. Nullable — existing
|
||||
* companies (approved before this column existed) have no recorded moment.
|
||||
*/
|
||||
export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface {
|
||||
name = "AddApprovedAtToCompanies3120000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS approved_at timestamptz`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS approved_at`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Append-only audit of company edits made before the company reaches Active
|
||||
* (the onboarding phase) — that write path has no approval gate and, until
|
||||
* now, left no trace of what changed (e.g. a phone number or a document).
|
||||
*/
|
||||
export class CreateCompanyRevisions3130000000000 implements MigrationInterface {
|
||||
name = 'CreateCompanyRevisions3130000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'company_revisions',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'company_id', type: 'uuid' },
|
||||
{ name: 'actor_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'summary', type: 'varchar', length: '255' },
|
||||
{ name: 'changes', type: 'jsonb', default: "'[]'::jsonb" },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['company_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.company_revisions',
|
||||
new TableIndex({ name: 'idx_company_revisions_company', columnNames: ['company_id'] }),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.company_revisions', true);
|
||||
}
|
||||
}
|
||||
@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
|
||||
expect(manager.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
const openInvoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: "PREPAID",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
currency: "USD",
|
||||
balanceAmount: 500,
|
||||
totalAmount: 500,
|
||||
paymentId: "intent-1",
|
||||
dueAt: null,
|
||||
};
|
||||
|
||||
const build = (payment: Record<string, unknown>) => {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(openInvoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ getRepository: () => repo } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
makeEvents() as never,
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
|
||||
const initiate = jest.fn();
|
||||
const { service } = build({ initiate });
|
||||
|
||||
await expect(
|
||||
service.payInvoice("inv-1", { method: "CAC_BANK" }),
|
||||
).rejects.toThrow(/payerAccount/);
|
||||
expect(initiate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
|
||||
const handlePaymentEvent = jest.fn();
|
||||
const { service } = build({
|
||||
initiate: jest.fn().mockResolvedValue({
|
||||
intentId: "intent-1",
|
||||
immediateSuccess: false,
|
||||
response: {
|
||||
intentId: "intent-1",
|
||||
status: "REQUIRES_ACTION",
|
||||
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
|
||||
},
|
||||
}),
|
||||
handlePaymentEvent,
|
||||
});
|
||||
|
||||
await service.payInvoice("inv-1", {
|
||||
method: "CAC_BANK",
|
||||
payerAccount: "77123456",
|
||||
});
|
||||
|
||||
expect(handlePaymentEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms the OTP against the intent stamped on the invoice", async () => {
|
||||
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
|
||||
const { service } = build({ confirmOtp });
|
||||
|
||||
await service.confirmInvoiceOtp("inv-1", "123456");
|
||||
|
||||
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/**
|
||||
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
|
||||
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
|
||||
* so "cancelled" must not stand in for "already paid" or "refunded".
|
||||
*/
|
||||
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
|
||||
switch (status) {
|
||||
case Freight.InvoiceStatus.Paid:
|
||||
return "ALREADY_PAID";
|
||||
case Freight.InvoiceStatus.Refunded:
|
||||
return "REFUNDED";
|
||||
case Freight.InvoiceStatus.Cancelled:
|
||||
return "CANCELLED";
|
||||
case Freight.InvoiceStatus.Expired:
|
||||
return "EXPIRED";
|
||||
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
|
||||
default:
|
||||
return "NOT_PAYABLE";
|
||||
}
|
||||
}
|
||||
|
||||
/** A single line to bill on a generated invoice. */
|
||||
export interface InvoiceLineInput {
|
||||
chargeType: string;
|
||||
@@ -352,6 +373,34 @@ export class BillingService {
|
||||
return this.payInvoice(id, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the CAC Bank OTP for one of the customer's own invoices
|
||||
* (ownership-checked). Settlement of the invoice happens inside the payment
|
||||
* service when the OTP succeeds.
|
||||
*/
|
||||
async confirmInvoiceOtpForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
otp: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
await this.findByIdForUser(id, userId);
|
||||
return this.confirmInvoiceOtp(id, otp);
|
||||
}
|
||||
|
||||
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
|
||||
async confirmInvoiceOtp(
|
||||
invoiceId: string,
|
||||
otp: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
const invoice = await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.findOne({ where: { id: invoiceId } });
|
||||
if (!invoice?.paymentId) {
|
||||
throw new NotFoundException("No payment to confirm for this invoice");
|
||||
}
|
||||
return this.payment.confirmOtp(invoice.paymentId, otp);
|
||||
}
|
||||
|
||||
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||
async documentForUser(
|
||||
id: string,
|
||||
@@ -1006,6 +1055,7 @@ export class BillingService {
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(
|
||||
@@ -1035,6 +1085,17 @@ export class BillingService {
|
||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
||||
}
|
||||
|
||||
// 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 (
|
||||
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
|
||||
!opts.payerAccount?.trim()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"payerAccount (mobile number) is required for CAC Bank",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.payment.initiate({
|
||||
referenceId: invoice.sourceId,
|
||||
source: invoice.source,
|
||||
@@ -1051,6 +1112,10 @@ export class BillingService {
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
|
||||
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
|
||||
payerName: invoice.company?.name,
|
||||
expiresAt: invoice.dueAt?.toISOString(),
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
@@ -1062,7 +1127,13 @@ export class BillingService {
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
if (!result.immediateSuccess) {
|
||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||
// code — so the demo shortcut must never fire for it.
|
||||
if (
|
||||
!result.immediateSuccess &&
|
||||
result.response.clientAction?.type !== "COLLECT_OTP" &&
|
||||
opts.method !== "CBE_BILL"
|
||||
) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
@@ -1107,4 +1178,67 @@ export class BillingService {
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
|
||||
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
|
||||
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
|
||||
*/
|
||||
async billQuery(referenceId: string): Promise<{
|
||||
stillPayable: boolean;
|
||||
payerName?: string | null;
|
||||
currentAmountMinor?: number | null;
|
||||
currency?: string | null;
|
||||
reason?: string | null;
|
||||
paymentReason?: string | null;
|
||||
}> {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const open = await repo.findOne({
|
||||
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
|
||||
if (open) {
|
||||
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
payerName: open.company?.name ?? null,
|
||||
currentAmountMinor: balance,
|
||||
currency: open.currency,
|
||||
// CBE shows this beside the amount on the confirmation screen — the invoice number
|
||||
// the payer is holding, not our internal reference.
|
||||
paymentReason: `Freight invoice ${open.invoiceNumber}`,
|
||||
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
|
||||
// expired, and that is what the payer at the CBE counter must be told.
|
||||
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
|
||||
};
|
||||
}
|
||||
|
||||
const latest = await repo.findOne({
|
||||
where: { sourceId: referenceId },
|
||||
relations: { company: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
// A bill reference whose invoice no longer exists at all — a data problem, not a
|
||||
// cancellation the payer did anything to cause.
|
||||
if (!latest) {
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: null,
|
||||
currentAmountMinor: null,
|
||||
currency: null,
|
||||
reason: "NOT_FOUND",
|
||||
};
|
||||
}
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: latest.company?.name ?? null,
|
||||
currentAmountMinor: Math.round(Number(latest.totalAmount)),
|
||||
currency: latest.currency,
|
||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||
reason: closedInvoiceReason(latest.status),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */
|
||||
export class ConfirmOtpDto {
|
||||
@ApiProperty({ description: "One-time password SMSed by the bank." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
/** Gateway options for paying an invoice from the customer portal. */
|
||||
export class PayInvoiceDto {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { sendPdf } from "./billing.controller";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||
|
||||
/**
|
||||
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||
@@ -96,4 +96,20 @@ export class PortalBillingController {
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("my-invoices/:id/confirm")
|
||||
@ApiOperation({
|
||||
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
|
||||
})
|
||||
confirmOtp(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: ConfirmOtpDto,
|
||||
) {
|
||||
return this.billingService.confirmInvoiceOtpForUser(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.otp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,26 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/carriage-acceptance-sheet')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
|
||||
})
|
||||
async carriageAcceptanceSheet(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||
async listCustomerTrucks(
|
||||
|
||||
@@ -70,6 +70,29 @@ export interface PaginatedBookings {
|
||||
};
|
||||
}
|
||||
|
||||
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
|
||||
interface CarriageAcceptanceWagonRow {
|
||||
sequenceNo: number;
|
||||
wagonType: string | null;
|
||||
wagonNumber: string | null;
|
||||
tareWeightTons: string | null;
|
||||
equatedLength: string | null;
|
||||
loadCapacityTons: string | null;
|
||||
allocatedWeightTons: string | null;
|
||||
trainNumber: string | null;
|
||||
departureAt: Date | null;
|
||||
marshalledAt: string | null;
|
||||
arrivalAt: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
}
|
||||
|
||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||
interface CarriageAcceptanceReceivedRow {
|
||||
allocatedWeightTons: string | null;
|
||||
containerNumbers: string | null;
|
||||
}
|
||||
|
||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
@@ -208,6 +231,284 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
||||
* the wagons are allocated before marshalling (import), so it is only available
|
||||
* once the booking has wagon allocations.
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
COALESCE(wt.code, wt.name) AS "wagonType",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
wt.tare_weight_tons AS "tareWeightTons",
|
||||
tsw.length_meters AS "equatedLength",
|
||||
tsw.capacity_tons AS "loadCapacityTons",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
s.train_number AS "trainNumber",
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.train_schedules s
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
|
||||
// takes custody of the cargo when it receives it, and the customer is handed
|
||||
// this sheet then — before the booking is put on a train. So a received
|
||||
// export booking gets its sheet off the received cargo, wagon columns blank
|
||||
// until the consist exists. Import keeps the allocation gate: nothing is
|
||||
// accepted from the customer before the wagons carry it.
|
||||
//
|
||||
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
|
||||
// and never appears on this sheet — it is only the signal that EDR has taken
|
||||
// the cargo, which is what the customer's sheet attests to.
|
||||
const pendingWagons = wagons.length === 0;
|
||||
if (pendingWagons) {
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] =
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
|
||||
ORDER BY inv.created_at`,
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
if (receivedLines.length === 0) {
|
||||
throw new BadRequestException(
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet'
|
||||
: 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
|
||||
);
|
||||
}
|
||||
wagons = receivedLines.map((row, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
wagonType: null,
|
||||
wagonNumber: null,
|
||||
tareWeightTons: null,
|
||||
equatedLength: null,
|
||||
loadCapacityTons: null,
|
||||
allocatedWeightTons: row.allocatedWeightTons,
|
||||
trainNumber: null,
|
||||
departureAt: null,
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons });
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
label: 'carriage acceptance sheet',
|
||||
fallback: (prepared) => buildTabularFallbackPdf(prepared),
|
||||
});
|
||||
return {
|
||||
filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
* remainder so the Price column always sums to the Total Amount on the sheet.
|
||||
*/
|
||||
private splitAmountAcrossWagons(total: number, weights: number[]): number[] {
|
||||
const sum = weights.reduce((acc, w) => acc + w, 0);
|
||||
const shares = weights.map((w) =>
|
||||
Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100,
|
||||
);
|
||||
const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100;
|
||||
shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100;
|
||||
return shares;
|
||||
}
|
||||
|
||||
private buildCarriageAcceptanceSheetHtml(
|
||||
booking: Booking,
|
||||
wagons: CarriageAcceptanceWagonRow[],
|
||||
{ pendingWagons }: { pendingWagons: boolean },
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
|
||||
const money = (v: number) =>
|
||||
v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
|
||||
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
|
||||
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
|
||||
const currency = booking.paymentCurrency ?? 'ETB';
|
||||
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
|
||||
const prices = this.splitAmountAcrossWagons(
|
||||
totalAmount,
|
||||
wagons.map((w) => Number(w.allocatedWeightTons) || 0),
|
||||
);
|
||||
const header = wagons[0];
|
||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||
|
||||
const totals = wagons.reduce(
|
||||
(acc, w) => ({
|
||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||
load: acc.load + (Number(w.allocatedWeightTons) || 0),
|
||||
length: acc.length + (Number(w.equatedLength) || 0),
|
||||
}),
|
||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||
);
|
||||
// A wagon carrying no weight and no container is running empty under this booking.
|
||||
const fullWagons = wagons.filter(
|
||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||
).length;
|
||||
|
||||
const rows = wagons
|
||||
.map(
|
||||
(w, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(w.wagonType)}</td>
|
||||
<td>${esc(w.wagonNumber)}</td>
|
||||
<td class="num">${num(w.tareWeightTons, 2)}</td>
|
||||
<td class="num">${num(w.equatedLength)}</td>
|
||||
<td class="num">${num(w.loadCapacityTons)}</td>
|
||||
<td>${esc(arrivalStation)}</td>
|
||||
<td>${esc(cargoName)}</td>
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</td>
|
||||
<td>${esc(w.sealNumbers)}</td>
|
||||
<td class="num">${money(prices[i])}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Carriage Acceptance Sheet</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||
.subtitle { font-size: 11px; color: #475569; margin-top: 4px; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tfoot td { background: #f8fafc; font-weight: 700; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Carriage Acceptance Sheet</h1>
|
||||
<div class="subtitle">Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Sheet No.
|
||||
<strong>CAS-${esc(booking.reference)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
|
||||
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
|
||||
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
|
||||
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
|
||||
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
|
||||
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">SN</th>
|
||||
<th>Type of Wagon</th>
|
||||
<th>Wagon No.</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Arrival Station</th>
|
||||
<th>Cargo Name</th>
|
||||
<th>Departure Station</th>
|
||||
<th>Container No.</th>
|
||||
<th>Seal No.</th>
|
||||
<th class="num">Price (${esc(currency)})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">${
|
||||
pendingWagons
|
||||
? `Received lines: ${wagons.length} — wagons pending marshalling`
|
||||
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
${
|
||||
pendingWagons
|
||||
? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.`
|
||||
: `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity, container and seal numbers must be verified against the physical consist
|
||||
before the sheet is signed.`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Signed by — EDR operations / date</div>
|
||||
<div class="line">Signed by — customer or agent / date</div>
|
||||
<div class="line">Signed by — marshalling yard / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
/**
|
||||
* An intercity corridor is valid when both yards are Ethiopian and at least
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BookingsService } from './bookings.service';
|
||||
|
||||
// The split is a pure helper on the prototype (never touches `this`), so it can be
|
||||
// exercised without constructing the service and its dependency graph.
|
||||
const split = (total: number, weights: number[]): number[] =>
|
||||
(
|
||||
BookingsService.prototype as unknown as {
|
||||
splitAmountAcrossWagons(total: number, weights: number[]): number[];
|
||||
}
|
||||
).splitAmountAcrossWagons(total, weights);
|
||||
|
||||
describe('carriage acceptance sheet — price split', () => {
|
||||
it('splits proportionally to allocated weight', () => {
|
||||
expect(split(100, [30, 10])).toEqual([75, 25]);
|
||||
});
|
||||
|
||||
it('splits equally when no weights are recorded', () => {
|
||||
expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]);
|
||||
});
|
||||
|
||||
it('always sums back to the booking total despite rounding', () => {
|
||||
const shares = split(100, [1, 1, 1]);
|
||||
expect(shares.reduce((a, b) => a + b, 0)).toBe(100);
|
||||
expect(shares).toEqual([33.33, 33.33, 33.34]);
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat
|
||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
|
||||
@@ -685,6 +686,17 @@ export class CompaniesController {
|
||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
||||
}
|
||||
|
||||
@Get(":companyId/revisions")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
|
||||
async listCompanyRevisions(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CompanyRevisionResponseDto[]> {
|
||||
const revisions =
|
||||
await this.companiesService.listCompanyRevisions(companyId);
|
||||
return revisions.map((r) => new CompanyRevisionResponseDto(r));
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
@@ -719,6 +731,25 @@ export class CompaniesController {
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/request-changes")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
|
||||
})
|
||||
async requestChangeRequestChanges(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectChangeRequestDto,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.requestChangeRequestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
|
||||
@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
||||
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||
* national ID number, so none is collected or derived here.
|
||||
*
|
||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
||||
* Fayda, and their details can't be edited afterwards.
|
||||
* Only the OWNER's credential varies by nationality:
|
||||
* - Ethiopian company: the owner is verified through Fayda.
|
||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
||||
* supplies a typed passport number — required on its own, whether or not the
|
||||
* owner also completes a (purely optional) Fayda verification.
|
||||
*
|
||||
* The PoA does not vary. A representative acts for the company inside Ethiopia
|
||||
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
|
||||
* is named, both nationalities must verify them, and their details come from
|
||||
* the verified payload rather than the form.
|
||||
*
|
||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||
* the portal offers a "same as owner" copy for, but it is never itself
|
||||
* Fayda-verified or gated on.
|
||||
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
},
|
||||
changeRequestRepo: {
|
||||
findPendingByCompanyId: jest.fn(async () => null),
|
||||
findLatestOpenByCompanyId: jest.fn(async () => null),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
@@ -114,6 +120,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -138,6 +151,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
@@ -229,9 +243,27 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("stages the change for review on an approved company", async () => {
|
||||
// Swapping the person who can act for a live company is exactly what the
|
||||
// backoffice review exists for, so it must not rewrite the row directly.
|
||||
it("stages an owner re-verification for review on an approved company", async () => {
|
||||
// The owner is the live company's identity proof, so re-verifying one is
|
||||
// exactly what the backoffice review exists for: it must not rewrite the
|
||||
// row directly.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
|
||||
await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies a PoA verification live on an approved company", async () => {
|
||||
// The PoA is personnel the company names for itself — the delegation paper
|
||||
// is what a reviewer actually judges — so it does not go to review.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
@@ -242,8 +274,8 @@ describe("Fayda identity verification binds a person to the company", () => {
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
||||
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||
});
|
||||
|
||||
it("refuses to rename a verified person by hand", async () => {
|
||||
@@ -367,7 +399,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
|
||||
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
ownerPassportNumber: "P1234567",
|
||||
...POA_VERIFIED,
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
||||
// The owner's credential is nationality-specific; the representative's is
|
||||
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
||||
// typed foreign name is not a representative the platform can accept.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
@@ -385,7 +439,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||
|
||||
@@ -15,9 +15,11 @@ import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
@@ -29,6 +31,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfile,
|
||||
CompanyProfile,
|
||||
CompanyChangeRequest,
|
||||
CompanyRevision,
|
||||
Booking,
|
||||
]),
|
||||
HttpModule,
|
||||
@@ -49,6 +52,7 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyChangeRequestRepository,
|
||||
CompanyRevisionRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
CompanyNotifierService,
|
||||
|
||||
@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
findPendingByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findLatestOpenByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
@@ -88,6 +91,13 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
revisionRepo: {
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "rev-1",
|
||||
...row,
|
||||
})),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
@@ -118,6 +128,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.revisionRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
|
||||
@@ -39,6 +39,7 @@ function makeService(existing: ExistingProfile[]) {
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
@@ -9,6 +10,11 @@ import { DataSource, EntityManager } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
import {
|
||||
diffCompanyUpdate,
|
||||
summarizeCompanyChanges,
|
||||
} from "./company-revision-diff.util";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import {
|
||||
CompanyDashboardRepository,
|
||||
@@ -64,6 +70,10 @@ import {
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "./entities/company-revision.entity";
|
||||
|
||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||
const LICENSE_RESOURCE = "company_profiles";
|
||||
@@ -81,6 +91,28 @@ const POA_ATTRIBUTES = [
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
/**
|
||||
* Personnel an approved company maintains itself: its contact person, its
|
||||
* general manager and its Power of Attorney. These name who to talk to, not
|
||||
* what the company is allowed to do, so freezing the settings page until a
|
||||
* reviewer gets to a new phone number costs more than it protects. They write
|
||||
* straight to the live row even for an active company.
|
||||
*
|
||||
* The PoA's *delegation letter* is deliberately not here — the paper is the
|
||||
* thing that actually evidences the delegation, so it still goes through
|
||||
* review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
|
||||
*/
|
||||
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
|
||||
"contactPersonName",
|
||||
"contactPersonPosition",
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
"contactVerifiedPhone",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
...POA_ATTRIBUTES,
|
||||
];
|
||||
/** Mandatory once the company operates as a freight forwarder. */
|
||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: "poaName", label: "PoA name" },
|
||||
@@ -154,10 +186,13 @@ export interface UserIdentity {
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
private readonly logger = new Logger(CompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||
private readonly revisionRepo: CompanyRevisionRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -660,7 +695,16 @@ export class CompaniesService {
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
const before = await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
|
||||
// Staff can also promote Pending -> Active directly through this generic
|
||||
// endpoint (not just via the first-profile-approval path), so stamp it here too.
|
||||
if (
|
||||
dto.status === CompanyStatus.Active &&
|
||||
before.status !== CompanyStatus.Active
|
||||
) {
|
||||
patch.approvedAt = new Date();
|
||||
}
|
||||
const updated = await this.companiesRepo.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||
|
||||
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||
@@ -818,6 +862,34 @@ export class CompaniesService {
|
||||
return companyUpdates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a version-history entry for an onboarding-phase edit (the company
|
||||
* is not yet Active, so the change went straight to the live row with no
|
||||
* approval gate to carry a record of it). Best-effort: a no-op patch or a
|
||||
* failure to write history must never break the edit that triggered it.
|
||||
*/
|
||||
private async recordCompanyRevision(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
actorId?: string | null,
|
||||
extraChanges: CompanyRevisionChange[] = [],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
|
||||
if (changes.length === 0) return;
|
||||
await this.revisionRepo.create({
|
||||
companyId: before.id,
|
||||
actorId: actorId ?? null,
|
||||
summary: summarizeCompanyChanges(changes),
|
||||
changes,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record company revision for ${before.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a TIN already registered to a *different* company. */
|
||||
private async assertTinAvailable(
|
||||
company: Company,
|
||||
@@ -844,10 +916,11 @@ export class CompaniesService {
|
||||
*
|
||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||
* as before. The company/role pending→approve gate already covers first-run.
|
||||
* - Company already `active` → do NOT touch the live Company. Stage the edit in
|
||||
* a pending change request (merging into any open one) so a backoffice
|
||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
||||
* customer until the review resolves.
|
||||
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
|
||||
* still write straight through; everything else does NOT touch the live
|
||||
* Company but is staged in a pending change request (merging into any open
|
||||
* one) so a backoffice reviewer can approve (apply) or reject (with a
|
||||
* note). Only the staged half locks the customer until the review resolves.
|
||||
*/
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
@@ -879,12 +952,41 @@ export class CompaniesService {
|
||||
);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
await this.recordCompanyRevision(company, companyUpdates, userId);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
// Approved company: stage the change for review, leaving the live row intact.
|
||||
// Approved company: personnel details apply immediately, the rest is staged
|
||||
// for review with the live row left intact.
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const fields = this.pickDefined(dto);
|
||||
const selfService: Record<string, any> = {};
|
||||
const staged: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
|
||||
else staged[key] = value;
|
||||
}
|
||||
|
||||
let live = company;
|
||||
if (Object.keys(selfService).length > 0) {
|
||||
live =
|
||||
(await this.companiesRepo.update(
|
||||
company.id,
|
||||
this.mapProfileDtoToCompanyUpdates(company, selfService),
|
||||
)) ?? company;
|
||||
live.companyProfiles = company.companyProfiles;
|
||||
}
|
||||
|
||||
if (Object.keys(staged).length === 0) {
|
||||
// Nothing a reviewer needs to see. Any request already open (a document
|
||||
// upload, an owner verification) still surfaces so its banner survives —
|
||||
// it just no longer gains fields it was never asked to review.
|
||||
return new ProfileResponseDto(
|
||||
profile,
|
||||
live,
|
||||
await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||
company.id,
|
||||
@@ -894,10 +996,14 @@ export class CompaniesService {
|
||||
if (existing) {
|
||||
request =
|
||||
(await this.changeRequestRepo.update(existing.id, {
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
||||
// Note is left untouched: if this request was ChangesRequested, the
|
||||
// reviewer's ask stays visible on the resubmitted (Pending) row —
|
||||
// clearing it here would hide what was asked for right when the
|
||||
// reviewer comes back to check whether it was actually addressed.
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
})) ?? existing;
|
||||
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
||||
} else {
|
||||
@@ -910,7 +1016,7 @@ export class CompaniesService {
|
||||
);
|
||||
request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot: fields,
|
||||
snapshot: staged,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
@@ -922,8 +1028,9 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Live company is unchanged; surface the pending state for the settings page.
|
||||
return new ProfileResponseDto(profile, company, request);
|
||||
// Only the personnel half (if any) landed; surface the pending state for
|
||||
// the settings page.
|
||||
return new ProfileResponseDto(profile, live, request);
|
||||
}
|
||||
|
||||
/** List a company's change requests, newest first (backoffice review). */
|
||||
@@ -934,6 +1041,53 @@ export class CompaniesService {
|
||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
||||
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
||||
await this.findCompanyById(companyId);
|
||||
return this.revisionRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair adjacent remove-then-add intents into one before/after revision
|
||||
* change — that's exactly how a "replace" is staged (see
|
||||
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
|
||||
* pushed together, and later merges only ever append after that pair, so
|
||||
* adjacency is preserved). A remove or add with no adjacent partner (a pure
|
||||
* add, or a pure removal) stands alone.
|
||||
*/
|
||||
private pairReplaceIntents<
|
||||
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
|
||||
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
let i = 0;
|
||||
while (i < intents.length) {
|
||||
const current = intents[i];
|
||||
const next = intents[i + 1];
|
||||
if (current.op === "remove" && next?.op === "add") {
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(next),
|
||||
from: current.fileName ?? null,
|
||||
to: next.fileName ?? null,
|
||||
fromFileId: current.fileId,
|
||||
toFileId: next.fileId,
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
field: `document:${current.fileId}`,
|
||||
label: labelFor(current),
|
||||
from: current.op === "remove" ? (current.fileName ?? null) : null,
|
||||
to: current.op === "add" ? (current.fileName ?? null) : null,
|
||||
fromFileId: current.op === "remove" ? current.fileId : null,
|
||||
toFileId: current.op === "add" ? current.fileId : null,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending change request: apply its snapshot to the live Company and
|
||||
* mark the request approved. Any staged documents are already attached to the
|
||||
@@ -963,6 +1117,30 @@ export class CompaniesService {
|
||||
await this.applyLicenseChanges(request);
|
||||
await this.applyDocumentChanges(request);
|
||||
|
||||
// This is the ONLY place post-approval FIELD/license/PoA-document changes
|
||||
// land on the live row — without this call, everything the #419
|
||||
// change-request flow does to those is invisible in Version History.
|
||||
// `documentFileIds` (the general bulk company-documents upload) is
|
||||
// deliberately NOT re-recorded here — those documents go live immediately
|
||||
// at upload time and are already recorded there (see
|
||||
// `uploadCompanyDocuments`); redoing it here would double the entry.
|
||||
const documentChanges: CompanyRevisionChange[] = [
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.licenseChanges ?? [],
|
||||
() => "Business license",
|
||||
),
|
||||
...this.pairReplaceIntents(
|
||||
request.documents?.documentChanges ?? [],
|
||||
(intent) => intent.code,
|
||||
),
|
||||
];
|
||||
await this.recordCompanyRevision(
|
||||
company,
|
||||
companyUpdates,
|
||||
reviewerId,
|
||||
documentChanges,
|
||||
);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Approved,
|
||||
@@ -973,12 +1151,62 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh upload under a single-file document slot (`isMultiple: false`)
|
||||
* replaces whatever was there, not adds to it — soft-delete the prior live
|
||||
* file(s) for that code, and describe each replacement (plus each genuinely
|
||||
* new upload) as a revision change carrying both file ids, so the reviewer
|
||||
* can open the previous and current file. Multi-file slots are left alone
|
||||
* (genuinely additive, no single "the" document to diff against). Unrecognised
|
||||
* codes (no matching field in the nationality's document setting) are also
|
||||
* left alone — safer to under-clean than to guess wrong. Independent of the
|
||||
* change-request review outcome: nothing else in this flow ever retires a
|
||||
* superseded document, on approve OR reject — these documents go live the
|
||||
* moment they're uploaded.
|
||||
*/
|
||||
private async replaceSingleFileCompanyDocuments(
|
||||
company: Company,
|
||||
before: FileRecord[],
|
||||
uploaded: FileRecord[],
|
||||
): Promise<CompanyRevisionChange[]> {
|
||||
const setting = await this.fileUploadSettingsService
|
||||
.getByCode(this.documentSettingCodeFor(company.nationality))
|
||||
.catch(() => null);
|
||||
const fields = setting?.fields ?? [];
|
||||
const singleFileCodes = new Set(
|
||||
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
|
||||
);
|
||||
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
|
||||
const uploadedIds = new Set(uploaded.map((f) => f.id));
|
||||
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const toRemove: FileRecord[] = [];
|
||||
for (const file of uploaded) {
|
||||
if (!singleFileCodes.has(file.code)) continue;
|
||||
const prior = before.find(
|
||||
(f) => f.code === file.code && !uploadedIds.has(f.id),
|
||||
);
|
||||
changes.push({
|
||||
field: `document:${file.code}`,
|
||||
label: labelByCode.get(file.code) ?? file.code,
|
||||
from: prior?.name ?? null,
|
||||
to: file.name,
|
||||
fromFileId: prior?.id ?? null,
|
||||
toFileId: file.id,
|
||||
});
|
||||
if (prior) toRemove.push(prior);
|
||||
}
|
||||
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload company documents. For an approved company this also opens/updates a
|
||||
* pending change request (recording the uploaded file ids) so the upload is
|
||||
* reviewed and the customer is locked until it clears — consistent with the
|
||||
* field-edit review. During onboarding (company not yet active) it's a plain
|
||||
* upload with no review.
|
||||
* upload with no review. Either way the documents go live immediately, so
|
||||
* the revision history is recorded right away too, not gated on a decision.
|
||||
*/
|
||||
async uploadCompanyDocuments(
|
||||
companyId: string,
|
||||
@@ -986,11 +1214,20 @@ export class CompaniesService {
|
||||
submittedBy?: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const before = await this.filesService.findByResource(
|
||||
companyId,
|
||||
"companies",
|
||||
);
|
||||
const uploaded = await this.filesService.uploadMany(
|
||||
companyId,
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
const documentChanges = await this.replaceSingleFileCompanyDocuments(
|
||||
company,
|
||||
before,
|
||||
uploaded,
|
||||
);
|
||||
await this.resolveDocumentChangeRequests(
|
||||
companyId,
|
||||
"companies",
|
||||
@@ -1004,6 +1241,9 @@ export class CompaniesService {
|
||||
submittedBy,
|
||||
);
|
||||
}
|
||||
if (documentChanges.length > 0) {
|
||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
@@ -1118,7 +1358,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
@@ -1179,6 +1420,37 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for specific fixes without rejecting outright: unlike
|
||||
* {@link rejectChangeRequest}, staged license/document intents are kept (the
|
||||
* row stays open), so the customer's next edit is appended to this SAME
|
||||
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
|
||||
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
|
||||
* of starting a fresh cycle.
|
||||
*/
|
||||
async requestChangeRequestChanges(
|
||||
id: string,
|
||||
note: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
await this.findCompanyById(id);
|
||||
await this.companiesRepo.softDelete(id);
|
||||
@@ -1420,6 +1692,7 @@ export class CompaniesService {
|
||||
) {
|
||||
await companyRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
}
|
||||
@@ -1720,16 +1993,11 @@ export class CompaniesService {
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
||||
// from the Fayda verification — so reporting them as missing fields would
|
||||
// ask for something the form no longer offers. The identity block below
|
||||
// No company types its PoA details — they arrive from the Fayda
|
||||
// verification whatever the nationality — so reporting them as missing
|
||||
// fields would ask for something no form offers. The identity block below
|
||||
// reports "verify your PoA" instead.
|
||||
const missingPoaFields =
|
||||
poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
||||
const delegation = await this.getPoaDelegationState(company.id);
|
||||
const delegationDue = poaRequired || poaProvided;
|
||||
const missingDelegation = delegationDue && !delegation.onFile;
|
||||
@@ -1754,9 +2022,7 @@ export class CompaniesService {
|
||||
...(identity.faydaRequired && !identity.owner.verified
|
||||
? ["Verify the company owner's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.faydaRequired &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!identity.poa.verified
|
||||
...((poaRequired || poaProvided) && !identity.poa.verified
|
||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.passportRequired && !identity.owner.passportNumber
|
||||
@@ -1768,26 +2034,20 @@ export class CompaniesService {
|
||||
// fields, required documents, one license per operational profile, and the
|
||||
// PoA details/paper whenever those are mandatory.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const poaItemCount =
|
||||
(poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.length
|
||||
: 0) + (delegationDue ? 1 : 0);
|
||||
const poaItemCount = delegationDue ? 1 : 0;
|
||||
// One item per identity credential the company has to prove: the owner
|
||||
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
||||
// is one and Fayda is what's mandatory here.
|
||||
const identityItemCount = identity.faydaRequired
|
||||
? delegationDue
|
||||
? 2
|
||||
: 1
|
||||
: identity.passportRequired
|
||||
? 1
|
||||
: 0;
|
||||
const missingIdentityCount = identity.faydaRequired
|
||||
? (identity.owner.verified ? 0 : 1) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0)
|
||||
: identity.passportRequired && !identity.owner.passportNumber
|
||||
? 1
|
||||
: 0;
|
||||
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||
// there is one — that one is Fayda whatever the nationality.
|
||||
const ownerCredentialDue =
|
||||
identity.faydaRequired || identity.passportRequired;
|
||||
const ownerCredentialProven = identity.faydaRequired
|
||||
? identity.owner.verified
|
||||
: Boolean(identity.owner.passportNumber);
|
||||
const identityItemCount =
|
||||
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||
const missingIdentityCount =
|
||||
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||
const total =
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
@@ -2225,7 +2485,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
@@ -2453,11 +2714,13 @@ export class CompaniesService {
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
// An approved company's profile edits are staged for backoffice review, and
|
||||
// swapping the person who can act for the company is exactly the kind of
|
||||
// edit that review exists for — so a verification lands the same way an
|
||||
// ordinary edit does, rather than quietly rewriting a live record.
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||
// is staged for backoffice review rather than quietly rewriting a live
|
||||
// record. The PoA is personnel — the company names its own representative,
|
||||
// and the delegation letter backing them is what the reviewer sees — so a
|
||||
// PoA verification lands live, matching the typed PoA fields in
|
||||
// `SELF_SERVICE_ATTRIBUTES`.
|
||||
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
||||
await this.stageIdentityChange(company, userId, identity);
|
||||
return this.getCompanyIdentityState(company);
|
||||
}
|
||||
@@ -2549,7 +2812,8 @@ export class CompaniesService {
|
||||
snapshot,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
return;
|
||||
@@ -2586,21 +2850,23 @@ export class CompaniesService {
|
||||
): void {
|
||||
const state = buildCompanyIdentityState(company);
|
||||
|
||||
// Only the owner's credential is nationality-specific: Fayda for an
|
||||
// Ethiopian company, a typed passport number for a foreign one.
|
||||
if (state.passportRequired) {
|
||||
if (!state.owner.passportNumber) {
|
||||
throw new BadRequestException(
|
||||
"Add the company owner's passport number before continuing.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.owner.verified) {
|
||||
} else if (!state.owner.verified) {
|
||||
throw new BadRequestException(
|
||||
"Verify the company owner's identity with Fayda before continuing.",
|
||||
);
|
||||
}
|
||||
|
||||
// The representative is not. A PoA acts for the company inside Ethiopia
|
||||
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
||||
// a foreign company nominates one rather than typing a name.
|
||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
@@ -2827,7 +3093,8 @@ export class CompaniesService {
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
// Note left untouched — see the comment in updateProfile's merge branch.
|
||||
status: ChangeRequestStatus.Pending,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { FindOperator, Repository } from "typeorm";
|
||||
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import {
|
||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
||||
|
||||
const COMPANY_ID = "company-1";
|
||||
|
||||
/** Matches a row's status against either a plain value or an `In([...])` operator. */
|
||||
function statusMatches(
|
||||
rowStatus: ChangeRequestStatus,
|
||||
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
|
||||
): boolean {
|
||||
if (where === undefined) return true;
|
||||
if (where instanceof FindOperator) return where.value.includes(rowStatus);
|
||||
return rowStatus === where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
||||
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
||||
function mockRepositoryOver(rows: Row[]) {
|
||||
return {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
||||
({
|
||||
where,
|
||||
}: {
|
||||
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
where.companyId === COMPANY_ID &&
|
||||
(where.status === undefined || row.status === where.status),
|
||||
statusMatches(row.status, where.status),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||
null,
|
||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
||||
expect(result?.id).toBe("pending");
|
||||
});
|
||||
|
||||
it("treats a changes-requested request as open, same as pending", async () => {
|
||||
const changesRequested: Row = {
|
||||
id: "changes-requested",
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = await subject([
|
||||
rejected,
|
||||
changesRequested,
|
||||
]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||
|
||||
expect(result?.id).toBe("changes-requested");
|
||||
});
|
||||
|
||||
it("returns the latest rejected request when nothing is pending", async () => {
|
||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||
COMPANY_ID,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
|
||||
const OPEN_FOR_EDIT_STATUSES = [
|
||||
ChangeRequestStatus.Pending,
|
||||
ChangeRequestStatus.ChangesRequested,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Company } from "./entities/company.entity";
|
||||
import type { CompanyRevisionChange } from "./entities/company-revision.entity";
|
||||
|
||||
/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */
|
||||
export const COMPANY_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Company name",
|
||||
phone: "Phone",
|
||||
email: "Email",
|
||||
address: "Address",
|
||||
country: "Country",
|
||||
tin: "TIN",
|
||||
vatNumber: "VAT number",
|
||||
fanNumber: "FAN number",
|
||||
nationality: "Nationality",
|
||||
website: "Website",
|
||||
licenceNumber: "Licence number",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House No",
|
||||
contactPersonName: "Contact person name",
|
||||
contactPersonPhone: "Contact person phone",
|
||||
contactPersonEmail: "Contact person email",
|
||||
contactPersonPosition: "Contact person position",
|
||||
generalManagerName: "General manager name",
|
||||
generalManagerPhone: "General manager phone",
|
||||
generalManagerEmail: "General manager email",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
poaAddress: "PoA address",
|
||||
documents: "Document",
|
||||
};
|
||||
|
||||
function displayValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the company row before a write against the patch about to be
|
||||
* applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar
|
||||
* columns plus a merged `attributes` blob). Only fields with a known label
|
||||
* are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never
|
||||
* shows up as noise.
|
||||
*/
|
||||
export function diffCompanyUpdate(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const { attributes: attrPatch, ...columnPatch } = patch;
|
||||
|
||||
for (const [field, nextRaw] of Object.entries(columnPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue((before as unknown as Record<string, unknown>)[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
|
||||
if (attrPatch) {
|
||||
const beforeAttrs = before.attributes ?? {};
|
||||
for (const [field, nextRaw] of Object.entries(attrPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue(beforeAttrs[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Short human summary of a change set, e.g. "phone, address changed". */
|
||||
export function summarizeCompanyChanges(
|
||||
changes: CompanyRevisionChange[],
|
||||
): string {
|
||||
if (changes.length === 0) return "No changes";
|
||||
const labels = changes.map((c) => c.label.toLowerCase());
|
||||
return labels.length <= 3
|
||||
? `${labels.join(", ")} changed`
|
||||
: `${labels.length} fields changed`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CompanyRevisionRepository extends BaseRepository<CompanyRevision> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyRevision)
|
||||
repo: Repository<CompanyRevision>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** Revision history for a company, newest first. */
|
||||
async findByCompanyId(companyId: string): Promise<CompanyRevision[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
|
||||
/**
|
||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
|
||||
* call to action, but the customer's edit appends to this SAME request
|
||||
* instead of starting a fresh one.
|
||||
*/
|
||||
review: {
|
||||
status: 'pending' | 'rejected';
|
||||
status: 'pending' | 'rejected' | 'changes_requested';
|
||||
note: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
||||
const open =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.review = open
|
||||
? {
|
||||
status: open.status as 'pending' | 'rejected',
|
||||
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||
note: open.note ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "../entities/company-revision.entity";
|
||||
|
||||
/** One version-history entry, shown on the backoffice customer detail page. */
|
||||
export class CompanyRevisionResponseDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: Date;
|
||||
|
||||
constructor(revision: CompanyRevision) {
|
||||
this.id = revision.id;
|
||||
this.companyId = revision.companyId;
|
||||
this.actorId = revision.actorId ?? null;
|
||||
this.summary = revision.summary;
|
||||
this.changes = revision.changes ?? [];
|
||||
this.createdAt = revision.createdAt;
|
||||
}
|
||||
}
|
||||
@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
const complete = faydaRequired
|
||||
? owner.verified && (!poaDue || poa.verified)
|
||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
||||
// proven with Fayda — a foreign company nominates a representative who holds
|
||||
// one rather than typing a name nothing backs.
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
const complete = ownerProven && (!poaDue || poa.verified);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
}
|
||||
|
||||
@@ -68,10 +68,12 @@ export class ProfileResponseDto {
|
||||
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
||||
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
|
||||
* prefill the proposed values from `pendingChanges` so the customer can amend
|
||||
* & resubmit — `"changes_requested"` just appends the edit to this same
|
||||
* request instead of starting a fresh one.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | null;
|
||||
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
|
||||
reviewNote: string | null;
|
||||
pendingChanges: Record<string, any> | null;
|
||||
|
||||
@@ -127,7 +129,8 @@ export class ProfileResponseDto {
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
@@ -135,7 +138,9 @@ export class ProfileResponseDto {
|
||||
? "pending"
|
||||
: openReview?.status === ChangeRequestStatus.Rejected
|
||||
? "rejected"
|
||||
: null;
|
||||
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||
? "changes_requested"
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
|
||||
@@ -97,6 +97,7 @@ export class ResponseCompanyDto {
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
|
||||
constructor(company: Company) {
|
||||
this.id = company.id;
|
||||
@@ -135,5 +136,6 @@ export class ResponseCompanyDto {
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
this.approvedAt = company.approvedAt ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
|
||||
/**
|
||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||
* settings page by an already-approved company are staged here (not written to
|
||||
* the live Company row) until a backoffice reviewer approves — at which point
|
||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
||||
* amend and resubmit.
|
||||
* the live Company row) until a backoffice reviewer resolves it:
|
||||
* - Approved — the snapshot is applied to the live Company row.
|
||||
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
|
||||
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
|
||||
* so the customer's next edit is appended (merged) into this SAME row instead
|
||||
* of starting a new cycle.
|
||||
*/
|
||||
export enum ChangeRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
ChangesRequested = "changes_requested",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
/**
|
||||
* One recorded field/document change, as shown on the customer's version
|
||||
* history. A document change carries `fromFileId`/`toFileId` alongside the
|
||||
* display names, so the reviewer can open the previous and current file —
|
||||
* not just read that "a document changed."
|
||||
*/
|
||||
export interface CompanyRevisionChange {
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
fromFileId?: string | null;
|
||||
toFileId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only audit of edits made to a company record BEFORE it reaches
|
||||
* `Active` (the onboarding phase), where {@link CompaniesService.updateProfile}
|
||||
* and {@link CompaniesService.uploadCompanyDocuments} write straight to the
|
||||
* live row with no approval gate — and, until this entity, no trace at all.
|
||||
* Post-approval edits already get history via `CompanyChangeRequest`; this
|
||||
* covers the gap before that gate exists.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "company_revisions" })
|
||||
@Index(["companyId"])
|
||||
export class CompanyRevision extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
@Column({ name: "actor_id", type: "uuid", nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
@Column({ name: "summary", type: "varchar", length: 255 })
|
||||
summary!: string;
|
||||
|
||||
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
|
||||
changes!: CompanyRevisionChange[];
|
||||
}
|
||||
@@ -61,6 +61,10 @@ export class Company extends BaseEntity {
|
||||
})
|
||||
status!: CompanyStatus;
|
||||
|
||||
/** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */
|
||||
@Column({ name: "approved_at", type: "timestamptz", nullable: true })
|
||||
approvedAt?: Date | null;
|
||||
|
||||
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
|
||||
@@ -51,7 +51,11 @@ export class FilesController {
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const record = await this.filesService.findById(fileId);
|
||||
// Includes soft-deleted records: a superseded document (replaced via a
|
||||
// single-file document slot, or resolved as part of a license/PoA swap)
|
||||
// is only reachable by UUID through the change-request/version-history
|
||||
// diff, where reviewers need to open the "previous" file to compare it.
|
||||
const record = await this.filesService.findByIdIncludingDeleted(fileId);
|
||||
|
||||
// Chat attachments are cross-tenant sensitive and this route has no
|
||||
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||
@@ -63,7 +67,9 @@ export class FilesController {
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.filesService.streamById(fileId);
|
||||
const { stream } = await this.filesService.streamById(fileId, {
|
||||
includeDeleted: true,
|
||||
});
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
const disposition = forceDownload ? "attachment" : "inline";
|
||||
|
||||
|
||||
@@ -245,6 +245,23 @@ export class FilesService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link findById}, but also matches a soft-deleted record — a
|
||||
* superseded document (replaced via a single-file slot, or a resolved
|
||||
* license/PoA swap) is exactly this: gone from every live listing, but its
|
||||
* id is still handed to reviewers in the change-request/version-history
|
||||
* diff so they can open the "previous" file for comparison. Only the
|
||||
* preview/download route should use this; every other caller wants the
|
||||
* default (soft-deleted = not found).
|
||||
*/
|
||||
async findByIdIncludingDeleted(id: string): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findById(id, {
|
||||
withDeleted: true,
|
||||
});
|
||||
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a reviewer verdict on one document. `change_requested` keeps the note
|
||||
* (the customer sees it verbatim); any other verdict clears it, so a stale
|
||||
@@ -360,8 +377,11 @@ export class FilesService {
|
||||
|
||||
async streamById(
|
||||
id: string,
|
||||
opts: { includeDeleted?: boolean } = {},
|
||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const record = opts.includeDeleted
|
||||
? await this.findByIdIncludingDeleted(id)
|
||||
: await this.findById(id);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
return { stream, record };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||
type PaymentType = string
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
|
||||
@@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||
referenceType?: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
forwardRef,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import {
|
||||
PaymentEventDto,
|
||||
MarkPaidResponseDto,
|
||||
BillQueryRequestDto,
|
||||
BillQueryResponseDto,
|
||||
} from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* WARNING: currently unauthenticated — anyone who can reach the API can mark
|
||||
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
|
||||
* Consumer side of the payment microservice's outbox relay. Only the payment service may
|
||||
* call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8).
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@Public()
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
private readonly logger = new Logger(InternalPaymentController.name);
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
constructor(
|
||||
private readonly paymentService: PaymentService,
|
||||
@Inject(forwardRef(() => BillingService))
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -36,4 +48,16 @@ export class InternalPaymentController {
|
||||
this.logger.log(`Marking payment ${event} as PAID`);
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
|
||||
@Post("bill-query")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Live still-payable check + payer name for a CBE bill (called while CBE is on the line)",
|
||||
})
|
||||
async billQuery(
|
||||
@Body() request: BillQueryRequestDto,
|
||||
): Promise<BillQueryResponseDto> {
|
||||
return this.billingService.billQuery(request.referenceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,30 @@ export class MarkPaidResponseDto {
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks
|
||||
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
|
||||
*/
|
||||
export class BillQueryRequestDto {
|
||||
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
|
||||
// property to a relative require() into packages/types, which does not exist inside the
|
||||
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
|
||||
// decorators below still give us enum docs + runtime validation.
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
}
|
||||
|
||||
export class BillQueryResponseDto {
|
||||
@ApiProperty() stillPayable!: boolean;
|
||||
@ApiPropertyOptional() payerName?: string | null;
|
||||
@ApiPropertyOptional() currentAmountMinor?: number | null;
|
||||
@ApiPropertyOptional() currency?: string | null;
|
||||
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
|
||||
@ApiPropertyOptional() paymentReason?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
@@ -16,85 +21,125 @@ import {
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
// process.env.PAYMENT_API_URL ??
|
||||
"https://paymentcallback.triaplc.com"
|
||||
// "http://localhost:3003"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
)
|
||||
// "http://localhost:3003"
|
||||
.replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
constructor(private readonly http: HttpService) { }
|
||||
constructor(private readonly http: HttpService) { }
|
||||
|
||||
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
|
||||
return this.call("POST", "/payments/initiate", request);
|
||||
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||
async initiate(
|
||||
request: InitiatePaymentRequest,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
return this.call("POST", "/payments/initiate", request);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/reconcile — settlement check for a domain order
|
||||
* (reconcile-before-cancel). Live-queries every non-failed intent at the
|
||||
* provider and registers any late capture found (flips it to SUCCEEDED and
|
||||
* emits payment.succeeded). `unverifiable: true` = could not confirm
|
||||
* "not paid" — the caller must NOT cancel/expire the order.
|
||||
*/
|
||||
async reconcileReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
||||
return this.call("POST", "/payments/reconcile", {
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||
async getIntentByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot | null> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/reconcile — settlement check for a domain order
|
||||
* (reconcile-before-cancel). Live-queries every non-failed intent at the
|
||||
* provider and registers any late capture found (flips it to SUCCEEDED and
|
||||
* emits payment.succeeded). `unverifiable: true` = could not confirm
|
||||
* "not paid" — the caller must NOT cancel/expire the order.
|
||||
*/
|
||||
async reconcileReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<{ paid: boolean; unverifiable: boolean }> {
|
||||
return this.call("POST", "/payments/reconcile", {
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
|
||||
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
|
||||
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
|
||||
* re-enter the code.
|
||||
*/
|
||||
async confirmOtp(
|
||||
intentId: string,
|
||||
otp: string,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
try {
|
||||
return await this.call<PaymentIntentSnapshot>(
|
||||
"POST",
|
||||
`/payments/intents/${intentId}/confirm`,
|
||||
{ otp },
|
||||
);
|
||||
} catch (err) {
|
||||
// `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
|
||||
// unknown intent or a bad OTP is client-fixable, so translate both to 400.
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
throw new BadRequestException("PaymentIntent not found");
|
||||
}
|
||||
if (err instanceof BadGatewayException) {
|
||||
const detail = err.message.replace(/^Payment service error: /, "");
|
||||
throw new BadRequestException(detail);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||
async getIntentByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot | null> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
method,
|
||||
url,
|
||||
data: body,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
const message = err instanceof Error && err.message ? err.message : String(err);
|
||||
this.logger.error(`payment service unreachable (${method} ${path}): ${message}`);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
private async call<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
method,
|
||||
url,
|
||||
data: body,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`payment service unreachable (${method} ${path}): ${message}`,
|
||||
);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
// CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old
|
||||
// 10s cap 502'd every CAC charge while the bank was still working —
|
||||
// orphaning an intent the payer had already been texted about. Matches
|
||||
// the passenger API's budget.
|
||||
HttpModule.register({
|
||||
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
||||
}),
|
||||
ConfigModule,
|
||||
forwardRef(() => BillingModule),
|
||||
// forwardRef(() => TrainSchedulingModule),
|
||||
|
||||
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { of, throwError } from "rxjs";
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types";
|
||||
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
/** Local intent projection row (the invoice's `paymentId` points at this). */
|
||||
function localIntent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "intent-1",
|
||||
refId: "booking-1",
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
status: "action-required",
|
||||
method: "cac-bank",
|
||||
merchantOrderId: "EDR_INV_1",
|
||||
clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRepo(rows: Record<string, unknown>[]) {
|
||||
const store = [...rows];
|
||||
return {
|
||||
findOneBy: jest.fn((where: Record<string, unknown>) =>
|
||||
Promise.resolve(
|
||||
store.find((r) =>
|
||||
Object.entries(where).every(([k, v]) => r[k] === v),
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
|
||||
const row = store.find((r) => r.id === where.id);
|
||||
if (row) Object.assign(row, data);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PaymentService.confirmOtp", () => {
|
||||
const build = (
|
||||
client: Partial<PaymentClientService>,
|
||||
rows = [localIntent()],
|
||||
) => {
|
||||
const repo = makeRepo(rows);
|
||||
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
|
||||
const service = new PaymentService(
|
||||
repo as never,
|
||||
client as never,
|
||||
billing as never,
|
||||
);
|
||||
return { service, repo, billing };
|
||||
};
|
||||
|
||||
it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => {
|
||||
const paidAt = "2026-07-31T10:00:00.000Z";
|
||||
const { service, repo, billing } = build({
|
||||
getIntentByReference: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }),
|
||||
confirmOtp: jest.fn().mockResolvedValue({
|
||||
intentId: "gw-1",
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: "11709363209530624",
|
||||
paidAt,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.confirmOtp("intent-1", "8280");
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
{ id: "intent-1" },
|
||||
expect.objectContaining({
|
||||
status: "success",
|
||||
transactionId: "11709363209530624",
|
||||
}),
|
||||
);
|
||||
// Billing settles the invoice linked by this intent id.
|
||||
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
|
||||
"intent-1",
|
||||
"11709363209530624",
|
||||
new Date(paidAt),
|
||||
);
|
||||
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
|
||||
});
|
||||
|
||||
it("forwards the OTP against the GATEWAY intent id, not the local one", async () => {
|
||||
const confirmOtp = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION });
|
||||
const { service } = build({
|
||||
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
|
||||
confirmOtp,
|
||||
});
|
||||
|
||||
await service.confirmOtp("intent-1", "8280");
|
||||
|
||||
expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280");
|
||||
});
|
||||
|
||||
it("leaves the intent open and does not settle when the OTP is not accepted", async () => {
|
||||
const { service, repo, billing } = build({
|
||||
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
|
||||
confirmOtp: jest.fn().mockResolvedValue({
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
failureMessage: "OTP confirmation failed",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.confirmOtp("intent-1", "0000");
|
||||
|
||||
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
{ id: "intent-1" },
|
||||
expect.objectContaining({ status: "action-required" }),
|
||||
);
|
||||
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
|
||||
});
|
||||
|
||||
it("404s when the gateway has no active intent for the reference", async () => {
|
||||
const { service } = build({
|
||||
getIntentByReference: jest.fn().mockResolvedValue(null),
|
||||
confirmOtp: jest.fn(),
|
||||
});
|
||||
|
||||
await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentClientService.confirmOtp", () => {
|
||||
const axiosErr = (status: number, message: string) =>
|
||||
new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status,
|
||||
statusText: "",
|
||||
data: { message },
|
||||
headers: new AxiosHeaders(),
|
||||
config: { headers: new AxiosHeaders() },
|
||||
},
|
||||
);
|
||||
|
||||
const build = (request: jest.Mock) =>
|
||||
new PaymentClientService({ request } as never);
|
||||
|
||||
it("posts the OTP to the payment service intent-confirm route", async () => {
|
||||
const request = jest
|
||||
.fn()
|
||||
.mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } }));
|
||||
|
||||
const result = await build(request).confirmOtp("gw-1", "8280");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
url: expect.stringContaining("/payments/intents/gw-1/confirm"),
|
||||
data: { otp: "8280" },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("SUCCEEDED");
|
||||
});
|
||||
|
||||
it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => {
|
||||
const request = jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
throwError(() => axiosErr(400, "OTP confirmation failed")),
|
||||
);
|
||||
|
||||
await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => {
|
||||
const request = jest
|
||||
.fn()
|
||||
.mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found")));
|
||||
|
||||
await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,10 @@ export interface InitiateIntentInput {
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
/** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */
|
||||
payerName?: string;
|
||||
/** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface InitiateIntentResult {
|
||||
@@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
CBE_BILL: "cbe-bill",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -214,8 +219,13 @@ export class PaymentService {
|
||||
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
try {
|
||||
|
||||
|
||||
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
|
||||
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
|
||||
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
|
||||
throw new BadRequestException(
|
||||
"CBE bill payment is only available for ETB invoices",
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
@@ -223,11 +233,15 @@ export class PaymentService {
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
// amountMinor: input.amountMinor,
|
||||
amountMinor:1,
|
||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||
// debited against the intent amount, so the 1-birr dev shortcut would break it.
|
||||
amountMinor: isCbeBill ? input.amountMinor : 1,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
payerName: input.payerName,
|
||||
expiresAt: input.expiresAt,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
@@ -374,6 +388,53 @@ export class PaymentService {
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
|
||||
* id (the invoice's `paymentId`) so the right invoice settles even when several
|
||||
* invoices share a domain reference. The active gateway intent is looked up by
|
||||
* reference, the OTP is forwarded, and the projection is refreshed. On success
|
||||
* billing settles the linked invoice (idempotent — the outbox path converges too).
|
||||
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
|
||||
*/
|
||||
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ id: intentId });
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
(local.referenceType as PaymentReferenceType) ??
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
local.refId,
|
||||
);
|
||||
if (!snapshot) {
|
||||
throw new NotFoundException("No active payment to confirm");
|
||||
}
|
||||
|
||||
const confirmed = await this.paymentClient.confirmOtp(
|
||||
snapshot.intentId,
|
||||
otp,
|
||||
);
|
||||
|
||||
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.markIntentSucceeded(local.id, {
|
||||
providerTxnId: confirmed.providerTxnId,
|
||||
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
} else {
|
||||
await this.paymentRepo.update(
|
||||
{ id: local.id },
|
||||
{
|
||||
status: this.toLocalStatus(confirmed.status),
|
||||
failerCode: confirmed.failureCode ?? undefined,
|
||||
failureMessage: confirmed.failureMessage ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||
return this.formatIntentStatus(refreshed ?? local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||
|
||||
@@ -60,8 +60,10 @@ export class RefundDto {
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
@ApiProperty({
|
||||
enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"],
|
||||
})
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@@ -80,6 +82,17 @@ export class ClientActionDto {
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)",
|
||||
})
|
||||
billReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
instructions?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" })
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -158,6 +158,14 @@ export class BookingJourneyService {
|
||||
this.events.emit('booking.completed', { bookingId });
|
||||
}
|
||||
|
||||
// The cargo is physically off the train at its own yard — mid-corridor or
|
||||
// final. WarehouseInventoryService picks this up to create the warehouse
|
||||
// record (import/intercity only; export already has one from receive).
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
});
|
||||
|
||||
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||
void this.completeMilestones(booking, [
|
||||
...(booking.tradeDirection === 'IMPORT'
|
||||
@@ -319,11 +327,19 @@ export class BookingJourneyService {
|
||||
RETURNING b.id, b.trade_direction`,
|
||||
[schedule.id, schedule.destinationStationId, now],
|
||||
);
|
||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||
for (const row of rows) {
|
||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||
if (row.trade_direction === 'DOMESTIC') {
|
||||
this.events.emit('booking.completed', { bookingId: row.id });
|
||||
}
|
||||
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
|
||||
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
|
||||
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
|
||||
// bookings caught by this fallback never left "awaiting unload".
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId: row.id,
|
||||
tradeDirection: row.trade_direction,
|
||||
});
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
@@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => {
|
||||
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
|
||||
});
|
||||
|
||||
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
|
||||
it('flips the position numbers when the flag is true', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
// Physically-last wagon (was seq 3, wt-c) is now position 1.
|
||||
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
|
||||
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||
// Physically-last wagon (wt-c) is now position 1.
|
||||
expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('keeps each booking with its own wagon — only the position changes', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
// The booking that was in the last wagon now sits at sequenceNo 1.
|
||||
expect(reversed[0].sequenceNo).toBe(1);
|
||||
const atPosition1 = reversed.find((s) => s.sequenceNo === 1);
|
||||
expect(
|
||||
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
|
||||
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
|
||||
).toBe('BKG-C');
|
||||
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
|
||||
expect(
|
||||
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
|
||||
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
|
||||
).toBe('BKG-A');
|
||||
});
|
||||
|
||||
// The regression that emptied every reversed train's container items: the
|
||||
// placement generators pair unit k (booking order) with slot k of this array,
|
||||
// and persistAllocationsAndLoads matches that sequenceNo against the
|
||||
// allocation's booking. Array order must stay packing order.
|
||||
it('keeps array order aligned with booking order so placements still match', () => {
|
||||
const reversed = applyWagonOrderReversal(plan, true);
|
||||
expect(
|
||||
reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId),
|
||||
).toEqual(['BKG-A', 'BKG-B', 'BKG-C']);
|
||||
});
|
||||
|
||||
it('does not mutate the input plan', () => {
|
||||
applyWagonOrderReversal(plan, true);
|
||||
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||
|
||||
@@ -470,15 +470,22 @@ export function planWagonsWithStock(params: {
|
||||
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
|
||||
* read them — so the stored train order and the schedule order stay identical,
|
||||
* just reversed. A false/absent flag returns the plan unchanged.
|
||||
*
|
||||
* Only the NUMBERS flip — the array itself stays in packing order. Container
|
||||
* placements are generated by walking the container units in booking order
|
||||
* against getContainerSlotSequenceNos(plan) in array order, then matched back to
|
||||
* their allocation by `sequenceNo:bookingId`. Reordering the array here broke
|
||||
* that pairing on every reversed schedule: unit 1 was handed the number of the
|
||||
* slot holding the LAST booking, the match missed, and persistAllocationsAndLoads
|
||||
* silently dropped every container item — which is why a reversed export train
|
||||
* printed a marshalling doc with no container numbers and 0/0 container counts.
|
||||
*/
|
||||
export function applyWagonOrderReversal(
|
||||
plan: WagonPlanSlot[],
|
||||
reverse: boolean | null | undefined,
|
||||
): WagonPlanSlot[] {
|
||||
if (!reverse) return plan;
|
||||
return [...plan]
|
||||
.reverse()
|
||||
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
|
||||
return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index }));
|
||||
}
|
||||
|
||||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
/**
|
||||
* A mid-corridor booking (import destined at an intermediate yard, or any
|
||||
* DOMESTIC/intercity ride-along) used to have its booking.status flipped by
|
||||
* the checkpoint-driven unload but never got a warehouse_inventory row — the
|
||||
* Arrival Queue's unload count never moved and the booking was effectively
|
||||
* stranded. handleBookingUnloadedAtYard reacts to the 'booking.unloadedAtYard'
|
||||
* event BookingJourneyService.unloadBooking() emits and creates that row.
|
||||
*/
|
||||
function makeService(opts: {
|
||||
existingInventory?: unknown;
|
||||
booking?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const created: Record<string, unknown>[] = [];
|
||||
|
||||
const inventoryRepository = {
|
||||
findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []),
|
||||
create: jest.fn((row: Record<string, unknown>) => {
|
||||
created.push(row);
|
||||
return Promise.resolve({ id: 'new-inv', ...row });
|
||||
}),
|
||||
};
|
||||
|
||||
const bookingRow =
|
||||
opts.booking === undefined
|
||||
? [{ weight: '10', freightType: 'CONTAINER', cargoTypeCode: 'GEN', customer: 'Acme' }]
|
||||
: opts.booking
|
||||
? [opts.booking]
|
||||
: [];
|
||||
|
||||
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||
service.inventoryRepository = inventoryRepository;
|
||||
service.dataSource = { query: jest.fn().mockResolvedValue(bookingRow), manager: {} };
|
||||
service.allocation = { resolveLocation: jest.fn().mockResolvedValue(null) };
|
||||
service.pickDefaultLocation = jest.fn().mockResolvedValue({ warehouseId: 'w1', yardId: 'y1', zoneId: 'z1' });
|
||||
service.applyCapacityDelta = jest.fn().mockResolvedValue(undefined);
|
||||
service.activityLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service.logger = { warn: jest.fn() };
|
||||
|
||||
return { service: service as unknown as WarehouseInventoryService, created };
|
||||
}
|
||||
|
||||
describe('handleBookingUnloadedAtYard', () => {
|
||||
it('creates an UNLOADED row with an IMPORT GRN for a fresh import booking', async () => {
|
||||
const { service, created } = makeService({});
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b1', tradeDirection: 'IMPORT' });
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0]).toMatchObject({ bookingId: 'b1', status: 'UNLOADED' });
|
||||
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
|
||||
});
|
||||
|
||||
it('creates one for a DOMESTIC/intercity ride-along too', async () => {
|
||||
const { service, created } = makeService({});
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b2', tradeDirection: 'DOMESTIC' });
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].grnNumber).toMatch(/^GRN-DOMESTIC-/);
|
||||
});
|
||||
|
||||
it('skips EXPORT — its warehouse record already exists from the origin receive', async () => {
|
||||
const { service, created } = makeService({});
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b3', tradeDirection: 'EXPORT' });
|
||||
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is idempotent — a booking that already has an inventory row is left alone', async () => {
|
||||
const { service, created } = makeService({ existingInventory: { id: 'existing' } });
|
||||
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
|
||||
.handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' });
|
||||
|
||||
expect(created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -95,11 +95,9 @@ export class WarehouseInspectionService {
|
||||
b.company_id AS "companyId",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
@@ -111,8 +109,11 @@ export class WarehouseInspectionService {
|
||||
readyForPickupAt: new Date(),
|
||||
});
|
||||
|
||||
const hasLastMile =
|
||||
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, which made this always true regardless of the
|
||||
// customer's actual self-haul/EDR-haul choice and permanently dead-coded
|
||||
// the self-haul nudge below. The delivery address is the real signal.
|
||||
const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.());
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
Between,
|
||||
@@ -1339,8 +1339,11 @@ export class WarehouseInventoryService {
|
||||
bcu.seal_numbers AS "sealNumbers",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
-- service_types.includes_last_mile/first_mile are NOT read here: every
|
||||
-- service type ships with both true, so OR-ing them in made this always
|
||||
-- true regardless of the customer's actual self-haul/EDR-haul choice.
|
||||
-- The address is the only per-booking record of that choice.
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
@@ -1351,8 +1354,7 @@ export class WarehouseInventoryService {
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.status AS "status",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus",
|
||||
fm.vehicle_id AS "firstMileVehicleId",
|
||||
@@ -1386,7 +1388,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
@@ -1494,8 +1495,8 @@ export class WarehouseInventoryService {
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
-- No service_types OR here either — see eligibleBookings above.
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
@@ -1519,14 +1520,12 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.company_id AS "companyId",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
${primaryContactUserJoin('company')}
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
@@ -1682,6 +1681,7 @@ export class WarehouseInventoryService {
|
||||
for (const pending of pendingNotifications) {
|
||||
void this.notifyOwnerInventoryReceived(pending.owner);
|
||||
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
|
||||
if (dto.direction === 'EXPORT') void this.notifyCarriageAcceptanceReady(pending.bookingId);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -1979,11 +1979,10 @@ export class WarehouseInventoryService {
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
ts.train_number AS "trainSchedule",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
-- No service_types OR here either — see eligibleBookings above.
|
||||
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||||
-- Multi-truck self-haul writes plates/drivers to
|
||||
-- customer_truck_assignments and leaves the booking columns null,
|
||||
-- so read the assignments first and keep the legacy column as the
|
||||
@@ -2018,7 +2017,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -2112,6 +2110,108 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking alighted from a train at ITS OWN destination yard — emitted by
|
||||
* BookingJourneyService.unloadBooking() for every direction, whether that
|
||||
* yard is a mid-corridor stop (checkpoint auto-unload) or the train's final
|
||||
* yard (manual per-booking unload). That per-booking flow only ever flips
|
||||
* booking.status; it never creates a warehouse_inventory row, which used to
|
||||
* strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status
|
||||
* read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved
|
||||
* (nothing else was watching for a mid-corridor arrival). This creates that
|
||||
* row the moment the cargo is physically off the train.
|
||||
*
|
||||
* EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is
|
||||
* created at the ORIGIN warehouse receive, before the cargo ever boards —
|
||||
* see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive()
|
||||
* above. Creating a second row here would duplicate that receipt.
|
||||
*
|
||||
* Idempotent — a booking already unloaded via this listener, a retried
|
||||
* checkpoint, or the final-yard "Auto Unload" bulk action is left alone.
|
||||
*/
|
||||
@OnEvent('booking.unloadedAtYard')
|
||||
async handleBookingUnloadedAtYard(payload: {
|
||||
bookingId: string;
|
||||
tradeDirection: string | null;
|
||||
}): Promise<void> {
|
||||
if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return;
|
||||
try {
|
||||
const existing = (
|
||||
await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } })
|
||||
)[0];
|
||||
if (existing) return;
|
||||
|
||||
const [booking]: Array<{
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
customer: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT COALESCE(
|
||||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||||
(SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||||
) AS weight,
|
||||
b.freight_type AS "freightType",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
company.name AS customer
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[payload.bookingId],
|
||||
);
|
||||
if (!booking) return;
|
||||
|
||||
const allocated = await this.allocation.resolveLocation({
|
||||
freightType: booking.freightType,
|
||||
tradeDirection: payload.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? (await this.pickDefaultLocation());
|
||||
if (!location) {
|
||||
this.logger.warn(
|
||||
`Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
bookingId: payload.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'UNLOADED',
|
||||
grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer),
|
||||
arrivedAt: now,
|
||||
unloadedAt: now,
|
||||
notes:
|
||||
(allocated as { rule?: { name: string } | null } | null)?.rule
|
||||
? `Unloaded → ${(allocated as { path?: string | null }).path}`
|
||||
: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: saved.warehouseId,
|
||||
description: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||||
});
|
||||
if (Number(saved.weight) > 0) {
|
||||
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
|
||||
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
|
||||
@@ -2689,16 +2789,16 @@ export class WarehouseInventoryService {
|
||||
if (!bookingId) return;
|
||||
const [booking] = await this.dataSource.query(
|
||||
`SELECT reference,
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
const hasLastMile =
|
||||
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, so it can't distinguish EDR last-mile from self-haul.
|
||||
// The delivery address is the only per-booking record of that choice.
|
||||
const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.());
|
||||
if (!booking?.reference || !hasLastMile) return;
|
||||
await this.lastMileService.acceptBooking(booking.reference);
|
||||
}
|
||||
@@ -2809,6 +2909,10 @@ export class WarehouseInventoryService {
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
if (dto.bookingId && bookingDirection === 'EXPORT') {
|
||||
void this.notifyCarriageAcceptanceReady(dto.bookingId);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
@@ -3057,14 +3161,15 @@ export class WarehouseInventoryService {
|
||||
*/
|
||||
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||
const runner = manager ?? this.dataSource;
|
||||
// service_types.includes_last_mile is NOT read here — every service type
|
||||
// ships with it true, which made the second disjunct below unreachable and
|
||||
// this method effectively return true only from an assigned truck.
|
||||
const [row]: Array<{ ok: number }> = await runner.query(
|
||||
`SELECT 1 AS ok
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
AND (b.customer_truck_assigned_at IS NOT NULL
|
||||
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
|
||||
AND COALESCE(st.includes_last_mile, false) = false))`,
|
||||
OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`,
|
||||
[bookingId],
|
||||
);
|
||||
return Boolean(row);
|
||||
@@ -5978,6 +6083,36 @@ export class WarehouseInventoryService {
|
||||
return booking ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the customer their export carriage acceptance sheet is ready to
|
||||
* download from the portal. Export acceptance happens at the warehouse gate
|
||||
* (see BookingsService.carriageAcceptanceSheet) — the sheet is generatable
|
||||
* as soon as the cargo is received, no wagon allocation required, so this
|
||||
* fires right after receive, not at marshalling.
|
||||
*/
|
||||
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Carriage acceptance sheet ready',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference: b.reference },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyOwnerInventoryReceived(params: {
|
||||
phone?: string | null;
|
||||
ownerName?: string | null;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
process.env.TYPEORM_LOGGING = 'false';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
|
||||
|
||||
/**
|
||||
* One-off backfill for bookings caught by the autoArriveAtFinalYard bug
|
||||
* (fixed in booking-journey.service.ts): the bulk final-yard arrival used to
|
||||
* flip booking status to ARRIVED/COMPLETED without ever emitting
|
||||
* booking.unloadedAtYard, so WarehouseInventoryService never created their
|
||||
* warehouse_inventory row. Reuses the same idempotent listener the live
|
||||
* event now calls, so it's safe to re-run.
|
||||
*/
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn'],
|
||||
});
|
||||
|
||||
try {
|
||||
const dataSource = app.get(DataSource);
|
||||
const inventory = app.get(WarehouseInventoryService);
|
||||
|
||||
const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
|
||||
`SELECT b.id, b.trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings b
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
|
||||
AND b.status IN ('ARRIVED', 'COMPLETED')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory wi
|
||||
WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
if (bookings.length === 0) {
|
||||
console.log('No bookings missing their unload inventory row.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Backfilling ${bookings.length} booking(s)...`);
|
||||
for (const booking of bookings) {
|
||||
await inventory.handleBookingUnloadedAtYard({
|
||||
bookingId: booking.id,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
});
|
||||
console.log(` - ${booking.id} (${booking.tradeDirection})`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
24
apps/edr-freight-api/src/scripts/migrate.ts
Normal file
24
apps/edr-freight-api/src/scripts/migrate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import "dotenv/config";
|
||||
import { AppDataSource } from "../data-source";
|
||||
import { ensurePostgresSchemas } from "../config/ensure-postgres-schemas";
|
||||
import { buildDataSourceOptions } from "../config/database.config";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await ensurePostgresSchemas(buildDataSourceOptions());
|
||||
|
||||
await AppDataSource.initialize();
|
||||
try {
|
||||
const applied = await AppDataSource.runMigrations();
|
||||
for (const migration of applied) {
|
||||
console.log(`applied: ${migration.name}`);
|
||||
}
|
||||
if (applied.length === 0) console.log("no pending migrations");
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user