mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
229 lines
6.2 KiB
TypeScript
229 lines
6.2 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { HttpService } from '@nestjs/axios';
|
|
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
import { AxiosError, AxiosRequestConfig } from 'axios';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import * as crypto from 'node:crypto';
|
|
import {
|
|
PaymentProvider,
|
|
ProviderInitiationInput,
|
|
ProviderInitiationResult,
|
|
ProviderStatus,
|
|
} from '../payments.types';
|
|
|
|
interface EBirrInitiateRequest {
|
|
merchantCode: string;
|
|
orderNo: string;
|
|
amount: number;
|
|
currency: string;
|
|
subject: string;
|
|
body: string;
|
|
notifyUrl: string;
|
|
returnUrl: string;
|
|
timestamp: number;
|
|
sign: string;
|
|
}
|
|
|
|
interface EBirrInitiateResponse {
|
|
code: string;
|
|
message: string;
|
|
data?: {
|
|
orderNo: string;
|
|
payUrl: string;
|
|
expireTime: number;
|
|
};
|
|
}
|
|
|
|
interface EBirrQueryResponse {
|
|
code: string;
|
|
message: string;
|
|
data?: {
|
|
orderNo: string;
|
|
tradeStatus: string;
|
|
tradeNo?: string;
|
|
totalAmount?: number;
|
|
payTime?: number;
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class EBirrProvider implements PaymentProvider {
|
|
readonly method = PaymentMethodType.EBIRR;
|
|
private readonly logger = new Logger(EBirrProvider.name);
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
private readonly http: HttpService,
|
|
) {}
|
|
|
|
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
|
const amount = input.amountMinor / 100;
|
|
const timestamp = Date.now();
|
|
|
|
const requestBody: EBirrInitiateRequest = {
|
|
merchantCode: this.merchantCode,
|
|
orderNo: input.merchantOrderId,
|
|
amount,
|
|
currency: input.currency,
|
|
subject: `EDR Ticket`,
|
|
body: `Train booking ${input.bookingRef}`,
|
|
notifyUrl: this.notifyUrl,
|
|
returnUrl: this.returnUrl,
|
|
timestamp,
|
|
sign: this.signRequest({
|
|
merchantCode: this.merchantCode,
|
|
orderNo: input.merchantOrderId,
|
|
amount,
|
|
timestamp,
|
|
}),
|
|
};
|
|
|
|
const response = await this.postJson<EBirrInitiateResponse>(
|
|
`${this.baseUrl}/gateway/api/pay/create`,
|
|
requestBody,
|
|
);
|
|
|
|
if (response.code !== '0000' || !response.data?.orderNo) {
|
|
throw new Error(`eBirr initiate failed: ${response.message}`);
|
|
}
|
|
|
|
const expiresAt = new Date(response.data.expireTime);
|
|
|
|
return {
|
|
providerOrderId: response.data.orderNo,
|
|
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
|
|
expiresAt,
|
|
rawInitiation: {
|
|
request: this.sanitize(requestBody),
|
|
response,
|
|
},
|
|
};
|
|
}
|
|
|
|
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
|
const timestamp = Date.now();
|
|
const requestBody = {
|
|
merchantCode: this.merchantCode,
|
|
orderNo: merchantOrderId,
|
|
timestamp,
|
|
sign: this.signRequest({
|
|
merchantCode: this.merchantCode,
|
|
orderNo: merchantOrderId,
|
|
timestamp,
|
|
}),
|
|
};
|
|
|
|
const response = await this.postJson<EBirrQueryResponse>(
|
|
`${this.baseUrl}/gateway/api/pay/query`,
|
|
requestBody,
|
|
);
|
|
|
|
if (response.code !== '0000' || !response.data) {
|
|
throw new Error(`eBirr query failed: ${response.message}`);
|
|
}
|
|
|
|
const mapped = this.mapStatus(response.data.tradeStatus);
|
|
|
|
return {
|
|
status: mapped,
|
|
providerTxnId: response.data.tradeNo,
|
|
failureCode: mapped === PaymentIntentStatus.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;
|
|
|
|
const expectedSign = this.signRequest(data);
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(sign),
|
|
Buffer.from(expectedSign),
|
|
);
|
|
}
|
|
|
|
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
|
|
return this.mapStatus(tradeStatus);
|
|
}
|
|
|
|
private mapStatus(tradeStatus: string): PaymentIntentStatus {
|
|
switch (tradeStatus?.toUpperCase()) {
|
|
case 'TRADE_SUCCESS':
|
|
case 'SUCCESS':
|
|
return PaymentIntentStatus.SUCCEEDED;
|
|
case 'TRADE_CLOSED':
|
|
case 'TRADE_FAILED':
|
|
case 'FAILED':
|
|
return PaymentIntentStatus.FAILED;
|
|
case 'WAIT_BUYER_PAY':
|
|
case 'PENDING':
|
|
return PaymentIntentStatus.REQUIRES_ACTION;
|
|
case 'PROCESSING':
|
|
return PaymentIntentStatus.PROCESSING;
|
|
default:
|
|
return PaymentIntentStatus.PROCESSING;
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
|
|
return crypto
|
|
.createHash('md5')
|
|
.update(signString)
|
|
.digest('hex')
|
|
.toUpperCase();
|
|
}
|
|
|
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
|
const config: AxiosRequestConfig = {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout: 10_000,
|
|
};
|
|
|
|
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`);
|
|
return res.data;
|
|
} catch (err) {
|
|
if (err instanceof AxiosError) {
|
|
this.logger.error(
|
|
`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}`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
|
|
const { sign: _sign, ...rest } = body;
|
|
return rest;
|
|
}
|
|
|
|
private get baseUrl(): string {
|
|
return this.config.get<string>('ebirr.baseUrl') ?? '';
|
|
}
|
|
private get merchantCode(): string {
|
|
return this.config.get<string>('ebirr.merchantCode') ?? '';
|
|
}
|
|
private get secretKey(): string {
|
|
return this.config.get<string>('ebirr.secretKey') ?? '';
|
|
}
|
|
private get notifyUrl(): string {
|
|
return this.config.get<string>('ebirr.notifyUrl') ?? '';
|
|
}
|
|
private get returnUrl(): string {
|
|
return this.config.get<string>('ebirr.returnUrl') ?? '';
|
|
}
|
|
}
|