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)¶
- invoice.paid com price conhecido
stripe trigger invoice.payment_succeeded(ou um checkout de assinatura test). ✅ Logstripe.invoice.paid;utxo_mint_eventsganha 1 linhasource=subscription;stripe_webhook_events.outcome='applied'. - 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. - 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 emstripe_invoice_id).
B. Reembolso / chargeback (A1)¶
- Refund:
stripe refunds create --charge <ch_id>de uma compra que mintou tokens. ✅ Logstripe.charge.refundedcomclawed_back_tsu>0;utxo_spend_eventsganhatest_id=clawback:charge_refunded_full; saldo (getBalance) cai pelo remanescente; contastatus='suspended'. - Chargeback:
stripe trigger charge.dispute.created. ✅ Logstripe.charge.disputed; clawback executado;suspended_reason='charge_disputed'.
C. Recompra / boost (M8)¶
- Boost (cartão):
/account/tokens→ comprarboost-100kcom cartão test4242…. ✅checkout.session.completed(payment_status=paid) → mintsource=boost(365d); saldo +100k em todas as telas. - (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)¶
- Para uma conta com assinatura + boost, compare:
GET /api/account/tokens,/account/finance,/account/tokens/cost-explorere oremainingQuotade uma chamada a/api/usage/report. ✅ Todos mostram o mesmo saldo gastável (inclui o boost). Não há mais divergênciaquota−consumed.
E. Consumo on-prem (C2, C3, M1) — bootstrap-controller¶
- Débito atômico: coloque a licença em
…/bootstrap/license.jwt, gere eventos em…/spend/<módulo>.jsonl, rodebootstrap-controller --cloud-url https://staging.app… --once. ✅usage_eventsinseridos e 1utxo_spend_eventsno mesmo lote; saldo cai pelo total. - 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--oncede novo. ✅ Reenviado com o mesmoclient_seq; cloud aceita;.inflightremovido; sem cobrança dupla. - C3 — 402 back-pressure: zere o saldo da conta, gere eventos, rode
--once. ✅ LogQUOTA_EXCEEDED; arquivoquota-exceeded.flagcriado; lote mantido (.inflight). Faça top-up (boost) e rode--once. ✅ Lote enviado, flag limpa, uso cobrado.
F. Avisos & auto-refill (A2, A3)¶
- A2 — low-tokens: consuma (via passo 9) até o saldo cruzar 15% da quota mensal.
✅ E-mail
low-tokensenviado uma vez ao owner (disparado pelo caminho on-prem). - 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: …". ✅ Resprefilled≥1; PaymentIntent off-session emsucceeded; mintsource=boost;last_refill_atatualizado. Rode de novo na mesma hora → ✅skipped(anti-flap), sem 2ª cobrança.
G. Licença & suspensão (M3, M4)¶
- M3 — rotação: com
LICENSE_JWT_KID=v1emita uma licença; adicioneLICENSE_JWT_SECRETS={"v1":"…","v2":"…"}, mudeLICENSE_JWT_SECRET/_KIDpara v2, emita outra. ✅/api/usage/reportaceita ambas (kid v1 e v2). - M4 — suspensão preserva boost: conta com boost + assinatura;
suspended_reasoncomeçando comsubscription_→ ✅ spend de boost ainda funciona. Mude paracharge_refunded_full→ ✅ spend bloqueado (403).
H. Watchdog (M6)¶
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 comoappliedantes de rodar, senão ele aparece como stale — comportamento correto.)
I. Transferências (A5)¶
- Entre duas contas
edudo 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).
- Authorize → redeem (caminho feliz): dispare uma run k6 curta pelo Dashboard.
✅
usage_ticketsganha 1 linhastatus='active'no run-start (débito no ledger pelo valor estimado); no run-complete a linha virastatus='redeemed'e o remanescente volta como mintsource=refund_anti(sourceRef=ticket-refund:<id>). Saldo final = saldo inicial − consumo real. - 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. - M4 — hand-back em deny local: com saldo OK, force um deny pós-lease (ex.:
ZTP_PREM_HSM_REQUIRED=truesem heartbeat de HSM válido) e dispare uma run. ✅ Run negada; o ticket criado no authorize é redimido comactualTsu=0(linharedeemed, refund total) — saldo volta ao valor inicial. - 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: …". ✅ RespostaexpiredTickets: 1comstrandedTsu= quota não-consumida; a linha virastatus='expired'; nenhum refund automático (sem mint novo); logusage.tickets.expired. Rodar de novo → ✅expiredTickets: 0(idempotente). - 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 permaneceactive(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.