Guia do Frontend Flutter — Cadastro e Assinatura¶
Público-alvo: Desenvolvedores Flutter/Dart
Objetivo: Implementar o fluxo completo de cadastro freemium e assinatura de customers.
🎯 Visão Geral do Fluxo¶
O sistema usa modelo freemium: customer se cadastra gratuitamente e assina depois.
flowchart TD
A[Usuario acessa pagina de cadastro] --> B{Preenche email/CPF}
B --> C[GET /customers/check-availability]
C --> D{Disponivel?}
D -->|Nao| E[Exibe erro em tempo real]
E --> B
D -->|Sim| F[Preenche demais campos]
F --> G["POST /customers/signup (sem verification_code)"]
G --> H{Sucesso?}
H -->|Sim| I[Exibe tela de verificacao de email]
H -->|Nao| J[Exibe erro 409/400]
I --> K[Usuario digita codigo de 6 digitos]
K --> L["POST /customers/signup (com verification_code)"]
L --> M{Codigo valido?}
M -->|Sim| N[Recebe JWT token]
M -->|Nao| O{Tentativas restantes?}
O -->|Sim| K
O -->|Nao| P[Reenviar codigo]
P --> I
N --> Q[Redireciona para Dashboard]
Q --> R{Quer assinar agora?}
R -->|Nao| S[Usa app gratuitamente]
R -->|Sim| T[Vai para pagina de planos]
T --> U[Escolhe plano e preenche pagamento]
U --> V[POST /asaas/subscribe ou /stripe/subscribe]
V --> W{Pagamento aprovado?}
W -->|Sim| X[Acesso liberado a videos]
W -->|Nao| Y[Exibe erro de pagamento]
S --> Z{Depois decide assinar?}
Z -->|Sim| T
I --> AA{Nao recebeu codigo?}
AA -->|Sim| AB[POST /auth/resend-verification]
AB --> I
Sincronização Automática no Login
A partir de 2026-02-05, o fluxo de login (POST /token) sincroniza
automaticamente o status de assinatura do customer com o gateway ativo (Stripe ou Asaas).
O que muda para o frontend:
- Após login bem-sucedido, chamar
GET /customers/me/subscriptionretorna o status real (já reconciliado) - Não é necessário implementar polling ou verificação manual de status após login
- O backend detecta automaticamente o gateway (
subscription.gateway) e sincroniza com a API correta - Se o gateway estiver fora, o login funciona normalmente (sync é best-effort)
Fluxo recomendado pós-login:
Gateways de Pagamento
O FDPlay suporta Stripe e Asaas como gateways de pagamento em rotas isoladas:
| Gateway | Rota de Subscribe | Documentação |
|---|---|---|
| Stripe (Cartao) | POST /stripe/subscribe |
Stripe Integration |
| Stripe (PIX) | POST /stripe/subscribe/pix |
Stripe Integration |
| Asaas (Cartão) | POST /asaas/subscribe |
Asaas Integration |
| Asaas (PIX) | POST /asaas/subscribe/pix |
Asaas Integration |
O middleware de acesso a vídeos é gateway-agnostic — só verifica status da assinatura.
Qual gateway usar? Consulte a preferência do admin em runtime:
Todos os gateways estão sempre disponíveis — essa rota indica apenas a preferência configurada pelo admin.🧪 Dados de Teste (Sandbox)¶
Ambiente Sandbox
Todos os dados abaixo só funcionam no ambiente sandbox. Em produção, use dados reais.
Planos Disponíveis¶
Obtendo Planos Atualizados
Os planos disponíveis devem ser obtidos via GET /api/v1/plans (endpoint público).
Os IDs de gateway são preenchidos automaticamente após sincronização.
| Campo | Exemplo | Descrição |
|---|---|---|
plan_id |
plan-basic |
ID interno usado no payload de subscribe |
name |
Plano Básico | Nome de exibição |
amount |
2990 |
R$ 29,90 (valor em centavos) |
interval_unit |
MONTH |
Frequência de cobrança |
trial_days |
7 |
Dias de teste grátis (se trial_enabled=true) |
tier |
1 |
Nível de acesso (1=básico, 2=plus, 3=premium) |
Cartões de Teste (Sandbox)¶
✅ Cartões que APROVAM¶
| Bandeira | Número | CVV | Validade | Uso |
|---|---|---|---|---|
| Visa | 4111111111111111 |
123 |
12/2030 |
Recomendado para testes |
| Visa | 4539620659922097 |
123 |
12/2030 |
Alternativo |
| Mastercard | 5425233430109903 |
123 |
12/2030 |
Teste Mastercard |
| Mastercard | 5500000000000004 |
123 |
12/2030 |
Alternativo |
| Elo | 6362970000457013 |
123 |
12/2030 |
Teste Elo |
❌ Cartões que RECUSAM (para testar erros)¶
| Número | Erro Retornado |
|---|---|
4000000000000002 |
Cartão recusado |
4000000000000069 |
Cartão expirado |
4000000000000127 |
CVV inválido |
CPFs Válidos para Teste¶
| CPF | Observação |
|---|---|
12345678909 |
Recomendado |
11144477735 |
Alternativo |
98765432100 |
Alternativo |
Formato
Sempre envie CPF sem pontos e traços (apenas números).
Configuracao de Ambiente¶
NUNCA hardcode a URL da API no codigo
A baseUrl deve ser configuravel por ambiente. Use variaveis de ambiente ou arquivo de configuracao.
class AppConfig {
/// URL base da API — configurar por ambiente
///
/// Producao: https://fdplay-api.infraifd.com/api/v1
/// Local: http://localhost:8000/api/v1
static String get baseUrl {
// Usar --dart-define ou .env
return const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:8000/api/v1',
);
}
}
Execucao com ambiente definido:
# Desenvolvimento (local)
flutter run --dart-define=API_BASE_URL=http://localhost:8000/api/v1
# Producao
flutter run --dart-define=API_BASE_URL=https://fdplay-api.infraifd.com/api/v1
Nos exemplos de codigo abaixo, baseUrl aparece como constante para simplicidade. Em codigo real, use AppConfig.baseUrl.
Etapa 1: Cadastro de Customer (Signup com Verificacao de Email)¶
Mudanca de Fluxo (2026-02-09)
O cadastro agora exige verificacao de email antes de liberar o JWT.
O mesmo endpoint POST /customers/signup opera em dois passos:
- Sem
verification_code: Cria customer + envia codigo de 6 digitos por email - Com
verification_code: Valida codigo + retorna JWT
O frontend NAO recebe JWT ate o email ser verificado.
Endpoint¶
Fluxo Sequencial¶
sequenceDiagram
participant App as Flutter App
participant API as FDPlay API
participant Resend as Resend (Email)
participant DB as MongoDB
Note over App: Passo 1: Enviar dados de cadastro
App->>API: POST /customers/signup<br/>[{username, email, password, ...}]
API->>DB: Cria customer (email_verified=false)
API->>Resend: Envia codigo 6 digitos
Resend-->>App: Email com codigo
API-->>App: {customer_id, email, message, expires_in_minutes}
Note over App: SEM JWT neste passo
Note over App: Passo 2: Verificar codigo
App->>API: POST /customers/signup<br/>[{email, password, ..., verification_code: "123456"}]
API->>DB: Valida hash do codigo
API->>DB: email_verified=true, limpa campos temporarios
API-->>App: {docs: [customer], info: {auth: {access_token}}}
Note over App: JWT recebido — salvar e redirecionar
JSON do Payload — Explicacao Campo a Campo¶
Passo 1 — Enviar dados (sem verification_code)¶
[
{
"username": "cliente_teste_001",
"email": "cliente.teste@example.com",
"password": "senha_segura_123",
"full_name": "Cliente Teste da Silva",
"tax_id": "12345678909",
"phone": "+5511999887766",
"nationality": "BR"
}
]
Passo 2 — Verificar codigo (com verification_code)¶
[
{
"username": "cliente_teste_001",
"email": "cliente.teste@example.com",
"password": "senha_segura_123",
"full_name": "Cliente Teste da Silva",
"tax_id": "12345678909",
"phone": "+5511999887766",
"nationality": "BR",
"verification_code": "482916"
}
]
| Campo | Tipo | Obrigatorio | Validacao | Descricao |
|---|---|---|---|---|
username |
String |
Sim | 3-50 chars, unico, sem espacos | Nome de usuario para login |
email |
String |
Sim | Email valido, unico | Email do customer |
password |
String |
Sim | Minimo 8 caracteres | Senha (sera hasheada no backend) |
full_name |
String |
Sim | 3-200 caracteres | Nome completo para cobranca |
tax_id |
String |
Nao | 11 digitos (CPF) ou 14 (CNPJ) | CPF/CNPJ. Opcional no cadastro, obrigatorio para pagamento |
phone |
String |
Sim | Formato +55XXXXXXXXXXX |
Telefone com codigo do pais |
nationality |
String |
Nao | 2-3 chars ISO 3166-1 | Nacionalidade (ex: BR, US, PRT) |
verification_code |
String |
Nao | Exatamente 6 digitos (^\d{6}$) |
Codigo recebido por email. Omitir para solicitar envio. Incluir para verificar. |
verify_email_now |
bool |
Nao | true (default) ou false |
true = fluxo Modo A (2 passos, JWT so apos verificacao). false = Modo B (JWT imediato, verificar depois via POST /customers/me/verify-email). |
Campos opcionais no cadastro
tax_id, nationality e address sao opcionais no signup. O tax_id so e exigido no momento do pagamento (subscribe). Isso permite cadastro de clientes estrangeiros sem CPF.
Modo B — Verificacao postergada (ADR-058)
Quando o usuario quer entrar imediatamente sem verificar email, envie verify_email_now: false no Passo 1.
A resposta inclui auth.access_token + email_verification_pending: true.
A verificacao pode ser feita depois com:
await http.post(
Uri.parse('$baseUrl/customers/me/verify-email'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
body: jsonEncode({'code': '482917'}),
);
Resposta: {message, email_verified: true, email_verified_at}.
Se nao houver codigo ativo (expirou, nunca foi enviado), solicite reenvio via POST /auth/resend-verification.
Por que e um Array?
O endpoint aceita List<CustomerSignup> para consistencia com outros endpoints CRUD. Envie sempre um array com um unico objeto.
Regras de Verificacao
- Expiracao: Codigo expira em 15 minutos
- Tentativas: Maximo 5 tentativas por codigo. Apos esgotar, reenvie o codigo.
- Rate limit reenvio: 1 reenvio por 60 segundos (enviar mesmo payload sem
verification_codereenvia) - Reenvio alternativo:
POST /auth/resend-verificationcom{"email": "..."}
Codigo Dart/Flutter — Cadastro com Verificacao de Email¶
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Modelo para dados de cadastro
class CustomerSignupData {
final String username;
final String email;
final String password;
final String fullName;
final String taxId;
final String phone;
final String? verificationCode;
CustomerSignupData({
required this.username,
required this.email,
required this.password,
required this.fullName,
required this.taxId,
required this.phone,
this.verificationCode,
});
Map<String, dynamic> toJson() {
final json = <String, dynamic>{
'username': username,
'email': email,
'password': password,
'full_name': fullName,
'tax_id': taxId.replaceAll(RegExp(r'\D'), ''),
'phone': phone.startsWith('+') ? phone : '+55$phone',
};
if (verificationCode != null) {
json['verification_code'] = verificationCode;
}
return json;
}
}
/// Resposta do Passo 1 (codigo enviado, sem JWT)
class SignupCodeSentResponse {
final String customerId;
final String email;
final String message;
final int expiresInMinutes;
SignupCodeSentResponse({
required this.customerId,
required this.email,
required this.message,
required this.expiresInMinutes,
});
factory SignupCodeSentResponse.fromJson(Map<String, dynamic> json) {
return SignupCodeSentResponse(
customerId: json['customer_id'],
email: json['email'],
message: json['message'],
expiresInMinutes: json['expires_in_minutes'],
);
}
}
/// Resposta do Passo 2 (email verificado, com JWT)
class SignupVerifiedResponse {
final String accessToken;
final String tokenType;
final String customerId;
final String message;
SignupVerifiedResponse({
required this.accessToken,
required this.tokenType,
required this.customerId,
required this.message,
});
factory SignupVerifiedResponse.fromJson(Map<String, dynamic> json) {
final info = json['info'] as Map<String, dynamic>;
final auth = info['auth'] as Map<String, dynamic>;
final docs = json['docs'] as List;
return SignupVerifiedResponse(
accessToken: auth['access_token'],
tokenType: auth['token_type'],
customerId: docs.isNotEmpty ? docs[0]['_id'] : '',
message: info['message'],
);
}
}
/// Service de autenticacao
class AuthService {
static String get baseUrl => AppConfig.baseUrl;
/// Passo 1: Envia dados de cadastro e solicita codigo de verificacao.
///
/// Retorna [SignupCodeSentResponse] com customer_id e tempo de expiracao.
/// Tambem funciona como reenvio se o email ja tem cadastro pendente.
Future<SignupCodeSentResponse> signupSendCode(CustomerSignupData data) async {
final url = Uri.parse('$baseUrl/customers/signup');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode([data.toJson()]),
);
final body = jsonDecode(response.body);
if (response.statusCode == 200) {
return SignupCodeSentResponse.fromJson(body);
}
_throwApiError(body, response.statusCode);
throw Exception('Erro desconhecido');
}
/// Passo 2: Envia codigo de verificacao e recebe JWT.
///
/// [data] deve conter o mesmo payload do Passo 1 + [verificationCode].
/// Retorna [SignupVerifiedResponse] com access_token JWT.
Future<SignupVerifiedResponse> signupVerifyCode(CustomerSignupData data) async {
assert(data.verificationCode != null, 'verification_code obrigatorio no Passo 2');
final url = Uri.parse('$baseUrl/customers/signup');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode([data.toJson()]),
);
final body = jsonDecode(response.body);
if (response.statusCode == 200) {
return SignupVerifiedResponse.fromJson(body);
}
_throwApiError(body, response.statusCode);
throw Exception('Erro desconhecido');
}
/// Reenviar codigo de verificacao via endpoint dedicado.
///
/// Alternativa ao reenvio pelo signup. Rate limited (1/minuto).
Future<void> resendVerificationCode(String email) async {
final url = Uri.parse('$baseUrl/auth/resend-verification');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email}),
);
if (response.statusCode == 429) {
final body = jsonDecode(response.body);
throw Exception(body['detail'] ?? 'Aguarde para reenviar o codigo.');
}
}
void _throwApiError(Map<String, dynamic> body, int statusCode) {
final detail = body['detail'];
if (detail is String) {
throw Exception(detail);
} else if (detail is List) {
final errors = detail.map((e) => e['msg']).join(', ');
throw Exception(errors);
}
throw Exception('Erro $statusCode');
}
}
Exemplo de Uso no Widget — Cadastro com Verificacao¶
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SignupScreen extends StatefulWidget {
@override
_SignupScreenState createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _formKey = GlobalKey<FormState>();
final _authService = AuthService();
bool _loading = false;
String? _error;
bool _codeSent = false; // Controla se estamos no Passo 2
// Controllers — dados de cadastro
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _fullNameController = TextEditingController();
final _taxIdController = TextEditingController();
final _phoneController = TextEditingController();
// Controller — codigo de verificacao
final _codeController = TextEditingController();
CustomerSignupData _buildSignupData({String? code}) {
return CustomerSignupData(
username: _usernameController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
fullName: _fullNameController.text.trim(),
taxId: _taxIdController.text,
phone: _phoneController.text,
verificationCode: code,
);
}
/// Passo 1: Enviar dados e solicitar codigo
Future<void> _handleSendCode() async {
if (!_formKey.currentState!.validate()) return;
setState(() { _loading = true; _error = null; });
try {
final data = _buildSignupData();
await _authService.signupSendCode(data);
setState(() => _codeSent = true);
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _loading = false);
}
}
/// Passo 2: Verificar codigo e receber JWT
Future<void> _handleVerifyCode() async {
final code = _codeController.text.trim();
if (code.length != 6) {
setState(() => _error = 'Digite o codigo de 6 digitos.');
return;
}
setState(() { _loading = true; _error = null; });
try {
final data = _buildSignupData(code: code);
final response = await _authService.signupVerifyCode(data);
// Salva token
final prefs = await SharedPreferences.getInstance();
await prefs.setString('access_token', response.accessToken);
await prefs.setString('user_id', response.customerId);
// Navega para dashboard
Navigator.pushReplacementNamed(context, '/dashboard');
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _loading = false);
}
}
/// Reenviar codigo
Future<void> _handleResendCode() async {
setState(() { _loading = true; _error = null; });
try {
await _authService.resendVerificationCode(_emailController.text.trim());
setState(() => _error = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Codigo reenviado!'), backgroundColor: Colors.green),
);
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_codeSent ? 'Verificar Email' : 'Criar Conta')),
body: Padding(
padding: EdgeInsets.all(16),
child: _codeSent ? _buildVerificationForm() : _buildSignupForm(),
),
);
}
/// Formulario de cadastro (Passo 1)
Widget _buildSignupForm() {
return Form(
key: _formKey,
child: ListView(
children: [
if (_error != null)
Container(
padding: EdgeInsets.all(12),
color: Colors.red.shade100,
child: Text(_error!, style: TextStyle(color: Colors.red)),
),
SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: InputDecoration(labelText: 'Nome de usuario'),
validator: (v) => v!.length < 3 ? 'Minimo 3 caracteres' : null,
),
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (v) => !v!.contains('@') ? 'Email invalido' : null,
),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Senha'),
obscureText: true,
validator: (v) => v!.length < 8 ? 'Minimo 8 caracteres' : null,
),
TextFormField(
controller: _fullNameController,
decoration: InputDecoration(labelText: 'Nome completo'),
validator: (v) => v!.length < 3 ? 'Nome muito curto' : null,
),
TextFormField(
controller: _taxIdController,
decoration: InputDecoration(labelText: 'CPF (apenas numeros)'),
keyboardType: TextInputType.number,
validator: (v) {
final digits = v!.replaceAll(RegExp(r'\D'), '');
return digits.length != 11 ? 'CPF deve ter 11 digitos' : null;
},
),
TextFormField(
controller: _phoneController,
decoration: InputDecoration(labelText: 'Telefone (11999887766)'),
keyboardType: TextInputType.phone,
validator: (v) => v!.length < 10 ? 'Telefone invalido' : null,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _handleSendCode,
child: _loading
? CircularProgressIndicator(color: Colors.white)
: Text('Criar Conta'),
),
],
),
);
}
/// Tela de verificacao de codigo (Passo 2)
Widget _buildVerificationForm() {
return ListView(
children: [
Icon(Icons.email_outlined, size: 64, color: Colors.blue),
SizedBox(height: 16),
Text(
'Verifique seu email',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
'Enviamos um codigo de 6 digitos para\n${_emailController.text}',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
SizedBox(height: 24),
if (_error != null)
Container(
padding: EdgeInsets.all(12),
margin: EdgeInsets.only(bottom: 16),
color: Colors.red.shade100,
child: Text(_error!, style: TextStyle(color: Colors.red)),
),
TextFormField(
controller: _codeController,
decoration: InputDecoration(
labelText: 'Codigo de verificacao',
hintText: '000000',
counterText: '',
),
keyboardType: TextInputType.number,
maxLength: 6,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24, letterSpacing: 8),
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _handleVerifyCode,
child: _loading
? CircularProgressIndicator(color: Colors.white)
: Text('Verificar'),
),
SizedBox(height: 16),
TextButton(
onPressed: _loading ? null : _handleResendCode,
child: Text('Reenviar codigo'),
),
TextButton(
onPressed: () => setState(() { _codeSent = false; _error = null; }),
child: Text('Voltar e editar dados'),
),
],
);
}
}
Respostas do Endpoint — Exemplos¶
Passo 1 — Codigo enviado (200 OK)¶
{
"customer_id": "678c1a2b3d4e5f6789012345",
"email": "cliente.teste@example.com",
"message": "Codigo de verificacao enviado para o email.",
"expires_in_minutes": 15
}
| Campo | Descricao |
|---|---|
customer_id |
ID do customer criado (MongoDB ObjectId) |
email |
Email para onde o codigo foi enviado |
message |
Mensagem de confirmacao |
expires_in_minutes |
Tempo de vida do codigo (15 min) |
Sem JWT neste passo
O Passo 1 NAO retorna token JWT. O customer e criado com email_verified=false e nao pode acessar endpoints protegidos ate completar o Passo 2.
Passo 2 — Email verificado (200 OK)¶
{
"docs": [
{
"_id": "678c1a2b3d4e5f6789012345",
"username": "cliente_teste_001",
"email": "cliente.teste@example.com",
"user_type": "customer",
"full_name": "Cliente Teste da Silva",
"tax_id": "12345678909",
"phone": "+5511999887766",
"email_verified": true,
"current_subscription_id": null,
"created_at": "2026-02-09T15:30:00Z",
"updated_at": "2026-02-09T15:30:45Z"
}
],
"info": {
"auth": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
},
"message": "Email verificado com sucesso.",
"next_step": "/api/v1/customers/me/subscribe"
},
"total_docs": 1,
"qty_docs_page": 1,
"current_page": 0
}
| Campo | Descricao |
|---|---|
docs[0]._id |
ID do customer verificado |
docs[0].email_verified |
true — email confirmado |
info.auth.access_token |
Token JWT — salvar para requests autenticadas |
info.next_step |
Proximo endpoint sugerido (assinatura) |
Erros possiveis¶
| Status | detail |
Causa | Acao do frontend |
|---|---|---|---|
400 |
Codigo expirado. Reenvie o codigo. |
Codigo passou de 15 min | Exibir botao "Reenviar codigo" |
400 |
Codigo incorreto. N tentativa(s) restante(s). |
Codigo errado | Exibir tentativas restantes |
404 |
Nenhum cadastro pendente encontrado para este email. |
Email nao tem cadastro pendente | Redirecionar para signup |
409 |
Email already exists |
Email ja verificado | Sugerir login |
409 |
Username already exists |
Username em uso | Pedir outro username |
429 |
Aguarde N segundos para reenviar o codigo. |
Rate limit reenvio (60s) | Exibir timer |
429 |
Maximo de tentativas atingido. Reenvie o codigo. |
5 tentativas erradas | Reenviar codigo automaticamente |
Endpoint de Reenvio de Codigo¶
Alternativa dedicada para reenviar o codigo sem precisar repetir todos os dados de cadastro:
Resposta (200 OK):
{
"message": "Se o email estiver cadastrado e pendente, o codigo sera reenviado.",
"expires_in_minutes": 15
}
Resposta Generica (Anti-Enumeracao)
A resposta e identica independente de o email existir ou nao. Isso previne que atacantes descubram quais emails estao cadastrados.
💳 Etapa 2: Assinatura (Subscribe)¶
O FDPlay suporta Stripe e Asaas como gateways de pagamento. Use os guias específicos para cada gateway:
| Gateway | Tipo | Guia |
|---|---|---|
| Stripe (Cartão) | POST /stripe/subscribe |
Stripe Integration |
| Stripe (PIX) | POST /stripe/subscribe/pix |
Stripe Integration |
| Asaas (Cartão) | POST /asaas/subscribe |
Asaas Integration |
| Asaas (PIX) | POST /asaas/subscribe/pix |
Asaas Integration |
Qual gateway usar?
Consulte a preferência configurada pelo admin:
Modelo de Endereço¶
/// Modelo para endereço
class AddressData {
final String street;
final String number;
final String complement;
final String locality;
final String city;
final String regionCode;
final String postalCode;
AddressData({
required this.street,
required this.number,
this.complement = '',
required this.locality,
required this.city,
required this.regionCode,
required this.postalCode,
});
Map<String, dynamic> toJson() => {
'street': street,
'number': number,
'complement': complement,
'locality': locality,
'city': city,
'region_code': regionCode.toUpperCase(),
'country': 'BRA',
'postal_code': postalCode.replaceAll(RegExp(r'\D'), ''),
};
}
Resposta de Sucesso — Assinatura¶
{
"subscription": {
"_id": "678c2d3e4f5a6b7890123456",
"customer_id": "678c1a2b3d4e5f6789012345",
"plan_id": "plan-basic",
"status": "active",
"payment_method": "credit_card",
"started_at": "2026-01-18T18:20:15Z",
"next_billing_date": "2026-02-18T00:00:00Z",
"expires_at": null,
"payment_failures": 0
},
"message": "Subscription created successfully. You can now access premium content."
}
Exemplo de Uso — Tela de Pagamento¶
class PaymentScreen extends StatefulWidget {
final String planId;
final String planName;
final int planAmount; // Em centavos
PaymentScreen({
required this.planId,
required this.planName,
required this.planAmount,
});
@override
_PaymentScreenState createState() => _PaymentScreenState();
}
class _PaymentScreenState extends State<PaymentScreen> {
final _formKey = GlobalKey<FormState>();
final _subscriptionService = SubscriptionService();
bool _loading = false;
String? _error;
// Controllers - Cartão
final _cardNumberController = TextEditingController();
final _expMonthController = TextEditingController();
final _expYearController = TextEditingController();
final _cvvController = TextEditingController();
final _holderNameController = TextEditingController();
// Controllers - Endereço
final _streetController = TextEditingController();
final _numberController = TextEditingController();
final _complementController = TextEditingController();
final _localityController = TextEditingController();
final _cityController = TextEditingController();
final _regionCodeController = TextEditingController();
final _postalCodeController = TextEditingController();
String get _formattedPrice =>
'R\$ ${(widget.planAmount / 100).toStringAsFixed(2)}';
Future<void> _handlePayment() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token');
if (token == null) {
throw Exception('Sessão expirada. Faça login novamente.');
}
final address = AddressData(
street: _streetController.text,
number: _numberController.text,
complement: _complementController.text,
locality: _localityController.text,
city: _cityController.text,
regionCode: _regionCodeController.text,
postalCode: _postalCodeController.text,
);
// Chama o service que criptografa e envia
final subscription = await _subscriptionService.subscribe(
token: token,
planId: widget.planId,
cardNumber: _cardNumberController.text,
cardHolder: _holderNameController.text,
cardCvv: _cvvController.text,
cardExpMonth: _expMonthController.text,
cardExpYear: _expYearController.text,
address: address,
);
// Sucesso!
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Assinatura criada! Status: ${subscription.status}'),
backgroundColor: Colors.green,
),
);
// Navega para vídeos
Navigator.pushReplacementNamed(context, '/videos');
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Assinar ${widget.planName}')),
body: Padding(
padding: EdgeInsets.all(16),
child: Form(
key: _formKey,
child: ListView(
children: [
// Preço
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Text(widget.planName, style: TextStyle(fontSize: 20)),
SizedBox(height: 8),
Text(_formattedPrice + '/mês',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
],
),
),
),
SizedBox(height: 16),
if (_error != null)
Container(
padding: EdgeInsets.all(12),
color: Colors.red.shade100,
child: Text(_error!, style: TextStyle(color: Colors.red)),
),
// Dados do Cartão
Text('Dados do Cartão', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
TextFormField(
controller: _cardNumberController,
decoration: InputDecoration(labelText: 'Número do cartão'),
keyboardType: TextInputType.number,
maxLength: 19,
validator: (v) {
final digits = v!.replaceAll(RegExp(r'\D'), '');
return digits.length < 15 ? 'Número inválido' : null;
},
),
Row(
children: [
Expanded(
child: TextFormField(
controller: _expMonthController,
decoration: InputDecoration(labelText: 'Mês'),
keyboardType: TextInputType.number,
maxLength: 2,
validator: (v) {
final month = int.tryParse(v!) ?? 0;
return month < 1 || month > 12 ? 'Inválido' : null;
},
),
),
SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _expYearController,
decoration: InputDecoration(labelText: 'Ano'),
keyboardType: TextInputType.number,
maxLength: 4,
validator: (v) {
final year = int.tryParse(v!) ?? 0;
return year < 2026 ? 'Inválido' : null;
},
),
),
SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _cvvController,
decoration: InputDecoration(labelText: 'CVV'),
keyboardType: TextInputType.number,
maxLength: 4,
obscureText: true,
validator: (v) => v!.length < 3 ? 'Inválido' : null,
),
),
],
),
TextFormField(
controller: _holderNameController,
decoration: InputDecoration(labelText: 'Nome no cartão'),
textCapitalization: TextCapitalization.characters,
validator: (v) => v!.length < 3 ? 'Nome inválido' : null,
),
SizedBox(height: 24),
// Endereço
Text('Endereço de Cobrança', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
TextFormField(
controller: _postalCodeController,
decoration: InputDecoration(labelText: 'CEP'),
keyboardType: TextInputType.number,
maxLength: 9,
validator: (v) {
final digits = v!.replaceAll(RegExp(r'\D'), '');
return digits.length != 8 ? 'CEP deve ter 8 dígitos' : null;
},
),
TextFormField(
controller: _streetController,
decoration: InputDecoration(labelText: 'Rua/Avenida'),
validator: (v) => v!.length < 3 ? 'Endereço inválido' : null,
),
Row(
children: [
Expanded(
flex: 1,
child: TextFormField(
controller: _numberController,
decoration: InputDecoration(labelText: 'Número'),
validator: (v) => v!.isEmpty ? 'Obrigatório' : null,
),
),
SizedBox(width: 16),
Expanded(
flex: 2,
child: TextFormField(
controller: _complementController,
decoration: InputDecoration(labelText: 'Complemento'),
),
),
],
),
TextFormField(
controller: _localityController,
decoration: InputDecoration(labelText: 'Bairro'),
validator: (v) => v!.length < 3 ? 'Bairro inválido' : null,
),
Row(
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: _cityController,
decoration: InputDecoration(labelText: 'Cidade'),
validator: (v) => v!.length < 3 ? 'Cidade inválida' : null,
),
),
SizedBox(width: 16),
Expanded(
flex: 1,
child: TextFormField(
controller: _regionCodeController,
decoration: InputDecoration(labelText: 'UF'),
textCapitalization: TextCapitalization.characters,
maxLength: 2,
validator: (v) => v!.length != 2 ? 'UF inválida' : null,
),
),
],
),
SizedBox(height: 32),
ElevatedButton(
onPressed: _loading ? null : _handlePayment,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
),
child: _loading
? CircularProgressIndicator(color: Colors.white)
: Text('Assinar por $_formattedPrice/mês'),
),
],
),
),
),
);
}
}
Resposta de Sucesso — Assinatura¶
{
"subscription": {
"_id": "678c2d3e4f5a6b7890123456",
"customer_id": "678c1a2b3d4e5f6789012345",
"plan_id": "plan-basic",
"status": "active",
"payment_method": "credit_card",
"started_at": "2026-01-18T18:20:15Z",
"next_billing_date": "2026-02-18T00:00:00Z",
"expires_at": null,
"payment_failures": 0
},
"message": "Subscription created successfully. You can now access premium content."
}
| Campo | Descrição |
|---|---|
status |
trial = período de teste, active = pagando |
next_billing_date |
Próxima cobrança (após trial) |
💰 Métodos de Pagamento Disponíveis¶
O sistema suporta diferentes métodos de pagamento:
| Método | Mensal | Trimestral | Semestral | Anual | Pagamento Único |
|---|---|---|---|---|---|
| Cartão de Crédito | ✅ Automático | - | - | - | ✅ |
| PIX | ✅ | ✅ | ✅ | ✅ | ✅ |
| Boleto | ✅ Via API | - | - | - | ✅ |
PIX para Assinaturas
O periodo do PIX e configurado pelo admin via pix_billing_mode: monthly (1m), quarterly (3m), semiannually (6m) ou yearly (12m).
O frontend consulta GET /api/v1/config/pix-billing-mode para exibir o valor correto.
🟢 PIX — Assinatura por Periodo¶
PIX para assinaturas funciona como pagamento unico do periodo configurado. O backend cria uma order PIX via Asaas e gera um QR code. Quando o cliente paga, um webhook ativa a assinatura automaticamente.
Fluxo PIX Anual¶
sequenceDiagram
participant App as Flutter App
participant API as FDPlay API
participant Asaas
participant DB as MongoDB
App->>API: POST /asaas/subscribe/pix<br/>{plan_id}
API->>Asaas: POST /pix/qrCodes (QR code PIX)
Asaas-->>API: {id, encodedImage, payload}
API->>DB: Subscription (status=pending)
API-->>App: {subscription, pix: {qr_codes}}
Note over App: Exibe QR Code para cliente
App->>App: Cliente escaneia com app do banco
Note over Asaas: Pagamento confirmado
Asaas->>API: POST /webhooks/asaas<br/>event: PAYMENT_RECEIVED
API->>DB: Subscription status=active<br/>expires_at = now + 1 ano
Note over App: GET /videos → 200 OK
Endpoint (Rota Preferida — Asaas)¶
Payload PIX — Campos¶
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
plan_id |
String |
✅ | Slug do plano (ex: plan-basic) |
promo_code |
String |
Nao | Codigo promocional (opcional) |
Rota Legacy
A rota POST /customers/me/subscribe e a rota legacy e retorna HTTP 501 (Not Implemented).
Use as rotas especificas por gateway:
- Asaas PIX:
POST /asaas/subscribe/pix - Asaas Cartao:
POST /asaas/subscribe - Stripe Cartao:
POST /stripe/subscribe - Stripe PIX:
POST /stripe/subscribe/pix
Resposta de Sucesso — PIX¶
{
"subscription": {
"_id": "507f1f77bcf86cd799439012",
"customer_id": "507f1f77bcf86cd799439011",
"plan_id": "plan-basic",
"status": "pending",
"payment_method": "pix",
"started_at": "2026-02-03T15:00:00Z",
"next_billing_date": null,
"expires_at": null
},
"pix": {
"order_id": "ORDE_A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"qr_codes": [
{
"id": "QRCO_XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"amount": {
"value": 23880
},
"text": "00020126580014br.gov.bcb.pix0136...",
"links": [
{
"rel": "QRCODE.PNG",
"href": "https://sandbox.asaas.com/qrcodes/QRCO_.../png",
"media": "image/png",
"type": "GET"
}
]
}
],
"expiration_date": "2026-02-03T15:30:00-03:00"
},
"message": "PIX subscription created. Scan QR code to pay. Subscription activates automatically after payment confirmation."
}
| Campo | Descrição |
|---|---|
subscription.status |
"pending" — aguardando pagamento PIX |
subscription.next_billing_date |
null — PIX não tem recorrência |
subscription.expires_at |
null — definido como now + 1 ano após pagamento |
pix.qr_codes[0].text |
Codigo "copia e cola" para pagamento |
pix.qr_codes[0].links[0].href |
URL da imagem PNG do QR Code |
pix.expiration_date |
QR Code expira em 30 minutos |
Erros Possíveis¶
| Codigo | Mensagem | Causa |
|---|---|---|
422 |
amount is required for PIX |
Campo amount ausente no payload |
400 |
Customer already has an active subscription |
Ja tem assinatura ativa |
404 |
Plan not found: plan-xxx |
plan_id invalido |
500 |
PIX order creation failed |
Erro ao criar order PIX no gateway |
Codigo Dart/Flutter — Assinatura PIX Anual¶
/// Service para assinatura PIX anual
class PixSubscriptionService {
static String get baseUrl => AppConfig.baseUrl;
/// Cria assinatura via PIX (Asaas)
///
/// [planId] — Slug do plano (ex: "plan-basic")
Future<PixSubscriptionResponse> subscribePix({
required String token,
required String planId,
String? promoCode,
}) async {
final payload = <String, dynamic>{
'plan_id': planId,
};
if (promoCode != null) {
payload['promo_code'] = promoCode;
}
final response = await http.post(
Uri.parse('$baseUrl/asaas/subscribe/pix'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode(payload),
);
final body = jsonDecode(response.body);
if (response.statusCode == 201) {
return PixSubscriptionResponse.fromJson(body);
} else {
final detail = body['detail'] ?? 'Erro ao criar assinatura PIX';
throw Exception(detail is String ? detail : jsonEncode(detail));
}
}
}
/// Resposta da assinatura PIX
class PixSubscriptionResponse {
final String subscriptionId;
final String orderId;
final String status;
final String qrCodeText;
final String qrCodeImageUrl;
final DateTime expiresAt;
final int amount;
PixSubscriptionResponse({
required this.subscriptionId,
required this.orderId,
required this.status,
required this.qrCodeText,
required this.qrCodeImageUrl,
required this.expiresAt,
required this.amount,
});
factory PixSubscriptionResponse.fromJson(Map<String, dynamic> json) {
final sub = json['subscription'];
final pix = json['pix'];
final qrCodes = pix['qr_codes'] as List;
final qrCode = qrCodes.isNotEmpty ? qrCodes[0] : {};
final links = (qrCode['links'] as List?) ?? [];
final imageLink = links.firstWhere(
(l) => l['media'] == 'image/png',
orElse: () => {'href': ''},
);
return PixSubscriptionResponse(
subscriptionId: sub['_id'],
orderId: pix['order_id'],
status: sub['status'],
qrCodeText: qrCode['text'] ?? '',
qrCodeImageUrl: imageLink['href'] ?? '',
expiresAt: DateTime.parse(pix['expiration_date']),
amount: qrCode['amount']?['value'] ?? 0,
);
}
String get formattedAmount =>
'R\$ ${(amount / 100).toStringAsFixed(2)}';
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
Tela de Pagamento PIX Anual (Widget)¶
class PixAnnualPaymentScreen extends StatefulWidget {
final String planId;
final String planName;
final int annualAmount; // Em centavos
const PixAnnualPaymentScreen({
Key? key,
required this.planId,
required this.planName,
required this.annualAmount,
}) : super(key: key);
@override
_PixAnnualPaymentScreenState createState() => _PixAnnualPaymentScreenState();
}
class _PixAnnualPaymentScreenState extends State<PixAnnualPaymentScreen> {
final _pixService = PixSubscriptionService();
bool _loading = true;
String? _error;
PixSubscriptionResponse? _pixResponse;
@override
void initState() {
super.initState();
_createPixSubscription();
}
Future<void> _createPixSubscription() async {
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token');
if (token == null) {
throw Exception('Sessão expirada. Faça login novamente.');
}
final response = await _pixService.subscribePix(
token: token,
planId: widget.planId,
);
setState(() {
_pixResponse = response;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString().replaceFirst('Exception: ', '');
_loading = false;
});
}
}
void _copyPixCode() {
if (_pixResponse != null) {
Clipboard.setData(ClipboardData(text: _pixResponse!.qrCodeText));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Código PIX copiado!'), backgroundColor: Colors.green),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('PIX — ${widget.planName} Anual')),
body: _loading
? Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() { _loading = true; _error = null; });
_createPixSubscription();
},
child: Text('Tentar Novamente'),
),
],
),
)
: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
children: [
// Info do plano
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(children: [
Text(widget.planName, style: TextStyle(fontSize: 18)),
SizedBox(height: 4),
Text('Plano Anual', style: TextStyle(color: Colors.grey)),
SizedBox(height: 8),
Text(
_pixResponse!.formattedAmount,
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Colors.green),
),
Text('pagamento único (12 meses)', style: TextStyle(color: Colors.grey)),
]),
),
),
SizedBox(height: 24),
Text('Escaneie o QR Code', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 16),
// QR Code image
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: _pixResponse!.qrCodeImageUrl.isNotEmpty
? Image.network(_pixResponse!.qrCodeImageUrl, width: 250, height: 250)
: Icon(Icons.qr_code_2, size: 250, color: Colors.grey),
),
SizedBox(height: 24),
Text('Ou copie o código PIX:', style: TextStyle(fontSize: 16)),
SizedBox(height: 8),
// Copia e cola
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(children: [
Expanded(
child: Text(
_pixResponse!.qrCodeText,
style: TextStyle(fontSize: 12, fontFamily: 'monospace'),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
IconButton(icon: Icon(Icons.copy), onPressed: _copyPixCode),
]),
),
SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _copyPixCode,
icon: Icon(Icons.copy),
label: Text('Copiar Código PIX'),
),
SizedBox(height: 24),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.timer, size: 16, color: Colors.orange),
SizedBox(width: 8),
Text(
'QR Code expira em 30 minutos',
style: TextStyle(color: Colors.orange),
),
]),
SizedBox(height: 16),
// Status info
Card(
color: Colors.blue.shade50,
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Após o pagamento:', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('1. O pagamento é confirmado automaticamente via webhook'),
Text('2. Sua assinatura é ativada (status: active)'),
Text('3. Acesso a vídeos é liberado por 12 meses'),
Text('4. Você pode verificar com GET /customers/me/subscription'),
],
),
),
),
],
),
),
);
}
}
Verificar Status da Assinatura PIX (Polling)¶
Após exibir o QR code, use polling para detectar quando o pagamento foi confirmado:
/// Verifica se a assinatura PIX foi ativada.
/// O backend resolve automaticamente subscriptions Stripe pendentes:
/// se o pagamento falhou, retorna status='cancelled' ou 404.
Future<void> pollSubscriptionStatus(String token) async {
Timer.periodic(Duration(seconds: 5), (timer) async {
try {
final response = await http.get(
Uri.parse('$baseUrl/customers/me/subscription'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final status = data['subscription']['status'];
if (status == 'active') {
timer.cancel();
Navigator.pushReplacementNamed(context, '/videos');
} else if (status == 'cancelled') {
timer.cancel();
// Pagamento falhou — exibir erro e permitir retry
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pagamento não confirmado. Tente novamente.')),
);
}
} else if (response.statusCode == 404) {
timer.cancel();
// Subscription removida (pagamento falhou) — permitir retry
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Pagamento expirado. Tente novamente.')),
);
}
} catch (_) {
// Ignorar erros de polling, tentar novamente
}
});
}
Webhook vs Polling
O backend recebe a confirmação de pagamento via webhook automaticamente. O polling no frontend serve apenas para atualizar a UI quando o status mudar de pending para active.
Resolução automática de status pendente (Stripe)
Para subscriptions Stripe com status: pending, o endpoint GET /customers/me/subscription consulta o Stripe API em tempo real. Se o pagamento falhou (incomplete, canceled), o backend atualiza para cancelled e limpa current_subscription_id automaticamente. O polling nunca retorna pending indefinidamente para pagamentos Stripe recusados.
Recuperar QR Code PIX (Abandono)¶
Se o customer fechar o app antes de pagar, pode recuperar o QR code na proxima visita:
/// Recuperar QR code PIX para subscription pending.
/// Retorna null se nao ha subscription pending.
Future<Map<String, dynamic>?> recoverPixQr(String token) async {
final resp = await http.get(
Uri.parse('$baseUrl/api/v1/customers/me/pix-qr'),
headers: {'Authorization': 'Bearer $token'},
);
if (resp.statusCode == 200) return jsonDecode(resp.body);
if (resp.statusCode == 404) return null; // sem pending
if (resp.statusCode == 410) return null; // QR expirado (Stripe, 30min)
throw Exception('Erro: ${resp.body}');
}
Fluxo recomendado no app
Ao abrir a tela de assinatura, verificar primeiro se ha subscription pending via GET /customers/me/pix-qr. Se retornar QR code, exibi-lo diretamente. Se retornar 404/410, exibir fluxo normal de subscribe.
Limpeza automatica (Asaas)
Subscriptions pending com mais de 24h sao auto-canceladas quando o customer tenta criar nova assinatura PIX. O frontend nao precisa gerenciar expiracao manualmente.
Cancelar PIX Pendente (trocar meio de pagamento)¶
Se o customer gerou um PIX mas quer pagar com cartao, cancele o PIX pendente primeiro:
/// Cancelar subscription pendente (PIX ou qualquer gateway).
/// Depois o customer pode criar nova subscription com outro metodo.
Future<void> cancelPendingSubscription(String token) async {
final resp = await http.delete(
Uri.parse('$baseUrl/api/v1/customers/me/subscription'),
headers: {'Authorization': 'Bearer $token'},
);
if (resp.statusCode != 200) {
throw Exception('Erro ao cancelar: ${resp.body}');
}
}
Fluxo completo: PIX → Cartao
GET /customers/me/pix-qr→ exibir QR code + botao "Cancelar e usar cartao"- Ao clicar:
DELETE /customers/me/subscription→ cancela PIX no gateway - Redirecionar para fluxo de cartao (
POST /asaas/subscribeouPOST /stripe/subscribe)
Trocar Cartao de Credito (Renew)¶
PUT /api/v1/customers/me/subscription/renew
Cancela a assinatura atual e cria uma nova com o mesmo plano e novo cartao. O gateway e detectado automaticamente a partir da assinatura ativa.
Payload — Asaas¶
{
"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"
}
}
Payload — Stripe¶
Resposta (200)¶
{
"subscription": {
"id": "507f1f77bcf86cd799439011",
"plan_id": "plan-basic",
"gateway": "asaas",
"status": "pending",
"payment_method": "credit_card",
"started_at": "2026-04-03T12:00:00+00:00"
},
"message": "Assinatura renovada com sucesso. Novo cartao configurado."
}
Erros¶
| Status | Detalhe |
|---|---|
| 400 | Campos obrigatorios faltando para o gateway |
| 400 | Subscription com status invalido para renew |
| 404 | Customer ou subscription nao encontrada |
Compra de Ingressos (Ticket Store)¶
Para compra avulsa de ingressos para eventos, consulte a secao Ticket Store abaixo.
🔍 Verificação de Disponibilidade (Opcional)¶
Antes do cadastro, verifique se email/CPF estão disponíveis.
Endpoint¶
Código Dart¶
class AvailabilityResult {
final bool? emailAvailable;
final bool? taxIdAvailable;
AvailabilityResult({this.emailAvailable, this.taxIdAvailable});
factory AvailabilityResult.fromJson(Map<String, dynamic> json) {
return AvailabilityResult(
emailAvailable: json['email_available'],
taxIdAvailable: json['tax_id_available'],
);
}
}
Future<AvailabilityResult> checkAvailability({
String? email,
String? taxId,
}) async {
final params = <String, String>{};
if (email != null) params['email'] = email;
if (taxId != null) params['tax_id'] = taxId.replaceAll(RegExp(r'\D'), '');
final url = Uri.parse('$baseUrl/customers/check-availability')
.replace(queryParameters: params);
final response = await http.get(url);
return AvailabilityResult.fromJson(jsonDecode(response.body));
}
// Uso com debounce
Timer? _debounce;
void _onEmailChanged(String value) {
_debounce?.cancel();
_debounce = Timer(Duration(milliseconds: 500), () async {
if (value.contains('@')) {
final result = await checkAvailability(email: value);
if (result.emailAvailable == false) {
setState(() => _emailError = 'Email já cadastrado');
} else {
setState(() => _emailError = null);
}
}
});
}
🔐 Autenticação — Header Authorization¶
Todas as requests após login devem incluir o token JWT:
Verificar Expiração do Token¶
bool isTokenExpired(String token) {
try {
final parts = token.split('.');
if (parts.length != 3) return true;
final payload = jsonDecode(
utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))
);
final exp = payload['exp'] as int;
return DateTime.now().millisecondsSinceEpoch > exp * 1000;
} catch (_) {
return true;
}
}
🔑 Recuperação de Senha (Forgot Password)¶
Fluxo para customers que esqueceram a senha. Totalmente público (sem autenticação).
Fluxo Visual¶
flowchart TD
A[Tela de Login] --> B[Clica em 'Esqueci minha senha']
B --> C[Tela Forgot Password]
C --> D[Digita email]
D --> E[POST /auth/forgot-password]
E --> F[Navega para tela de código]
F --> G[Usuário abre email]
G --> H[Copia código de 6 dígitos]
H --> I[Digita código + nova senha + confirmação]
I --> J[POST /auth/reset-password]
J --> K{Sucesso?}
K -->|Sim| L[Redireciona ao Login]
K -->|Código expirado| M[Exibe erro + botão para reenviar]
K -->|Código inválido| N[Exibe erro + tentativas restantes]
K -->|Tentativas esgotadas| M
Endpoints Envolvidos¶
| Endpoint | Método | Auth | Descrição |
|---|---|---|---|
/auth/forgot-password |
POST | ❌ Não | Solicita envio de código de 6 dígitos por email |
/auth/reset-password |
POST | ❌ Não | Redefine senha com email + código + nova senha |
Parâmetros do Código
- Código de 6 dígitos numéricos
- Expira em 15 minutos
- Máximo 5 tentativas por código
- Reenvio a cada 60 segundos (mesmo endpoint
forgot-password)
Código Dart/Flutter — Recuperação de Senha¶
import 'dart:convert';
import 'package:http/http.dart' as http;
class PasswordResetService {
static String get baseUrl => AppConfig.baseUrl;
/// Solicita reset de senha (envia email com código de 6 dígitos).
///
/// Sempre retorna sucesso (anti-enumeração de emails).
/// Rate limit: 1 reenvio a cada 60 segundos (429 se antes).
Future<String> forgotPassword(String email) async {
final response = await http.post(
Uri.parse('$baseUrl/auth/forgot-password'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email}),
);
if (response.statusCode == 429) {
final body = jsonDecode(response.body);
throw Exception(body['detail'] ?? 'Aguarde para reenviar o código.');
}
final body = jsonDecode(response.body);
return body['message'];
}
/// Redefine a senha usando o código de 6 dígitos recebido via email.
///
/// Lança [Exception] se o código for inválido, expirado
/// ou se as tentativas foram esgotadas.
Future<String> resetPassword({
required String email,
required String code,
required String newPassword,
}) async {
final response = await http.post(
Uri.parse('$baseUrl/auth/reset-password'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'email': email,
'code': code,
'new_password': newPassword,
}),
);
final body = jsonDecode(response.body);
if (response.statusCode == 200) {
return body['message'];
}
if (response.statusCode == 429) {
throw Exception(body['detail'] ?? 'Tentativas esgotadas. Solicite um novo código.');
}
throw Exception(body['detail'] ?? 'Erro ao redefinir senha');
}
}
Widget — Tela Esqueci Minha Senha¶
class ForgotPasswordScreen extends StatefulWidget {
@override
_ForgotPasswordScreenState createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _emailController = TextEditingController();
final _service = PasswordResetService();
bool _loading = false;
String? _error;
Future<void> _handleSubmit() async {
if (_emailController.text.isEmpty || !_emailController.text.contains('@')) {
setState(() => _error = 'Digite um email válido');
return;
}
setState(() { _loading = true; _error = null; });
try {
await _service.forgotPassword(_emailController.text.trim());
// Navega para tela de código, passando o email
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ResetPasswordScreen(
email: _emailController.text.trim(),
),
),
);
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Esqueci Minha Senha')),
body: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Digite seu email para receber o código de redefinição de senha.',
textAlign: TextAlign.center,
),
SizedBox(height: 24),
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
),
if (_error != null) ...[
SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Colors.red)),
],
SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _handleSubmit,
child: _loading
? CircularProgressIndicator(color: Colors.white)
: Text('Enviar Código'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Voltar ao Login'),
),
],
),
),
);
}
}
Widget — Tela Redefinir Senha (Código)¶
class ResetPasswordScreen extends StatefulWidget {
final String email; // Email informado na tela anterior
const ResetPasswordScreen({Key? key, required this.email}) : super(key: key);
@override
_ResetPasswordScreenState createState() => _ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final _codeController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
final _service = PasswordResetService();
bool _loading = false;
bool _resending = false;
bool _success = false;
String? _error;
Future<void> _handleSubmit() async {
if (_codeController.text.length != 6) {
setState(() => _error = 'Digite o código de 6 dígitos');
return;
}
if (_passwordController.text.length < 8) {
setState(() => _error = 'A senha deve ter no mínimo 8 caracteres');
return;
}
if (_passwordController.text != _confirmController.text) {
setState(() => _error = 'As senhas não coincidem');
return;
}
setState(() { _loading = true; _error = null; });
try {
await _service.resetPassword(
email: widget.email,
code: _codeController.text,
newPassword: _passwordController.text,
);
setState(() => _success = true);
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _loading = false);
}
}
Future<void> _handleResend() async {
setState(() { _resending = true; _error = null; });
try {
await _service.forgotPassword(widget.email);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Novo código enviado!')),
);
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _resending = false);
}
}
@override
Widget build(BuildContext context) {
if (_success) {
return Scaffold(
body: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, size: 64, color: Colors.green),
SizedBox(height: 16),
Text('Senha redefinida com sucesso!', style: TextStyle(fontSize: 18)),
SizedBox(height: 8),
Text('Faça login com sua nova senha.'),
SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.pushReplacementNamed(context, '/login'),
child: Text('Ir para Login'),
),
],
),
),
);
}
return Scaffold(
appBar: AppBar(title: Text('Redefinir Senha')),
body: Padding(
padding: EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Digite o código de 6 dígitos enviado para ${widget.email}',
textAlign: TextAlign.center,
),
SizedBox(height: 24),
TextFormField(
controller: _codeController,
decoration: InputDecoration(labelText: 'Código de 6 dígitos'),
keyboardType: TextInputType.number,
maxLength: 6,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 24, letterSpacing: 8),
),
SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Nova senha (mínimo 8 caracteres)'),
obscureText: true,
),
SizedBox(height: 16),
TextFormField(
controller: _confirmController,
decoration: InputDecoration(labelText: 'Confirmar nova senha'),
obscureText: true,
),
if (_error != null) ...[
SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Colors.red)),
],
SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _handleSubmit,
child: _loading
? CircularProgressIndicator(color: Colors.white)
: Text('Redefinir Senha'),
),
SizedBox(height: 16),
TextButton(
onPressed: _resending ? null : _handleResend,
child: _resending
? SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2))
: Text('Reenviar código'),
),
],
),
),
),
);
}
}
Documentação Completa
Para detalhes técnicos sobre os endpoints, validação de código e erros, veja API de Autenticação — Forgot Password.
🗑️ Exclusão de Conta (Self-Service)¶
Permite que o customer delete sua própria conta. A assinatura ativa (se houver) é cancelada automaticamente no gateway de pagamento.
Fluxo Visual¶
flowchart TD
A[Tela de Configurações / Perfil] --> B[Clica em 'Excluir Conta']
B --> C[Modal de Confirmação]
C --> D{Tem assinatura ativa?}
D -->|Sim| E[Aviso: assinatura será cancelada]
D -->|Não| F[Aviso: ação irreversível]
E --> G[Digita senha atual]
F --> G
G --> H[DELETE /customers/me]
H --> I{Sucesso?}
I -->|Sim| J[Limpar tokens locais]
J --> K[Redirecionar ao Login]
I -->|Senha incorreta| L[Exibir erro]
Endpoint¶
| Endpoint | Método | Auth | Body | Descrição |
|---|---|---|---|---|
/customers/me |
DELETE | ✅ JWT | {password} |
Deleta conta com cascade |
Código Dart/Flutter — Exclusão de Conta¶
import 'dart:convert';
import 'package:http/http.dart' as http;
class AccountService {
static String get baseUrl => AppConfig.baseUrl;
/// Deleta a conta do customer autenticado.
///
/// Requer confirmação de senha. Se houver assinatura ativa,
/// ela é cancelada no gateway de pagamento automaticamente (cascade).
/// Após sucesso, o token JWT torna-se inválido.
Future<void> deleteAccount({
required String token,
required String password,
}) async {
final response = await http.delete(
Uri.parse('$baseUrl/customers/me'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({'password': password}),
);
if (response.statusCode != 200) {
final body = jsonDecode(response.body);
final message = body['error']?['message'] ?? body['detail'] ?? 'Erro ao deletar conta';
throw Exception(message);
}
}
}
Widget — Dialog de Confirmação¶
class DeleteAccountDialog extends StatefulWidget {
final bool hasActiveSubscription;
const DeleteAccountDialog({Key? key, this.hasActiveSubscription = false}) : super(key: key);
@override
_DeleteAccountDialogState createState() => _DeleteAccountDialogState();
}
class _DeleteAccountDialogState extends State<DeleteAccountDialog> {
final _passwordController = TextEditingController();
final _accountService = AccountService();
bool _loading = false;
String? _error;
Future<void> _handleDelete() async {
if (_passwordController.text.isEmpty) {
setState(() => _error = 'Digite sua senha');
return;
}
setState(() { _loading = true; _error = null; });
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token');
if (token == null) {
throw Exception('Sessão expirada');
}
await _accountService.deleteAccount(
token: token,
password: _passwordController.text,
);
// Limpar dados locais
await prefs.clear();
// Redirecionar ao login
Navigator.of(context).pushNamedAndRemoveUntil('/login', (_) => false);
} catch (e) {
setState(() => _error = e.toString().replaceFirst('Exception: ', ''));
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Excluir Conta'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Esta ação é irreversível. Todos os seus dados serão excluídos permanentemente.',
style: TextStyle(color: Colors.red),
),
if (widget.hasActiveSubscription) ...[
SizedBox(height: 12),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Expanded(
child: Text(
'Sua assinatura ativa será cancelada automaticamente.',
style: TextStyle(color: Colors.orange.shade900),
),
),
],
),
),
],
SizedBox(height: 16),
Text('Digite sua senha para confirmar:'),
SizedBox(height: 8),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Senha atual',
border: OutlineInputBorder(),
),
),
if (_error != null) ...[
SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Colors.red, fontSize: 13)),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancelar'),
),
ElevatedButton(
onPressed: _loading ? null : _handleDelete,
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: _loading
? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text('Excluir Conta', style: TextStyle(color: Colors.white)),
),
],
);
}
}
// Uso na tela de perfil/configurações:
showDialog(
context: context,
builder: (_) => DeleteAccountDialog(
hasActiveSubscription: customer.currentSubscriptionId != null,
),
);
Documentação Completa
Para detalhes técnicos sobre o endpoint, cascade e erros, veja Customers API — Deletar Conta.
📊 Fluxo Completo de Teste — Script Dart¶
Obter plan_id
O plan_id deve ser obtido de GET /api/v1/plans. Use plan-basic, plan-plus ou plan-premium.
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Script para testar fluxo completo (Stripe) — execute no console
Future<void> testarFluxoCompleto() async {
final baseUrl = AppConfig.baseUrl;
// Dados de teste com timestamp para evitar duplicação
final timestamp = DateTime.now().millisecondsSinceEpoch;
final customerData = {
'username': 'teste_$timestamp',
'email': 'teste_$timestamp@example.com',
'password': 'senha_segura_123',
'full_name': 'Cliente Teste da Silva',
'tax_id': '12345678909',
'phone': '+5511999887766',
};
print('=== ETAPA 1: CADASTRO ===\n');
// 1. Cadastro
final signupResponse = await http.post(
Uri.parse('$baseUrl/customers/signup'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode([customerData]),
);
if (signupResponse.statusCode != 200) {
print('Erro no cadastro: ${signupResponse.body}');
return;
}
final signupData = jsonDecode(signupResponse.body);
final token = signupData['info']['auth']['access_token'];
print('Customer cadastrado!');
print(' Username: teste_$timestamp');
print(' Token: ${token.substring(0, 50)}...\n');
// 2. Criar assinatura via Stripe
// Para Stripe: use flutter_stripe para obter um payment_method_id
// Para Asaas: consulte asaas-integration.md
print('=== ETAPA 2: CRIAR ASSINATURA (STRIPE) ===\n');
final subscribePayload = {
'plan_id': 'plan-basic', // Obtido de GET /api/v1/plans
'payment_method_id': 'pm_card_visa', // ID obtido via flutter_stripe
};
final subscribeResponse = await http.post(
Uri.parse('$baseUrl/stripe/subscribe'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode(subscribePayload),
);
if (subscribeResponse.statusCode != 200) {
print('Erro na assinatura: ${subscribeResponse.body}');
return;
}
final subscribeData = jsonDecode(subscribeResponse.body);
final subscription = subscribeData['subscription'];
print('Assinatura criada com sucesso!');
print(' Status: ${subscription['status']}');
print(' Proxima cobranca: ${subscription['next_billing_date']}');
print('\n=== TESTE CONCLUIDO COM SUCESSO ===');
}
🐛 Tratamento de Erros¶
Erros Comuns e Soluções¶
| Código | Mensagem | Causa | Solução |
|---|---|---|---|
409 |
"Username already exists" | Username em uso | Sugerir outro username |
409 |
"Email already exists" | Email já cadastrado | Oferecer login |
400 |
"String should have at least X characters" | Validação falhou | Verificar campo indicado |
400 |
"Address is required" | Falta endereço | Exibir formulário de endereço |
400 |
"Customer already has active subscription" | Já tem assinatura | Redirecionar para /subscription |
401 |
"Could not validate credentials" | Token inválido/expirado | Fazer login novamente |
404 |
"Plan not found" | plan_id inválido |
Verificar plano existe |
500 |
"Subscription creation failed" | Erro no gateway de pagamento | Verificar dados do cartão ou contatar suporte |
Parser de Erros¶
String parseError(http.Response response) {
try {
final body = jsonDecode(response.body);
final detail = body['detail'];
if (detail is String) {
return detail;
} else if (detail is List) {
// Erros de validação Pydantic
return detail.map((e) {
final field = (e['loc'] as List).last;
final msg = e['msg'];
return '$field: $msg';
}).join('\n');
}
} catch (_) {}
return 'Erro desconhecido (${response.statusCode})';
}
🖼️ Avatar e Histórico de Reprodução (Novidade 2026-02-15)¶
Novos endpoints unificados para Admin e Customer gerenciarem foto de perfil e progresso de vídeos.
Documentação Completa
Para detalhes técnicos, payloads, erros e widgets completos, veja Perfil — Avatar & Watch History.
Avatar — Resumo para o Frontend¶
O avatar é armazenado no GridFS (MongoDB). O fluxo é:
- Upload:
POST /api/v1/me/avatar(multipart/form-data, max 5 MB, JPEG/PNG/WebP) - Exibir:
GET /api/v1/avatars/{avatar_id}(público, sem auth — usar emImage.network) - Remover:
DELETE /api/v1/me/avatar
O avatar_id está disponível em GET /customers/me (campo avatar_id). Ao fazer upload de um novo avatar, o antigo é substituído automaticamente.
// Upload de avatar
final uri = Uri.parse('$baseUrl/me/avatar');
final request = http.MultipartRequest('POST', uri)
..headers['Authorization'] = 'Bearer $token'
..files.add(await http.MultipartFile.fromPath('file', imageFile.path));
final response = await request.send();
final body = jsonDecode(await response.stream.bytesToString());
final avatarId = body['avatar_id']; // Usar em GET /avatars/{avatarId}
// Exibir avatar (público, sem auth)
Image.network('${AppConfig.baseUrl}/avatars/$avatarId')
Watch History — Resumo para o Frontend¶
O progresso de reprodução é salvo numa collection separada (watch_history), indexada por user_id + video_id.
| Ação | Endpoint | Quando usar |
|---|---|---|
| Salvar progresso | PUT /me/watch-history |
A cada 10-15s durante reprodução + ao pausar/sair |
| Recuperar progresso | GET /me/watch-history?video_id=xxx |
Ao abrir o player de vídeo |
| "Continuar Assistindo" | GET /me/watch-history |
Na home, listar vídeos com progresso |
| Limpar histórico | DELETE /me/watch-history |
Nas configurações do app |
// Salvar progresso (chamar a cada 10-15 segundos)
await http.put(
Uri.parse('$baseUrl/me/watch-history'),
headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
body: jsonEncode({'video_id': videoId, 'timeline': positionInSeconds}),
);
// Recuperar progresso ao abrir player
final resp = await http.get(
Uri.parse('$baseUrl/me/watch-history?video_id=$videoId'),
headers: {'Authorization': 'Bearer $token'},
);
final history = jsonDecode(resp.body)['watch_history'];
if (history.isNotEmpty) {
final savedPosition = history[0]['timeline']; // Seek para esta posição
}
💳 Stripe — Gateway de Pagamento¶
O FDPlay suporta Stripe e Asaas como gateways de pagamento, com rotas completamente separadas. O admin define qual é a preferência atual via PUT /admin/config/payment-gateway, e o frontend consulta via GET /config/payment-gateway. Ver Asaas Integration para o guia completo do Asaas.
Consultar Gateway Padrão (Flutter)¶
/// Chamar no init do app ou antes de exibir tela de pagamento.
Future<String> getDefaultGateway() async {
final resp = await http.get(Uri.parse('$baseUrl/api/v1/config/payment-gateway'));
final data = jsonDecode(resp.body);
return data['default_payment_gateway']; // 'asaas' ou 'stripe'
}
Quando Usar Cada Gateway¶
| Cenário | Gateway Recomendado |
|---|---|
| Admin definiu preferência via config | Seguir GET /config/payment-gateway |
| Cliente brasileiro, cartão ou PIX | Asaas (integração mais simples, sem SDK) |
| Cliente brasileiro, quer pagar com boleto | Asaas |
| Cliente internacional ou prefere cartão de crédito | Stripe |
| Aplicativo precisa de Apple Pay / Google Pay | Stripe |
| Troca de cartão sem cancelar assinatura | Stripe ou Asaas (ambos in-place update) |
Quick Start Stripe (Flutter)¶
// 1. Obter publishable key do backend (NUNCA hardcodar)
final configResp = await http.get(Uri.parse('$baseUrl/api/v1/stripe/config'));
final publishableKey = jsonDecode(configResp.body)['publishable_key'];
Stripe.publishableKey = publishableKey;
await Stripe.instance.applySettings();
// 2. Coletar cartão via CardField widget (PCI Level 1)
// 3. Criar PaymentMethod
final pm = await Stripe.instance.createPaymentMethod(
params: PaymentMethodParams.card(paymentMethodData: PaymentMethodData()),
);
// 4. Criar assinatura
final resp = await http.post(
Uri.parse('$baseUrl/api/v1/stripe/subscribe'),
headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
body: jsonEncode({'plan_id': 'plan-basic', 'payment_method_id': pm.id}),
);
// 5. Tratar resultado
final result = jsonDecode(resp.body);
final info = result['info'];
if (info['payment_failed'] == true) {
// Cartao recusado — exibir info['message'] e permitir retry
} else if (info['client_secret'] != null) {
await Stripe.instance.confirmPayment(paymentIntentClientSecret: info['client_secret']);
}
Quick Start Stripe PIX (Flutter)¶
// 1. Criar assinatura PIX (sem Stripe SDK — backend cria server-side)
final resp = await http.post(
Uri.parse('$baseUrl/api/v1/stripe/subscribe/pix'),
headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
body: jsonEncode({'plan_id': 'plan-basic', 'amount': 23880}),
);
final result = jsonDecode(resp.body);
// 2. Exibir QR code para o cliente
final qrCode = result['pix']['qr_code']; // Copia e cola
final qrImage = result['pix']['qr_code_image_url']; // Imagem PNG
// 3. Ativacao automatica via webhook (nada mais a fazer no frontend)
Documentação completa: Integração Stripe
Ticket Store¶
Endpoints para compra de ingressos para eventos. Todos os endpoints de compra requerem autenticacao JWT.
Listar Eventos¶
Query params:
| Parametro | Tipo | Default | Descricao |
|---|---|---|---|
qty_docs_page |
int |
20 |
Itens por pagina (1-100) |
current_page |
int |
0 |
Pagina atual |
Retorna apenas eventos ativos, com is_sale_box_office=true e data futura.
Resposta (200):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"title": "Show ao Vivo",
"event_date": "2026-06-15T20:00:00Z",
"ticket_value": 50.0,
"capacity": 500,
"tickets_issued": 120,
"available_tickets": 380,
"image_id": "507f1f77bcf86cd799439012",
"is_sale_box_office": true,
"is_active": true
}
],
"current_page": 0,
"qty_docs_page": 20
}
Detalhe do Evento¶
Resposta (200): Mesmo formato de um item da listagem, com campo available_tickets calculado.
Comprar via Asaas Cartao¶
POST /api/v1/store/purchase/asaas/card
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json
Body:
{
"event_id": "507f1f77bcf86cd799439011",
"quantity": 2,
"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"
},
"promo_code": null
}
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
event_id |
String |
Sim | ObjectId do evento |
quantity |
int |
Nao | Quantidade de ingressos (1-10, default 1) |
credit_card |
Object |
Sim | Dados do cartao (holderName, number, expiryMonth, expiryYear, ccv) |
credit_card_holder_info |
Object |
Sim | Dados do titular (name, email, cpfCnpj, postalCode, addressNumber, phone) |
promo_code |
String |
Nao | Codigo promocional (opcional) |
Resposta (200):
{
"order": {
"_id": "507f1f77bcf86cd799439099",
"customer_id": "507f1f77bcf86cd799439011",
"event_id": "507f1f77bcf86cd799439012",
"quantity": 2,
"total": 100.0,
"gateway": "asaas",
"payment_method": "credit_card",
"payment_status": "CONFIRMED",
"ticket_ids": ["tid1", "tid2"]
},
"payment": {
"id": "pay_xxx",
"status": "CONFIRMED",
"billing_type": "CREDIT_CARD"
}
}
Comprar via Asaas PIX¶
POST /api/v1/store/purchase/asaas/pix
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json
Body:
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
event_id |
String |
Sim | ObjectId do evento |
quantity |
int |
Nao | Quantidade de ingressos (1-10, default 1) |
Resposta (200):
{
"order": {
"_id": "507f1f77bcf86cd799439099",
"customer_id": "507f1f77bcf86cd799439011",
"event_id": "507f1f77bcf86cd799439012",
"quantity": 1,
"total": 50.0,
"gateway": "asaas",
"payment_method": "pix",
"payment_status": "PENDING",
"ticket_ids": ["tid1"]
},
"payment": {
"id": "pay_xxx",
"status": "PENDING",
"billing_type": "PIX"
},
"pix": {
"payload": "00020126580014br.gov.bcb.pix0136...",
"encoded_image": "base64...",
"expiration_date": "2026-06-15T20:30:00Z"
}
}
Comprar via Stripe Cartao¶
POST /api/v1/store/purchase/stripe/card
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json
Body:
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
event_id |
String |
Sim | ObjectId do evento |
quantity |
int |
Nao | Quantidade de ingressos (1-10, default 1) |
payment_method_id |
String |
Sim | Stripe PaymentMethod ID (pm_xxx) obtido via flutter_stripe |
Resposta (200):
{
"order": {
"_id": "507f1f77bcf86cd799439099",
"customer_id": "507f1f77bcf86cd799439011",
"event_id": "507f1f77bcf86cd799439012",
"quantity": 2,
"total": 100.0,
"gateway": "stripe",
"payment_method": "credit_card",
"payment_status": "succeeded",
"ticket_ids": ["tid1", "tid2"]
},
"payment": {
"id": "pi_xxx",
"status": "succeeded",
"client_secret": "pi_xxx_secret_xxx"
}
}
3D Secure
Se payment.status for requires_action e payment.client_secret estiver presente, use Stripe.instance.confirmPayment(paymentIntentClientSecret: clientSecret) para completar a autenticacao 3D Secure.
Comprar via Stripe PIX¶
POST /api/v1/store/purchase/stripe/pix
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json
Body:
Resposta (200):
{
"order": {
"_id": "507f1f77bcf86cd799439099",
"customer_id": "507f1f77bcf86cd799439011",
"event_id": "507f1f77bcf86cd799439012",
"quantity": 1,
"total": 50.0,
"gateway": "stripe",
"payment_method": "pix",
"payment_status": "requires_action",
"ticket_ids": ["tid1"]
},
"payment": {
"id": "pi_xxx",
"status": "requires_action",
"client_secret": "pi_xxx_secret_xxx"
},
"pix": {
"qr_code": "00020126580014br.gov.bcb.pix0136...",
"image_url": "https://...",
"expires_at": 1718486400
}
}
Listar Meus Pedidos¶
Query params:
| Parametro | Tipo | Default | Descricao |
|---|---|---|---|
qty_docs_page |
int |
10 |
Itens por pagina (1-100) |
current_page |
int |
0 |
Pagina atual |
Resposta (200):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439099",
"customer_id": "507f1f77bcf86cd799439011",
"event_id": "507f1f77bcf86cd799439012",
"quantity": 2,
"total": 100.0,
"gateway": "asaas",
"payment_method": "credit_card",
"payment_status": "CONFIRMED",
"status": "pending",
"ticket_ids": ["tid1", "tid2"],
"created_at": "2026-06-10T15:00:00Z"
}
],
"current_page": 0,
"qty_docs_page": 10
}
Detalhe do Pedido¶
Resposta (200): Mesmo formato de um item da listagem de pedidos.
Obter QR Code do Ingresso¶
Gera um payload QR criptografado para o ingresso. O ingresso deve pertencer ao customer autenticado e ter status available.
Resposta (200):
{
"ticket_id": "507f1f77bcf86cd799439050",
"qr_payload": "encrypted_qr_token_string...",
"status": "available"
}
| Erro | Status | Descricao |
|---|---|---|
| Pagamento pendente (PIX) | 400 |
Ticket payment is still pending. |
| Ingresso ja consumido | 400 |
Ticket already consumed. |
| Ingresso expirado | 400 |
Ticket expired. |
| Ingresso nao encontrado | 404 |
Ticket not found or does not belong to you. |
Nota sobre
pending: Tickets comprados via Ticket Store com PIX sao criados imediatamente comstatus='pending'e so mudam paraavailableapos o webhook de confirmacao do pagamento. O frontend deve: - ConsultarGET /me/ticket-orders/{order_id}e aguardarpayment_status ∈ {CONFIRMED, RECEIVED, succeeded}antes de chamar este endpoint. - Exibir visualmente ticketspendingde forma distinta (ex: laranja), sem permitir emissao de QR. - Ticketspendingnao sao consumiveis nem viaPOST /tickets/{id}/consumenem via QR admin (retornam 400).
Validar QR Code (Admin)¶
Body:
Resposta (200):
{
"valid": true,
"ticket_id": "507f1f77bcf86cd799439050",
"customer_id": "507f1f77bcf86cd799439011",
"status": "available",
"event": {
"title": "Show ao Vivo",
"event_date": "2026-06-15T20:00:00Z",
"location": "Teatro Municipal"
}
}
Consumir QR Code (Admin)¶
Body:
Valida e consome o ingresso em uma unica operacao. O ingresso deve ter status available.
Resposta (200):
{
"consumed": true,
"ticket_id": "507f1f77bcf86cd799439050",
"consumed_at": "2026-06-15T20:15:00Z"
}
| Erro | Status | Descricao |
|---|---|---|
| QR invalido | 400 |
Invalid or expired QR code. |
| Ja consumido | 400 |
Ticket already consumed. |
| Pagamento pendente | 400 |
Ticket payment is still pending. |
| Evento passado | 400 |
Ticket expired (event date passed). |
Perfil — Avatar e Historico de Reproducao¶
Upload de Avatar¶
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
file |
File |
Sim | Imagem JPEG, PNG ou WebP (max 5 MB) |
Resposta (200):
Substituicao automatica
Se o usuario ja possui avatar, o antigo e removido do GridFS automaticamente.
Remover Avatar¶
Resposta (200):
| Erro | Status | Descricao |
|---|---|---|
| Sem avatar | 404 |
No avatar found. |
Obter Avatar (Publico)¶
Autenticacao: Nao requerida. Endpoint publico para uso em tags <img> ou Image.network.
Resposta: StreamingResponse com a imagem (JPEG, PNG ou WebP).
Atualizar Historico de Reproducao¶
Body:
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
video_id |
String |
Sim | ObjectId do video |
timeline |
float |
Sim | Posicao de reproducao em segundos (>= 0) |
Resposta (200):
{
"message": "Progresso atualizado.",
"video_id": "507f1f77bcf86cd799439012",
"timeline": 542.8,
"upserted": true
}
Quando chamar
Chamar a cada 10-15 segundos durante reproducao e ao pausar/sair do player.
Obter Historico de Reproducao¶
Query params (opcionais):
| Parametro | Tipo | Descricao |
|---|---|---|
video_id |
String |
Filtrar por video especifico |
Resposta (200):
{
"watch_history": [
{
"video_id": "507f1f77bcf86cd799439012",
"timeline": 542.8,
"updated_at": "2026-03-25T12:00:00+00:00"
}
]
}
Limpar Historico de Reproducao¶
Query params (opcionais):
| Parametro | Tipo | Descricao |
|---|---|---|
video_id |
String |
Deletar apenas o historico de um video especifico. Se omitido, limpa todo o historico. |
Resposta (200):
Fluxos de Autenticacao¶
Alterar Senha (Autenticado)¶
Body:
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
current_password |
String |
Sim | Senha atual |
new_password |
String |
Sim | Nova senha (minimo 8 caracteres) |
Resposta (200):
| Erro | Status | Descricao |
|---|---|---|
| Senha incorreta | 400 |
Current password is incorrect |
| Usuario nao encontrado | 404 |
User not found |
Esqueci Minha Senha (Publico)¶
Autenticacao: Nao requerida.
Body:
Resposta (200): Sempre retorna a mesma mensagem (anti-enumeracao de emails).
Rate limit
Um codigo por 60 segundos por email. Se chamado antes do cooldown, retorna 429.
Redefinir Senha com Codigo (Publico)¶
Autenticacao: Nao requerida.
Body:
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
email |
String |
Sim | Email da conta |
code |
String |
Sim | Codigo de 6 digitos recebido por email |
new_password |
String |
Sim | Nova senha (minimo 8 caracteres) |
Resposta (200):
| Erro | Status | Descricao |
|---|---|---|
| Codigo expirado | 400 |
Code expired. Request a new one. |
| Codigo incorreto | 400 |
Codigo incorreto. N tentativa(s) restante(s). |
| Tentativas esgotadas | 429 |
Maximum attempts reached. Request a new code. |
| Usuario nao encontrado | 404 |
User not found. |
Customer Self-Service¶
Obter Perfil¶
Resposta (200):
{
"docs": [
{
"_id": "678c1a2b3d4e5f6789012345",
"username": "cliente_teste_001",
"email": "cliente.teste@example.com",
"user_type": "customer",
"full_name": "Cliente Teste da Silva",
"tax_id": "12345678909",
"phone": "+5511999887766",
"nationality": "BR",
"email_verified": true,
"avatar_id": "507f1f77bcf86cd799439011",
"current_subscription_id": "678c2d3e4f5a6b7890123456",
"created_at": "2026-02-09T15:30:00Z",
"updated_at": "2026-02-09T15:30:45Z"
}
],
"total_docs": 1,
"qty_docs_page": 1,
"current_page": 0
}
Atualizar Perfil¶
Body: Array com um objeto contendo os campos a atualizar (todos opcionais):
[
{
"_id": "678c1a2b3d4e5f6789012345",
"full_name": "Joao da Silva Atualizado",
"phone": "+5511987654321",
"nationality": "BR",
"address": {
"street": "Av. Paulista",
"number": "1000",
"complement": "Apto 123",
"locality": "Bela Vista",
"city": "Sao Paulo",
"region_code": "SP",
"country": "BRA",
"postal_code": "01310100"
}
}
]
| Campo | Tipo | Descricao |
|---|---|---|
full_name |
String |
Nome completo (3-200 chars) |
phone |
String |
Telefone |
tax_id |
String |
CPF/CNPJ |
nationality |
String |
Codigo pais ISO 3166-1 (2-3 chars) |
address |
Object |
Endereco de cobranca |
my_list |
list[String] |
Lista pessoal do customer |
Email nao pode ser alterado diretamente
Tentativas de alterar email via este endpoint retornam 400. Use o fluxo de troca de email (POST /customers/me/change-email).
Resposta (200): Mesmo formato da resposta de GET /customers/me com os dados atualizados.
Solicitar Troca de Email¶
POST /api/v1/customers/me/change-email
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json
Body:
Resposta (200):
{
"message": "Codigo de verificacao enviado para o novo email.",
"new_email": "novo_email@example.com",
"expires_in_minutes": 15
}
| Erro | Status | Descricao |
|---|---|---|
| Mesmo email | 400 |
New email must be different from the current one. |
| Email em uso | 409 |
This email is already in use. |
| Rate limit | 429 |
Aguarde N segundos para reenviar o codigo. |
Confirmar Troca de Email¶
POST /api/v1/customers/me/confirm-email
Authorization: Bearer <TOKEN_JWT>
Content-Type: application/json
Body:
| Campo | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
verification_code |
String |
Sim | Codigo de 6 digitos enviado ao novo email |
Resposta (200): Retorna os dados atualizados do customer (mesmo formato de GET /customers/me), com info.message: "Email alterado com sucesso.".
| Erro | Status | Descricao |
|---|---|---|
| Sem troca pendente | 400 |
No pending email change found. |
| Codigo expirado | 400 |
Code expired. Request a new verification code. |
| Codigo incorreto | 400 |
Codigo incorreto. N tentativa(s) restante(s). |
| Email em uso (race condition) | 409 |
This email is already in use. |
| Tentativas esgotadas | 429 |
Maximum attempts reached. Request a new verification code. |
Excluir Conta¶
Body:
Requer confirmacao de senha. Cancela assinatura ativa no gateway de pagamento automaticamente (cascade).
Resposta (200):
{
"message": "Conta excluida com sucesso.",
"details": {
"customer_id": "678c1a2b3d4e5f6789012345",
"subscription_cancelled": true,
"gateway_response": null
}
}
| Erro | Status | Descricao |
|---|---|---|
| Senha incorreta | 400 |
Incorrect password. |
| Customer nao encontrado | 404 |
Customer not found. |
Acao irreversivel
Apos exclusao, o token JWT torna-se invalido. Limpar dados locais e redirecionar ao login.
Listar Meus Codigos Promocionais¶
Retorna todos os codigos promocionais atribuidos ao customer, com status de resgate.
Resposta (200):
{
"codes": [
{
"code": "PROMO10",
"title": "Desconto primeiro mes",
"subtitle": "Primeiro mes por R$ 5,50",
"code_type": "promo",
"redeemed": false,
"discount_first_month": 5.50,
"tickets": null,
"event_date": null,
"image_id": null
},
{
"code": "TICKET-VIP",
"title": "Ingresso VIP",
"subtitle": "Ingresso para evento especial",
"code_type": "ticket",
"redeemed": true,
"discount_first_month": null,
"tickets": 2,
"event_date": "2026-06-15T20:00:00+00:00",
"image_id": "507f1f77bcf86cd799439012"
}
]
}
Se o customer nao possui codigos, retorna {"codes": []}.
Suporte¶
- Swagger UI (Producao): https://fdplay-api.infraifd.com/api/v1/docs
- Swagger UI (Local): http://localhost:8000/api/v1/docs
- Ambiente de Teste: Asaas Sandbox + Stripe Test Mode
- Email: dev@fdplay.com