Files
edr-platform/packages/payment-providers/src/providers/card/card.provider.ts

236 lines
6.4 KiB
TypeScript

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 * as crypto from "node:crypto";
interface CardInitiateRequest {
amount: number;
currency: string;
description: string;
metadata: {
merchantOrderId: string;
orderRef: string;
};
return_url: string;
webhook_url: string;
}
interface CardInitiateResponse {
id: string;
status: string;
client_secret: string;
checkout_url: string;
expires_at: number;
}
interface CardQueryResponse {
id: string;
status: string;
amount: number;
currency: string;
transaction_id?: string;
paid_at?: number;
failure_code?: string;
failure_message?: string;
}
@Injectable()
export class CardProvider implements PaymentProvider {
readonly method = ProviderMethod.CARD;
private readonly logger = new Logger(CardProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
const amount = input.amountMinor / 100;
const requestBody: CardInitiateRequest = {
amount,
currency: input.currency,
description: `${input.orderRef}`,
metadata: {
merchantOrderId: input.merchantOrderId,
orderRef: input.orderRef,
},
// 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,
};
const response = await this.postJson<CardInitiateResponse>(
`${this.baseUrl}/v1/payment_intents`,
requestBody,
);
if (!response.id) {
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 },
expiresAt,
rawInitiation: {
request: requestBody,
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
// For card payments, we need to find the payment intent by metadata
// In a real implementation, we'd store the provider order ID and use it directly
const response = await this.getJson<CardQueryResponse>(
`${this.baseUrl}/v1/payment_intents/search?metadata[merchantOrderId]=${merchantOrderId}`,
);
const mapped = this.mapStatus(response.status);
return {
status: mapped,
providerTxnId: response.transaction_id,
failureCode: response.failure_code,
failureMessage: response.failure_message,
rawResponse: response as unknown as Record<string, unknown>,
};
}
verifyWebhookSignature(
payload: Record<string, unknown>,
signature: string,
): boolean {
const payloadString = JSON.stringify(payload);
const expectedSignature = crypto
.createHmac("sha256", this.webhookSecret)
.update(payloadString)
.digest("hex");
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} catch {
return false;
}
}
mapWebhookStatus(status: string): ProviderPaymentStatus {
return this.mapStatus(status);
}
private mapStatus(status: string): ProviderPaymentStatus {
switch (status?.toLowerCase()) {
case "succeeded":
case "paid":
return ProviderPaymentStatus.SUCCEEDED;
case "failed":
case "canceled":
case "expired":
return ProviderPaymentStatus.FAILED;
case "requires_payment_method":
case "requires_confirmation":
case "requires_action":
return ProviderPaymentStatus.REQUIRES_ACTION;
case "processing":
return ProviderPaymentStatus.PROCESSING;
default:
return ProviderPaymentStatus.PROCESSING;
}
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
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`,
);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`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}`,
);
}
throw err;
}
}
private async getJson<T>(url: string): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
timeout: 10_000,
};
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`,
);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`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}`,
);
}
throw err;
}
}
private get baseUrl(): string {
return this.config.get<string>("card.baseUrl") ?? "";
}
private get apiKey(): string {
return this.config.get<string>("card.apiKey") ?? "";
}
private get webhookSecret(): string {
return this.config.get<string>("card.webhookSecret") ?? "";
}
private get webhookUrl(): string {
return this.config.get<string>("card.webhookUrl") ?? "";
}
private get returnUrl(): string {
return this.config.get<string>("card.returnUrl") ?? "";
}
}