Skip to content

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:

  • promodesconto na assinatura. Pode agregar filhos via children_code_ids (ex: um ticket).
  • ticketingresso 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

GET /api/v1/customers/validate-promo-code?code=FDPLAY2026

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.
  • children com 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):

{
  "valid": false,
  "message": "Codigo invalido ou inativo."
}

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)

PUT /api/v1/customers/me/redeem-promo-code?code=FDPLAY2026

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:

  1. Marcar o cupom como "JA UTILIZADO" na UI
  2. Se code_type="ticket", recarregar GET /me/tickets para ver novos ingressos
  3. Se necessario, recarregar GET /customers/me para 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

POST /api/v1/customers/signup
[{
  "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 usado
  • promo_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:

  1. Atualiza o email da conta existente para o email enviado na nova requisicao
  2. Gera e reenvia um novo codigo de verificacao para o email atualizado
  3. 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

POST /api/v1/admin/promo-codes

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

GET /api/v1/admin/promo-codes

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

GET /api/v1/admin/promo-codes/{promo_code_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

PUT /api/v1/admin/promo-codes/{promo_code_id}

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:

{
  "event_date": "2026-05-01T20:00:00Z",
  "description": "Data do evento atualizada"
}

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)

DELETE /api/v1/admin/promo-codes/{promo_code_id}

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

GET /api/v1/admin/promo-codes/stats

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

  1. Desconto de primeiro mes (discount_first_month) so se aplica a codigos promo
  2. Consumo individual (tipo bilhete) — cada customer pode resgatar cada codigo uma vez. Rastreado via redeemed_promo_codes no Customer. Sem limite global de usos (max_uses removido)
  3. Desconto via pagamento avulso (Asaas credit card) — o primeiro mes e cobrado via create_payment avulso com valor descontado. A subscription e criada com valor cheio (R$19,90) e nextDueDate +30 dias. O pagamento avulso envia externalReference=subscription._id para 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)
  4. Codigos sao case-insensitive — armazenados em UPPERCASE, convertidos no signup e validacao
  5. Hard deleteDELETE remove permanentemente do MongoDB e deleta imagem associada do GridFS
  6. Visibilidade por event_date — cupom visivel enquanto event_date for null OU event_date >= now(). Apos a data do evento, o cupom fica invisivel para o frontend
  7. Imagem via GridFS — upload na criacao (POST /admin/promo-codes com multipart/form-data) ou separado via POST /admin/promo-codes/{id}/image. Acesso publico via token Fernet (padrao archive-records-public). Toda imagem deve ser visivel em archive records
  8. Valores em reaisdiscount_first_month em 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
  9. Capacidade do evento respeitada — ao criar tickets via promo code (signup, redeem ou admin), o backend verifica event.capacity antes de emitir. Se tickets_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-codes com targets, e POST /tickets (admin manual). Ver Controle de capacidade

Imagem do Cupom

Upload de Imagem (Admin)

POST /api/v1/admin/promo-codes/{promo_code_id}/image

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)

DELETE /api/v1/admin/promo-codes/{promo_code_id}/image

Remove a imagem do cupom do GridFS e limpa o campo image_id.

Response (200):

{
  "message": "Imagem do cupom removida com sucesso."
}

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:

GET /api/v1/archive-records-public/{encrypted_token}

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:

GET /api/v1/customers/validate-promo-code?code=CINEMA2026

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 destaque
    • subtitle → subtitulo abaixo
    • description → texto descritivo
    • image_id → montar URL da imagem: GET /api/v1/archive-records-public/{encryptFernet(image_id)}
    • event_date → mostrar data do evento formatada
    • tickets → 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:

PUT /api/v1/customers/me/redeem-promo-code?code=CINEMA2026
Authorization: Bearer <customer_token>

O que acontece no backend:

  1. Verifica se o customer ja resgatou este codigo (via redeemed_promo_codes)
  2. Valida se o cupom esta ativo, nao expirado, event_date ok
  3. Para ticket: cria ingressos na collection tickets
  4. Adiciona o codigo em redeemed_promo_codes do 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:

  1. Marcar o cupom como "JA UTILIZADO" na UI (nao permitir resgatar de novo)
  2. Se code_type="ticket", recarregar GET /me/tickets para ver novos ingressos
  3. Opcionalmente, recarregar GET /customers/me para 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_ids contem apenas ObjectIds. Para obter detalhes (titulo, desconto, imagem), o frontend deve usar GET /customers/validate-promo-code?code=CODE para cada codigo em redeemed_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_code e 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

POST /api/v1/asaas/subscribe/pix
{
  "plan_id": "plan-annual",
  "promo_code": "187QDB"
}

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

GET /api/v1/customers/me/promo-codes
Authorization: Bearer <customer_token>

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_code no payload do subscribe (Caminho D — recomendado)
  • [ ] Exibir cupons do customer via GET /customers/me/promo-codes (novo endpoint)
  • [ ] Filtrar redeemed=false para compartilhar, redeemed=true para 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)

[
  {
    "customer_id": "69c19295...",
    "event_id": "69c1a000...",
    "title": "Ingresso VIP"
  }
]

title e 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