mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #167 from Tria-plc/freight_feature/payments
Freight feature/payments
This commit is contained in:
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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<number> {
|
||||
const now = Date.now();
|
||||
@@ -21,41 +26,43 @@ export class CbeExchangeService {
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
const apiUrl = this.configService.get<string>('app.cbeExchange.apiUrl') ?? '';
|
||||
const fallbackRate = this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
|
||||
const cacheTtlMs = this.configService.get<number>('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<number>('app.cbeExchange.fallbackRate') ?? 130;
|
||||
const cacheTtlMs =
|
||||
this.configService.get<number>('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<string, unknown>)['currency'] === 'USD' ||
|
||||
(entry as Record<string, unknown>)['Currency'] === 'USD'
|
||||
),
|
||||
) as Record<string, unknown> | undefined;
|
||||
private getScrapeUrl(): string {
|
||||
const configured =
|
||||
this.configService.get<string>('app.cbeExchange.scrapeUrl') ??
|
||||
this.configService.get<string>('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<string, unknown>;
|
||||
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, '>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules v2",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
},
|
||||
@@ -106,31 +100,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Trains",
|
||||
href: "/dashboard/trains",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Wagon types",
|
||||
href: "/dashboard/wagon-types",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
// {
|
||||
// label: "Trains",
|
||||
// href: "/dashboard/trains",
|
||||
// icon: <Train />,
|
||||
// },
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
// href: "/dashboard/wagon-types",
|
||||
// icon: <Boxes />,
|
||||
// },
|
||||
{
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Containers",
|
||||
href: "/dashboard/containers",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Cargoes",
|
||||
href: "/dashboard/cargoes",
|
||||
icon: <Package />,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
// href: "/dashboard/containers",
|
||||
// icon: <Container />,
|
||||
// },
|
||||
// {
|
||||
// label: "Cargoes",
|
||||
// href: "/dashboard/cargoes",
|
||||
// icon: <Package />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -271,7 +265,10 @@ const App = () => {
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
/>
|
||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
|
||||
@@ -150,7 +150,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}
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && <span className="fsb-icon">{item.icon}</span>}
|
||||
<span className="fsb-item-label">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="fsb-chevron"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
className="fsb-chevron-btn"
|
||||
aria-label={isOpen ? "Collapse section" : "Expand section"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleExpanded(item.href!);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="fsb-chevron"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700} c="dark.5" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrainScheduleTrackPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
@@ -63,7 +86,7 @@ export default function TrainScheduleTrackPage() {
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Loader size="sm" color="green" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="md" px={{ base: "xs", sm: 0 }} py="md" maw={1080} mx="auto" w="100%">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
@@ -118,89 +143,111 @@ export default function TrainScheduleTrackPage() {
|
||||
Back to schedule
|
||||
</Button>
|
||||
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -50,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: "var(--mantine-color-green-7)" }}>
|
||||
<Navigation size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
{/* Header */}
|
||||
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={24} />
|
||||
</Box>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Track train
|
||||
<Title order={3} fw={800}>
|
||||
Train tracking
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge variant="white" c="green.8" radius="sm" style={{ fontWeight: 600 }}>
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge variant="white" c="green.8" radius="sm">
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={340}>
|
||||
<RouteCorridor onDark origin={track.origin} destination={track.destination} />
|
||||
<Box maw={360}>
|
||||
<RouteCorridor origin={track.origin} destination={track.destination} variant="compact" />
|
||||
</Box>
|
||||
<StatusPill status={track.status} />
|
||||
</Stack>
|
||||
</Group>
|
||||
<StatusPill status={track.status} />
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile onDark icon={Train} label="Progress" value={progressLabel} hint="stations reached" />
|
||||
<StatTile onDark icon={MapPin} label="Current" value={track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"} />
|
||||
<StatTile onDark icon={CalendarClock} label="Departed" value={formatDateTime(track.actualDepartureAt)} />
|
||||
<StatTile onDark icon={Flag} label="Arrived" value={formatDateTime(track.actualArrivalAt)} />
|
||||
</SimpleGrid>
|
||||
{/* Journey progress */}
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
|
||||
Journey progress
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c="green.8">
|
||||
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={clampedPct}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color="green"
|
||||
striped={track.status === "DISPATCHED"}
|
||||
animated={track.status === "DISPATCHED"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Meta strip */}
|
||||
<Group justify="space-between" wrap="wrap" gap="lg">
|
||||
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
|
||||
<MetaStat
|
||||
icon={<CalendarClock size={16} />}
|
||||
label="Departed"
|
||||
value={formatDateTime(track.actualDepartureAt)}
|
||||
/>
|
||||
<MetaStat
|
||||
icon={<Flag size={16} />}
|
||||
label="Arrived"
|
||||
value={formatDateTime(track.actualArrivalAt)}
|
||||
/>
|
||||
<MetaStat
|
||||
icon={<Train size={16} />}
|
||||
label="Stations"
|
||||
value={`${reached} of ${totalStations}`}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Corridor */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
|
||||
<Navigation size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={4} fw={700}>
|
||||
Route corridor
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: track.status === "ARRIVED"
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="green">
|
||||
<Navigation size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={800} size="sm">
|
||||
Route corridor
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: track.status === "ARRIVED"
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<RouteCorridorTrack
|
||||
@@ -216,47 +263,68 @@ export default function TrainScheduleTrackPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Timeline */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} fw={700}>
|
||||
Checkpoint log
|
||||
</Title>
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No checkpoints logged yet.
|
||||
{/* Checkpoint log */}
|
||||
<Paper radius="lg" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap" mb="md">
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={800} size="sm">
|
||||
Checkpoint log
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(cp.occurredAt)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<MapPin size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
No checkpoints yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={300}>
|
||||
Each station the train passes will be logged here with its timestamp.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(cp.occurredAt)}
|
||||
</Text>
|
||||
{cp.note ? (
|
||||
<Text size="xs" mt={2}>
|
||||
{cp.note}
|
||||
</Text>
|
||||
{cp.note ? <Text size="xs">{cp.note}</Text> : null}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Calendar, RefreshCw, TrainTrack } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@edr/ui-common';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useRoutes } from '@/hooks/useRoutes';
|
||||
import { trainSchedulingService } from '@/services/trainScheduling.service';
|
||||
|
||||
const inputClassName =
|
||||
'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950';
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
if (typeof message === 'string') return message;
|
||||
const violations = error.response?.data?.violations;
|
||||
if (Array.isArray(violations)) return violations.join(', ');
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const TrainsPage = () => {
|
||||
const qc = useQueryClient();
|
||||
const [routeId, setRouteId] = useState('');
|
||||
const [scheduleDate, setScheduleDate] = useState('');
|
||||
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [scheduleSearch, setScheduleSearch] = useState('');
|
||||
const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
|
||||
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
|
||||
});
|
||||
const schedulesQuery = useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
|
||||
queryFn: () => trainSchedulingService.listSchedules(),
|
||||
});
|
||||
const detailQuery = useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
|
||||
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
|
||||
enabled: Boolean(detailId),
|
||||
});
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((route) => route.isActive),
|
||||
[routesQuery.data],
|
||||
);
|
||||
const selectedRoute = activeRoutes.find((route) => route.id === routeId) ?? null;
|
||||
const selectedLocomotive = (locomotivesQuery.data ?? []).find(
|
||||
(locomotive) => locomotive.id === selectedLocomotiveId,
|
||||
);
|
||||
|
||||
const filteredSchedules = useMemo(() => {
|
||||
const query = scheduleSearch.trim().toLowerCase();
|
||||
|
||||
return (schedulesQuery.data ?? []).filter((schedule) => {
|
||||
const matchesStatus =
|
||||
scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter;
|
||||
|
||||
if (!matchesStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
schedule.id,
|
||||
schedule.routeName ?? '',
|
||||
schedule.origin ?? '',
|
||||
schedule.destination ?? '',
|
||||
schedule.locomotive?.code ?? '',
|
||||
schedule.status,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!routeId || !scheduleDate || !selectedLocomotiveId) {
|
||||
throw new Error('Please select route, departure date, and locomotive');
|
||||
}
|
||||
|
||||
return trainSchedulingService.createSchedule({
|
||||
routeId,
|
||||
scheduleDate: new Date(`${scheduleDate}T08:00:00.000Z`).toISOString(),
|
||||
locomotiveId: selectedLocomotiveId,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success('Train schedule created');
|
||||
setRouteId('');
|
||||
setScheduleDate('');
|
||||
setSelectedLocomotiveId('');
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
|
||||
setDetailId(data.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(parseError(error, 'Failed to create train schedule'));
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
|
||||
onSuccess: (data) => {
|
||||
toast.success('Train schedule cancelled');
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) });
|
||||
setDetailId(data.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(parseError(error, 'Failed to cancel train schedule'));
|
||||
},
|
||||
});
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const isBusy = createMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train schedules' }]} />
|
||||
|
||||
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-5 border-b border-border px-6 py-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
||||
<TrainTrack className="size-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Train Schedules</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create the train schedule first, reserve the locomotive, and assign bookings and wagons later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
void routesQuery.refetch();
|
||||
void schedulesQuery.refetch();
|
||||
void locomotivesQuery.refetch();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 p-6 xl:grid-cols-[1.1fr,1.4fr]">
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Calendar className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Schedule builder</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Route</label>
|
||||
<Select value={routeId} onValueChange={setRouteId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select active route" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeRoutes.map((route) => (
|
||||
<SelectItem key={route.id} value={route.id}>
|
||||
{route.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Departure date</label>
|
||||
<input
|
||||
className={inputClassName}
|
||||
type="date"
|
||||
value={scheduleDate}
|
||||
onChange={(event) => setScheduleDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Locomotive</label>
|
||||
<Select value={selectedLocomotiveId} onValueChange={setSelectedLocomotiveId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select available locomotive" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locomotivesQuery.data ?? []).map((locomotive) => (
|
||||
<SelectItem key={locomotive.id} value={locomotive.id}>
|
||||
{locomotive.code} - {locomotive.maxPullWeightTons}T / {locomotive.maxTrainLengthMeters}m
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Destination</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Locomotive capacity</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{selectedLocomotive
|
||||
? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Next step</p>
|
||||
<p className="mt-2 text-sm font-medium">Assign bookings, then allocate wagons</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Button className="w-full" disabled={isBusy} onClick={() => createMutation.mutate()}>
|
||||
Create schedule
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border bg-background/60 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Created schedules</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{filteredSchedules.length} schedules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-[1fr,220px]">
|
||||
<input
|
||||
className={inputClassName}
|
||||
placeholder="Search by schedule, route, locomotive, or status"
|
||||
value={scheduleSearch}
|
||||
onChange={(event) => setScheduleSearch(event.target.value)}
|
||||
/>
|
||||
<Select value={scheduleStatusFilter} onValueChange={setScheduleStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">DRAFT</SelectItem>
|
||||
<SelectItem value="SCHEDULED">SCHEDULED</SelectItem>
|
||||
<SelectItem value="DISPATCHED">DISPATCHED</SelectItem>
|
||||
<SelectItem value="ARRIVED">ARRIVED</SelectItem>
|
||||
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Schedule</th>
|
||||
<th className="px-3 py-3">Departure</th>
|
||||
<th className="px-3 py-3">Route</th>
|
||||
<th className="px-3 py-3">Locomotive</th>
|
||||
<th className="px-3 py-3">Bookings</th>
|
||||
<th className="px-3 py-3">Wagons</th>
|
||||
<th className="px-3 py-3">Weight</th>
|
||||
<th className="px-3 py-3">Length</th>
|
||||
<th className="px-3 py-3">Status</th>
|
||||
<th className="px-3 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{filteredSchedules.map((schedule) => (
|
||||
<tr key={schedule.id} className="hover:bg-muted/20">
|
||||
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
|
||||
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
|
||||
<td className="px-3 py-3">
|
||||
{schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`}
|
||||
</td>
|
||||
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
|
||||
<td className="px-3 py-3">{schedule.bookingsCount}</td>
|
||||
<td className="px-3 py-3">{schedule.wagonCount}</td>
|
||||
<td className="px-3 py-3">{schedule.totalWeightTons} T</td>
|
||||
<td className="px-3 py-3">{schedule.totalLengthMeters} m</td>
|
||||
<td className="px-3 py-3">{schedule.status}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailId(schedule.id)}>
|
||||
View
|
||||
</Button>
|
||||
{schedule.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => cancelMutation.mutate(schedule.id)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
|
||||
No train schedules matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={Boolean(detailId)} onOpenChange={(open) => (!open ? setDetailId(null) : null)}>
|
||||
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Train schedule detail</DialogTitle>
|
||||
<DialogDescription>
|
||||
Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{detail ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule</p>
|
||||
<p className="mt-2 break-all font-mono text-xs">{detail.id}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Departure</p>
|
||||
<p className="mt-2 text-sm font-medium">{formatDate(detail.scheduledDepartureDate)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
|
||||
<p className="mt-2 text-sm font-medium">{detail.route?.name ?? '-'}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin / destination</p>
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
|
||||
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Status</p>
|
||||
<p className="mt-2 text-sm font-medium">{detail.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border p-4">
|
||||
<h3 className="text-lg font-semibold">Locomotive</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{detail.trainSet?.locomotive
|
||||
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity / ${detail.trainSet.locomotive.maxTrainLengthMeters ?? 0}m)`
|
||||
: 'No locomotive attached'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border p-4">
|
||||
<h3 className="text-lg font-semibold">Wagons and allocations</h3>
|
||||
{(detail.trainSet?.wagons?.length ?? 0) === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No wagons allocated yet.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4">
|
||||
{(detail.trainSet?.wagons ?? []).map((wagon) => (
|
||||
<div key={wagon.id} className="rounded-xl border border-border p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border p-4">
|
||||
<h3 className="text-lg font-semibold">Bookings in schedule</h3>
|
||||
{detail.bookings.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">No bookings assigned yet.</p>
|
||||
) : (
|
||||
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
|
||||
<table className="min-w-full divide-y divide-border text-sm">
|
||||
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Reference</th>
|
||||
<th className="px-3 py-2">Customer</th>
|
||||
<th className="px-3 py-2">Weight</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{detail.bookings.map((booking) => (
|
||||
<tr key={booking.id}>
|
||||
<td className="px-3 py-2">{booking.reference ?? booking.id}</td>
|
||||
<td className="px-3 py-2">{booking.customer ?? '-'}</td>
|
||||
<td className="px-3 py-2">{booking.weightTons} T</td>
|
||||
<td className="px-3 py-2">{booking.status ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading schedule detail...</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainsPage;
|
||||
Submodule apps/edr-freight-web/backoffice/user-management deleted from 7c2e1d9bdf
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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<InitiateResponseDto> {
|
||||
// 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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
13
apps/edr-payment-api/src/config/cac.config.ts
Normal file
13
apps/edr-payment-api/src/config/cac.config.ts
Normal file
@@ -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),
|
||||
}));
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PaymentIntentSnapshot> {
|
||||
return this.intentsService.confirm(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PaymentIntentSnapshot> {
|
||||
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<ProviderStatus> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<number>("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);
|
||||
|
||||
@@ -13,22 +13,36 @@ export class DMoneyWebhookService {
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: CacAuthConfig,
|
||||
) {}
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (this.cache && Date.now() < this.cache.expiresAt) {
|
||||
return this.cache.accessToken;
|
||||
}
|
||||
return this.signin();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
private async signin(): Promise<string> {
|
||||
if (this.signinInFlight) return this.signinInFlight;
|
||||
|
||||
this.signinInFlight = this.doSignin();
|
||||
try {
|
||||
return await this.signinInFlight;
|
||||
} finally {
|
||||
this.signinInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async doSignin(): Promise<string> {
|
||||
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<CacSigninResponse>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderInitiationResult> {
|
||||
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<CacPaymentInitiateResponse>(
|
||||
"/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<CacConfirmResult> {
|
||||
const requestBody: CacPaymentConfirmRequest = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
payment_request_id: Number(paymentRequestId),
|
||||
otp,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.postJson<CacPaymentConfirmResponse>(
|
||||
"/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<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "SUCCEEDED",
|
||||
providerTxnId: String(
|
||||
response.confirmReference ?? response.reference,
|
||||
),
|
||||
reference: response.reference,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
} 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<ProviderStatus> {
|
||||
const lookupRef = reference ?? merchantOrderId;
|
||||
const requestBody: CacGetPaymentByReferenceRequest = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
reference: lookupRef,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.postJson<CacPaymentByReferenceResponse>(
|
||||
"/paymentapi/GetPaymentByReferenceRequest",
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.transactionNo != null && response.transactionDate) {
|
||||
return {
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: String(response.transactionNo),
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: { notFound: true, reference: lookupRef },
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
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<T>(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<T>(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<string, unknown> {
|
||||
const { app_key: _appKey, api_key: _apiKey, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return (this.config.get<string>("cac.baseUrl") ?? "").replace(/\/$/, "");
|
||||
}
|
||||
private get username(): string {
|
||||
return this.config.get<string>("cac.username") ?? "";
|
||||
}
|
||||
private get password(): string {
|
||||
return this.config.get<string>("cac.password") ?? "";
|
||||
}
|
||||
private get appKey(): string {
|
||||
return this.config.get<string>("cac.appKey") ?? "";
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>("cac.apiKey") ?? "";
|
||||
}
|
||||
private get companyServicesId(): number {
|
||||
return this.config.get<number>("cac.companyServicesId") ?? 0;
|
||||
}
|
||||
private get defaultCurrency(): string {
|
||||
return this.config.get<string>("cac.currency") ?? "DJF";
|
||||
}
|
||||
private get tokenTtlMs(): number {
|
||||
return this.config.get<number>("cac.tokenTtlMs") ?? 23 * 60 * 60 * 1000;
|
||||
}
|
||||
private get otpExpiryMs(): number {
|
||||
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
@@ -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<boolean>("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<ProviderInitiationResult> {
|
||||
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<DMoneyInitiateResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/initiate`,
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildPreOrderRequest(input);
|
||||
const response = await this.postJson<DMoneyPreOrderResponse>(
|
||||
`${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<ProviderStatus> {
|
||||
const token = await this.getFabricToken();
|
||||
const timestamp = new Date().toISOString();
|
||||
const signature = this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const response = await this.postJson<DMoneyQueryResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/query`,
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
||||
const response = await this.postJson<DMoneyQueryOrderResponse>(
|
||||
`${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<string, unknown>,
|
||||
mapped === ProviderPaymentStatus.FAILED && orderStatus
|
||||
? orderStatus
|
||||
: undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): 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<string> {
|
||||
const response = await this.postJson<DMoneyAuthResponse>(
|
||||
/** 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<string, unknown>): 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<string> {
|
||||
const response = await this.postJson<DMoneyFabricTokenResponse>(
|
||||
`${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, unknown>): 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<string, unknown>,
|
||||
this.privateKey,
|
||||
);
|
||||
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||
}
|
||||
|
||||
private buildQueryOrderRequest(
|
||||
merchantOrderId: string,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
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<string, string> = {
|
||||
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<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
token?: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"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<T>(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<string, unknown> {
|
||||
const { signature: _signature, ...rest } = body;
|
||||
private sanitize(body: DMoneyPreOrderRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>("dmoney.baseUrl") ?? "";
|
||||
}
|
||||
private get merchantId(): string {
|
||||
return this.config.get<string>("dmoney.merchantId") ?? "";
|
||||
private get webBaseUrl(): string {
|
||||
return this.config.get<string>("dmoney.webBaseUrl") ?? "";
|
||||
}
|
||||
private get fabricAppId(): string {
|
||||
return this.config.get<string>("dmoney.fabricAppId") ?? "";
|
||||
}
|
||||
private get appSecret(): string {
|
||||
return this.config.get<string>("dmoney.appSecret") ?? "";
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>("dmoney.secretKey") ?? "";
|
||||
private get merchantAppId(): string {
|
||||
return this.config.get<string>("dmoney.merchantAppId") ?? "";
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>("dmoney.merchantCode") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>("dmoney.notifyUrl") ?? "";
|
||||
@@ -263,4 +337,19 @@ export class DMoneyProvider implements PaymentProvider {
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>("dmoney.returnUrl") ?? "";
|
||||
}
|
||||
private get timeoutExpress(): string {
|
||||
return this.config.get<string>("dmoney.timeoutExpress") ?? "120m";
|
||||
}
|
||||
private get language(): string {
|
||||
return this.config.get<string>("dmoney.language") ?? "en";
|
||||
}
|
||||
private get currency(): string {
|
||||
return this.config.get<string>("dmoney.currency") ?? "FDJ";
|
||||
}
|
||||
private get privateKey(): string {
|
||||
return this.config.get<string>("dmoney.privateKey") ?? "";
|
||||
}
|
||||
private get publicKey(): string {
|
||||
return this.config.get<string>("dmoney.publicKey") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function buildCanonicalString(requestObject: Record<string, unknown>): 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<string, unknown>): st
|
||||
if (biz && typeof biz === 'object') {
|
||||
for (const key of Object.keys(biz as Record<string, unknown>)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = (biz as Record<string, unknown>)[key];
|
||||
const value = (biz as Record<string, unknown>)[key];
|
||||
if (value === undefined) continue;
|
||||
fieldMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user