Skip to content

Token Economy — Roteiro de Validação em Staging (ADR-0103)

Validação funcional antes de liberar tráfego de produção. Executar contra um ambiente de staging com Stripe em test mode. Cada passo lista o comando e o resultado esperado. Ferramentas: Stripe CLI, psql, e o binário bootstrap-controller (flag --once).

Pré: stripe listen --forward-to https://staging.app.tlsstress.art/api/stripe/webhook (use o whsec_… que a CLI imprime como STRIPE_WEBHOOK_SECRET no staging).


A. Aquisição → crédito (C4, A4)

  1. invoice.paid com price conhecido stripe trigger invoice.payment_succeeded (ou um checkout de assinatura test). ✅ Log stripe.invoice.paid; utxo_mint_events ganha 1 linha source=subscription; stripe_webhook_events.outcome='applied'.
  2. A4 — price desconhecido: criar um Price test fora do catálogo e pagar uma fatura com ele. ✅ Log stripe.invoice.unmapped_price.CRITICAL; outcome='ignored'; nenhum mint; saldo inalterado.
  3. C4 — retry transitório: com um evento real na aba Events do Stripe, force um erro transitório (ex.: derrube o RDS por segundos) e use stripe events resend <evt_id>. ✅ 1ª entrega → 5xx, outcome='failed'; o resend reprocessa e vira 'applied' sem duplicar o mint (idempotente em stripe_invoice_id).

B. Reembolso / chargeback (A1)

  1. Refund: stripe refunds create --charge <ch_id> de uma compra que mintou tokens. ✅ Log stripe.charge.refunded com clawed_back_tsu>0; utxo_spend_events ganha test_id=clawback:charge_refunded_full; saldo (getBalance) cai pelo remanescente; conta status='suspended'.
  2. Chargeback: stripe trigger charge.dispute.created. ✅ Log stripe.charge.disputed; clawback executado; suspended_reason='charge_disputed'.

C. Recompra / boost (M8)

  1. Boost (cartão): /account/tokens → comprar boost-100k com cartão test 4242…. ✅ checkout.session.completed (payment_status=paid) → mint source=boost (365d); saldo +100k em todas as telas.
  2. (Opcional) método assíncrono: se habilitar boleto/SEPA, confirme que o crédito só ocorre no checkout.session.async_payment_succeeded, não antes.

D. Saldo único (C1)

  1. Para uma conta com assinatura + boost, compare: GET /api/account/tokens, /account/finance, /account/tokens/cost-explorer e o remainingQuota de uma chamada a /api/usage/report. ✅ Todos mostram o mesmo saldo gastável (inclui o boost). Não há mais divergência quota−consumed.

E. Consumo on-prem (C2, C3, M1) — bootstrap-controller

  1. Débito atômico: coloque a licença em …/bootstrap/license.jwt, gere eventos em …/spend/<módulo>.jsonl, rode bootstrap-controller --cloud-url https://staging.app… --once. ✅ usage_events inseridos e 1 utxo_spend_events no mesmo lote; saldo cai pelo total.
  2. C2 — sem perda em falha: gere eventos, bloqueie a saída de rede e rode --once. ✅ Falha logada; os arquivos viram *.inflight (não some). Restaure a rede, rode --once de novo. ✅ Reenviado com o mesmo client_seq; cloud aceita; .inflight removido; sem cobrança dupla.
  3. C3 — 402 back-pressure: zere o saldo da conta, gere eventos, rode --once. ✅ Log QUOTA_EXCEEDED; arquivo quota-exceeded.flag criado; lote mantido (.inflight). Faça top-up (boost) e rode --once. ✅ Lote enviado, flag limpa, uso cobrado.

F. Avisos & auto-refill (A2, A3)

  1. A2 — low-tokens: consuma (via passo 9) até o saldo cruzar 15% da quota mensal. ✅ E-mail low-tokens enviado uma vez ao owner (disparado pelo caminho on-prem).
  2. A3 — auto-refill: UPDATE auto_refill_settings SET enabled=true, pack_id='boost-100k', stripe_payment_method_id='<pm test>' WHERE account_id=…; deixe o saldo abaixo do threshold, curl -XPOST …/api/cron/auto-refill -H "x-cron-secret: …". ✅ Resp refilled≥1; PaymentIntent off-session em succeeded; mint source=boost; last_refill_at atualizado. Rode de novo na mesma hora → ✅ skipped (anti-flap), sem 2ª cobrança.

G. Licença & suspensão (M3, M4)

  1. M3 — rotação: com LICENSE_JWT_KID=v1 emita uma licença; adicione LICENSE_JWT_SECRETS={"v1":"…","v2":"…"}, mude LICENSE_JWT_SECRET/_KID para v2, emita outra. ✅ /api/usage/report aceita ambas (kid v1 e v2).
  2. M4 — suspensão preserva boost: conta com boost + assinatura; suspended_reason começando com subscription_ → ✅ spend de boost ainda funciona. Mude para charge_refunded_full → ✅ spend bloqueado (403).

H. Watchdog (M6)

  1. curl -XPOST …/api/cron/reconcile-ledger -H "x-cron-secret: …". ✅ staleBillingWebhooks: 0, overspentUtxos: 0, staleActiveTickets: 0. (Após o passo 3 deixe o evento estabilizar como applied antes de rodar, senão ele aparece como stale — comportamento correto.)

I. Transferências (A5)

  1. Entre duas contas edu do mesmo país: criar transfer (debita o remetente), aceitar (credita o destinatário). ✅ Soma-zero no ledger; transfer (receiver) + débito (sender) casam; nenhum débito órfão se a criação falhar no re-check de limite.

J. Tickets L2 — pré-autorização, redeem, hand-back e expiração (ADR-0104 + #1275)

Pré: gate L2 ligado (broker no controller + envs do Dashboard — ver l2-pre-authorization.md).

  1. Authorize → redeem (caminho feliz): dispare uma run k6 curta pelo Dashboard. ✅ usage_tickets ganha 1 linha status='active' no run-start (débito no ledger pelo valor estimado); no run-complete a linha vira status='redeemed' e o remanescente volta como mint source=refund_anti (sourceRef=ticket-refund:<id>). Saldo final = saldo inicial − consumo real.
  2. 402 nega a run: zere o saldo e dispare uma run. ✅ Run negada (402 no run-start); nenhum ticket criado; sealed-audit registra license.deny.
  3. M4 — hand-back em deny local: com saldo OK, force um deny pós-lease (ex.: ZTP_PREM_HSM_REQUIRED=true sem heartbeat de HSM válido) e dispare uma run. ✅ Run negada; o ticket criado no authorize é redimido com actualTsu=0 (linha redeemed, refund total) — saldo volta ao valor inicial.
  4. H2 — sweep de expiração (use-it-or-lose-it): crie um ticket e simule o abandono (run-complete nunca chega):
    UPDATE usage_tickets SET expires_at = now() - interval '25 hours'
     WHERE id = '<ticket_id>' AND status = 'active';
    
    curl -XPOST …/api/cron/expire-tickets -H "x-cron-secret: …". ✅ Resposta expiredTickets: 1 com strandedTsu = quota não-consumida; a linha vira status='expired'; nenhum refund automático (sem mint novo); log usage.tickets.expired. Rodar de novo → ✅ expiredTickets: 0 (idempotente).
  5. Redeem tardio dentro do grace: crie outro ticket, ajuste expires_at = now() - interval '1 hour' (vencido, mas dentro do grace de 24h), rode o sweep. ✅ Ticket permanece active (grace protege); um redeem agora ainda refunda normalmente.

Critério de aprovação

Todos os ✅ acima observados em staging. Só então liberar o tráfego de produção (seguir deploy-activation-checklist.md). Registrar evidências (logs/prints) no ticket de release.