Skip to content

Commit 9c3b0d5

Browse files
authored
feat(webhook): validate Heleket IP and signature (#44)
1 parent ca27d5f commit 9c3b0d5

7 files changed

Lines changed: 147 additions & 13 deletions

File tree

src/api/payment/webhook/webhook.controller.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ import {
88
} from '@nestjs/common'
99
import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'
1010

11+
import { HeleketService } from '@/libs/heleket/heleket.service'
12+
1113
import { WebhookService } from './webhook.service'
1214

1315
@Controller('webhook')
1416
export class WebhookController {
15-
public constructor(private readonly webhookService: WebhookService) {}
17+
public constructor(
18+
private readonly webhookService: WebhookService,
19+
private readonly heleketService: HeleketService
20+
) {}
1621

1722
@ApiOperation({
1823
summary: 'YooKassa Webhook',
@@ -34,10 +39,20 @@ export class WebhookController {
3439
return { ok: true }
3540
}
3641

42+
@ApiOperation({
43+
summary: 'Heleket Webhook',
44+
description: 'Endpoint to receive Heleket crypto payment events.'
45+
})
46+
@ApiOkResponse({
47+
description: 'Webhook processed successfully',
48+
schema: {
49+
example: { ok: true }
50+
}
51+
})
3752
@Post('crypto')
3853
@HttpCode(HttpStatus.OK)
39-
public async cryptoWebhook(@Body() payload: any) {
40-
console.log(payload)
54+
public async cryptoWebhook(@Body() payload: any, @Ip() ip: string) {
55+
this.heleketService.verifyWebhook(ip, payload)
4156

4257
await this.webhookService.handleCrypto(payload)
4358

src/api/payment/webhook/webhook.service.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,22 @@ export class WebhookService {
7272
}
7373

7474
public async handleCrypto(payload: any) {
75-
if (payload.status !== 'paid' || payload.status !== 'paid_over') return
76-
77-
await this.processPayment({
78-
provider: 'crypto',
79-
paymentId: payload.order_id,
80-
paymentData: payload
81-
})
75+
if (payload.status === 'paid' || payload.status === 'paid_over') {
76+
return await this.processPayment({
77+
provider: 'crypto',
78+
paymentId: payload.order_id,
79+
paymentData: payload
80+
})
81+
} else if (payload.status === 'fail') {
82+
return await this.prismaService.payment.update({
83+
where: {
84+
id: payload.order_id
85+
},
86+
data: {
87+
status: PaymentStatus.FAILED
88+
}
89+
})
90+
}
8291
}
8392

8493
private async processPayment({

src/libs/heleket/enums/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './payment-status.enum'
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Статусы платежей Heleket
3+
*/
4+
export enum HeleketPaymentStatus {
5+
CONFIRM_CHECK = 'confirm_check',
6+
PAID = 'paid',
7+
PAID_OVER = 'paid_over',
8+
FAIL = 'fail',
9+
WRONG_AMOUNT = 'wrong_amount',
10+
CANCEL = 'cancel',
11+
SYSTEM_FAIL = 'system_fail',
12+
REFUND_PROCESS = 'refund_process',
13+
REFUND_FAIL = 'refund_fail',
14+
REFUND_PAID = 'refund_paid'
15+
}

src/libs/heleket/heleket.service.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
11
import { HttpService } from '@nestjs/axios'
2-
import { Inject, Injectable } from '@nestjs/common'
3-
import { createHash } from 'crypto'
2+
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
3+
import { createHash, timingSafeEqual } from 'crypto'
44
import { firstValueFrom } from 'rxjs'
55

66
import { type HeleketOptions, HeleketOptionsSymbol } from '@/common/interfaces'
77

88
import type {
99
ApiResponse,
1010
CreatePaymentRequest,
11-
CreatePaymentResponse
11+
CreatePaymentResponse,
12+
HeleketPaymentWebhook
1213
} from './interfaces'
1314

1415
@Injectable()
1516
export class HeleketService {
1617
private readonly API_URL: string
18+
private readonly TRUSTED_IPS: string[]
1719

1820
public constructor(
1921
@Inject(HeleketOptionsSymbol)
2022
private readonly options: HeleketOptions,
2123
private readonly httpService: HttpService
2224
) {
2325
this.API_URL = 'https://api.heleket.com/v1'
26+
this.TRUSTED_IPS = ['31.133.220.8']
2427
}
2528

2629
public async createPayment(data: CreatePaymentRequest) {
@@ -32,6 +35,31 @@ export class HeleketService {
3235
return response.result
3336
}
3437

38+
public verifyWebhook(ip: string, payload: HeleketPaymentWebhook) {
39+
if (!this.TRUSTED_IPS.includes(ip))
40+
throw new BadRequestException('Invalid IP')
41+
42+
const { sign } = payload
43+
const dataCopy = { ...payload }
44+
delete dataCopy.sign
45+
46+
const jsonData = JSON.stringify(dataCopy).replace(/\//g, '\\/')
47+
48+
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64')
49+
50+
const hash = createHash('md5')
51+
.update(base64Data + this.options.apiKey, 'utf8')
52+
.digest('hex')
53+
54+
if (
55+
!timingSafeEqual(
56+
Buffer.from(hash.toLowerCase(), 'utf8'),
57+
Buffer.from(sign.toLowerCase(), 'utf8')
58+
)
59+
)
60+
throw new BadRequestException('Invalid signature')
61+
}
62+
3563
private async request<T = any>(
3664
path: string,
3765
body: any = {}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './common.interface'
22
export * from './create-payment.interface'
3+
export * from './webhook.interface'
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { HeleketPaymentStatus } from '../enums'
2+
3+
/**
4+
* Информация о конвертации валюты для платежа
5+
*/
6+
export interface HeleketPaymentConvert {
7+
/** Код валюты, в которую будет конвертирован платеж */
8+
to_currency: string
9+
/** Комиссия за конвертацию */
10+
commission: string
11+
/** Курс конверсии */
12+
rate: string
13+
/** Сумма конверсии в to_currency, которая была добавлена на баланс продавца с вычетом комиссии (merchant_amount * rate) */
14+
amount: string
15+
}
16+
17+
/**
18+
* Payload webhook от Heleket
19+
*/
20+
export interface HeleketPaymentWebhook {
21+
/** Тип счета */
22+
type: 'payment'
23+
/** UUID платежа */
24+
uuid: string
25+
/** Идентификатор заказа в вашей системе */
26+
order_id: string
27+
/** Сумма счета-фактуры */
28+
amount: string
29+
/** Сумма, фактически уплаченная клиентом (может быть null, если платеж ещё не совершен) */
30+
payment_amount: string | null
31+
/** Сумма в долларах США, фактически оплаченная клиентом */
32+
payment_amount_usd?: string
33+
/** Сумма, добавленная на баланс продавца с вычетом комиссии */
34+
merchant_amount: string | null
35+
/** Сумма комиссии Heleket */
36+
commission?: string
37+
/** Завершена ли счет-фактура */
38+
is_final: boolean
39+
/** Статус платежа */
40+
status: HeleketPaymentStatus
41+
/** Адрес кошелька плательщика */
42+
from?: string | null
43+
/** UUID статического кошелька */
44+
wallet_address_uuid?: string | null
45+
/** Сеть блокчейна */
46+
network?: string
47+
/** Валюта счета-фактуры */
48+
currency: string
49+
/** Валюта, которой клиент расплатился */
50+
payer_currency: string
51+
/** Сумма в валюте плательщика */
52+
payer_amount: string
53+
/** Курс обмена для payer_amount */
54+
payer_amount_exchange_rate?: string
55+
/** Дополнительная информация */
56+
additional_data?: string | null
57+
/** Идентификатор трансфера */
58+
transfer_id?: string | null
59+
/** Хэш транзакции в блокчейне */
60+
txid?: string | null
61+
/** Подпись webhook */
62+
sign: string
63+
/** Информация о конвертации валюты */
64+
convert?: HeleketPaymentConvert
65+
}

0 commit comments

Comments
 (0)