diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index b7089cae4..1438cbfe0 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -39,12 +39,28 @@ export type { TelebirrTradeStatus, } from './providers/telebirr/telebirr.types'; +// Waafi HPP request/response types (exported for apps that build/inspect requests directly) +export type { + WaafiState, + WaafiHppPurchaseRequest, + WaafiHppPurchaseResponse, + WaafiGetTranInfoRequest, + WaafiGetTranInfoResponse, +} from './providers/waafi/waafi.types'; + // Webhook payload types export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types'; export type { CardWebhookPayload } from './webhooks/card-webhook.types'; -export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types'; +export type { + WaafiWebhookPayload, + WaafiWebhookTransactionPayload, + WaafiWebhookTestPayload, + WaafiWebhookHeaders, + WaafiWebhookEvent, + WaafiWebhookStatus, +} from './webhooks/waafi-webhook.types'; // DI token for injecting all providers as an array (future multi-provider wiring) export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS'); diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index ea16eccb5..515de1786 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -11,71 +11,18 @@ import { } from '@edr/types'; import { AxiosError, AxiosRequestConfig } from 'axios'; import { firstValueFrom } from 'rxjs'; +import * as crypto from 'node:crypto'; +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 { @@ -88,31 +35,30 @@ export class WaafiProvider implements PaymentProvider { ) {} async initiate(input: ProviderInitiationInput): Promise { - const requestBody = this.buildInitiateRequest(input); - const response = await this.postJson( + const requestBody = this.buildPurchaseRequest(input); + const response = await this.postJson( `${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, + providerOrderId: orderId, clientAction: { type: 'REDIRECT', url: checkoutUrl }, - expiresAt, + expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS), rawInitiation: { request: this.sanitize(requestBody), response, @@ -121,113 +67,144 @@ export class WaafiProvider implements PaymentProvider { } async queryStatus(merchantOrderId: string): Promise { - const requestBody = this.buildQueryRequest(merchantOrderId); - const response = await this.postJson( + const requestBody = this.buildGetTranInfoRequest(merchantOrderId); + const response = await this.postJson( `${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, }; } - mapState(state: string | undefined): ProviderPaymentStatus { - switch (state) { + /** 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 'CANCELED': case 'CANCELLED': + return ProviderPaymentStatus.CANCELLED; + case 'DECLINED': + case 'FAILED': case 'EXPIRED': + case 'TIMEOUT': return ProviderPaymentStatus.FAILED; case 'PENDING': case 'INITIATED': return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': - return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - verifyWebhookSignature(payload: Record): 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(), + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), channelName: 'WEB', - serviceName: 'API_PURCHASE', + 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, + hppSuccessCallbackUrl: this.successUrl, + hppFailureCallbackUrl: 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(), + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), channelName: 'WEB', - serviceName: 'API_QUERY', + serviceName: 'HPP_GETTRANINFO', serviceParams: { merchantUid: this.merchantUid, - apiUserId: this.apiUserId, - apiKey: this.apiKey, + storeId: this.storeId, + hppKey: this.hppKey, referenceId: merchantOrderId, }, }; } - private generateRequestId(): string { - return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + /** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */ + private toAmount(amountMinor: number): number { + return Math.trunc(amountMinor) / 100; + } + + private timestamp(): string { + return Math.round(Date.now() / 1000).toString(); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, timeout: WAAFI_HTTP_TIMEOUT_MS, }; @@ -252,24 +229,41 @@ export class WaafiProvider implements PaymentProvider { } } - private sanitize(body: WaafiInitiateRequest): Record { - const sanitized = { ...body }; - if (sanitized.serviceParams?.apiKey) { - sanitized.serviceParams.apiKey = '***REDACTED***'; - } - return sanitized as unknown as Record; + private sanitize(body: WaafiHppPurchaseRequest): Record { + return { + ...body, + serviceParams: { ...body.serviceParams, hppKey: '***REDACTED***' }, + }; } private get baseUrl(): string { - return this.config.get('waafi.baseUrl') ?? 'https://api.waafipay.net'; + return this.config.get('waafi.baseUrl') ?? 'https://sandbox.waafipay.com'; } private get merchantUid(): string { return this.config.get('waafi.merchantUid') ?? ''; } - private get apiUserId(): string { - return this.config.get('waafi.apiUserId') ?? ''; + private get storeId(): string { + return this.config.get('waafi.storeId') ?? ''; } - private get apiKey(): string { - return this.config.get('waafi.apiKey') ?? ''; + private get hppKey(): string { + return this.config.get('waafi.hppKey') ?? ''; + } + private get webhookSecret(): string { + return this.config.get('waafi.webhookSecret') ?? ''; + } + private get paymentMethod(): string { + return this.config.get('waafi.paymentMethod') ?? 'MWALLET_ACCOUNT'; + } + private get currency(): string { + return this.config.get('waafi.currency') ?? ''; + } + private get successUrl(): string { + return this.config.get('waafi.successUrl') ?? ''; + } + private get failureUrl(): string { + return this.config.get('waafi.failureUrl') ?? ''; + } + private get respDataFormat(): number { + return this.config.get('waafi.respDataFormat') ?? 1; } } diff --git a/packages/payment-providers/src/providers/waafi/waafi.types.ts b/packages/payment-providers/src/providers/waafi/waafi.types.ts new file mode 100644 index 000000000..3f3c6f3cf --- /dev/null +++ b/packages/payment-providers/src/providers/waafi/waafi.types.ts @@ -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 { + 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 { + 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; + +export interface WaafiHppPurchaseParams { + hppUrl: string; + directPaymentLink?: string; + orderId: string; + referenceId: string; +} + +export type WaafiHppPurchaseResponse = WaafiResponseEnvelope; + +// --- 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; + +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; diff --git a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts index 247c46d40..658b293cd 100644 --- a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts @@ -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; diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 243c020f2..f3cb0d541 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -42,6 +42,12 @@ export interface ProviderInitiationInput { amountMinor: number; currency: string; platform?: PaymentPlatform; + /** + * 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; } export interface ProviderInitiationResult {