Skip to content

Gerenciar Pagamento & Recorrência (Front-end)

Guia de referência para as telas de "Minha assinatura": como o cliente vê a recorrência, troca o método de pagamento, troca o cartão, retoma um PIX pendente, cancela e volta a assinar.

Todos os endpoints aqui são do próprio cliente (Bearer <jwt> do customer). Nada nesta página exige permissão de admin.


1. Modelo mental (leia antes de desenhar a tela)

  • O cliente tem no máximo uma assinatura corrente, apontada por customer.current_subscription_id. Cancelar zera esse ponteiro.
  • A assinatura carrega dois eixos independentes:
    • gateway: asaas | stripe — quem cobra;
    • payment_method: credit_card | pix — como cobra.
  • O front não escolhe o gateway em nenhuma troca: ele é herdado da assinatura atual (nas rotas de troca) ou do padrão da plataforma (GET /config/payment-gateway) num novo assinar.
  • Trocar cartãotrocar método. São caminhos diferentes:
Intenção do cliente Caminho Efeito na recorrência
"Meu cartão venceu / quero outro cartão" PUT /asaas/subscription/credit-card ou PUT /stripe/subscription/payment-method In-place — mesma assinatura, mesmo ciclo, sem nova cobrança
"Quero trocar cartão → PIX" (ou voltar a assinar por PIX) PUT /customers/me/subscription/renew com payment_method: "pix" Cancela e recria — devolve QR, novo período
"Quero trocar de plano" PUT /customers/me/subscription/renew com plan_id Cancela e recria no plano novo
"Quero cancelar" DELETE /customers/me/subscription Encerra no gateway + local
"Quero assinar de novo" (sem assinatura corrente) POST /asaas/subscribe (cartão) ou POST /asaas/subscribe/pix-automatico (PIX) Cria assinatura nova

POST /customers/me/subscribe não funciona

A rota existe mas responde 501 (Subscription gateway not yet configured). Para assinar, use as rotas de gateway (/asaas/subscribe*, /stripe/subscribe*).


2. Tela "Minha assinatura" — o que carregar

GET /api/v1/customers/me/subscription        → estado da recorrência
GET /api/v1/config/payment-gateway           → { "default_payment_gateway": "asaas" }
GET /api/v1/config/pix-subscription-mode     → { "pix_subscription_mode": "recurring" }
GET /api/v1/config/pix-billing-mode          → { "pix_billing_mode": "monthly" }
GET /api/v1/plans                            → planos ativos (troca de plano)

GET /customers/me/subscription — 200

{
  "subscription": {
    "_id": "507f1f77bcf86cd799439011",
    "plan_id": "plan-basic",
    "gateway": "asaas",
    "status": "active",
    "payment_method": "pix",
    "asaas_pix_auth_id": "pixauth_xxx",
    "pix_period_months": 1,
    "started_at": "2026-08-01T12:00:00+00:00",
    "next_billing_date": null,
    "expires_at": "2026-09-01T12:00:00+00:00"
  },
  "message": "Active subscription retrieved successfully"
}

Campos que a tela deve usar:

Campo Como interpretar na UI
status Já vem efetivo: um active vencido chega como expired, e com falhas de pagamento demais chega como suspended. Não recalcule no app
payment_method pix → assinatura por PIX; credit_card → cartão
asaas_pix_auth_id Presente ⇒ Pix Automático (débito recorrente autorizado no banco). Ausente com payment_method: 'pix' ⇒ PIX manual (QR por ciclo, legado)
pix_period_months Meses vendidos por ciclo PIX (12 = anual). Congelado no assinar — não reflita a config atual do painel
expires_at Fim do acesso pago (PIX). Use para "Sua assinatura vale até …"
next_billing_date Próxima cobrança do cartão. null em PIX — nesse caso mostre expires_at
pix_qr_expired Só aparece quando status: 'pending' + payment_method: 'pix'. true ⇒ o QR atual já passou da janela de 10 min: ofereça "Gerar novo QR"

Stripe pendente resolve sozinho

Para assinatura Stripe em pending, este GET consulta a Stripe ao vivo e grava o status real. Um pending que virou incomplete/canceled lá volta daqui como cancelled — o polling do app tem resposta definitiva, não fica eternamente pendente.

404 No active subscription found for this customer é o caso normal de "cliente sem assinatura" — trate como estado da tela (mostrar planos), não como erro.


3. Trocar o cartão (mantendo a recorrência)

Use quando o cliente só quer atualizar o cartão. É in-place: a assinatura, o ciclo e a data da próxima cobrança não mudam, e nenhuma cobrança é disparada.

PUT /api/v1/asaas/subscription/credit-card
Authorization: Bearer <jwt>
{
  "credit_card": {
    "holderName": "NOME NO CARTAO",
    "number": "5162306219378829",
    "expiryMonth": "05",
    "expiryYear": "2028",
    "ccv": "318"
  },
  "credit_card_holder_info": {
    "name": "Nome Completo",
    "email": "email@example.com",
    "cpfCnpj": "24971563792",
    "postalCode": "89223005",
    "addressNumber": "277",
    "phone": "47998781877"
  }
}
{ "message": "Credit card updated successfully.", "updated_at": "2026-08-19T12:00:00+00:00" }
PUT /api/v1/stripe/subscription/payment-method
Authorization: Bearer <jwt>
{ "payment_method_id": "pm_1ABC2DEF3GHI" }
{ "message": "Payment method updated successfully.", "updated_at": "2026-08-19T12:00:00+00:00" }

O pm_xxx vem do SDK da Stripe no cliente — o app nunca envia número de cartão para esta rota.

Erros comuns

Status Detalhe Leitura
404 No active subscription Sem assinatura corrente — não ofereça a ação
400 Current subscription is not managed by Asaas./Stripe. Chamou a rota do gateway errado — decida pelo gateway do GET
400 Subscription has no Asaas ID / no Stripe ID Assinatura sem objeto de recorrência no gateway (ex.: Pix Automático) — não é caminho de cartão; use renew

Assinatura PIX não tem cartão para trocar

Numa assinatura PIX (inclusive Pix Automático) estas rotas falham com 400. A troca de método passa obrigatoriamente pelo renew (§4).


4. Trocar o método de pagamento / trocar de plano (renew)

PUT /api/v1/customers/me/subscription/renew
Authorization: Bearer <jwt>

Este endpoint cancela a assinatura atual e cria uma nova. Gateway é detectado automaticamente a partir da assinatura vigente.

Campo Tipo Quando enviar
payment_method "pix" | "credit_card" Opcional. Omitido = herda o método da assinatura anterior
plan_id String Opcional. Omitido = mantém o plano atual
promo_code String Opcional, aplicado só na renovação PIX
credit_card + credit_card_holder_info Objetos Obrigatórios quando o caminho é cartão no Asaas
payment_method_id String (pm_xxx) Obrigatório quando o caminho é cartão na Stripe

4.1 A regra que mais confunde: PIX ganha do cartão

O backend renova via PIX recorrente quando:

  • o payload traz payment_method: "pix", ou
  • a assinatura anterior era PIX (payment_method == 'pix' ou tem asaas_pix_auth_id).

Ou seja: para um cliente que veio de PIX, mandar payment_method: "credit_card" é no-op — ele recebe um QR de volta, não uma assinatura de cartão. Motivo: a tela de renovação criptografa cartão com a chave RSA da PagSeguro, gateway não integrado neste backend; uma renovação por cartão para esse cliente não teria como ser honrada no servidor.

Nunca roteie um fluxo de cartão por aqui esperando cartão

Sempre inspecione a resposta: se vier info.pix.qr_code, a tela precisa renderizar QR, não "cartão atualizado com sucesso".

4.2 Caminho PIX (Pix Automático)

Só é permitido em assinatura não-ativa. Com assinatura ainda ativa a API responde 400:

Subscription is still active. A PIX renewal would revoke the current authorization and charge a new full period without proration. Cancel the subscription first, or renew after it expires.

A razão é concreta: renovar por PIX revoga a autorização vigente e emite um QR de um período inteiro, sem pro-rata — o cliente pagaria o período duas vezes. Na UI, para quem está ativo, ofereça cancelar e depois assinar por PIX (ou esperar expirar), nunca "trocar para PIX agora".

Requisição

{ "payment_method": "pix", "plan_id": "plan-basic" }

Resposta 200 (mesmo envelope do subscribe PIX — QR em info.pix)

{
  "info": {
    "pix": {
      "authorization_id": "pixauth_xxx",
      "qr_code": "00020101...6304ABCD",
      "qr_code_image": "iVBORw0KGgoAAAANS...",
      "generated_at": "2026-08-19T12:00:00+00:00",
      "expiration_date": "2026-08-19T12:10:00+00:00",
      "value": 19.9,
      "recurring_value": 39.9
    },
    "asaas_pix_auth_id": "pixauth_xxx",
    "authorization_status": "PENDING_FIRST_PAYMENT",
    "message": "Autorização Pix Automático criada. Escaneie o QR code e confirme a recorrência no app do seu banco."
  },
  "docs": [ { "...": "documento da subscription (status pending)" } ]
}
  • value = primeira cobrança (pode estar com desconto de cupom); recurring_value = valor cheio das cobranças seguintes.
  • expiration_date é a janela de 10 minutos da FDPlay (ADR-0095), não o TTL da Asaas. Mostre contagem regressiva por ela.
  • Um único QR paga a 1ª cobrança e autoriza a recorrência no banco. Depois disso a Asaas gera as cobranças sozinha — não há QR por ciclo.

4.3 Caminho cartão (mesmo cartão-para-cartão)

Exige status atual active ou pending e devolve o payload de cartão:

{
  "subscription": {
    "id": "507f1f77bcf86cd799439011",
    "plan_id": "plan-basic",
    "gateway": "asaas",
    "status": "pending",
    "payment_method": "credit_card",
    "started_at": "2026-08-19T12:00:00+00:00"
  },
  "message": "Assinatura renovada com sucesso. Novo cartão configurado."
}

A nova assinatura nasce pending; o webhook do gateway (PAYMENT_RECEIVED no Asaas, invoice.paid na Stripe) a promove para active. A tela deve dar o feedback de "processando" e repolar GET /customers/me/subscription.

4.4 Erros do renew

Status Detalhe Causa
400 Subscription is still active. A PIX renewal would revoke… Tentou renovar por PIX com assinatura ainda ativa
400 credit_card and credit_card_holder_info are required for Asaas gateway. Caminho cartão/Asaas sem dados do cartão
400 payment_method_id is required for Stripe gateway. Caminho cartão/Stripe sem token
400 Cannot renew subscription with status "cancelled". Caminho cartão só aceita active/pending — para reativar cancelada, use PIX ou assine de novo (§7)
400 Plan is no longer active Plano desativado — recarregue GET /plans
400 Cannot resolve a plan to renew into… Assinatura antiga sem plan_id e nenhum enviado
404 No active subscription found Sem current_subscription_id

5. Retomar um PIX pendente (recuperar o QR)

Enquanto a assinatura está pending + pix, o QR pode ser rebuscado quantas vezes for preciso — cada chamada gera uma nova janela de 10 minutos:

GET /api/v1/customers/me/pix-qr
{
  "subscription_id": "507f1f77bcf86cd799439011",
  "status": "pending",
  "gateway": "asaas",
  "pix": {
    "authorization_id": "pixauth_xxx",
    "qr_code": "00020101...6304ABCD",
    "qr_code_image": "iVBORw0KGgoAAAANS...",
    "generated_at": "2026-08-19T12:00:00+00:00",
    "expiration_date": "2026-08-19T12:10:00+00:00"
  },
  "message": "PIX QR code retrieved. Scan to complete payment."
}
  • Asaas: pix.qr_code (copia-e-cola) + pix.qr_code_image (base64 PNG, sem prefixo data:).
  • Stripe: os campos são qr_code_image_url (URL PNG), expires_at e hosted_instructions_url — trate os dois formatos.
Status Detalhe Ação na UI
400 Subscription is not pending (current status: active) Já pago — atualize a tela
400 Subscription is not a PIX payment Assinatura de cartão
404 No PIX QR code available for this authorization Sem cobrança em aberto — recarregue o estado
410 PIX authorization is no longer payable… Autorização cancelada/expirada: cancele a assinatura e assine de novo

Polling

Depois de exibir o QR, repolar GET /customers/me/subscription a cada ~5s. A ativação vem por webhook; não existe endpoint de "confirmar pagamento".


6. Cancelar a assinatura

Use sempre a rota do customer — ela é a única que cobre todos os casos:

DELETE /api/v1/customers/me/subscription
{
  "message": "Subscription cancelled successfully",
  "cancelled_at": "2026-08-19T12:00:00+00:00",
  "gateway_response": { "...": "resposta bruta do gateway" },
  "note": "Subscription cancelled in payment gateway and local database"
}

O que ela faz que as rotas por gateway não fazem por completo:

  • Pix Automático: revoga a autorização e cancela a subscription wrapper que a Asaas cria junto. Revogar só a autorização interrompe o débito futuro mas deixa o wrapper emitindo uma cobrança PIX não paga por ciclo, para sempre.
  • Falha honesta: se a chamada ao gateway falhar, o acesso é revogado localmente e a resposta diz issomessage vira "Subscription cancelled locally; gateway cancellation failed and will be retried" e a assinatura fica marcada para reparo. Mostre esse texto ao cliente, não um "cancelado com sucesso" genérico.

Não é cancelamento no fim do período

DELETE /customers/me/subscription corta o acesso imediatamente (status local vira cancelled e o ponteiro do cliente é limpo). Deixe isso explícito na confirmação. (A rota específica DELETE /stripe/subscription cancela no fim do período no lado da Stripe, mas marca cancelled local na hora — a divergência é mais um motivo para usar a rota do customer.)

Um e-mail de cancelamento é enviado automaticamente pela API.


7. Assinar de novo depois de cancelar

Sem current_subscription_id, o caminho é o subscribe do gateway padrão (GET /config/payment-gateway):

POST /api/v1/asaas/subscribe
{
  "plan_id": "plan-basic",
  "credit_card": { "holderName": "…", "number": "…", "expiryMonth": "05", "expiryYear": "2028", "ccv": "318" },
  "credit_card_holder_info": { "name": "…", "email": "…", "cpfCnpj": "…", "postalCode": "…", "addressNumber": "…", "phone": "…" },
  "promo_code": "OPCIONAL"
}

POST /api/v1/asaas/subscribe/pix-automatico
{ "plan_id": "plan-basic", "promo_code": "OPCIONAL" }

Exige tax_id (CPF) no perfil — sem ele, 400 CPF (tax_id) is required for PIX payments. Update profile first.

Guardas que a UI precisa respeitar

Status Detalhe Significado
400 Customer already has an active subscription. Cancel the current subscription before subscribing again. Já existe assinatura ativa
400 Customer already has a pending payment… / pending Pix Automático authorization… Existe pendente com menos de 24h — leve o cliente ao QR (§5) em vez de criar outra. Pendente com mais de 24h é considerada abandonada e liberada automaticamente
429 Outra operação de assinatura já está em andamento. Aguarde alguns segundos. Lock anti-duplo-clique. Desabilite o botão durante a requisição e trate 429 como "aguarde", nunca como falha para retry imediato

8. Fluxo de decisão da tela

                    GET /customers/me/subscription
              ┌─────────────────┼──────────────────┐
          404 │             200 │ status=pending   │ 200 status=active
              ▼                 ▼                  ▼
     ┌────────────────┐  ┌──────────────┐   ┌──────────────────────┐
     │ Sem assinatura │  │ payment_     │   │ payment_method?      │
     │ → §7 subscribe │  │ method=pix?  │   └──────────┬───────────┘
     └────────────────┘  └──────┬───────┘              │
                                │ sim         ┌────────┴────────┐
                                ▼             │ credit_card     │ pix
                     ┌────────────────────┐   ▼                 ▼
                     │ §5 GET /me/pix-qr  │  ┌──────────────┐ ┌────────────────┐
                     │ + polling          │  │ §3 trocar    │ │ Só cancelar +  │
                     └────────────────────┘  │ cartão       │ │ reassinar (§6, │
                                             │ (in-place)   │ │ §7). Renew PIX │
                                             │ §4 trocar    │ │ em ativa = 400 │
                                             │ p/ PIX/plano │ └────────────────┘
                                             └──────────────┘
              status=expired / suspended / cancelled
              §4 renew (PIX) — permitido fora de "active"
              ou §7 subscribe, se não há mais assinatura corrente

9. Exemplo Flutter (serviço enxuto)

class SubscriptionService {
  SubscriptionService(this.baseUrl, this.token);
  final String baseUrl;
  final String token;

  Map<String, String> get _headers => {
        'Authorization': 'Bearer $token',
        'Content-Type': 'application/json',
      };

  /// Estado atual. Retorna null quando o cliente não tem assinatura (404).
  Future<Map<String, dynamic>?> current() async {
    final r = await http.get(
      Uri.parse('$baseUrl/api/v1/customers/me/subscription'),
      headers: _headers,
    );
    if (r.statusCode == 404) return null;
    if (r.statusCode != 200) throw ApiException(r);
    return jsonDecode(r.body)['subscription'] as Map<String, dynamic>;
  }

  /// Troca só o cartão, mantendo a recorrência. Escolhe a rota pelo gateway.
  Future<void> updateCard(
    String gateway, {
    Map<String, dynamic>? creditCard,
    Map<String, dynamic>? holderInfo,
    String? paymentMethodId,
  }) async {
    final isAsaas = gateway == 'asaas';
    final r = await http.put(
      Uri.parse(isAsaas
          ? '$baseUrl/api/v1/asaas/subscription/credit-card'
          : '$baseUrl/api/v1/stripe/subscription/payment-method'),
      headers: _headers,
      body: jsonEncode(isAsaas
          ? {'credit_card': creditCard, 'credit_card_holder_info': holderInfo}
          : {'payment_method_id': paymentMethodId}),
    );
    if (r.statusCode != 200) throw ApiException(r);
  }

  /// Renova para PIX recorrente. Só chame com assinatura NÃO ativa (§4.2).
  /// Retorna o bloco `pix` com o QR a renderizar.
  Future<Map<String, dynamic>> renewWithPix({String? planId, String? promoCode}) async {
    final r = await http.put(
      Uri.parse('$baseUrl/api/v1/customers/me/subscription/renew'),
      headers: _headers,
      body: jsonEncode({
        'payment_method': 'pix',
        if (planId != null) 'plan_id': planId,
        if (promoCode != null) 'promo_code': promoCode,
      }),
    );
    if (r.statusCode != 200) throw ApiException(r);
    return jsonDecode(r.body)['info']['pix'] as Map<String, dynamic>;
  }

  /// Recupera o QR de um PIX pendente (nova janela de 10 min a cada chamada).
  Future<Map<String, dynamic>> pixQr() async {
    final r = await http.get(
      Uri.parse('$baseUrl/api/v1/customers/me/pix-qr'),
      headers: _headers,
    );
    if (r.statusCode != 200) throw ApiException(r); // 410 => reassinar
    return jsonDecode(r.body)['pix'] as Map<String, dynamic>;
  }

  /// Cancela. `gatewayOk == false` => avisar que a cobrança pode persistir.
  Future<({String message, bool gatewayOk})> cancel() async {
    final r = await http.delete(
      Uri.parse('$baseUrl/api/v1/customers/me/subscription'),
      headers: _headers,
    );
    if (r.statusCode != 200) throw ApiException(r);
    final body = jsonDecode(r.body) as Map<String, dynamic>;
    final message = body['message'] as String;
    return (message: message, gatewayOk: !message.contains('gateway cancellation failed'));
  }
}

Renderizar o QR do Asaas (base64 puro, sem prefixo data:):

Image.memory(base64Decode(pix['qr_code_image'] as String));

10. Checklist de implementação

  • [ ] Ler gateway do GET antes de escolher rota de cartão (Asaas × Stripe).
  • [ ] Botão "trocar cartão" oculto em assinatura PIX (retorna 400).
  • [ ] Botão "mudar para PIX" desabilitado enquanto status == 'active' (400 por design).
  • [ ] Tratar toda resposta de renew checando info.pix antes de assumir cartão.
  • [ ] Contagem regressiva de 10 min pelo expiration_date/expires_at do QR; botão "gerar novo QR" chamando /me/pix-qr.
  • [ ] Polling de GET /customers/me/subscription após pagar/renovar (ativação é por webhook).
  • [ ] 429 no subscribe = "aguarde", com botão desabilitado durante a chamada.
  • [ ] Texto de cancelamento deixando claro que o acesso encerra na hora.
  • [ ] Repassar a mensagem honesta de falha de cancelamento no gateway.
  • [ ] Nunca chamar POST /customers/me/subscribe (501).

Referências