Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { AdminService } from './admin.service';
import { User } from '../users/entities/user.entity';
import { Shipment } from '../shipments/entities/shipment.entity';
Expand Down
34 changes: 34 additions & 0 deletions backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ export interface PlatformStats {

export type AdminAuditRequest = AuditMetadataCarrier;

export interface PaginatedUsers {
data: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}

export interface PaginatedAdminShipments {
data: Shipment[];
total: number;
page: number;
limit: number;
totalPages: number;
}

export interface PlatformStats {
users: {
total: number;
byRole: Record<UserRole, number>;
active: number;
inactive: number;
};
shipments: {
total: number;
byStatus: Record<ShipmentStatus, number>;
disputesPending: number;
};
revenue: {
totalCompleted: number;
currency: string;
};
}

export interface EscrowReconciliationResult {
shipmentId: string;
offChain: {
Expand Down
1 change: 1 addition & 0 deletions backend/src/carriers/carrier-earnings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function makeShipment(
insurancePremium: null,
status: ShipmentStatus.COMPLETED,
notes: null,
cancellationFee: null,
pickupDate: null,
estimatedDeliveryDate: null,
actualDeliveryDate: new Date('2026-08-15T12:00:00.000Z'),
Expand Down
1 change: 1 addition & 0 deletions backend/src/carriers/carriers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function makeShipment(overrides: Partial<Shipment> = {}): Shipment {
insurancePremium: null,
status: ShipmentStatus.COMPLETED,
notes: null,
cancellationFee: null,
pickupDate: null,
estimatedDeliveryDate: new Date('2024-01-10'),
actualDeliveryDate: new Date('2024-01-09'),
Expand Down
19 changes: 16 additions & 3 deletions backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import * as dotenv from 'dotenv';

dotenv.config();

/**
* Glob for migration files, shared between the DataSource and its spec so the
* two cannot drift.
*
* Every migration in this directory is named `<unix-millis>-<Name>.ts` by
* `npm run migration:create` and the TypeORM CLI's own default, so anchoring
* on a leading run of digits selects migrations and nothing else.
*
* The previous `src/migrations/*.ts` also matched `migrations.spec.ts` — the
* Jest suite for the migrations. The CLI would have loaded that file as if it
* were a migration and failed, because it exports no `MigrationInterface`
* implementation.
*/
export const MIGRATIONS_GLOB = 'src/migrations/[0-9]*.ts';

/**
* Dedicated DataSource used by the TypeORM CLI for generating and running
* migrations. The application itself uses TypeOrmModule.forRootAsync()
Expand All @@ -23,9 +38,7 @@ export const AppDataSource = new DataSource({
database: process.env.DATABASE_NAME ?? 'freightflow',
// Glob patterns — CLI resolves these at runtime via ts-node
entities: ['src/**/*.entity.ts'],
// Migration filenames begin with a numeric timestamp. Exclude the colocated
// Jest migration specs from the TypeORM CLI migration list.
migrations: ['src/migrations/[0-9]*.ts'],
migrations: [MIGRATIONS_GLOB],
// Never synchronize in migration mode
synchronize: false,
logging: ['migration'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateShipmentTemplatesTable1724313600000
implements MigrationInterface
{
name = 'CreateShipmentTemplatesTable1724313600000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "shipment_templates" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"origin" VARCHAR(255) NOT NULL,
"destination" VARCHAR(255) NOT NULL,
"cargo_description" TEXT NOT NULL,
"weight_kg" NUMERIC(10,2) NOT NULL,
"price" NUMERIC(14,2) NOT NULL,
"currency" VARCHAR(3) NOT NULL DEFAULT 'USD',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT "PK_shipment_templates_id" PRIMARY KEY ("id"),
CONSTRAINT "UQ_shipment_templates_user_id_name"
UNIQUE ("user_id", "name"),
CONSTRAINT "FK_shipment_templates_user" FOREIGN KEY ("user_id")
REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`);

await queryRunner.query(`
CREATE INDEX "IDX_shipment_templates_user_id"
ON "shipment_templates" ("user_id")
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_shipment_templates_user_id"`);
await queryRunner.query(`DROP TABLE "shipment_templates"`);
}
}
29 changes: 29 additions & 0 deletions backend/src/migrations/1724400000000-AddShipmentCancellationFee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddShipmentCancellationFee1724400000000
implements MigrationInterface
{
name = 'AddShipmentCancellationFee1724400000000';

/**
* `cancellation_fee` records the tiered fee retained by the platform when
* a shipment is cancelled (see `CancellationFeeService`). It is NULL
* while the shipment is live, so the column also answers "was this
* cancelled for a fee?".
*
* Guarded with IF NOT EXISTS so the migration is safe to re-run on
* environments where `synchronize` already created the column.
*/
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "shipments"
ADD COLUMN IF NOT EXISTS "cancellation_fee" NUMERIC(14,2)
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "shipments" DROP COLUMN IF EXISTS "cancellation_fee"
`);
}
}
102 changes: 97 additions & 5 deletions backend/src/migrations/migrations.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { globSync } from 'node:fs';
import { AppDataSource, MIGRATIONS_GLOB } from '../data-source';
import { CreatePaymentsTable1724140800000 } from './1724140800000-CreatePaymentsTable';
import { AddPaymentFundingFields1724227200000 } from './1724227200000-AddPaymentFundingFields';
import { CreateShipmentTemplatesTable1724313600000 } from './1724313600000-CreateShipmentTemplatesTable';
import { AddShipmentCancellationFee1724400000000 } from './1724400000000-AddShipmentCancellationFee';
import { AddCertificationDocumentProvenance1724313600000 } from './1724313600000-AddCertificationDocumentProvenance';
import { CreateTwoFactorOtpTable1724313600000 } from './1724313600000-CreateTwoFactorOtpTable';
import { AppDataSource } from '../data-source';

function mockQueryRunner() {
return {
Expand Down Expand Up @@ -39,10 +42,66 @@ describe('migrations', () => {
);
});

it('uses a CLI migration glob that excludes colocated specs', () => {
expect(AppDataSource.options.migrations).toEqual([
'src/migrations/[0-9]*.ts',
]);
it('creates and drops the shipment templates table', async () => {
const queryRunner = mockQueryRunner();
const migration = new CreateShipmentTemplatesTable1724313600000();

await migration.up(queryRunner as never);
await migration.down(queryRunner as never);

expect(queryRunner.query).toHaveBeenCalledWith(
expect.stringContaining('CREATE TABLE "shipment_templates"'),
);
expect(queryRunner.query).toHaveBeenCalledWith(
expect.stringContaining('DROP TABLE "shipment_templates"'),
);
});

it('gives shipment templates every field the entity declares', async () => {
const queryRunner = mockQueryRunner();
await new CreateShipmentTemplatesTable1724313600000().up(
queryRunner as never,
);

const createTableCall = queryRunner.query.mock.calls.find(
(call: unknown[]) =>
String(call[0]).includes('CREATE TABLE "shipment_templates"'),
);
const sql = String((createTableCall as unknown[] | undefined)?.[0]);

for (const column of [
'"user_id"',
'"name"',
'"origin"',
'"destination"',
'"cargo_description"',
'"weight_kg"',
'"price"',
'"currency"',
'"created_at"',
'"updated_at"',
]) {
expect(sql).toContain(column);
}
// Ownership is a per-user namespace with a unique name, and deleting
// a user must take their templates with it.
expect(sql).toContain('UNIQUE ("user_id", "name")');
expect(sql).toContain('REFERENCES "users"("id") ON DELETE CASCADE');
});

it('adds and removes the shipment cancellation fee column', async () => {
const queryRunner = mockQueryRunner();
const migration = new AddShipmentCancellationFee1724400000000();

await migration.up(queryRunner as never);
await migration.down(queryRunner as never);

expect(queryRunner.query).toHaveBeenCalledWith(
expect.stringContaining('ADD COLUMN IF NOT EXISTS "cancellation_fee"'),
);
expect(queryRunner.query).toHaveBeenCalledWith(
expect.stringContaining('DROP COLUMN IF EXISTS "cancellation_fee"'),
);
});

it('adds platform document provenance and expiry-safe certification fields', async () => {
Expand Down Expand Up @@ -101,4 +160,37 @@ describe('migrations', () => {
expect.stringContaining('DROP TABLE "two_factor_otps"'),
);
});

describe('CLI discovery glob', () => {
// The DataSource glob used to be `src/migrations/*.ts`, which also matched
// this spec file. The TypeORM CLI loads every match as a migration and
// fails on one that exports no MigrationInterface.
it('is the glob the DataSource actually uses', () => {
expect(AppDataSource.options.migrations).toEqual([MIGRATIONS_GLOB]);
});

it('does not match the spec file', () => {
const matched = globSync(MIGRATIONS_GLOB);

expect(matched).not.toContain('migrations.spec.ts');
expect(
matched.every((path) => !path.endsWith('.spec.ts')),
).toBe(true);
});

it('still matches every real migration', () => {
const matched = globSync(MIGRATIONS_GLOB);

for (const migration of [
'1724140800000-CreatePaymentsTable.ts',
'1724227200000-AddPaymentFundingFields.ts',
'1724313600000-AddCertificationDocumentProvenance.ts',
'1724313600000-CreateShipmentTemplatesTable.ts',
'1724313600000-CreateTwoFactorOtpTable.ts',
'1724400000000-AddShipmentCancellationFee.ts',
]) {
expect(matched).toContain(`src/migrations/${migration}`);
}
});
});
});
1 change: 1 addition & 0 deletions backend/src/notifications/notifications.gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('NotificationsGateway', () => {
onChainShipmentId: null,
insurancePremium: null,
notes: null,
cancellationFee: null,
pickupDate: null,
estimatedDeliveryDate: null,
actualDeliveryDate: null,
Expand Down
1 change: 1 addition & 0 deletions backend/src/notifications/notifications.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function makeShipment(overrides: Partial<Shipment> = {}): Shipment {
onChainShipmentId: null,
insurancePremium: null,
notes: null,
cancellationFee: null,
pickupDate: null,
estimatedDeliveryDate: null,
actualDeliveryDate: null,
Expand Down
21 changes: 21 additions & 0 deletions backend/src/payments/errors/payment-flow.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type PaymentFlowErrorCode =
| 'PAYMENT_ALREADY_FUNDED'
| 'PAYMENT_ALREADY_IN_FLIGHT'
| 'MISSING_WALLET_ADDRESS'
| 'INVALID_CANCELLATION_FEE'
| 'ESCROW_SIMULATION_FAILED'
| 'ESCROW_SUBMISSION_FAILED'
| 'ESCROW_CONTRACT_REJECTED';
Expand Down Expand Up @@ -86,6 +87,26 @@ export class MissingWalletAddressError extends PaymentFlowError {
}
}

/**
* The computed cancellation fee would leave the shipper with nothing, which
* the escrow contract refuses (`fee >= amount`). Raised before any chain call
* so the payment row stays `FUNDED` and the shipment is not cancelled into an
* inconsistent state.
*/
export class InvalidCancellationFeeError extends PaymentFlowError {
constructor(
readonly escrowedAmount: number,
readonly requestedFee: number,
) {
super(
'INVALID_CANCELLATION_FEE',
`A cancellation fee of ${requestedFee} cannot be charged against an escrowed amount of ${escrowedAmount}`,
HttpStatus.UNPROCESSABLE_ENTITY,
{ escrowedAmount, requestedFee },
);
}
}

/**
* Simulation failed before anything was submitted — most commonly the
* shipper's token balance or `approve` allowance to the escrow contract is
Expand Down
Loading