diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index e659b950a..fa8644945 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -15,7 +15,16 @@ export default registerAs("app", () => ({ maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, cbeExchange: { - apiUrl: process.env.CBE_EXCHANGE_API_URL ?? "", + /** ethio.forex CBET page — scraped for USD buying/selling rates. */ + scrapeUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", + /** @deprecated use scrapeUrl — kept for backward-compatible config reads */ + apiUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts index 0f596831f..27e89896b 100644 --- a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts +++ b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts @@ -1,6 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; + +/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ +const USD_RATE_REGEX = + /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; + @Injectable() export class CbeExchangeService { private readonly logger = new Logger(CbeExchangeService.name); @@ -10,9 +16,8 @@ export class CbeExchangeService { constructor(private readonly configService: ConfigService) {} /** - * Returns the current CBE USD→ETB exchange rate. - * Fetches live from CBE_EXCHANGE_API_URL, caches for CBE_EXCHANGE_CACHE_TTL_MS, - * and falls back to CBE_EXCHANGE_FALLBACK_RATE when the API is unreachable. + * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. + * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. */ async getUsdToEtbRate(): Promise { const now = Date.now(); @@ -21,41 +26,43 @@ export class CbeExchangeService { return this.cachedRate; } - const apiUrl = this.configService.get('app.cbeExchange.apiUrl') ?? ''; - const fallbackRate = this.configService.get('app.cbeExchange.fallbackRate') ?? 130; - const cacheTtlMs = this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; - - if (!apiUrl) { - this.logger.warn( - `CBE_EXCHANGE_API_URL not configured — using fallback rate ${fallbackRate} ETB/USD`, - ); - return fallbackRate; - } + const scrapeUrl = this.getScrapeUrl(); + const fallbackRate = + this.configService.get('app.cbeExchange.fallbackRate') ?? 130; + const cacheTtlMs = + this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; try { - const response = await fetch(apiUrl, { + const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(8_000), - headers: { Accept: 'application/json' }, + headers: { 'User-Agent': 'Mozilla/5.0' }, }); if (!response.ok) { - throw new Error(`CBE API responded with status ${response.status}`); + throw new Error(`CBE scrape responded with status ${response.status}`); } - const json = await response.json(); - const rate = this.parseRate(json); + const html = await response.text(); + const rates = this.parseScrapedRates(html); - if (!rate || !Number.isFinite(rate) || rate <= 0) { - throw new Error(`Invalid rate value parsed from CBE API response: ${rate}`); + if (!rates) { + throw new Error('USD rate not found in ethio.forex page HTML'); + } + + const rate = rates.selling; + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error(`Invalid selling rate parsed: ${rate}`); } this.cachedRate = rate; this.cacheExpiresAt = now + cacheTtlMs; - this.logger.log(`CBE USD→ETB rate refreshed: ${rate}`); + this.logger.log( + `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, + ); return rate; } catch (err) { this.logger.error( - `Failed to fetch CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, + `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, ); if (this.cachedRate !== null) { @@ -67,49 +74,33 @@ export class CbeExchangeService { } } - /** - * Parses the USD→ETB selling rate from the CBE API JSON response. - * CBE API typically returns an array of currency objects. - * Adjust this method if the API shape differs. - * - * Expected shape (one common format): - * [ { currency: "USD", selling: "130.50", ... }, ... ] - */ - private parseRate(json: unknown): number | null { - if (Array.isArray(json)) { - const usdEntry = json.find( - (entry: unknown) => - typeof entry === 'object' && - entry !== null && - ( - (entry as Record)['currency'] === 'USD' || - (entry as Record)['Currency'] === 'USD' - ), - ) as Record | undefined; + private getScrapeUrl(): string { + const configured = + this.configService.get('app.cbeExchange.scrapeUrl') ?? + this.configService.get('app.cbeExchange.apiUrl'); + return configured?.trim() || DEFAULT_SCRAPE_URL; + } - if (!usdEntry) return null; + private parseScrapedRates( + html: string, + ): { buying: number; selling: number } | null { + const decoded = this.unescapeHtml(html); + const match = USD_RATE_REGEX.exec(decoded); + if (!match) return null; - const selling = - usdEntry['selling'] ?? - usdEntry['Selling'] ?? - usdEntry['sellingRate'] ?? - usdEntry['rate'] ?? - usdEntry['Rate']; + const buying = Number(match[1]); + const selling = Number(match[2]); + if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; - return selling !== undefined ? Number(selling) : null; - } + return { buying, selling }; + } - if (typeof json === 'object' && json !== null) { - const obj = json as Record; - const selling = - obj['selling'] ?? - obj['Selling'] ?? - obj['sellingRate'] ?? - obj['usdToEtb'] ?? - obj['rate']; - return selling !== undefined ? Number(selling) : null; - } - - return null; + private unescapeHtml(html: string): string { + return html + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); } } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index a9999675e..2bb81a331 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -3,7 +3,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; type PaymentType = "booking" -type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" +type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -18,7 +18,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["booking"] }) type!: PaymentType; - @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney"] }) + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) method!: PaymentMethod @Column({ type: "enum", enum: ["ETB", "USD"] }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index bb8f21e7e..736f5d274 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -58,7 +58,7 @@ export class PaymentController { @Post("initiate") @ApiOperation({ summary: "Initiate payment for a freight booking", - description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money`, + description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`, }) @ApiOkResponse({ type: InitiateResponseDto }) initiatePayment(@Body() dto: InitiatePaymentDto) { diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1b5b9e69f..3f90628f0 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -157,6 +157,7 @@ export class PaymentService { WAAFI: "waafi", CARD: "card", DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; const method: PaymentEntity["method"] = PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; 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 b5c09c2c3..67ca68e87 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -11,6 +11,7 @@ export enum PaymentMethodTypeEnum { WAAFI = "WAAFI", CARD = "CARD", DMONEY = "DMONEY", + CAC_BANK = "CAC_BANK", } export class InitiatePaymentDto { @@ -59,8 +60,8 @@ export class RefundDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) - type!: "REDIRECT" | "LAUNCH_APP"; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) + type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @@ -73,6 +74,12 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) shortCode?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + providerOrderId?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + message?: string; } export class InitiateResponseDto { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 8a448c917..17184bf97 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1052,7 +1052,9 @@ export class TrainSchedulingService { const invalidStatus = bookings.filter( (b) => - !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && !b.isGovernment, + !(targetScheduleId && b.trainScheduleId === targetScheduleId) && + !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && + !b.isGovernment, ); if (invalidStatus.length) { const statuses = [...new Set(invalidStatus.map((b) => b.status))]; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8f89b6e37..e1f9a3798 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -38,7 +38,6 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import TrainsPage from "./pages/trains/TrainsPage"; import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; @@ -78,11 +77,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ items: [ { label: "Train Schedules", - href: "/dashboard/operations/train-scheduling", - icon: , - }, - { - label: "Train Schedules v2", href: "/dashboard/operations/train-scheduling-v2", icon: , }, @@ -106,31 +100,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/locomotives", icon: , }, - { - label: "Trains", - href: "/dashboard/trains", - icon: , - }, - { - label: "Wagon types", - href: "/dashboard/wagon-types", - icon: , - }, + // { + // label: "Trains", + // href: "/dashboard/trains", + // icon: , + // }, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, { label: "Wagons", href: "/dashboard/wagons", icon: , }, - { - label: "Containers", - href: "/dashboard/containers", - icon: , - }, - { - label: "Cargoes", - href: "/dashboard/cargoes", - icon: , - }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, ], }, { @@ -271,7 +265,10 @@ const App = () => { path="booking-requests/:id/contract" element={} /> - } /> + } + /> } /> - setValues((current) => ({ ...current, [field.name]: e.currentTarget.value })) + setValues((current) => ({ + ...current, + [field.name]: e.target?.value ?? "", + })) } error={error} minRows={3} @@ -164,7 +167,10 @@ const FleetFormDialog = ({ label={field.label} value={String(value ?? "")} onChange={(e) => - setValues((current) => ({ ...current, [field.name]: e.currentTarget.value })) + setValues((current) => ({ + ...current, + [field.name]: e.target?.value ?? "", + })) } error={error} /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css index 8d14daad9..17dce66c3 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css @@ -181,6 +181,22 @@ transition: transform 220ms ease; } +.fsb-chevron-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + border: none; + background: transparent; + cursor: pointer; + flex-shrink: 0; +} + +.fsb-chevron-btn:hover .fsb-chevron { + color: #64748b; +} + /* ---- Nested branch ---- */ .fsb-branch { margin: 2px 0 2px 22px; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 708888c62..a2d685d0f 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -180,23 +180,37 @@ const FreightSidebar = ({ href={item.href} className="fsb-item" data-active={isActive} - onClick={(e) => navigateTo(e, item.href!)} + onClick={(e) => { + if (hasChildren) { + setExpanded((current) => ({ + ...current, + [item.href!]: true, + })); + } + navigateTo(e, item.href!); + }} > {item.icon && {item.icon}} {item.label} {hasChildren && ( - { e.preventDefault(); e.stopPropagation(); toggleExpanded(item.href!); }} - /> + > + + )} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index a498580a7..bc57dcdeb 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -45,16 +45,9 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ }, { prefix: "/dashboard/operations/train-scheduling-v2", - meta: { - title: "Train Schedules v2", - subtitle: "Operational train scheduling with full allocation workflow", - }, - }, - { - prefix: "/dashboard/operations/train-scheduling", meta: { title: "Train Schedules", - subtitle: "Create and manage container train schedules", + subtitle: "Operational train scheduling with full allocation workflow", }, }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx index 2091d091c..6f85b82d6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -16,7 +16,7 @@ import { Group, Loader, Paper, - SimpleGrid, + Progress, Stack, Text, ThemeIcon, @@ -25,12 +25,8 @@ import { } from "@mantine/core"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; -import { - RouteCorridor, - StatTile, - StatusPill, - scheduleBrand, -} from "@/components/trainScheduling/scheduleVisuals"; +import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; +import { freightBrand } from "@/theme/freight-brand"; import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; import { useToast } from "@/hooks/use-toast"; @@ -54,6 +50,33 @@ function formatDateTime(iso?: string | null) { }); } +/** Compact icon + label + value cell used in the header meta strip. */ +function MetaStat({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( + + + {icon} + + + + {label} + + + {value} + + + + ); +} + export default function TrainScheduleTrackPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); @@ -63,7 +86,7 @@ export default function TrainScheduleTrackPage() { if (trackQuery.isLoading) { return ( - + ); } @@ -80,7 +103,9 @@ export default function TrainScheduleTrackPage() { const canLog = track.status === "DISPATCHED"; const totalStations = track.stations.length; const reached = Math.min(track.currentSequenceNo + 1, totalStations); - const progressLabel = `${reached} / ${totalStations}`; + const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; + const clampedPct = Math.min(100, Math.max(0, progressPct)); + const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; const handleLog = (sequenceNo: number) => { const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; @@ -105,7 +130,7 @@ export default function TrainScheduleTrackPage() { }; return ( - + - - -
-
-
- -

Schedule builder

-
- -
-
- - -
- -
- - setScheduleDate(event.target.value)} - /> -
- -
- - -
-
- -
-
-

Origin

-

- {selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'} -

-
-
-

Destination

-

- {selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'} -

-
-
-

Locomotive capacity

-

- {selectedLocomotive - ? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m` - : '-'} -

-
-
-

Next step

-

Assign bookings, then allocate wagons

-
-
- -
- -
-
- -
-
-
-

Created schedules

-

- Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work. -

-
- - {filteredSchedules.length} schedules - -
- -
- setScheduleSearch(event.target.value)} - /> - -
- -
- - - - - - - - - - - - - - - - - {filteredSchedules.map((schedule) => ( - - - - - - - - - - - - - ))} - {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( - - - - ) : null} - -
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} - {schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`} - {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} -
- - {schedule.status !== 'CANCELLED' ? ( - - ) : null} -
-
- No train schedules matched the current filters. -
-
-
-
- - - (!open ? setDetailId(null) : null)}> - - - Train schedule detail - - Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation. - - - - {detail ? ( -
-
-
-

Schedule

-

{detail.id}

-
-
-

Departure

-

{formatDate(detail.scheduledDepartureDate)}

-
-
-

Route

-

{detail.route?.name ?? '-'}

-
-
-

Origin / destination

-

- {detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '} - {detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'} -

-
-
-

Status

-

{detail.status}

-
-
- -
-

Locomotive

-

- {detail.trainSet?.locomotive - ? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity / ${detail.trainSet.locomotive.maxTrainLengthMeters ?? 0}m)` - : 'No locomotive attached'} -

-
- -
-

Wagons and allocations

- {(detail.trainSet?.wagons?.length ?? 0) === 0 ? ( -

No wagons allocated yet.

- ) : ( -
- {(detail.trainSet?.wagons ?? []).map((wagon) => ( -
-
-
-

- Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} -

-

- {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m -

-
-
-
- ))} -
- )} -
- -
-

Bookings in schedule

- {detail.bookings.length === 0 ? ( -

No bookings assigned yet.

- ) : ( -
- - - - - - - - - - - {detail.bookings.map((booking) => ( - - - - - - - ))} - -
ReferenceCustomerWeightStatus
{booking.reference ?? booking.id}{booking.customer ?? '-'}{booking.weightTons} T{booking.status ?? '-'}
-
- )} -
-
- ) : ( -

Loading schedule detail...

- )} -
-
- - ); -}; - -export default TrainsPage; diff --git a/apps/edr-freight-web/backoffice/user-management b/apps/edr-freight-web/backoffice/user-management deleted file mode 160000 index 7c2e1d9bd..000000000 --- a/apps/edr-freight-web/backoffice/user-management +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7c2e1d9bdf42014ba4005838256222dcf6f065e4 diff --git a/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql new file mode 100644 index 000000000..9b9228768 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260615132114_add_dmoney_to_payment_method/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY'; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 34fe198d2..33eeb2386 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -3,9 +3,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - schemas = ["passenger"] + provider = "postgresql" + url = env("DATABASE_URL") + schemas = ["passenger"] } enum UserRole { @@ -71,12 +71,12 @@ enum Currency { } model CoachType { - id String @id @default(uuid()) + id String @id @default(uuid()) code String name String - type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage' - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage' + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt coaches Coach[] seatClasses SeatClass[] @@ -84,21 +84,21 @@ model CoachType { } model SeatClass { - id String @id @default(uuid()) - coachTypeId String - name String - description String? - baseFareMinor Int - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - coachType CoachType @relation(fields: [coachTypeId], references: [id]) - fareRules FareRule[] + id String @id @default(uuid()) + coachTypeId String + name String + description String? + baseFareMinor Int + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + fareRules FareRule[] routeFareRules RouteFareRule[] - segmentFares SegmentFareRule[] + segmentFares SegmentFareRule[] + @@unique([coachTypeId, name]) @@index([coachTypeId]) - @@schema("passenger") } @@ -130,6 +130,7 @@ enum PaymentMethodType { CARD WALLET WAAFI + DMONEY @@schema("passenger") } @@ -225,34 +226,34 @@ enum DevicePlatform { } model User { - id String @id @default(uuid()) - email String @unique - phone String @unique - fullName String - passwordHash String - role UserRole @default(PASSENGER) - nationality String? - nationalityCode String? - passportNumber String? - nationalId String? - failedLoginAttempts Int @default(0) - lockedUntil DateTime? - blockedUntil DateTime? - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + email String @unique + phone String @unique + fullName String + passwordHash String + role UserRole @default(PASSENGER) + nationality String? + nationalityCode String? + passportNumber String? + nationalId String? + failedLoginAttempts Int @default(0) + lockedUntil DateTime? + blockedUntil DateTime? + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - faydaVerified Boolean @default(false) - faydaVerifiedAt DateTime? - faydaSub String? @unique + faydaVerified Boolean @default(false) + faydaVerifiedAt DateTime? + faydaSub String? @unique - passenger Passenger? - agent Agent? - sessions Session[] - devices Device[] - preferences UserPreferences? - auditLogs AuditLog[] - fraudAlerts FraudAlert[] + passenger Passenger? + agent Agent? + sessions Session[] + devices Device[] + preferences UserPreferences? + auditLogs AuditLog[] + fraudAlerts FraudAlert[] faydaVerificationSessions FaydaVerificationSession[] @@ -260,34 +261,34 @@ model User { } model Session { - id String @id @default(uuid()) - userId String - token String @unique - expiresAt DateTime - ipAddress String? - userAgent String? + id String @id @default(uuid()) + userId String + token String @unique + expiresAt DateTime + ipAddress String? + userAgent String? lastActivityAt DateTime @default(now()) - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@schema("passenger") } model Passenger { - id String @id @default(uuid()) - userId String @unique + id String @id @default(uuid()) + userId String @unique defaultTravelerProfileId String? - preferredLanguage String? - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - bookings Booking[] - loyalty LoyaltyAccount? - wallet WalletAccount? - notifications Notification[] - travelerProfiles TravelerProfile[] - savedRoutes SavedRoute[] - @@index([userId]) + preferredLanguage String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + bookings Booking[] + loyalty LoyaltyAccount? + wallet WalletAccount? + notifications Notification[] + travelerProfiles TravelerProfile[] + savedRoutes SavedRoute[] + @@index([userId]) @@schema("passenger") } @@ -306,21 +307,21 @@ model TravelerProfile { } model Station { - id String @id @default(uuid()) - code String @unique - name String - city String - countryCode String? - isOperational Boolean @default(true) - timezone String @default("Africa/Addis_Ababa") - lat Decimal @db.Decimal(9, 6) - lng Decimal @db.Decimal(9, 6) - originSchedules TrainSchedule[] @relation("OriginTrips") - destinationSchedules TrainSchedule[] @relation("DestinationTrips") + id String @id @default(uuid()) + code String @unique + name String + city String + countryCode String? + isOperational Boolean @default(true) + timezone String @default("Africa/Addis_Ababa") + lat Decimal @db.Decimal(9, 6) + lng Decimal @db.Decimal(9, 6) + originSchedules TrainSchedule[] @relation("OriginTrips") + destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] crowdSignals StationCrowdSignal[] - @@index([city, countryCode]) + @@index([city, countryCode]) @@schema("passenger") } @@ -354,18 +355,18 @@ model TrainSchedule { onTimePercent Int @default(100) carbonRating String @default("A") notes String? - train Train @relation(fields: [trainId], references: [id]) - route Route? @relation(fields: [routeId], references: [id]) - originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) - destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) - coachAssignments CoachAssignment[] - bookings Booking[] - stopTimes TripStopTime[] - liveStatus TripLiveStatus? - menuItems MenuItem[] - journeySegments JourneySegment[] - @@index([departureAt, originStationId]) + train Train @relation(fields: [trainId], references: [id]) + route Route? @relation(fields: [routeId], references: [id]) + originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) + destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) + coachAssignments CoachAssignment[] + bookings Booking[] + stopTimes TripStopTime[] + liveStatus TripLiveStatus? + menuItems MenuItem[] + journeySegments JourneySegment[] + @@index([departureAt, originStationId]) @@schema("passenger") } @@ -378,10 +379,10 @@ model TripStopTime { plannedDepartureAt DateTime? actualArrivalAt DateTime? status StopStatus @default(UPCOMING) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - station Station @relation(fields: [stationId], references: [id]) - @@unique([scheduleId, sequence]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + station Station @relation(fields: [stationId], references: [id]) + @@unique([scheduleId, sequence]) @@schema("passenger") } @@ -395,64 +396,64 @@ model TripLiveStatus { currentSpeedKph Int? platformLabel String? updatedAt DateTime @updatedAt - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) @@schema("passenger") } model Coach { - id String @id @default(uuid()) - coachTypeId String - number String @unique - arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2' - capacity Int @default(0) // Total seats/beds - status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + coachTypeId String + number String @unique + arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2' + capacity Int @default(0) // Total seats/beds + status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt coachType CoachType @relation(fields: [coachTypeId], references: [id]) seats Seat[] assignments CoachAssignment[] - @@index([coachTypeId]) + @@index([coachTypeId]) @@schema("passenger") } model CoachAssignment { - id String @id @default(uuid()) - scheduleId String - coachId String - positionNumber Int - isOperational Boolean @default(true) - createdAt DateTime @default(now()) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - coach Coach @relation(fields: [coachId], references: [id]) + id String @id @default(uuid()) + scheduleId String + coachId String + positionNumber Int + isOperational Boolean @default(true) + createdAt DateTime @default(now()) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + coach Coach @relation(fields: [coachId], references: [id]) + @@unique([scheduleId, positionNumber]) @@index([scheduleId]) - @@schema("passenger") } model Seat { - id String @id @default(uuid()) + id String @id @default(uuid()) coachId String - seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach) + seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach) row Int col String - kind SeatKind @default(STANDARD) - status SeatStatus @default(AVAILABLE) + kind SeatKind @default(STANDARD) + status SeatStatus @default(AVAILABLE) heldUntil DateTime? - isWindow Boolean @default(false) - isAisle Boolean @default(false) - bedPosition String? // 'lower', 'middle', 'upper' - premiumFeeMinor Int @default(0) - coach Coach @relation(fields: [coachId], references: [id]) - bookingSeats BookingSeat[] - blocks SeatBlock[] - ticketSeats TicketSeat[] + isWindow Boolean @default(false) + isAisle Boolean @default(false) + bedPosition String? // 'lower', 'middle', 'upper' + premiumFeeMinor Int @default(0) + coach Coach @relation(fields: [coachId], references: [id]) + bookingSeats BookingSeat[] + blocks SeatBlock[] + ticketSeats TicketSeat[] + @@unique([coachId, seatNumber]) @@unique([coachId, row, col]) @@index([coachId]) - @@schema("passenger") } @@ -465,8 +466,8 @@ model SeatHold { createdBy String? expiresAt DateTime createdAt DateTime @default(now()) - @@index([expiresAt]) + @@index([expiresAt]) @@schema("passenger") } @@ -474,77 +475,77 @@ model FareRule { id String @id @default(uuid()) tripId String? route String? - nationality String? // Ethiopian, Djiboutian, Other + nationality String? // Ethiopian, Djiboutian, Other seatClassId String baseFareMinor Int - seatClass SeatClass @relation(fields: [seatClassId], references: [id]) - currency String @default("ETB") - refundable Boolean @default(true) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + currency String @default("ETB") + refundable Boolean @default(true) validFrom DateTime validUntil DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@schema("passenger") } model Booking { - id String @id @default(uuid()) - bookingRef String @unique - passengerId String - scheduleId String - status BookingStatus @default(DRAFT) - currency String @default("ETB") - totalMinor Int - adultCount Int @default(1) - childCount Int @default(0) - displayCurrency Currency? + id String @id @default(uuid()) + bookingRef String @unique + passengerId String + scheduleId String + status BookingStatus @default(DRAFT) + currency String @default("ETB") + totalMinor Int + adultCount Int @default(1) + childCount Int @default(0) + displayCurrency Currency? displayTotalMinor Int? - bookingType String @default("ONE_WAY") - contactEmail String? - contactPhone String? - userAgent String? - source String @default("WEB") - promoCode String? - paidAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - seats BookingSeat[] - paymentIntent PaymentIntent? - ticket Ticket? - foodOrders FoodOrder[] - agentBooking AgentBooking? - modifications BookingModification[] - cancellation BookingCancellation? - baggage BaggageBooking[] - @@index([passengerId, status]) + bookingType String @default("ONE_WAY") + contactEmail String? + contactPhone String? + userAgent String? + source String @default("WEB") + promoCode String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + seats BookingSeat[] + paymentIntent PaymentIntent? + ticket Ticket? + foodOrders FoodOrder[] + agentBooking AgentBooking? + modifications BookingModification[] + cancellation BookingCancellation? + baggage BaggageBooking[] + @@index([passengerId, status]) @@schema("passenger") } model BookingSeat { - id String @id @default(uuid()) - bookingId String - seatId String - passengerName String - dateOfBirth DateTime? - passengerCategory PassengerCategory @default(ADULT) - idDocumentType IdDocumentType? - idDocumentNumber String? - passportNumber String? - passportCountry String? - verifaydaVerified Boolean @default(false) - verifaydaData Json? - faydaVerifiedAt DateTime? - faydaSub String? - faydaVerifiedName String? - seatLabelSnapshot String? - fareMinor Int? - displayCurrency Currency? - displayFareMinor Int? - booking Booking @relation(fields: [bookingId], references: [id]) - seat Seat @relation(fields: [seatId], references: [id]) + id String @id @default(uuid()) + bookingId String + seatId String + passengerName String + dateOfBirth DateTime? + passengerCategory PassengerCategory @default(ADULT) + idDocumentType IdDocumentType? + idDocumentNumber String? + passportNumber String? + passportCountry String? + verifaydaVerified Boolean @default(false) + verifaydaData Json? + faydaVerifiedAt DateTime? + faydaSub String? + faydaVerifiedName String? + seatLabelSnapshot String? + fareMinor Int? + displayCurrency Currency? + displayFareMinor Int? + booking Booking @relation(fields: [bookingId], references: [id]) + seat Seat @relation(fields: [seatId], references: [id]) @@schema("passenger") } @@ -587,11 +588,11 @@ model PaymentIntent { expiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - booking Booking @relation(fields: [bookingId], references: [id]) - refunds PaymentRefund[] + booking Booking @relation(fields: [bookingId], references: [id]) + refunds PaymentRefund[] + @@index([providerOrderId]) @@index([providerTxnId]) - @@schema("passenger") } @@ -607,68 +608,68 @@ model PaymentWebhookEvent { receivedAt DateTime @default(now()) processedAt DateTime? processingError String? + @@unique([provider, externalEventId]) @@index([merchantOrderId]) - @@schema("passenger") } model PaymentRefund { - id String @id @default(uuid()) + id String @id @default(uuid()) paymentIntentId String amountMinor Int reason String? providerRefundId String? status String - createdAt DateTime @default(now()) - paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) + createdAt DateTime @default(now()) + paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) @@schema("passenger") } model Ticket { - id String @id @default(uuid()) - bookingId String @unique - bookingRef String - status String @default("CONFIRMED") - qrPayload String - barcodePayload String? - pdfUrl String? - deliveryChannel String @default("EMAIL") - issuedAt DateTime @default(now()) - validatedAt DateTime? - validatorId String? - booking Booking @relation(fields: [bookingId], references: [id]) - validationLogs GateValidationLog[] - seats TicketSeat[] + id String @id @default(uuid()) + bookingId String @unique + bookingRef String + status String @default("CONFIRMED") + qrPayload String + barcodePayload String? + pdfUrl String? + deliveryChannel String @default("EMAIL") + issuedAt DateTime @default(now()) + validatedAt DateTime? + validatorId String? + booking Booking @relation(fields: [bookingId], references: [id]) + validationLogs GateValidationLog[] + seats TicketSeat[] @@schema("passenger") } model TicketSeat { - id String @id @default(uuid()) + id String @id @default(uuid()) ticketId String seatId String - seatIndex Int @default(0) - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - seat Seat @relation(fields: [seatId], references: [id]) + seatIndex Int @default(0) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + seat Seat @relation(fields: [seatId], references: [id]) + @@index([ticketId]) @@index([seatId]) - @@schema("passenger") } model LoyaltyAccount { - id String @id @default(uuid()) - passengerId String @unique - pointsBalance Int @default(0) - lifetimePoints Int @default(0) - tier LoyaltyTier @default(BRONZE) - tierUpdatedAt DateTime? - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - ledger LoyaltyLedgerEntry[] - rewards LoyaltyReward[] + id String @id @default(uuid()) + passengerId String @unique + pointsBalance Int @default(0) + lifetimePoints Int @default(0) + tier LoyaltyTier @default(BRONZE) + tierUpdatedAt DateTime? + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + ledger LoyaltyLedgerEntry[] + rewards LoyaltyReward[] @@schema("passenger") } @@ -681,35 +682,35 @@ model LoyaltyLedgerEntry { bookingId String? balanceAfter Int createdAt DateTime @default(now()) - account LoyaltyAccount @relation(fields: [accountId], references: [id]) + account LoyaltyAccount @relation(fields: [accountId], references: [id]) @@schema("passenger") } model LoyaltyReward { - id String @id @default(uuid()) + id String @id @default(uuid()) accountId String title String costPoints Int - available Boolean @default(true) + available Boolean @default(true) description String? - account LoyaltyAccount @relation(fields: [accountId], references: [id]) + account LoyaltyAccount @relation(fields: [accountId], references: [id]) @@schema("passenger") } model WalletAccount { - id String @id @default(uuid()) - passengerId String @unique - balanceMinor Int @default(0) - status String @default("ACTIVE") - holdMinor Int @default(0) - currency String @default("ETB") - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - ledger WalletLedgerEntry[] - @@index([passengerId]) + id String @id @default(uuid()) + passengerId String @unique + balanceMinor Int @default(0) + status String @default("ACTIVE") + holdMinor Int @default(0) + currency String @default("ETB") + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + ledger WalletLedgerEntry[] + @@index([passengerId]) @@schema("passenger") } @@ -722,7 +723,7 @@ model WalletLedgerEntry { description String relatedBookingId String? createdAt DateTime @default(now()) - wallet WalletAccount @relation(fields: [walletId], references: [id]) + wallet WalletAccount @relation(fields: [walletId], references: [id]) @@schema("passenger") } @@ -737,7 +738,7 @@ model Notification { deepLink String? metadata Json? createdAt DateTime @default(now()) - passenger Passenger @relation(fields: [passengerId], references: [id]) + passenger Passenger @relation(fields: [passengerId], references: [id]) @@schema("passenger") } @@ -759,15 +760,15 @@ model Promotion { } model StationCrowdSignal { - id String @id @default(uuid()) + id String @id @default(uuid()) stationId String level String label String statusLabel String confidence Int? observedAt DateTime? - updatedAt DateTime @updatedAt - station Station @relation(fields: [stationId], references: [id]) + updatedAt DateTime @updatedAt + station Station @relation(fields: [stationId], references: [id]) @@schema("passenger") } @@ -793,44 +794,44 @@ model MenuCategory { } model MenuItem { - id String @id @default(uuid()) - scheduleId String - categoryId String - name String - priceMinor Int - currency String @default("ETB") - available Boolean @default(true) + id String @id @default(uuid()) + scheduleId String + categoryId String + name String + priceMinor Int + currency String @default("ETB") + available Boolean @default(true) availableUntil DateTime? - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - category MenuCategory @relation(fields: [categoryId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + category MenuCategory @relation(fields: [categoryId], references: [id]) @@schema("passenger") } model FoodOrder { - id String @id @default(uuid()) - bookingId String - status FoodOrderStatus @default(PENDING) - totalMinor Int - currency String @default("ETB") + id String @id @default(uuid()) + bookingId String + status FoodOrderStatus @default(PENDING) + totalMinor Int + currency String @default("ETB") specialInstructions String? - estimatedReadyAt DateTime? - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) - items FoodOrderItem[] + estimatedReadyAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + items FoodOrderItem[] @@schema("passenger") } model FoodOrderItem { - id String @id @default(uuid()) + id String @id @default(uuid()) orderId String menuItemId String name String quantity Int unitPriceMinor Int? lineTotalMinor Int - order FoodOrder @relation(fields: [orderId], references: [id]) + order FoodOrder @relation(fields: [orderId], references: [id]) @@schema("passenger") } @@ -850,30 +851,30 @@ model FaqArticle { question String answerMarkdown String rank Int @default(0) - category FaqCategory @relation(fields: [categoryId], references: [id]) + category FaqCategory @relation(fields: [categoryId], references: [id]) @@schema("passenger") } model SupportConversation { - id String @id @default(uuid()) - userId String + id String @id @default(uuid()) + userId String assignedAgentId String? - status SupportConversationStatus @default(OPEN) - createdAt DateTime @default(now()) - messages SupportMessage[] + status SupportConversationStatus @default(OPEN) + createdAt DateTime @default(now()) + messages SupportMessage[] @@schema("passenger") } model SupportMessage { - id String @id @default(uuid()) + id String @id @default(uuid()) conversationId String sender SupportSender text String attachments Json? - createdAt DateTime @default(now()) - conversation SupportConversation @relation(fields: [conversationId], references: [id]) + createdAt DateTime @default(now()) + conversation SupportConversation @relation(fields: [conversationId], references: [id]) @@schema("passenger") } @@ -893,7 +894,7 @@ model UserPreferences { locale String @default("en") darkMode Boolean @default(false) language String @default("en") - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@schema("passenger") } @@ -906,48 +907,48 @@ model Device { pushToken String? trusted Boolean @default(false) lastSeenAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@schema("passenger") } model SavedRoute { - id String @id @default(uuid()) + id String @id @default(uuid()) passengerId String fromStationId String toStationId String fromName String toName String - tripCount Int @default(0) - createdAt DateTime @default(now()) - passenger Passenger @relation(fields: [passengerId], references: [id]) + tripCount Int @default(0) + createdAt DateTime @default(now()) + passenger Passenger @relation(fields: [passengerId], references: [id]) @@schema("passenger") } model Journey { - id String @id @default(uuid()) - passengerId String - status String - totalMinor Int - currency String @default("ETB") - createdAt DateTime @default(now()) + id String @id @default(uuid()) + passengerId String + status String + totalMinor Int + currency String @default("ETB") + createdAt DateTime @default(now()) journeySegments JourneySegment[] @@schema("passenger") } model JourneySegment { - id String @id @default(uuid()) - journeyId String - scheduleId String - segmentOrder Int - seatId String? - coachId String? - departureStationId String - arrivalStationId String - journey Journey @relation(fields: [journeyId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + id String @id @default(uuid()) + journeyId String + scheduleId String + segmentOrder Int + seatId String? + coachId String? + departureStationId String + arrivalStationId String + journey Journey @relation(fields: [journeyId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) @@schema("passenger") } @@ -962,8 +963,8 @@ model OtpCode { expiresAt DateTime verified Boolean @default(false) createdAt DateTime @default(now()) - @@index([email, phone]) + @@index([email, phone]) @@schema("passenger") } @@ -974,39 +975,39 @@ model PasswordResetToken { expiresAt DateTime used Boolean @default(false) createdAt DateTime @default(now()) - @@index([userId]) + @@index([userId]) @@schema("passenger") } model Route { - id String @id @default(uuid()) - code String @unique - name String - description String? - effectiveFrom DateTime + id String @id @default(uuid()) + code String @unique + name String + description String? + effectiveFrom DateTime effectiveUntil DateTime? - active Boolean @default(true) - createdAt DateTime @default(now()) - stops RouteStop[] - fareRules RouteFareRule[] - segmentFares SegmentFareRule[] - schedules TrainSchedule[] + active Boolean @default(true) + createdAt DateTime @default(now()) + stops RouteStop[] + fareRules RouteFareRule[] + segmentFares SegmentFareRule[] + schedules TrainSchedule[] @@schema("passenger") } model RouteStop { - id String @id @default(uuid()) - routeId String - stationId String - sequence Int - distanceKm Int? - createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + routeId String + stationId String + sequence Int + distanceKm Int? + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + @@unique([routeId, sequence]) @@index([routeId, stationId]) - @@schema("passenger") } @@ -1023,120 +1024,120 @@ model RouteFareRule { validFrom DateTime validUntil DateTime? createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) - seatClass SeatClass @relation(fields: [seatClassId], references: [id]) - @@index([routeId, seatClassId]) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + @@index([routeId, seatClassId]) @@schema("passenger") } model SegmentFareRule { - id String @id @default(uuid()) - routeId String - originStopSequence Int + id String @id @default(uuid()) + routeId String + originStopSequence Int destinationStopSequence Int - seatClassId String - baseFareMinor Int - nationality String? // Optional: Ethiopian, Djiboutian, Other - currency String @default("ETB") - validFrom DateTime - validUntil DateTime? - createdAt DateTime @default(now()) - route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) - seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + seatClassId String + baseFareMinor Int + nationality String? // Optional: Ethiopian, Djiboutian, Other + currency String @default("ETB") + validFrom DateTime + validUntil DateTime? + createdAt DateTime @default(now()) + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) + seatClass SeatClass @relation(fields: [seatClassId], references: [id]) + @@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality]) @@index([routeId, seatClassId]) - @@schema("passenger") } model Agent { - id String @id @default(uuid()) - userId String @unique - agentCode String @unique - stationId String? - commissionRate Int @default(5) - active Boolean @default(true) - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - bookings AgentBooking[] - shifts AgentShift[] - commissions AgentCommission[] + id String @id @default(uuid()) + userId String @unique + agentCode String @unique + stationId String? + commissionRate Int @default(5) + active Boolean @default(true) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + bookings AgentBooking[] + shifts AgentShift[] + commissions AgentCommission[] @@schema("passenger") } model AgentBooking { - id String @id @default(uuid()) - agentId String - bookingId String @unique - paymentMethod String - cashReceived Int? - changeGiven Int? - paperTicket Boolean @default(false) - createdAt DateTime @default(now()) - agent Agent @relation(fields: [agentId], references: [id]) - booking Booking @relation(fields: [bookingId], references: [id]) + id String @id @default(uuid()) + agentId String + bookingId String @unique + paymentMethod String + cashReceived Int? + changeGiven Int? + paperTicket Boolean @default(false) + createdAt DateTime @default(now()) + agent Agent @relation(fields: [agentId], references: [id]) + booking Booking @relation(fields: [bookingId], references: [id]) @@schema("passenger") } model AgentShift { - id String @id @default(uuid()) - agentId String - openedAt DateTime @default(now()) - closedAt DateTime? - openingBalance Int @default(0) - closingBalance Int? - reconciled Boolean @default(false) - notes String? - agent Agent @relation(fields: [agentId], references: [id]) - @@index([agentId, openedAt]) + id String @id @default(uuid()) + agentId String + openedAt DateTime @default(now()) + closedAt DateTime? + openingBalance Int @default(0) + closingBalance Int? + reconciled Boolean @default(false) + notes String? + agent Agent @relation(fields: [agentId], references: [id]) + @@index([agentId, openedAt]) @@schema("passenger") } model AgentCommission { - id String @id @default(uuid()) + id String @id @default(uuid()) agentId String bookingId String amountMinor Int rate Int paidAt DateTime? - createdAt DateTime @default(now()) - agent Agent @relation(fields: [agentId], references: [id]) - @@index([agentId, paidAt]) + createdAt DateTime @default(now()) + agent Agent @relation(fields: [agentId], references: [id]) + @@index([agentId, paidAt]) @@schema("passenger") } model BookingModification { - id String @id @default(uuid()) - bookingId String - modifiedBy String + id String @id @default(uuid()) + bookingId String + modifiedBy String modificationType String - oldData Json - newData Json - fareAdjustment Int @default(0) - reason String? - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) - @@index([bookingId]) + oldData Json + newData Json + fareAdjustment Int @default(0) + reason String? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + @@index([bookingId]) @@schema("passenger") } model BookingCancellation { - id String @id @default(uuid()) - bookingId String @unique - cancelledBy String - reason String? - refundAmount Int - refundMethod String - refundStatus String - processedAt DateTime? - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) + id String @id @default(uuid()) + bookingId String @unique + cancelledBy String + reason String? + refundAmount Int + refundMethod String + refundStatus String + processedAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) @@schema("passenger") } @@ -1150,79 +1151,79 @@ model GateValidationLog { reason String? validatedAt DateTime @default(now()) ticket Ticket @relation(fields: [ticketId], references: [id]) + @@index([ticketId]) @@index([validatorId]) - @@schema("passenger") } model BaggageAllowance { - id String @id @default(uuid()) - seatClassId String - maxWeightKg Int - maxPiecesCount Int - excessFeePerKg Int - currency String @default("ETB") - createdAt DateTime @default(now()) + id String @id @default(uuid()) + seatClassId String + maxWeightKg Int + maxPiecesCount Int + excessFeePerKg Int + currency String @default("ETB") + createdAt DateTime @default(now()) @@schema("passenger") } model BaggageBooking { - id String @id @default(uuid()) - bookingId String - weightKg Int - piecesCount Int - excessFeeMinor Int @default(0) - paid Boolean @default(false) - createdAt DateTime @default(now()) - booking Booking @relation(fields: [bookingId], references: [id]) - @@index([bookingId]) + id String @id @default(uuid()) + bookingId String + weightKg Int + piecesCount Int + excessFeeMinor Int @default(0) + paid Boolean @default(false) + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + @@index([bookingId]) @@schema("passenger") } model AuditLog { - id String @id @default(uuid()) - userId String? - action String - entityType String - entityId String? - oldData Json? - newData Json? - ipAddress String? - userAgent String? - createdAt DateTime @default(now()) - user User? @relation(fields: [userId], references: [id]) + id String @id @default(uuid()) + userId String? + action String + entityType String + entityId String? + oldData Json? + newData Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + user User? @relation(fields: [userId], references: [id]) + @@index([userId, createdAt]) @@index([entityType, entityId]) - @@schema("passenger") } model NotificationTemplate { - id String @id @default(uuid()) - code String @unique - channel String - subject String? + id String @id @default(uuid()) + code String @unique + channel String + subject String? bodyTemplate String - active Boolean @default(true) - createdAt DateTime @default(now()) + active Boolean @default(true) + createdAt DateTime @default(now()) @@schema("passenger") } model SeatBlock { - id String @id @default(uuid()) - seatId String - reason String - blockedBy String - approvedBy String? - blockedAt DateTime @default(now()) - unblockAt DateTime? - seat Seat @relation(fields: [seatId], references: [id]) - @@index([seatId]) + id String @id @default(uuid()) + seatId String + reason String + blockedBy String + approvedBy String? + blockedAt DateTime @default(now()) + unblockAt DateTime? + seat Seat @relation(fields: [seatId], references: [id]) + @@index([seatId]) @@schema("passenger") } @@ -1234,8 +1235,8 @@ model OperationalReport { data Json generatedBy String? createdAt DateTime @default(now()) - @@index([reportType, dateFrom]) + @@index([reportType, dateFrom]) @@schema("passenger") } @@ -1261,9 +1262,9 @@ model FraudAlert { acknowledged Boolean @default(false) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId, createdAt]) @@index([acknowledged]) - @@schema("passenger") } @@ -1275,45 +1276,45 @@ model CurrencyExchangeRate { effectiveDate DateTime @default(now()) source String @default("MANUAL") createdAt DateTime @default(now()) + @@unique([fromCurrency, toCurrency, effectiveDate]) @@index([fromCurrency, toCurrency]) - @@schema("passenger") } model VerifaydaVerification { - id String @id @default(uuid()) - bookingId String? - nationalId String - requestPayload Json - responsePayload Json? - verified Boolean @default(false) - failureReason String? - verifiedAt DateTime? - createdAt DateTime @default(now()) + id String @id @default(uuid()) + bookingId String? + nationalId String + requestPayload Json + responsePayload Json? + verified Boolean @default(false) + failureReason String? + verifiedAt DateTime? + createdAt DateTime @default(now()) + @@index([nationalId]) @@index([bookingId]) - @@schema("passenger") } model SavedPassengerProfile { - id String @id @default(uuid()) - userId String? - deviceId String? - passengerName String - dateOfBirth DateTime - idDocumentType IdDocumentType - passportNumber String? - passportCountry String? - nationality String? - phone String? - email String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String? + deviceId String? + passengerName String + dateOfBirth DateTime + idDocumentType IdDocumentType + passportNumber String? + passportCountry String? + nationality String? + phone String? + email String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([userId]) @@index([deviceId]) - @@schema("passenger") } @@ -1335,13 +1336,11 @@ model FaydaVerificationSession { userId String? bookingId String? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([bookingId]) @@index([state]) @@index([expiresAt]) - @@schema("passenger") } - 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 309bd4a0a..c1b168138 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -20,7 +20,8 @@ export enum PaymentMethodTypeEnum { TELEBIRR = "TELEBIRR", // Ethiopia CBE_BIRR = "CBE_BIRR", // Ethiopia EBIRR = "EBIRR", // Ethiopia - WAAFI = "WAAFI", // Djibouti + WAAFI = "WAAFI", + DMONEY= "DMONEY",// Djibouti CARD = "CARD", // International WALLET = "WALLET", // Internal } @@ -95,9 +96,8 @@ export class SupportedPaymentMethodDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type: - | "REDIRECT" - | "LAUNCH_APP"; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) + type: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @ApiPropertyOptional({ @@ -112,6 +112,10 @@ export class ClientActionDto { description: "Set when type=LAUNCH_APP (mobile flow)", }) shortCode?: string; + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + providerOrderId?: string; + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + message?: string; } export class InitiateResponseDto { 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 fe80ae88e..59438c4ad 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -43,6 +43,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); + /** + * DEMO ONLY: when true, a WALLET "payment" is treated as instantly successful — the wallet + * balance check and debit are skipped and the booking is confirmed + ticket issued as if fully + * paid. Lets the happy-path be demoed while a real provider (e.g. Telebirr) is unavailable. + * Never enable in production. Toggle with WALLET_DEMO_AUTO_SUCCEED in the env. + */ + private readonly walletDemoAutoSucceed = true; + constructor( private prisma: PrismaService, private seatsService: SeatsService, @@ -196,6 +204,35 @@ export class PaymentsService { private async initiateWalletPayment( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ): Promise { + // DEMO ONLY (WALLET_DEMO_AUTO_SUCCEED): pretend the payment succeeded — no balance check, + // no debit — and run the exact same finalize path a real successful payment uses + // (booking → CONFIRMED, seats confirmed, ticket issued). Remove once a real provider works. + if (this.walletDemoAutoSucceed) { + this.logger.warn( + `WALLET_DEMO_AUTO_SUCCEED enabled — faking a successful WALLET payment for booking ${booking.bookingRef} (${booking.id})`, + ); + const demoIntent = await this.prisma.paymentIntent.upsert({ + where: { bookingId: booking.id }, + update: { + status: PaymentIntentStatus.PROCESSING, + failureCode: null, + method: PaymentMethodType.WALLET, + }, + create: { + bookingId: booking.id, + amountMinor: booking.totalMinor, + method: PaymentMethodType.WALLET, + status: PaymentIntentStatus.PROCESSING, + providerRef: `WALLET-DEMO-${Date.now()}`, + }, + }); + await this.finalizePaymentSuccess({ intentId: demoIntent.id }); + const settled = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: demoIntent.id }, + }); + return this.formatIntentResponse(settled); + } + const debitResult = await this.prisma.$transaction(async (tx) => { const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId }, diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts index de92f3909..410a8146b 100644 --- a/apps/edr-payment-api/src/app.module.ts +++ b/apps/edr-payment-api/src/app.module.ts @@ -12,6 +12,7 @@ import cbeConfig from "./config/cbe.config"; import ebirrConfig from "./config/ebirr.config"; import cardConfig from "./config/card.config"; import dmoneyConfig from "./config/dmoney.config"; +import cacConfig from "./config/cac.config"; import { HealthModule } from "./modules/health/health.module"; import { IntentsModule } from "./modules/intents/intents.module"; import { OutboxModule } from "./modules/outbox/outbox.module"; @@ -34,6 +35,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module"; ebirrConfig, cardConfig, dmoneyConfig, + cacConfig, ], }), TypeOrmModule.forRootAsync({ diff --git a/apps/edr-payment-api/src/config/cac.config.ts b/apps/edr-payment-api/src/config/cac.config.ts new file mode 100644 index 000000000..360e64299 --- /dev/null +++ b/apps/edr-payment-api/src/config/cac.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("cac", () => ({ + baseUrl: process.env.CAC_BASE_URL || "", + username: process.env.CAC_USERNAME || "", + password: process.env.CAC_PASSWORD || "", + appKey: process.env.CAC_APP_KEY || "", + apiKey: process.env.CAC_API_KEY || "", + companyServicesId: Number(process.env.CAC_COMPANY_SERVICES_ID || 0), + currency: process.env.CAC_CURRENCY || "DJF", + tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000), + otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 60 * 1000), +})); diff --git a/apps/edr-payment-api/src/config/dmoney.config.ts b/apps/edr-payment-api/src/config/dmoney.config.ts index 78751f8d5..d0938eeea 100644 --- a/apps/edr-payment-api/src/config/dmoney.config.ts +++ b/apps/edr-payment-api/src/config/dmoney.config.ts @@ -2,9 +2,17 @@ import { registerAs } from "@nestjs/config"; export default registerAs("dmoney", () => ({ baseUrl: process.env.DMONEY_BASE_URL ?? "", - appId: process.env.DMONEY_APP_ID ?? "", + webBaseUrl: process.env.DMONEY_WEB_BASE_URL ?? "", + fabricAppId: process.env.DMONEY_FABRIC_APP_ID ?? "", appSecret: process.env.DMONEY_APP_SECRET ?? "", - publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", - privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + merchantAppId: process.env.DMONEY_MERCHANT_APP_ID ?? "", + merchantCode: process.env.DMONEY_MERCHANT_CODE ?? "", notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "", + returnUrl: process.env.DMONEY_RETURN_URL ?? "", + timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m", + language: process.env.DMONEY_LANGUAGE ?? "en", + currency: process.env.DMONEY_CURRENCY ?? "FDJ", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + insecureTls: process.env.DMONEY_INSECURE_TLS === "true", })); diff --git a/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts new file mode 100644 index 000000000..1dfae2dde --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/dto/confirm-payment.dto.ts @@ -0,0 +1,14 @@ +import { IsString, Length } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { ConfirmPaymentRequest } from "@edr/types"; + +/** Wire shape is the shared `ConfirmPaymentRequest` contract from @edr/types. */ +export class ConfirmPaymentDto implements ConfirmPaymentRequest { + @ApiProperty({ + description: "One-time password sent to the payer's mobile via SMS", + example: "123456", + }) + @IsString() + @Length(1, 10) + otp!: string; +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts index 858ad3bae..35f3461d2 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.controller.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -15,6 +15,7 @@ import { InitiatePaymentRequestDto, IntentReferenceQueryDto, } from "./dto/initiate-payment.dto"; +import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; import { IntentsService } from "./intents.service"; /** @@ -66,4 +67,17 @@ export class IntentsController { query.referenceId, ); } + + @Post("intents/:id/confirm") + @ApiOperation({ + summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", + description: + "Submits the SMS OTP to complete payment. Only supported for providers that use COLLECT_OTP clientAction.", + }) + async confirm( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ConfirmPaymentDto, + ): Promise { + return this.intentsService.confirm(id, dto); + } } diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 492a86b78..aebb65ebb 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -6,12 +6,14 @@ import { NotFoundException, } from "@nestjs/common"; import { DataSource, QueryFailedError } from "typeorm"; -import { createMerchantOrderId } from "@edr/payment-providers"; +import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers"; import { + ConfirmPaymentRequest, InitiatePaymentRequest, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, + ProviderMethod, ProviderPaymentStatus, ProviderStatus, } from "@edr/types"; @@ -52,6 +54,7 @@ export class IntentsService { private readonly dataSource: DataSource, @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, + private readonly cacBankProvider: CacBankProvider, ) {} /* ------------------------------------------------------------------ initiate */ @@ -85,6 +88,15 @@ export class IntentsService { ); } + if ( + request.provider === ProviderMethod.CAC_BANK && + !request.payerAccount?.trim() + ) { + throw new BadRequestException( + "payerAccount (customer mobile number) is required for CAC_BANK", + ); + } + const merchantOrderId = createMerchantOrderId(); const result = await provider.initiate({ merchantOrderId, @@ -134,6 +146,65 @@ export class IntentsService { } } + /* ------------------------------------------------------------------ confirm (OTP providers) */ + + async confirm( + intentId: string, + request: ConfirmPaymentRequest, + ): Promise { + const intent = await this.intentsRepository.findById(intentId); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + + if (intent.provider !== ProviderMethod.CAC_BANK) { + throw new BadRequestException( + `Confirm is not supported for provider: ${intent.provider}`, + ); + } + + if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) { + throw new BadRequestException( + `Intent is not awaiting confirmation (status=${intent.status})`, + ); + } + + if (!intent.providerOrderId) { + throw new BadRequestException("Intent has no provider order id"); + } + + const confirmResult = await this.cacBankProvider.confirmPayment( + intent.providerOrderId, + request.otp, + ); + + if (confirmResult.reference) { + await this.intentsRepository.update(intent.id, { + rawInitiation: { + ...(intent.rawInitiation ?? {}), + reference: confirmResult.reference, + confirmResponse: confirmResult.rawResponse, + }, + }); + } + + if (confirmResult.status === "SUCCEEDED") { + await this.applyProviderResult(intent.id, { + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: confirmResult.providerTxnId, + paidAt: new Date(), + }); + } else { + await this.applyProviderResult(intent.id, { + status: ProviderPaymentStatus.FAILED, + failureCode: confirmResult.failureCode, + failureMessage: confirmResult.failureMessage, + }); + } + + const updated = await this.intentsRepository.findById(intent.id); + if (!updated) throw new NotFoundException("PaymentIntent not found"); + return this.toSnapshot(updated); + } + /** * Decide whether an existing active intent can be returned as-is. An expired * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) @@ -192,7 +263,7 @@ export class IntentsService { if (!refreshable || !stale || !provider) return intent; try { - const status = await provider.queryStatus(intent.merchantOrderId); + const status = await this.queryProviderStatus(intent); await this.applyProviderResult( intent.id, this.fromProviderStatus(status), @@ -207,6 +278,26 @@ export class IntentsService { } } + private async queryProviderStatus( + intent: PaymentIntent, + ): Promise { + const provider = this.providers.get(intent.provider); + if (!provider) { + throw new Error(`Unknown provider: ${intent.provider}`); + } + + if (intent.provider === ProviderMethod.CAC_BANK) { + const reference = (intent.rawInitiation as { reference?: string }) + ?.reference; + return this.cacBankProvider.queryStatus( + intent.merchantOrderId, + reference, + ); + } + + return provider.queryStatus(intent.merchantOrderId); + } + fromProviderStatus(status: ProviderStatus): ProviderResultInput { return { status: status.status, diff --git a/apps/edr-payment-api/src/modules/providers/providers.module.ts b/apps/edr-payment-api/src/modules/providers/providers.module.ts index af2983ee4..88af1a721 100644 --- a/apps/edr-payment-api/src/modules/providers/providers.module.ts +++ b/apps/edr-payment-api/src/modules/providers/providers.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { CardProvider, + CacBankProvider, CbeBirrProvider, DMoneyProvider, EBirrProvider, @@ -23,6 +24,7 @@ const providerClasses = [ CardProvider, WaafiProvider, DMoneyProvider, + CacBankProvider, ]; /** diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts index 5216798f2..b74c9b899 100644 --- a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -7,7 +7,8 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { SchedulerRegistry } from "@nestjs/schedule"; -import { ProviderPaymentStatus } from "@edr/types"; +import { ProviderPaymentStatus, ProviderMethod } from "@edr/types"; +import { CacBankProvider } from "@edr/payment-providers"; import { PAYMENT_PROVIDER_MAP, PaymentProviderMap, @@ -38,6 +39,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy { private readonly schedulerRegistry: SchedulerRegistry, @Inject(PAYMENT_PROVIDER_MAP) private readonly providers: PaymentProviderMap, + private readonly cacBankProvider: CacBankProvider, ) { this.intervalMs = config.get("app.reconciliation.sweepIntervalMs") ?? 60_000; @@ -82,7 +84,13 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy { try { const provider = this.providers.get(intent.provider); if (provider) { - const status = await provider.queryStatus(intent.merchantOrderId); + const status = + intent.provider === ProviderMethod.CAC_BANK + ? await this.cacBankProvider.queryStatus( + intent.merchantOrderId, + (intent.rawInitiation as { reference?: string })?.reference, + ) + : await provider.queryStatus(intent.merchantOrderId); const result = this.intentsService.fromProviderStatus(status); if (result.status !== intent.status || result.providerTxnId) { await this.intentsService.applyProviderResult(intent.id, result); diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts index 51937dee4..163f01107 100644 --- a/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts @@ -13,22 +13,36 @@ export class DMoneyWebhookService { const signatureValid = this.provider.verifyWebhookSignature( payload as unknown as Record, ); - const mapped = this.provider.mapWebhookStatus(payload.status); + const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); + const providerTxnId = payload.transId ?? payload.payment_order_id; await this.processor.process({ provider: this.provider.method, - externalEventId: `${payload.orderId}_${payload.status}`, - merchantOrderId: payload.merchantOrderId, - providerTxnId: payload.transactionId, + externalEventId: `${payload.payment_order_id}_${payload.trade_status}`, + merchantOrderId: payload.merch_order_id, + providerTxnId, signatureValid, - rawStatus: payload.status, + rawStatus: payload.trade_status, payload: payload as unknown as Record, result: { status: mapped, - providerTxnId: payload.transactionId, - paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, - failureCode: payload.status, + providerTxnId, + paidAt: this.parseTransEndTime(payload.trans_end_time), + failureCode: payload.trade_status, }, }); } + + /** D-Money sends trans_end_time either as epoch ms/s or "YYYY-MM-DD HH:mm:ss". */ + private parseTransEndTime(raw: string | undefined): Date | undefined { + if (!raw) return undefined; + if (/^\d+$/.test(raw)) { + const n = parseInt(raw, 10); + if (Number.isNaN(n)) return undefined; + // 13-digit value is milliseconds, otherwise seconds. + return new Date(raw.length >= 13 ? n : n * 1000); + } + const parsed = new Date(raw.replace(" ", "T")); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; + } } diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index 8f1d1bd9e..d367971d0 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -136,7 +136,7 @@ export class WebhooksController { } catch (err) { this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`); } - return { success: true }; + return { code: "0", msg: "Success", result: "SUCCESS" }; } private message(err: unknown): string { diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index 7b77b5684..523d9fb03 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -20,6 +20,7 @@ export { EBirrProvider } from './providers/ebirr/ebirr.provider'; export { CardProvider } from './providers/card/card.provider'; export { WaafiProvider } from './providers/waafi/waafi.provider'; export { DMoneyProvider } from './providers/dmoney/dmoney.provider'; +export { CacBankProvider } from './providers/cac-bank/cac-bank.provider'; // Telebirr crypto + types (exported for apps that build/verify signatures directly) export { @@ -40,6 +41,16 @@ export type { TelebirrTradeStatus, } from './providers/telebirr/telebirr.types'; +// D-Money request/response types (exported for apps that build/inspect requests directly) +export type { + DMoneyFabricTokenResponse, + DMoneyPreOrderBizContent, + DMoneyPreOrderRequest, + DMoneyPreOrderResponse, + DMoneyQueryOrderResponse, + DMoneyOrderStatus, +} from './providers/dmoney/dmoney.types'; + // Waafi HPP request/response types (exported for apps that build/inspect requests directly) export type { WaafiState, @@ -49,6 +60,19 @@ export type { WaafiGetTranInfoResponse, } from './providers/waafi/waafi.types'; +// CAC Bank request/response types +export type { + CacSigninRequest, + CacSigninResponse, + CacPaymentInitiateRequest, + CacPaymentInitiateResponse, + CacPaymentConfirmRequest, + CacPaymentConfirmResponse, + CacGetPaymentByReferenceRequest, + CacPaymentByReferenceResponse, + CacConfirmResult, +} from './providers/cac-bank/cac-bank.types'; + // Webhook payload types export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts new file mode 100644 index 000000000..ee20b0aa5 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.auth.ts @@ -0,0 +1,88 @@ +import { Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import type { CacSigninRequest, CacSigninResponse } from "./cac-bank.types"; + +interface TokenCache { + accessToken: string; + expiresAt: number; +} + +export interface CacAuthConfig { + baseUrl: string; + username: string; + password: string; + tokenTtlMs: number; +} + +/** + * In-memory JWT cache for CAC Bank. Tokens are valid 24h per the API docs; + * we refresh proactively before expiry. + */ +export class CacBankAuth { + private readonly logger = new Logger(CacBankAuth.name); + private cache: TokenCache | null = null; + private signinInFlight: Promise | null = null; + + constructor( + private readonly http: HttpService, + private readonly config: CacAuthConfig, + ) {} + + async getAccessToken(): Promise { + if (this.cache && Date.now() < this.cache.expiresAt) { + return this.cache.accessToken; + } + return this.signin(); + } + + invalidate(): void { + this.cache = null; + } + + private async signin(): Promise { + if (this.signinInFlight) return this.signinInFlight; + + this.signinInFlight = this.doSignin(); + try { + return await this.signinInFlight; + } finally { + this.signinInFlight = null; + } + } + + private async doSignin(): Promise { + const body: CacSigninRequest = { + username: this.config.username, + password: this.config.password, + }; + const url = `${this.config.baseUrl}/paymentapi/auth/signin`; + + try { + const res = await firstValueFrom( + this.http.post(url, body, { + headers: { "Content-Type": "application/json" }, + timeout: 10_000, + }), + ); + const token = res.data.accessToken; + if (!token) { + throw new Error("CAC signin returned no accessToken"); + } + this.cache = { + accessToken: token, + expiresAt: Date.now() + this.config.tokenTtlMs, + }; + this.logger.debug("CAC signin succeeded; token cached"); + return token; + } catch (err) { + if (err instanceof AxiosError) { + this.logger.error( + `CAC signin failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + ); + } + throw err; + } + } +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts new file mode 100644 index 000000000..8d2afa548 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -0,0 +1,271 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { + PaymentProvider, + ProviderInitiationInput, + ProviderInitiationResult, + ProviderStatus, + ProviderPaymentStatus, + ProviderMethod, +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import { CacBankAuth } from "./cac-bank.auth"; +import type { + CacConfirmResult, + CacGetPaymentByReferenceRequest, + CacPaymentByReferenceResponse, + CacPaymentConfirmRequest, + CacPaymentConfirmResponse, + CacPaymentInitiateRequest, + CacPaymentInitiateResponse, +} from "./cac-bank.types"; + +@Injectable() +export class CacBankProvider implements PaymentProvider { + readonly method = ProviderMethod.CAC_BANK; + private readonly logger = new Logger(CacBankProvider.name); + private auth: CacBankAuth | null = null; + + constructor( + private readonly config: ConfigService, + private readonly http: HttpService, + ) {} + + async initiate( + input: ProviderInitiationInput, + ): Promise { + if (!input.payerAccount) { + throw new Error("CAC Bank requires payerAccount (customer mobile number)"); + } + + const requestBody: CacPaymentInitiateRequest = { + app_key: this.appKey, + api_key: this.apiKey, + customer_mobile: input.payerAccount, + currency: input.currency || this.defaultCurrency, + desc: `EDR ${input.orderRef}`.slice(0, 500), + vender_ref: input.merchantOrderId, + amount: this.toMajorAmount(input.amountMinor, input.currency), + company_services_id: this.companyServicesId, + }; + + const response = await this.postJson( + "/paymentapi/PaymentInitiateRequest", + requestBody, + ); + + if (response.paymentRequestId == null) { + throw new Error( + `CAC Bank initiate failed: ${JSON.stringify(response)}`, + ); + } + + const providerOrderId = String(response.paymentRequestId); + const expiresAt = new Date(Date.now() + this.otpExpiryMs); + + return { + providerOrderId, + clientAction: { + type: "COLLECT_OTP", + providerOrderId, + message: "Enter the OTP sent to your phone", + }, + expiresAt, + rawInitiation: { + request: this.sanitizeKeys(requestBody), + response, + venderRef: input.merchantOrderId, + }, + }; + } + + async confirmPayment( + paymentRequestId: string, + otp: string, + ): Promise { + const requestBody: CacPaymentConfirmRequest = { + app_key: this.appKey, + api_key: this.apiKey, + payment_request_id: Number(paymentRequestId), + otp, + }; + + try { + const response = await this.postJson( + "/paymentapi/PaymentConfirmationRequest", + requestBody, + ); + + if (response.confirmReference == null && !response.reference) { + return { + status: "FAILED", + failureCode: "CONFIRM_REJECTED", + failureMessage: response.description ?? "Confirmation rejected", + rawResponse: response as unknown as Record, + }; + } + + return { + status: "SUCCEEDED", + providerTxnId: String( + response.confirmReference ?? response.reference, + ), + reference: response.reference, + rawResponse: response as unknown as Record, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + status: "FAILED", + failureCode: "CONFIRM_ERROR", + failureMessage: message, + rawResponse: {}, + }; + } + } + + async queryStatus( + merchantOrderId: string, + reference?: string, + ): Promise { + const lookupRef = reference ?? merchantOrderId; + const requestBody: CacGetPaymentByReferenceRequest = { + app_key: this.appKey, + api_key: this.apiKey, + reference: lookupRef, + }; + + try { + const response = await this.postJson( + "/paymentapi/GetPaymentByReferenceRequest", + requestBody, + ); + + if (response.transactionNo != null && response.transactionDate) { + return { + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: String(response.transactionNo), + rawResponse: response as unknown as Record, + }; + } + + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: response as unknown as Record, + }; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) { + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: { notFound: true, reference: lookupRef }, + }; + } + throw err; + } + } + + private async postJson(path: string, body: unknown): Promise { + const token = await this.getAuth().getAccessToken(); + const url = `${this.baseUrl}${path}`; + const config: AxiosRequestConfig = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + timeout: 10_000, + }; + + const started = Date.now(); + try { + const res = await firstValueFrom(this.http.post(url, body, config)); + this.logger.debug( + `CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`, + ); + return res.data; + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 401) { + this.getAuth().invalidate(); + const retryToken = await this.getAuth().getAccessToken(); + const retryConfig: AxiosRequestConfig = { + ...config, + headers: { + ...config.headers, + Authorization: `Bearer ${retryToken}`, + }, + }; + const res = await firstValueFrom( + this.http.post(url, body, retryConfig), + ); + return res.data; + } + + if (err instanceof AxiosError) { + this.logger.error( + `CAC Bank POST ${path} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + ); + } else { + this.logger.error( + `CAC Bank POST ${path} threw: ${err instanceof Error ? err.message : err}`, + ); + } + throw err; + } + } + + private getAuth(): CacBankAuth { + if (!this.auth) { + this.auth = new CacBankAuth(this.http, { + baseUrl: this.baseUrl, + username: this.username, + password: this.password, + tokenTtlMs: this.tokenTtlMs, + }); + } + return this.auth; + } + + /** DJF has no fractional units — amountMinor is the major amount. */ + private toMajorAmount(amountMinor: number, currency: string): number { + if (currency.toUpperCase() === "DJF") { + return amountMinor; + } + return amountMinor / 100; + } + + private sanitizeKeys( + body: CacPaymentInitiateRequest | CacPaymentConfirmRequest, + ): Record { + const { app_key: _appKey, api_key: _apiKey, ...rest } = body; + return rest; + } + + private get baseUrl(): string { + return (this.config.get("cac.baseUrl") ?? "").replace(/\/$/, ""); + } + private get username(): string { + return this.config.get("cac.username") ?? ""; + } + private get password(): string { + return this.config.get("cac.password") ?? ""; + } + private get appKey(): string { + return this.config.get("cac.appKey") ?? ""; + } + private get apiKey(): string { + return this.config.get("cac.apiKey") ?? ""; + } + private get companyServicesId(): number { + return this.config.get("cac.companyServicesId") ?? 0; + } + private get defaultCurrency(): string { + return this.config.get("cac.currency") ?? "DJF"; + } + private get tokenTtlMs(): number { + return this.config.get("cac.tokenTtlMs") ?? 23 * 60 * 60 * 1000; + } + private get otpExpiryMs(): number { + return this.config.get("cac.otpExpiryMs") ?? 10 * 60 * 1000; + } +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts new file mode 100644 index 000000000..6e2b7ba5b --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts @@ -0,0 +1,65 @@ +export interface CacSigninRequest { + username: string; + password: string; +} + +export interface CacSigninResponse { + id: number; + username: string; + email: string; + accessToken: string; + tokenType: string; +} + +export interface CacPaymentInitiateRequest { + app_key: string; + api_key: string; + customer_mobile: string; + currency: string; + desc?: string; + vender_ref?: string; + amount: number; + company_services_id: number; +} + +export interface CacPaymentInitiateResponse { + description: string; + paymentRequestId: number; +} + +export interface CacPaymentConfirmRequest { + app_key: string; + api_key: string; + payment_request_id: number; + otp: string; +} + +export interface CacPaymentConfirmResponse { + description: string; + confirmReference: number; + reference: string; +} + +export interface CacGetPaymentByReferenceRequest { + app_key: string; + api_key: string; + reference: string; +} + +export interface CacPaymentByReferenceResponse { + description: string; + customerName?: string; + reference: string; + amount: number; + transactionDate: string; + transactionNo: number; +} + +export interface CacConfirmResult { + status: "SUCCEEDED" | "FAILED"; + providerTxnId?: string; + reference?: string; + failureCode?: string; + failureMessage?: string; + rawResponse: Record; +} diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 35ca54a3b..cadb5e482 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -11,97 +11,83 @@ import { } from "@edr/types"; import { AxiosError, AxiosRequestConfig } from "axios"; import { firstValueFrom } from "rxjs"; -import * as crypto from "node:crypto"; +import * as https from "node:https"; +import { + createNonceStr, + createTimestamp, + signRequestObject, + verifyRequestObject, +} from "../telebirr/telebirr.crypto"; +import { + DMoneyFabricTokenResponse, + DMoneyPreOrderRequest, + DMoneyPreOrderResponse, + DMoneyQueryOrderResponse, +} from "./dmoney.types"; -interface DMoneyAuthResponse { - token: string; -} - -interface DMoneyInitiateRequest { - merchantId: string; - merchantOrderId: string; - amount: string; - currency: string; - description: string; - returnUrl: string; - notifyUrl: string; - payerPhone?: string; - timestamp: string; - signature: string; -} - -interface DMoneyInitiateResponse { - success: boolean; - orderId: string; - checkoutUrl?: string; - expiresIn: number; -} - -interface DMoneyQueryResponse { - success: boolean; - orderId: string; - status: string; - transactionId?: string; - amount?: string; - currency?: string; - paidAt?: string; - payerPhone?: string; -} +const DMONEY_HTTP_TIMEOUT_MS = 10_000; +/** + * D-Money (Djibouti) shares the same payment-gateway platform as Telebirr: fabric-token auth, + * payment.preorder / payment.queryorder, SHA256withRSA (PSS) signing, and a signed paygate + * web-checkout redirect. This provider mirrors TelebirrProvider, differing only in endpoint + * paths, the already-"Bearer"-prefixed token, the queryOrder status field (order_status), and + * the web-only client action (no LAUNCH_APP). Crypto is reused from telebirr.crypto (RSA-PSS). + */ @Injectable() export class DMoneyProvider implements PaymentProvider { readonly method = ProviderMethod.DMONEY; private readonly logger = new Logger(DMoneyProvider.name); + private readonly httpsAgent: https.Agent; constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) {} + ) { + const insecure = this.config.get("dmoney.insecureTls"); + if (insecure) { + this.logger.warn( + "DMONEY_INSECURE_TLS=true — TLS verification disabled for D-Money calls. DEV ONLY.", + ); + } + this.httpsAgent = new https.Agent({ + rejectUnauthorized: !insecure, + secureProtocol: "TLSv1_2_method", + }); + } async initiate( input: ProviderInitiationInput, ): Promise { - const token = await this.getFabricToken(); - const amount = (input.amountMinor / 100).toFixed(2); - const timestamp = new Date().toISOString(); - - const requestBody: DMoneyInitiateRequest = { - merchantId: this.merchantId, - merchantOrderId: input.merchantOrderId, - amount, - currency: input.currency, - description: `EDR ${input.orderRef}`, - returnUrl: this.returnUrl, - notifyUrl: this.notifyUrl, - timestamp, - signature: this.signRequest({ - merchantId: this.merchantId, - merchantOrderId: input.merchantOrderId, - amount, - timestamp, - }), - }; - - const response = await this.postJson( - `${this.baseUrl}/api/v1/payment/initiate`, + const fabricToken = await this.applyFabricToken(); + const requestBody = this.buildPreOrderRequest(input); + const response = await this.postJson( + `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`, requestBody, - token, + { + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, + Authorization: fabricToken, + }, ); - if (!response.success || !response.orderId) { - throw new Error(`DMoney initiate failed: ${JSON.stringify(response)}`); + const prepayId = response.biz_content?.prepay_id; + if (response.result !== "SUCCESS" || !prepayId) { + throw new Error( + `D-Money preOrder failed: ${JSON.stringify(response)}`, + ); } - const expiresAt = new Date(Date.now() + response.expiresIn * 1000); + const expiresAt = this.computeExpiresAt( + requestBody.biz_content.timeout_express, + ); return { - providerOrderId: response.orderId, - clientAction: response.checkoutUrl - ? { type: "REDIRECT", url: response.checkoutUrl } - : { - type: "REDIRECT", - url: `${this.baseUrl}/checkout/${response.orderId}`, - }, + providerOrderId: prepayId, + clientAction: { + type: "REDIRECT", + url: this.buildCheckoutUrl(prepayId), + }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -111,151 +97,239 @@ export class DMoneyProvider implements PaymentProvider { } async queryStatus(merchantOrderId: string): Promise { - const token = await this.getFabricToken(); - const timestamp = new Date().toISOString(); - const signature = this.signRequest({ - merchantId: this.merchantId, - merchantOrderId, - timestamp, - }); - - const response = await this.postJson( - `${this.baseUrl}/api/v1/payment/query`, + const fabricToken = await this.applyFabricToken(); + const requestBody = this.buildQueryOrderRequest(merchantOrderId); + const response = await this.postJson( + `${this.baseUrl}/apiaccess/payment/v1/merchant/queryOrder`, + requestBody, { - merchantId: this.merchantId, - merchantOrderId, - timestamp, - signature, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, + Authorization: fabricToken, }, - token, ); - const mapped = this.mapStatus(response.status); + const orderStatus = response.biz_content?.order_status; + const providerTxnId = response.biz_content?.payment_order_id; + const mapped = this.mapOrderStatus(orderStatus); return { status: mapped, - providerTxnId: response.transactionId, + providerTxnId, failureCode: - mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, - rawResponse: response as unknown as Record, + mapped === ProviderPaymentStatus.FAILED && orderStatus + ? orderStatus + : undefined, + rawResponse: response as Record, }; } - verifyWebhookSignature(payload: Record): boolean { - const { signature, ...data } = payload; - if (!signature || typeof signature !== "string") return false; - - const expectedSignature = this.signRequest(data); - return crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature), - ); - } - - mapWebhookStatus(status: string): ProviderPaymentStatus { - return this.mapStatus(status); - } - - private mapStatus(status: string): ProviderPaymentStatus { - switch (status?.toUpperCase()) { + /** queryOrder `order_status` → shared status. */ + mapOrderStatus(orderStatus: string | undefined): ProviderPaymentStatus { + switch (orderStatus) { + case "PAY_SUCCESS": + case "Completed": case "SUCCESS": - case "COMPLETED": return ProviderPaymentStatus.SUCCEEDED; - case "FAILED": - case "REJECTED": - case "EXPIRED": - case "CANCELLED": + case "PAY_FAILED": + case "Failure": + case "ORDER_CLOSED": + case "Expired": return ProviderPaymentStatus.FAILED; - case "PENDING": + case "WAIT_PAY": return ProviderPaymentStatus.REQUIRES_ACTION; - case "PROCESSING": + case "PAYING": + case "Paying": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - private async getFabricToken(): Promise { - const response = await this.postJson( + /** Notification `trade_status` → shared status. */ + mapWebhookTradeStatus( + tradeStatus: string | undefined, + ): ProviderPaymentStatus { + switch (tradeStatus) { + case "Completed": + return ProviderPaymentStatus.SUCCEEDED; + case "Failure": + case "Expired": + return ProviderPaymentStatus.FAILED; + case "Paying": + return ProviderPaymentStatus.PROCESSING; + default: + return ProviderPaymentStatus.PROCESSING; + } + } + + verifyWebhookSignature(payload: Record): boolean { + if (!this.publicKey) { + this.logger.error( + "DMONEY_PUBLIC_KEY not configured; rejecting all webhooks", + ); + return false; + } + return verifyRequestObject(payload, this.publicKey); + } + + private async applyFabricToken(): Promise { + const response = await this.postJson( `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/token`, + { appSecret: this.appSecret }, { - appSecret: this.appSecret, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, }, ); - - if (!response.token) { + if (!response?.token) { throw new Error( - `DMoney authentication failed: ${JSON.stringify(response)}`, + `D-Money token request failed: ${JSON.stringify(response)}`, ); } - + // D-Money returns the token already prefixed with "Bearer " — use it verbatim. return response.token; } - private signRequest(data: Record): string { - const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&"); + private buildPreOrderRequest( + input: ProviderInitiationInput, + ): DMoneyPreOrderRequest { + const totalAmount = (input.amountMinor).toFixed(2); + const redirectUrl = input.redirectUrl ?? this.returnUrl; + const req = { + timestamp: createTimestamp(), + nonce_str: createNonceStr(), + method: "payment.preorder" as const, + version: "1.0" as const, + biz_content: { + notify_url: this.notifyUrl, + appid: this.merchantAppId, + merch_code: this.merchantCode, + merch_order_id: input.merchantOrderId, + trade_type: "Checkout" as const, + title: `EDR ${input.orderRef}`, + total_amount: totalAmount, + trans_currency: 1 == 1 ? "DJF": this.currency, + timeout_express: this.timeoutExpress, + ...(redirectUrl ? { redirect_url: redirectUrl } : {}), + }, + }; - return crypto - .createHmac("sha256", this.secretKey) - .update(signString) - .digest("hex"); + console.log("\n\n\n") + console.log(req) + console.log("\n\n\n") + const sign = signRequestObject( + req as unknown as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; + } + + private buildQueryOrderRequest( + merchantOrderId: string, + ): Record { + const req = { + timestamp: createTimestamp(), + nonce_str: createNonceStr(), + method: "payment.queryorder", + version: "1.0", + biz_content: { + appid: this.merchantAppId, + merch_code: this.merchantCode, + merch_order_id: merchantOrderId, + }, + }; + const sign = signRequestObject( + req as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; + } + + private buildCheckoutUrl(prepayId: string): string { + // Only these five fields are signed for the paygate URL. + const map: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + }; + const sign = signRequestObject(map, this.privateKey); + const query = [ + `appid=${map.appid}`, + `merch_code=${map.merch_code}`, + `nonce_str=${map.nonce_str}`, + `prepay_id=${map.prepay_id}`, + `timestamp=${map.timestamp}`, + `sign=${sign}`, + "sign_type=SHA256WithRSA", + "version=1.0", + "trade_type=Checkout", + `language=${this.language}`, + ].join("&"); + return `${this.webBaseUrl}/payment/web/paygate?${query}`; + } + + private computeExpiresAt(timeoutExpress: string): Date { + const match = /^(\d+)m$/.exec(timeoutExpress); + const minutes = match ? parseInt(match[1], 10) : 120; + return new Date(Date.now() + minutes * 60_000); } private async postJson( url: string, body: unknown, - token?: string, + headers: Record, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - const config: AxiosRequestConfig = { headers, - timeout: 10_000, + timeout: DMONEY_HTTP_TIMEOUT_MS, + httpsAgent: this.httpsAgent, }; - const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); this.logger.debug( - `DMoney POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + `D-Money POST ${url} status=${res.status} latency=${Date.now() - started}ms`, ); return res.data; } catch (err) { if (err instanceof AxiosError) { this.logger.error( - `DMoney POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, + `D-Money POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`, ); } else { this.logger.error( - `DMoney POST ${url} threw: ${err instanceof Error ? err.message : err}`, + `D-Money POST ${url} threw: ${err instanceof Error ? err.message : err}`, ); } throw err; } } - private sanitize(body: DMoneyInitiateRequest): Record { - const { signature: _signature, ...rest } = body; + private sanitize(body: DMoneyPreOrderRequest): Record { + const { sign: _sign, ...rest } = body; return rest; } private get baseUrl(): string { return this.config.get("dmoney.baseUrl") ?? ""; } - private get merchantId(): string { - return this.config.get("dmoney.merchantId") ?? ""; + private get webBaseUrl(): string { + return this.config.get("dmoney.webBaseUrl") ?? ""; + } + private get fabricAppId(): string { + return this.config.get("dmoney.fabricAppId") ?? ""; } private get appSecret(): string { return this.config.get("dmoney.appSecret") ?? ""; } - private get secretKey(): string { - return this.config.get("dmoney.secretKey") ?? ""; + private get merchantAppId(): string { + return this.config.get("dmoney.merchantAppId") ?? ""; + } + private get merchantCode(): string { + return this.config.get("dmoney.merchantCode") ?? ""; } private get notifyUrl(): string { return this.config.get("dmoney.notifyUrl") ?? ""; @@ -263,4 +337,19 @@ export class DMoneyProvider implements PaymentProvider { private get returnUrl(): string { return this.config.get("dmoney.returnUrl") ?? ""; } + private get timeoutExpress(): string { + return this.config.get("dmoney.timeoutExpress") ?? "120m"; + } + private get language(): string { + return this.config.get("dmoney.language") ?? "en"; + } + private get currency(): string { + return this.config.get("dmoney.currency") ?? "FDJ"; + } + private get privateKey(): string { + return this.config.get("dmoney.privateKey") ?? ""; + } + private get publicKey(): string { + return this.config.get("dmoney.publicKey") ?? ""; + } } diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.types.ts b/packages/payment-providers/src/providers/dmoney/dmoney.types.ts new file mode 100644 index 000000000..72a5f2669 --- /dev/null +++ b/packages/payment-providers/src/providers/dmoney/dmoney.types.ts @@ -0,0 +1,76 @@ +export interface DMoneyFabricTokenResponse { + /** Returned already prefixed with "Bearer " — set Authorization to this value verbatim. */ + token: string; + effectiveDate?: string; + expirationDate?: string; +} + +export interface DMoneyPreOrderBizContent { + notify_url: string; + appid: string; + merch_code: string; + merch_order_id: string; + trade_type: 'Checkout'; + title: string; + total_amount: string; + trans_currency: string; + timeout_express: string; + business_type?: string; + redirect_url?: string; + callback_info?: string; +} + +export interface DMoneyPreOrderRequest { + timestamp: string; + nonce_str: string; + method: 'payment.preorder'; + version: '1.0'; + biz_content: DMoneyPreOrderBizContent; + sign: string; + sign_type: 'SHA256WithRSA'; +} + +export interface DMoneyPreOrderResponse { + result?: 'SUCCESS' | 'FAIL'; + code?: string; + msg?: string; + nonce_str?: string; + sign?: string; + sign_type?: string; + biz_content?: { + merch_order_id?: string; + prepay_id?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type DMoneyOrderStatus = + | 'PAY_SUCCESS' + | 'PAY_FAILED' + | 'WAIT_PAY' + | 'ORDER_CLOSED' + | 'PAYING' + | 'Completed' + | 'Failure' + | 'Expired' + | 'Paying'; + +export interface DMoneyQueryOrderResponse { + result?: 'SUCCESS' | 'FAIL'; + code?: string; + msg?: string; + nonce_str?: string; + sign?: string; + sign_type?: string; + biz_content?: { + merch_order_id?: string; + order_status?: DMoneyOrderStatus | string; + payment_order_id?: string; + trans_time?: string; + trans_currency?: string; + total_amount?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts b/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts index 12ea9624a..d8c2e6763 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts @@ -17,6 +17,7 @@ export function buildCanonicalString(requestObject: Record): st for (const key of Object.keys(requestObject)) { if (EXCLUDE_FIELDS.has(key)) continue; + if (requestObject[key] === undefined) continue; fieldMap[key] = requestObject[key]; } @@ -24,7 +25,9 @@ export function buildCanonicalString(requestObject: Record): st if (biz && typeof biz === 'object') { for (const key of Object.keys(biz as Record)) { if (EXCLUDE_FIELDS.has(key)) continue; - fieldMap[key] = (biz as Record)[key]; + const value = (biz as Record)[key]; + if (value === undefined) continue; + fieldMap[key] = value; } } diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index dd2a77bec..0f0210414 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -101,17 +101,20 @@ export class TelebirrProvider implements PaymentProvider { }, ); - const tradeStatus = response.biz_content?.trade_status; + this.logger.log(response); + + // const tradeStatus = response.biz_content?.trade_status; + const orderStatus = response.biz_content?.order_status; const providerTxnId = response.biz_content?.trans_id ?? response.biz_content?.payment_order_id; - const mapped = this.mapTradeStatus(tradeStatus); + const mapped = this.mapTradeStatus(orderStatus); return { status: mapped, providerTxnId, failureCode: - mapped === ProviderPaymentStatus.FAILED && tradeStatus - ? tradeStatus + mapped === ProviderPaymentStatus.FAILED && orderStatus + ? orderStatus : undefined, rawResponse: response as Record, }; @@ -195,7 +198,7 @@ export class TelebirrProvider implements PaymentProvider { private buildCreateOrderRequest( input: ProviderInitiationInput, ): CreateOrderRequest { - const totalAmount = String(input.amountMinor / 100); + const totalAmount = String(input.amountMinor); const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), @@ -211,7 +214,7 @@ export class TelebirrProvider implements PaymentProvider { total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - redirect_url: input.redirectUrl, + ...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}), }, }; const sign = signRequestObject( diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index 276ff2de2..8551f6086 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -221,7 +221,7 @@ export class WaafiProvider implements PaymentProvider { /** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */ private toAmount(amountMinor: number): number { - return Math.trunc(amountMinor) / 100; + return Math.trunc(amountMinor); } private timestamp(): string { diff --git a/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts b/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts index 3a8362066..7b0ed9147 100644 --- a/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/dmoney-webhook.types.ts @@ -1,13 +1,18 @@ export interface DMoneyWebhookPayload { - merchantId: string; - merchantOrderId: string; - orderId: string; - status: string; - transactionId?: string; - amount?: string; - currency?: string; - paidAt?: string; - payerPhone?: string; - signature: string; + appid: string; + merch_code: string; + merch_order_id: string; + payment_order_id: string; + notify_time?: string; + trans_end_time?: string; + total_amount?: string; + trans_currency?: string; + /** Paying | Expired | Completed | Failure */ + trade_status: string; + transId?: string; + callback_info?: string; + notify_url?: string; + sign: string; + sign_type?: string; [key: string]: unknown; } diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 02930d151..6439395f4 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -23,6 +23,7 @@ export enum ProviderMethod { WAAFI = "WAAFI", CARD = "CARD", DMONEY = "DMONEY", + CAC_BANK = "CAC_BANK", } export type PaymentPlatform = "web" | "mobile"; @@ -34,6 +35,11 @@ export type ClientAction = appId: string; receiveCode?: string; shortCode: string; + } + | { + type: "COLLECT_OTP"; + providerOrderId: string; + message?: string; }; export interface ProviderInitiationInput { @@ -125,6 +131,11 @@ export interface InitiatePaymentRequest { idempotencyKey?: string; } +/** Body of `POST /payments/intents/:id/confirm` (OTP-based providers such as CAC Bank). */ +export interface ConfirmPaymentRequest { + otp: string; +} + /** Response of `POST /payments/initiate` and shape of intent lookups. */ export interface PaymentIntentSnapshot { intentId: string;