-
-
Save Dozorengel/ed704f29dab16bdfbfd0b62391025fcd to your computer and use it in GitHub Desktop.
Payment integration with YooKassa API, including single and recurrent payments (NestJS)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {HttpService, Injectable} from '@nestjs/common'; | |
import {InjectRepository} from '@nestjs/typeorm'; | |
import {ConfigService} from '@nestjs/config'; | |
import {Repository} from 'typeorm'; | |
import {map} from 'rxjs/operators'; | |
import * as hmacSHA512 from 'crypto-js/hmac-sha512'; | |
import * as hex from 'crypto-js/enc-hex'; | |
import { | |
PaymentAmount, | |
PaymentData, | |
PaymentDataRecurrent, | |
PaymentHookRequest, | |
PaymentReceipt, | |
PaymentReceiptItem, | |
PaymentResponse, | |
PaymentStatus, | |
PaymentStatusDto, | |
} from '@lib/data-transfer-objects'; | |
import {Payment, PaymentItem} from './entities'; | |
import {CreateAutoPaymentRequest, CreatePaymentRequest, CreatePaymentResponse, OrderProduct} from './payment.interface'; | |
const StatusMap = { | |
[PaymentStatus.PENDING]: PaymentStatusDto.PENDING, | |
[PaymentStatus.CANCELED]: PaymentStatusDto.FAILED, | |
[PaymentStatus.SUCCEEDED]: PaymentStatusDto.PAID, | |
[PaymentStatus.WAITING_FOR_CAPTURE]: PaymentStatusDto.PENDING, | |
}; | |
@Injectable() | |
export class PaymentService { | |
private config: { | |
HOST?: string; | |
PORT?: string; | |
NOTIFICATION_URL?: string; | |
RETURN_URL?: string; | |
TOKEN?: string; | |
HMAC_KEY?: string; | |
} = {}; | |
constructor( | |
private httpService: HttpService, | |
@InjectRepository(Payment) | |
private paymentRepository: Repository<Payment>, | |
@InjectRepository(PaymentItem) | |
private paymentItemRepository: Repository<PaymentItem>, | |
private configService: ConfigService | |
) { | |
this.config.HOST = this.configService.get<string>('payment.host'); | |
this.config.PORT = this.configService.get<string>('payment.port'); | |
this.config.NOTIFICATION_URL = this.configService.get<string>('payment.notification_url'); | |
this.config.RETURN_URL = this.configService.get<string>('payment.return_url'); | |
this.config.TOKEN = this.configService.get<string>('payment.token'); | |
this.config.HMAC_KEY = this.configService.get<string>('payment.hmac_key'); | |
} | |
async createPayment(createPaymentRequest: CreatePaymentRequest): Promise<CreatePaymentResponse> { | |
const {orderProducts, email, shopAccountId, isAutoPay = false} = createPaymentRequest; | |
const payment = await this.makePayment(email, orderProducts); | |
const paymentData: PaymentData = { | |
acquiring_bank_account_id: shopAccountId, | |
amount: this.makeAmount(payment.amount), | |
confirmation: { | |
type: 'redirect', | |
return_url: this.generateReturnUrl(payment), | |
}, | |
description: 'Заказ на сайте', | |
notification_url: this.config.NOTIFICATION_URL, | |
receipt: await this.makeReceipt(payment.email, payment.paymentItems, orderProducts), | |
}; | |
if (isAutoPay) { | |
paymentData.auto = true; | |
} | |
const response = await this.sendPaymentRequest(paymentData); | |
payment.payment_external_id = response.id; | |
payment.status = PaymentStatusDto.PENDING; | |
await this.paymentRepository.save(payment); | |
return { | |
id: payment.id, | |
email: payment.email, | |
status: payment.status, | |
confirmation: response.confirmation, | |
}; | |
} | |
async createAutoPayment(createAutoPaymentRequest: CreateAutoPaymentRequest): Promise<void> { | |
const {orderProducts, email, paymentSubscription, shopAccountId} = createAutoPaymentRequest; | |
const payment = await this.makePayment(email, orderProducts); | |
const paymentData: PaymentDataRecurrent = { | |
acquiring_bank_account_id: shopAccountId, | |
notification_url: this.config.NOTIFICATION_URL, | |
auto_payment_id: paymentSubscription.recurrentPaymentExternalId, | |
}; | |
const response = await this.sendPaymentRequest(paymentData); | |
payment.payment_external_id = response.id; | |
payment.status = PaymentStatusDto.PENDING; | |
await this.paymentRepository.save(payment); | |
} | |
async confirmPayment(request: PaymentHookRequest): Promise<Payment> { | |
const payment = await this.paymentRepository.findOneOrFail( | |
{payment_external_id: request.order_id}, | |
{relations: ['paymentItems']} | |
); | |
payment.status = StatusMap[request.status]; | |
return this.paymentRepository.save(payment); | |
} | |
async failedProcessPayment(payment: Payment): Promise<Payment> { | |
payment.status = PaymentStatusDto.FAILED_IN_PROCESSING; | |
return payment.save(); | |
} | |
async processedPayment(payment: Payment): Promise<Payment> { | |
payment.status = PaymentStatusDto.PROCESSED; | |
return payment.save(); | |
} | |
private generateReturnUrl(payment: Payment) { | |
const type = payment.paymentItems[0].product_type; | |
return `${this.config.RETURN_URL}?orderId=${payment.id}&type=${type}`; | |
} | |
private async makePayment(email: string, orderProducts: OrderProduct[]): Promise<Payment> { | |
return await this.paymentRepository.manager.connection.transaction(async (manager) => { | |
let payment = this.paymentRepository.create({email, amount: 0, status: PaymentStatusDto.IN_PAY}); | |
payment = await manager.save(payment); | |
const paymentItems: PaymentItem[] = []; | |
for (const orderProduct of orderProducts) { | |
const paymentItem = this.paymentItemRepository.create({ | |
payment_id: payment.id, | |
product_type: orderProduct.productType, | |
product_id: orderProduct.product.id, | |
amount: orderProduct.product.price * orderProduct.count * 100, | |
quantity: orderProduct.count, | |
}); | |
paymentItems.push(paymentItem); | |
payment.amount += paymentItem.amount; | |
await manager.save(paymentItem); | |
} | |
payment.paymentItems = paymentItems; | |
return await manager.save(payment); | |
}); | |
} | |
private makeAmount(amount: number): PaymentAmount { | |
return { | |
value: (amount / 100).toFixed(2), | |
currency: 'RUB', | |
}; | |
} | |
private async makeReceipt( | |
email: string, | |
paymentItems: PaymentItem[], | |
orderProducts: OrderProduct[] | |
): Promise<PaymentReceipt> { | |
const receiptItems: PaymentReceiptItem[] = []; | |
for (const paymentItem of paymentItems) { | |
const orderProduct = orderProducts.find((orderProduct) => orderProduct.product.id === paymentItem.product_id); | |
receiptItems.push({ | |
description: orderProduct.product.title, | |
quantity: String(paymentItem.quantity), | |
amount: this.makeAmount(paymentItem.amount), | |
vat_code: 1, | |
payment_subject: 'commodity', | |
}); | |
} | |
return { | |
customer: {email}, | |
tax_system_code: 2, | |
items: receiptItems, | |
}; | |
} | |
private sendPaymentRequest(data: PaymentData | PaymentDataRecurrent): Promise<PaymentResponse> { | |
let paymentServiceUrl = `${this.config.HOST}:${this.config.PORT}/payments`; | |
if ('auto_payment_id' in data && data.auto_payment_id) { | |
paymentServiceUrl += '/auto'; | |
} | |
const config = {headers: {Authorization: 'Bearer ' + this.config.TOKEN}}; | |
return this.httpService | |
.post(paymentServiceUrl, data, config) | |
.pipe(map((res) => res.data)) | |
.toPromise(); | |
} | |
validateHook(request: PaymentHookRequest) { | |
const data = Object.keys(request) | |
.sort() | |
.reduce((acc, item) => ({...acc, [item]: request[item]}), {} as PaymentHookRequest); | |
const hash = data.hash; | |
delete data.hash; | |
return hash === hmacSHA512(JSON.stringify(data), this.config.HMAC_KEY).toString(hex); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment