mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
import {
|
|
TelebirrProvider,
|
|
TelebirrWebhookPayload,
|
|
ProviderPaymentStatus,
|
|
} from '@edr/payment-providers';
|
|
import { PrismaService } from '../../../common/prisma.service';
|
|
import { PaymentsService } from '../payments.service';
|
|
|
|
@Injectable()
|
|
export class TelebirrWebhookService {
|
|
private readonly logger = new Logger(TelebirrWebhookService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly provider: TelebirrProvider,
|
|
private readonly payments: PaymentsService,
|
|
) {}
|
|
|
|
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
|
const merchantOrderId = payload.merch_order_id;
|
|
const externalEventId = this.buildExternalEventId(payload);
|
|
// TODO: re-enable Telebirr public-key signature verification — skipped for now
|
|
// const signatureValid = this.provider.verifyWebhookSignature(
|
|
// payload as unknown as Record<string, unknown>,
|
|
// );
|
|
const signatureValid = true;
|
|
|
|
const eventRow = await this.persistEvent({
|
|
externalEventId,
|
|
merchantOrderId,
|
|
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
|
signatureValid,
|
|
status: payload.trade_status,
|
|
payload,
|
|
});
|
|
|
|
if (!eventRow) {
|
|
this.logger.log(
|
|
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// TODO: re-enable signature gate once verifyWebhookSignature is restored
|
|
// if (!signatureValid) {
|
|
// this.logger.warn(
|
|
// `Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`,
|
|
// );
|
|
// await this.markProcessed(eventRow.id, 'signature-invalid');
|
|
// return;
|
|
// }
|
|
|
|
const intent = await this.prisma.paymentIntent.findUnique({
|
|
where: { merchantOrderId },
|
|
});
|
|
if (!intent) {
|
|
this.logger.warn(
|
|
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
|
|
);
|
|
await this.markProcessed(eventRow.id, 'intent-not-found');
|
|
return;
|
|
}
|
|
|
|
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
|
|
|
try {
|
|
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
|
await this.payments.finalizePaymentSuccess({
|
|
intentId: intent.id,
|
|
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
|
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
|
});
|
|
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
|
await this.payments.markPaymentFailed({
|
|
intentId: intent.id,
|
|
failureCode: payload.trade_status,
|
|
});
|
|
} else {
|
|
await this.prisma.paymentIntent.update({
|
|
where: { id: intent.id },
|
|
data: {
|
|
status: mapped as unknown as PaymentIntentStatus,
|
|
providerTxnId: payload.trans_id ?? undefined,
|
|
},
|
|
});
|
|
}
|
|
await this.markProcessed(eventRow.id);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
this.logger.error(
|
|
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
|
|
);
|
|
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
|
|
return `${payload.payment_order_id}_${payload.trade_status}`;
|
|
}
|
|
|
|
private async persistEvent(input: {
|
|
externalEventId: string;
|
|
merchantOrderId: string;
|
|
providerTxnId?: string;
|
|
signatureValid: boolean;
|
|
status: string;
|
|
payload: TelebirrWebhookPayload;
|
|
}): Promise<{ id: string } | null> {
|
|
try {
|
|
return await this.prisma.paymentWebhookEvent.create({
|
|
data: {
|
|
provider: PaymentMethodType.TELEBIRR,
|
|
externalEventId: input.externalEventId,
|
|
merchantOrderId: input.merchantOrderId,
|
|
providerTxnId: input.providerTxnId,
|
|
signatureValid: input.signatureValid,
|
|
status: input.status,
|
|
payload: input.payload as unknown as Prisma.InputJsonValue,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
} catch (err) {
|
|
if (
|
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
err.code === 'P2002'
|
|
) {
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
|
await this.prisma.paymentWebhookEvent.update({
|
|
where: { id: eventId },
|
|
data: { processedAt: new Date(), processingError },
|
|
});
|
|
}
|
|
|
|
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
|
if (!raw) return undefined;
|
|
const n = parseInt(raw, 10);
|
|
if (Number.isNaN(n)) return undefined;
|
|
return new Date(n * 1000);
|
|
}
|
|
}
|