mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
|||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger(PaymentsService.name);
|
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(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private seatsService: SeatsService,
|
private seatsService: SeatsService,
|
||||||
@@ -196,6 +204,35 @@ export class PaymentsService {
|
|||||||
private async initiateWalletPayment(
|
private async initiateWalletPayment(
|
||||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||||
): Promise<InitiateResponseDto> {
|
): 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 debitResult = await this.prisma.$transaction(async (tx) => {
|
||||||
const wallet = await tx.walletAccount.findUnique({
|
const wallet = await tx.walletAccount.findUnique({
|
||||||
where: { passengerId: booking.passengerId },
|
where: { passengerId: booking.passengerId },
|
||||||
|
|||||||
@@ -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",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -41,6 +41,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,
|
||||||
|
|||||||
@@ -11,97 +11,83 @@ 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",
|
||||||
: {
|
url: this.buildCheckoutUrl(prepayId),
|
||||||
type: "REDIRECT",
|
},
|
||||||
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
|
||||||
},
|
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
@@ -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(
|
||||||
|
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`,
|
`${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(
|
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;
|
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).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") ?? "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
for (const key of Object.keys(requestObject)) {
|
||||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||||
|
if (requestObject[key] === undefined) continue;
|
||||||
fieldMap[key] = requestObject[key];
|
fieldMap[key] = requestObject[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +25,9 @@ export function buildCanonicalString(requestObject: Record<string, unknown>): st
|
|||||||
if (biz && typeof biz === 'object') {
|
if (biz && typeof biz === 'object') {
|
||||||
for (const key of Object.keys(biz as Record<string, unknown>)) {
|
for (const key of Object.keys(biz as Record<string, unknown>)) {
|
||||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
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 =
|
const providerTxnId =
|
||||||
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
|
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
|
||||||
const mapped = this.mapTradeStatus(tradeStatus);
|
const mapped = this.mapTradeStatus(orderStatus);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId,
|
providerTxnId,
|
||||||
failureCode:
|
failureCode:
|
||||||
mapped === ProviderPaymentStatus.FAILED && tradeStatus
|
mapped === ProviderPaymentStatus.FAILED && orderStatus
|
||||||
? tradeStatus
|
? orderStatus
|
||||||
: undefined,
|
: undefined,
|
||||||
rawResponse: response as Record<string, unknown>,
|
rawResponse: response as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
@@ -195,7 +198,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
private buildCreateOrderRequest(
|
private buildCreateOrderRequest(
|
||||||
input: ProviderInitiationInput,
|
input: ProviderInitiationInput,
|
||||||
): CreateOrderRequest {
|
): CreateOrderRequest {
|
||||||
const totalAmount = String(input.amountMinor / 100);
|
const totalAmount = String(input.amountMinor);
|
||||||
const req = {
|
const req = {
|
||||||
timestamp: createTimestamp(),
|
timestamp: createTimestamp(),
|
||||||
nonce_str: createNonceStr(),
|
nonce_str: createNonceStr(),
|
||||||
@@ -211,7 +214,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
redirect_url: "https://google.com",
|
...(input.redirectUrl ? { redirect_url: input.redirectUrl } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sign = signRequestObject(
|
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). */
|
/** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */
|
||||||
private toAmount(amountMinor: number): number {
|
private toAmount(amountMinor: number): number {
|
||||||
return Math.trunc(amountMinor) / 100;
|
return Math.trunc(amountMinor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private timestamp(): string {
|
private timestamp(): string {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user