mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
Warehouse Enhancemendt
This commit is contained in:
@@ -20,6 +20,7 @@ export { EBirrProvider } from './providers/ebirr/ebirr.provider';
|
||||
export { CardProvider } from './providers/card/card.provider';
|
||||
export { WaafiProvider } from './providers/waafi/waafi.provider';
|
||||
export { DMoneyProvider } from './providers/dmoney/dmoney.provider';
|
||||
export { CacBankProvider } from './providers/cac-bank/cac-bank.provider';
|
||||
|
||||
// Telebirr crypto + types (exported for apps that build/verify signatures directly)
|
||||
export {
|
||||
@@ -40,12 +41,51 @@ export type {
|
||||
TelebirrTradeStatus,
|
||||
} from './providers/telebirr/telebirr.types';
|
||||
|
||||
// D-Money request/response types (exported for apps that build/inspect requests directly)
|
||||
export type {
|
||||
DMoneyFabricTokenResponse,
|
||||
DMoneyPreOrderBizContent,
|
||||
DMoneyPreOrderRequest,
|
||||
DMoneyPreOrderResponse,
|
||||
DMoneyQueryOrderResponse,
|
||||
DMoneyOrderStatus,
|
||||
} from './providers/dmoney/dmoney.types';
|
||||
|
||||
// Waafi HPP request/response types (exported for apps that build/inspect requests directly)
|
||||
export type {
|
||||
WaafiState,
|
||||
WaafiHppPurchaseRequest,
|
||||
WaafiHppPurchaseResponse,
|
||||
WaafiGetTranInfoRequest,
|
||||
WaafiGetTranInfoResponse,
|
||||
} from './providers/waafi/waafi.types';
|
||||
|
||||
// CAC Bank request/response types
|
||||
export type {
|
||||
CacSigninRequest,
|
||||
CacSigninResponse,
|
||||
CacPaymentInitiateRequest,
|
||||
CacPaymentInitiateResponse,
|
||||
CacPaymentConfirmRequest,
|
||||
CacPaymentConfirmResponse,
|
||||
CacGetPaymentByReferenceRequest,
|
||||
CacPaymentByReferenceResponse,
|
||||
CacConfirmResult,
|
||||
} from './providers/cac-bank/cac-bank.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)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import type { CacSigninRequest, CacSigninResponse } from "./cac-bank.types";
|
||||
|
||||
interface TokenCache {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CacAuthConfig {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
tokenTtlMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory JWT cache for CAC Bank. Tokens are valid 24h per the API docs;
|
||||
* we refresh proactively before expiry.
|
||||
*/
|
||||
export class CacBankAuth {
|
||||
private readonly logger = new Logger(CacBankAuth.name);
|
||||
private cache: TokenCache | null = null;
|
||||
private signinInFlight: Promise<string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
private readonly config: CacAuthConfig,
|
||||
) {}
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (this.cache && Date.now() < this.cache.expiresAt) {
|
||||
return this.cache.accessToken;
|
||||
}
|
||||
return this.signin();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
private async signin(): Promise<string> {
|
||||
if (this.signinInFlight) return this.signinInFlight;
|
||||
|
||||
this.signinInFlight = this.doSignin();
|
||||
try {
|
||||
return await this.signinInFlight;
|
||||
} finally {
|
||||
this.signinInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async doSignin(): Promise<string> {
|
||||
const body: CacSigninRequest = {
|
||||
username: this.config.username,
|
||||
password: this.config.password,
|
||||
};
|
||||
const url = `${this.config.baseUrl}/paymentapi/auth/signin`;
|
||||
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<CacSigninResponse>(url, body, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
const token = res.data.accessToken;
|
||||
if (!token) {
|
||||
throw new Error("CAC signin returned no accessToken");
|
||||
}
|
||||
this.cache = {
|
||||
accessToken: token,
|
||||
expiresAt: Date.now() + this.config.tokenTtlMs,
|
||||
};
|
||||
this.logger.debug("CAC signin succeeded; token cached");
|
||||
return token;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`CAC signin failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { CacBankAuth } from "./cac-bank.auth";
|
||||
import type {
|
||||
CacConfirmResult,
|
||||
CacGetPaymentByReferenceRequest,
|
||||
CacPaymentByReferenceResponse,
|
||||
CacPaymentConfirmRequest,
|
||||
CacPaymentConfirmResponse,
|
||||
CacPaymentInitiateRequest,
|
||||
CacPaymentInitiateResponse,
|
||||
} from "./cac-bank.types";
|
||||
|
||||
@Injectable()
|
||||
export class CacBankProvider implements PaymentProvider {
|
||||
readonly method = ProviderMethod.CAC_BANK;
|
||||
private readonly logger = new Logger(CacBankProvider.name);
|
||||
private auth: CacBankAuth | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
if (!input.payerAccount) {
|
||||
throw new Error("CAC Bank requires payerAccount (customer mobile number)");
|
||||
}
|
||||
|
||||
const requestBody: CacPaymentInitiateRequest = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
customer_mobile: input.payerAccount,
|
||||
currency: input.currency || this.defaultCurrency,
|
||||
desc: `EDR ${input.orderRef}`.slice(0, 500),
|
||||
vender_ref: input.merchantOrderId,
|
||||
amount: this.toMajorAmount(input.amountMinor, input.currency),
|
||||
company_services_id: this.companyServicesId,
|
||||
};
|
||||
|
||||
const response = await this.postJson<CacPaymentInitiateResponse>(
|
||||
"/paymentapi/PaymentInitiateRequest",
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.paymentRequestId == null) {
|
||||
throw new Error(
|
||||
`CAC Bank initiate failed: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const providerOrderId = String(response.paymentRequestId);
|
||||
const expiresAt = new Date(Date.now() + this.otpExpiryMs);
|
||||
|
||||
return {
|
||||
providerOrderId,
|
||||
clientAction: {
|
||||
type: "COLLECT_OTP",
|
||||
providerOrderId,
|
||||
message: "Enter the OTP sent to your phone",
|
||||
},
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitizeKeys(requestBody),
|
||||
response,
|
||||
venderRef: input.merchantOrderId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async confirmPayment(
|
||||
paymentRequestId: string,
|
||||
otp: string,
|
||||
): Promise<CacConfirmResult> {
|
||||
const requestBody: CacPaymentConfirmRequest = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
payment_request_id: Number(paymentRequestId),
|
||||
otp,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.postJson<CacPaymentConfirmResponse>(
|
||||
"/paymentapi/PaymentConfirmationRequest",
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.confirmReference == null && !response.reference) {
|
||||
return {
|
||||
status: "FAILED",
|
||||
failureCode: "CONFIRM_REJECTED",
|
||||
failureMessage: response.description ?? "Confirmation rejected",
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "SUCCEEDED",
|
||||
providerTxnId: String(
|
||||
response.confirmReference ?? response.reference,
|
||||
),
|
||||
reference: response.reference,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
status: "FAILED",
|
||||
failureCode: "CONFIRM_ERROR",
|
||||
failureMessage: message,
|
||||
rawResponse: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async queryStatus(
|
||||
merchantOrderId: string,
|
||||
reference?: string,
|
||||
): Promise<ProviderStatus> {
|
||||
const lookupRef = reference ?? merchantOrderId;
|
||||
const requestBody: CacGetPaymentByReferenceRequest = {
|
||||
app_key: this.appKey,
|
||||
api_key: this.apiKey,
|
||||
reference: lookupRef,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.postJson<CacPaymentByReferenceResponse>(
|
||||
"/paymentapi/GetPaymentByReferenceRequest",
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.transactionNo != null && response.transactionDate) {
|
||||
return {
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: String(response.transactionNo),
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
return {
|
||||
status: ProviderPaymentStatus.PROCESSING,
|
||||
rawResponse: { notFound: true, reference: lookupRef },
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const token = await this.getAuth().getAccessToken();
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(
|
||||
`CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 401) {
|
||||
this.getAuth().invalidate();
|
||||
const retryToken = await this.getAuth().getAccessToken();
|
||||
const retryConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${retryToken}`,
|
||||
},
|
||||
};
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<T>(url, body, retryConfig),
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`CAC Bank POST ${path} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`CAC Bank POST ${path} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuth(): CacBankAuth {
|
||||
if (!this.auth) {
|
||||
this.auth = new CacBankAuth(this.http, {
|
||||
baseUrl: this.baseUrl,
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
tokenTtlMs: this.tokenTtlMs,
|
||||
});
|
||||
}
|
||||
return this.auth;
|
||||
}
|
||||
|
||||
/** DJF has no fractional units — amountMinor is the major amount. */
|
||||
private toMajorAmount(amountMinor: number, currency: string): number {
|
||||
if (currency.toUpperCase() === "DJF") {
|
||||
return amountMinor;
|
||||
}
|
||||
return amountMinor / 100;
|
||||
}
|
||||
|
||||
private sanitizeKeys(
|
||||
body: CacPaymentInitiateRequest | CacPaymentConfirmRequest,
|
||||
): Record<string, unknown> {
|
||||
const { app_key: _appKey, api_key: _apiKey, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return (this.config.get<string>("cac.baseUrl") ?? "").replace(/\/$/, "");
|
||||
}
|
||||
private get username(): string {
|
||||
return this.config.get<string>("cac.username") ?? "";
|
||||
}
|
||||
private get password(): string {
|
||||
return this.config.get<string>("cac.password") ?? "";
|
||||
}
|
||||
private get appKey(): string {
|
||||
return this.config.get<string>("cac.appKey") ?? "";
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>("cac.apiKey") ?? "";
|
||||
}
|
||||
private get companyServicesId(): number {
|
||||
return this.config.get<number>("cac.companyServicesId") ?? 0;
|
||||
}
|
||||
private get defaultCurrency(): string {
|
||||
return this.config.get<string>("cac.currency") ?? "DJF";
|
||||
}
|
||||
private get tokenTtlMs(): number {
|
||||
return this.config.get<number>("cac.tokenTtlMs") ?? 23 * 60 * 60 * 1000;
|
||||
}
|
||||
private get otpExpiryMs(): number {
|
||||
return this.config.get<number>("cac.otpExpiryMs") ?? 10 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface CacSigninRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface CacSigninResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
export interface CacPaymentInitiateRequest {
|
||||
app_key: string;
|
||||
api_key: string;
|
||||
customer_mobile: string;
|
||||
currency: string;
|
||||
desc?: string;
|
||||
vender_ref?: string;
|
||||
amount: number;
|
||||
company_services_id: number;
|
||||
}
|
||||
|
||||
export interface CacPaymentInitiateResponse {
|
||||
description: string;
|
||||
paymentRequestId: number;
|
||||
}
|
||||
|
||||
export interface CacPaymentConfirmRequest {
|
||||
app_key: string;
|
||||
api_key: string;
|
||||
payment_request_id: number;
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export interface CacPaymentConfirmResponse {
|
||||
description: string;
|
||||
confirmReference: number;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
export interface CacGetPaymentByReferenceRequest {
|
||||
app_key: string;
|
||||
api_key: string;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
export interface CacPaymentByReferenceResponse {
|
||||
description: string;
|
||||
customerName?: string;
|
||||
reference: string;
|
||||
amount: number;
|
||||
transactionDate: string;
|
||||
transactionNo: number;
|
||||
}
|
||||
|
||||
export interface CacConfirmResult {
|
||||
status: "SUCCEEDED" | "FAILED";
|
||||
providerTxnId?: string;
|
||||
reference?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
rawResponse: Record<string, unknown>;
|
||||
}
|
||||
@@ -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") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,95 +8,86 @@ 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 https from "node:https";
|
||||
import {
|
||||
createNonceStr,
|
||||
createTimestamp,
|
||||
signRequestObject,
|
||||
verifyRequestObject,
|
||||
} from "../telebirr/telebirr.crypto";
|
||||
import {
|
||||
DMoneyFabricTokenResponse,
|
||||
DMoneyPreOrderRequest,
|
||||
DMoneyPreOrderResponse,
|
||||
DMoneyQueryOrderResponse,
|
||||
} from "./dmoney.types";
|
||||
|
||||
interface DMoneyAuthResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface DMoneyInitiateRequest {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
returnUrl: string;
|
||||
notifyUrl: string;
|
||||
payerPhone?: string;
|
||||
timestamp: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface DMoneyInitiateResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
checkoutUrl?: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface DMoneyQueryResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
paidAt?: string;
|
||||
payerPhone?: string;
|
||||
}
|
||||
const DMONEY_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* D-Money (Djibouti) shares the same payment-gateway platform as Telebirr: fabric-token auth,
|
||||
* payment.preorder / payment.queryorder, SHA256withRSA (PSS) signing, and a signed paygate
|
||||
* web-checkout redirect. This provider mirrors TelebirrProvider, differing only in endpoint
|
||||
* paths, the already-"Bearer"-prefixed token, the queryOrder status field (order_status), and
|
||||
* the web-only client action (no LAUNCH_APP). Crypto is reused from telebirr.crypto (RSA-PSS).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DMoneyProvider implements PaymentProvider {
|
||||
readonly method = ProviderMethod.DMONEY;
|
||||
private readonly logger = new Logger(DMoneyProvider.name);
|
||||
private readonly httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
) {
|
||||
const insecure = this.config.get<boolean>("dmoney.insecureTls");
|
||||
if (insecure) {
|
||||
this.logger.warn(
|
||||
"DMONEY_INSECURE_TLS=true — TLS verification disabled for D-Money calls. DEV ONLY.",
|
||||
);
|
||||
}
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: !insecure,
|
||||
secureProtocol: "TLSv1_2_method",
|
||||
});
|
||||
}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const token = await this.getFabricToken();
|
||||
const amount = (input.amountMinor / 100).toFixed(2);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const requestBody: DMoneyInitiateRequest = {
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
returnUrl: this.returnUrl,
|
||||
notifyUrl: this.notifyUrl,
|
||||
timestamp,
|
||||
signature: this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<DMoneyInitiateResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/initiate`,
|
||||
async initiate(
|
||||
input: ProviderInitiationInput,
|
||||
): Promise<ProviderInitiationResult> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildPreOrderRequest(input);
|
||||
const response = await this.postJson<DMoneyPreOrderResponse>(
|
||||
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
|
||||
requestBody,
|
||||
token,
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"X-APP-Key": this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.success || !response.orderId) {
|
||||
throw new Error(`DMoney initiate failed: ${JSON.stringify(response)}`);
|
||||
const prepayId = response.biz_content?.prepay_id;
|
||||
if (response.result !== "SUCCESS" || !prepayId) {
|
||||
throw new Error(
|
||||
`D-Money preOrder failed: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
|
||||
const expiresAt = this.computeExpiresAt(
|
||||
requestBody.biz_content.timeout_express,
|
||||
);
|
||||
|
||||
return {
|
||||
providerOrderId: response.orderId,
|
||||
clientAction: response.checkoutUrl
|
||||
? { type: 'REDIRECT', url: response.checkoutUrl }
|
||||
: { type: 'REDIRECT', url: `${this.baseUrl}/checkout/${response.orderId}` },
|
||||
providerOrderId: prepayId,
|
||||
clientAction: {
|
||||
type: "REDIRECT",
|
||||
url: this.buildCheckoutUrl(prepayId),
|
||||
},
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
@@ -106,147 +97,259 @@ export class DMoneyProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const token = await this.authenticate();
|
||||
const timestamp = new Date().toISOString();
|
||||
const signature = this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const response = await this.postJson<DMoneyQueryResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/query`,
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
||||
const response = await this.postJson<DMoneyQueryOrderResponse>(
|
||||
`${this.baseUrl}/apiaccess/payment/v1/merchant/queryOrder`,
|
||||
requestBody,
|
||||
{
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-APP-Key": this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
token,
|
||||
);
|
||||
|
||||
const mapped = this.mapStatus(response.status);
|
||||
const orderStatus = response.biz_content?.order_status;
|
||||
const providerTxnId = response.biz_content?.payment_order_id;
|
||||
const mapped = this.mapOrderStatus(orderStatus);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transactionId,
|
||||
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
providerTxnId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED && orderStatus
|
||||
? orderStatus
|
||||
: undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { signature, ...data } = payload;
|
||||
if (!signature || typeof signature !== 'string') return false;
|
||||
|
||||
const expectedSignature = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): ProviderPaymentStatus {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'SUCCESS':
|
||||
case 'COMPLETED':
|
||||
/** queryOrder `order_status` → shared status. */
|
||||
mapOrderStatus(orderStatus: string | undefined): ProviderPaymentStatus {
|
||||
switch (orderStatus) {
|
||||
case "PAY_SUCCESS":
|
||||
case "Completed":
|
||||
case "SUCCESS":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'FAILED':
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
case 'CANCELLED':
|
||||
case "PAY_FAILED":
|
||||
case "Failure":
|
||||
case "ORDER_CLOSED":
|
||||
case "Expired":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
case "WAIT_PAY":
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
case "PAYING":
|
||||
case "Paying":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private async getFabricToken(): Promise<string> {
|
||||
const response = await this.postJson<DMoneyAuthResponse>(
|
||||
/** Notification `trade_status` → shared status. */
|
||||
mapWebhookTradeStatus(
|
||||
tradeStatus: string | undefined,
|
||||
): ProviderPaymentStatus {
|
||||
switch (tradeStatus) {
|
||||
case "Completed":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case "Failure":
|
||||
case "Expired":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case "Paying":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
if (!this.publicKey) {
|
||||
this.logger.error(
|
||||
"DMONEY_PUBLIC_KEY not configured; rejecting all webhooks",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return verifyRequestObject(payload, this.publicKey);
|
||||
}
|
||||
|
||||
private async applyFabricToken(): Promise<string> {
|
||||
const response = await this.postJson<DMoneyFabricTokenResponse>(
|
||||
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/token`,
|
||||
{ appSecret: this.appSecret },
|
||||
{
|
||||
appSecret: this.appSecret,
|
||||
"Content-Type": "application/json",
|
||||
"X-APP-Key": this.fabricAppId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.token) {
|
||||
throw new Error(`DMoney authentication failed: ${JSON.stringify(response)}`);
|
||||
if (!response?.token) {
|
||||
throw new Error(
|
||||
`D-Money token request failed: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// D-Money returns the token already prefixed with "Bearer " — use it verbatim.
|
||||
return response.token;
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join('&');
|
||||
private buildPreOrderRequest(
|
||||
input: ProviderInitiationInput,
|
||||
): DMoneyPreOrderRequest {
|
||||
const totalAmount = (input.amountMinor).toFixed(2);
|
||||
const redirectUrl = input.redirectUrl ?? this.returnUrl;
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: "payment.preorder" as const,
|
||||
version: "1.0" as const,
|
||||
biz_content: {
|
||||
notify_url: this.notifyUrl,
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: "Checkout" as const,
|
||||
title: `EDR ${input.orderRef}`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: 1 == 1 ? "DJF": this.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
...(redirectUrl ? { redirect_url: redirectUrl } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
return crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.update(signString)
|
||||
.digest('hex');
|
||||
console.log("\n\n\n")
|
||||
console.log(req)
|
||||
console.log("\n\n\n")
|
||||
const sign = signRequestObject(
|
||||
req as unknown as Record<string, unknown>,
|
||||
this.privateKey,
|
||||
);
|
||||
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown, token?: string): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
private buildQueryOrderRequest(
|
||||
merchantOrderId: string,
|
||||
): Record<string, unknown> {
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: "payment.queryorder",
|
||||
version: "1.0",
|
||||
biz_content: {
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: merchantOrderId,
|
||||
},
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const sign = signRequestObject(
|
||||
req as Record<string, unknown>,
|
||||
this.privateKey,
|
||||
);
|
||||
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||
}
|
||||
|
||||
private buildCheckoutUrl(prepayId: string): string {
|
||||
// Only these five fields are signed for the paygate URL.
|
||||
const map: Record<string, string> = {
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
nonce_str: createNonceStr(),
|
||||
prepay_id: prepayId,
|
||||
timestamp: createTimestamp(),
|
||||
};
|
||||
const sign = signRequestObject(map, this.privateKey);
|
||||
const query = [
|
||||
`appid=${map.appid}`,
|
||||
`merch_code=${map.merch_code}`,
|
||||
`nonce_str=${map.nonce_str}`,
|
||||
`prepay_id=${map.prepay_id}`,
|
||||
`timestamp=${map.timestamp}`,
|
||||
`sign=${sign}`,
|
||||
"sign_type=SHA256WithRSA",
|
||||
"version=1.0",
|
||||
"trade_type=Checkout",
|
||||
`language=${this.language}`,
|
||||
].join("&");
|
||||
return `${this.webBaseUrl}/payment/web/paygate?${query}`;
|
||||
}
|
||||
|
||||
private computeExpiresAt(timeoutExpress: string): Date {
|
||||
const match = /^(\d+)m$/.exec(timeoutExpress);
|
||||
const minutes = match ? parseInt(match[1], 10) : 120;
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
private async postJson<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers,
|
||||
timeout: 10_000,
|
||||
timeout: DMONEY_HTTP_TIMEOUT_MS,
|
||||
httpsAgent: this.httpsAgent,
|
||||
};
|
||||
|
||||
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(
|
||||
`D-Money POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`DMoney POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
`D-Money POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`DMoney POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
this.logger.error(
|
||||
`D-Money POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: DMoneyInitiateRequest): Record<string, unknown> {
|
||||
const { signature: _signature, ...rest } = body;
|
||||
private sanitize(body: DMoneyPreOrderRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
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') ?? '';
|
||||
private get webBaseUrl(): string {
|
||||
return this.config.get<string>("dmoney.webBaseUrl") ?? "";
|
||||
}
|
||||
private get fabricAppId(): string {
|
||||
return this.config.get<string>("dmoney.fabricAppId") ?? "";
|
||||
}
|
||||
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') ?? '';
|
||||
private get merchantAppId(): string {
|
||||
return this.config.get<string>("dmoney.merchantAppId") ?? "";
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>("dmoney.merchantCode") ?? "";
|
||||
}
|
||||
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") ?? "";
|
||||
}
|
||||
private get timeoutExpress(): string {
|
||||
return this.config.get<string>("dmoney.timeoutExpress") ?? "120m";
|
||||
}
|
||||
private get language(): string {
|
||||
return this.config.get<string>("dmoney.language") ?? "en";
|
||||
}
|
||||
private get currency(): string {
|
||||
return this.config.get<string>("dmoney.currency") ?? "FDJ";
|
||||
}
|
||||
private get privateKey(): string {
|
||||
return this.config.get<string>("dmoney.privateKey") ?? "";
|
||||
}
|
||||
private get publicKey(): string {
|
||||
return this.config.get<string>("dmoney.publicKey") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface DMoneyFabricTokenResponse {
|
||||
/** Returned already prefixed with "Bearer " — set Authorization to this value verbatim. */
|
||||
token: string;
|
||||
effectiveDate?: string;
|
||||
expirationDate?: string;
|
||||
}
|
||||
|
||||
export interface DMoneyPreOrderBizContent {
|
||||
notify_url: string;
|
||||
appid: string;
|
||||
merch_code: string;
|
||||
merch_order_id: string;
|
||||
trade_type: 'Checkout';
|
||||
title: string;
|
||||
total_amount: string;
|
||||
trans_currency: string;
|
||||
timeout_express: string;
|
||||
business_type?: string;
|
||||
redirect_url?: string;
|
||||
callback_info?: string;
|
||||
}
|
||||
|
||||
export interface DMoneyPreOrderRequest {
|
||||
timestamp: string;
|
||||
nonce_str: string;
|
||||
method: 'payment.preorder';
|
||||
version: '1.0';
|
||||
biz_content: DMoneyPreOrderBizContent;
|
||||
sign: string;
|
||||
sign_type: 'SHA256WithRSA';
|
||||
}
|
||||
|
||||
export interface DMoneyPreOrderResponse {
|
||||
result?: 'SUCCESS' | 'FAIL';
|
||||
code?: string;
|
||||
msg?: string;
|
||||
nonce_str?: string;
|
||||
sign?: string;
|
||||
sign_type?: string;
|
||||
biz_content?: {
|
||||
merch_order_id?: string;
|
||||
prepay_id?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type DMoneyOrderStatus =
|
||||
| 'PAY_SUCCESS'
|
||||
| 'PAY_FAILED'
|
||||
| 'WAIT_PAY'
|
||||
| 'ORDER_CLOSED'
|
||||
| 'PAYING'
|
||||
| 'Completed'
|
||||
| 'Failure'
|
||||
| 'Expired'
|
||||
| 'Paying';
|
||||
|
||||
export interface DMoneyQueryOrderResponse {
|
||||
result?: 'SUCCESS' | 'FAIL';
|
||||
code?: string;
|
||||
msg?: string;
|
||||
nonce_str?: string;
|
||||
sign?: string;
|
||||
sign_type?: string;
|
||||
biz_content?: {
|
||||
merch_order_id?: string;
|
||||
order_status?: DMoneyOrderStatus | string;
|
||||
payment_order_id?: string;
|
||||
trans_time?: string;
|
||||
trans_currency?: string;
|
||||
total_amount?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -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") ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function buildCanonicalString(requestObject: Record<string, unknown>): st
|
||||
|
||||
for (const key of Object.keys(requestObject)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
if (requestObject[key] === undefined) continue;
|
||||
fieldMap[key] = requestObject[key];
|
||||
}
|
||||
|
||||
@@ -24,7 +25,9 @@ export function buildCanonicalString(requestObject: Record<string, unknown>): st
|
||||
if (biz && typeof biz === 'object') {
|
||||
for (const key of Object.keys(biz as Record<string, unknown>)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = (biz as Record<string, unknown>)[key];
|
||||
const value = (biz as Record<string, unknown>)[key];
|
||||
if (value === undefined) continue;
|
||||
fieldMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,51 +95,58 @@ 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,
|
||||
},
|
||||
);
|
||||
|
||||
const tradeStatus = response.biz_content?.trade_status;
|
||||
this.logger.log(response);
|
||||
|
||||
// const tradeStatus = response.biz_content?.trade_status;
|
||||
const orderStatus = response.biz_content?.order_status;
|
||||
const providerTxnId =
|
||||
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
|
||||
const mapped = this.mapTradeStatus(tradeStatus);
|
||||
const mapped = this.mapTradeStatus(orderStatus);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
failureCode:
|
||||
mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||
mapped === ProviderPaymentStatus.FAILED && orderStatus
|
||||
? orderStatus
|
||||
: 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 {
|
||||
switch (tradeStatus) {
|
||||
case 'Completed':
|
||||
mapWebhookTradeStatus(
|
||||
tradeStatus: string | undefined,
|
||||
): ProviderPaymentStatus {
|
||||
switch (tradeStatus?.toUpperCase()) {
|
||||
case "COMPLETED":
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'Failure':
|
||||
case 'Expired':
|
||||
case "FAILURE":
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'Paying':
|
||||
case 'Pending':
|
||||
case "PAYING":
|
||||
case "PENDING":
|
||||
case "EXPIRED":
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
@@ -142,7 +155,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 +168,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 +188,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 {
|
||||
const totalAmount = String(input.amountMinor / 100);
|
||||
private buildCreateOrderRequest(
|
||||
input: ProviderInitiationInput,
|
||||
): CreateOrderRequest {
|
||||
const totalAmount = String(input.amountMinor);
|
||||
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,
|
||||
title: `EDR ${input.orderRef}`,
|
||||
trade_type: "Checkout" as const,
|
||||
title: `EDR booking payment`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
redirect_url: input.redirectUrl
|
||||
...(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 +260,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 +304,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 +314,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 +327,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 `-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC/ZcoOng1sJZ4CegopQVCw3HYqqVRLEudgT+dDpS8fRVy7zBgqZunju2VRCQuHeWs7yWgc9QGd4/8kRSLY+jlvKNeZ60yWcqEY+eKyQMmcjOz2Sn41fcVNgF+HV3DGiV4b23B6BCMjnpEFIb9d99/TsjsFSc7gCPgfl2yWDxE/Y1B2tVE6op2qd63YsMVFQGdre/CQYvFJENpQaBLMq4hHyBDgluUXlF0uA1X7UM0ZjbFC6ZIB/Hn1+pl5Ua8dKYrkVaecolmJT/s7c/+/1JeN+ja8luBoONsoODt2mTeVJHLF9Y3oh5rI+IY8HukIZJ1U6O7/JcjH3aRJTZagXUS9AgMBAAECggEBALBIBx8JcWFfEDZFwuAWeUQ7+VX3mVx/770kOuNx24HYt718D/HV0avfKETHqOfA7AQnz42EF1Yd7Rux1ZO0e3unSVRJhMO4linT1XjJ9ScMISAColWQHk3wY4va/FLPqG7N4L1w3BBtdjIc0A2zRGLNcFDBlxl/CVDHfcqD3CXdLukm/friX6TvnrbTyfAFicYgu0+UtDvfxTL3pRL3u3WTkDvnFK5YXhoazLctNOFrNiiIpCW6dJ7WRYRXuXhz7C0rENHyBtJ0zura1WD5oDbRZ8ON4v1KV4QofWiTFXJpbDgZdEeJJmFmt5HIi+Ny3P5n31WwZpRMHGeHrV23//0CgYEA+2/gYjYWOW3JgMDLX7r8fGPTo1ljkOUHuH98H/a/lE3wnnKKx+2ngRNZX4RfvNG4LLeWTz9plxR2RAqqOTbX8fj/NA/sS4mru9zvzMY1925FcX3WsWKBgKlLryl0vPScq4ejMLSCmypGz4VgLMYZqT4NYIkU2Lo1G1MiDoLy0CcCgYEAwt77exynUhM7AlyjhAA2wSINXLKsdFFF1u976x9kVhOfmbAutfMJPEQWb2WXaOJQMvMpgg2rU5aVsyEcuHsRH/2zatrxrGqLqgxaiqPz4ELINIh1iYK/hdRpr1vATHoebOv1wt8/9qxITNKtQTgQbqYci3KV1lPsOrBAB5S57nsCgYAvw+cagS/jpQmcngOEoh8I+mXgKEET64517DIGWHe4kr3dO+FFbc5eZPCbhqgxVJ3qUM4LK/7BJq/46RXBXLvVSfohR80Z5INtYuFjQ1xJLveeQcuhUxdK+95W3kdBBi8lHtVPkVsmYvekwK+ukcuaLSGZbzE4otcn47kajKHYDQKBgDbQyIbJ+ZsRw8CXVHu2H7DWJlIUBIS3s+CQ/xeVfgDkhjmSIKGX2to0AOeW+S9MseiTE/L8a1wY+MUppE2UeK26DLUbH24zjlPoI7PqCJjl0DFOzVlACSXZKV1lfsNEeriC61/EstZtgezyOkAlSCIH4fGr6tAeTU349Bnt0RtvAoGBAObgxjeH6JGpdLz1BbMj8xUHuYQkbxNeIPhH29CySn0vfhwg9VxAtIoOhvZeCfnsCRTj9OZjepCeUqDiDSoFznglrKhfeKUndHjvg+9kiae92iI6qJudPCHMNwP8wMSphkxUqnXFR3lr9A765GA980818UWZdrhrjLKtIIZdh+X1\n-----END PRIVATE KEY-----`
|
||||
}
|
||||
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,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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
103
packages/payment-providers/src/providers/waafi/waafi.types.ts
Normal file
103
packages/payment-providers/src/providers/waafi/waafi.types.ts
Normal 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>;
|
||||
@@ -1,13 +1,18 @@
|
||||
export interface DMoneyWebhookPayload {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
paidAt?: string;
|
||||
payerPhone?: string;
|
||||
signature: string;
|
||||
appid: string;
|
||||
merch_code: string;
|
||||
merch_order_id: string;
|
||||
payment_order_id: string;
|
||||
notify_time?: string;
|
||||
trans_end_time?: string;
|
||||
total_amount?: string;
|
||||
trans_currency?: string;
|
||||
/** Paying | Expired | Completed | Failure */
|
||||
trade_status: string;
|
||||
transId?: string;
|
||||
callback_info?: string;
|
||||
notify_url?: string;
|
||||
sign: string;
|
||||
sign_type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./payments";
|
||||
export * from "./payment-messaging";
|
||||
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
|
||||
55
packages/types/src/common/payment-messaging.ts
Normal file
55
packages/types/src/common/payment-messaging.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export enum ProviderMethod {
|
||||
WAAFI = "WAAFI",
|
||||
CARD = "CARD",
|
||||
DMONEY = "DMONEY",
|
||||
CAC_BANK = "CAC_BANK",
|
||||
}
|
||||
|
||||
export type PaymentPlatform = "web" | "mobile";
|
||||
@@ -34,6 +35,11 @@ export type ClientAction =
|
||||
appId: string;
|
||||
receiveCode?: string;
|
||||
shortCode: string;
|
||||
}
|
||||
| {
|
||||
type: "COLLECT_OTP";
|
||||
providerOrderId: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
@@ -43,8 +49,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 +82,108 @@ 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;
|
||||
}
|
||||
|
||||
/** Body of `POST /payments/intents/:id/confirm` (OTP-based providers such as CAC Bank). */
|
||||
export interface ConfirmPaymentRequest {
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/** Response of `POST /payments/initiate` and shape of intent lookups. */
|
||||
export type 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;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { BaseEntity } from "../common";
|
||||
|
||||
export * from "./file_upload_settings";
|
||||
export * from "./dropdown_settings";
|
||||
export * from "./file_upload_settings";
|
||||
export * from "./overview";
|
||||
|
||||
export enum TradeDirection {
|
||||
IMPORT = 'IMPORT',
|
||||
EXPORT = 'EXPORT',
|
||||
BOTH = 'BOTH',
|
||||
IMPORT = "IMPORT",
|
||||
EXPORT = "EXPORT",
|
||||
BOTH = "BOTH",
|
||||
}
|
||||
|
||||
export enum PriorityType {
|
||||
USD_PAYER = 'USD_PAYER',
|
||||
RAIL_AND_FORWARDING = 'RAIL_AND_FORWARDING',
|
||||
GOVERNMENT_ACCOUNT = 'GOVERNMENT_ACCOUNT',
|
||||
HIGH_VOLUME_SHIPMENT = 'HIGH_VOLUME_SHIPMENT',
|
||||
USD_PAYER = "USD_PAYER",
|
||||
RAIL_AND_FORWARDING = "RAIL_AND_FORWARDING",
|
||||
GOVERNMENT_ACCOUNT = "GOVERNMENT_ACCOUNT",
|
||||
HIGH_VOLUME_SHIPMENT = "HIGH_VOLUME_SHIPMENT",
|
||||
}
|
||||
|
||||
/** Bonus applied to government bookings so they outrank commercial priority. */
|
||||
@@ -22,23 +22,23 @@ export const GOVERNMENT_PRIORITY_BONUS = 50_000;
|
||||
|
||||
export interface GovernmentBookingFields {
|
||||
isGovernment: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
governmentInstitution?: string | null;
|
||||
}
|
||||
|
||||
export enum ExceededAction {
|
||||
WARNING_ONLY = 'WARNING_ONLY',
|
||||
HARD_BLOCK = 'HARD_BLOCK',
|
||||
WARNING_ONLY = "WARNING_ONLY",
|
||||
HARD_BLOCK = "HARD_BLOCK",
|
||||
}
|
||||
|
||||
export enum CalculationMethod {
|
||||
PER_TON = 'PER_TON',
|
||||
FLAT_FEE = 'FLAT_FEE',
|
||||
PERCENTAGE = 'PERCENTAGE',
|
||||
PER_TON = "PER_TON",
|
||||
FLAT_FEE = "FLAT_FEE",
|
||||
PERCENTAGE = "PERCENTAGE",
|
||||
}
|
||||
|
||||
export enum FreightType {
|
||||
Container = 'CONTAINER',
|
||||
Bulk = 'BULK',
|
||||
Container = "CONTAINER",
|
||||
Bulk = "BULK",
|
||||
}
|
||||
|
||||
export enum BookingStatus {
|
||||
@@ -53,6 +53,12 @@ export enum BookingStatus {
|
||||
FullyExecuted = "FULLY_EXECUTED",
|
||||
PnrGenerated = "PNR_GENERATED",
|
||||
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
/** Selected in a batch and notified to pay within the pay window. */
|
||||
SelectedForBatch = "SELECTED_FOR_BATCH",
|
||||
/** @deprecated Use SelectedForBatch */
|
||||
AwaitingPayment = "SELECTED_FOR_BATCH",
|
||||
/** Missed the 1h pay window — recoverable via move/cancel (no re-approval). */
|
||||
Expired = "EXPIRED",
|
||||
Paid = "PAID",
|
||||
InTransit = "IN_TRANSIT",
|
||||
Completed = "COMPLETED",
|
||||
@@ -113,6 +119,13 @@ export enum TrainScheduleStatus {
|
||||
Cancelled = "CANCELLED",
|
||||
}
|
||||
|
||||
/** Whether a schedule is still accepting / holding bookings (orthogonal to its operational status). */
|
||||
export enum ScheduleBookingWindow {
|
||||
Open = "OPEN",
|
||||
Full = "FULL",
|
||||
Closed = "CLOSED",
|
||||
}
|
||||
|
||||
export enum AllocationLoadType {
|
||||
Container = "CONTAINER",
|
||||
Bulk = "BULK",
|
||||
@@ -148,6 +161,22 @@ export enum WagonReadiness {
|
||||
|
||||
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
|
||||
export enum TrainCheckpointKind {
|
||||
Departed = "DEPARTED",
|
||||
Passed = "PASSED",
|
||||
Arrived = "ARRIVED",
|
||||
}
|
||||
|
||||
export interface ITrainCheckpointEvent extends BaseEntity {
|
||||
trainScheduleId: string;
|
||||
yardId: string;
|
||||
sequenceNo: number;
|
||||
kind: TrainCheckpointKind;
|
||||
occurredAt: string;
|
||||
note?: string | null;
|
||||
recordedByUserId?: string | null;
|
||||
}
|
||||
|
||||
export enum BulkPricingUnit {
|
||||
PerWagon = "PER_WAGON",
|
||||
PerTon = "PER_TON",
|
||||
@@ -255,6 +284,9 @@ export interface IBooking extends BaseEntity {
|
||||
totalAmount: number;
|
||||
paymentStatus: PaymentStatus;
|
||||
|
||||
shippingLineId?: string | null;
|
||||
serviceTypeId: string;
|
||||
|
||||
contractType: "NEW" | "RENEWAL";
|
||||
previousContractId?: string | null;
|
||||
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
||||
@@ -284,6 +316,11 @@ export interface IBooking extends BaseEntity {
|
||||
endDate?: string | null;
|
||||
financialTerms?: string | null;
|
||||
|
||||
/** When the batch engine picked this booking and opened the pay window. */
|
||||
selectedForBatchAt?: string | null;
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
paymentDeadline?: string | null;
|
||||
|
||||
containers?: Array<{ type: string; qty: number; vgm: number }> | null;
|
||||
|
||||
versionNumber: number;
|
||||
@@ -363,6 +400,18 @@ export interface BookingReferenceService {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
serviceName: string;
|
||||
description?: string | null | undefined;
|
||||
canBeBookedAlone: boolean;
|
||||
includesFirstMile: boolean;
|
||||
includesLastMile: boolean;
|
||||
includesCustoms: boolean;
|
||||
priorityBonusPoints: number;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface BookingReferenceShippingLine {
|
||||
@@ -393,7 +442,40 @@ export interface BookingReferenceData {
|
||||
cargo_type: BookingReferenceCargoTypeGroup[];
|
||||
}
|
||||
|
||||
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
||||
// ── Train Scheduling (bookable schedules) ──────────────────────────────────────
|
||||
|
||||
export interface BookableSchedulesQuery {
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
}
|
||||
|
||||
export interface BookableScheduleLocomotive {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
readiness: string | null;
|
||||
}
|
||||
|
||||
export interface BookableScheduleItem {
|
||||
id: string;
|
||||
scheduleDate: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
locomotive: BookableScheduleLocomotive | null;
|
||||
wagonCount: number;
|
||||
totalWeightTons: number;
|
||||
totalLengthMeters: number;
|
||||
bookingsCount: number;
|
||||
freightType: FreightType | "MIXED" | null;
|
||||
status: TrainScheduleStatus;
|
||||
bookingWindowStatus: ScheduleBookingWindow;
|
||||
maxWagons: number;
|
||||
remainingWagons: number;
|
||||
}
|
||||
|
||||
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreateBookingContainerDto {
|
||||
containerTypeId: string;
|
||||
@@ -402,31 +484,34 @@ export interface CreateBookingContainerDto {
|
||||
}
|
||||
|
||||
export interface CreateBookingDto {
|
||||
reference?: string;
|
||||
customerId?: string;
|
||||
companyId?: string;
|
||||
trainId?: string;
|
||||
freightShapeValidation?: boolean | undefined;
|
||||
reference?: string | undefined;
|
||||
isGovernment?: boolean | undefined;
|
||||
governmentInstitution?: string | undefined;
|
||||
companyId?: string | undefined;
|
||||
trainId?: string | undefined;
|
||||
trainScheduleId?: string | undefined;
|
||||
scheduledDate: string;
|
||||
contractType: "NEW" | "RENEWAL";
|
||||
previousContractId?: string;
|
||||
contractType: string;
|
||||
previousContractId?: string | undefined;
|
||||
serviceTypeId: string;
|
||||
firstMilePickupAddress?: string;
|
||||
lastMileDeliveryAddress?: string;
|
||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
|
||||
firstMilePickupAddress?: string | undefined;
|
||||
lastMileDeliveryAddress?: string | undefined;
|
||||
equipmentReturn: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
freightType: FreightType;
|
||||
cargoTypeId?: string;
|
||||
cargoFreeText?: string;
|
||||
shippingLineId?: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
cargoTypeId?: string | undefined;
|
||||
cargoFreeText?: string | undefined;
|
||||
shippingLineId?: string | undefined;
|
||||
cargoTotalWeightVgm: number;
|
||||
isHazardous?: boolean;
|
||||
paymentCurrency: "ETB" | "USD";
|
||||
pnrCode?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
financialTerms?: string;
|
||||
isHazardous?: boolean | undefined;
|
||||
paymentCurrency: string;
|
||||
pnrCode?: string | undefined;
|
||||
startDate?: string | undefined;
|
||||
endDate?: string | undefined;
|
||||
financialTerms?: string | undefined;
|
||||
containers?: CreateBookingContainerDto[];
|
||||
allowConsolidation?: boolean;
|
||||
}
|
||||
|
||||
@@ -2,3 +2,5 @@ export * from "./common/index";
|
||||
export * from "./freight/index";
|
||||
export * as Freight from "./freight/index";
|
||||
export * as Passenger from "./passenger/index";
|
||||
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments";
|
||||
export { PaymentReferenceType, PaymentService } from "./common/payments";
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Table } from "@mantine/core";
|
||||
import { Table as TanstackTable } from "@tanstack/react-table";
|
||||
|
||||
import { TableCell, TableRow } from "../table";
|
||||
import { Button } from "../button";
|
||||
|
||||
export function DataTableError({
|
||||
@@ -15,8 +15,8 @@ export function DataTableError({
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getVisibleFlatColumns().length}>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={table.getVisibleFlatColumns().length}>
|
||||
<div className="flex items-center my-6 justify-center">
|
||||
<div className="text-center">
|
||||
<div className="my-4">
|
||||
@@ -34,7 +34,7 @@ export function DataTableError({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Table } from "@mantine/core";
|
||||
import { Table as TanstackTable } from "@tanstack/react-table";
|
||||
|
||||
import { Skeleton } from "../skeleton";
|
||||
import { TableCell, TableRow } from "../table";
|
||||
|
||||
export function DataTableSkeleton({ table }: { table: TanstackTable<any> }) {
|
||||
return Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<Table.Tr key={i}>
|
||||
{table.getAllColumns().map((column) => (
|
||||
<TableCell key={column.id}>
|
||||
<Table.Td key={column.id}>
|
||||
<Skeleton className="h-8" />
|
||||
</TableCell>
|
||||
</Table.Td>
|
||||
))}
|
||||
</TableRow>
|
||||
</Table.Tr>
|
||||
));
|
||||
}
|
||||
|
||||
@@ -4,14 +4,7 @@ import {
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../table";
|
||||
import { Table } from "@mantine/core";
|
||||
import { DataTableProps } from "./types";
|
||||
import { DataTableSkeleton } from "./skeleton";
|
||||
import { DataTableError } from "./error";
|
||||
@@ -56,12 +49,12 @@ export function DataTable<TData, TValue>({
|
||||
<>
|
||||
<div className={containerClassName}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<Table.Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<Table.Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
<Table.Th
|
||||
key={header.id}
|
||||
className={
|
||||
(header.column.columnDef.meta as Record<string, any>)
|
||||
@@ -75,13 +68,13 @@ export function DataTable<TData, TValue>({
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
</Table.Th>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{status === "loading" && <DataTableSkeleton table={table} />}
|
||||
{status === "error" && (
|
||||
<DataTableError
|
||||
@@ -94,7 +87,7 @@ export function DataTable<TData, TValue>({
|
||||
{status === "success" &&
|
||||
(table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
<Table.Tr
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={(event) => {
|
||||
@@ -129,7 +122,7 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
<Table.Td
|
||||
key={cell.id}
|
||||
className={
|
||||
(cell.column.columnDef.meta as Record<string, any>)
|
||||
@@ -140,21 +133,21 @@ export function DataTable<TData, TValue>({
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
</Table.Td>
|
||||
))}
|
||||
</TableRow>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
colSpan={table.getVisibleFlatColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{emptyMessage ?? "No data"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user