Codigos Promocionais¶
Sistema de codigos promocionais com dois tipos: promo (desconto na assinatura) e ticket (ingresso de evento).
Tipos de Codigo (code_type)¶
| Campo | promo |
ticket |
|---|---|---|
| Quem cria | Admin | Admin |
discount_first_month |
✅ Aplica desconto na assinatura | ❌ Ignorado |
bonus_tickets |
❌ Ignorado | 🎟️ Quantidade de ingressos a criar |
event_id |
❌ Ignorado | ✅ Obrigatório — ingresso vinculado a este evento |
target_customer_ids |
Opcional — envia direto para usuários | ✅ Usuários que recebem o ingresso |
children_code_ids |
✅ Agrega códigos filhos (bundle) | ❌ Não usado |
| Precisa assinar? | ✅ Resgatado na assinatura | ❌ Independente de assinatura |
| Exemplo | 9DYUYV — R$5,50 no 1º mês |
449GQW — ingresso Goiânia |
Resumo:
promo→ desconto na assinatura. Pode agregar filhos viachildren_code_ids(ex: umticket).ticket→ ingresso de evento (respeita capacidade do evento). Independente de assinatura.
Bundle: desconto + ingresso
Um promo com children_code_ids contendo um ticket entrega desconto + ingresso.
O promo aplica o desconto na assinatura e o ticket filho é entregue ao customer
para ser resgatado separadamente (cada filho tem ciclo de vida independente).
Documentacao de Eventos e Ingressos
Para detalhes completos sobre o tipo ticket, rotas de eventos e ingressos,
consulte Eventos e Ingressos.
Visao Geral do Fluxo¶
FLUXO COMPLETO (2 MOMENTOS)
┌─────────────────────────────────────────────────────────────────┐
│ MOMENTO 1 — AQUISICAO │
│ │
│ Admin cria codigo promo Pessoa ve campanha │
│ (ex: FDPLAY2026) e acessa o app │
│ │ │ │
│ └──────────┐ ┌───────────────────┘ │
│ ▼ ▼ │
│ ┌──────────────┐ │
│ │ SIGNUP │ │
│ │ │ │
│ │ username │ │
│ │ email │ │
│ │ password │ │
│ │ CPF │ │
│ │ CEP │◄── Ja obtido no cadastro │
│ │ promo_code │◄── "FDPLAY2026" │
│ └──────┬───────┘ │
│ │ │
│ ┌──────▼───────┐ │
│ │ VALIDACAO │ │
│ │ do email │ │
│ └──────┬───────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ SUBSCRIBE │ │
│ │ R$ 19,90/mes │◄── Preco normal │
│ │ (Asaas/Stripe) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ BONUS │ │
│ │ 1 ingresso │ │
│ │ cinema │ │
│ │ │ │
│ │ "Botao do │ │
│ │ ingresso" │ │
│ │ visivel │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Fluxo Completo do Cupom de Desconto¶
FLUXO COMPLETO — CUPOM DE DESCONTO
===================================
┌─────────────────────────────────────────────────────────────────────────┐
│ CRIAÇÃO DO CUPOM (Admin) │
│ │
│ POST /admin/promo-codes (JSON body) │
│ [{ │
│ title: "Promoção Lançamento", ◄── obrigatório │
│ subtitle: "Desconto especial...", ◄── opcional │
│ event_date: "2026-04-15T20:00:00Z", ◄── visível até esta data │
│ discount_first_month: 5.50 │
│ }] │
│ ► code: "MEU_CODE" (opcional) ◄── se null, backend gera │ │
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ ┌────────────────────────┐ ┌─────────────────────┐ │
│ │ GridFS │ │ MongoDB │ │
│ │ fs.files / fs.chunks │────►│ promo_codes │ │
│ │ (armazena imagem) │ │ image_id: OID ✓ │ │
│ └────────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ VISIBILIDADE DO CUPOM (Frontend) │
│ │
│ Regra: is_active=true │
│ AND (event_date == null OR event_date >= now()) │
│ │
│ ┌──────────────────┬───────────────────┬────────────┐ │
│ │ is_active │ event_date │ Visível? │ │
│ ├──────────────────┼───────────────────┼────────────┤ │
│ │ true │ null │ ✓ SIM │ │
│ │ true │ futuro │ ✓ SIM │ │
│ │ true │ passado │ ✗ NÃO │ │
│ │ false │ (qualquer) │ ✗ NÃO │ │
│ └──────────────────┴───────────────────┴────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ EXIBIÇÃO DO CUPOM NO APP (Frontend) │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ IMAGEM │ │ GET /archive-records-public/ │
│ │ │ (via Fernet token) │ │ {encrypt(image_id)} │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ │ title: "Promoção Lançamento" │ │
│ │ subtitle: "Desconto especial..." │ │
│ │ description: "Campanha filme X" │ │
│ │ event_date: "15/04/2026 20:00" │ │
│ │ │ │
│ │ [ USAR CUPOM: FDPLAY2026 ] │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ VALIDAÇÃO E USO (Signup) │
│ │
│ Frontend Backend MongoDB │
│ │ │ │ │
│ │ GET /validate-promo-code │ │ │
│ │ ?code=FDPLAY2026 ────────►│ │ │
│ │ │ Busca promo_code ◄────────│ │
│ │ │ Verifica: │ │
│ │ │ - existe? │ │
│ │ │ - is_active? │ │
│ │ │ - não expirado? │ │
│ │ { valid: true, │ - event_date ok? │ │
│ │ title, subtitle, │ │ │
│ │ image_id, ... } ◄───────┤ │ │
│ │ │ │ │
│ │ POST /customers/signup │ │ │
│ │ { ..., promo_code: │ │ │
│ │ "FDPLAY2026" } ────────►│ │ │
│ │ │ Cria customer com: │ │
│ │ │ - promo_code_used │ │
│ │ │ - promo_code_id ────────►│ │
│ │ { token, customer } ◄──────┤ │ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ GESTÃO DE IMAGEM (Admin) │
│ │
│ Upload: POST /admin/promo-codes/{id}/image │
│ multipart/form-data, campo "file" │
│ → Armazena no GridFS │
│ → Substitui imagem anterior (deleta antiga) │
│ → Atualiza image_id no documento │
│ │
│ Remover: DELETE /admin/promo-codes/{id}/image │
│ → Deleta do GridFS │
│ → $unset image_id │
│ │
│ Acesso: GET /archive-records-public/{fernet_encrypt(image_id)} │
│ → Público (sem auth) │
│ → Retorna StreamingResponse com imagem │
└─────────────────────────────────────────────────────────────────────────┘
Tabela de Cenarios¶
| Tipo | Funcao | Quem Cria | Quem Usa |
|---|---|---|---|
| promo | Desconto na assinatura | Admin | Qualquer pessoa |
| ticket | Ingresso de evento (respeita capacidade) | Admin | Direcionado via target_customer_ids ou qualquer pessoa |
Endpoints da API¶
Tabela Completa de Endpoints¶
Rotas Admin (Requer JWT + user_type='admin')¶
| Metodo | Endpoint | Descricao |
|---|---|---|
| POST | /api/v1/admin/promo-codes |
Criar codigo promocional |
| GET | /api/v1/admin/promo-codes |
Listar codigos promocionais (paginado) |
| GET | /api/v1/admin/promo-codes/stats |
Estatisticas de codigos promocionais |
| GET | /api/v1/admin/promo-codes/{id} |
Obter codigo promocional por ID |
| PUT | /api/v1/admin/promo-codes/{id} |
Atualizar codigo promocional |
| DELETE | /api/v1/admin/promo-codes/{id} |
Deletar codigo promocional permanentemente |
| POST | /api/v1/admin/promo-codes/{id}/image |
Upload ou substituir imagem do cupom (JPEG, PNG, WebP, max 5 MB) |
| DELETE | /api/v1/admin/promo-codes/{id}/image |
Remover imagem do cupom |
Rotas Customer¶
| Metodo | Endpoint | Descricao | Auth |
|---|---|---|---|
| GET | /api/v1/customers/validate-promo-code |
Validar codigo promo | ❌ Publica (auth opcional) |
| PUT | /api/v1/customers/me/redeem-promo-code |
Resgatar codigo promo | ✅ JWT |
| GET | /api/v1/customers/me/promo-codes |
Listar codigos promo do customer | ✅ JWT |
Publicos (sem auth)¶
Validar Codigo Promo¶
Response (valido):
{
"valid": true,
"code": "FDPLAY2026",
"title": "Promocao Lancamento",
"subtitle": "Desconto especial para o filme X",
"code_type": "promo",
"tickets": null,
"discount_first_month": null,
"event_date": "2026-04-15T20:00:00Z",
"image_id": "665f1a2b3c4d5e6f7a8b9c0e",
"description": "Campanha lancamento filme X",
"consumed": null,
"children": null
}
Explicacao de cada campo da response:
| Campo | Tipo | Descricao para o Frontend |
|---|---|---|
valid |
bool | true se o cupom existe, esta ativo e nao expirou. Se false, mostrar message ao usuario |
code |
string | Codigo do cupom em UPPERCASE (ex: "FDPLAY2026"). Usar para exibir e para chamar o endpoint de resgate |
title |
string | Titulo principal do cupom. Exibir em destaque no card (ex: "Promocao Lancamento") |
subtitle |
string | null | Subtitulo do cupom. Exibir abaixo do titulo. Se null, nao exibir |
code_type |
"promo" | "ticket" |
Tipo do cupom. "promo" = desconto na assinatura, "ticket" = ingresso de evento. Pode ser usado para diferenciar layout/cores no card |
tickets |
int | null | Quantidade de ingressos que o customer recebe ao resgatar (apenas code_type="ticket"). Exibir como destaque (ex: "Ganhe 1 ingresso!"). null para code_type="promo" |
discount_first_month |
float | null | Valor do primeiro mes em reais. 1.00 = R$ 1,00. null = preco normal do plano (sem desconto). So se aplica a codigos promo. Exibir como "Primeiro mes por R$ X,XX" |
event_date |
string (ISO) | null | Data do evento associado ao cupom. Formato ISO 8601 (ex: "2026-04-15T20:00:00Z"). Se null, cupom nao tem data de evento. Exibir formatado (ex: "15/04/2026 20:00"). Apos esta data o cupom fica invisivel |
image_id |
string (ObjectId) | null | ID da imagem do cupom no GridFS. Se null, nao ha imagem. Para exibir, montar URL: GET /api/v1/archive-records-public/{encryptFernet(image_id)}. Usar em Image.network() |
description |
string | null | Descricao longa do cupom. Texto livre para detalhar a promocao. Se null, nao exibir |
consumed |
bool | null | Se o customer ja resgatou este cupom. true = ja usado (mostrar "JA UTILIZADO"), false = disponivel (mostrar botao "RESGATAR"), null = usuario nao autenticado (nao e possivel saber). Requer auth (token Bearer no header) para retornar true/false |
children |
list | null | Codigos filhos bundled com este cupom. null = sem filhos. Se presente, lista de objetos com code, title, subtitle, code_type, tickets, discount_first_month, event_date, image_id, description. Exemplo: um cupom promo (desconto) pode ter um filho ticket (ingresso) — ao resgatar o pai, o customer recebe ambos os beneficios |
children: como usar no frontend
children == null→ Cupom simples, sem filhos. Exibir normalmente.childrencom itens → Cupom bundle. Exibir os beneficios do pai e de cada filho. Exemplo: "Primeiro mes por R$ 5,50 + 1 ingresso para A Escolha de Ficar - SP"
Exemplo response com filhos (cupom bundle):
{
"valid": true,
"code": "PROMOINGRESSO",
"title": "PROMOINGRESSO",
"subtitle": "",
"code_type": "promo",
"tickets": null,
"discount_first_month": 5.50,
"event_date": null,
"image_id": null,
"description": "",
"consumed": null,
"children": [
{
"code": "SAOPAULO",
"title": "A Escolha de Ficar - SP",
"subtitle": "São Paulo",
"code_type": "ticket",
"tickets": 1,
"discount_first_month": null,
"event_date": "2026-04-10T19:30:00+00:00",
"image_id": null,
"description": ""
}
]
}
Campos do objeto filho (children[]):
| Campo | Tipo | Descricao |
|---|---|---|
code |
string | Codigo do filho |
title |
string | Titulo do filho |
subtitle |
string | null | Subtitulo |
code_type |
"promo" | "ticket" |
Tipo do filho |
tickets |
int | null | Ingressos (se ticket) |
discount_first_month |
float | null | Desconto (se promo) |
event_date |
string | null | Data do evento (ISO 8601) |
image_id |
string | null | ID da imagem no GridFS |
description |
string | null | Descricao |
consumed: como usar no frontend
consumed == null→ Usuario nao logado. Mostrar botao "ENTRAR PARA RESGATAR"consumed == false→ Usuario logado, cupom disponivel. Mostrar botao "RESGATAR"consumed == true→ Usuario logado, ja resgatou. Mostrar badge "JA UTILIZADO" (desabilitado)
Response (invalido):
Mensagens de invalidez possiveis:
| Mensagem | Causa |
|---|---|
Código inválido ou inativo. |
Codigo nao existe ou is_active=false |
Código expirado. |
valid_until no passado |
Código disponível a partir de DD/MM/YYYY. |
valid_from no futuro |
Evento já encerrado. |
event_id aponta para evento com event_date no passado; sem event_id, usa promo_codes.event_date |
Autenticados (requer auth)¶
Resgatar Codigo Promo (Redeem)¶
Consome o codigo promocional para o customer autenticado. Registra em redeemed_promo_codes. Para code_type="ticket", cria ingressos na collection tickets. Cada customer so pode resgatar cada codigo uma vez (como um bilhete).
Query parameters:
| Parametro | Tipo | Descricao |
|---|---|---|
code |
string (3-50 chars) | Codigo promocional a resgatar |
Response (200):
{
"message": "Código resgatado com sucesso.",
"code": "FDPLAY2026",
"title": "Promocao Lancamento",
"tickets": 1,
"consumed": true,
"redeemed_at": "2026-03-22T15:30:00+00:00"
}
Explicacao de cada campo da response:
| Campo | Tipo | Descricao para o Frontend |
|---|---|---|
message |
string | Mensagem de sucesso para exibir ao usuario |
code |
string | Codigo do cupom resgatado |
title |
string | Titulo do cupom — usar na tela de confirmacao |
tickets |
int | null | Quantidade de ingressos criados (para code_type="ticket"). null para promo. Exibir: "X ingresso(s) adicionado(s)!" |
consumed |
bool | Sempre true apos resgate bem-sucedido |
redeemed_at |
string (ISO) | Data/hora do resgate. Pode ser exibido como historico |
Apos o resgate
Apos receber 200, o frontend deve:
- Marcar o cupom como "JA UTILIZADO" na UI
- Se
code_type="ticket", recarregarGET /me/ticketspara ver novos ingressos - Se necessario, recarregar
GET /customers/mepara obter dados atualizados
Erros:
| Status | Detalhe |
|---|---|
400 |
Código inválido ou inativo. |
400 |
Código expirado. |
400 |
Evento já encerrado. |
409 |
Código já utilizado por este usuário. |
Fluxo:
Frontend Backend MongoDB
│ │ │
│ PUT /me/redeem-promo-code │ │
│ ?code=FDPLAY2026 ────────►│ │
│ │ Busca customer ◄────────────│ users
│ │ Verifica redeemed_promo_ │
│ │ codes (duplicata?) ──────► │
│ │ │
│ │ Busca promo_code ◄──────────│ promo_codes
│ │ Valida: │
│ │ - ativo? │
│ │ - nao expirado? │
│ │ - event_date ok? │
│ │ │
│ │ cria tickets (se ticket) │
│ │ $push redeemed_promo_ │
│ │ codes: "FDPLAY2026" ───────►│ users
│ │ │
│ { message, code, │ │
│ title, tickets, │ │
│ redeemed_at } ◄─────────┤ │
Signup com Codigo Promo¶
[{
"username": "joao_silva",
"email": "joao@example.com",
"password": "senha_segura_123",
"full_name": "Joao da Silva",
"tax_id": "12345678909",
"phone": "+5511987654321",
"promo_code": "FDPLAY2026"
}]
O campo promo_code e opcional. Se valido, o customer recebe:
promo_code_used: codigo usadopromo_code_id: referencia ao documento PromoCode
Comportamento de Upsert no Signup (email nao verificado)¶
Quando um POST /customers/signup e realizado com um tax_id ja cadastrado mas ainda nao verificado (email_verified = false), o sistema nao retorna 409. Em vez disso, executa um upsert:
- Atualiza o
emailda conta existente para o email enviado na nova requisicao - Gera e reenvia um novo codigo de verificacao para o email atualizado
- Retorna a mesma resposta de sucesso de um signup normal
Objetivo do upsert
Este comportamento permite que o usuario corrija um email digitado errado durante o cadastro sem precisar de intervencao manual. A conta so e efetivamente criada (e bloqueada) apos a verificacao do email. Enquanto nao verificada, o email pode ser atualizado livremente.
Conta ja verificada
Se o tax_id pertencer a uma conta com email_verified = true, o sistema retorna 409 Conflict normalmente. O upsert so se aplica a contas ainda nao verificadas.
| Cenario | Comportamento |
|---|---|
tax_id novo |
Cria nova conta, envia verificacao |
tax_id existente + email_verified = false |
Atualiza email, reenvia verificacao |
tax_id existente + email_verified = true |
409 Conflict |
Admin (requer auth + admin)¶
Todos os endpoints admin exigem header Authorization: Bearer <token> com usuario admin.
Criar Codigo Promo¶
Cria um codigo promocional. O campo code e opcional — se nao enviado (ou null), o backend gera automaticamente um codigo unico de 6 caracteres alfanumericos. Se enviado, o backend valida unicidade e usa o valor fornecido. Imagem e enviada separadamente via POST /admin/promo-codes/{id}/image.
Content-Type: application/json
Request body: list[PromoCodeCreate] (array JSON com 1 objeto)
Campos do PromoCodeCreate:
Codigo opcional
O campo code e opcional. Se omitido ou null, o backend gera automaticamente um codigo unico de 6 caracteres alfanumericos (ex: "K9X1A3"). Se enviado (3-50 chars), o backend valida unicidade — retorna 400 se ja existir. O codigo final e retornado na response.
| Campo | Tipo | Obrigatorio | Default | Descricao |
|---|---|---|---|---|
code |
string (3-50 chars) | null | Nao | null (autogerado) |
Codigo personalizado. Se null, o backend gera automaticamente (6 chars alfanumericos). Se fornecido, deve ser unico — retorna 400 se ja existir |
title |
string (1-100 chars) | Sim | - | Titulo de exibicao do cupom. Aparece em destaque no card do frontend. Ex: "Promocao Lancamento" |
subtitle |
string (max 200) | null | Nao | null |
Subtitulo de exibicao. Texto complementar ao titulo. Ex: "Desconto especial para o filme X" |
code_type |
"promo" | "ticket" |
Nao | "promo" |
Tipo do codigo. "promo" = desconto na assinatura (pode agregar filhos via children_code_ids). "ticket" = gera ingresso(s) vinculado(s) a um evento (requer event_id) |
discount_first_month |
float | null | Nao | null |
Preco do primeiro mes em reais. 1.00 = R$ 1,00. 19.90 = R$ 19,90. null = preco normal do plano (sem desconto). So se aplica a codigos promo |
bonus_tickets |
int (>= 0) | Nao | 0 |
Quantidade de ingressos a criar ao resgatar (apenas para code_type="ticket"). Ignorado para promo |
valid_from |
datetime (ISO) | null | Nao | null |
Inicio da janela de validade. Antes desta data, o codigo nao pode ser resgatado. null = disponivel imediatamente |
valid_until |
datetime (ISO) | null | Nao | null |
Fim da janela de validade. Apos esta data, o codigo nao pode mais ser resgatado. null = sem expiracao (valido indefinidamente) |
event_date |
datetime (ISO) | null | Nao | null |
Data do evento associado ao cupom. O cupom fica visivel ate esta data. Apos a data, o cupom some do frontend. null = sem data de evento (sempre visivel enquanto ativo) |
event_id |
str | null | Nao* | null |
ID do evento vinculado. Obrigatorio para code_type="ticket". Referencia o documento Event que sera associado ao(s) ingresso(s) gerado(s) |
target_customer_ids |
list[str] | Nao | [] |
Lista de IDs de customers direcionados. Vazio = qualquer customer pode resgatar. Nao-vazio = apenas esses customers podem validar e resgatar (403 para outros). Preenchido automaticamente quando filhos sao atribuidos via bundle |
children_code_ids |
list[str] | Nao | [] |
IDs de codigos agrupados neste bundle. Ao resgatar o pai, filhos sao atribuidos ao customer (nao resgatados). Cada filho deve ser resgatado individualmente. Ver Hierarquia |
is_active |
bool | Nao | true |
Se o codigo esta ativo. false = desativado (nao aparece, nao pode ser resgatado) |
description |
string (max 500) | null | Nao | null |
Descricao longa do cupom. Texto livre para detalhar a promocao. Exibido no card do frontend |
Imagem do cupom
A imagem nao e enviada no POST de criacao. Apos criar o codigo, use o endpoint separado POST /admin/promo-codes/{id}/image para upload da imagem.
Exemplo — codigo promo (aquisicao):
[{
"title": "Promocao Lancamento",
"subtitle": "Desconto especial para o filme X",
"code_type": "promo",
"event_date": "2026-04-15T20:00:00Z",
"description": "Campanha lancamento filme X"
}]
discount_first_month
Valor em reais — preco do primeiro mes. 1.00 = R$ 1,00. null = preco normal do plano.
Apenas para code_type="promo". Em modos PIX multi-mes (quarterly, semiannually, yearly), o desconto substitui 1 dos N meses:
valor_final = ((N - 1) × preco_mensal) + discount_first_month.
Exemplo trimestral: plano R$29,90/mes, cupom R$1,00 → 2×29,90 + 1,00 = R$60,80.
Response (200):
{
"docs": [
{
"_id": "665f1a2b3c4d5e6f7a8b9c0d",
"code": "K9X1A3",
"title": "Promocao Lancamento",
"subtitle": "Desconto especial para o filme X",
"code_type": "promo",
"discount_first_month": null,
"valid_from": null,
"valid_until": null,
"event_date": "2026-04-15T20:00:00Z",
"event_id": null,
"target_customer_ids": [],
"is_active": true,
"image_id": null,
"description": "Campanha lancamento filme X",
"created_at": "2026-03-25T10:30:00Z",
"updated_at": "2026-03-25T10:30:00Z"
}
],
"info": null,
"links": [],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
Listar Codigos¶
Lista codigos promocionais com paginacao, filtragem e ordenacao.
Query parameters:
| Parametro | Tipo | Default | Descricao |
|---|---|---|---|
_id |
ObjectId | null | null |
Filtrar por ID especifico |
query |
string (JSON) | null | null |
Filtro MongoDB como string JSON (ex: {"code_type":"promo"}) |
sort |
string (JSON) | {"created_at":-1} |
Ordenacao MongoDB como string JSON |
docs_range |
string (tupla) | (0, 0) |
Range de documentos |
qty_docs_page |
int | 10 |
Quantidade de documentos por pagina |
current_page |
int | 0 |
Pagina atual (0-indexed) |
Exemplos de uso:
GET /api/v1/admin/promo-codes?current_page=0&qty_docs_page=10
GET /api/v1/admin/promo-codes?query={"code_type":"ticket"}&sort={"created_at":-1}
GET /api/v1/admin/promo-codes?query={"is_active":true}&qty_docs_page=20
Response (200):
{
"docs": [
{
"_id": "665f1a2b3c4d5e6f7a8b9c0d",
"code": "FDPLAY2026",
"title": "Promocao Lancamento",
"subtitle": "Desconto especial para o filme X",
"code_type": "promo",
"discount_first_month": null,
"valid_from": null,
"valid_until": null,
"event_date": "2026-04-15T20:00:00Z",
"event_id": null,
"target_customer_ids": [],
"is_active": true,
"image_id": "665f1a2b3c4d5e6f7a8b9c0e",
"description": "Campanha lancamento filme X",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-03-10T14:20:00Z"
}
],
"info": null,
"links": [],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 10,
"qty_of_pages": 2,
"qty_total_docs": 17
}
}
Obter Codigo por ID¶
Retorna um unico codigo promocional pelo seu ObjectId.
Path parameters:
| Parametro | Tipo | Descricao |
|---|---|---|
promo_code_id |
string (ObjectId) | ID do codigo promocional |
Response (200):
{
"_id": "665f1a2b3c4d5e6f7a8b9c0d",
"code": "FDPLAY2026",
"title": "Promocao Lancamento",
"subtitle": "Desconto especial para o filme X",
"code_type": "promo",
"discount_first_month": null,
"valid_from": null,
"valid_until": null,
"event_date": "2026-04-15T20:00:00Z",
"is_active": true,
"image_id": "665f1a2b3c4d5e6f7a8b9c0e",
"description": "Campanha lancamento filme X",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-03-10T14:20:00Z"
}
Erros:
| Status | Detalhe |
|---|---|
404 |
Codigo promocional nao encontrado. |
Atualizar Codigo¶
Atualiza campos de um codigo promocional. Apenas campos enviados no body serao atualizados (partial update). O campo code e editavel — se enviado, o backend valida unicidade (retorna 400 se ja existir em outro cupom). O campo updated_at e atualizado automaticamente.
Path parameters:
| Parametro | Tipo | Descricao |
|---|---|---|
promo_code_id |
string (ObjectId) | ID do codigo promocional |
Request body (PromoCodeUpdate) — todos os campos sao opcionais:
| Campo | Tipo | Descricao |
|---|---|---|
code |
string (3-50 chars) | null | Novo codigo. Deve ser unico — retorna 400 se ja existir em outro cupom |
title |
string (1-100 chars) | null | Titulo de exibicao |
subtitle |
string (max 200) | null | Subtitulo de exibicao |
discount_first_month |
float (>= 0) | null | Preco 1o mes em reais |
valid_from |
datetime | null | Inicio da janela de validade |
valid_until |
datetime | null | Fim da janela de validade |
event_date |
datetime | null | Data de fallback para visibilidade. Se event_id existir, a data real do evento vinculado tem precedencia |
is_active |
bool | null | Se o codigo esta ativo |
description |
string (max 500) | null | Descricao admin |
Exemplo:
Response (200): retorna o documento atualizado completo (mesmo formato de GET por ID).
{
"_id": "665f1a2b3c4d5e6f7a8b9c0d",
"code": "FDPLAY2026",
"title": "Promocao Lancamento",
"subtitle": "Desconto especial para o filme X",
"code_type": "promo",
"discount_first_month": null,
"valid_from": null,
"valid_until": null,
"event_date": "2026-05-01T20:00:00Z",
"is_active": true,
"image_id": "665f1a2b3c4d5e6f7a8b9c0e",
"description": "Data do evento atualizada",
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-03-20T09:15:00Z"
}
Erros:
| Status | Detalhe |
|---|---|
400 |
Nenhum campo para atualizar. |
400 |
Code "XYZ" already exists. (se code ja pertence a outro cupom) |
404 |
Codigo promocional nao encontrado. |
Deletar Codigo (hard delete)¶
Remove permanentemente o codigo promocional do banco de dados. Se houver imagem associada no GridFS, ela tambem e deletada.
Path parameters:
| Parametro | Tipo | Descricao |
|---|---|---|
promo_code_id |
string (ObjectId) | ID do codigo promocional |
Response (200):
{
"message": "Codigo promocional deletado permanentemente.",
"promo_code_id": "665f1a2b3c4d5e6f7a8b9c0d"
}
Erros:
| Status | Detalhe |
|---|---|
404 |
Codigo promocional nao encontrado. |
Estatisticas¶
Retorna estatisticas agregadas dos codigos promocionais, incluindo contagens por tipo, top codigos mais usados e quantidade de clientes que usaram promo codes.
Response (200):
{
"by_type": {
"promo": {
"total_codes": 5,
"active_codes": 3
},
"ticket": {
"total_codes": 12,
"active_codes": 10
}
},
"customers_with_promo": 47,
"top_codes": [
{
"code": "FDPLAY2026",
"code_type": "promo",
"title": "Promocao Lancamento",
"event_date": "2026-04-15T20:00:00Z",
"is_active": true
}
]
}
Detalhes dos campos da response:
| Campo | Descricao |
|---|---|
by_type |
Contagens agrupadas por code_type (total, ativos) |
customers_with_promo |
Total de clientes que usaram qualquer promo code |
top_codes |
Top 10 codigos ativos mais recentes |
Fluxo Tecnico Detalhado¶
Signup com Promo Code¶
Frontend Backend MongoDB
│ │ │
│ POST /signup │ │
│ { promo_code: "FDPLAY2026" } ─┤ │
│ │ Valida codigo: │
│ │ - Existe? │
│ │ - Ativo? │
│ │ - Nao expirado? │
│ │ │
│ │ Cria customer com: │
│ │ - promo_code_used │
│ │ - promo_code_id ───►│ users
│ │ │
│ { customer_id, message } ◄────┤ │
Modelo de Dados¶
PromoCode (collection: promo_codes)¶
| Campo | Tipo | Descricao |
|---|---|---|
code |
string | Codigo unico em UPPERCASE. E o identificador que o usuario digita para resgatar (ex: "FDPLAY2026", "449GQW") |
title |
string | Titulo principal de exibicao do cupom. Mostrado em destaque no card/tela do cupom |
subtitle |
string | null | Subtitulo de exibicao. Texto complementar ao titulo. null = nao exibir |
code_type |
"promo" | "ticket" |
Tipo do codigo. "promo" = desconto na assinatura (pode agregar filhos via children_code_ids). "ticket" = ingresso de evento (respeita capacidade) |
discount_first_month |
float | null | Preco do 1o mes em reais (1.00 = R$1,00). null = preco normal. So se aplica a promo |
bonus_tickets |
int | Para ticket: quantidade de ingressos a criar. Para promo: ignorado |
valid_from |
datetime | null | Inicio da janela de validade. null = imediato |
valid_until |
datetime | null | Fim da janela de validade. null = sem expiracao |
event_date |
datetime | null | Data de fallback para visibilidade. Se event_id existir, o backend usa events.event_date como fonte de verdade; sem event_id, usa este campo. null = sempre visivel |
is_active |
bool | Se o codigo esta ativo. false = desativado (nao aparece, nao pode ser resgatado) |
image_id |
ObjectId | null | ID da imagem do cupom no GridFS. Para exibir: GET /api/v1/archive-records-public/{encryptFernet(image_id)}. null = sem imagem |
owner_customer_id |
ObjectId | null | (legado) Campo nao utilizado atualmente. Mantido por retrocompatibilidade |
description |
string | null | Descricao longa do cupom. Texto livre para detalhar a promocao |
created_at |
datetime | Data de criacao do codigo (UTC, automatico) |
updated_at |
datetime | Data da ultima atualizacao (UTC, automatico) |
Campos adicionados no Customer (collection: users)¶
| Campo | Tipo | Descricao para o Frontend |
|---|---|---|
promo_code_used |
string | null | Codigo usado no signup. Se preenchido, customer entrou via promocao. Exibir historico |
promo_code_id |
ObjectId | null | Referencia ao documento PromoCode usado no signup. Uso interno (nao exibir) |
bonus_tickets |
int | (legado) Campo nao utilizado atualmente. Ingressos sao gerenciados via collection tickets |
redeemed_promo_codes |
list[string] | Lista de codigos ja resgatados por este customer. Previne uso duplo (409 Conflict). Frontend pode usar para marcar cupons como "JA UTILIZADO" |
Campos adicionados na Subscription (collection: subscriptions)¶
| Campo | Tipo | Descricao |
|---|---|---|
promo_code_id |
ObjectId | null | PromoCode aplicado |
promo_code_used |
string | null | Codigo usado |
discount_first_month |
float | null | Preco 1o mes em reais |
original_amount |
int | null | Valor original do plano (centavos — padrao do Plan) |
discount_applied |
bool | Se desconto foi aplicado |
discount_needs_restore |
bool | Se precisa restaurar valor apos 1o pagamento |
Regras de Negocio¶
- Desconto de primeiro mes (
discount_first_month) so se aplica a codigospromo - Consumo individual (tipo bilhete) — cada customer pode resgatar cada codigo uma vez. Rastreado via
redeemed_promo_codesno Customer. Sem limite global de usos (max_usesremovido) - Desconto via pagamento avulso (Asaas credit card) — o primeiro mes e cobrado via
create_paymentavulso com valor descontado. A subscription e criada com valor cheio (R$19,90) enextDueDate+30 dias. O pagamento avulso enviaexternalReference=subscription._idpara que o webhook ative a subscription. Nao ha restauracao de valor — a subscription ja nasce com o preco correto. Para PIX Asaas, o desconto e aplicado na subscription e restaurado via webhook (fluxo legado mantido) - Codigos sao case-insensitive — armazenados em UPPERCASE, convertidos no signup e validacao
- Hard delete —
DELETEremove permanentemente do MongoDB e deleta imagem associada do GridFS - Visibilidade por event_date — cupom visivel enquanto
event_datefornullOUevent_date >= now(). Apos a data do evento, o cupom fica invisivel para o frontend - Imagem via GridFS — upload na criacao (
POST /admin/promo-codescom multipart/form-data) ou separado viaPOST /admin/promo-codes/{id}/image. Acesso publico via token Fernet (padraoarchive-records-public). Toda imagem deve ser visivel em archive records - Valores em reais —
discount_first_monthem reais (1.00 = R$1,00), nao centavos. Em PIX multi-mes (quarterly/semiannually/yearly), desconto aplica-se a 1 dos N meses:((N-1) × mensal) + discount_first_month - Capacidade do evento respeitada — ao criar tickets via promo code (signup, redeem ou admin), o backend verifica
event.capacityantes de emitir. Setickets_issued + bonus_tickets > capacity, os tickets nao sao criados (retorna 0 no signup/redeem, 409 no admin). Isso vale para todos os caminhos: signup com promo,PUT /me/redeem-promo-code,POST /admin/promo-codescom targets, ePOST /tickets(admin manual). Ver Controle de capacidade
Imagem do Cupom¶
Upload de Imagem (Admin)¶
Upload ou substituicao da imagem do cupom. Aceita JPEG, PNG ou WebP (max 5 MB). A imagem e armazenada no GridFS e o campo image_id do PromoCode e atualizado. Se ja existir uma imagem, a anterior e deletada do GridFS.
Content-Type: multipart/form-data
Form field:
| Campo | Tipo | Descricao |
|---|---|---|
file |
binary | Arquivo de imagem (JPEG, PNG ou WebP, max 5 MB) |
Response (200):
{
"message": "Imagem do cupom atualizada com sucesso.",
"promo_code_id": "665f1a2b3c4d5e6f7a8b9c0d",
"image_id": "665f1a2b3c4d5e6f7a8b9c0e"
}
Erros:
| Status | Detalhe |
|---|---|
400 |
Tipo de arquivo nao permitido / Arquivo excede limite |
404 |
Codigo promocional nao encontrado |
Remover Imagem (Admin)¶
Remove a imagem do cupom do GridFS e limpa o campo image_id.
Response (200):
Erros:
| Status | Detalhe |
|---|---|
404 |
Codigo promocional nao encontrado / Nenhuma imagem encontrada |
Acessar Imagem (Publico)¶
A imagem do cupom e acessada pelo endpoint publico padrao do projeto, usando token Fernet:
O encrypted_token e gerado cifrando o image_id (ObjectId) com Fernet. O frontend deve usar o mesmo fluxo de criptografia ja existente para arquivos de archive records.
Exemplo Flutter/Dart:
// Montar URL publica da imagem do cupom
final imageUrl = '$baseUrl/api/v1/archive-records-public/${encryptFernet(promoCode.imageId)}';
// Usar em widget Image
Image.network(imageUrl)
Visibilidade do Cupom (Frontend)¶
O frontend deve filtrar cupons usando a regra combinada:
bool isCouponVisible(PromoCode promo) {
if (!promo.isActive) return false;
if (promo.eventDate != null && promo.eventDate!.isBefore(DateTime.now())) {
return false;
}
return true;
}
| Condicao | Visivel? |
|---|---|
is_active=true, event_date=null |
Sim |
is_active=true, event_date no futuro |
Sim |
is_active=true, event_date no passado |
Nao |
is_active=false (qualquer event_date) |
Nao |
Implementacao no Frontend¶
1. Tela de Signup — Campo Codigo Promo¶
// Validar codigo em tempo real (debounce 500ms)
final resp = await http.get(
Uri.parse('$baseUrl/api/v1/customers/validate-promo-code?code=$code'),
);
final data = json.decode(resp.body);
if (data['valid']) {
// Mostrar banner: "Voce ganhara 1 ingresso de cinema!"
// Se discount_first_month != null: "Primeiro mes por R$ X,XX!"
}
2. Enviar promo_code no Signup¶
final signupPayload = [{
'username': username,
'email': email,
'password': password,
'full_name': fullName,
'tax_id': taxId,
'phone': phone,
'promo_code': promoCode, // campo opcional
}];
await http.post(
Uri.parse('$baseUrl/api/v1/customers/signup'),
body: json.encode(signupPayload),
);
Swagger / OpenAPI
Todos os endpoints estao documentados no Swagger interativo em /api/v1/docs (ex: https://fdplay-api.infraifd.com/api/v1/docs). Use o Swagger para testar requests diretamente no navegador, ver schemas exatos de request/response, e validar parametros. Em caso de duvida, o Swagger e a fonte de verdade.
Guia Passo a Passo — Fluxo Completo do Cupom¶
Este guia descreve exatamente o que o frontend precisa fazer em cada etapa, na ordem correta.
Passo 1 — Admin cria o cupom¶
O admin cria o cupom via painel admin. O frontend admin envia um POST com JSON body (array com 1 objeto). O campo code e opcional — se omitido ou null, o backend gera automaticamente (6 chars alfanumericos). Se fornecido, deve ser unico (3-50 chars):
POST /api/v1/admin/promo-codes
Authorization: Bearer <admin_token>
Content-Type: application/json
[{
"title": "Ingresso Cinema Gratis",
"subtitle": "Filme para toda familia",
"event_date": "2026-12-31T23:59:59Z",
"description": "Ganhe 2 ingressos de cinema!"
}]
Response: { "promo_code_id": "...", "code": "K9X1A3", "code_type": "promo" }
O code retornado (ex: "K9X1A3") e o codigo que o customer vai usar para resgatar.
O promo_code_id retornado e usado no proximo passo para upload da imagem.
Passo 2 — Admin faz upload da imagem (opcional)¶
Apos criar, o admin envia a imagem do cupom via endpoint separado (multipart/form-data):
POST /api/v1/admin/promo-codes/{promo_code_id}/image
Authorization: Bearer <admin_token>
Content-Type: multipart/form-data
file: <imagem.jpg> (JPEG, PNG ou WebP, max 5 MB)
Response: { "image_id": "..." }
O image_id e usado pelo frontend do customer para exibir a imagem via endpoint publico.
Passo 3 — Customer consulta o cupom (publico)¶
O customer digita o codigo do cupom. O frontend consulta os detalhes:
Sem auth: retorna os dados do cupom + "consumed": null (nao sabe se ja usou).
Com auth (Bearer token): retorna os dados + "consumed": true/false (sabe se ja usou).
Response:
{
"valid": true,
"code": "CINEMA2026",
"title": "Ingresso Cinema Gratis",
"subtitle": "Filme para toda familia",
"tickets": 2,
"discount_first_month": null,
"event_date": "2026-12-31T23:59:59+00:00",
"image_id": "69c084d227ad7c1823881bb4",
"description": "Ganhe 2 ingressos de cinema!",
"consumed": false,
"children": null
}
O que o frontend faz com cada campo:
valid == false→ mostrar mensagem de erro (message)valid == true→ montar o card visual do cupom:title→ titulo em destaquesubtitle→ subtitulo abaixodescription→ texto descritivoimage_id→ montar URL da imagem:GET /api/v1/archive-records-public/{encryptFernet(image_id)}event_date→ mostrar data do evento formatadatickets→ mostrar "Ganhe X ingresso(s)!"consumed == null→ usuario nao logado → botao "ENTRAR PARA RESGATAR"consumed == false→ usuario logado, cupom disponivel → botao "RESGATAR"consumed == true→ usuario logado, ja usou → badge "JA UTILIZADO" (desabilitado)
Passo 4 — Customer resgata o cupom (autenticado)¶
Quando o customer clica em "RESGATAR", o frontend chama:
O que acontece no backend:
- Verifica se o customer ja resgatou este codigo (via
redeemed_promo_codes) - Valida se o cupom esta ativo, nao expirado, event_date ok
- Para
ticket: cria ingressos na collectiontickets - Adiciona o codigo em
redeemed_promo_codesdo customer ($push)
Response (200) — code_type='promo':
{
"message": "Codigo resgatado com sucesso.",
"code": "CINEMA2026",
"title": "Ingresso Cinema Gratis",
"consumed": true,
"redeemed_at": "2026-03-23T00:13:19+00:00"
}
Response (200) — code_type='ticket':
{
"message": "Codigo resgatado com sucesso.",
"code": "948POF",
"title": "Lancamento Filme Teste",
"tickets_created": 2,
"consumed": true,
"redeemed_at": "2026-03-26T00:14:44+00:00"
}
tickets_created vs bonus_tickets
Para code_type='ticket', a response retorna tickets_created (quantidade de ingressos criados na collection tickets) em vez de bonus_tickets. Os ingressos ficam visiveis em GET /api/v1/me/tickets.
Erros possiveis:
| Status | Mensagem | O que fazer |
|---|---|---|
400 |
Codigo invalido ou inativo | Mostrar erro |
400 |
Codigo expirado | Mostrar erro |
400 |
Evento ja encerrado | Mostrar erro |
409 |
Codigo ja utilizado por este usuario | Mostrar "Ja utilizado" |
Passo 5 — Apos o resgate¶
Apos receber status 200 do resgate, o frontend deve:
- Marcar o cupom como "JA UTILIZADO" na UI (nao permitir resgatar de novo)
- Se
code_type="ticket", recarregarGET /me/ticketspara ver novos ingressos - Opcionalmente, recarregar
GET /customers/mepara dados atualizados
3. Tela do Cupom¶
Fluxo: consultar detalhes → exibir card visual → resgatar.
// 1. Consultar detalhes do cupom (publico, sem auth)
final resp = await http.get(
Uri.parse('$baseUrl/api/v1/customers/validate-promo-code?code=$code'),
);
final data = json.decode(resp.body);
if (data['valid']) {
// 2. Montar card visual do cupom
final imageUrl = data['image_id'] != null
? '$baseUrl/api/v1/archive-records-public/${encryptFernet(data['image_id'])}'
: null;
showCouponCard(
title: data['title'], // "Promocao Lancamento"
subtitle: data['subtitle'], // "Desconto especial para o filme X"
description: data['description'],
imageUrl: imageUrl, // foto do cupom via GridFS
eventDate: data['event_date'], // "2026-04-15T20:00:00Z"
tickets: data['tickets'],
);
// 2b. Exibir beneficios dos filhos (se existirem)
final children = data['children'] as List?;
if (children != null && children.isNotEmpty) {
for (final child in children) {
if (child['code_type'] == 'ticket') {
showBadge('+ ${child['tickets']} ingresso: ${child['title']}');
} else if (child['code_type'] == 'promo' && child['discount_first_month'] != null) {
showBadge('+ Desconto: R\$ ${child['discount_first_month']}');
}
}
}
}
// 3. Resgatar cupom (autenticado)
final redeemResp = await http.put(
Uri.parse('$baseUrl/api/v1/customers/me/redeem-promo-code?code=$code'),
headers: {'Authorization': 'Bearer $token'},
);
final result = json.decode(redeemResp.body);
if (redeemResp.statusCode == 200) {
// Sucesso: mostrar confirmacao
showSuccess('${result['title']} resgatado com sucesso!');
} else if (redeemResp.statusCode == 409) {
// Ja utilizado
showError('Voce ja resgatou este cupom.');
} else {
showError(result['detail']);
}
4. Botao do Ingresso¶
Verificar se o customer tem tickets disponiveis via GET /me/tickets?status=available:
final ticketsResp = await http.get(
Uri.parse('\$baseUrl/api/v1/me/tickets?status=available'),
headers: authHeaders,
);
final tickets = jsonDecode(ticketsResp.body)['docs'];
if (tickets.isNotEmpty) {
// Mostrar "Botao do Ingresso" no app
}
Fluxo Atualizado: Modelo N:M (multiplos cupons por customer)¶
ADR-042 — O modelo promo code <-> customer agora e N:M. Um customer pode ter multiplos cupons. Cupons podem ser direcionados a customers especificos.
Campos no Customer (MongoDB)¶
| Campo | Tipo | Funcao |
|---|---|---|
promo_code_ids |
list[ObjectId] |
Cupons atribuidos ao customer (referencia promo_codes._id). Detalhes obtidos via $lookup |
redeemed_promo_codes |
list[string] |
Codigos ja consumidos (strings dos codes resgatados). Usado para check de duplicata |
bonus_tickets |
int |
(legado) Campo nao utilizado. Ingressos via collection tickets |
promo_code_id |
ObjectId \| null |
(legado) Codigo usado no signup. Mantido por retrocompatibilidade |
promo_code_used |
string \| null |
(legado) String do codigo usado no signup |
Campos no PromoCode (MongoDB)¶
| Campo | Tipo | Funcao |
|---|---|---|
target_customer_ids |
list[ObjectId] |
Customers direcionados. Se vazio = codigo publico. Se nao-vazio = apenas esses customers podem resgatar |
children_code_ids |
list[ObjectId] |
IDs de codigos agrupados (bundle). Ao resgatar o pai, filhos sao atribuidos (nao resgatados). Cada filho tem ciclo de vida independente. Ver Hierarquia |
discount_first_month |
float \| null |
Preco do primeiro mes em reais (ex: 9.90 = R$9,90). null = preco normal |
bonus_tickets |
int |
Para ticket: quantidade de ingressos a criar. Para promo: ignorado |
Arquitetura do Fluxo¶
═══════════════════════════════════════════════════════════════
FLUXO PROMO CODE — 3 CAMINHOS
═══════════════════════════════════════════════════════════════
CAMINHO A: Admin direciona cupom a customers (automatico)
──────────────────────────────────────────────────────────
POST /admin/promo-codes
body: { title, discount_first_month,
target_customer_ids: ["customer_id_1", "customer_id_2"] }
│
├── Salva em promo_codes collection
│
└── AUTO-PUSH nos customers alvo:
$push promo_code_ids: ObjectId (referencia)
$push redeemed_promo_codes: "CODE" (string)
cria tickets (se code_type=ticket)
│
▼
Customer faz login → GET /customers/me
→ promo_code_ids contem os OIDs dos cupons
→ Frontend faz lookup para exibir detalhes
CAMINHO B: Customer digita codigo manualmente (antes do pagamento)
─────────────────────────────────────────────────────────────────
GET /customers/validate-promo-code?code=ABC123
→ { valid: true, title, discount_first_month, consumed: false }
│
▼
PUT /customers/me/redeem-promo-code?code=ABC123
→ $push promo_code_ids: ObjectId
→ $push redeemed_promo_codes: "ABC123"
→ cria tickets (se code_type=ticket)
│
▼
Desconto fica disponivel para o proximo subscribe
CAMINHO C: Signup com codigo
────────────────────────────
POST /customers/signup
body: { ..., promo_code: "ABC123" }
→ Customer criado com:
promo_code_ids: [ObjectId]
redeemed_promo_codes: ["ABC123"]
│
▼
Desconto disponivel no primeiro subscribe
CAMINHO D: Codigo aplicado NA HORA do pagamento (RECOMENDADO)
─────────────────────────────────────────────────────────────
POST /asaas/subscribe (ou /stripe/subscribe)
body: { plan_id, credit_card, ..., promo_code: "ABC123" }
│
▼
Backend automaticamente:
1. Valida codigo (ativo, nao expirado, target ok)
2. Vincula ao customer ($push promo_code_ids + redeemed)
3. Cria tickets (se code_type=ticket)
4. Aplica desconto na subscription (se promo com discount_first_month)
│
▼
Tudo em uma unica request!
TODOS OS CAMINHOS → SUBSCRIBE
─────────────────────────────
POST /asaas/subscribe (ou /stripe/subscribe)
campo opcional: "promo_code": "ABC123"
│
▼
Se promo_code no payload:
→ Vincula + resgata + aplica desconto automaticamente
Se promo_code ja estava vinculado (caminhos A/B/C):
→ Busca promo_code_ids do customer
→ Aplica o MELHOR desconto (menor valor)
│
▼
Subscription criada com desconto aplicado
→ discount_needs_restore: true (Asaas)
→ discount_needs_restore: false (Stripe)
│
▼
Webhook PAYMENT_RECEIVED (1o pagamento Asaas)
→ Restaura valor original no gateway
Exemplo: GET /customers/me (resposta)¶
{
"promo_code_ids": [
"69c098da1f3d010b4fbbcc79",
"69c17e20546d2c1a80dac5d1"
],
"redeemed_promo_codes": [
"187QDB",
"WFV875"
],
"promo_code_id": null,
"promo_code_used": null
}
Nota:
promo_code_idscontem apenas ObjectIds. Para obter detalhes (titulo, desconto, imagem), o frontend deve usarGET /customers/validate-promo-code?code=CODEpara cada codigo emredeemed_promo_codes, ou aguardar o endpoint de agregacao (futuro).
Frontend: Exibir cupons do customer¶
// GET /customers/me
final customer = await getCustomerMe();
final promoCodeIds = List<String>.from(customer['promo_code_ids'] ?? []);
final redeemedCodes = List<String>.from(customer['redeemed_promo_codes'] ?? []);
// bonus_tickets is a legacy field — use GET /me/tickets instead
// Exibir quantidade de cupons
if (promoCodeIds.isNotEmpty) {
print('Voce tem ${promoCodeIds.length} cupom(ns)');
}
// Para obter detalhes de cada cupom, validar cada codigo:
for (final code in redeemedCodes) {
final resp = await http.get(
Uri.parse('$baseUrl/api/v1/customers/validate-promo-code?code=$code'),
headers: authHeaders,
);
final promo = jsonDecode(resp.body);
if (promo['valid'] == true) {
print('Cupom: ${promo['title']}');
print('Desconto: R\$ ${promo['discount_first_month']}');
print('Tickets: ${promo['tickets']}');
}
}
Frontend: Resgatar codigo manualmente¶
// 1. Validar codigo
final validateResp = await http.get(
Uri.parse('$baseUrl/api/v1/customers/validate-promo-code?code=$code'),
headers: authHeaders,
);
final validateResult = jsonDecode(validateResp.body);
if (!validateResult['valid']) {
showError(validateResult['message']);
return;
}
// 2. Verificar se ja foi consumido
if (validateResult['consumed'] == true) {
showError('Voce ja resgatou este cupom.');
return;
}
// 3. Resgatar
final redeemResp = await http.put(
Uri.parse('$baseUrl/api/v1/customers/me/redeem-promo-code?code=$code'),
headers: authHeaders,
);
final redeemResult = jsonDecode(redeemResp.body);
if (redeemResp.statusCode == 200) {
showSuccess('${redeemResult['title']} resgatado!');
if (redeemResult['discount_first_month'] != null) {
showInfo('Desconto de R\$ ${redeemResult['discount_first_month']} na proxima assinatura.');
}
} else if (redeemResp.statusCode == 403) {
showError('Este cupom nao esta disponivel para voce.');
} else if (redeemResp.statusCode == 409) {
showError('Voce ja resgatou este cupom.');
} else {
showError(redeemResult['detail']);
}
Frontend: Subscribe com promo code (RECOMENDADO)¶
// O codigo promo vai DIRETO no payload do subscribe
// O backend faz tudo: valida, vincula, aplica desconto
final subscribePayload = {
'plan_id': 'plan-basic',
'credit_card': {
'holderName': 'DANIEL WARLES',
'number': '5162306219378829',
'expiryMonth': '05',
'expiryYear': '2028',
'ccv': '318',
},
'credit_card_holder_info': {
'name': 'Daniel Warles',
'email': 'danielwarles.eng@gmail.com',
'cpfCnpj': '03602313140',
'postalCode': '74000000',
'addressNumber': '100',
'phone': '62982177957',
},
// Campo opcional — se presente, o backend vincula e aplica desconto
'promo_code': '187QDB',
};
final resp = await http.post(
Uri.parse('$baseUrl/api/v1/asaas/subscribe'),
headers: {...authHeaders, 'Content-Type': 'application/json'},
body: jsonEncode(subscribePayload),
);
if (resp.statusCode == 201) {
// Sucesso! Desconto aplicado automaticamente se codigo valido
showSuccess('Assinatura criada com sucesso!');
}
Importante: O campo
promo_codee opcional. Se omitido, o subscribe funciona normalmente sem desconto. Se o codigo for invalido ou ja consumido, o subscribe continua sem desconto (nao bloqueia a assinatura).
Frontend: Validar codigo (preview antes do pagamento)¶
// Opcional: mostrar preview do desconto ao usuario antes de pagar
final resp = await http.get(
Uri.parse('$baseUrl/api/v1/customers/validate-promo-code?code=$code'),
headers: authHeaders,
);
final promo = jsonDecode(resp.body);
if (promo['valid'] == true) {
if (promo['discount_first_month'] != null) {
showInfo('Primeiro mes por R\$ ${promo['discount_first_month']}');
}
if (promo['tickets'] != null && promo['tickets'] > 0) {
showInfo('${promo['tickets']} ingresso(s) incluido(s)!');
}
} else {
showError(promo['message']);
}
Frontend: Resgatar codigo manualmente (sem subscribe)¶
// Usar APENAS se o customer quer resgatar bonus SEM assinar agora
final redeemResp = await http.put(
Uri.parse('$baseUrl/api/v1/customers/me/redeem-promo-code?code=$code'),
headers: authHeaders,
);
final result = jsonDecode(redeemResp.body);
if (redeemResp.statusCode == 200) {
showSuccess('${result['title']} resgatado!');
} else if (redeemResp.statusCode == 403) {
showError('Este cupom nao esta disponivel para voce.');
} else if (redeemResp.statusCode == 409) {
showError('Voce ja resgatou este cupom.');
} else {
showError(result['detail']);
}
Respostas da API por endpoint¶
POST /asaas/subscribe (com promo_code)¶
201 Created (com desconto aplicado):
{
"message": "Assinatura criada com sucesso.",
"subscription_id": "69c194ae...",
"status": "pending",
"gateway": "asaas",
"discount_applied": true,
"discount_first_month": 9.9,
"original_amount": 1990
}
201 Created (sem desconto / codigo invalido ignorado):
{
"message": "Assinatura criada com sucesso.",
"subscription_id": "69c194ae...",
"status": "pending",
"gateway": "asaas"
}
PUT /customers/me/redeem-promo-code?code=ABC123¶
| Status | Significado |
|---|---|
| 200 | Codigo resgatado com sucesso |
| 400 | Codigo invalido, inativo ou expirado |
| 403 | Codigo direcionado — customer nao esta na lista target_customer_ids |
| 409 | Customer ja resgatou este codigo |
GET /customers/validate-promo-code?code=ABC123¶
| Status | Significado |
|---|---|
| 200 | Retorna info do codigo (valid, title, discount_first_month, consumed) |
Os campos tickets e discount_first_month são retornados de forma exclusiva conforme o code_type:
code_type: ticket — ingresso de evento:
{
"valid": true,
"code": "449GQW",
"title": "Goiania VIP",
"code_type": "ticket",
"tickets": 1,
"discount_first_month": null,
"event_date": "2026-04-15T19:00:00+00:00",
"consumed": false
}
code_type: promo — desconto na assinatura:
{
"valid": true,
"code": "9DYUYV",
"title": "Desconto primeiro mês",
"subtitle": "Primeiro mês por R$ 5,50",
"code_type": "promo",
"tickets": null,
"discount_first_month": 5.5,
"event_date": null,
"consumed": false
}
Regra de exibição no frontend
tickets != null→ exibir "Você ganhou N ingresso(s) para o evento"discount_first_month != null→ exibir "Primeiro mês por R$ X,XX"- Os dois campos nunca vêm preenchidos ao mesmo tempo.
Payloads por gateway¶
Asaas Cartao¶
POST /api/v1/asaas/subscribe
{
"plan_id": "plan-basic",
"credit_card": { "holderName", "number", "expiryMonth", "expiryYear", "ccv" },
"credit_card_holder_info": { "name", "email", "cpfCnpj", "postalCode", "addressNumber", "phone" },
"promo_code": "187QDB"
}
Asaas PIX¶
Stripe Cartao¶
POST /api/v1/stripe/subscribe
{
"plan_id": "plan-basic",
"payment_method_id": "pm_1234567890abcdef",
"promo_code": "187QDB"
}
Stripe PIX¶
POST /api/v1/stripe/subscribe/pix
{
"plan_id": "plan-basic",
"amount": 23880,
"promo_code": "187QDB"
}
Listar Codigos Promocionais do Customer¶
Retorna todos os codigos atribuidos ao customer com detalhes e flag redeemed.
Response:
{
"codes": [
{
"code": "449GQW",
"title": "A Escolha de Ficar",
"subtitle": null,
"code_type": "ticket",
"redeemed": true,
"discount_first_month": null,
"event_date": "2026-04-06T00:00:00",
"image_id": null
},
{
"code": "T2LY94",
"title": "Um Ingresso e Desconto no Primeiro Mes",
"subtitle": "Ingresso+",
"code_type": "ticket",
"redeemed": false,
"discount_first_month": 5.50,
"event_date": "2026-04-06T00:00:00",
"image_id": null
}
]
}
| Campo | Descricao |
|---|---|
code |
Codigo string para compartilhar |
redeemed |
true = ja usado pelo customer, false = disponivel para compartilhar |
discount_first_month |
Desconto em reais (null = sem desconto) |
tickets |
Ingressos que o resgate concede (apenas ticket) |
image_id |
ID da imagem no GridFS (acesso via archive-records-public/{token}) |
Fluxo recomendado no frontend:
1. GET /customers/me/promo-codes → lista com flag redeemed
2. Filtrar: redeemed=false → codigos para compartilhar
3. Filtrar: redeemed=true → codigos ja usados (historico)
4. Exibir botao "Compartilhar" apenas nos redeemed=false
Dart/Flutter:
Future<List<Map<String, dynamic>>> getMyPromoCodes(String token) async {
final resp = await http.get(
Uri.parse('$baseUrl/api/v1/customers/me/promo-codes'),
headers: {'Authorization': 'Bearer $token'},
);
final data = jsonDecode(resp.body);
return List<Map<String, dynamic>>.from(data['codes']);
}
// Filtrar para compartilhar
final shareable = codes.where((c) => c['redeemed'] == false).toList();
// Filtrar historico
final redeemed = codes.where((c) => c['redeemed'] == true).toList();
Substituir logica antiga
O frontend deve usar GET /customers/me/promo-codes em vez de montar a lista manualmente a partir de redeemed_promo_codes + validate-promo-code. O novo endpoint ja retorna tudo resolvido com flag redeemed.
Checklist Frontend (Promo Codes)¶
- [ ] Tela de pagamento: campo "Codigo promocional" (opcional)
- [ ] Validar codigo ao digitar (preview com
GET /validate-promo-code) - [ ] Enviar
promo_codeno payload do subscribe (Caminho D — recomendado) - [ ] Exibir cupons do customer via
GET /customers/me/promo-codes(novo endpoint) - [ ] Filtrar
redeemed=falsepara compartilhar,redeemed=truepara historico - [ ] Tratar HTTP 403 no redeem (cupom direcionado)
Eventos e Ingressos (Tickets)¶
Arquitetura¶
Event (Evento) Ticket (Ingresso)
┌──────────────────────┐ ┌──────────────────────┐
│ _id │ │ _id │
│ title │◄────────┐│ event_id (FK) │
│ description │ ││ customer_id (FK) │
│ event_type: │ ││ title │
│ 'movie_premiere' │ ││ description │
│ 'lecture' │ ││ status: │
│ 'screening' │ ││ 'available' │
│ 'other' │ ││ 'consumed' │
│ event_date │ ││ 'expired' │
│ location │ ││ promo_code_id │
│ capacity (max) │ ││ promo_code │
│ tickets_issued │ ││ consumed_at │
│ image_id │ ││ image_id │
│ is_active │ ││ created_at │
│ created_at │ │└──────────────────────┘
└──────────────────────┘ │
│ │
└─────────────────────────┘
1 : N
Fluxo Completo¶
CRIACAO DE EVENTOS (Admin)
──────────────────────────
POST /api/v1/events
body: { title, description, event_type, event_date,
location, capacity, image_id }
│
▼
Evento criado em collection events
→ Sem tickets ainda (tickets sao criados separadamente)
CRIACAO DE TICKETS — 3 caminhos
────────────────────────────────
A) Admin cria ticket avulso vinculado ao evento:
POST /api/v1/tickets
body: { customer_id, event_id, title }
→ Ticket.event_id = referencia ao Event
→ Event.tickets_issued incrementado
B) Via PromoCode (code_type='ticket'):
POST /admin/promo-codes
body: { code_type: 'ticket', event_id: "...",
target_customer_ids: ["id1", "id2"] }
→ Auto-push: cria Ticket para cada target
→ Ticket.event_id = Event do PromoCode
→ Ticket.promo_code_id = referencia ao PromoCode
C) Customer resgata codigo manualmente:
PUT /customers/me/redeem-promo-code?code=ABC123
→ Se code_type='ticket': cria Ticket
→ Ticket.event_id = herdado do PromoCode
CONSULTA
────────
Admin:
GET /api/v1/events → Lista eventos
GET /api/v1/events?_id=X → Detalhe + contagem tickets
GET /api/v1/tickets → Todos os tickets
Customer:
GET /api/v1/me/tickets → Meus ingressos
GET /api/v1/events → Eventos disponiveis
CONSUMO DO TICKET
─────────────────
PUT /api/v1/tickets/{id}/consume
│
├── Valida: ticket pertence ao customer (ou admin)
├── Valida: status == 'available'
├── Valida: event.event_date nao expirou
│
▼
status: 'available' → 'consumed'
consumed_at: now()
EXEMPLO COMPLETO
────────────────
1. Admin cria Evento "Lancamento Filme X" (15/abril)
2. Admin cria PromoCode tipo 'ticket':
{ code_type: 'ticket', event_id: "...",
title: "Ingresso Filme X",
target_customer_ids: ["daniel", "maria"] }
3. Sistema auto-cria:
→ Ticket p/ Daniel (event_id → Filme X, status: available)
→ Ticket p/ Maria (event_id → Filme X, status: available)
4. Daniel abre o app:
GET /me/tickets → ve "Ingresso Filme X"
5. No dia do evento:
PUT /tickets/{id}/consume → status: consumed
6. Apos 15/abril:
Tickets nao consumidos → status: expired
Endpoints de Eventos¶
POST /api/v1/events (Admin)¶
{
"title": "Lancamento Filme X",
"description": "Pre-estreia exclusiva para assinantes",
"event_type": "movie_premiere",
"event_date": "2026-04-15T20:00:00Z",
"location": "Cinema Goiania - Sala 3",
"capacity": 100
}
Tipos de evento: movie_premiere, lecture, screening, other
GET /api/v1/events¶
Lista eventos. Acessivel por admin e customer.
{
"docs": [
{
"_id": "69c1a000...",
"title": "Lancamento Filme X",
"event_type": "movie_premiere",
"event_date": "2026-04-15T20:00:00Z",
"location": "Cinema Goiania - Sala 3",
"capacity": 100,
"tickets_issued": 25,
"is_active": true
}
]
}
Endpoints de Tickets¶
POST /api/v1/tickets (Admin)¶
titlee opcional — se omitido, herda o titulo do evento.
GET /api/v1/me/tickets (Customer)¶
{
"docs": [
{
"_id": "69c47a74e0b2434dd41c064d",
"customer_id": "69c43ce41705cf593ae4cead",
"event_id": "69c1dadce9c9cdd70fe6b58c",
"title": "Lancamento Filme Teste",
"description": "2 ingressos gratis",
"status": "available",
"promo_code_id": "69c47a73e1b00b363f3fd387",
"promo_code": "948POF",
"consumed_at": null,
"image_id": null,
"created_at": "2026-03-26T00:14:44.230000Z",
"updated_at": "2026-03-26T00:14:44.230000Z"
}
],
"msg": "ok",
"pagination": { "current_page": 0, "qty_docs_page": 10, "qty_of_pages": 1, "qty_total_docs": 1 }
}
Filtro por status: GET /api/v1/me/tickets?status=available
PUT /api/v1/tickets/{id}/consume¶
200 OK:
{
"docs": [
{
"_id": "69c47a74e0b2434dd41c064d",
"customer_id": "69c43ce41705cf593ae4cead",
"event_id": "69c1dadce9c9cdd70fe6b58c",
"title": "Lancamento Filme Teste",
"description": "2 ingressos gratis",
"status": "consumed",
"promo_code_id": "69c47a73e1b00b363f3fd387",
"promo_code": "948POF",
"consumed_at": "2026-03-26T00:14:44.555000Z",
"image_id": null,
"created_at": "2026-03-26T00:14:44.230000Z",
"updated_at": "2026-03-26T00:14:44.555000Z"
}
],
"msg": "ok",
"pagination": { "current_page": 0, "qty_docs_page": 1, "qty_of_pages": 1, "qty_total_docs": 1 }
}
| Status | Significado |
|---|---|
| 200 | Ticket consumido |
| 400 | Ticket ja consumido ou expirado |
| 403 | Acesso negado (nao e o dono) |
| 404 | Ticket nao encontrado |
Frontend: Listar eventos e tickets¶
// 1. Listar eventos disponiveis
final eventsResp = await http.get(
Uri.parse('$baseUrl/api/v1/events'),
headers: authHeaders,
);
final events = jsonDecode(eventsResp.body)['docs'];
for (final event in events) {
print('${event['title']} - ${event['event_date']}');
print('Local: ${event['location']}');
print('Vagas: ${event['capacity'] - event['tickets_issued']} restantes');
}
// 2. Listar meus ingressos
final ticketsResp = await http.get(
Uri.parse('$baseUrl/api/v1/me/tickets'),
headers: authHeaders,
);
final tickets = jsonDecode(ticketsResp.body)['docs'];
for (final ticket in tickets) {
print('${ticket['title']} - ${ticket['status']}');
}
Frontend: Consumir ingresso¶
// Consumir ticket (ex: QR code scanner no evento)
final resp = await http.put(
Uri.parse('$baseUrl/api/v1/tickets/$ticketId/consume'),
headers: authHeaders,
);
if (resp.statusCode == 200) {
showSuccess('Ingresso validado!');
} else if (resp.statusCode == 400) {
final detail = jsonDecode(resp.body)['detail'];
showError(detail); // "Ticket ja foi consumido" ou "Ticket expirado"
} else if (resp.statusCode == 403) {
showError('Este ingresso nao pertence a voce.');
}
Frontend: Exibir "Botao do Ingresso"¶
// Verificar se customer tem tickets disponiveis
final ticketsResp = await http.get(
Uri.parse('$baseUrl/api/v1/me/tickets?status=available'),
headers: authHeaders,
);
final tickets = jsonDecode(ticketsResp.body)['docs'];
if (tickets.isNotEmpty) {
// Mostrar "Botao do Ingresso" no app
// Ao clicar: exibir lista de ingressos disponiveis
// Cada ingresso mostra: titulo, evento, data, botao "Usar"
}
Checklist Frontend (Eventos + Tickets)¶
- [ ] Tela de eventos: listar eventos ativos (
GET /events) - [ ] Tela "Meus Ingressos": listar tickets do customer (
GET /me/tickets) - [ ] Filtrar tickets por status (
?status=available) - [ ] Botao "Usar Ingresso": chamar
PUT /tickets/{id}/consume - [ ] Tratar ticket expirado (status 400)
- [ ] Tratar ticket ja consumido (status 400)
- [ ] Exibir "Botao do Ingresso" na home se customer tem tickets
available - [ ] (Opcional) QR code no ticket para validacao presencial