Merge branch 'feat/payment-microservice' of github.com:Tria-plc/edr-platform into freight_feature/payments

This commit is contained in:
marshal
2026-06-14 02:38:19 +03:00
192 changed files with 15254 additions and 5671 deletions

View File

@@ -40,12 +40,28 @@ export type {
TelebirrTradeStatus,
} from './providers/telebirr/telebirr.types';
// Waafi HPP request/response types (exported for apps that build/inspect requests directly)
export type {
WaafiState,
WaafiHppPurchaseRequest,
WaafiHppPurchaseResponse,
WaafiGetTranInfoRequest,
WaafiGetTranInfoResponse,
} from './providers/waafi/waafi.types';
// Webhook payload types
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types';
export type {
WaafiWebhookPayload,
WaafiWebhookTransactionPayload,
WaafiWebhookTestPayload,
WaafiWebhookHeaders,
WaafiWebhookEvent,
WaafiWebhookStatus,
} from './webhooks/waafi-webhook.types';
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
// DI token for injecting all providers as an array (future multi-provider wiring)

View File

@@ -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") ?? "";
}
}

View File

@@ -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") ?? "";
}
}

View File

@@ -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 DMoneyAuthResponse {
token: string;
@@ -58,7 +58,9 @@ export class DMoneyProvider implements PaymentProvider {
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
const token = await this.getFabricToken();
const amount = (input.amountMinor / 100).toFixed(2);
const timestamp = new Date().toISOString();
@@ -95,8 +97,11 @@ export class DMoneyProvider implements PaymentProvider {
return {
providerOrderId: response.orderId,
clientAction: response.checkoutUrl
? { type: 'REDIRECT', url: response.checkoutUrl }
: { type: 'REDIRECT', url: `${this.baseUrl}/checkout/${response.orderId}` },
? { type: "REDIRECT", url: response.checkoutUrl }
: {
type: "REDIRECT",
url: `${this.baseUrl}/checkout/${response.orderId}`,
},
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
@@ -106,7 +111,7 @@ export class DMoneyProvider implements PaymentProvider {
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const token = await this.authenticate();
const token = await this.getFabricToken();
const timestamp = new Date().toISOString();
const signature = this.signRequest({
merchantId: this.merchantId,
@@ -130,14 +135,15 @@ export class DMoneyProvider 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(
@@ -152,17 +158,17 @@ export class DMoneyProvider 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 'CANCELLED':
case "FAILED":
case "REJECTED":
case "EXPIRED":
case "CANCELLED":
return ProviderPaymentStatus.FAILED;
case 'PENDING':
case "PENDING":
return ProviderPaymentStatus.REQUIRES_ACTION;
case 'PROCESSING':
case "PROCESSING":
return ProviderPaymentStatus.PROCESSING;
default:
return ProviderPaymentStatus.PROCESSING;
@@ -178,7 +184,9 @@ export class DMoneyProvider implements PaymentProvider {
);
if (!response.token) {
throw new Error(`DMoney authentication failed: ${JSON.stringify(response)}`);
throw new Error(
`DMoney authentication failed: ${JSON.stringify(response)}`,
);
}
return response.token;
@@ -186,22 +194,24 @@ export class DMoneyProvider 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, token?: string): Promise<T> {
private async postJson<T>(
url: string,
body: unknown,
token?: string,
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
"Content-Type": "application/json",
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
headers["Authorization"] = `Bearer ${token}`;
}
const config: AxiosRequestConfig = {
@@ -212,7 +222,9 @@ export class DMoneyProvider implements PaymentProvider {
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`DMoney POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
this.logger.debug(
`DMoney POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
@@ -220,7 +232,9 @@ export class DMoneyProvider implements PaymentProvider {
`DMoney POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`DMoney POST ${url} threw: ${err instanceof Error ? err.message : err}`);
this.logger.error(
`DMoney POST ${url} threw: ${err instanceof Error ? err.message : err}`,
);
}
throw err;
}
@@ -232,21 +246,21 @@ export class DMoneyProvider implements PaymentProvider {
}
private get baseUrl(): string {
return this.config.get<string>('dmoney.baseUrl') ?? '';
return this.config.get<string>("dmoney.baseUrl") ?? "";
}
private get merchantId(): string {
return this.config.get<string>('dmoney.merchantId') ?? '';
return this.config.get<string>("dmoney.merchantId") ?? "";
}
private get appSecret(): string {
return this.config.get<string>('dmoney.appSecret') ?? '';
return this.config.get<string>("dmoney.appSecret") ?? "";
}
private get secretKey(): string {
return this.config.get<string>('dmoney.secretKey') ?? '';
return this.config.get<string>("dmoney.secretKey") ?? "";
}
private get notifyUrl(): string {
return this.config.get<string>('dmoney.notifyUrl') ?? '';
return this.config.get<string>("dmoney.notifyUrl") ?? "";
}
private get returnUrl(): string {
return this.config.get<string>('dmoney.returnUrl') ?? '';
return this.config.get<string>("dmoney.returnUrl") ?? "";
}
}

View File

@@ -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") ?? "";
}
}

View File

@@ -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") ?? "";
}
}

View File

@@ -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,111 +8,70 @@ import {
ProviderStatus,
ProviderPaymentStatus,
ProviderMethod,
} from '@edr/types';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
} 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";
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
interface WaafiInitiateRequest {
schemaVersion: string;
requestId: string;
timestamp: string;
channelName: string;
serviceName: string;
serviceParams: {
merchantUid: string;
apiUserId: string;
apiKey: string;
paymentMethod: string;
payerInfo: {
accountNo: string;
};
transactionInfo: {
referenceId: string;
invoiceId: string;
amount: number;
currency: string;
description: string;
};
};
}
interface WaafiInitiateResponse {
responseCode: string;
responseMsg: string;
params?: {
state: string;
referenceId: string;
transactionId: string;
checkoutUrl?: string;
};
}
interface WaafiQueryRequest {
schemaVersion: string;
requestId: string;
timestamp: string;
channelName: string;
serviceName: string;
serviceParams: {
merchantUid: string;
apiUserId: string;
apiKey: string;
transactionId?: string;
referenceId?: string;
};
}
interface WaafiQueryResponse {
responseCode: string;
responseMsg: string;
params?: {
state: string;
referenceId: string;
transactionId: string;
amount: number;
currency: string;
paidAmount?: number;
};
}
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;
@Injectable()
export class WaafiProvider implements PaymentProvider {
readonly method = ProviderMethod.WAAFI;
private readonly logger = new Logger(WaafiProvider.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
) {
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.",
);
}
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const requestBody = this.buildInitiateRequest(input);
const response = await this.postJson<WaafiInitiateResponse>(
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
const requestBody = this.buildPurchaseRequest(input);
const response = await this.postJson<WaafiHppPurchaseResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
if (response.responseCode !== '2001') {
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
throw new Error(
`Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`,
`Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`,
);
}
const transactionId = response.params?.transactionId;
const checkoutUrl = response.params?.checkoutUrl || `${this.baseUrl}/checkout?ref=${transactionId}`;
if (!transactionId) {
throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`);
const checkoutUrl =
response.params?.hppUrl ?? response.params?.directPaymentLink;
const orderId = response.params?.orderId;
if (!checkoutUrl || !orderId) {
throw new Error(
`Waafi HPP_PURCHASE succeeded but returned no hppUrl/orderId: ${JSON.stringify(response)}`,
);
}
const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes
return {
providerOrderId: transactionId,
clientAction: { type: 'REDIRECT', url: checkoutUrl },
expiresAt,
providerOrderId: orderId,
clientAction: { type: "REDIRECT", url: checkoutUrl },
expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS),
rawInitiation: {
request: this.sanitize(requestBody),
response,
@@ -121,114 +80,159 @@ export class WaafiProvider implements PaymentProvider {
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const requestBody = this.buildQueryRequest(merchantOrderId);
const response = await this.postJson<WaafiQueryResponse>(
const requestBody = this.buildGetTranInfoRequest(merchantOrderId);
const response = await this.postJson<WaafiGetTranInfoResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
const state = response.params?.state;
const rawState = response.params?.status ?? response.params?.tranStatusDesc;
const transactionId = response.params?.transactionId;
const mapped = this.mapState(state);
const mapped = this.mapStatus(rawState);
return {
status: mapped,
providerTxnId: transactionId,
failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined,
failureCode:
mapped === ProviderPaymentStatus.FAILED && rawState
? rawState
: undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
mapState(state: string | undefined): ProviderPaymentStatus {
switch (state) {
case 'APPROVED':
case 'SUCCESS':
/** Map a webhook `payment.status` to the shared status enum. */
mapWebhookStatus(status: string | undefined): ProviderPaymentStatus {
return this.mapStatus(status);
}
/**
* Verify an HMAC-SHA256 webhook signature.
*
* Signing string is `{timestamp}.{eventId}.{rawBody}` over the *raw* request body bytes — the
* caller must pass the unparsed body string. Returns false (never throws) on any mismatch so
* callers can treat verification as a boolean gate.
*/
verifyWebhookSignature(
rawBody: string,
signature: string | undefined,
timestamp: string | undefined,
eventId: string | undefined,
): boolean {
if (!this.webhookSecret) {
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",
);
return false;
}
const signingString = `${timestamp}.${eventId}.${rawBody}`;
const expected = crypto
.createHmac("sha256", this.webhookSecret)
.update(signingString)
.digest("hex");
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":
return ProviderPaymentStatus.SUCCEEDED;
case 'FAILED':
case 'DECLINED':
case 'CANCELLED':
case 'EXPIRED':
case "CANCELED":
case "CANCELLED":
return ProviderPaymentStatus.CANCELLED;
case "DECLINED":
case "FAILED":
case "EXPIRED":
case "TIMEOUT":
return ProviderPaymentStatus.FAILED;
case 'PENDING':
case 'INITIATED':
case "PENDING":
case "INITIATED":
return ProviderPaymentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return ProviderPaymentStatus.PROCESSING;
default:
return ProviderPaymentStatus.PROCESSING;
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
// Waafi webhook signature verification
// Implementation depends on Waafi's webhook signature mechanism
const signature = payload.signature as string;
const apiKey = this.apiKey;
if (!signature || !apiKey) {
this.logger.error('Waafi webhook missing signature or API key not configured');
return false;
}
// TODO: Implement actual signature verification based on Waafi documentation
// For now, basic validation
return signature.length > 0;
}
private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest {
const amount = input.amountMinor / 100; // Convert minor units to major
private buildPurchaseRequest(
input: ProviderInitiationInput,
): WaafiHppPurchaseRequest {
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
channelName: 'WEB',
serviceName: 'API_PURCHASE',
schemaVersion: "1.0",
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: "WEB",
serviceName: "HPP_PURCHASE",
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
paymentMethod: 'MWALLET_ACCOUNT',
payerInfo: {
accountNo: 'CUSTOMER', // Customer enters their number on Waafi page
},
storeId: this.storeId,
hppKey: this.hppKey,
paymentMethod: this.paymentMethod,
// 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.
...(input.payerAccount
? { payerInfo: { subscriptionId: input.payerAccount } }
: {}),
transactionInfo: {
referenceId: input.merchantOrderId,
invoiceId: input.orderRef,
amount,
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
amount: this.toAmount(input.amountMinor),
// Waafi has no ETB; `waafi.currency` overrides the booking currency when set.
currency: this.currency || input.currency,
description: `EDR ${input.orderRef}`,
},
},
};
}
private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest {
private buildGetTranInfoRequest(
merchantOrderId: string,
): WaafiGetTranInfoRequest {
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
channelName: 'WEB',
serviceName: 'API_QUERY',
schemaVersion: "1.0",
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: "WEB",
serviceName: "HPP_GETTRANINFO",
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
storeId: this.storeId,
hppKey: this.hppKey,
referenceId: merchantOrderId,
},
};
}
private generateRequestId(): string {
return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
/** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */
private toAmount(amountMinor: number): number {
return Math.trunc(amountMinor) / 100;
}
private timestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
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,
};
const started = Date.now();
@@ -252,24 +256,43 @@ export class WaafiProvider implements PaymentProvider {
}
}
private sanitize(body: WaafiInitiateRequest): Record<string, unknown> {
const sanitized = { ...body };
if (sanitized.serviceParams?.apiKey) {
sanitized.serviceParams.apiKey = '***REDACTED***';
}
return sanitized as unknown as Record<string, unknown>;
private sanitize(body: WaafiHppPurchaseRequest): Record<string, unknown> {
return {
...body,
serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" },
};
}
private get baseUrl(): string {
return this.config.get<string>('waafi.baseUrl') ?? 'https://api.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 apiUserId(): string {
return this.config.get<string>('waafi.apiUserId') ?? '';
private get storeId(): string {
return this.config.get<string>("waafi.storeId") ?? "";
}
private get apiKey(): string {
return this.config.get<string>('waafi.apiKey') ?? '';
private get hppKey(): string {
return this.config.get<string>("waafi.hppKey") ?? "";
}
private get webhookSecret(): string {
return this.config.get<string>("waafi.webhookSecret") ?? "";
}
private get paymentMethod(): string {
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
}
private get currency(): string {
return this.config.get<string>("waafi.currency") ?? "";
}
private get successUrl(): string {
return this.config.get<string>("waafi.successUrl") ?? "";
}
private get failureUrl(): string {
return this.config.get<string>("waafi.failureUrl") ?? "";
}
private get respDataFormat(): number {
return this.config.get<number>("waafi.respDataFormat") ?? 1;
}
}

View File

@@ -0,0 +1,103 @@
/**
* WaafiPay (Hosted Payment Page) request/response types.
*
* WaafiPay multiplexes every operation through a single `POST /asm` endpoint, discriminated by
* `serviceName`. We use the HPP family (`HPP_PURCHASE`, `HPP_GETTRANINFO`) which returns a hosted
* redirect URL and supports webhooks — see docs/waffi/intro.md.
*/
/** Terminal/intermediate transaction states reported by Waafi (sync `state` / `HPP_GETTRANINFO`). */
export type WaafiState =
| 'APPROVED'
| 'DECLINED'
| 'FAILED'
| 'CANCELED'
| 'EXPIRED'
| 'TIMEOUT'
| string;
/** Common request envelope shared by every `/asm` call. */
export interface WaafiRequestEnvelope<TServiceParams> {
schemaVersion: '1.0';
requestId: string;
timestamp: string;
channelName: 'WEB';
serviceName: string;
serviceParams: TServiceParams;
}
/** Common response envelope. `responseCode === '2001'` means the request was processed (not paid). */
export interface WaafiResponseEnvelope<TParams> {
schemaVersion: string;
timestamp: string;
responseId: string;
responseCode: string;
errorCode: string;
responseMsg: string;
params?: TParams;
}
// --- HPP_PURCHASE -----------------------------------------------------------------------------
export interface WaafiHppPurchaseServiceParams {
merchantUid: string;
storeId: string;
hppKey: string;
paymentMethod: string;
hppSuccessCallbackUrl: string;
hppFailureCallbackUrl: string;
/** Callback data format: 1 = POST, 2 = GET, 4 = Result Token. */
hppRespDataFormat: number;
/** Required for MWALLET_ACCOUNT — pre-fills (and locks) the payer's phone on the hosted page. */
payerInfo?: {
subscriptionId: string;
};
transactionInfo: {
referenceId: string;
amount: number;
currency: string;
description?: string;
};
}
export type WaafiHppPurchaseRequest = WaafiRequestEnvelope<WaafiHppPurchaseServiceParams>;
export interface WaafiHppPurchaseParams {
hppUrl: string;
directPaymentLink?: string;
orderId: string;
referenceId: string;
}
export type WaafiHppPurchaseResponse = WaafiResponseEnvelope<WaafiHppPurchaseParams>;
// --- HPP_GETTRANINFO --------------------------------------------------------------------------
export interface WaafiGetTranInfoServiceParams {
merchantUid: string;
storeId: string;
hppKey: string;
/** Either the merchant referenceId or the Waafi transactionId may be supplied. */
referenceId?: string;
transactionId?: string;
}
export type WaafiGetTranInfoRequest = WaafiRequestEnvelope<WaafiGetTranInfoServiceParams>;
export interface WaafiGetTranInfoParams {
tranStatusDesc?: string;
amount?: string;
payerId?: string;
paymentMethod?: string;
description?: string;
tranDate?: string;
currency?: string;
invoiceId?: string;
referenceId?: string;
tranAmount?: string;
transactionId?: string;
tranStatusId?: string;
status?: WaafiState;
}
export type WaafiGetTranInfoResponse = WaafiResponseEnvelope<WaafiGetTranInfoParams>;

View File

@@ -1,15 +1,66 @@
export interface WaafiWebhookPayload {
schemaVersion: string;
requestId: string;
timestamp: string;
eventType: string;
params: {
state: string;
referenceId: string;
transactionId: string;
amount: number;
currency: string;
description?: string;
};
signature?: string;
/**
* WaafiPay webhook payloads (HPP authorization / refund / test).
*
* Webhooks are HMAC-SHA256 signed over `{timestamp}.{event_id}.{raw_body}` except `webhook.test`,
* which is unsigned and only sent to validate endpoint reachability. See docs/waffi/intro.md.
*/
export type WaafiWebhookEvent = 'authorization' | 'refund' | 'webhook.test';
export type WaafiWebhookStatus =
| 'APPROVED'
| 'FAILED'
| 'DECLINED'
| 'CANCELED'
| 'EXPIRED'
| 'TIMEOUT'
| string;
/** Headers Waafi sends alongside signed webhooks (lowercased, as exposed by NestJS). */
export interface WaafiWebhookHeaders {
'x-webhook-timestamp'?: string;
'x-webhook-event-id'?: string;
'x-webhook-signature'?: string;
'x-webhook-signature-alg'?: string;
}
/** Nested payment object present on `authorization` and `refund` events. */
export interface WaafiWebhookPayment {
transaction_id: string;
/** Present on authorization events (optional). */
order_id?: string;
transfer_code: string;
amount: number;
currency: string;
/** Present on authorization events. */
payment_method?: string;
status: WaafiWebhookStatus;
/** Our merchantOrderId. */
reference_id: string;
/** Present on authorization events. */
channel?: string;
description?: string;
date: string;
}
/** Unsigned validation ping sent on webhook registration/update. */
export interface WaafiWebhookTestPayload {
event: 'webhook.test';
message?: string;
merchant_uid: string;
}
/** Real transaction notification (authorization or refund). */
export interface WaafiWebhookTransactionPayload {
event: 'authorization' | 'refund';
merchant_id: number;
merchant_uid: string;
user_id: string;
/** Authorization only. */
customer_identity?: string;
/** Authorization only (optional). */
cardholder_name?: string;
payment: WaafiWebhookPayment;
}
export type WaafiWebhookPayload = WaafiWebhookTestPayload | WaafiWebhookTransactionPayload;

View File

@@ -1,4 +1,5 @@
export * from "./payments";
export * from "./payment-messaging";
export interface BaseEntity {
id: string;

View File

@@ -0,0 +1,55 @@
/* ------------------------------------------------------------------------------------------------
* Payment event messaging contract (RabbitMQ)
*
* The single source of truth for the broker topology shared between the payment microservice
* (publisher) and the domain apps (consumers). Both sides import these constants/helpers so the
* exchange name, routing keys, and queue names can never drift apart.
*
* Topology (see docs/payment-service/rabbitmq/):
* exchange payment.events (topic, durable) ← every payment event is published here
* exchange payment.events.dlx (topic, durable) ← dead-letter for events a consumer rejects
* routing payment.<service>.<outcome> e.g. payment.passenger.succeeded
* queue <service>.payment-events bound to payment.<service>.*
* queue <service>.payment-events.dlq dead-letter queue (bound on the dlx)
* ---------------------------------------------------------------------------------------------- */
import { PaymentEventType, PaymentService } from "./payments";
/** Topic exchange every payment event is published to. */
export const PAYMENT_EVENTS_EXCHANGE = "payment.events";
/** Dead-letter exchange for payment events a consumer could not process (poison messages). */
export const PAYMENT_EVENTS_DLX = "payment.events.dlx";
/**
* Routing key for a payment event: `payment.<service>.<outcome>`.
* e.g. `payment.passenger.succeeded`, `payment.freight.failed`.
*/
export function paymentRoutingKey(
service: PaymentService,
eventType: PaymentEventType,
): string {
// "payment.succeeded" -> "succeeded", "payment.failed" -> "failed"
const outcome = eventType.split(".")[1];
return `payment.${service.toLowerCase()}.${outcome}`;
}
/** Binding pattern a service's queue uses so it receives only its own events. */
export function paymentServiceBindingPattern(service: PaymentService): string {
return `payment.${service.toLowerCase()}.*`;
}
/** Durable queue names per owning service: the main work queue and its dead-letter queue. */
export const PAYMENT_QUEUES: Record<
PaymentService,
{ main: string; dlq: string }
> = {
[PaymentService.PASSENGER]: {
main: "passenger.payment-events",
dlq: "passenger.payment-events.dlq",
},
[PaymentService.FREIGHT]: {
main: "freight.payment-events",
dlq: "freight.payment-events.dlq",
},
};

View File

@@ -43,8 +43,17 @@ export interface ProviderInitiationInput {
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
returnUrl?: string
redirectUrl?: string
/**
* Payer account identifier (e.g. mobile-wallet MSISDN in full international format).
* Optional and provider-specific: some wallet providers (e.g. Waafi HPP with
* MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page.
*/
payerAccount?: string;
/** 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 {
@@ -67,3 +76,103 @@ export interface PaymentProvider {
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}
/* ------------------------------------------------------------------------------------------------
* Payment microservice contracts (docs/payment-service)
*
* Shared shapes exchanged between the payment microservice (apps/edr-payment-api) and the
* domain apps (passenger/freight). Both sides import these so the wire format cannot drift.
* ---------------------------------------------------------------------------------------------- */
/** Which domain app owns the order being paid for. Routing discriminator on every intent. */
export enum PaymentService {
PASSENGER = "PASSENGER",
FREIGHT = "FREIGHT",
}
/** What kind of domain order the intent references (soft reference — never a cross-schema FK). */
export enum PaymentReferenceType {
BOOKING = "BOOKING",
SHIPMENT = "SHIPMENT",
}
/** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */
export interface InitiatePaymentRequest {
service: PaymentService;
referenceType: PaymentReferenceType;
/** Domain order id (booking/shipment id). Soft reference; the app has already validated it. */
referenceId: string;
/** Human-readable order ref (e.g. booking ref) shown on provider pages. Defaults to referenceId. */
orderRef?: string;
/** App-asserted authoritative amount in minor units (computed server-side by the domain app). */
amountMinor: number;
currency: string;
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;
}
/** Response of `POST /payments/initiate` and shape of intent lookups. */
export interface PaymentIntentSnapshot {
intentId: string;
service: PaymentService;
referenceType: PaymentReferenceType;
referenceId: string;
merchantOrderId: string;
provider: ProviderMethod;
status: ProviderPaymentStatus;
amountMinor: number;
currency: string;
clientAction?: ClientAction;
providerTxnId?: string;
paidAt?: string;
failureCode?: string;
failureMessage?: string;
expiresAt?: string;
}
export type PaymentEventType = "payment.succeeded" | "payment.failed";
/** Versioned envelope delivered (at-least-once) to the owning app's mark-paid consumer. */
interface PaymentEventBase {
version: 1;
/** Outbox row id — stable across redeliveries; consumers may use it as a dedupe key. */
eventId: string;
eventType: PaymentEventType;
occurredAt: string;
service: PaymentService;
intentId: string;
referenceType: PaymentReferenceType;
referenceId: string;
merchantOrderId: string;
provider: ProviderMethod;
amountMinor: number;
currency: string;
}
export interface PaymentSucceededEvent extends PaymentEventBase {
eventType: "payment.succeeded";
providerTxnId?: string;
paidAt: string;
}
export interface PaymentFailedEvent extends PaymentEventBase {
eventType: "payment.failed";
failureCode?: string;
failureMessage?: string;
}
export type PaymentEvent = PaymentSucceededEvent | PaymentFailedEvent;