Fluxo de Pagamentos (ASAAS)
Documentação da integração com ASAAS para gestão de carteiras, transferências e Pix automático.
Visão Geral
O fast_deliv usa o ASAAS como processador de pagamentos para:
- Subcontas de motoristas — cada driver tem uma subconta ASAAS
- Transferências internas — da conta principal para subcontas (ganhos por entrega)
- Pix automático — saque do saldo para chave Pix do motorista
Arquitetura de Pagamentos
Cliente paga empresa
│
▼
Conta Principal ASAAS (empresa)
│
│ Transferência interna (quando entrega completa)
│ POST /transfers (operationType: ASAAS_ACCOUNT)
▼
Subconta ASAAS do Driver
│
│ Saque via Pix (quando driver solicita)
│ POST /transfers (operationType: PIX)
▼
Chave Pix do Driver (CPF/Email/Telefone/EVP)
AsaasClient
Implementado em backend/app/services/asaas.py:
class AsaasClient:
def __init__(self) -> None:
self._base = settings.asaas_base_url # sandbox ou prod
self._headers = {
"access_token": settings.asaas_api_key,
"Content-Type": "application/json",
}
Métodos disponíveis
create_subconta(name, email, cpf_cnpj)
Cria subconta ASAAS para um driver.
result = await asaas.create_subconta(
name="João Silva",
email="joao@email.com",
cpf_cnpj="12345678901",
)
# result: {"id": "acc_...", "walletId": "wal_...", ...}
transfer_to_driver(wallet_id, amount, idempotency_key)
Transfere da conta principal para a subconta do driver.
result = await asaas.transfer_to_driver(
wallet_id="wal_123",
amount=Decimal("29.00"),
idempotency_key="delivery-uuid", # idempotente
)
pix_transfer(amount, pix_key, pix_key_type, idempotency_key)
Envia Pix para a chave do motorista.
result = await asaas.pix_transfer(
amount=Decimal("100.00"),
pix_key="11999999999",
pix_key_type="PHONE",
idempotency_key="withdrawal-uuid", # idempotente
)
Fluxo de Saque
1. Driver solicita saque
POST /api/v1/wallets/withdraw
{
"amount": 100.00,
"pix_key": "11999999999",
"pix_key_type": "PHONE"
}
2. Backend valida saldo
balance = Decimal(str(wallet["balance"]))
if balance < body.amount:
raise HTTPException(422, f"Saldo insuficiente: R$ {balance:.2f}")
3. Cria registro de saque
withdrawal = supabase.table("withdrawals").insert({
"driver_id": driver_id,
"amount": str(body.amount),
"pix_key": body.pix_key,
"pix_key_type": body.pix_key_type,
"status": "processing",
}).execute()
4. Chama ASAAS para transferência Pix
transfer = await asaas.pix_transfer(
amount=body.amount,
pix_key=body.pix_key,
pix_key_type=body.pix_key_type,
idempotency_key=withdrawal["id"], # UUID do saque = idempotency key
)
5. Atualiza com ID ASAAS
supabase.table("withdrawals").update({
"asaas_transfer_id": transfer.get("id"),
"status": "pending",
}).eq("id", withdrawal["id"]).execute()
6. ASAAS confirma via webhook
Backend valida HMAC e atualiza status para completed.
Webhook ASAAS
Configuração
O ASAAS envia notificações para sua URL configurada no dashboard.
A URL deve ser: https://fast-deliv-backend.vercel.app/api/v1/webhooks/asaas
Validação de Assinatura
@router.post("/asaas")
async def asaas_webhook(
request: Request,
asaas_signature: str | None = Header(default=None, alias="asaas-signature"),
) -> dict[str, str]:
body = await request.body()
if asaas_signature:
expected = hmac.new(
settings.asaas_webhook_secret.encode(),
body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, asaas_signature):
raise HTTPException(401, "Assinatura inválida")
compare_digest é obrigatório
Use sempre hmac.compare_digest() para comparação timing-safe.
Comparação direta com == é vulnerável a timing attacks.
Eventos Processados
| Evento ASAAS | Ação no Sistema |
|---|---|
TRANSFER_DONE |
withdrawal.status = 'completed' |
TRANSFER_APPROVED |
withdrawal.status = 'completed' |
TRANSFER_FAILED |
withdrawal.status = 'failed' |
TRANSFER_CANCELLED |
withdrawal.status = 'failed' |
Payload de Exemplo
{
"event": "TRANSFER_DONE",
"transfer": {
"id": "tra_000012345",
"value": 100.00,
"status": "DONE",
"scheduledDate": "2026-05-18",
"effectiveDate": "2026-05-18",
"pixTransactionId": "..."
}
}
Idempotência
Toda chamada ao ASAAS usa Idempotency-Key para evitar duplicações:
- Para saques:
idempotency_key = withdrawal.id(UUID único por saque) - Para ganhos de entrega:
idempotency_key = delivery.id
Se a chamada falhar e for refeita com a mesma Idempotency-Key, o ASAAS retorna o resultado original sem criar duplicata.
Status de Pagamento
Status do Withdrawal
| Status | Descrição |
|---|---|
processing |
Chamada ASAAS sendo feita |
pending |
Transferência criada no ASAAS, aguardando confirmação |
completed |
Webhook TRANSFER_DONE recebido e validado |
failed |
Erro na transferência ASAAS |
Ambientes
Sandbox (Desenvolvimento)
No sandbox, use dados fictícios:
- CPF: 12345678909
- Transferências completam instantaneamente
- Webhooks podem ser disparados manualmente pelo dashboard
Produção
Nunca misture chaves sandbox/produção
Uma chave de produção no ambiente sandbox (ou vice-versa) causa erros e pode ter implicações financeiras reais.
Tratamento de Erros
O backend trata falhas ASAAS de forma graciosa:
try:
transfer = await asaas.pix_transfer(...)
# atualiza withdrawal com asaas_transfer_id
except Exception as exc:
# marca saque como failed
supabase.table("withdrawals").update({
"status": "failed",
"error_message": str(exc),
}).eq("id", withdrawal["id"]).execute()
raise HTTPException(502, "Falha na transferência ASAAS") from exc
O saldo do driver não é debitado até a confirmação via webhook (ou implementação de debito antecipado com reconciliação).
Monitoramento
Consultas úteis para monitorar pagamentos:
-- Saques pendentes há mais de 1 hora
SELECT * FROM withdrawals
WHERE status = 'pending'
AND requested_at < now() - interval '1 hour';
-- Saques com falha hoje
SELECT * FROM withdrawals
WHERE status = 'failed'
AND requested_at::date = current_date;
-- Volume de saques por motorista (mês atual)
SELECT driver_id, SUM(amount) as total_sacado
FROM withdrawals
WHERE status = 'completed'
AND requested_at >= date_trunc('month', now())
GROUP BY driver_id;