From 312014b6780cf611eeee48c5763874db0dfe15de Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 07:13:24 +0000 Subject: [PATCH 01/19] fix first/last mile --- .../src/pages/operations/FirstMilePage.tsx | 60 ++++++++++++++++++- .../src/pages/operations/LastMilePage.tsx | 60 ++++++++++++++++++- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index a8c55ecf4..69812182f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => { ]; return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; }; +/** + * The mile bills as distance × pricePerKm in the vehicle's own currency, so a + * vehicle missing either field cannot produce an invoice line. Returns the + * human-readable gap, or null when the vehicle is billable. + */ +const pricingGap = ( + v?: { pricePerKm?: number | string | null; currency?: string | null } | null, +): string | null => { + if (!v) return null; + const missing: string[] = []; + if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM"); + if (!String(v.currency ?? "").trim()) missing.push("Currency"); + return missing.length ? missing.join(" and ") : null; +}; const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; const cargoDesc = (r: FirstMileRecord) => { @@ -681,6 +695,22 @@ const FirstMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment + // below rather than silently billing 0 once distances are entered. + const pricingGapById = useMemo(() => { + const map = new Map(); + const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => { + if (v?.id) map.set(v.id, pricingGap(v)); + }; + for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v); + for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle); + add(activeRecord?.vehicle); + return map; + }, [vehiclesData, activeRecord]); + + const vehicleLabelFor = (id: string) => + assignVehicleOptions.find((o) => o.value === id)?.label ?? id; + // Full booking (with container units) for the assign modal's container dropdown. // Fetched on open so container numbers show regardless of what the list embeds. const { data: assignBooking } = useQuery({ @@ -932,6 +962,21 @@ const FirstMilePage = () => { if (!targetIds.length) return; + // Backstop for rows the Select guard never saw (pre-filled reassignments). + const unpriced = vehicles + .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) })) + .filter((v): v is { label: string; gap: string } => Boolean(v.gap)); + if (unpriced.length) { + toast({ + title: "Vehicle is not priced", + description: `${unpriced + .map((v) => `${v.label} (${v.gap} not set)`) + .join("; ")} — set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + // Empty set = unassign all (setVehicles releases the removed vehicles). Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { @@ -1365,9 +1410,18 @@ const FirstMilePage = () => { (o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), )} value={row.vehicleId} - onChange={(v) => - setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) - } + onChange={(v) => { + const gap = v ? pricingGapById.get(v) : null; + if (v && gap) { + toast({ + title: "Vehicle is not priced", + description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))); + }} searchable clearable disabled={assignVehicleOptions.length === 0} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index e5de54880..cd5d22c6b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -251,6 +251,20 @@ const billingIssues = (r: LastMileRecord) => { ]; return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; }; +/** + * The mile bills as distance × pricePerKm in the vehicle's own currency, so a + * vehicle missing either field cannot produce an invoice line. Returns the + * human-readable gap, or null when the vehicle is billable. + */ +const pricingGap = ( + v?: { pricePerKm?: number | string | null; currency?: string | null } | null, +): string | null => { + if (!v) return null; + const missing: string[] = []; + if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM"); + if (!String(v.currency ?? "").trim()) missing.push("Currency"); + return missing.length ? missing.join(" and ") : null; +}; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; const cargoDesc = (r: LastMileRecord) => { @@ -870,6 +884,22 @@ const LastMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment + // below rather than silently billing 0 once distances are entered. + const pricingGapById = useMemo(() => { + const map = new Map(); + const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => { + if (v?.id) map.set(v.id, pricingGap(v)); + }; + for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v); + for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle); + add(activeRecord?.vehicle); + return map; + }, [vehiclesData, activeRecord]); + + const vehicleLabelFor = (id: string) => + assignVehicleOptions.find((o) => o.value === id)?.label ?? id; + // Full booking (with container units) for the assign modal's container dropdown. // Fetched on open so container numbers show regardless of what the list embeds. const { data: assignBooking } = useQuery({ @@ -1018,6 +1048,21 @@ const LastMilePage = () => { if (!targetIds.length) return; + // Backstop for rows the Select guard never saw (pre-filled reassignments). + const unpriced = vehicles + .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) })) + .filter((v): v is { label: string; gap: string } => Boolean(v.gap)); + if (unpriced.length) { + toast({ + title: "Vehicle is not priced", + description: `${unpriced + .map((v) => `${v.label} (${v.gap} not set)`) + .join("; ")} — set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + // Empty set = unassign all (setVehicles releases the removed vehicles). Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { @@ -1738,9 +1783,18 @@ const LastMilePage = () => { (o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), )} value={row.vehicleId} - onChange={(v) => - setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) - } + onChange={(v) => { + const gap = v ? pricingGapById.get(v) : null; + if (v && gap) { + toast({ + title: "Vehicle is not priced", + description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))); + }} searchable clearable disabled={assignVehicleOptions.length === 0} From a4b330941215f32e24732bce9e1a07bd7f75b9c1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 07:43:54 +0000 Subject: [PATCH 02/19] add seed --- ...0000000000-SeedEdrWagonFleetErNumbering.ts | 104 ++++++++++++++++++ .../src/scripts/seed-edr-wagons.ts | 34 +++--- 2 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts new file mode 100644 index 000000000..fdfcf8be5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -0,0 +1,104 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Re-seed the EDR wagon fleet onto the official ER numbering. + * + * Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons + * on a `-NNNN` scheme and wrote the status as 'Available' — mixed case + * that never matches WagonStatus.Available ('AVAILABLE'), so status filters + * silently returned nothing. This seed uses the enum value. + * + * Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon + * specs (capacity/length/tare) stay owned by wagon_types and are not touched — + * the types already exist and only the wagon↔type link is (re)established here. + */ +type FleetRow = { + code: string; + start: number; + end: number; + count: number; +}; + +/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */ +const FLEET: FleetRow[] = [ + { code: 'PW2', start: 1, end: 220, count: 220 }, + { code: 'CW4', start: 221, end: 330, count: 110 }, + { code: 'CW3', start: 331, end: 350, count: 20 }, + { code: 'KW2', start: 351, end: 370, count: 20 }, + { code: 'KW3', start: 371, end: 390, count: 20 }, + { code: 'NW5', start: 391, end: 940, count: 550 }, + { code: 'BW1', start: 941, end: 950, count: 10 }, + { code: 'GW2', start: 951, end: 1060, count: 110 }, + { code: 'NW6', start: 1061, end: 1080, count: 20 }, + { code: 'NW7', start: 1081, end: 1100, count: 20 }, +]; + +const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`; + +export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface { + name = 'SeedEdrWagonFleetErNumbering2260000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Full replacement: the ER range is the fleet of record, so any wagon + // outside it is stale seed data. Safe to hard-delete — containers and + // train_set_wagons null their link, wagon_movements cascade. + await queryRunner.query(`DELETE FROM freight.wagons;`); + + for (const row of FLEET) { + if (row.end - row.start + 1 !== row.count) { + throw new Error(`wagon_range_mismatch:${row.code}`); + } + + const [typeRecord] = await queryRunner.query( + `SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`, + [row.code], + ); + + if (!typeRecord?.id) { + throw new Error(`wagon_type_missing:${row.code}`); + } + + // generate_series builds the range server-side — one round trip per type + // instead of 1100 individual INSERTs. + await queryRunner.query( + ` + INSERT INTO freight.wagons ( + wagon_number, + wagon_type_id, + status, + current_yard_id, + train_id, + sequence_number, + notes, + train_set_wagon_id, + current_train_schedule_id + ) + SELECT + 'ER' || LPAD(seq::text, 4, '0'), + $1::uuid, + 'AVAILABLE', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM generate_series($2::int, $3::int) AS seq + ON CONFLICT (wagon_number) DO UPDATE SET + wagon_type_id = EXCLUDED.wagon_type_id, + status = EXCLUDED.status, + current_yard_id = EXCLUDED.current_yard_id, + updated_at = now(); + `, + [typeRecord.id, row.start, row.end], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`, + [wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)], + ); + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts index b6a6484f8..716532165 100644 --- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -1,5 +1,5 @@ import { AppDataSource } from '../data-source'; -import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet'; +import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering'; async function seedEdRWagons() { await AppDataSource.initialize(); @@ -10,28 +10,32 @@ async function seedEdRWagons() { await queryRunner.connect(); await queryRunner.startTransaction(); - await new SeedEdRWagonFleet1750400000000().up(queryRunner); + await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner); - const [summary] = await queryRunner.query(` + const summary = await queryRunner.query(` SELECT - COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2, - COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4, - COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3, - COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2, - COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3, - COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5, - COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready, - COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready, - COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready + wt.code, + wt.name, + COUNT(*)::int AS wagons, + MIN(w.wagon_number) AS first_wagon, + MAX(w.wagon_number) AS last_wagon, + COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available, + COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard FROM freight.wagons w JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id - WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940'; + WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100' + GROUP BY wt.code, wt.name + ORDER BY MIN(w.wagon_number); + `); + + const [totals] = await queryRunner.query(` + SELECT COUNT(*)::int AS total FROM freight.wagons; `); await queryRunner.commitTransaction(); - console.log('Seeded EDR wagon fleet:', summary); + console.table(summary); + console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`); } catch (error) { await queryRunner.rollbackTransaction(); throw error; From edbef5ccf20e8513f591c64284e54d846952447b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 16 Jul 2026 11:01:31 +0300 Subject: [PATCH 03/19] add permissions --- .../src/modules/tickets/tickets.controller.ts | 7 ++++--- .../src/seed/passenger-permissions.registry.ts | 9 +++++++-- apps/edr-passenger-web/backoffice/src/lib/permissions.ts | 1 + .../src/providers/waafi/waafi.provider.ts | 6 +++++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index e98245755..51d3b0630 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Tickets') @Controller('tickets') @@ -10,7 +11,7 @@ export class TicketsController { constructor(private service: TicketsService) {} @Post('smart-assign/:bookingId') - @PassengerAdmin() + @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Smart seat assignment + ticket generation', @@ -23,7 +24,7 @@ export class TicketsController { } @Post('generate/:bookingId') - @PassengerAdmin() + @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index d62d5a261..05235e3a8 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -22,6 +22,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'), perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'), perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'), + perm('7f3a1e9c-2b4d-4c8a-9e6f-1a2b3c4d5e6f', 'edr_passenger_app:tickets:generate', 'Generate tickets'), perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'), perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'), perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), @@ -82,8 +83,9 @@ export const PASSENGER_PERMS = { manage: 'edr_passenger_app:passengers:manage', }, tickets: { - view: 'edr_passenger_app:tickets:view', - manage: 'edr_passenger_app:tickets:manage', + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + generate: 'edr_passenger_app:tickets:generate', }, payments: { view: 'edr_passenger_app:payments:view', @@ -172,6 +174,7 @@ export const ROLE_PERMISSION_PRESETS = { PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.agents.view, PASSENGER_PERMS.audit.view, @@ -182,6 +185,7 @@ export const ROLE_PERMISSION_PRESETS = { ticketOfficer: [ PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.bookings.view, PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.dashboard.view, @@ -194,6 +198,7 @@ export const ROLE_PERMISSION_PRESETS = { PASSENGER_PERMS.passengers.view, PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.dashboard.view, ], diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts index 2a893f971..2c2d54f80 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts @@ -12,6 +12,7 @@ export const PERMS = { tickets: { view: 'edr_passenger_app:tickets:view', manage: 'edr_passenger_app:tickets:manage', + generate: 'edr_passenger_app:tickets:generate', }, // ── Master Data ──────────────────────────────────────────────── diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index 5914fcd61..e7d78f752 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -129,13 +129,17 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit { requestBody, ); + this.logger.log( + `Waafi HPP_GETTRANINFO ref=${merchantOrderId} response: ${JSON.stringify(response)}`, + ); + // Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an // unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206 // "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING), // never terminal — so the intent keeps waiting for the webhook / its expiry rather than being // wrongly resolved off a "no info" response. if (response.responseCode !== WAAFI_SUCCESS_CODE) { - this.logger.debug( + this.logger.warn( `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`, ); return { From 4a04aa7e974f7d811a05765d520e9704cca9423b Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 08:13:22 +0000 Subject: [PATCH 04/19] fic --- ...0000000000-SeedEdrWagonFleetErNumbering.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts index fdfcf8be5..717a48217 100644 --- a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -44,6 +44,14 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter // train_set_wagons null their link, wagon_movements cascade. await queryRunner.query(`DELETE FROM freight.wagons;`); + // Wagon.wagonNumber declares `unique: true`, but some environments never got + // the constraint. Repair it here — the table is empty at this point, so the + // index build cannot fail on pre-existing duplicates. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key + ON freight.wagons (wagon_number); + `); + for (const row of FLEET) { if (row.end - row.start + 1 !== row.count) { throw new Error(`wagon_range_mismatch:${row.code}`); @@ -59,7 +67,9 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter } // generate_series builds the range server-side — one round trip per type - // instead of 1100 individual INSERTs. + // instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon + // was deleted above, so a plain INSERT cannot collide, and the clause would + // otherwise hard-require a unique index this table lacks on some envs. await queryRunner.query( ` INSERT INTO freight.wagons ( @@ -83,12 +93,7 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter NULL, NULL, NULL - FROM generate_series($2::int, $3::int) AS seq - ON CONFLICT (wagon_number) DO UPDATE SET - wagon_type_id = EXCLUDED.wagon_type_id, - status = EXCLUDED.status, - current_yard_id = EXCLUDED.current_yard_id, - updated_at = now(); + FROM generate_series($2::int, $3::int) AS seq; `, [typeRecord.id, row.start, row.end], ); From a852b4e619b1ab3d673e02e292c00fb37522d8c2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 16 Jul 2026 11:43:04 +0300 Subject: [PATCH 05/19] Seats report, supplementary change for bookings added --- .../migration.sql | 33 ++ apps/edr-passenger-api/prisma/schema.prisma | 23 + .../modules/payments/payments.controller.ts | 110 +++- .../src/modules/payments/payments.module.ts | 7 +- .../src/modules/payments/payments.service.ts | 35 +- .../payments/supplementary-charges.service.ts | 193 +++++++ .../payments/SupplementaryChargesModal.tsx | 290 +++++++++++ .../backoffice/src/app/payments/page.tsx | 11 +- .../app/payments/useSupplementaryCharges.ts | 47 ++ .../backoffice/src/app/reports/page.tsx | 469 +++++++++++------- .../src/app/reports/seats/layout.tsx | 3 + .../backoffice/src/app/reports/seats/page.tsx | 324 ++++++++++++ .../src/components/layout/Sidebar.tsx | 3 +- .../backoffice/src/lib/api/index.ts | 19 + packages/types/src/common/payments.ts | 1 + 15 files changed, 1392 insertions(+), 176 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/seats/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql new file mode 100644 index 000000000..fce916917 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260716082303_add_supplementary_charge/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "SupplementaryCharge" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "amountMinor" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'ETB', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "paymentToken" TEXT NOT NULL, + "providerTxnId" TEXT, + "notes" TEXT, + "createdBy" TEXT NOT NULL, + "paidAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SupplementaryCharge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SupplementaryCharge_paymentToken_key" ON "SupplementaryCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_bookingId_idx" ON "SupplementaryCharge"("bookingId"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_paymentToken_idx" ON "SupplementaryCharge"("paymentToken"); + +-- CreateIndex +CREATE INDEX "SupplementaryCharge_status_idx" ON "SupplementaryCharge"("status"); + +-- AddForeignKey +ALTER TABLE "SupplementaryCharge" ADD CONSTRAINT "SupplementaryCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 2721bb156..cef7d0033 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -567,6 +567,7 @@ model Booking { cancellation BookingCancellation? baggage BaggageBooking[] excessBaggageCharges ExcessBaggageCharge[] + supplementaryCharges SupplementaryCharge[] journey Journey? @@index([passengerId, status]) @@ -1234,6 +1235,28 @@ model BaggageBooking { @@schema("passenger") } +model SupplementaryCharge { + id String @id @default(uuid()) + bookingId String + reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION" + amountMinor Int + currency String @default("ETB") + status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED + paymentToken String @unique @default(uuid()) + providerTxnId String? + notes String? + createdBy String + paidAt DateTime? + expiresAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId]) + @@index([paymentToken]) + @@index([status]) + @@schema("passenger") +} + model ExcessBaggageCharge { id String @id @default(uuid()) bookingId String diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 50cd99ec4..9a0288656 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -39,12 +39,34 @@ import { import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; +import { SupplementaryChargesService } from "./supplementary-charges.service"; +import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +class CreateSupplementaryChargeDto { + @ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string; + @ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number; + @ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string; + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} + +class WaiveSupplementaryChargeDto { + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} + +class PaySupplementaryChargeDto { + @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; + @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile'; +} @ApiTags("Payment") @Controller("payments") // @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PaymentsController { - constructor(private service: PaymentsService) {} + constructor( + private service: PaymentsService, + private supplementaryService: SupplementaryChargesService, + ) {} @Delete(":id") @PassengerStaff([PASSENGER_PERMS.admin]) @@ -315,6 +337,92 @@ export class PaymentsController { } } + // ── Supplementary Charges ────────────────────────────────────────────────── + + @Post('supplementary') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) + createSupplementaryCharge( + @Body() dto: CreateSupplementaryChargeDto, + @Headers('x-iam-user-id') iamUserId?: string, + ) { + return this.supplementaryService.create({ + ...dto, + createdBy: iamUserId ?? 'staff', + }); + } + + @Get('supplementary') + @PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List supplementary charges (staff only)' }) + @ApiQuery({ name: 'bookingRef', required: false }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + listSupplementaryCharges( + @Query('bookingRef') bookingRef?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.supplementaryService.getAll({ + bookingRef, + status, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 20, + }); + } + + @Get('supplementary/by-token/:token') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' }) + getSupplementaryByToken(@Param('token') token: string) { + return this.supplementaryService.getByToken(token); + } + + @Post('supplementary/by-token/:token/pay') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' }) + paySupplementaryCharge( + @Param('token') token: string, + @Body() dto: PaySupplementaryChargeDto, + ) { + return this.supplementaryService.pay(token, dto.method, dto.platform); + } + + @Post('supplementary/:id/mark-paid') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' }) + markSupplementaryPaid( + @Param('id') id: string, + @Body() body: { providerTxnId?: string }, + ) { + return this.supplementaryService.markPaid(id, body.providerTxnId); + } + + @Post('supplementary/:id/waive') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Waive a supplementary charge (staff only)' }) + waiveSupplementaryCharge( + @Param('id') id: string, + @Body() dto: WaiveSupplementaryChargeDto, + @Headers('x-iam-user-id') iamUserId?: string, + ) { + return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff'); + } + + @Post('supplementary/:id/resend') + @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' }) + resendSupplementaryLink(@Param('id') id: string) { + return this.supplementaryService.resendLink(id); + } + private buildRedirectHtml(url: string): string { const escaped = url.replace(/\"/g, """); return ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 3c08eb3cd..6e4be1aa0 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -12,6 +12,7 @@ import { } from "@edr/types"; import { PaymentsController } from "./payments.controller"; import { PaymentsService } from "./payments.service"; +import { SupplementaryChargesService } from "./supplementary-charges.service"; import { InternalPaymentsController } from "./internal-payments.controller"; import { PaymentClientService } from "./payment-client.service"; import { PaymentEventsConsumer } from "./payment-events.consumer"; @@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module"; import { CurrencyModule } from "../currency/currency.module"; import { AuditModule } from "../../common/audit.module"; +import { NotificationsModule } from "../notifications/notifications.module"; + const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; function rabbitMQImport(): DynamicModule[] { @@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] { TicketsModule, CurrencyModule, AuditModule, - // The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an - // OTP and can take tens of seconds). Keep this hop generous; overridable via env. + NotificationsModule, HttpModule.register({ timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, }), @@ -65,6 +67,7 @@ function rabbitMQImport(): DynamicModule[] { controllers: [PaymentsController, InternalPaymentsController], providers: [ PaymentsService, + SupplementaryChargesService, PaymentClientService, PaymentEventsConsumer, ServiceAuthGuard, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5bd068255..78856ccd5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -833,19 +833,46 @@ export class PaymentsService { return { alreadyFinalized: false }; } + private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise { + if (event.eventType === 'payment.failed') { + this.logger.warn(`supplementary charge ${event.referenceId} payment failed`); + return { processed: true }; + } + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } }); + if (!charge) { + this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`); + return { processed: false, reason: 'charge-not-found' }; + } + if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true }; + await this.prisma.supplementaryCharge.update({ + where: { id: charge.id }, + data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } }); + return { processed: true }; + } + async handlePaymentEvent( event: PaymentEventDto, ): Promise { - if ( - event.service !== PaymentServiceEnum.PASSENGER || - event.referenceType !== PaymentReferenceType.BOOKING - ) { + if (event.service !== PaymentServiceEnum.PASSENGER) { this.logger.warn( `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`, ); return { processed: false, reason: "foreign-reference" }; } + if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) { + return this.handleSupplementaryChargeEvent(event); + } + + if (event.referenceType !== PaymentReferenceType.BOOKING) { + this.logger.warn( + `mark-paid: ignoring unknown referenceType ${event.referenceType}`, + ); + return { processed: false, reason: "foreign-reference" }; + } + if (event.eventType === "payment.failed") { const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: event.referenceId }, diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts new file mode 100644 index 000000000..8b604d905 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -0,0 +1,193 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; +import { EmailClientService } from '../notifications/email-client.service'; +import { PaymentClientService } from './payment-client.service'; +import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types'; + +const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours + +@Injectable() +export class SupplementaryChargesService { + private readonly logger = new Logger(SupplementaryChargesService.name); + + constructor( + private prisma: PrismaService, + private auditService: AuditService, + private smsClient: SmsClientService, + private emailClient: EmailClientService, + private paymentClient: PaymentClientService, + ) {} + + async create(dto: { + bookingRef: string; + amountMinor: number; + reason: string; + notes?: string; + createdBy: string; + }) { + const booking = await this.prisma.booking.findUnique({ + where: { bookingRef: dto.bookingRef }, + include: { passenger: { include: { user: true } } }, + }); + if (!booking) throw new NotFoundException('Booking not found'); + if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) { + throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge'); + } + if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive'); + + const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); + const charge = await this.prisma.supplementaryCharge.create({ + data: { + bookingId: booking.id, + reason: dto.reason, + amountMinor: dto.amountMinor, + notes: dto.notes ?? null, + createdBy: dto.createdBy, + expiresAt, + }, + }); + + const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; + const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + await this.sendLink(charge, booking.bookingRef, phone, email); + + await this.auditService.log({ + action: 'CREATE', + entityType: 'SupplementaryCharge', + entityId: charge.id, + newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason }, + }); + return charge; + } + + async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) { + const { bookingRef, status, page = 1, pageSize = 20 } = filters; + const skip = (page - 1) * pageSize; + const where: any = {}; + if (status) where.status = status; + if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } }; + + await this.prisma.supplementaryCharge.updateMany({ + where: { status: 'PENDING', expiresAt: { lt: new Date() } }, + data: { status: 'EXPIRED' }, + }); + + const [items, total] = await Promise.all([ + this.prisma.supplementaryCharge.findMany({ + where, + include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.supplementaryCharge.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async getByToken(token: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { paymentToken: token }, + include: { booking: { select: { bookingRef: true } } }, + }); + if (!charge) throw new NotFoundException('Payment link not found'); + if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid'); + if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived'); + if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) { + if (charge.status === 'PENDING') { + await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } }); + } + throw new BadRequestException('This payment link has expired'); + } + return charge; + } + + async markPaid(id: string, providerTxnId?: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') return charge; + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } }); + return updated; + } + + async pay(token: string, method: string, platform?: 'web' | 'mobile') { + const charge = await this.getByToken(token); // validates status/expiry + + const paymentMethod = method as ProviderMethod; + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const returnUrl = `${portalUrl}/pay-balance/${token}/success`; + const failureUrl = `${portalUrl}/pay-balance/${token}/failed`; + + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE, + referenceId: charge.id, + orderRef: `SC-${charge.id.substring(0, 8)}`, + amountMinor: charge.amountMinor, + currency: charge.currency, + provider: paymentMethod, + platform, + returnUrl, + failureUrl, + }); + + return snapshot; + } + + async waive(id: string, notes: string, waivedBy: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge'); + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { status: 'WAIVED', notes }, + }); + await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } }); + return updated; + } + + async resendLink(id: string) { + const charge = await this.prisma.supplementaryCharge.findUnique({ + where: { id }, + include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } }, + }); + if (!charge) throw new NotFoundException('Charge not found'); + if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges'); + const updated = await this.prisma.supplementaryCharge.update({ + where: { id }, + data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) }, + }); + await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail); + return { sent: true }; + } + + private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) { + const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174'; + const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`; + const amount = (charge.amountMinor / 100).toFixed(2); + const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`; + + if (phone) { + try { await this.smsClient.sendSms({ to: phone, message: msg }); } + catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); } + } + if (email) { + try { + await this.emailClient.sendEmail({ + to: email, + subject: `EDR — Outstanding balance for booking ${bookingRef}`, + text: msg, + }); + } catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); } + } + if (!phone && !email) { + this.logger.warn(`No contact info for supplementary charge ${charge.id}`); + } + } +} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx new file mode 100644 index 000000000..9fb0de6bb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState } from 'react'; +import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; +import Badge from '@/components/ui/Badge'; +import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { + useSupplementaryCharges, + useCreateSupplementaryCharge, + useMarkSupplementaryPaid, + useWaiveSupplementaryCharge, + useResendSupplementaryLink, +} from './useSupplementaryCharges'; + +type Tab = 'create' | 'list'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; + +const STATUS_COLORS: Record = { + PENDING: 'warning', + PAID: 'success', + WAIVED: 'info', + EXPIRED: 'error', +}; + +export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { + const [tab, setTab] = useState('create'); + const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' }); + + // Create form state + const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + const [formError, setFormError] = useState(null); + const [createSuccess, setCreateSuccess] = useState(null); + + const { data: chargesData, isLoading } = useSupplementaryCharges(listFilters); + const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []); + + const createMutation = useCreateSupplementaryCharge(() => { + setCreateSuccess(`Charge created and payment link sent.`); + setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + setFormError(null); + setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000); + }); + + const markPaidMutation = useMarkSupplementaryPaid(); + const waiveMutation = useWaiveSupplementaryCharge(); + const resendMutation = useResendSupplementaryLink(); + + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + + const flash = (msg: string) => { + setActionSuccess(msg); + setTimeout(() => setActionSuccess(null), 3000); + }; + + const handleCreate = async () => { + setFormError(null); + const amountMinor = Math.round(parseFloat(form.amountEtb) * 100); + if (!form.bookingRef.trim()) return setFormError('Booking reference is required'); + if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount'); + try { + await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined }); + } catch (e: any) { + setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge'); + } + }; + + const handleMarkPaid = async (id: string) => { + setActionError(null); + try { + await markPaidMutation.mutateAsync({ id }); + flash('Marked as paid'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleWaive = async (id: string) => { + setActionError(null); + try { + await waiveMutation.mutateAsync({ id }); + flash('Charge waived'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + const handleResend = async (id: string) => { + setActionError(null); + try { + await resendMutation.mutateAsync(id); + flash('Payment link resent'); + } catch (e: any) { + setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); + } + }; + + return ( + + {/* Tabs */} +
+ {(['create', 'list'] as Tab[]).map((t) => ( + + ))} +
+ + {/* ── CREATE TAB ── */} + {tab === 'create' && ( +
+ {createSuccess && ( +
✓ {createSuccess}
+ )} + {formError && ( +
{formError}
+ )} + +
+
+ + setForm({ ...form, bookingRef: e.target.value })} + /> +
+
+ + setForm({ ...form, amountEtb: e.target.value })} + /> +
+
+ + +
+
+ +