Reference
Payments Service - Реализация
Детальная реализация Payments Service
Payments Service - Реализация
Обзор
Payments Service — микросервис на NestJS для управления платежами, интеграцией с банковской системой РФ и СБП. Отвечает за пополнение, вывод средств и сверки.
Структура проекта
apps/services/payments/src/
├── app/
│ ├── app.controller.ts
│ └── app.service.ts
├── app.module.ts
├── config/
│ └── config.module.ts
├── domains/
│ └── payments/
│ ├── application/
│ │ └── use-cases/
│ ├── domain/
│ │ ├── repositories/
│ │ ├── services/
│ │ └── entities/
│ └── presentation/
│ └── payments-grpc.gateway.ts
├── infrastructure/
│ ├── database/
│ ├── kafka/
│ ├── sbp/
│ └── logging/
├── main.ts
└── shared/Модули
AppModule
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DrizzleModule } from '@maniton/nestjs-common/database';
import { KafkaModule } from '@maniton/nestjs-common/kafka';
import { LoggingModule } from '@maniton/nestjs-common/logging';
import { PaymentsModule } from './domains/payments/payments.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
DrizzleModule.forRoot({
url: process.env.DATABASE_URL,
}),
KafkaModule.forRoot({
brokers: process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'],
clientId: 'payments-service',
}),
LoggingModule.forRoot({
level: process.env.LOG_LEVEL || 'info',
}),
PaymentsModule,
],
controllers: [],
providers: [],
})
export class AppModule {}Use Cases
CreatePaymentIntentUseCase
Создание намерения платежа для пополнения.
// domains/payments/application/use-cases/create-payment-intent.use-case.ts
import { Injectable } from '@nestjs/common';
import { PaymentIntentRepository } from '../../domain/repositories/payment-intent.repository';
import { SBPService } from '../../domain/services/sbp.service';
import { EventPublisher } from '../../domain/services/event-publisher';
@Injectable()
export class CreatePaymentIntentUseCase {
constructor(
private readonly paymentIntentRepository: PaymentIntentRepository,
private readonly sbpService: SBPService,
private readonly eventPublisher: EventPublisher,
) {}
async execute(request: {
context: any;
userId: string;
amount: { amount: string; currencyCode: string };
method: 'SBP_QR' | 'CARD';
}): Promise<any> {
const { context, userId, amount, method } = request;
// Создание PaymentIntent
const paymentIntent = await this.paymentIntentRepository.create({
userId,
amount,
method,
status: 'PENDING',
createdAt: new Date(),
});
// Генерация QR-кода для СБП
if (method === 'SBP_QR') {
const qr = await this.sbpService.createQR(Number(amount.amount.value), 'Пополнение баланса');
await this.paymentIntentRepository.update(paymentIntent.id, {
qrPayload: qr.qrPayload,
paymentUrl: qr.paymentUrl,
});
}
// Публикация события
await this.eventPublisher.publish({
type: 'PaymentIntentCreated',
payload: paymentIntent,
});
return { paymentIntent };
}
}HandlePaymentWebhookUseCase
Обработка вебхука от банка.
// domains/payments/application/use-cases/handle-payment-webhook.use-case.ts
import { Injectable } from '@nestjs/common';
import { PaymentIntentRepository } from '../../domain/repositories/payment-intent.repository';
import { EventPublisher } from '../../domain/services/event-publisher';
@Injectable()
export class HandlePaymentWebhookUseCase {
constructor(
private readonly paymentIntentRepository: PaymentIntentRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(webhook: {
paymentId: string;
status: string;
amount: { amount: string; currencyCode: string };
providerPayload: string;
}): Promise<void> {
// Валидация подписи
if (!this.validateSignature(webhook)) {
throw new Error('Invalid signature');
}
// Получение PaymentIntent
const paymentIntent = await this.paymentIntentRepository.findById(webhook.paymentId);
if (!paymentIntent) {
throw new Error('Payment intent not found');
}
// Проверка суммы
if (webhook.amount.amount.value !== paymentIntent.amount.amount.value) {
throw new Error('Amount mismatch');
}
// Обновление статуса
await this.paymentIntentRepository.update(webhook.paymentId, {
status: webhook.status,
providerPayload: webhook.providerPayload,
});
// Публикация события
if (webhook.status === 'SUCCESS') {
await this.eventPublisher.publish({
type: 'PaymentCompleted',
payload: paymentIntent,
});
}
}
private validateSignature(webhook: any): boolean {
// Валидация подписи вебхока от банка
// Требуется интеграция с криптографической библиотекой для проверки подписи
// Временно возвращаем true для разработки
return true;
}
}ReconcileUseCase
Сверка балансов.
// domains/payments/application/use-cases/reconcile.use-case.ts
import { Injectable } from '@nestjs/common';
import { BankClient } from '../../domain/clients/bank.client';
import { CfaClient } from '../../domain/clients/cfa.client';
import { ReportRepository } from '../../domain/repositories/report.repository';
@Injectable()
export class ReconcileUseCase {
constructor(
private readonly bankClient: BankClient,
private readonly cfaClient: CfaClient,
private readonly reportRepository: ReportRepository,
) {}
async execute(businessDate: Date): Promise<any> {
// Получение баланса банка
const bankBalance = await this.bankClient.getBalance();
// Получение supply CFA-RUB
const cfaRubSupply = await this.cfaClient.getSupply('CFA-RUB');
// Сравнение
const balanced = bankBalance.amount.value === cfaRubSupply.amount.value;
// Создание отчета
const report = await this.reportRepository.create({
businessDate,
bankBalance,
tokenSupply: cfaRubSupply,
balanced,
notes: balanced ? 'Balanced' : 'Discrepancy detected',
createdAt: new Date(),
});
// Алерт если не совпадает
if (!balanced) {
// Требуется интеграция с системой алертов для уведомления о рассогласовании
// Временно логируем ошибку
this.logger.error('Reconciliation failed', {
reportId: report.reportId,
bankBalance: report.bankBalance,
tokenSupply: report.tokenSupply,
});
}
return { report };
}
}Connect RPC Gateway
PaymentsGrpcGateway
// domains/payments/presentation/payments-grpc.gateway.ts
import { ConnectGateway, ConnectRouter } from '@maniton/nestjs-common/connect';
import { PaymentsService } from '@maniton/contracts/gen/ts/maniton/payments/v1/payments_pb';
import type { PaymentsUseCases } from './payments-grpc.gateway';
export class PaymentsGrpcGateway extends ConnectGateway {
protected readonly options = {
requestPathPrefix: '/payments-service',
};
protected registerRoutes(router: ConnectRouter): void {
router.service(PaymentsService, {
createPaymentIntent: (req) => this.useCases.createPaymentIntent.execute(req),
handlePaymentWebhook: (req) => this.useCases.handlePaymentWebhook.execute(req),
initiatePayout: (req) => this.useCases.initiatePayout.execute(req),
reconcile: (req) => this.useCases.reconcile.execute(req),
});
}
}Клиенты
BankClient
// domains/payments/domain/clients/bank.client.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class BankClient {
constructor(private readonly config: ConfigService) {}
async getBalance(): Promise<any> {
const apiKey = this.config.get('BANK_API_KEY');
const apiUrl = this.config.get('BANK_API_URL');
const response = await fetch(`${apiUrl}/balance`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
return response.json();
}
async initiateTransfer(params: {
account: string;
amount: number;
currency: string;
}): Promise<any> {
const apiKey = this.config.get('BANK_API_KEY');
const apiUrl = this.config.get('BANK_API_URL');
const response = await fetch(`${apiUrl}/transfer`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
return response.json();
}
}CfaClient
// domains/payments/domain/clients/cfa.client.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class CfaClient {
constructor(private readonly config: ConfigService) {}
async getSupply(instrumentId: string): Promise<any> {
const apiUrl = this.config.get('CFA_API_URL');
const response = await fetch(`${apiUrl}/supply?instrument_id=${instrumentId}`);
return response.json();
}
}Сервисы
SBPService
// domains/payments/domain/services/sbp.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SBPService {
constructor(private readonly config: ConfigService) {}
async createQR(amount: number, description: string): Promise<any> {
const apiKey = this.config.get('SBP_API_KEY');
const apiUrl = this.config.get('SBP_API_URL');
const response = await fetch(`${apiUrl}/qr`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount,
description,
}),
});
return response.json();
}
async getQRStatus(qrId: string): Promise<any> {
const apiKey = this.config.get('SBP_API_KEY');
const apiUrl = this.config.get('SBP_API_URL');
const response = await fetch(`${apiUrl}/qr/${qrId}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
return response.json();
}
}Репозитории
PaymentIntentRepository
// domains/payments/domain/repositories/payment-intent.repository.ts
import { Injectable } from '@nestjs/common';
import { DrizzleService } from '@maniton/nestjs-common/database';
import { paymentIntents } from '@maniton/contracts/gen/drizzle/schema';
import { eq } from 'drizzle-orm';
@Injectable()
export class PaymentIntentRepository {
constructor(private readonly drizzle: DrizzleService) {}
async findById(id: string) {
const [paymentIntent] = await this.drizzle.db
.select()
.from(paymentIntents)
.where(eq(paymentIntents.id, id))
.limit(1);
return paymentIntent;
}
async create(data: typeof paymentIntents.$inferInsert) {
const [paymentIntent] = await this.drizzle.db.insert(paymentIntents).values(data).returning();
return paymentIntent;
}
async update(id: string, data: Partial<typeof paymentIntents.$inferInsert>) {
const [paymentIntent] = await this.drizzle.db
.update(paymentIntents)
.set(data)
.where(eq(paymentIntents.id, id))
.returning();
return paymentIntent;
}
async list(pagination?: { limit?: number; offset?: number }) {
return this.drizzle.db
.select()
.from(paymentIntents)
.limit(pagination?.limit)
.offset(pagination?.offset);
}
}ReportRepository
// domains/payments/domain/repositories/report.repository.ts
import { Injectable } from '@nestjs/common';
import { DrizzleService } from '@maniton/nestjs-common/database';
import { reconciliationReports } from '@maniton/contracts/gen/drizzle/schema';
@Injectable()
export class ReportRepository {
constructor(private readonly drizzle: DrizzleService) {}
async create(data: typeof reconciliationReports.$inferInsert) {
const [report] = await this.drizzle.db.insert(reconciliationReports).values(data).returning();
return report;
}
async list(pagination?: { limit?: number; offset?: number }) {
return this.drizzle.db
.select()
.from(reconciliationReports)
.limit(pagination?.limit)
.offset(pagination?.offset);
}
}Мониторинг
Метрики
// infrastructure/metrics/metrics.service.ts
import { Injectable } from '@nestjs/common';
import { Counter, Histogram, Gauge } from 'prom-client';
@Injectable()
export class MetricsService {
private readonly paymentIntentsCreatedTotal = new Counter({
name: 'payments_intents_created_total',
help: 'Total number of payment intents created',
labelNames: ['method'],
});
private readonly paymentWebhooksReceivedTotal = new Counter({
name: 'payments_webhooks_received_total',
help: 'Total number of payment webhooks received',
labelNames: ['status'],
});
private readonly paymentProcessingDuration = new Histogram({
name: 'payments_processing_duration_seconds',
help: 'Time spent processing payments',
labelNames: ['method'],
buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60],
});
private readonly reconciliationDuration = new Histogram({
name: 'payments_reconciliation_duration_seconds',
help: 'Time spent on reconciliation',
buckets: [1, 5, 10, 30, 60],
});
incrementPaymentIntentCreated(method: string) {
this.paymentIntentsCreatedTotal.inc({ method });
}
incrementPaymentWebhookReceived(status: string) {
this.paymentWebhooksReceivedTotal.inc({ status });
}
recordPaymentProcessingDuration(method: string, duration: number) {
this.paymentProcessingDuration.observe({ method }, duration);
}
recordReconciliationDuration(duration: number) {
this.reconciliationDuration.observe(duration);
}
}Логи
// infrastructure/logging/logger.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class LoggerService {
private readonly context = 'PaymentsService';
info(message: string, meta?: Record<string, any>) {
console.log(`[${this.context}] [INFO] ${message}`, meta);
}
error(message: string, error?: Error, meta?: Record<string, any>) {
if (error) {
console.error(`[${this.context}] [ERROR] ${message}`, error, meta);
} else {
console.error(`[${this.context}] [ERROR] ${message}`, meta);
}
}
warn(message: string, meta?: Record<string, any>) {
console.warn(`[${this.context}] [WARN] ${message}`, meta);
}
debug(message: string, meta?: Record<string, any>) {
if (process.env.LOG_LEVEL === 'debug') {
console.debug(`[${this.context}] [DEBUG] ${message}`, meta);
}
}
}Тестирование
Unit Tests
// domains/payments/application/use-cases/create-payment-intent.use-case.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CreatePaymentIntentUseCase } from './create-payment-intent.use-case';
import { PaymentIntentRepository } from '../../domain/repositories/payment-intent.repository';
import { SBPService } from '../../domain/services/sbp.service';
describe('CreatePaymentIntentUseCase', () => {
let useCase: CreatePaymentIntentUseCase;
let paymentIntentRepository: jest.Mocked<PaymentIntentRepository>;
let sbpService: jest.Mocked<SBPService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CreatePaymentIntentUseCase,
{
provide: PaymentIntentRepository,
useValue: {
create: jest.fn(),
update: jest.fn(),
},
},
{
provide: SBPService,
useValue: {
createQR: jest.fn().mockResolvedValue({
qrPayload: 'qr-payload',
paymentUrl: 'https://payment.url',
}),
},
},
],
}).compile();
useCase = module.get(CreatePaymentIntentUseCase);
paymentIntentRepository = module.get(PaymentIntentRepository);
sbpService = module.get(SBPService);
});
it('should create payment intent', async () => {
const request = {
context: { requestId: '1', correlationId: '1', idempotencyKey: '1' },
userId: 'user-123',
amount: { amount: '1000', currencyCode: 'RUB' },
method: 'SBP_QR',
};
const result = await useCase.execute(request);
expect(paymentIntentRepository.create).toHaveBeenCalled();
expect(sbpService.createQR).toHaveBeenCalled();
});
});Troubleshooting
Проблема: Вебхук не приходит
Решение:
- Проверьте, что endpoint доступен извне
- Проверьте конфигурацию CORS
- Проверьте логи безопасности (WAF)
Проблема: Сверка не проходит
Решение:
- Проверьте синхронизацию времени
- Проверьте транзакции, которые не прошли
- Ручной разбор расхождений