Skip to content

Sistema de Depositos

Documentacao completa do fluxo de depositos, desde a iniciacao ate a confirmacao via webhook. Todos os valores monetarios sao armazenados e trafegados em centavos (BIGINT).


Arquivos Fonte

ArquivoResponsabilidade
src/application/services/deposit.service.tsServico principal de depositos
src/infrastructure/payment/horsepay.provider.tsProvider de pagamento PIX (HorsePay)
src/infrastructure/database/repositories/deposit.repository.tsRepository Prisma para depositos
src/domain/repositories/deposit.repository.interface.tsInterface do repository
src/presentation/controllers/payment.controller.tsController HTTP
src/presentation/modules/payment.module.tsModulo NestJS
src/application/dto/payment.dto.tsDTOs de request/response
src/infrastructure/websocket/websocket.gateway.tsEmissao de eventos WebSocket
src/application/services/transaction.service.tsCriacao de transacoes de credito
src/application/services/money.service.tsFormatacao de valores monetarios

Modelo Prisma (Deposit)

Definido em prisma/schema.prisma, linhas 176-193:

prisma
model Deposit {
  id              BigInt          @id
  userId          BigInt          @map("user_id")
  amountCents     BigInt          @map("amount_cents")
  method          PaymentMethod
  externalId      String?         @unique @map("external_id")
  status          DepositStatus
  metadata        Json?
  createdAt       DateTime        @default(now()) @map("created_at")
  confirmedAt     DateTime?       @map("confirmed_at")

  user            User            @relation(fields: [userId], references: [id])

  @@map("deposits")
  @@index([userId, status])
  @@index([externalId])
  @@index([createdAt])
}

Enums Relacionados

prisma
enum PaymentMethod {
  PIX
  CREDIT_CARD
  CRYPTO
}

enum DepositStatus {
  PENDING
  CONFIRMED
  FAILED
  EXPIRED
}

Campos Detalhados

CampoTipoDescricao
idBigIntSnowflake ID gerado pelo SnowflakeService
userIdBigIntID do usuario que iniciou o deposito
amountCentsBigIntValor do deposito em centavos (ex: 10000 = R$100,00)
methodPaymentMethodMetodo de pagamento (PIX, CREDIT_CARD, CRYPTO)
externalIdString?ID externo retornado pelo provider (HorsePay external_id)
statusDepositStatusEstado atual do deposito
metadataJson?Dados adicionais (pixCopyPaste, pixQrCodeBase64, horsePayExternalId)
createdAtDateTimeTimestamp de criacao
confirmedAtDateTime?Timestamp de confirmacao (preenchido ao confirmar)

Indices

  • @@index([userId, status]) - Consultas por usuario filtradas por status
  • @@index([externalId]) - Busca por ID externo (webhook)
  • @@index([createdAt]) - Ordenacao cronologica

Interface do Repository

Definida em src/domain/repositories/deposit.repository.interface.ts:

typescript
import { Deposit, DepositStatus, PaymentMethod } from '@prisma/client';

export interface IDepositRepository {
  create(data: CreateDepositData): Promise<Deposit>;
  findById(id: bigint): Promise<Deposit | null>;
  findByExternalId(externalId: string): Promise<Deposit | null>;
  findByUserId(userId: bigint, limit?: number, offset?: number): Promise<Deposit[]>;
  updateStatus(id: bigint, status: DepositStatus, confirmedAt?: Date): Promise<Deposit>;
  updatePaymentData(id: bigint, externalId: string, metadata: any): Promise<Deposit>;
}

export interface CreateDepositData {
  userId: bigint;
  amountCents: bigint;
  method: PaymentMethod;
  externalId?: string;
  metadata?: any;
}

Implementacao do Repository

Definido em src/infrastructure/database/repositories/deposit.repository.ts:

typescript
@Injectable()
export class DepositRepository implements IDepositRepository {
  constructor(
    private prisma: PrismaService,
    private snowflake: SnowflakeService,
  ) {}

  async create(data: CreateDepositData): Promise<Deposit> {
    return this.prisma.deposit.create({
      data: {
        id: this.snowflake.generate(),
        userId: data.userId,
        amountCents: data.amountCents,
        method: data.method,
        externalId: data.externalId,
        status: 'PENDING',
        metadata: data.metadata,
      },
    });
  }

  async findById(id: bigint): Promise<Deposit | null> {
    return this.prisma.deposit.findUnique({
      where: { id },
    });
  }

  async findByExternalId(externalId: string): Promise<Deposit | null> {
    return this.prisma.deposit.findUnique({
      where: { externalId },
    });
  }

  async findByUserId(userId: bigint, limit: number = 50, offset: number = 0): Promise<Deposit[]> {
    return this.prisma.deposit.findMany({
      where: { userId },
      orderBy: { createdAt: 'desc' },
      take: limit,
      skip: offset,
    });
  }

  async updateStatus(id: bigint, status: DepositStatus, confirmedAt?: Date): Promise<Deposit> {
    return this.prisma.deposit.update({
      where: { id },
      data: {
        status,
        confirmedAt,
      },
    });
  }

  async updatePaymentData(id: bigint, externalId: string, metadata: any): Promise<Deposit> {
    return this.prisma.deposit.update({
      where: { id },
      data: {
        externalId,
        metadata,
      },
    });
  }
}

Pontos importantes:

  • O id e gerado via SnowflakeService.generate() (BigInt unico distribuido)
  • Todo deposito inicia com status: 'PENDING'
  • findByExternalId usa indice @@index([externalId]) com constraint @unique

DepositService

Servico principal definido em src/application/services/deposit.service.ts.

Dependencias Injetadas

typescript
@Injectable()
export class DepositService {
  private readonly logger = new Logger(DepositService.name);

  constructor(
    private depositRepository: DepositRepository,
    private transactionService: TransactionService,
    private userRepository: UserRepository,
    private horsePayProvider: HorsePayProvider,
    private webSocketGateway: WebSocketGatewayService,
    private moneyService: MoneyService,
  ) {}
}

Interface de Retorno

typescript
export interface InitiateDepositResult {
  id: string;
  userId: string;
  amountCents: string;
  method: PaymentMethod;
  status: string;
  pixCopyPaste?: string;
  pixQrCodeBase64?: string;
  createdAt: Date;
}

Metodo: initiateDeposit

Inicia um novo deposito. Cria o registro no banco e, se o metodo for PIX, chama o HorsePay para gerar o QR Code.

typescript
async initiateDeposit(
  userId: bigint,
  amountCents: bigint,
  method: PaymentMethod,
  payerName?: string,
): Promise<InitiateDepositResult>

Parametros:

ParametroTipoObrigatorioDescricao
userIdbigintSimID do usuario
amountCentsbigintSimValor em centavos
methodPaymentMethodSimPIX, CREDIT_CARD ou CRYPTO
payerNamestringNaoNome do pagador (usado no PIX)

Validacoes:

typescript
if (amountCents < 1000) {
  throw new BadRequestException('Minimum deposit is R$ 10.00');
}

if (amountCents > 500000) {
  throw new BadRequestException('Maximum deposit is R$ 5,000.00');
}
  • Deposito minimo: 1000 centavos (R$10,00)
  • Deposito maximo: 500000 centavos (R$5.000,00)

Fluxo Passo a Passo:

  1. Valida valor minimo (>= 100 centavos)
  2. Cria registro Deposit no banco com status: PENDING
  3. Se metodo = PIX: a. Busca dados do usuario (nome) via UserRepository.findById b. Chama HorsePayProvider.createPixOrder() com o depositId como client_reference_id c. Atualiza o deposito com externalId e metadata (pixCopyPaste, pixQrCodeBase64, horsePayExternalId) d. Em caso de erro na criacao do PIX, atualiza status para FAILED e relanca o erro
  4. Retorna o resultado com dados do deposito e codigos PIX

Retorno:

typescript
return {
  id: deposit.id.toString(),
  userId: deposit.userId.toString(),
  amountCents: deposit.amountCents.toString(),
  method: deposit.method,
  status: deposit.status,
  pixCopyPaste,        // Codigo PIX copia e cola
  pixQrCodeBase64,     // QR Code em base64
  createdAt: deposit.createdAt,
};

Tratamento de Erro no PIX:

typescript
try {
  const pixOrder = await this.horsePayProvider.createPixOrder(
    deposit.id.toString(),
    name,
    amountCents,
  );
  // ... atualiza deposito com dados PIX
} catch (error: any) {
  this.logger.error(`Failed to create PIX order for deposit ${deposit.id}`, error.message);
  await this.depositRepository.updateStatus(deposit.id, 'FAILED');
  throw error;
}

Se a criacao do PIX falhar:

  • O deposito e marcado como FAILED
  • O erro e propagado para o controller
  • O usuario recebe a mensagem de erro

Metodo: confirmDeposit

Confirma um deposito pendente, credita o saldo do usuario com bonus, distribui tickets e emite eventos WebSocket.

typescript
async confirmDeposit(depositId: bigint)

Fluxo Passo a Passo:

  1. Busca o deposito via depositRepository.findById
  2. Valida estado: se nao existe, lanca NotFoundException; se nao esta PENDING, lanca BadRequestException
  3. Calcula bonus: o percentual vem do cupom aplicado ao deposito (dinamico). O exemplo abaixo assume um cupom de 10%
typescript
const bonusPercentage = 10; // percentual do cupom aplicado (exemplo)
const bonusAmount = (deposit.amountCents * BigInt(bonusPercentage)) / BigInt(100);
const totalCredit = deposit.amountCents + bonusAmount;

Exemplo: deposito de R$100,00 (10000 centavos)

  • bonusAmount = 10000 * 10 / 100 = 1000 centavos (R$10,00)
  • totalCredit = 10000 + 1000 = 11000 centavos (R$110,00)
  1. Cria transacao de credito via TransactionService.credit():
typescript
await this.transactionService.credit(
  deposit.userId,
  totalCredit,
  `Deposit confirmed #${depositId} (+${bonusPercentage}% bonus)`,
  {
    depositId: depositId.toString(),
    method: deposit.method,
    bonusPercentage,
    bonusAmountCents: bonusAmount.toString(),
  },
);
  1. Atualiza estatisticas do usuario:
typescript
await this.userRepository.updateStats(deposit.userId, {
  totalDepositedCents: deposit.amountCents,
});

Nota: totalDepositedCents recebe o valor SEM bonus (valor real depositado).

  1. Calcula e distribui tickets de sorteio (raffle):
typescript
const ticketsToAdd = Math.floor(Number(deposit.amountCents) / 3000);
if (ticketsToAdd > 0) {
  const user = await this.userRepository.findById(deposit.userId);
  if (user) {
    await this.userRepository.update(deposit.userId, {
      tickets: user.tickets + ticketsToAdd,
    });
  }
}

Regra: 1 ticket a cada R$30,00 (3000 centavos) depositados.

  • Deposito de R$100,00: Math.floor(10000 / 3000) = 3 tickets
  • Deposito de R$29,99: Math.floor(2999 / 3000) = 0 tickets
  • Deposito de R$90,00: Math.floor(9000 / 3000) = 3 tickets
  1. Calcula e distribui event tickets:
typescript
const eventTicketsToAdd = ticketsToAdd * 250;
if (eventTicketsToAdd > 0) {
  await this.userRepository.incrementEventTickets(deposit.userId, eventTicketsToAdd);
}

Regra: cada ticket de sorteio gera 250 event tickets.

  • 3 tickets de sorteio = 750 event tickets
  1. Atualiza status do deposito para CONFIRMED com timestamp:
typescript
const updatedDeposit = await this.depositRepository.updateStatus(
  depositId,
  'CONFIRMED',
  new Date(),
);
  1. Emite eventos WebSocket:
typescript
const newBalanceCents = await this.transactionService.getUserBalance(deposit.userId);

this.webSocketGateway.emitBalanceUpdate(deposit.userId, newBalanceCents);
this.webSocketGateway.emitDepositConfirmed(deposit.userId, {
  id: depositId.toString(),
  amountCents: totalCredit.toString(),
  amountFormatted: this.moneyService.format(totalCredit),
  method: deposit.method,
});

Dois eventos sao emitidos para a sala privada user:{userId}:

  • balance:updated com { balanceCents: string } - saldo atualizado em centavos
  • deposit:confirmed com detalhes do deposito (valor COM bonus)

IMPORTANTE: O emitBalanceUpdate recebe bigint direto (centavos), NUNCA formatado.


Metodo: handleWebhook

Recebe webhooks de provedores de pagamento e roteia para o handler correto.

typescript
async handleWebhook(provider: string, data: any)

Fluxo:

typescript
if (provider === 'horsepay') {
  return this.handleHorsePayWebhook(data);
}

// Fallback para outros providers
const externalId = data.externalId || data.id;
const deposit = await this.depositRepository.findByExternalId(externalId);

if (data.status === 'approved' || data.status === 'paid') {
  return this.confirmDeposit(deposit.id);
} else if (data.status === 'failed' || data.status === 'cancelled') {
  return this.depositRepository.updateStatus(deposit.id, 'FAILED');
}

Metodo: handleHorsePayWebhook (privado)

Handler especifico para webhooks do HorsePay.

typescript
private async handleHorsePayWebhook(data: any)

Fluxo Passo a Passo:

  1. Verifica a assinatura HMAC: se HORSE_WEBHOOK_SECRET esta configurado, a assinatura e obrigatoria. A assinatura recebida no header X-Signature: sha256=<hex> e recalculada como HMAC-SHA256(rawBody, HORSE_WEBHOOK_SECRET) e comparada em tempo constante (timingSafeEqual). Assinatura ausente ou invalida -> 401 (nao processa). O rawBody cru e necessario para o calculo (ver rawBody: true no main.ts).
  2. Parseia o payload via horsePayProvider.parseWebhookPayload(data)
  3. Valida presenca do client_reference_id (que e o depositId)
  4. Busca o deposito via depositRepository.findById(BigInt(client_reference_id))
  5. Idempotencia: se o deposito ja nao esta PENDING, retorna sucesso sem reprocessar
typescript
if (deposit.status !== 'PENDING') {
  this.logger.warn(`Deposit ${depositId} already processed (status: ${deposit.status})`);
  return { success: true, message: 'Deposit already processed' };
}
  1. Pull-confirm (nao confia no status do webhook): chama horsePayProvider.getDepositStatus(externalId) para consultar o status direto na API do HorsePay. So credita se o provider reportar success/paid E o valor conferir (confirmation.valueCents === deposit.amountCents).
  2. Confirmacao: com status pago e valor conferido, chama confirmDeposit(deposit.id) para creditar o usuario.
  3. Divergencia de valor: se o valor pago diverge do esperado, registra auditoria DEPOSIT_AMOUNT_MISMATCH e nao credita.

Idempotencia e crucial: o HorsePay pode enviar o mesmo webhook multiplas vezes. O sistema garante que o deposito so e processado uma vez verificando deposit.status !== 'PENDING'.

Seguranca: o backend nunca credita apenas com base no booleano status do webhook. A confirmacao exige assinatura HMAC valida + pull-confirm server-side (getDepositStatus) + conferencia de valor. Isso fecha a falha onde um payload forjado ({"status": true}) poderia creditar saldo.


Metodo: getUserDeposits

Lista depositos de um usuario com paginacao.

typescript
async getUserDeposits(userId: bigint, limit?: number, offset?: number)

Delega para depositRepository.findByUserId(userId, limit, offset). Default: limit = 50, offset = 0.


HorsePay Provider

Provider de pagamento PIX definido em src/infrastructure/payment/horsepay.provider.ts.

Configuracao

typescript
@Injectable()
export class HorsePayProvider {
  private readonly apiClient: AxiosInstance;
  private readonly clientKey: string;
  private readonly clientSecret: string;
  private readonly callbackUrl: string;

  private accessToken: string | null = null;
  private tokenExpiresAt: number = 0;

  constructor() {
    this.clientKey = process.env.HORSE_CLIENT || '';
    this.clientSecret = process.env.HORSE_SECRET || '';
    this.callbackUrl = `${process.env.APP_URL || 'https://api.procases.example'}/payment/deposit/webhook/horsepay`;

    this.apiClient = axios.create({
      baseURL: 'https://api.horsepay.io',
      timeout: 30000,
      headers: {
        'Content-Type': 'application/json',
      },
    });
  }
}

Variaveis de Ambiente Necessarias:

VariavelDescricao
HORSE_CLIENTClient key para autenticacao
HORSE_SECRETClient secret para autenticacao
APP_URLURL base da API (para construir callback URL)

Callback URL: {APP_URL}/payment/deposit/webhook/horsepay


Metodo: authenticate (privado)

Autentica com a API do HorsePay e cacheia o token.

typescript
private async authenticate(): Promise<string>

Endpoint: POST https://api.horsepay.io/auth/token

Body:

json
{
  "client_key": "HORSE_CLIENT",
  "client_secret": "HORSE_SECRET"
}

Response:

json
{
  "access_token": "eyJhbGci..."
}

Cache do Token:

typescript
this.accessToken = response.data.access_token;
this.tokenExpiresAt = now + 55 * 60 * 1000; // 55 minutos

O token e cacheado por 55 minutos em memoria. Na proxima chamada, se o token ainda nao expirou, e reutilizado sem nova requisicao HTTP.

Erro de Autenticacao:

typescript
throw new HttpException(
  'Payment provider authentication failed',
  HttpStatus.SERVICE_UNAVAILABLE,  // 503
);

Metodo: createPixOrder

Cria uma ordem de pagamento PIX no HorsePay.

typescript
async createPixOrder(
  depositId: string,
  payerName: string,
  amountCents: bigint,
): Promise<CreatePixOrderResult>

Endpoint: POST https://api.horsepay.io/transaction/neworder

Header: Authorization: Bearer {access_token}

Request Body (Interface):

typescript
interface HorsePayOrderRequest {
  payer_name: string;
  amount: number;        // valor em REAIS (nao centavos)
  callback_url: string;
  client_reference_id: string;  // depositId
}

Conversao de centavos para reais:

typescript
const amountReais = Number(amountCents) / 100;

Request Body (Exemplo):

json
{
  "payer_name": "Joao Silva",
  "amount": 100.00,
  "callback_url": "https://api.procases.example/payment/deposit/webhook/horsepay",
  "client_reference_id": "7142591820701696"
}

Response (Interface):

typescript
interface HorsePayOrderResponse {
  copy_past: string;      // Codigo PIX copia e cola
  external_id: number;    // ID externo do HorsePay
  payer_name: string;
  payment: string;        // QR Code em base64
  status: number;
}

Retorno mapeado:

typescript
return {
  externalId: external_id.toString(),
  pixCopyPaste: copy_past,
  pixQrCodeBase64: payment,
};

Tratamento de Erro 401 (Token Expirado):

typescript
if (error.response?.status === 401) {
  this.accessToken = null;
  this.tokenExpiresAt = 0;
  throw new HttpException(
    'Payment provider authentication expired',
    HttpStatus.SERVICE_UNAVAILABLE,
  );
}

Quando recebe 401, limpa o cache do token. Na proxima tentativa, o authenticate() vai buscar um novo token.


Metodo: parseWebhookPayload

Parseia o payload do webhook do HorsePay.

typescript
parseWebhookPayload(data: any): HorsePayWebhookPayload

Interface do Payload:

typescript
export interface HorsePayWebhookPayload {
  amount: number;              // Valor pago em reais
  document: string;            // CPF/CNPJ do pagador
  end_to_end: string;          // ID end-to-end do PIX (Banco Central)
  external_id: number;         // ID da ordem no HorsePay
  name: string;                // Nome do pagador
  status: boolean;             // true = pago, false = nao pago
  client_reference_id: string; // depositId (nosso ID interno)
}

Exemplo de payload recebido:

json
{
  "amount": 100.00,
  "document": "12345678901",
  "end_to_end": "E12345678901234567890123456789012",
  "external_id": 98765,
  "name": "Joao Silva",
  "status": true,
  "client_reference_id": "7142591820701696"
}

Metodo: isPaymentConfirmed

Verifica se o pagamento foi confirmado baseado no payload.

typescript
isPaymentConfirmed(payload: HorsePayWebhookPayload): boolean {
  return payload.status === true;
}

Este helper apenas inspeciona o campo status do payload. Ele nao e a fonte de confirmacao: o handleHorsePayWebhook so credita apos validar a assinatura HMAC e fazer o pull-confirm server-side (getDepositStatus) com conferencia de valor. O booleano do webhook sozinho nunca credita saldo.


Controller HTTP

Definido em src/presentation/controllers/payment.controller.ts.

POST /payment/deposit/initiate

Inicia um novo deposito. Requer autenticacao.

typescript
@Post('deposit/initiate')
@ApiOperation({ summary: 'Initiate a deposit' })
@ApiResponse({ status: 201, type: DepositResponseDto })
async initiateDeposit(
  @CurrentUser() user: User,
  @Body() dto: InitiateDepositDto,
): Promise<DepositResponseDto>

Request DTO:

typescript
export class InitiateDepositDto {
  @ApiProperty({ example: 10000, description: 'Amount in cents (R$ 100.00 = 10000)' })
  @IsNumber()
  @Min(1000)
  @Max(500000)
  amountCents: number;

  @ApiProperty({ enum: ['PIX', 'CREDIT_CARD', 'CRYPTO'] })
  @IsEnum(['PIX', 'CREDIT_CARD', 'CRYPTO'])
  method: string;

  @ApiProperty({ example: 'Joao Silva', required: false })
  @IsString()
  @IsOptional()
  payerName?: string;
}

Validacoes do DTO:

  • amountCents: numero, minimo 1000 (R$10,00), maximo 500000 (R$5.000,00)
  • method: enum restrito a PIX, CREDIT_CARD, CRYPTO
  • payerName: string opcional

Response DTO:

typescript
export class DepositResponseDto {
  id: string;
  userId: string;
  amountCents: string;
  amount: string;              // Valor formatado em reais (ex: "100.00")
  method: string;
  status: string;
  externalId?: string;
  pixCopyPaste?: string;       // Codigo PIX copia e cola
  pixQrCodeBase64?: string;    // QR Code em base64
  createdAt: string;
  confirmedAt?: string;
}

Exemplo de Request:

json
POST /payment/deposit/initiate
Content-Type: application/json
Cookie: sessionId=abc123

{
  "amountCents": 10000,
  "method": "PIX",
  "payerName": "Joao Silva"
}

Exemplo de Response (201):

json
{
  "id": "7142591820701696",
  "userId": "7142591820701400",
  "amountCents": "10000",
  "amount": "100.00",
  "method": "PIX",
  "status": "PENDING",
  "pixCopyPaste": "00020126580014br.gov.bcb.pix01...",
  "pixQrCodeBase64": "iVBORw0KGgoAAAANSUhEUgAA...",
  "createdAt": "2025-01-15T14:30:00.000Z"
}

POST /payment/deposit/webhook/:provider

Endpoint publico para receber webhooks de provedores de pagamento.

typescript
@Post('deposit/webhook/:provider')
@Public()
@ApiOperation({ summary: 'Webhook for deposit confirmation' })
@ApiResponse({ status: 200 })
async depositWebhook(@Param('provider') provider: string, @Body() data: any)

Pontos importantes:

  • Decorado com @Public() - NAO requer autenticacao
  • O parametro provider identifica o provider (ex: horsepay)
  • O body e tipado como any pois cada provider tem formato diferente

URL completa: POST /payment/deposit/webhook/horsepay


GET /payment/deposits

Lista depositos do usuario autenticado com paginacao.

typescript
@Get('deposits')
@ApiOperation({ summary: 'Get user deposits' })
@ApiResponse({ status: 200, type: [DepositResponseDto] })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'offset', required: false, type: Number })
async getDeposits(
  @CurrentUser() user: User,
  @Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
  @Query('offset', new ParseIntPipe({ optional: true })) offset?: number,
): Promise<DepositResponseDto[]>

Exemplo de Request:

GET /payment/deposits?limit=20&offset=0
Cookie: sessionId=abc123

Exemplo de Response (200):

json
[
  {
    "id": "7142591820701696",
    "userId": "7142591820701400",
    "amountCents": "10000",
    "amount": "100.00",
    "method": "PIX",
    "status": "CONFIRMED",
    "externalId": "98765",
    "createdAt": "2025-01-15T14:30:00.000Z",
    "confirmedAt": "2025-01-15T14:31:00.000Z"
  }
]

Diagrama de Fluxo Completo

Fluxo Normal (PIX com Sucesso)

+----------+     +-------------+     +-----------------+     +-----------+     +----------+
| Frontend |     | Controller  |     | DepositService  |     | HorsePay  |     | Database |
+----------+     +-------------+     +-----------------+     +-----------+     +----------+
     |                 |                     |                     |                |
     | POST /deposit/initiate               |                     |                |
     |---------------->|                     |                     |                |
     |                 | initiateDeposit()   |                     |                |
     |                 |-------------------->|                     |                |
     |                 |                     | Valida >= 100 cents |                |
     |                 |                     |                     |                |
     |                 |                     | create(PENDING)     |                |
     |                 |                     |------------------------------------------->|
     |                 |                     |                     |           Deposit ID |
     |                 |                     |<-------------------------------------------|
     |                 |                     |                     |                |
     |                 |                     | findById(userId)    |                |
     |                 |                     |------------------------------------------->|
     |                 |                     |                     |        User data |
     |                 |                     |<-------------------------------------------|
     |                 |                     |                     |                |
     |                 |                     | createPixOrder()    |                |
     |                 |                     |-------------------->|                |
     |                 |                     |                     | POST /auth/token
     |                 |                     |                     |----+           |
     |                 |                     |                     |    | (se cache  |
     |                 |                     |                     |<---+ expirado)  |
     |                 |                     |                     |                |
     |                 |                     |                     | POST /neworder |
     |                 |                     |                     |----+           |
     |                 |                     |                     |    |           |
     |                 |                     |                     |<---+           |
     |                 |                     | {pixCopyPaste, qr}  |                |
     |                 |                     |<--------------------|                |
     |                 |                     |                     |                |
     |                 |                     | updatePaymentData() |                |
     |                 |                     |------------------------------------------->|
     |                 |                     |                     |                |
     |                 | {id, pixCopyPaste}  |                     |                |
     |                 |<--------------------|                     |                |
     | Response        |                     |                     |                |
     |<----------------|                     |                     |                |
     |                 |                     |                     |                |
     | Mostra QR Code  |                     |                     |                |
     | Usuario paga    |                     |                     |                |
     |                 |                     |                     |                |

Fluxo do Webhook (Confirmacao)

Diagrama simplificado (caminho de credito). Antes de creditar, o backend valida a assinatura HMAC do webhook e faz o pull-confirm server-side (getDepositStatus) com conferencia de valor (ver metodo handleHorsePayWebhook).

+-----------+     +-------------+     +-----------------+     +------------------+     +----------+
| HorsePay  |     | Controller  |     | DepositService  |     | TransactionSvc   |     | Database |
+-----------+     +-------------+     +-----------------+     +------------------+     +----------+
     |                 |                     |                     |                      |
     | POST /webhook/horsepay               |                     |                      |
     |---------------->|                     |                     |                      |
     |                 | handleWebhook()     |                     |                      |
     |                 |-------------------->|                     |                      |
     |                 |                     | parseWebhookPayload |                      |
     |                 |                     |                     |                      |
     |                 |                     | findById(depositId) |                      |
     |                 |                     |---------------------------------------------->|
     |                 |                     |                     |               Deposit |
     |                 |                     |<----------------------------------------------|
     |                 |                     |                     |                      |
     |                 |                     | status == PENDING?  |                      |
     |                 |                     | isPaymentConfirmed? |                      |
     |                 |                     |                     |                      |
     |                 |                     | confirmDeposit()    |                      |
     |                 |                     |--+                  |                      |
     |                 |                     |  | Calcula bonus 10%|                      |
     |                 |                     |  | totalCredit      |                      |
     |                 |                     |<-+                  |                      |
     |                 |                     |                     |                      |
     |                 |                     | credit(totalCredit) |                      |
     |                 |                     |-------------------->|                      |
     |                 |                     |                     | createDoubleEntry()  |
     |                 |                     |                     |--------------------->|
     |                 |                     |                     | DEBIT (SYSTEM) +     |
     |                 |                     |                     | CREDIT (user)        |
     |                 |                     |                     |<---------------------|
     |                 |                     |<--------------------|                      |
     |                 |                     |                     |                      |
     |                 |                     | updateStats()       |                      |
     |                 |                     |---------------------------------------------->|
     |                 |                     |                     |                      |
     |                 |                     | calcTickets, update |                      |
     |                 |                     |---------------------------------------------->|
     |                 |                     |                     |                      |
     |                 |                     | updateStatus(CONF)  |                      |
     |                 |                     |---------------------------------------------->|
     |                 |                     |                     |                      |
     |                 |                     | getUserBalance()    |                      |
     |                 |                     |-------------------->|                      |
     |                 |                     | newBalanceCents     |                      |
     |                 |                     |<--------------------|                      |
     |                 |                     |                     |                      |
     |                 |                     | emitBalanceUpdate() |                      |
     |                 |                     | emitDepositConfirm()|                      |
     |                 |                     |                     |                      |
     |                 | {success: true}     |                     |                      |
     |                 |<--------------------|                     |                      |
     | 200 OK          |                     |                     |                      |
     |<----------------|                     |                     |                      |

Estados do Deposito

                    +----------+
                    |          |
                    | PENDING  |
                    |          |
                    +----+-----+
                         |
              +----------+----------+
              |                     |
              v                     v
       +-----------+          +----------+
       |           |          |          |
       | CONFIRMED |          |  FAILED  |
       |           |          |          |
       +-----------+          +----------+
                                    ^
                                    |
                              +----------+
                              |          |
                              | EXPIRED  |
                              |          |
                              +----------+
  • PENDING: Deposito criado, aguardando pagamento
  • CONFIRMED: Pagamento confirmado via webhook, saldo creditado
  • FAILED: Falha na criacao do PIX ou pagamento rejeitado
  • EXPIRED: Deposito expirou sem pagamento (futuro, nao implementado ainda)

Eventos WebSocket Emitidos

balance:updated

Emitido para a sala privada user:{userId} apos confirmacao.

typescript
this.server.to(`user:${userId}`).emit('balance:updated', {
  balanceCents: balanceCents.toString()
});

Payload:

json
{
  "balanceCents": "11000"
}

O valor e SEMPRE em centavos como string. O frontend faz parseInt(data.balanceCents, 10).

deposit:confirmed

Emitido para a sala privada user:{userId} apos confirmacao.

typescript
this.server.to(`user:${userId}`).emit('deposit:confirmed', {
  id: depositId.toString(),
  amountCents: totalCredit.toString(),
  amountFormatted: this.moneyService.format(totalCredit),
  method: deposit.method,
});

Payload:

json
{
  "id": "7142591820701696",
  "amountCents": "11000",
  "amountFormatted": "R$ 110,00",
  "method": "PIX"
}

O amountCents aqui inclui o bonus (valor depositado + 10%).


Modulo NestJS

Definido em src/presentation/modules/payment.module.ts:

typescript
@Module({
  imports: [WebSocketModule],
  controllers: [PaymentController],
  providers: [
    DepositService,
    WithdrawalService,
    TransactionService,
    TwoFactorService,
    Enable2FAUseCase,
    Disable2FAUseCase,
    Generate2FASecretUseCase,
    TransactionRepository,
    DepositRepository,
    WithdrawalRepository,
    UserRepository,
    MoneyService,
    HorsePayProvider,
  ],
  exports: [TransactionService, DepositService, WithdrawalService],
})
export class PaymentModule {}

O PaymentModule exporta TransactionService, DepositService e WithdrawalService para uso em outros modulos.


Regras de Negocio

1. Limites de Deposito

  • Valor minimo: 1000 centavos (R$10,00)
  • Valor maximo: 500000 centavos (R$5.000,00)
  • Validado no DTO (@Min(1000) / @Max(500000)) e no servico

2. Bonus de Deposito

  • Percentual do bonus vem do cupom aplicado ao deposito (bonusPercentage, dinamico); no exemplo, 10%
  • Formula: bonusAmount = amountCents * bonusPercentage / 100
  • O saldo creditado = amountCents + bonusAmount
  • O totalDepositedCents nas estatisticas recebe o valor SEM bonus

3. Tickets de Sorteio (Raffle)

  • Formula: Math.floor(amountCents / 3000) tickets
  • 1 ticket para cada R$30,00 depositados
  • Arredondamento para baixo (floor)

4. Event Tickets

  • Formula: ticketsToAdd * 250 event tickets
  • Distribuidos via userRepository.incrementEventTickets

5. Idempotencia de Webhook

  • Se o deposito ja nao esta PENDING, o webhook retorna sucesso sem reprocessar
  • Impede credito duplicado em caso de webhook duplicado

6. Snowflake IDs

  • Todos os IDs sao gerados via SnowflakeService.generate()
  • BigInt de 64 bits com timestamp embutido
  • Epoch customizado: 1704067200000 (01/01/2024 00:00:00 UTC)

Casos Extremos (Edge Cases)

Webhook Duplicado

O HorsePay pode enviar o mesmo webhook mais de uma vez. O sistema e idempotente:

typescript
if (deposit.status !== 'PENDING') {
  this.logger.warn(`Deposit ${depositId} already processed (status: ${deposit.status})`);
  return { success: true, message: 'Deposit already processed' };
}

Retorna { success: true } sem reprocessar, evitando credito duplicado.

Falha na Criacao do PIX

Se createPixOrder falhar (HorsePay fora do ar, erro de rede, etc):

  1. O deposito e marcado como FAILED no banco
  2. O erro e propagado para o controller
  3. O usuario recebe erro HTTP 400 ou 503
  4. O usuario deve tentar novamente (novo deposito)

Token do HorsePay Expirado

Se receber HTTP 401 durante createPixOrder:

  1. O cache do token e limpo (accessToken = null, tokenExpiresAt = 0)
  2. Lanca HttpException com status 503
  3. Na proxima tentativa, authenticate() busca novo token

Deposito Nao Encontrado no Webhook

Se o client_reference_id do webhook nao corresponde a nenhum deposito:

typescript
if (!deposit) {
  throw new NotFoundException(`Deposit ${depositId} not found`);
}

Retorna HTTP 404 para o HorsePay.

Credenciais Nao Configuradas

Se HORSE_CLIENT ou HORSE_SECRET nao estiverem definidos:

typescript
if (!this.clientKey || !this.clientSecret) {
  this.logger.warn('HorsePay credentials not configured');
}

Apenas loga um warning. A falha real acontece na tentativa de autenticacao.


Debugging

Logs do Backend

[DepositService] PIX order created for deposit 7142591820701696
[DepositService] Webhook received from horsepay: {"amount":100,"status":true,...}
[DepositService] HorsePay webhook: client_reference_id=7142591820701696, status=true
[DepositService] Deposit 7142591820701696 confirmed successfully
[HorsePayProvider] Authenticating with HorsePay...
[HorsePayProvider] HorsePay authentication successful
[HorsePayProvider] Creating PIX order for deposit 7142591820701696, amount: R$ 100
[HorsePayProvider] PIX order created successfully. External ID: 98765
[WebSocketGatewayService] [Balance Update] User: 1, BalanceCents: 11000, Room: user:1
[WebSocketGatewayService] [Deposit] Confirmed for user 1: R$ 110,00 via PIX

Verificacoes

  1. Deposito nao confirmado: Verifique se o webhook esta chegando (Webhook received from horsepay)
  2. Saldo nao atualizado: Verifique se o emitBalanceUpdate esta sendo chamado e se ha clientes na sala WebSocket
  3. Erro 503: Verifique credenciais do HorsePay (HORSE_CLIENT, HORSE_SECRET)
  4. Erro no PIX: Verifique logs do HorsePay (Failed to create PIX order)
  5. Webhook duplicado: Verifique se aparece Deposit already processed nos logs

Tabela de Relacionamentos

Deposit (1) --> (1) User
Deposit (1) --> (1) Transaction (criada via TransactionService.credit())
Transaction (1) <-> (1) Transaction (par DEBIT/CREDIT via relatedTransactionId)

Confirmacao Manual (Admin)

Existe um use case para confirmacao manual por admin em src/application/use-cases/admin/confirm-deposit.use-case.ts:

typescript
await this.transactionService.credit(
  deposit.userId,
  deposit.amountCents,
  'DEPOSIT_CONFIRMED',
  {
    depositId: deposit.id.toString(),
    method: deposit.method,
    adminUserId: adminUserId.toString(),
  },
);

A confirmacao manual via admin nao aplica bonus e nao distribui tickets. Credita apenas o valor original do deposito.


Resumo do Fluxo de Dados

1. Frontend envia POST /payment/deposit/initiate com {amountCents, method, payerName?}
2. Backend valida (>= 100 centavos), cria Deposit(PENDING), chama HorsePay
3. HorsePay retorna {copy_past, external_id, payment(QR base64)}
4. Backend atualiza Deposit com externalId e metadata
5. Frontend recebe {id, pixCopyPaste, pixQrCodeBase64} e mostra QR Code
6. Usuario paga via app do banco
7. HorsePay envia POST /payment/deposit/webhook/horsepay com {status, client_reference_id} + header X-Signature (HMAC)
8. Backend valida a assinatura HMAC (401 se invalida), verifica idempotencia (status == PENDING) e faz pull-confirm (getDepositStatus) conferindo o valor
9. Backend calcula bonus (percentual do cupom), totalCredit = amount + bonus
10. TransactionService.credit() cria par DEBIT(SYSTEM)/CREDIT(user)
11. UserRepository.updateStats() incrementa totalDepositedCents
12. Calcula tickets: floor(amountCents / 3000) raffle + tickets * 250 event
13. Atualiza Deposit status para CONFIRMED
14. Emite balance:updated e deposit:confirmed via WebSocket

Documentação Técnica - ProCases