mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 07:15:45 +00:00
236 lines
6.4 KiB
TypeScript
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") ?? "";
|
|
}
|
|
}
|