feat: ( payment ) implement d-money payment

This commit is contained in:
Abubeker Yasin
2026-06-16 08:49:41 +03:00
parent 96ec2923c2
commit ca9a67837c
10 changed files with 825 additions and 621 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'DMONEY';

View File

@@ -96,9 +96,9 @@ model SeatClass {
fareRules FareRule[] fareRules FareRule[]
routeFareRules RouteFareRule[] routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[] segmentFares SegmentFareRule[]
@@unique([coachTypeId, name]) @@unique([coachTypeId, name])
@@index([coachTypeId]) @@index([coachTypeId])
@@schema("passenger") @@schema("passenger")
} }
@@ -130,6 +130,7 @@ enum PaymentMethodType {
CARD CARD
WALLET WALLET
WAAFI WAAFI
DMONEY
@@schema("passenger") @@schema("passenger")
} }
@@ -286,8 +287,8 @@ model Passenger {
notifications Notification[] notifications Notification[]
travelerProfiles TravelerProfile[] travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[] savedRoutes SavedRoute[]
@@index([userId])
@@index([userId])
@@schema("passenger") @@schema("passenger")
} }
@@ -319,8 +320,8 @@ model Station {
destinationSchedules TrainSchedule[] @relation("DestinationTrips") destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[] stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[] crowdSignals StationCrowdSignal[]
@@index([city, countryCode])
@@index([city, countryCode])
@@schema("passenger") @@schema("passenger")
} }
@@ -364,8 +365,8 @@ model TrainSchedule {
liveStatus TripLiveStatus? liveStatus TripLiveStatus?
menuItems MenuItem[] menuItems MenuItem[]
journeySegments JourneySegment[] journeySegments JourneySegment[]
@@index([departureAt, originStationId])
@@index([departureAt, originStationId])
@@schema("passenger") @@schema("passenger")
} }
@@ -380,8 +381,8 @@ model TripStopTime {
status StopStatus @default(UPCOMING) status StopStatus @default(UPCOMING)
schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
station Station @relation(fields: [stationId], references: [id]) station Station @relation(fields: [stationId], references: [id])
@@unique([scheduleId, sequence])
@@unique([scheduleId, sequence])
@@schema("passenger") @@schema("passenger")
} }
@@ -412,8 +413,8 @@ model Coach {
coachType CoachType @relation(fields: [coachTypeId], references: [id]) coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[] seats Seat[]
assignments CoachAssignment[] assignments CoachAssignment[]
@@index([coachTypeId])
@@index([coachTypeId])
@@schema("passenger") @@schema("passenger")
} }
@@ -426,9 +427,9 @@ model CoachAssignment {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
coach Coach @relation(fields: [coachId], references: [id]) coach Coach @relation(fields: [coachId], references: [id])
@@unique([scheduleId, positionNumber]) @@unique([scheduleId, positionNumber])
@@index([scheduleId]) @@index([scheduleId])
@@schema("passenger") @@schema("passenger")
} }
@@ -449,10 +450,10 @@ model Seat {
bookingSeats BookingSeat[] bookingSeats BookingSeat[]
blocks SeatBlock[] blocks SeatBlock[]
ticketSeats TicketSeat[] ticketSeats TicketSeat[]
@@unique([coachId, seatNumber]) @@unique([coachId, seatNumber])
@@unique([coachId, row, col]) @@unique([coachId, row, col])
@@index([coachId]) @@index([coachId])
@@schema("passenger") @@schema("passenger")
} }
@@ -465,8 +466,8 @@ model SeatHold {
createdBy String? createdBy String?
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([expiresAt])
@@index([expiresAt])
@@schema("passenger") @@schema("passenger")
} }
@@ -518,8 +519,8 @@ model Booking {
modifications BookingModification[] modifications BookingModification[]
cancellation BookingCancellation? cancellation BookingCancellation?
baggage BaggageBooking[] baggage BaggageBooking[]
@@index([passengerId, status])
@@index([passengerId, status])
@@schema("passenger") @@schema("passenger")
} }
@@ -589,9 +590,9 @@ model PaymentIntent {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id])
refunds PaymentRefund[] refunds PaymentRefund[]
@@index([providerOrderId]) @@index([providerOrderId])
@@index([providerTxnId]) @@index([providerTxnId])
@@schema("passenger") @@schema("passenger")
} }
@@ -607,9 +608,9 @@ model PaymentWebhookEvent {
receivedAt DateTime @default(now()) receivedAt DateTime @default(now())
processedAt DateTime? processedAt DateTime?
processingError String? processingError String?
@@unique([provider, externalEventId]) @@unique([provider, externalEventId])
@@index([merchantOrderId]) @@index([merchantOrderId])
@@schema("passenger") @@schema("passenger")
} }
@@ -652,9 +653,9 @@ model TicketSeat {
seatIndex Int @default(0) seatIndex Int @default(0)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
seat Seat @relation(fields: [seatId], references: [id]) seat Seat @relation(fields: [seatId], references: [id])
@@index([ticketId]) @@index([ticketId])
@@index([seatId]) @@index([seatId])
@@schema("passenger") @@schema("passenger")
} }
@@ -708,8 +709,8 @@ model WalletAccount {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id]) passenger Passenger @relation(fields: [passengerId], references: [id])
ledger WalletLedgerEntry[] ledger WalletLedgerEntry[]
@@index([passengerId])
@@index([passengerId])
@@schema("passenger") @@schema("passenger")
} }
@@ -962,8 +963,8 @@ model OtpCode {
expiresAt DateTime expiresAt DateTime
verified Boolean @default(false) verified Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([email, phone])
@@index([email, phone])
@@schema("passenger") @@schema("passenger")
} }
@@ -974,8 +975,8 @@ model PasswordResetToken {
expiresAt DateTime expiresAt DateTime
used Boolean @default(false) used Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId])
@@index([userId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1004,9 +1005,9 @@ model RouteStop {
distanceKm Int? distanceKm Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
@@unique([routeId, sequence]) @@unique([routeId, sequence])
@@index([routeId, stationId]) @@index([routeId, stationId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1025,8 +1026,8 @@ model RouteFareRule {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
seatClass SeatClass @relation(fields: [seatClassId], references: [id]) seatClass SeatClass @relation(fields: [seatClassId], references: [id])
@@index([routeId, seatClassId])
@@index([routeId, seatClassId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1044,9 +1045,9 @@ model SegmentFareRule {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
seatClass SeatClass @relation(fields: [seatClassId], references: [id]) seatClass SeatClass @relation(fields: [seatClassId], references: [id])
@@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality]) @@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality])
@@index([routeId, seatClassId]) @@index([routeId, seatClassId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1091,8 +1092,8 @@ model AgentShift {
reconciled Boolean @default(false) reconciled Boolean @default(false)
notes String? notes String?
agent Agent @relation(fields: [agentId], references: [id]) agent Agent @relation(fields: [agentId], references: [id])
@@index([agentId, openedAt])
@@index([agentId, openedAt])
@@schema("passenger") @@schema("passenger")
} }
@@ -1105,8 +1106,8 @@ model AgentCommission {
paidAt DateTime? paidAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
agent Agent @relation(fields: [agentId], references: [id]) agent Agent @relation(fields: [agentId], references: [id])
@@index([agentId, paidAt])
@@index([agentId, paidAt])
@@schema("passenger") @@schema("passenger")
} }
@@ -1121,8 +1122,8 @@ model BookingModification {
reason String? reason String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
@@index([bookingId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1150,9 +1151,9 @@ model GateValidationLog {
reason String? reason String?
validatedAt DateTime @default(now()) validatedAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id]) ticket Ticket @relation(fields: [ticketId], references: [id])
@@index([ticketId]) @@index([ticketId])
@@index([validatorId]) @@index([validatorId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1177,8 +1178,8 @@ model BaggageBooking {
paid Boolean @default(false) paid Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
@@index([bookingId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1194,9 +1195,9 @@ model AuditLog {
userAgent String? userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
@@index([userId, createdAt]) @@index([userId, createdAt])
@@index([entityType, entityId]) @@index([entityType, entityId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1221,8 +1222,8 @@ model SeatBlock {
blockedAt DateTime @default(now()) blockedAt DateTime @default(now())
unblockAt DateTime? unblockAt DateTime?
seat Seat @relation(fields: [seatId], references: [id]) seat Seat @relation(fields: [seatId], references: [id])
@@index([seatId])
@@index([seatId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1234,8 +1235,8 @@ model OperationalReport {
data Json data Json
generatedBy String? generatedBy String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([reportType, dateFrom])
@@index([reportType, dateFrom])
@@schema("passenger") @@schema("passenger")
} }
@@ -1261,9 +1262,9 @@ model FraudAlert {
acknowledged Boolean @default(false) acknowledged Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt]) @@index([userId, createdAt])
@@index([acknowledged]) @@index([acknowledged])
@@schema("passenger") @@schema("passenger")
} }
@@ -1275,9 +1276,9 @@ model CurrencyExchangeRate {
effectiveDate DateTime @default(now()) effectiveDate DateTime @default(now())
source String @default("MANUAL") source String @default("MANUAL")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([fromCurrency, toCurrency, effectiveDate]) @@unique([fromCurrency, toCurrency, effectiveDate])
@@index([fromCurrency, toCurrency]) @@index([fromCurrency, toCurrency])
@@schema("passenger") @@schema("passenger")
} }
@@ -1291,9 +1292,9 @@ model VerifaydaVerification {
failureReason String? failureReason String?
verifiedAt DateTime? verifiedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([nationalId]) @@index([nationalId])
@@index([bookingId]) @@index([bookingId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1311,9 +1312,9 @@ model SavedPassengerProfile {
email String? email String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([userId]) @@index([userId])
@@index([deviceId]) @@index([deviceId])
@@schema("passenger") @@schema("passenger")
} }
@@ -1341,7 +1342,5 @@ model FaydaVerificationSession {
@@index([bookingId]) @@index([bookingId])
@@index([state]) @@index([state])
@@index([expiresAt]) @@index([expiresAt])
@@schema("passenger") @@schema("passenger")
} }

View File

@@ -20,7 +20,8 @@ export enum PaymentMethodTypeEnum {
TELEBIRR = "TELEBIRR", // Ethiopia TELEBIRR = "TELEBIRR", // Ethiopia
CBE_BIRR = "CBE_BIRR", // Ethiopia CBE_BIRR = "CBE_BIRR", // Ethiopia
EBIRR = "EBIRR", // Ethiopia EBIRR = "EBIRR", // Ethiopia
WAAFI = "WAAFI", // Djibouti WAAFI = "WAAFI",
DMONEY= "DMONEY",// Djibouti
CARD = "CARD", // International CARD = "CARD", // International
WALLET = "WALLET", // Internal WALLET = "WALLET", // Internal
} }

View File

@@ -2,9 +2,17 @@ import { registerAs } from "@nestjs/config";
export default registerAs("dmoney", () => ({ export default registerAs("dmoney", () => ({
baseUrl: process.env.DMONEY_BASE_URL ?? "", 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 ?? "", appSecret: process.env.DMONEY_APP_SECRET ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", merchantAppId: process.env.DMONEY_MERCHANT_APP_ID ?? "",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", merchantCode: process.env.DMONEY_MERCHANT_CODE ?? "",
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "", 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",
})); }));

View File

@@ -13,22 +13,36 @@ export class DMoneyWebhookService {
const signatureValid = this.provider.verifyWebhookSignature( const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>, 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({ await this.processor.process({
provider: this.provider.method, provider: this.provider.method,
externalEventId: `${payload.orderId}_${payload.status}`, externalEventId: `${payload.payment_order_id}_${payload.trade_status}`,
merchantOrderId: payload.merchantOrderId, merchantOrderId: payload.merch_order_id,
providerTxnId: payload.transactionId, providerTxnId,
signatureValid, signatureValid,
rawStatus: payload.status, rawStatus: payload.trade_status,
payload: payload as unknown as Record<string, unknown>, payload: payload as unknown as Record<string, unknown>,
result: { result: {
status: mapped, status: mapped,
providerTxnId: payload.transactionId, providerTxnId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, paidAt: this.parseTransEndTime(payload.trans_end_time),
failureCode: payload.status, 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;
}
} }

View File

@@ -136,7 +136,7 @@ export class WebhooksController {
} catch (err) { } catch (err) {
this.logger.error(`D-Money webhook handler threw: ${this.message(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 { private message(err: unknown): string {

View File

@@ -40,6 +40,16 @@ export type {
TelebirrTradeStatus, TelebirrTradeStatus,
} from './providers/telebirr/telebirr.types'; } 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) // Waafi HPP request/response types (exported for apps that build/inspect requests directly)
export type { export type {
WaafiState, WaafiState,

View File

@@ -11,96 +11,82 @@ import {
} from "@edr/types"; } from "@edr/types";
import { AxiosError, AxiosRequestConfig } from "axios"; import { AxiosError, AxiosRequestConfig } from "axios";
import { firstValueFrom } from "rxjs"; 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 { const DMONEY_HTTP_TIMEOUT_MS = 10_000;
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;
}
/**
* 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() @Injectable()
export class DMoneyProvider implements PaymentProvider { export class DMoneyProvider implements PaymentProvider {
readonly method = ProviderMethod.DMONEY; readonly method = ProviderMethod.DMONEY;
private readonly logger = new Logger(DMoneyProvider.name); private readonly logger = new Logger(DMoneyProvider.name);
private readonly httpsAgent: https.Agent;
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly http: HttpService, 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( async initiate(
input: ProviderInitiationInput, input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> { ): Promise<ProviderInitiationResult> {
const token = await this.getFabricToken(); const fabricToken = await this.applyFabricToken();
const amount = (input.amountMinor / 100).toFixed(2); const requestBody = this.buildPreOrderRequest(input);
const timestamp = new Date().toISOString(); const response = await this.postJson<DMoneyPreOrderResponse>(
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
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`,
requestBody, requestBody,
token, {
"Content-Type": "application/json",
"X-APP-Key": this.fabricAppId,
Authorization: fabricToken,
},
); );
if (!response.success || !response.orderId) { const prepayId = response.biz_content?.prepay_id;
throw new Error(`DMoney initiate failed: ${JSON.stringify(response)}`); 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 { return {
providerOrderId: response.orderId, providerOrderId: prepayId,
clientAction: response.checkoutUrl clientAction: {
? { type: "REDIRECT", url: response.checkoutUrl }
: {
type: "REDIRECT", type: "REDIRECT",
url: `${this.baseUrl}/checkout/${response.orderId}`, url: this.buildCheckoutUrl(prepayId),
}, },
expiresAt, expiresAt,
rawInitiation: { rawInitiation: {
@@ -111,151 +97,239 @@ export class DMoneyProvider implements PaymentProvider {
} }
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> { async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const token = await this.getFabricToken(); const fabricToken = await this.applyFabricToken();
const timestamp = new Date().toISOString(); const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const signature = this.signRequest({ const response = await this.postJson<DMoneyQueryOrderResponse>(
merchantId: this.merchantId, `${this.baseUrl}/apiaccess/payment/v1/merchant/queryOrder`,
merchantOrderId, requestBody,
timestamp,
});
const response = await this.postJson<DMoneyQueryResponse>(
`${this.baseUrl}/api/v1/payment/query`,
{ {
merchantId: this.merchantId, "Content-Type": "application/json",
merchantOrderId, "X-APP-Key": this.fabricAppId,
timestamp, Authorization: fabricToken,
signature,
}, },
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 { return {
status: mapped, status: mapped,
providerTxnId: response.transactionId, providerTxnId,
failureCode: failureCode:
mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, mapped === ProviderPaymentStatus.FAILED && orderStatus
rawResponse: response as unknown as Record<string, unknown>, ? orderStatus
: undefined,
rawResponse: response as Record<string, unknown>,
}; };
} }
verifyWebhookSignature(payload: Record<string, unknown>): boolean { /** queryOrder `order_status` → shared status. */
const { signature, ...data } = payload; mapOrderStatus(orderStatus: string | undefined): ProviderPaymentStatus {
if (!signature || typeof signature !== "string") return false; switch (orderStatus) {
case "PAY_SUCCESS":
const expectedSignature = this.signRequest(data); case "Completed":
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()) {
case "SUCCESS": case "SUCCESS":
case "COMPLETED":
return ProviderPaymentStatus.SUCCEEDED; return ProviderPaymentStatus.SUCCEEDED;
case "FAILED": case "PAY_FAILED":
case "REJECTED": case "Failure":
case "EXPIRED": case "ORDER_CLOSED":
case "CANCELLED": case "Expired":
return ProviderPaymentStatus.FAILED; return ProviderPaymentStatus.FAILED;
case "PENDING": case "WAIT_PAY":
return ProviderPaymentStatus.REQUIRES_ACTION; return ProviderPaymentStatus.REQUIRES_ACTION;
case "PROCESSING": case "PAYING":
case "Paying":
return ProviderPaymentStatus.PROCESSING; return ProviderPaymentStatus.PROCESSING;
default: default:
return ProviderPaymentStatus.PROCESSING; return ProviderPaymentStatus.PROCESSING;
} }
} }
private async getFabricToken(): Promise<string> { /** Notification `trade_status` → shared status. */
const response = await this.postJson<DMoneyAuthResponse>( mapWebhookTradeStatus(
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/token`, tradeStatus: string | undefined,
{ ): ProviderPaymentStatus {
appSecret: this.appSecret, switch (tradeStatus) {
}, case "Completed":
); return ProviderPaymentStatus.SUCCEEDED;
case "Failure":
if (!response.token) { case "Expired":
throw new Error( return ProviderPaymentStatus.FAILED;
`DMoney authentication failed: ${JSON.stringify(response)}`, 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 },
{
"Content-Type": "application/json",
"X-APP-Key": this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(
`D-Money token request failed: ${JSON.stringify(response)}`,
);
}
// D-Money returns the token already prefixed with "Bearer " — use it verbatim.
return response.token; return response.token;
} }
private signRequest(data: Record<string, unknown>): string { private buildPreOrderRequest(
const sortedKeys = Object.keys(data).sort(); input: ProviderInitiationInput,
const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&"); ): DMoneyPreOrderRequest {
const totalAmount = (input.amountMinor / 100).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 console.log("\n\n\n")
.createHmac("sha256", this.secretKey) console.log(req)
.update(signString) console.log("\n\n\n")
.digest("hex"); 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>( private async postJson<T>(
url: string, url: string,
body: unknown, body: unknown,
token?: string, headers: Record<string, string>,
): Promise<T> { ): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const config: AxiosRequestConfig = { const config: AxiosRequestConfig = {
headers, headers,
timeout: 10_000, timeout: DMONEY_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
}; };
const started = Date.now(); const started = Date.now();
try { try {
const res = await firstValueFrom(this.http.post<T>(url, body, config)); const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug( 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; return res.data;
} catch (err) { } catch (err) {
if (err instanceof AxiosError) { if (err instanceof AxiosError) {
this.logger.error( 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 { } else {
this.logger.error( 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; throw err;
} }
} }
private sanitize(body: DMoneyInitiateRequest): Record<string, unknown> { private sanitize(body: DMoneyPreOrderRequest): Record<string, unknown> {
const { signature: _signature, ...rest } = body; const { sign: _sign, ...rest } = body;
return rest; return rest;
} }
private get baseUrl(): string { private get baseUrl(): string {
return this.config.get<string>("dmoney.baseUrl") ?? ""; return this.config.get<string>("dmoney.baseUrl") ?? "";
} }
private get merchantId(): string { private get webBaseUrl(): string {
return this.config.get<string>("dmoney.merchantId") ?? ""; return this.config.get<string>("dmoney.webBaseUrl") ?? "";
}
private get fabricAppId(): string {
return this.config.get<string>("dmoney.fabricAppId") ?? "";
} }
private get appSecret(): string { private get appSecret(): string {
return this.config.get<string>("dmoney.appSecret") ?? ""; return this.config.get<string>("dmoney.appSecret") ?? "";
} }
private get secretKey(): string { private get merchantAppId(): string {
return this.config.get<string>("dmoney.secretKey") ?? ""; return this.config.get<string>("dmoney.merchantAppId") ?? "";
}
private get merchantCode(): string {
return this.config.get<string>("dmoney.merchantCode") ?? "";
} }
private get notifyUrl(): string { private get notifyUrl(): string {
return this.config.get<string>("dmoney.notifyUrl") ?? ""; return this.config.get<string>("dmoney.notifyUrl") ?? "";
@@ -263,4 +337,19 @@ export class DMoneyProvider implements PaymentProvider {
private get returnUrl(): string { private get returnUrl(): string {
return this.config.get<string>("dmoney.returnUrl") ?? ""; 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") ?? "";
}
} }

View File

@@ -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;
}

View File

@@ -1,13 +1,18 @@
export interface DMoneyWebhookPayload { export interface DMoneyWebhookPayload {
merchantId: string; appid: string;
merchantOrderId: string; merch_code: string;
orderId: string; merch_order_id: string;
status: string; payment_order_id: string;
transactionId?: string; notify_time?: string;
amount?: string; trans_end_time?: string;
currency?: string; total_amount?: string;
paidAt?: string; trans_currency?: string;
payerPhone?: string; /** Paying | Expired | Completed | Failure */
signature: string; trade_status: string;
transId?: string;
callback_info?: string;
notify_url?: string;
sign: string;
sign_type?: string;
[key: string]: unknown; [key: string]: unknown;
} }