From 7888dc771ede40323ae35acaaa19ef87f66b982d Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 10:47:55 +0300 Subject: [PATCH 01/10] fix: (passenger): block payment initiation too close to the booking deadline --- .../common/utils/payment-deadline.utils.ts | 30 ++++++-- .../src/modules/payments/payments.dto.ts | 4 ++ .../modules/payments/payments.service.spec.ts | 68 +++++++++++++++++++ .../src/modules/payments/payments.service.ts | 48 +++++++++++-- .../src/providers/dmoney/dmoney.provider.ts | 2 +- 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index e125bb0bf..8c391aef1 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -13,11 +13,17 @@ export const MAX_PAYMENT_HOURS = 2; export const CUTOFF_MINUTES = 30; /** - * payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes) - * - * checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level - * checkinMinutesBefore so that each route's own window is respected. + * How long a passenger is given to finish one provider payment session, once opened. + * 5 minutes of actual paying (redirect → PIN/OTP → provider callback) + 1 minute of slack. */ +export const PAYMENT_SESSION_MINUTES = 6; + + +export const MIN_PAYMENT_WINDOW_MINUTES = 7; + +export const PAYMENT_SETTLE_MARGIN_SECONDS = 60; + + export function computePaymentDeadline( createdAt: Date, departureAt: Date, @@ -27,3 +33,19 @@ export function computePaymentDeadline( const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } + + +export function canOpenPaymentSession( + paymentDeadline: Date, + now: Date = new Date(), +): boolean { + return paymentDeadline.getTime() - now.getTime() >= MIN_PAYMENT_WINDOW_MINUTES * 60 * 1000; +} + +export function computePaymentSessionExpiry( + paymentDeadline: Date, + now: Date = new Date(), +): Date { + const sessionEnd = new Date(now.getTime() + PAYMENT_SESSION_MINUTES * 60 * 1000); + return sessionEnd < paymentDeadline ? sessionEnd : paymentDeadline; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index dc389e5a1..1d033e5ff 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -154,6 +154,10 @@ export class InitiateResponseDto { @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; + /** When this payment session stops being offered — PAYMENT_SESSION_MINUTES from initiation, capped at paymentDeadline. Drives the client-side countdown. */ + @ApiPropertyOptional() sessionExpiresAt?: string; + /** The booking's payment deadline: after it, the booking is auto-cancelled. */ + @ApiPropertyOptional() paymentDeadline?: string; } export class IntentStatusDto { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 8c4edf083..186383335 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -16,6 +16,11 @@ import { ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; +import { + MAX_PAYMENT_HOURS, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SESSION_MINUTES, +} from "../../common/utils/payment-deadline.utils"; describe("PaymentsService", () => { let service: PaymentsService; @@ -163,6 +168,69 @@ describe("PaymentsService", () => { ).rejects.toThrow(BadRequestException); }); + /** + * A booking whose payment deadline lands exactly `minutesLeft` from now: the deadline is + * MIN(createdAt + MAX_PAYMENT_HOURS, departure - checkin), so back-date createdAt and keep + * departure far away. Derived from MAX_PAYMENT_HOURS so the test survives changes to it. + */ + const bookingWithDeadlineIn = (minutesLeft: number) => ({ + ...mockBooking, + createdAt: new Date( + Date.now() - (MAX_PAYMENT_HOURS * 60 - minutesLeft) * 60 * 1000, + ), + originStationId: null, + schedule: { + departureAt: new Date(Date.now() + 10 * 60 * 60 * 1000), + stopTimes: [], + route: null, + }, + }); + + it("should refuse to open a provider session that cannot finish before auto-cancel", async () => { + // 2 minutes left — the real incident: the session was opened, the provider captured the + // money, and the auto-cancel cron had already cancelled the booking by then. + mockPrisma.booking.findUnique.mockResolvedValue(bookingWithDeadlineIn(2)); + + await expect( + service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }), + ).rejects.toThrow(BadRequestException); + + // Nothing may reach the provider — no session, no capture, no orphan payment. + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); + }); + + it("should open a session and report its expiry when the window is wide enough", async () => { + const minutesLeft = MIN_PAYMENT_WINDOW_MINUTES + 3; + mockPrisma.booking.findUnique.mockResolvedValue( + bookingWithDeadlineIn(minutesLeft), + ); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + }); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "TELEBIRR" as any, + }); + + expect(mockPaymentClient.initiate).toHaveBeenCalled(); + // Session ends PAYMENT_SESSION_MINUTES from now — before the deadline, not at it. + const sessionMs = + new Date(result.sessionExpiresAt!).getTime() - Date.now(); + expect(sessionMs).toBeLessThanOrEqual(PAYMENT_SESSION_MINUTES * 60 * 1000); + expect(new Date(result.sessionExpiresAt!).getTime()).toBeLessThan( + new Date(result.paymentDeadline!).getTime(), + ); + }); + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPaymentClient.initiate.mockResolvedValue( 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 14e48dce5..2b1b14cf1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -29,7 +29,13 @@ import { MarkPaidResponseDto, BillQueryResponseDto, } from "./internal-payments.dto"; -import { computePaymentDeadline } from "../../common/utils/payment-deadline.utils"; +import { + computePaymentDeadline, + computePaymentSessionExpiry, + canOpenPaymentSession, + MIN_PAYMENT_WINDOW_MINUTES, + PAYMENT_SETTLE_MARGIN_SECONDS, +} from "../../common/utils/payment-deadline.utils"; import { PaymentClientService, PaymentDiagnostic, @@ -260,6 +266,27 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } + // Refuse to open a provider session that cannot finish before auto-cancel. Everything below + // this point hands the passenger off to an external provider (redirect/HPP/OTP), which takes + // minutes; TasksService cancels the booking the first cron tick after its payment deadline. + // Opening a session with less than MIN_PAYMENT_WINDOW_MINUTES left produces the worst possible + // outcome — the provider captures the money and the booking is already CANCELLED when the + // capture lands. WALLET is exempt (returned above): it is an instant internal balance debit. + const paymentDeadline = await this.computeBookingPaymentDeadline(booking.id); + const sessionExpiresAt = paymentDeadline + ? computePaymentSessionExpiry(paymentDeadline) + : undefined; + if (paymentDeadline && !canOpenPaymentSession(paymentDeadline)) { + const remainingMs = paymentDeadline.getTime() - Date.now(); + throw new BadRequestException( + remainingMs <= 0 + ? "The payment window for this booking has expired. Please make a new booking." + : `Too little time is left to start a payment (${Math.ceil(remainingMs / 60000)} minute(s) ` + + `until this booking expires; at least ${MIN_PAYMENT_WINDOW_MINUTES} are required). ` + + `Please make a new booking.`, + ); + } + // Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the // single passenger projection row (upserted by bookingId below) tracks the latest session. // Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here. @@ -317,9 +344,7 @@ export class PaymentsService { payerName = booking.seats.find((s) => s.leg === 1)?.passengerName ?? booking.seats[0]?.passengerName; - expiresAt = ( - await this.computeBookingPaymentDeadline(booking.id) - )?.toISOString(); + expiresAt = paymentDeadline?.toISOString(); } const snapshot = await this.paymentClient.initiate({ @@ -350,7 +375,11 @@ export class PaymentsService { where: { id: intent.id }, }); } - return this.formatIntentResponse(intent); + return { + ...this.formatIntentResponse(intent), + sessionExpiresAt: sessionExpiresAt?.toISOString(), + paymentDeadline: paymentDeadline?.toISOString(), + }; } /** @@ -436,8 +465,15 @@ export class PaymentsService { if (booking.status !== "PENDING_PAYMENT") { return { ...base, stillPayable: false, reason: "NOT_PAYABLE" }; } + // A CBE debit confirmed now lands in seconds, so this doesn't need the full + // MIN_PAYMENT_WINDOW_MINUTES that opening a session does — but it must not be confirmed so + // close to the deadline that the auto-cancel cron cancels the booking before the capture is + // registered. Refusing here is what keeps CBE from debiting a passenger for a dead booking. const deadline = await this.computeBookingPaymentDeadline(booking.id); - if (deadline && deadline.getTime() < Date.now()) { + if ( + deadline && + deadline.getTime() - PAYMENT_SETTLE_MARGIN_SECONDS * 1000 < Date.now() + ) { return { ...base, stillPayable: false, reason: "EXPIRED" }; } return { ...base, stillPayable: true, reason: null }; diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 51e90381b..3af351e32 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -353,7 +353,7 @@ export class DMoneyProvider implements PaymentProvider { return this.config.get("dmoney.returnUrl") ?? ""; } private get timeoutExpress(): string { - return this.config.get("dmoney.timeoutExpress") ?? "120m"; + return this.config.get("dmoney.timeoutExpress") ?? "5m"; } private get language(): string { return this.config.get("dmoney.language") ?? "en"; From f51ee7cf598f838ca1373ca8b75d6c90179bba30 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 7 Aug 2026 13:48:58 +0300 Subject: [PATCH 02/10] feat: (payment) add telebirr mini-app in-app payment flow --- .../src/modules/payment/payments.dto.ts | 28 +++- .../modules/payments/payments.controller.ts | 11 +- .../src/modules/payments/payments.dto.ts | 38 ++++- .../payments/supplementary-charges.service.ts | 3 +- .../portal/src/app/booking/payment/page.tsx | 133 +++++++++++++++++- .../portal/src/lib/telebirr-bridge.ts | 106 ++++++++++++++ .../intents/dto/initiate-payment.dto.ts | 9 +- .../providers/telebirr/telebirr.provider.ts | 81 +++++++++-- packages/types/src/common/payments.ts | 13 +- 9 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 255da0ea2..ee7b703d2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,14 +60,38 @@ export class RefundDto { } export class ClientActionDto { + // INVOKE_BRIDGE (SuperApp mini-app payload) is part of the shared ClientAction union and so + // must be assignable here, but freight never requests platform=inapp and therefore never + // receives one. Passenger owns that flow — see docs/telebirr-miniapp/. @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type!: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + bridge?: "TELEBIRR"; + + @ApiPropertyOptional({ + description: "Set when type=INVOKE_BRIDGE (SuperApp mini app) — not used by freight", + }) + rawRequest?: string; + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) appId?: 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 7da37df33..9c1bfa65d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -56,7 +56,7 @@ class WaiveSupplementaryChargeDto { class PaySupplementaryChargeDto { @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; - @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile'; + @ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto; } @ApiTags("Payment") @@ -307,7 +307,14 @@ export class PaymentsController { }) @ApiQuery({ name: "bookingId", required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiQuery({ + name: "platform", + enum: ["web", "mobile"], + required: false, + description: + "Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " + + "to redirect and must go through POST /payments/initiate for the bridge payload.", + }) @ApiProduces("text/html") async checkout( @Query("bookingId") bookingId: string, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 1d033e5ff..448aa4e7b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -28,7 +28,8 @@ export enum PaymentMethodTypeEnum { CBE_BILL = "CBE_BILL", // Ethiopia (pay at any CBE channel by bill number) } -export type PaymentPlatformDto = "web" | "mobile"; +/** Mirrors `PaymentPlatform` in @edr/types — see there for what each surface means. */ +export type PaymentPlatformDto = "web" | "mobile" | "inapp"; export class InitiatePaymentDto { @ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string; @@ -45,12 +46,14 @@ export class InitiatePaymentDto { @IsString() paymentMethodId?: string; @ApiPropertyOptional({ - enum: ["web", "mobile"], + enum: ["web", "mobile", "inapp"], default: "web", - description: "Payment platform (web or mobile)", + description: + "Payer surface. `inapp` = the portal is running inside a SuperApp mini-app WebView " + + "(Telebirr), which cannot follow redirect flows and gets a bridge payload instead.", }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatformDto; @ApiPropertyOptional({ description: @@ -117,9 +120,20 @@ export class SupportedPaymentMethodDto { export class ClientActionDto { @ApiProperty({ - enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + enum: [ + "REDIRECT", + "LAUNCH_APP", + "INVOKE_BRIDGE", + "COLLECT_OTP", + "SHOW_BILL_REFERENCE", + ], }) - type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; + type: + | "REDIRECT" + | "LAUNCH_APP" + | "INVOKE_BRIDGE" + | "COLLECT_OTP" + | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -134,6 +148,18 @@ export class ClientActionDto { description: "Set when type=LAUNCH_APP (mobile flow)", }) shortCode?: string; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app) — which SuperApp host bridge to call", + enum: ["TELEBIRR"], + }) + bridge?: "TELEBIRR"; + @ApiPropertyOptional({ + description: + "Set when type=INVOKE_BRIDGE (telebirr mini app). Signed query string handed verbatim " + + "to the host bridge (js_fun_start_pay). NOT a URL — never navigate to it.", + }) + rawRequest?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) providerOrderId?: string; @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) 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 index f8c316d87..079792366 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -5,6 +5,7 @@ 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'; +import { PaymentPlatformDto } from './payments.dto'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours @@ -119,7 +120,7 @@ export class SupplementaryChargesService { async pay( token: string, method: string, - platform?: 'web' | 'mobile', + platform?: PaymentPlatformDto, requestOrigin?: string | null, ) { const charge = await this.getByToken(token); // validates status/expiry diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 685ba5ee2..166b1c799 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -6,7 +6,12 @@ import { usePaymentStore } from "@/lib/payment-store"; import { useMutation, useQuery } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; -import { useState, useEffect } from "react"; +import { + isTelebirrMiniApp, + onTelebirrPayResult, + startTelebirrPay, +} from "@/lib/telebirr-bridge"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { PaymentMethod } from "@/types"; import { format } from "date-fns"; import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format'; @@ -55,6 +60,17 @@ export default function PaymentPage() { } | null>(null); const [billCopied, setBillCopied] = useState(false); + // Telebirr mini app: the SuperApp payment sheet is open (or just closed) and we're + // polling our own status endpoint for the webhook-backed outcome. + const [verifyingPayment, setVerifyingPayment] = useState(false); + + // Resolved once on mount — SSR has no `window`, so this must not be read during render + // of the first (server) pass. + const [inMiniApp, setInMiniApp] = useState(false); + useEffect(() => { + setInMiniApp(isTelebirrMiniApp()); + }, []); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; // Use the same display currency as the review page — stored on the schedule at search time. @@ -72,8 +88,26 @@ export default function PaymentPage() { }, }); + // Inside the telebirr SuperApp only telebirr can complete: every other method is a + // redirect/HPP flow, and the mini-app WebView cannot follow the scheme handoffs those + // gateways use. Offering them would strand the payer on a dead page. + // Memoised: this feeds an effect's dep array, and a fresh array identity every render + // would re-run that effect on every render. + const availableMethods = useMemo( + () => paymentMethods.filter((m) => m.enabled && (!inMiniApp || m.type === 'TELEBIRR')), + [paymentMethods, inMiniApp], + ); + const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null; + // A method chosen before the container was known (or carried over in state) may no longer + // be offerable — drop it rather than letting Pay fire against a hidden method. + useEffect(() => { + if (selectedMethod && !availableMethods.some((m) => m.type === selectedMethod)) { + setSelectedMethod(null); + } + }, [selectedMethod, availableMethods]); + // Derive charge currency directly from the selected method — no separate state that can lag. const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase(); @@ -128,6 +162,70 @@ export default function PaymentPage() { } }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]); + /** + * Poll our own status endpoint until the payment reaches a terminal state. + * + * Used by the telebirr mini-app flow, where nothing navigates and therefore no return page + * ever runs. The bridge callback only tells us the sheet closed; the authoritative outcome + * is the webhook-backed status the API reports here. + */ + const pollPaymentStatus = useCallback( + async (attemptsLeft: number): Promise => { + if (!bookingId) return; + try { + const res: any = await apiClient.get(`/payments/status/${bookingId}`); + if (res?.status === 'SUCCEEDED') { + setVerifyingPayment(false); + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + return; + } + if (res?.status === 'FAILED' || res?.status === 'CANCELLED') { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError(res?.failureMessage || "Payment was not completed. Please try again."); + return; + } + } catch { + // Transient read failure — keep polling; the attempt budget bounds it. + } + + if (attemptsLeft <= 0) { + // Don't call it failed: telebirr may have taken the money and the webhook is simply + // still in flight. Stop spinning, tell the truth, and let the payer re-check. + setVerifyingPayment(false); + setIsProcessing(false); + setPaymentError( + "We haven't received confirmation yet. If you completed the payment, your booking " + + "will be confirmed shortly — check My Bookings in a moment before paying again.", + ); + return; + } + setTimeout(() => void pollPaymentStatus(attemptsLeft - 1), 1500); + }, + [bookingId, router, updateStatus], + ); + + /** + * Telebirr mini app reports the sheet outcome on a global callback rather than a redirect. + * Registered on mount — the SuperApp can call back the moment the sheet closes, so it must + * already be installed before the bridge is invoked. + */ + useEffect(() => { + return onTelebirrPayResult((succeeded) => { + if (!succeeded) { + setVerifyingPayment(false); + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError("Payment was cancelled or declined. Please try again."); + return; + } + setVerifyingPayment(true); + void pollPaymentStatus(15); + }); + }, [pollPaymentStatus, updateStatus]); + const paymentMutation = useMutation({ mutationFn: async (data: any) => { return await apiClient.post("/payments/initiate", { @@ -135,7 +233,7 @@ export default function PaymentPage() { method: data.method, paymentMethodId: data.paymentMethodId, payerAccount: data.payerAccount, - platform: 'web', + platform: isTelebirrMiniApp() ? 'inapp' : 'web', }); }, onSuccess: async (data: any) => { @@ -163,6 +261,23 @@ export default function PaymentPage() { return; } + // Telebirr mini app: hand the signed rawRequest to the SuperApp bridge. Nothing + // navigates — telebirr draws its payment sheet over the WebView and reports back on + // the global callback registered above, which starts the status polling. + if (data?.clientAction?.type === 'INVOKE_BRIDGE') { + setPaymentIntent(data.intentId); + updateStatus("REQUIRES_ACTION"); + if (!startTelebirrPay(data.clientAction.rawRequest)) { + setIsProcessing(false); + updateStatus("FAILED"); + setPaymentError( + "Couldn't open the telebirr payment sheet. Please reopen this page from the " + + "telebirr app and try again.", + ); + } + return; + } + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI' || selectedMethod === 'DMONEY') && data?.clientAction?.type === 'REDIRECT') { setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); @@ -505,7 +620,15 @@ export default function PaymentPage() { {isProcessing && (
- {paymentMutation.isSuccess ? ( + {verifyingPayment ? ( + <> + +

Confirming payment

+

+ Checking with telebirr — this only takes a moment. +

+ + ) : paymentMutation.isSuccess ? ( <>

Loading...

@@ -688,13 +811,13 @@ export default function PaymentPage() {

Failed to load payment methods. Please refresh.

- ) : paymentMethods.length === 0 ? ( + ) : availableMethods.length === 0 ? (

No payment methods available at the moment.

) : (
- {paymentMethods.filter(m => m.enabled).map((method) => { + {availableMethods.map((method) => { const Icon = getIconForMethod(method.type); const isSelected = selectedMethod === method.type; return ( diff --git a/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts new file mode 100644 index 000000000..fd2a9ccea --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/telebirr-bridge.ts @@ -0,0 +1,106 @@ +/** + * Telebirr SuperApp mini-app bridge. + * + * When the portal runs inside the telebirr SuperApp, the ordinary web checkout is unusable: + * the H5 paygate page hands off to the native wallet with a custom scheme + * (`kcbconsumer://h5checkout?...`) that the SuperApp's WebView cannot resolve, so the payer + * only ever sees `net::ERR_UNKNOWN_URL_SCHEME`. + * + * The in-app flow never navigates. The API returns a signed `rawRequest` string + * (clientAction.type === "INVOKE_BRIDGE") which is handed to the host's JS bridge; telebirr + * renders its own payment sheet over the WebView and reports the outcome on a global callback. + * + * See docs/telebirr-miniapp/inapp-payment-plan.md. + */ + +/** Name of the global the SuperApp calls back into. Must be a property of `window`. */ +export const TELEBIRR_PAY_CALLBACK = "handleEdrPaymentCallback"; + +type ConsumerApp = { evaluate: (payload: string) => void }; + +declare global { + interface Window { + /** Injected by the telebirr SuperApp WebView. Absent everywhere else. */ + consumerapp?: ConsumerApp; + [TELEBIRR_PAY_CALLBACK]?: (response: unknown) => void; + } +} + +/** + * True only when the telebirr host bridge is actually present. + * + * Deliberately does NOT sniff the user agent. A UA match without `window.consumerapp` would + * make us request `platform: "inapp"` and get back a bare rawRequest we have no way to use — + * there is no navigating our way out of that, because by then the server has already committed + * to the bridge payload. Gating on the bridge object keeps the decision and the capability in + * sync: if we can't call it, we don't ask for it. + */ +export function isTelebirrMiniApp(): boolean { + return typeof window !== "undefined" && typeof window.consumerapp?.evaluate === "function"; +} + +/** + * Hand a signed rawRequest to the SuperApp to open its payment sheet. + * + * Register the callback (see `onTelebirrPayResult`) BEFORE calling this — the host may invoke + * it as soon as the sheet closes. Returns false when the bridge is missing or throws, so the + * caller can surface an error instead of leaving the payer on a dead spinner. + */ +export function startTelebirrPay(rawRequest: string): boolean { + if (!isTelebirrMiniApp()) return false; + try { + window.consumerapp!.evaluate( + JSON.stringify({ + functionName: "js_fun_start_pay", + params: { + rawRequest, + functionCallBackName: TELEBIRR_PAY_CALLBACK, + }, + }), + ); + return true; + } catch (err) { + console.error("[telebirr] bridge evaluate failed:", err); + return false; + } +} + +/** + * Install the global result callback; returns a disposer for effect cleanup. + * + * The result is a TRIGGER TO VERIFY, never proof of payment — the payer can close the sheet, + * the host can report success before settlement lands, and the payload shape is not a contract. + * Confirmation always comes from polling our own payment status (webhook-backed). + */ +export function onTelebirrPayResult(handler: (succeeded: boolean) => void): () => void { + if (typeof window === "undefined") return () => {}; + window[TELEBIRR_PAY_CALLBACK] = (response: unknown) => { + handler(isSuccessResponse(response)); + }; + return () => { + delete window[TELEBIRR_PAY_CALLBACK]; + }; +} + +/** + * Telebirr reports `code: 0` (number or string) for success. The payload arrives as either a + * JSON string or an object depending on host version, and an unparseable payload is treated as + * success on purpose: polling is what decides the outcome, and a false "failed" would strand a + * payer who actually paid. + */ +function isSuccessResponse(response: unknown): boolean { + let parsed: unknown = response; + if (typeof response === "string") { + try { + parsed = JSON.parse(response); + } catch { + return true; + } + } + if (parsed && typeof parsed === "object" && "code" in parsed) { + const code = (parsed as { code: unknown }).code; + if (code === undefined || code === null) return true; + return code === 0 || code === "0"; + } + return true; +} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index 6d602b28a..fa7587b2e 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -62,9 +62,14 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @IsEnum(ProviderMethod) provider!: ProviderMethod; - @ApiPropertyOptional({ enum: ["web", "mobile"] }) + @ApiPropertyOptional({ + enum: ["web", "mobile", "inapp"], + description: + "Payer surface. `inapp` = running inside a SuperApp mini-app WebView (Telebirr), " + + "which cannot follow redirect/HPP flows and gets a bridge payload instead.", + }) @IsOptional() - @IsIn(["web", "mobile"]) + @IsIn(["web", "mobile", "inapp"]) platform?: PaymentPlatform; @ApiPropertyOptional({ diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index d732a51e3..32bf859b1 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { HttpService } from "@nestjs/axios"; import { + ClientAction, + PaymentPlatform, PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, @@ -67,15 +69,7 @@ export class TelebirrProvider implements PaymentProvider { requestBody.biz_content.timeout_express, ); const platform = input.platform ?? "web"; - const clientAction = - platform === "mobile" - ? { - type: "LAUNCH_APP" as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; + const clientAction = this.buildClientAction(platform, prepayId, response); return { providerOrderId: prepayId, @@ -199,6 +193,11 @@ export class TelebirrProvider implements PaymentProvider { input: ProviderInitiationInput, ): CreateOrderRequest { const totalAmount = String(input.amountMinor); + // In-app pays inside the SuperApp overlay and never navigates, so there is no browser + // to send back — telebirr's own in-app integration omits redirect_url entirely. Keep it + // absent rather than undefined: a signed-but-unsent field is what produced the earlier + // "verify sign failed" (see docs/payment-service + telebirr.crypto skip-undefined). + const wantsRedirect = input.platform !== "inapp" && !!input.redirectUrl; const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), @@ -214,7 +213,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - ...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}), + ...(wantsRedirect ? { redirect_url: input.redirectUrl! } : {}), }, }; const sign = signRequestObject( @@ -245,6 +244,68 @@ export class TelebirrProvider implements PaymentProvider { return { ...req, sign, sign_type: "SHA256WithRSA" }; } + /** + * Telebirr exposes the same pre-order three ways; only the launch payload differs. + * + * - `mobile` — native app hands off to the wallet app with a receiveCode. + * - `inapp` — the portal is running inside the telebirr SuperApp mini-app WebView. The + * H5 checkout page is unusable there: it deep-links to `kcbconsumer://…`, + * which the WebView cannot resolve (`net::ERR_UNKNOWN_URL_SCHEME`). The + * signed rawRequest goes to the host JS bridge instead — no navigation. + * - `web` — ordinary browser; redirect to the H5 checkout page. + */ + private buildClientAction( + platform: PaymentPlatform, + prepayId: string, + response: CreateOrderResponse, + ): ClientAction { + switch (platform) { + case "mobile": + return { + type: "LAUNCH_APP", + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + }; + case "inapp": + return { + type: "INVOKE_BRIDGE", + bridge: "TELEBIRR", + rawRequest: this.buildInAppRawRequest(prepayId), + }; + default: + return { type: "REDIRECT", url: this.buildCheckoutUrl(prepayId) }; + } + } + + /** + * Signed request handed verbatim to the SuperApp bridge (`js_fun_start_pay`). + * + * Emits `appid, merch_code, nonce_str, prepay_id, timestamp, sign_type, sign` in that + * order — no `webBaseUrl` prefix and no `version`/`trade_type` tail, because the bridge + * takes the bare query string rather than a URL. + * + * `sign_type` sits in the map purely so it lands in the output in the right position; + * `buildCanonicalString` excludes it (as does telebirr's own reference implementation), + * so the signature covers the same five fields as the web checkout URL. + * + * Kept separate from `buildCheckoutUrl` rather than sharing a builder: the two payloads + * are consumed by different validators, and the web flow is live. + */ + private buildInAppRawRequest(prepayId: string): string { + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + sign_type: "SHA256WithRSA", + }; + const sign = signRequestObject(map, this.privateKey); + const fields = Object.entries(map).map(([k, v]) => `${k}=${v}`); + return [...fields, `sign=${sign}`].join("&"); + } + private buildCheckoutUrl(prepayId: string): string { const map: Record = { appid: this.merchantAppId, diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 04849f54d..05be21682 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -32,7 +32,8 @@ export enum ProviderMethod { CBE_BILL = "CBE_BILL", } -export type PaymentPlatform = "web" | "mobile"; + +export type PaymentPlatform = "web" | "mobile" | "inapp"; export type ClientAction = | { type: "REDIRECT"; url: string } @@ -42,6 +43,16 @@ export type ClientAction = receiveCode?: string; shortCode: string; } + | { + type: "INVOKE_BRIDGE"; + /** Which SuperApp host bridge the payload targets. */ + bridge: "TELEBIRR"; + /** + * Signed query string handed verbatim to the host bridge (`js_fun_start_pay`). + * NOT a URL — it has no scheme or host and must never be navigated to. + */ + rawRequest: string; + } | { type: "COLLECT_OTP"; providerOrderId: string; From d2eb47d14b1982c8741164ff8aa1c14327a2b278 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 23:00:06 +0000 Subject: [PATCH 03/10] feat(ui): --- apps/edr-freight-api/.env.example | 6 ++ .../src/modules/auth/list-users.service.ts | 25 ++++++++ .../ruleEngine/RuleEngineCardGrid.tsx | 2 +- .../ruleEngine/RuleEngineRecordActions.tsx | 58 ++++++++++--------- .../ruleEngine/RuleEngineResourcePage.tsx | 16 +++-- .../src/pages/wagons/WagonTransfersPage.tsx | 14 ----- 6 files changed, 74 insertions(+), 47 deletions(-) diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 96af26034..48a4de52f 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -76,6 +76,12 @@ SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false +# Limits GET /staff/users to employees of this IAM organization (iam.organizations.key). +# Unset = every employee. A key matching no organization returns no users. +# Dev seed key: edr_freight +# Production: ETHIO_DJIBOUTI_STANDARD_GAUGE_RAILWAY_SHARE_COMPANY_001 +FREIGHT_ORG_KEY=edr_freight + # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost MINIO_PORT=9000 diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts index cf7e22be2..c598e105a 100644 --- a/apps/edr-freight-api/src/modules/auth/list-users.service.ts +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { PaginatedResponse } from '@edr/types'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; @@ -19,6 +20,7 @@ import { paginateQuery } from '../../common/utils/pagination.util'; export class ListUsersService { constructor( @InjectRepository(User) private readonly users: Repository, + private readonly config: ConfigService, ) {} findAll(query: ListUsersQueryDto): Promise> { @@ -40,6 +42,29 @@ export class ListUsersService { ]) .orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC'); + // Restrict to one IAM organization when configured. The org key differs per + // environment (dev seeds `edr_freight`, production uses the registered + // company key), so this is config rather than a constant. An unset key + // means no restriction; a key matching no organization matches no user — + // failing closed rather than silently widening to every org. + const orgKey = this.config.get('FREIGHT_ORG_KEY'); + if (orgKey) { + // EXISTS, not a join: a user with several employee rows would otherwise + // be returned once per row, duplicating them in the list and inflating + // `getManyAndCount`'s total. + qb.andWhere( + `EXISTS ( + SELECT 1 + FROM iam.employees emp + JOIN iam.organizations org ON org.id = emp.organization_id + WHERE emp.user_id = "user".id + AND org.key = :orgKey + AND org.deleted_at IS NULL + )`, + { orgKey }, + ); + } + if (query.userType) { qb.andWhere('user.userType = :userType', { userType: query.userType }); } diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index 762f5bc84..e3fe9cb37 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -253,7 +253,7 @@ const RuleEngineCardGrid = ({ config={config} layout="compact" readOnly={readOnly} - onEdit={onEdit ?? (() => { })} + onEdit={onEdit} onDelete={onDelete ?? (() => { })} onViewChain={onViewChain} onSubmitRate={onSubmitRate} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx index 96440c001..f724d5b60 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineRecordActions.tsx @@ -14,7 +14,7 @@ import type { RuleEngineRecord } from "@/types/rule-engine"; export interface RuleEngineRecordActionsProps { record: RuleEngineRecord; config: RuleEngineResourceConfig; - onEdit: (record: RuleEngineRecord) => void; + onEdit?: (record: RuleEngineRecord) => void; onDelete: (record: RuleEngineRecord) => void; onViewChain?: () => void; onSubmitRate?: (id: string) => void; @@ -93,17 +93,19 @@ const RuleEngineRecordActions = ({ ) : null}
- + {onEdit ? ( + + ) : null} - {canRequest ? ( - - ) : null} } /> From cd90ccb0f50ff92c451cb820c110c351363ac68b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 23:50:27 +0000 Subject: [PATCH 04/10] feat(contracts): per-cargo bulk contract templates --- .../contract-document-view-model.builder.ts | 2 + .../3320000000000-BulkContractTemplates.ts | 100 ++++++ .../contract-templates.controller.ts | 70 +++-- .../contract-templates.repository.ts | 49 ++- .../contract-templates.service.ts | 142 +++++++-- .../dto/contract-template.dto.ts | 29 ++ .../entities/contract-template.entity.ts | 41 ++- .../contracts/contract-transition.service.ts | 2 + .../rule-engine/dto/create-cargo-type.dto.ts | 10 + .../rule-engine/entities/cargo-type.entity.ts | 8 + .../services/cargo-types.service.ts | 45 +++ .../src/seed/freight-permissions.registry.ts | 26 ++ apps/edr-freight-web/backoffice/src/App.tsx | 20 +- .../useContractTemplates.ts | 16 + .../backoffice/src/lib/permissions.ts | 4 + .../ContractTemplatesPage.tsx | 293 ++++++++++++++++-- .../src/pages/ruleEngine/CargoTypesPage.tsx | 13 + .../services/contract-templates.service.ts | 39 ++- 18 files changed, 802 insertions(+), 107 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index d4f0c4039..36e4e34d0 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts new file mode 100644 index 000000000..39a10ca90 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts @@ -0,0 +1,100 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const CONTAINER_CODES = [ + 'IMPORT_CONTAINER_CUSTOMS', + 'IMPORT_CONTAINER_NO_CUSTOMS', + 'EXPORT_CONTAINER_CUSTOMS', + 'EXPORT_CONTAINER_NO_CUSTOMS', + 'INTERCITY_CONTAINER', +]; + +const BULK_CODES = [ + 'IMPORT_BULK_CUSTOMS', + 'IMPORT_BULK_NO_CUSTOMS', + 'EXPORT_BULK_CUSTOMS', + 'EXPORT_BULK_NO_CUSTOMS', + 'INTERCITY_BULK', +]; + +/** + * Bulk contract templates become staff-created, keyed by (cargo type, customs + * clearing) instead of the fixed direction codes. The five container templates + * stay seeded and become undeletable system rows; the five seeded bulk rows are + * retired (soft-deleted). cargo_types gains has_contract_template, marking + * which bulk commodities may carry their own template. + */ +export class BulkContractTemplates3320000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id), + ADD COLUMN IF NOT EXISTS with_customs boolean, + ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false + `); + + // Generated bulk codes (BULK__NO_CUSTOMS) outgrow varchar(40). + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ALTER COLUMN code TYPE varchar(80) + `); + + await queryRunner.query( + `UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`, + [CONTAINER_CODES], + ); + + // Retire the fixed bulk templates; staff recreate them per cargo type. + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = now() + WHERE code = ANY($1) AND deleted_at IS NULL`, + [BULK_CODES], + ); + + // Code stays unique among live rows only, so a deleted combo can be + // recreated under the same generated code. + await queryRunner.query( + `ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code + ON freight.contract_templates (code) WHERE deleted_at IS NULL + `); + + // One template per (bulk cargo type, customs option) — the "same + // combination" rule, enforced even under concurrent creates. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs + ON freight.contract_templates (cargo_type_id, with_customs) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT uq_contract_templates_code UNIQUE (code) + `); + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`, + [BULK_CODES], + ); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP COLUMN IF EXISTS cargo_type_id, + DROP COLUMN IF EXISTS with_customs, + DROP COLUMN IF EXISTS is_system + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template + `); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts index 6cba17f61..7053d224f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ContractTemplatesService } from "./contract-templates.service"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticlesDto, UpdateArticleDto, UpdateContractTemplateDto, } from "./dto/contract-template.dto"; +// `view` opens the Templates page; `read` is API-read-only for other pages +// that show template data; create/update/delete gate each write. `manage` is +// the legacy write key and keeps working for roles that already hold it. +const TEMPLATE_READ = [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.read, + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + +const TEMPLATE_UPDATE = [ + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + @ApiTags("contract-templates") @Controller("contract-templates") export class ContractTemplatesController { constructor(private readonly service: ContractTemplatesService) {} - // Reads are staff-only (the backoffice Templates tab is the only consumer); - // writes are admin-guarded like other freight configuration resources. - @Get() - @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, - FREIGHT_PERMS.admin, - ]) - @ApiOperation({ summary: "List the six contract document templates" }) + @BookingStaff(TEMPLATE_READ) + @ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" }) list() { return this.service.list(); } - @Get(":code") + @Post() @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.create, FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin, ]) + @ApiOperation({ + summary: + "Create a bulk contract template for a (cargo type, customs option) pair", + }) + create(@Body() dto: CreateContractTemplateDto) { + return this.service.create(dto); + } + + @Get(":code") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Get one contract template by code" }) getByCode(@Param("code") code: string) { return this.service.getByCode(code); } @Patch(":code") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { return this.service.update(code, dto); } - @Post(":code/preview") + @Delete(":code") @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.settings.contractTemplates.delete, FREIGHT_PERMS.admin, ]) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: "Delete a staff-created bulk template (system templates refuse)", + }) + remove(@Param("code") code: string) { + return this.service.remove(code); + } + + @Post(":code/preview") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Render an HTML preview of the template against mock contract data", }) @@ -77,21 +109,21 @@ export class ContractTemplatesController { /* ------------------------- article routes ------------------------- */ @Put(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { return this.service.replaceArticles(code, dto.articles); } @Post(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Add an article to the template" }) addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { return this.service.addArticle(code, dto); } @Patch(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update an article's title or body" }) updateArticle( @Param("code") code: string, @@ -102,7 +134,7 @@ export class ContractTemplatesController { } @Delete(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Remove an article from the template" }) removeArticle( @Param("code") code: string, diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 2f4fb0117..9d50b1677 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; -import { - ContractTemplate, - ContractTemplateCode, -} from "./entities/contract-template.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContractTemplate } from "./entities/contract-template.entity"; @Injectable() export class ContractTemplatesRepository extends BaseRepository { @@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository { + findByCode(code: string): Promise { return this.repository.findOne({ where: { code } }); } override findAll(): Promise { - return this.repository.find({ order: { code: "ASC" } }); + return this.repository.find({ + relations: { cargoType: true }, + order: { code: "ASC" }, + }); + } + + findByCargoCombo( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository.findOne({ where: { cargoTypeId, withCustoms } }); + } + + /** + * The active bulk template covering this cargo type: written against the + * cargo type itself or against its parent group (the two are mutually + * exclusive, so at most one row matches). + */ + findActiveBulkTemplate( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository + .createQueryBuilder("t") + .where("t.is_active = true") + .andWhere("t.with_customs = :withCustoms", { withCustoms }) + .andWhere( + `(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = ( + SELECT c.parent_group_id FROM freight.cargo_types c + WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL + ))`, + { cargoTypeId }, + ) + .getOne(); + } + + findCargoType(id: string): Promise { + return this.repository.manager + .getRepository(CargoType) + .findOne({ where: { id } }); } async saveTemplate(template: ContractTemplate): Promise { diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index ea41b57a2..da9c849f7 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; @@ -11,6 +16,7 @@ import { import { ContractTemplatesRepository } from "./contract-templates.repository"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticleDto, UpdateArticleDto, @@ -53,13 +59,17 @@ export class ContractTemplatesService { async list(): Promise { const templates = await this.repository.findAll(); const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); - return templates.sort( - (a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), - ); + // Seeded container templates first in canonical order, then staff-created + // bulk templates alphabetically. + return templates.sort((a, b) => { + const ra = rank.get(a.code as ContractTemplateCode) ?? 99; + const rb = rank.get(b.code as ContractTemplateCode) ?? 99; + return ra !== rb ? ra - rb : a.name.localeCompare(b.name); + }); } async getByCode(code: string): Promise { - const template = await this.repository.findByCode(this.assertCode(code)); + const template = await this.repository.findByCode(code?.toUpperCase() ?? ""); if (!template) { throw new NotFoundException(`Contract template ${code} not found`); } @@ -67,15 +77,93 @@ export class ContractTemplatesService { } /** - * The active template used when generating a contract document for the given - * direction/freight/customs triple; null when missing or deactivated (the - * renderer then falls back to the built-in generic layout). + * Staff-created bulk template for one (cargo type, customs option) pair. + * The cargo type must have hasContractTemplate enabled and the combination + * must not already exist — the same commodity + customs pairing is edited, + * never duplicated. + */ + async create(dto: CreateContractTemplateDto): Promise { + const cargoType = await this.repository.findCargoType(dto.cargoTypeId); + if (!cargoType) { + throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + if (!cargoType.hasContractTemplate) { + throw new BadRequestException( + `"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`, + ); + } + const variant = dto.withCustoms ? "with" : "without"; + const existing = await this.repository.findByCargoCombo( + dto.cargoTypeId, + dto.withCustoms, + ); + if (existing) { + throw new ConflictException( + `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + ); + } + + const template = new ContractTemplate(); + template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase(); + template.name = + dto.name ?? + `${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`; + template.description = dto.description ?? null; + template.documentTitle = dto.withCustoms + ? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` + : `${cargoType.cargoTypeName} Transportation Services`; + template.whereasClauses = []; + template.articles = []; + template.isActive = true; + template.cargoTypeId = cargoType.id; + template.withCustoms = dto.withCustoms; + template.isSystem = false; + try { + return await this.repository.saveTemplate(template); + } catch (error) { + // Partial unique index backstop for concurrent creates of the same combo. + if ((error as { code?: string })?.code === "23505") { + throw new ConflictException( + `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + ); + } + throw error; + } + } + + /** Bulk templates only — the five seeded container templates are permanent. */ + async remove(code: string): Promise { + const template = await this.getByCode(code); + if (template.isSystem) { + throw new BadRequestException( + "System container templates cannot be deleted", + ); + } + await this.repository.softDelete(template.id); + } + + /** + * The active template used when generating a contract document. Container + * contracts resolve through the fixed direction/customs codes; bulk contracts + * resolve through the staff-created template for the contract's cargo type + * (or its parent group) and customs option. Null when nothing matches or the + * match is deactivated (the renderer then falls back to the built-in generic + * layout). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, + cargoTypeId?: string | null, ): Promise { + const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); + if (isBulk) { + if (!cargoTypeId) return null; + return this.repository.findActiveBulkTemplate( + cargoTypeId, + Boolean(customsClearingEnabled), + ); + } const code = contractTemplateCodeFor( tradeDirection, freightType, @@ -180,16 +268,32 @@ export class ContractTemplatesService { : this.sorted(template.articles), }; - const view = this.buildMockView(template.code, dynamicTemplate); + const view = this.buildMockView(template, dynamicTemplate); return { html: this.renderer.render(view) }; } + /** + * Registry key the mock preview renders against. Staff-created bulk + * templates aren't in the fixed code map — they preview against the + * representative bulk import pack matching their customs option. + */ + private previewKeyFor(template: ContractTemplate): string { + if (template.cargoTypeId) { + return template.withCustoms + ? "IMP_BULK_USD_FORWARDING" + : "IMP_BULK_USD_TRANSPORT_ONLY"; + } + return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode]; + } + private buildMockView( - code: ContractTemplateCode, + template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, ): ContractViewModel { - const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); - const isBulk = code.endsWith("_BULK"); + const code = template.code; + const previewKey = this.previewKeyFor(template); + const meta = getTemplateMeta(previewKey); + const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); const now = new Date(); // Representative rate schedule so the admin preview shows the live-rate @@ -200,7 +304,7 @@ export class ContractTemplatesService { bookingId: "00000000-0000-0000-0000-000000000000", reference: "EDR/CT/2026/0042", status: "CONTRACT_READY", - templateKey: PREVIEW_TEMPLATE_KEYS[code], + templateKey: previewKey, template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, contractDate: now.toLocaleDateString("en-GB", { day: "numeric", @@ -275,7 +379,7 @@ export class ContractTemplatesService { } /** Static, representative rate schedule for the admin preview only. */ - private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + private mockRateSchedule(code: string, isBulk: boolean): RateSchedule { const dir = code.startsWith("IMPORT") ? "import" : code.startsWith("EXPORT") @@ -311,16 +415,6 @@ export class ContractTemplatesService { }; } - private assertCode(code: string): ContractTemplateCode { - const upper = code?.toUpperCase() as ContractTemplateCode; - if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { - throw new BadRequestException( - `Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`, - ); - } - return upper; - } - private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 0ea69f262..c8911d8e5 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -6,12 +6,41 @@ import { IsInt, IsOptional, IsString, + IsUUID, MaxLength, Min, MinLength, ValidateNested, } from "class-validator"; +export class CreateContractTemplateDto { + @ApiProperty({ + description: + "Bulk cargo type this template is written for (must have hasContractTemplate enabled)", + format: "uuid", + }) + @IsUUID() + cargoTypeId!: string; + + @ApiProperty({ + description: "Whether this is the with-customs-clearing variant", + }) + @IsBoolean() + withCustoms!: boolean; + + @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ description: "Short description shown on the template card" }) + @IsOptional() + @IsString() + description?: string; +} + export class UpdateContractTemplateDto { @ApiPropertyOptional({ description: "Display name of the template" }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index c322c20a4..af0721572 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -1,11 +1,18 @@ import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, Index } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; /** - * The ten canonical contract document templates. Import and export split by - * customs clearing (× freight type = 8); intercity does not, because it is a - * purely domestic Ethiopian movement that crosses no border and therefore has - * no customs leg at all (× freight type = 2). + * The five seeded container templates (import/export split by customs + * clearing; intercity is domestic, crosses no border, so it has a single + * template). These are system rows: always present, never deletable. + * + * Bulk templates are NOT seeded — staff create them per bulk cargo type + * (`cargoTypeId`) and customs option (`withCustoms`), one template per + * combination. Their codes are generated as BULK__(NO_)CUSTOMS. + * The retired direction-keyed bulk codes remain listed so old frozen document + * snapshots still label correctly. * * Contracts store DOMESTIC for intercity movements; the template layer labels * those INTERCITY to match the commercial vocabulary used on the printed @@ -76,10 +83,12 @@ export function contractTemplateCodeFor( } @Entity({ schema: "freight", name: "contract_templates" }) -@Index(["code"], { unique: true }) +// Uniqueness lives in partial DB indexes (live rows only): code, and +// (cargo_type_id, with_customs) for staff-created bulk templates. +@Index(["code"]) export class ContractTemplate extends BaseEntity { - @Column({ name: "code", type: "varchar", length: 40, unique: true }) - code!: ContractTemplateCode; + @Column({ name: "code", type: "varchar", length: 80 }) + code!: string; @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity { @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; + + /** Bulk templates only: the cargo type this template is written for. */ + @Column({ name: "cargo_type_id", type: "uuid", nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true }) + @JoinColumn({ name: "cargo_type_id" }) + cargoType?: CargoType | null; + + /** Bulk templates only: whether this is the with-customs-clearing variant. */ + @Column({ name: "with_customs", type: "boolean", nullable: true }) + withCustoms?: boolean | null; + + /** The five seeded container templates — cannot be deleted. */ + @Column({ name: "is_system", type: "boolean", default: false }) + isSystem!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 0a073c9f8..ba3e3fd8b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -424,6 +424,8 @@ export class ContractTransitionService { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index eefc560e8..d116a4b4a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -70,6 +70,16 @@ export class CreateCargoTypeDto { @IsBoolean() hasLashing?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'Allow staff to write bulk contract templates for this cargo type. ' + + 'Mutually exclusive with the parent group / children having it.', + }) + @IsOptional() + @IsBoolean() + hasContractTemplate?: boolean; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index e685f3a2d..b7717684a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -82,6 +82,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'has_lashing', type: 'boolean', default: false }) hasLashing!: boolean; + /** + * Whether staff may write bulk contract templates against this cargo type. + * Mutually exclusive between a parent group and its children: if the parent + * provides the template, no child may, and vice versa. + */ + @Column({ name: 'has_contract_template', type: 'boolean', default: false }) + hasContractTemplate!: boolean; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 1f25b0823..9c11005e9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -135,6 +135,35 @@ export class CargoTypesService { return map; } + /** + * A cargo type and its parent group may not BOTH offer a contract template — + * the template would be ambiguous for bookings of the child. To enable the + * child, the parent must be turned off first (and vice versa). + */ + private async assertContractTemplateExclusive(input: { + id?: string; + parentGroupId?: string | null; + }): Promise { + if (input.parentGroupId) { + const parent = await this.repository.findById(input.parentGroupId); + if (parent?.hasContractTemplate) { + throw new BadRequestException( + `Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`, + ); + } + } + if (input.id) { + const children = await this.repository.findAll({ + where: { parentGroupId: input.id, hasContractTemplate: true }, + }); + if (children.length) { + throw new BadRequestException( + `Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`, + ); + } + } + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -144,6 +173,9 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + if (dto.hasContractTemplate) { + await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId }); + } const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { explicitOrder: dto.displayOrder, @@ -160,6 +192,7 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, + hasContractTemplate: dto.hasContractTemplate ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, @@ -183,6 +216,18 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + // Re-check the parent/child template exclusivity whenever the flag or the + // parent moves and the row ends up flagged. + const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate; + if ( + willHaveTemplate && + (dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined) + ) { + await this.assertContractTemplateExclusive({ + id, + parentGroupId: dto.parentGroupId ?? existing.parentGroupId, + }); + } const { wagonTypeIds, itemsPerWagonMap, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index b103dead4..e119c0fe2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1249,6 +1249,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:contract_templates:manage", "Edit contract templates & articles", ), + // Granular split of contract-template access. `view` opens the sidebar page; + // `read` is API-read-only for other pages that display template data. + perm( + "b4e00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:contract_templates:create", + "Create bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:contract_templates:update", + "Update contract templates & articles", + ), + perm( + "b4e00001-0001-4000-8000-000000000005", + "edr_freight_app:settings:contract_templates:delete", + "Delete bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000006", + "edr_freight_app:settings:contract_templates:read", + "Read contract template data (API only)", + ), ]; // N. Previously-ungated staff surfaces (support inbox, procurement, compliance, @@ -1670,6 +1692,10 @@ export const FREIGHT_PERMS = { contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", + create: "edr_freight_app:settings:contract_templates:create", + update: "edr_freight_app:settings:contract_templates:update", + delete: "edr_freight_app:settings:contract_templates:delete", + read: "edr_freight_app:settings:contract_templates:read", }, }, audit: { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6fd666a8d..15aa1987f 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -590,7 +590,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Contract templates", href: "/dashboard/contract-templates", icon: , - permission: FREIGHT_PERMS.admin, + // `view` opens the page; `read` alone is API-only and shows no menu. + permission: [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.admin, + ], }, { label: "Audit logs", @@ -1470,7 +1474,12 @@ const App = () => { + } @@ -1478,7 +1487,12 @@ const App = () => { + } diff --git a/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts b/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts index 7e01d883c..0d7d78b56 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts @@ -4,6 +4,7 @@ import toast from "react-hot-toast"; import { contractTemplatesService, type ArticlePayload, + type CreateContractTemplatePayload, type UpdateContractTemplatePayload, } from "@/services/contract-templates.service"; @@ -59,6 +60,21 @@ function useTemplateMutation( }); } +export function useCreateContractTemplate() { + return useTemplateMutation( + (payload: CreateContractTemplatePayload) => + contractTemplatesService.create(payload), + "Template created", + ); +} + +export function useDeleteContractTemplate() { + return useTemplateMutation( + (code: string) => contractTemplatesService.remove(code), + "Template deleted", + ); +} + export function useUpdateContractTemplate(code: string) { return useTemplateMutation( (payload: UpdateContractTemplatePayload) => diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 368e9e74b..8e58d642d 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -317,6 +317,10 @@ export const FREIGHT_PERMS = { contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", + create: "edr_freight_app:settings:contract_templates:create", + update: "edr_freight_app:settings:contract_templates:update", + delete: "edr_freight_app:settings:contract_templates:delete", + read: "edr_freight_app:settings:contract_templates:read", }, }, audit: { diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 30b31f54d..1a66bbdd5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -1,11 +1,15 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { Badge, Box, Button, Card, Group, + Modal, + SegmentedControl, + Select, SimpleGrid, Skeleton, Stack, @@ -19,11 +23,21 @@ import { Container, Eye, FileText, + Lock, Pencil, + Plus, + Trash2, } from "lucide-react"; +import { useAuth } from "@/auth/useAuth"; import { PageContainer, PageHeader } from "@/components/page"; -import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates"; +import { + useContractTemplates, + useCreateContractTemplate, + useDeleteContractTemplate, +} from "@/hooks/contract-templates/useContractTemplates"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { cargoTypesService } from "@/services/cargo-types.service"; import type { ContractTemplate } from "@/services/contract-templates.service"; import TemplatePreviewModal from "./TemplatePreviewModal"; @@ -39,22 +53,18 @@ const DIRECTION_DOT: Record = { INTERCITY: "var(--mantine-color-orange-5)", }; -function templateDirection(code: ContractTemplate["code"]): string { - return code.split("_")[0]; +function isBulk(template: ContractTemplate): boolean { + return Boolean(template.cargoTypeId); } -// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is -// the second one — never the suffix. -function isBulk(code: ContractTemplate["code"]): boolean { - return code.split("_")[1] === "BULK"; -} - -// Intercity is domestic and crosses no border, so it has no customs variant at -// all — hence null rather than false, which would wrongly read as a deliberate -// "client clears its own customs" choice. -function customsVariant(code: ContractTemplate["code"]): boolean | null { - if (code.endsWith("_NO_CUSTOMS")) return false; - if (code.endsWith("_CUSTOMS")) return true; +// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is +// domestic and crosses no border, so it has no customs variant at all — hence +// null rather than false, which would wrongly read as a deliberate "client +// clears its own customs" choice. +function customsVariant(template: ContractTemplate): boolean | null { + if (isBulk(template)) return template.withCustoms ?? null; + if (template.code.endsWith("_NO_CUSTOMS")) return false; + if (template.code.endsWith("_CUSTOMS")) return true; return null; } @@ -66,10 +76,28 @@ function formatUpdated(value: string): string { }); } +interface CargoTypeOption { + id: string; + cargoTypeName?: string; + hasContractTemplate?: boolean; +} + export default function ContractTemplatesPage() { const navigate = useNavigate(); + const { user } = useAuth(); const { data: templates, isLoading } = useContractTemplates(); const [previewCode, setPreviewCode] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const perms = FREIGHT_PERMS.settings.contractTemplates; + const isAdmin = hasPermission(user, FREIGHT_PERMS.admin); + const canManage = isAdmin || hasPermission(user, perms.manage); + const canCreate = canManage || hasPermission(user, perms.create); + const canUpdate = canManage || hasPermission(user, perms.update); + const canDelete = isAdmin || hasPermission(user, perms.delete); + + const deleteTemplate = useDeleteContractTemplate(); const previewTemplate = templates?.find((t) => t.code === previewCode); @@ -77,20 +105,34 @@ export default function ContractTemplatesPage() { } + onClick={() => setCreateOpen(true)} + > + New bulk template + + ) : undefined + } /> {isLoading - ? Array.from({ length: 10 }, (_, i) => ) + ? Array.from({ length: 6 }, (_, i) => ) : (templates ?? []).map((template) => ( setPreviewCode(template.code)} onEdit={() => navigate(`/dashboard/contract-templates/${template.code}`) } + onDelete={() => setDeleteTarget(template)} /> ))} @@ -100,22 +142,174 @@ export default function ContractTemplatesPage() { title={previewTemplate ? `${previewTemplate.name} — preview` : undefined} onClose={() => setPreviewCode(null)} /> + + setCreateOpen(false)} + onCreated={(code) => { + setCreateOpen(false); + navigate(`/dashboard/contract-templates/${code}`); + }} + /> + + {/* ── Delete confirm ─────────────────────────────────────── */} + setDeleteTarget(null)} + title="Delete contract template?" + centered + size="sm" + > + + + This will delete{" "} + + {deleteTarget?.name} + {" "} + and its articles. Contracts already generated keep their frozen + document; new contracts for this combination fall back to the + generic layout until a new template is created. + + + + + + + ); } +/** + * Staff pick the customs option first, then a bulk cargo type that has + * "has contract template" enabled. One template per combination — the API + * rejects duplicates, so an existing pairing must be edited instead. + */ +function CreateTemplateModal({ + opened, + onClose, + onCreated, +}: { + opened: boolean; + onClose: () => void; + onCreated: (code: string) => void; +}) { + const [withCustoms, setWithCustoms] = useState("true"); + const [cargoTypeId, setCargoTypeId] = useState(null); + const create = useCreateContractTemplate(); + + const { data: cargoTypes, isLoading } = useQuery({ + queryKey: ["cargo-types", "contract-template-options"], + queryFn: () => cargoTypesService.getCargoTypes(), + enabled: opened, + }); + + const options = useMemo( + () => + ((cargoTypes ?? []) as CargoTypeOption[]) + .filter((cargoType) => cargoType.hasContractTemplate) + .map((cargoType) => ({ + value: cargoType.id, + label: cargoType.cargoTypeName ?? "Untitled", + })), + [cargoTypes], + ); + + const close = () => { + setCargoTypeId(null); + onClose(); + }; + + return ( + + +
+ + Customs clearing + + +
+ + + {passengerList.length > 0 && (