Skip to content

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:

  1. Subcontas de motoristas — cada driver tem uma subconta ASAAS
  2. Transferências internas — da conta principal para subcontas (ganhos por entrega)
  3. 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

POST /api/v1/webhooks/asaas
{"event": "TRANSFER_DONE", "transfer": {"id": "tra_000012345"}}

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:

headers = {
    **self._headers,
    "Idempotency-Key": idempotency_key,
}
  • 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

processing → pending → completed
                    ↘ failed
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)

ASAAS_BASE_URL=https://sandbox.asaas.com/api/v3
ASAAS_API_KEY=$aact_... (chave sandbox)

No sandbox, use dados fictícios: - CPF: 12345678909 - Transferências completam instantaneamente - Webhooks podem ser disparados manualmente pelo dashboard

Produção

ASAAS_BASE_URL=https://api.asaas.com/v3
ASAAS_API_KEY=$aact_... (chave 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;