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
| Arquivo | Responsabilidade |
|---|---|
src/application/services/deposit.service.ts | Servico principal de depositos |
src/infrastructure/payment/horsepay.provider.ts | Provider de pagamento PIX (HorsePay) |
src/infrastructure/database/repositories/deposit.repository.ts | Repository Prisma para depositos |
src/domain/repositories/deposit.repository.interface.ts | Interface do repository |
src/presentation/controllers/payment.controller.ts | Controller HTTP |
src/presentation/modules/payment.module.ts | Modulo NestJS |
src/application/dto/payment.dto.ts | DTOs de request/response |
src/infrastructure/websocket/websocket.gateway.ts | Emissao de eventos WebSocket |
src/application/services/transaction.service.ts | Criacao de transacoes de credito |
src/application/services/money.service.ts | Formatacao de valores monetarios |
Modelo Prisma (Deposit)
Definido em prisma/schema.prisma, linhas 176-193:
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
enum PaymentMethod {
PIX
CREDIT_CARD
CRYPTO
}
enum DepositStatus {
PENDING
CONFIRMED
FAILED
EXPIRED
}Campos Detalhados
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID gerado pelo SnowflakeService |
userId | BigInt | ID do usuario que iniciou o deposito |
amountCents | BigInt | Valor do deposito em centavos (ex: 10000 = R$100,00) |
method | PaymentMethod | Metodo de pagamento (PIX, CREDIT_CARD, CRYPTO) |
externalId | String? | ID externo retornado pelo provider (HorsePay external_id) |
status | DepositStatus | Estado atual do deposito |
metadata | Json? | Dados adicionais (pixCopyPaste, pixQrCodeBase64, horsePayExternalId) |
createdAt | DateTime | Timestamp de criacao |
confirmedAt | DateTime? | 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:
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:
@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
ide gerado viaSnowflakeService.generate()(BigInt unico distribuido) - Todo deposito inicia com
status: 'PENDING' findByExternalIdusa indice@@index([externalId])com constraint@unique
DepositService
Servico principal definido em src/application/services/deposit.service.ts.
Dependencias Injetadas
@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
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.
async initiateDeposit(
userId: bigint,
amountCents: bigint,
method: PaymentMethod,
payerName?: string,
): Promise<InitiateDepositResult>Parametros:
| Parametro | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
userId | bigint | Sim | ID do usuario |
amountCents | bigint | Sim | Valor em centavos |
method | PaymentMethod | Sim | PIX, CREDIT_CARD ou CRYPTO |
payerName | string | Nao | Nome do pagador (usado no PIX) |
Validacoes:
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:
- Valida valor minimo (>= 100 centavos)
- Cria registro
Depositno banco comstatus: PENDING - Se metodo = PIX: a. Busca dados do usuario (nome) via
UserRepository.findByIdb. ChamaHorsePayProvider.createPixOrder()com o depositId comoclient_reference_idc. Atualiza o deposito comexternalIdemetadata(pixCopyPaste, pixQrCodeBase64, horsePayExternalId) d. Em caso de erro na criacao do PIX, atualiza status paraFAILEDe relanca o erro - Retorna o resultado com dados do deposito e codigos PIX
Retorno:
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:
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.
async confirmDeposit(depositId: bigint)Fluxo Passo a Passo:
- Busca o deposito via
depositRepository.findById - Valida estado: se nao existe, lanca
NotFoundException; se nao estaPENDING, lancaBadRequestException - Calcula bonus: o percentual vem do cupom aplicado ao deposito (dinamico). O exemplo abaixo assume um cupom de 10%
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)
- Cria transacao de credito via
TransactionService.credit():
await this.transactionService.credit(
deposit.userId,
totalCredit,
`Deposit confirmed #${depositId} (+${bonusPercentage}% bonus)`,
{
depositId: depositId.toString(),
method: deposit.method,
bonusPercentage,
bonusAmountCents: bonusAmount.toString(),
},
);- Atualiza estatisticas do usuario:
await this.userRepository.updateStats(deposit.userId, {
totalDepositedCents: deposit.amountCents,
});Nota: totalDepositedCents recebe o valor SEM bonus (valor real depositado).
- Calcula e distribui tickets de sorteio (raffle):
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
- Calcula e distribui event tickets:
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
- Atualiza status do deposito para
CONFIRMEDcom timestamp:
const updatedDeposit = await this.depositRepository.updateStatus(
depositId,
'CONFIRMED',
new Date(),
);- Emite eventos WebSocket:
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:updatedcom{ balanceCents: string }- saldo atualizado em centavosdeposit:confirmedcom 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.
async handleWebhook(provider: string, data: any)Fluxo:
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.
private async handleHorsePayWebhook(data: any)Fluxo Passo a Passo:
- Verifica a assinatura HMAC: se
HORSE_WEBHOOK_SECRETesta configurado, a assinatura e obrigatoria. A assinatura recebida no headerX-Signature: sha256=<hex>e recalculada comoHMAC-SHA256(rawBody, HORSE_WEBHOOK_SECRET)e comparada em tempo constante (timingSafeEqual). Assinatura ausente ou invalida -> 401 (nao processa). OrawBodycru e necessario para o calculo (verrawBody: truenomain.ts). - Parseia o payload via
horsePayProvider.parseWebhookPayload(data) - Valida presenca do
client_reference_id(que e odepositId) - Busca o deposito via
depositRepository.findById(BigInt(client_reference_id)) - Idempotencia: se o deposito ja nao esta
PENDING, retorna sucesso sem reprocessar
if (deposit.status !== 'PENDING') {
this.logger.warn(`Deposit ${depositId} already processed (status: ${deposit.status})`);
return { success: true, message: 'Deposit already processed' };
}- 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 reportarsuccess/paidE o valor conferir (confirmation.valueCents === deposit.amountCents). - Confirmacao: com status pago e valor conferido, chama
confirmDeposit(deposit.id)para creditar o usuario. - Divergencia de valor: se o valor pago diverge do esperado, registra auditoria
DEPOSIT_AMOUNT_MISMATCHe 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.
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
@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:
| Variavel | Descricao |
|---|---|
HORSE_CLIENT | Client key para autenticacao |
HORSE_SECRET | Client secret para autenticacao |
APP_URL | URL 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.
private async authenticate(): Promise<string>Endpoint: POST https://api.horsepay.io/auth/token
Body:
{
"client_key": "HORSE_CLIENT",
"client_secret": "HORSE_SECRET"
}Response:
{
"access_token": "eyJhbGci..."
}Cache do Token:
this.accessToken = response.data.access_token;
this.tokenExpiresAt = now + 55 * 60 * 1000; // 55 minutosO 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:
throw new HttpException(
'Payment provider authentication failed',
HttpStatus.SERVICE_UNAVAILABLE, // 503
);Metodo: createPixOrder
Cria uma ordem de pagamento PIX no HorsePay.
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):
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:
const amountReais = Number(amountCents) / 100;Request Body (Exemplo):
{
"payer_name": "Joao Silva",
"amount": 100.00,
"callback_url": "https://api.procases.example/payment/deposit/webhook/horsepay",
"client_reference_id": "7142591820701696"
}Response (Interface):
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:
return {
externalId: external_id.toString(),
pixCopyPaste: copy_past,
pixQrCodeBase64: payment,
};Tratamento de Erro 401 (Token Expirado):
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.
parseWebhookPayload(data: any): HorsePayWebhookPayloadInterface do Payload:
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:
{
"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.
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.
@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:
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, CRYPTOpayerName: string opcional
Response DTO:
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:
POST /payment/deposit/initiate
Content-Type: application/json
Cookie: sessionId=abc123
{
"amountCents": 10000,
"method": "PIX",
"payerName": "Joao Silva"
}Exemplo de Response (201):
{
"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.
@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
provideridentifica o provider (ex:horsepay) - O body e tipado como
anypois cada provider tem formato diferente
URL completa: POST /payment/deposit/webhook/horsepay
GET /payment/deposits
Lista depositos do usuario autenticado com paginacao.
@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=abc123Exemplo de Response (200):
[
{
"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 metodohandleHorsePayWebhook).
+-----------+ +-------------+ +-----------------+ +------------------+ +----------+
| 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.
this.server.to(`user:${userId}`).emit('balance:updated', {
balanceCents: balanceCents.toString()
});Payload:
{
"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.
this.server.to(`user:${userId}`).emit('deposit:confirmed', {
id: depositId.toString(),
amountCents: totalCredit.toString(),
amountFormatted: this.moneyService.format(totalCredit),
method: deposit.method,
});Payload:
{
"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:
@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
totalDepositedCentsnas 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 * 250event 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:
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):
- O deposito e marcado como
FAILEDno banco - O erro e propagado para o controller
- O usuario recebe erro HTTP 400 ou 503
- O usuario deve tentar novamente (novo deposito)
Token do HorsePay Expirado
Se receber HTTP 401 durante createPixOrder:
- O cache do token e limpo (
accessToken = null,tokenExpiresAt = 0) - Lanca
HttpExceptioncom status 503 - Na proxima tentativa,
authenticate()busca novo token
Deposito Nao Encontrado no Webhook
Se o client_reference_id do webhook nao corresponde a nenhum deposito:
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:
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 PIXVerificacoes
- Deposito nao confirmado: Verifique se o webhook esta chegando (
Webhook received from horsepay) - Saldo nao atualizado: Verifique se o
emitBalanceUpdateesta sendo chamado e se ha clientes na sala WebSocket - Erro 503: Verifique credenciais do HorsePay (
HORSE_CLIENT,HORSE_SECRET) - Erro no PIX: Verifique logs do HorsePay (
Failed to create PIX order) - Webhook duplicado: Verifique se aparece
Deposit already processednos 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:
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