mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
feat: ( payment ) integrate the passenger to payment microservice
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
} from "@edr/types";
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
interface CardInitiateRequest {
|
||||
amount: number;
|
||||
@@ -54,7 +54,9 @@ export class CardProvider implements PaymentProvider {
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
|
||||
const requestBody: CardInitiateRequest = {
|
||||
@@ -65,7 +67,8 @@ export class CardProvider implements PaymentProvider {
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
orderRef: input.orderRef,
|
||||
},
|
||||
return_url: this.returnUrl,
|
||||
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||
return_url: input.returnUrl ?? this.returnUrl,
|
||||
webhook_url: this.webhookUrl,
|
||||
};
|
||||
|
||||
@@ -75,14 +78,16 @@ export class CardProvider implements PaymentProvider {
|
||||
);
|
||||
|
||||
if (!response.id) {
|
||||
throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`);
|
||||
throw new Error(
|
||||
`Card gateway initiate failed: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(response.expires_at * 1000);
|
||||
|
||||
return {
|
||||
providerOrderId: response.id,
|
||||
clientAction: { type: 'REDIRECT', url: response.checkout_url },
|
||||
clientAction: { type: "REDIRECT", url: response.checkout_url },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: requestBody,
|
||||
@@ -109,12 +114,15 @@ export class CardProvider implements PaymentProvider {
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>, signature: string): boolean {
|
||||
verifyWebhookSignature(
|
||||
payload: Record<string, unknown>,
|
||||
signature: string,
|
||||
): boolean {
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', this.webhookSecret)
|
||||
.createHmac("sha256", this.webhookSecret)
|
||||
.update(payloadString)
|
||||
.digest('hex');
|
||||
.digest("hex");
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
@@ -132,18 +140,18 @@ export class CardProvider implements PaymentProvider {
|
||||
|
||||
private mapStatus(status: string): ProviderPaymentStatus {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'succeeded':
|
||||
case 'paid':
|
||||
case "succeeded":
|
||||
case "paid":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
case 'expired':
|
||||
case "failed":
|
||||
case "canceled":
|
||||
case "expired":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'requires_payment_method':
|
||||
case 'requires_confirmation':
|
||||
case 'requires_action':
|
||||
case "requires_payment_method":
|
||||
case "requires_confirmation":
|
||||
case "requires_action":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'processing':
|
||||
case "processing":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
@@ -153,8 +161,8 @@ export class CardProvider implements PaymentProvider {
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
@@ -162,7 +170,9 @@ export class CardProvider implements PaymentProvider {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
this.logger.debug(
|
||||
`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
@@ -170,7 +180,9 @@ export class CardProvider implements PaymentProvider {
|
||||
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
this.logger.error(
|
||||
`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -179,7 +191,7 @@ export class CardProvider implements PaymentProvider {
|
||||
private async getJson<T>(url: string): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
@@ -187,7 +199,9 @@ export class CardProvider implements PaymentProvider {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.get<T>(url, config));
|
||||
this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
this.logger.debug(
|
||||
`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
@@ -195,25 +209,27 @@ export class CardProvider implements PaymentProvider {
|
||||
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
this.logger.error(
|
||||
`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('card.baseUrl') ?? '';
|
||||
return this.config.get<string>("card.baseUrl") ?? "";
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>('card.apiKey') ?? '';
|
||||
return this.config.get<string>("card.apiKey") ?? "";
|
||||
}
|
||||
private get webhookSecret(): string {
|
||||
return this.config.get<string>('card.webhookSecret') ?? '';
|
||||
return this.config.get<string>("card.webhookSecret") ?? "";
|
||||
}
|
||||
private get webhookUrl(): string {
|
||||
return this.config.get<string>('card.webhookUrl') ?? '';
|
||||
return this.config.get<string>("card.webhookUrl") ?? "";
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('card.returnUrl') ?? '';
|
||||
return this.config.get<string>("card.returnUrl") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
} from "@edr/types";
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
interface CbeBirrInitiateRequest {
|
||||
merchantId: string;
|
||||
@@ -51,7 +51,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const amount = (input.amountMinor / 100).toFixed(2);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
@@ -61,7 +63,8 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
returnUrl: this.returnUrl,
|
||||
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||
returnUrl: input.returnUrl ?? this.returnUrl,
|
||||
notifyUrl: this.notifyUrl,
|
||||
timestamp,
|
||||
signature: this.signRequest({
|
||||
@@ -85,7 +88,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
|
||||
return {
|
||||
providerOrderId: response.orderId,
|
||||
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
|
||||
clientAction: { type: "REDIRECT", url: response.paymentUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
@@ -117,14 +120,15 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transactionId,
|
||||
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { signature, ...data } = payload;
|
||||
if (!signature || typeof signature !== 'string') return false;
|
||||
if (!signature || typeof signature !== "string") return false;
|
||||
|
||||
const expectedSignature = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
@@ -139,16 +143,16 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
|
||||
private mapStatus(status: string): ProviderPaymentStatus {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'SUCCESS':
|
||||
case 'COMPLETED':
|
||||
case "SUCCESS":
|
||||
case "COMPLETED":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'FAILED':
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
case "FAILED":
|
||||
case "REJECTED":
|
||||
case "EXPIRED":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
case "PENDING":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
case "PROCESSING":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
@@ -157,21 +161,19 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join('&');
|
||||
const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&");
|
||||
|
||||
return crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.createHmac("sha256", this.secretKey)
|
||||
.update(signString)
|
||||
.digest('hex');
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Merchant-Id': this.merchantId,
|
||||
"Content-Type": "application/json",
|
||||
"X-Merchant-Id": this.merchantId,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
@@ -179,7 +181,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
this.logger.debug(
|
||||
`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
@@ -187,7 +191,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
this.logger.error(
|
||||
`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -199,18 +205,18 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('cbe.baseUrl') ?? '';
|
||||
return this.config.get<string>("cbe.baseUrl") ?? "";
|
||||
}
|
||||
private get merchantId(): string {
|
||||
return this.config.get<string>('cbe.merchantId') ?? '';
|
||||
return this.config.get<string>("cbe.merchantId") ?? "";
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>('cbe.secretKey') ?? '';
|
||||
return this.config.get<string>("cbe.secretKey") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>('cbe.notifyUrl') ?? '';
|
||||
return this.config.get<string>("cbe.notifyUrl") ?? "";
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('cbe.returnUrl') ?? '';
|
||||
return this.config.get<string>("cbe.returnUrl") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class DMoneyProvider implements PaymentProvider {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
@@ -99,9 +99,9 @@ export class DMoneyProvider implements PaymentProvider {
|
||||
clientAction: response.checkoutUrl
|
||||
? { type: "REDIRECT", url: response.checkoutUrl }
|
||||
: {
|
||||
type: "REDIRECT",
|
||||
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
||||
},
|
||||
type: "REDIRECT",
|
||||
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
||||
},
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
} from "@edr/types";
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import * as crypto from "node:crypto";
|
||||
|
||||
interface EBirrInitiateRequest {
|
||||
merchantCode: string;
|
||||
@@ -58,7 +58,9 @@ export class EBirrProvider implements PaymentProvider {
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
const timestamp = Date.now();
|
||||
|
||||
@@ -70,7 +72,8 @@ export class EBirrProvider implements PaymentProvider {
|
||||
subject: `EDR Ticket`,
|
||||
body: `Order ${input.orderRef}`,
|
||||
notifyUrl: this.notifyUrl,
|
||||
returnUrl: this.returnUrl,
|
||||
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||
returnUrl: input.returnUrl ?? this.returnUrl,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
@@ -85,7 +88,7 @@ export class EBirrProvider implements PaymentProvider {
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.code !== '0000' || !response.data?.orderNo) {
|
||||
if (response.code !== "0000" || !response.data?.orderNo) {
|
||||
throw new Error(`eBirr initiate failed: ${response.message}`);
|
||||
}
|
||||
|
||||
@@ -93,7 +96,7 @@ export class EBirrProvider implements PaymentProvider {
|
||||
|
||||
return {
|
||||
providerOrderId: response.data.orderNo,
|
||||
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
|
||||
clientAction: { type: "REDIRECT", url: response.data.payUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
@@ -120,7 +123,7 @@ export class EBirrProvider implements PaymentProvider {
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.code !== '0000' || !response.data) {
|
||||
if (response.code !== "0000" || !response.data) {
|
||||
throw new Error(`eBirr query failed: ${response.message}`);
|
||||
}
|
||||
|
||||
@@ -129,20 +132,20 @@ export class EBirrProvider implements PaymentProvider {
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.data.tradeNo,
|
||||
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.data.tradeStatus : undefined,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED
|
||||
? response.data.tradeStatus
|
||||
: undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { sign, ...data } = payload;
|
||||
if (!sign || typeof sign !== 'string') return false;
|
||||
if (!sign || typeof sign !== "string") return false;
|
||||
|
||||
const expectedSign = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(sign),
|
||||
Buffer.from(expectedSign),
|
||||
);
|
||||
return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign));
|
||||
}
|
||||
|
||||
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
@@ -151,17 +154,17 @@ export class EBirrProvider implements PaymentProvider {
|
||||
|
||||
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
switch (tradeStatus?.toUpperCase()) {
|
||||
case 'TRADE_SUCCESS':
|
||||
case 'SUCCESS':
|
||||
case "TRADE_SUCCESS":
|
||||
case "SUCCESS":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'TRADE_CLOSED':
|
||||
case 'TRADE_FAILED':
|
||||
case 'FAILED':
|
||||
case "TRADE_CLOSED":
|
||||
case "TRADE_FAILED":
|
||||
case "FAILED":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'WAIT_BUYER_PAY':
|
||||
case 'PENDING':
|
||||
case "WAIT_BUYER_PAY":
|
||||
case "PENDING":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
case "PROCESSING":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
@@ -170,21 +173,21 @@ export class EBirrProvider implements PaymentProvider {
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join('&') + `&key=${this.secretKey}`;
|
||||
const signString =
|
||||
sortedKeys.map((key) => `${key}=${data[key]}`).join("&") +
|
||||
`&key=${this.secretKey}`;
|
||||
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.createHash("md5")
|
||||
.update(signString)
|
||||
.digest('hex')
|
||||
.digest("hex")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
@@ -192,7 +195,9 @@ export class EBirrProvider implements PaymentProvider {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
this.logger.debug(
|
||||
`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
@@ -200,7 +205,9 @@ export class EBirrProvider implements PaymentProvider {
|
||||
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
this.logger.error(
|
||||
`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -212,18 +219,18 @@ export class EBirrProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('ebirr.baseUrl') ?? '';
|
||||
return this.config.get<string>("ebirr.baseUrl") ?? "";
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>('ebirr.merchantCode') ?? '';
|
||||
return this.config.get<string>("ebirr.merchantCode") ?? "";
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>('ebirr.secretKey') ?? '';
|
||||
return this.config.get<string>("ebirr.secretKey") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>('ebirr.notifyUrl') ?? '';
|
||||
return this.config.get<string>("ebirr.notifyUrl") ?? "";
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('ebirr.returnUrl') ?? '';
|
||||
return this.config.get<string>("ebirr.returnUrl") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
@@ -8,22 +8,22 @@ import {
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as https from 'node:https';
|
||||
} from "@edr/types";
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import * as https from "node:https";
|
||||
import {
|
||||
createNonceStr,
|
||||
createTimestamp,
|
||||
signRequestObject,
|
||||
verifyRequestObject,
|
||||
} from './telebirr.crypto';
|
||||
} from "./telebirr.crypto";
|
||||
import {
|
||||
CreateOrderRequest,
|
||||
CreateOrderResponse,
|
||||
FabricTokenResponse,
|
||||
QueryOrderResponse,
|
||||
} from './telebirr.types';
|
||||
} from "./telebirr.types";
|
||||
|
||||
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -37,17 +37,21 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
const insecure = this.config.get<boolean>('telebirr.insecureTls');
|
||||
const insecure = this.config.get<boolean>("telebirr.insecureTls");
|
||||
if (insecure) {
|
||||
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
|
||||
this.logger.warn(
|
||||
"TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.",
|
||||
);
|
||||
}
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: !insecure,
|
||||
secureProtocol: 'TLSv1_2_method',
|
||||
secureProtocol: "TLSv1_2_method",
|
||||
});
|
||||
}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildCreateOrderRequest(input);
|
||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
||||
@@ -59,17 +63,19 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
|
||||
const platform = input.platform ?? 'web';
|
||||
const expiresAt = this.computeExpiresAt(
|
||||
requestBody.biz_content.timeout_express,
|
||||
);
|
||||
const platform = input.platform ?? "web";
|
||||
const clientAction =
|
||||
platform === 'mobile'
|
||||
platform === "mobile"
|
||||
? {
|
||||
type: 'LAUNCH_APP' as const,
|
||||
appId: this.merchantAppId,
|
||||
receiveCode: response.biz_content?.receiveCode,
|
||||
shortCode: this.merchantCode,
|
||||
}
|
||||
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
|
||||
type: "LAUNCH_APP" as const,
|
||||
appId: this.merchantAppId,
|
||||
receiveCode: response.biz_content?.receiveCode,
|
||||
shortCode: this.merchantCode,
|
||||
}
|
||||
: { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) };
|
||||
|
||||
return {
|
||||
providerOrderId: prepayId,
|
||||
@@ -89,8 +95,8 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
||||
requestBody,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
"Content-Type": "application/json",
|
||||
"X-APP-Key": this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
@@ -104,36 +110,40 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||
mapped === ProviderPaymentStatus.FAILED && tradeStatus
|
||||
? tradeStatus
|
||||
: undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'PAY_SUCCESS':
|
||||
case "PAY_SUCCESS":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'PAY_FAILED':
|
||||
case 'ORDER_CLOSED':
|
||||
case "PAY_FAILED":
|
||||
case "ORDER_CLOSED":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'WAIT_PAY':
|
||||
case "WAIT_PAY":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PAYING':
|
||||
case "PAYING":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||
mapWebhookTradeStatus(
|
||||
tradeStatus: string | undefined,
|
||||
): ProviderPaymentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'Completed':
|
||||
case "Completed":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'Failure':
|
||||
case 'Expired':
|
||||
case "Failure":
|
||||
case "Expired":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'Paying':
|
||||
case 'Pending':
|
||||
case "Paying":
|
||||
case "Pending":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
@@ -142,7 +152,9 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
if (!this.publicKey) {
|
||||
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
|
||||
this.logger.error(
|
||||
"TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return verifyRequestObject(payload, this.publicKey);
|
||||
@@ -153,12 +165,14 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
`${this.baseUrl}/payment/v1/token`,
|
||||
{ appSecret: this.appSecret },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
"Content-Type": "application/json",
|
||||
"X-APP-Key": this.fabricAppId,
|
||||
},
|
||||
);
|
||||
if (!response?.token) {
|
||||
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
|
||||
throw new Error(
|
||||
`Telebirr token request failed: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
return response.token;
|
||||
}
|
||||
@@ -171,51 +185,61 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
||||
body,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
"Content-Type": "application/json",
|
||||
"X-APP-Key": this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
|
||||
private buildCreateOrderRequest(
|
||||
input: ProviderInitiationInput,
|
||||
): CreateOrderRequest {
|
||||
const totalAmount = String(input.amountMinor / 100);
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: 'payment.preorder' as const,
|
||||
version: '1.0' as const,
|
||||
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,
|
||||
trade_type: "Checkout" as const,
|
||||
title: `EDR ${input.orderRef}`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
redirect_url: input.redirectUrl
|
||||
redirect_url: input.redirectUrl,
|
||||
},
|
||||
};
|
||||
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
|
||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
||||
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> {
|
||||
private buildQueryOrderRequest(
|
||||
merchantOrderId: string,
|
||||
): Record<string, unknown> {
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: 'payment.queryorder',
|
||||
version: '1.0',
|
||||
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' };
|
||||
const sign = signRequestObject(
|
||||
req as Record<string, unknown>,
|
||||
this.privateKey,
|
||||
);
|
||||
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||
}
|
||||
|
||||
private buildCheckoutUrl(prepayId: string): string {
|
||||
@@ -233,27 +257,34 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
`nonce_str=${map.nonce_str}`,
|
||||
`prepay_id=${map.prepay_id}`,
|
||||
`timestamp=${map.timestamp}`,
|
||||
'sign_type=SHA256WithRSA',
|
||||
"sign_type=SHA256WithRSA",
|
||||
`sign=${sign}`,
|
||||
'version=1.0',
|
||||
'trade_type=Checkout',
|
||||
].join('&');
|
||||
"version=1.0",
|
||||
"trade_type=Checkout",
|
||||
].join("&");
|
||||
return `${this.webBaseUrl}${rawRequest}`;
|
||||
}
|
||||
|
||||
private computeExpiresAt(timeoutExpress: string): Date {
|
||||
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
|
||||
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
|
||||
const minutes = match
|
||||
? this.toMinutes(parseInt(match[1], 10), match[2])
|
||||
: 15;
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
private toMinutes(n: number, unit: string): number {
|
||||
switch (unit) {
|
||||
case 's': return Math.max(1, Math.round(n / 60));
|
||||
case 'm': return n;
|
||||
case 'h': return n * 60;
|
||||
case 'd': return n * 60 * 24;
|
||||
default: return 15;
|
||||
case "s":
|
||||
return Math.max(1, Math.round(n / 60));
|
||||
case "m":
|
||||
return n;
|
||||
case "h":
|
||||
return n * 60;
|
||||
case "d":
|
||||
return n * 60 * 24;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +301,9 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
this.logger.debug(
|
||||
`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
@@ -278,7 +311,9 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
this.logger.error(
|
||||
`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -289,14 +324,34 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
|
||||
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
|
||||
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
|
||||
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
|
||||
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
|
||||
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
|
||||
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
|
||||
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
|
||||
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
|
||||
private get publicKey(): string { return this.config.get<string>('telebirr.publicKey') ?? ''; }
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>("telebirr.baseUrl") ?? "";
|
||||
}
|
||||
private get webBaseUrl(): string {
|
||||
return this.config.get<string>("telebirr.webBaseUrl") ?? "";
|
||||
}
|
||||
private get fabricAppId(): string {
|
||||
return this.config.get<string>("telebirr.fabricAppId") ?? "";
|
||||
}
|
||||
private get appSecret(): string {
|
||||
return this.config.get<string>("telebirr.appSecret") ?? "";
|
||||
}
|
||||
private get merchantAppId(): string {
|
||||
return this.config.get<string>("telebirr.merchantAppId") ?? "";
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>("telebirr.merchantCode") ?? "";
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>("telebirr.notifyUrl") ?? "";
|
||||
}
|
||||
private get timeoutExpress(): string {
|
||||
return this.config.get<string>("telebirr.timeoutExpress") ?? "15m";
|
||||
}
|
||||
private get privateKey(): string {
|
||||
return this.config.get<string>("telebirr.privateKey") ?? "";
|
||||
}
|
||||
private get publicKey(): string {
|
||||
return this.config.get<string>("telebirr.publicKey") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
@@ -8,20 +8,20 @@ import {
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as https from 'node:https';
|
||||
} 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 {
|
||||
WaafiGetTranInfoRequest,
|
||||
WaafiGetTranInfoResponse,
|
||||
WaafiHppPurchaseRequest,
|
||||
WaafiHppPurchaseResponse,
|
||||
} from './waafi.types';
|
||||
} from "./waafi.types";
|
||||
|
||||
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
|
||||
const WAAFI_SUCCESS_CODE = '2001';
|
||||
const WAAFI_SUCCESS_CODE = "2001";
|
||||
/** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */
|
||||
const WAAFI_HPP_SESSION_MS = 5 * 60_000;
|
||||
|
||||
@@ -35,16 +35,18 @@ export class WaafiProvider implements PaymentProvider {
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
const insecure = this.config.get<boolean>('waafi.insecureTls');
|
||||
const insecure = this.config.get<boolean>("waafi.insecureTls");
|
||||
if (insecure) {
|
||||
this.logger.warn(
|
||||
'WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.',
|
||||
"WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.",
|
||||
);
|
||||
}
|
||||
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
|
||||
}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const requestBody = this.buildPurchaseRequest(input);
|
||||
const response = await this.postJson<WaafiHppPurchaseResponse>(
|
||||
`${this.baseUrl}/asm`,
|
||||
@@ -57,7 +59,8 @@ export class WaafiProvider implements PaymentProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const checkoutUrl = response.params?.hppUrl ?? response.params?.directPaymentLink;
|
||||
const checkoutUrl =
|
||||
response.params?.hppUrl ?? response.params?.directPaymentLink;
|
||||
const orderId = response.params?.orderId;
|
||||
if (!checkoutUrl || !orderId) {
|
||||
throw new Error(
|
||||
@@ -67,7 +70,7 @@ export class WaafiProvider implements PaymentProvider {
|
||||
|
||||
return {
|
||||
providerOrderId: orderId,
|
||||
clientAction: { type: 'REDIRECT', url: checkoutUrl },
|
||||
clientAction: { type: "REDIRECT", url: checkoutUrl },
|
||||
expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS),
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
@@ -91,7 +94,9 @@ export class WaafiProvider implements PaymentProvider {
|
||||
status: mapped,
|
||||
providerTxnId: transactionId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED && rawState ? rawState : undefined,
|
||||
mapped === ProviderPaymentStatus.FAILED && rawState
|
||||
? rawState
|
||||
: undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -115,61 +120,70 @@ export class WaafiProvider implements PaymentProvider {
|
||||
eventId: string | undefined,
|
||||
): boolean {
|
||||
if (!this.webhookSecret) {
|
||||
this.logger.error('WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks');
|
||||
this.logger.error(
|
||||
"WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!signature || !timestamp || !eventId) {
|
||||
this.logger.warn('Waafi webhook missing signature/timestamp/event-id headers');
|
||||
this.logger.warn(
|
||||
"Waafi webhook missing signature/timestamp/event-id headers",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const signingString = `${timestamp}.${eventId}.${rawBody}`;
|
||||
const expected = crypto
|
||||
.createHmac('sha256', this.webhookSecret)
|
||||
.createHmac("sha256", this.webhookSecret)
|
||||
.update(signingString)
|
||||
.digest('hex');
|
||||
.digest("hex");
|
||||
|
||||
const provided = Buffer.from(signature, 'utf8');
|
||||
const computed = Buffer.from(expected, 'utf8');
|
||||
const provided = Buffer.from(signature, "utf8");
|
||||
const computed = Buffer.from(expected, "utf8");
|
||||
if (provided.length !== computed.length) return false;
|
||||
return crypto.timingSafeEqual(provided, computed);
|
||||
}
|
||||
|
||||
private mapStatus(raw: string | undefined): ProviderPaymentStatus {
|
||||
switch (raw?.toUpperCase()) {
|
||||
case 'APPROVED':
|
||||
case 'SUCCESS':
|
||||
case "APPROVED":
|
||||
case "SUCCESS":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'CANCELED':
|
||||
case 'CANCELLED':
|
||||
case "CANCELED":
|
||||
case "CANCELLED":
|
||||
return ProviderPaymentStatus.CANCELLED;
|
||||
case 'DECLINED':
|
||||
case 'FAILED':
|
||||
case 'EXPIRED':
|
||||
case 'TIMEOUT':
|
||||
case "DECLINED":
|
||||
case "FAILED":
|
||||
case "EXPIRED":
|
||||
case "TIMEOUT":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
case 'INITIATED':
|
||||
case "PENDING":
|
||||
case "INITIATED":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private buildPurchaseRequest(input: ProviderInitiationInput): WaafiHppPurchaseRequest {
|
||||
private buildPurchaseRequest(
|
||||
input: ProviderInitiationInput,
|
||||
): WaafiHppPurchaseRequest {
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
schemaVersion: "1.0",
|
||||
requestId: crypto.randomUUID(),
|
||||
timestamp: this.timestamp(),
|
||||
channelName: 'WEB',
|
||||
serviceName: 'HPP_PURCHASE',
|
||||
channelName: "WEB",
|
||||
serviceName: "HPP_PURCHASE",
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
storeId: this.storeId,
|
||||
hppKey: this.hppKey,
|
||||
paymentMethod: this.paymentMethod,
|
||||
hppSuccessCallbackUrl: this.successUrl,
|
||||
hppFailureCallbackUrl: this.failureUrl,
|
||||
// Browser bounce-back is per-transaction (each calling app has its own UI), so the
|
||||
// caller-supplied URLs win; the static config is only a fallback. UX-only — the
|
||||
// webhook remains the single source of truth for payment state.
|
||||
hppSuccessCallbackUrl: input.returnUrl ?? this.successUrl,
|
||||
hppFailureCallbackUrl: input.failureUrl ?? this.failureUrl,
|
||||
hppRespDataFormat: this.respDataFormat,
|
||||
// MWALLET_ACCOUNT requires the payer phone up front; omit if the caller did not supply it
|
||||
// and let the hosted page collect it. See docs/waffi open question on payer-phone sourcing.
|
||||
@@ -187,13 +201,15 @@ export class WaafiProvider implements PaymentProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private buildGetTranInfoRequest(merchantOrderId: string): WaafiGetTranInfoRequest {
|
||||
private buildGetTranInfoRequest(
|
||||
merchantOrderId: string,
|
||||
): WaafiGetTranInfoRequest {
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
schemaVersion: "1.0",
|
||||
requestId: crypto.randomUUID(),
|
||||
timestamp: this.timestamp(),
|
||||
channelName: 'WEB',
|
||||
serviceName: 'HPP_GETTRANINFO',
|
||||
channelName: "WEB",
|
||||
serviceName: "HPP_GETTRANINFO",
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
storeId: this.storeId,
|
||||
@@ -214,7 +230,7 @@ export class WaafiProvider implements PaymentProvider {
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: WAAFI_HTTP_TIMEOUT_MS,
|
||||
httpsAgent: this.httpsAgent,
|
||||
};
|
||||
@@ -243,38 +259,40 @@ export class WaafiProvider implements PaymentProvider {
|
||||
private sanitize(body: WaafiHppPurchaseRequest): Record<string, unknown> {
|
||||
return {
|
||||
...body,
|
||||
serviceParams: { ...body.serviceParams, hppKey: '***REDACTED***' },
|
||||
serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" },
|
||||
};
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('waafi.baseUrl') ?? 'https://sandbox.waafipay.net';
|
||||
return (
|
||||
this.config.get<string>("waafi.baseUrl") ?? "https://sandbox.waafipay.net"
|
||||
);
|
||||
}
|
||||
private get merchantUid(): string {
|
||||
return this.config.get<string>('waafi.merchantUid') ?? '';
|
||||
return this.config.get<string>("waafi.merchantUid") ?? "";
|
||||
}
|
||||
private get storeId(): string {
|
||||
return this.config.get<string>('waafi.storeId') ?? '';
|
||||
return this.config.get<string>("waafi.storeId") ?? "";
|
||||
}
|
||||
private get hppKey(): string {
|
||||
return this.config.get<string>('waafi.hppKey') ?? '';
|
||||
return this.config.get<string>("waafi.hppKey") ?? "";
|
||||
}
|
||||
private get webhookSecret(): string {
|
||||
return this.config.get<string>('waafi.webhookSecret') ?? '';
|
||||
return this.config.get<string>("waafi.webhookSecret") ?? "";
|
||||
}
|
||||
private get paymentMethod(): string {
|
||||
return this.config.get<string>('waafi.paymentMethod') ?? 'MWALLET_ACCOUNT';
|
||||
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
|
||||
}
|
||||
private get currency(): string {
|
||||
return this.config.get<string>('waafi.currency') ?? '';
|
||||
return this.config.get<string>("waafi.currency") ?? "";
|
||||
}
|
||||
private get successUrl(): string {
|
||||
return this.config.get<string>('waafi.successUrl') ?? '';
|
||||
return this.config.get<string>("waafi.successUrl") ?? "";
|
||||
}
|
||||
private get failureUrl(): string {
|
||||
return this.config.get<string>('waafi.failureUrl') ?? '';
|
||||
return this.config.get<string>("waafi.failureUrl") ?? "";
|
||||
}
|
||||
private get respDataFormat(): number {
|
||||
return this.config.get<number>('waafi.respDataFormat') ?? 1;
|
||||
return this.config.get<number>("waafi.respDataFormat") ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface ProviderInitiationInput {
|
||||
/** Optional caller-supplied redirect targets for redirect/HPP-style providers. */
|
||||
returnUrl?: string;
|
||||
redirectUrl?: string;
|
||||
/** Where the browser lands when the hosted page fails/cancels (UX only — never trusted). */
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export interface ProviderInitiationResult {
|
||||
@@ -94,11 +96,6 @@ export enum PaymentReferenceType {
|
||||
SHIPMENT = "SHIPMENT",
|
||||
}
|
||||
|
||||
/** `merchant_order_id` prefix per owning service — lets a webhook be routed before a DB lookup. */
|
||||
export const MERCHANT_ORDER_PREFIX: Record<PaymentService, string> = {
|
||||
[PaymentService.PASSENGER]: "PSG-",
|
||||
[PaymentService.FREIGHT]: "FRT-",
|
||||
};
|
||||
|
||||
/** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */
|
||||
export interface InitiatePaymentRequest {
|
||||
@@ -114,6 +111,16 @@ export interface InitiatePaymentRequest {
|
||||
provider: ProviderMethod;
|
||||
platform?: PaymentPlatform;
|
||||
payerAccount?: string;
|
||||
/**
|
||||
* Where the provider's hosted page sends the BROWSER back after success — each calling app
|
||||
* passes its own UI URL (passenger portal vs freight portal). Per-transaction and UX-only:
|
||||
* the redirect never confirms payment (only the webhook / status query does), so per-app
|
||||
* values are safe even though the server-to-server webhook URL is one per merchant.
|
||||
* Falls back to the payment service's provider config when omitted.
|
||||
*/
|
||||
returnUrl?: string;
|
||||
/** Failure/cancel counterpart of returnUrl. */
|
||||
failureUrl?: string;
|
||||
/** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user