Манитон Docs
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

Проблема: Вебхук не приходит

Решение:

  1. Проверьте, что endpoint доступен извне
  2. Проверьте конфигурацию CORS
  3. Проверьте логи безопасности (WAF)

Проблема: Сверка не проходит

Решение:

  1. Проверьте синхронизацию времени
  2. Проверьте транзакции, которые не прошли
  3. Ручной разбор расхождений

Дополнительные ресурсы

On this page