De Quota a Recursos: Projetando Subscription Extras sem Superdimensionar o Sistema de Spins
Como uma funcionalidade aparentemente simples de assinatura expôs os limites entre quotas, recursos consumíveis, estado do frontend e autoridade do backend.
Existe um tipo particular de problema de engenharia que começa de forma aparentemente pequena.
Você tem uma quota.
Então precisa de alguns spins a mais.
Então adiciona um campo.
Depois esse campo precisa se comportar de forma diferente da quota original.
Então o frontend precisa saber qual deles usar.
Depois o backend precisa saber qual deles foi realmente consumido.
E, de repente, o que parecia ser:
“Vamos dar alguns spins extras para usuários premium.”
se transformou em uma questão de modelagem de recursos.
Foi exatamente isso que aconteceu enquanto estávamos estendendo o sistema de spins do PromoBet.
A parte interessante não foi simplesmente adicionar subscriptionExtras.
O mais importante foi descobrir o que exatamente subscriptionExtras representava, onde deveria ficar e como introduzi-lo sem transformar o sistema existente de spins em um framework genérico de gerenciamento de recursos antes de realmente precisarmos de um.
O ponto de partida
O sistema existente já possuía um conceito de quota.
Em uma experiência de tenant, um usuário poderia ter uma determinada quantidade de spins de cortesia disponíveis. Esses spins estavam associados ao tenant e participavam dos limites de utilização do tenant.
Conceitualmente, o sistema era aproximadamente assim:
Usuário
│
└── Quota do tenant
│
├── spinsToday
└── utilização do tenantIsso funcionava porque existia apenas um recurso relevante.
O problema apareceu quando os planos de assinatura passaram a precisar fornecer spins adicionais.
À primeira vista, seria tentador simplesmente aumentar a quota:
quota do tenant = spins de cortesia + spins da assinaturaMas isso imediatamente cria problemas semânticos.
Os spins de assinatura deveriam se comportar de maneira diferente:
- São concedidos pela assinatura do usuário.
- São compartilhados pela experiência do usuário entre tenants.
- Não devem consumir a quota de cortesia do tenant.
- Não devem consumir a utilização semanal/mensal do usuário nos tenants.
- Devem ser rastreados e consumidos independentemente.
- Eventualmente, outros tipos de recursos consumíveis também poderão existir.
Então surgiu a pergunta:
Isso realmente é uma quota maior ou é um recurso diferente?
Essa distinção acabou direcionando boa parte da arquitetura.
A primeira distinção importante: quota não é todo spin disponível
A primeira correção conceitual foi separar capacidade de recursos.
A quota existente do tenant representa uma capacidade de cortesia:
Tenant
└── Quota de cortesia
├── used
├── remaining
└── limitOs subscriptionExtras possuem semântica diferente:
Usuário
└── Subscription extras
├── granted
├── used
├── remaining
├── plan
└── updatedAtO ponto importante é que essas duas estruturas não precisam ser matematicamente combinadas.
Um usuário pode ter:
Cortesia: 3
Subscription extras: 10Isso não significa necessariamente:
Quota total = 13porque esses 13 spins não possuem as mesmas regras de contabilização.
Em vez disso, temos dois recursos:
Cortesia
└── 3 disponíveis
Assinatura
└── 10 disponíveisParece uma distinção pequena, mas ela evita bastante complexidade posteriormente.
Modelando subscriptionExtras
A primeira implementação introduziu um módulo de domínio dedicado.
O recurso é representado assim:
{
granted: 10,
used: 0,
plan: "premium",
updatedAt: "..."
}com remaining sendo derivado de:
remaining = granted - usedA camada de domínio fornece operações para esse recurso.
Por exemplo, consumir um extra é propositalmente simples:
function consumeSubscriptionExtra({
subscriptionExtras,
now = new Date().toISOString(),
}) {
const resolved = resolveSubscriptionExtras({
subscriptionExtras,
});
if (resolved.remaining <= 0) {
throw new Error("SUBSCRIPTION_EXTRAS_EXHAUSTED");
}
return {
granted: resolved.granted,
used: resolved.used + 1,
remaining: resolved.remaining - 1,
plan: resolved.plan,
updatedAt: now,
};
}Existe uma decisão arquitetural importante aqui.
Essa função conhece o recurso.
Ela não conhece:
- Firestore
- HTTP
- Express
- autenticação
- tenants
- Stripe
- endpoint de spin
Ela simplesmente responde:
“Dado este estado do recurso, como ele fica depois de consumir uma unidade?”
Isso torna a operação determinística e fácil de raciocinar e testar.
Os planos de assinatura também eram uma fonte de confusão
Havia outra distinção sutil que inicialmente causou confusão.
O plano do usuário possui um valor global e um tenantMultiplier.
Por exemplo:
const USER_SPIN_PLANS = {
free: {
global: 10,
tenantMultiplier: 0,
},
premium: {
global: 20,
tenantMultiplier: 0.5,
},
"premium+": {
global: 30,
tenantMultiplier: 1,
},
};Seria fácil interpretar global como sendo os próprios subscriptionExtras.
Mas não é isso que ele representa.
A quantidade de extras da assinatura é derivada do plano:
premium
global = 20
tenantMultiplier = 0.5
20 × 0.5 = 10 subscription extrasE:
premium+
30 × 1 = 30 subscription extrasEntão a relação real é:
Plano do usuário
│
├── entitlement global
│
└── multiplicador de tenant
│
▼
subscription extrasEssa distinção importa porque, caso contrário, o sistema de quotas começa a misturar conceitos que não são equivalentes.
O segundo problema: o que um spin realmente consome?
Depois que o recurso existia, surgiu a próxima pergunta:
Como o sistema de spins sabe qual recurso o usuário quer consumir?
O primeiro impulso poderia ser adicionar mais condições em torno da quota existente.
Mas isso ainda deixaria o endpoint de spin tentando adivinhar.
Em vez disso, foi introduzido um vocabulário explícito de SpinSource:
const SPIN_SOURCES = {
COURTESY: "courtesy",
SUBSCRIPTION: "subscription",
PROMO_GIRO: "promoGiro",
};Isso dá ao request de spin um significado semântico explícito:
{
"spinSource": "subscription"
}Agora o backend consegue distinguir:
courtesy
subscription
promoGirosem confundir essa informação com o conceito existente de origem do catálogo.
Essa distinção foi especialmente importante porque source já era utilizado para representar de onde vinha o catálogo de produtos.
São duas perguntas diferentes:
catalogSource
→ De onde vieram esses produtos?
spinSource
→ Qual recurso pagou por este spin?Reutilizar o mesmo campo para as duas coisas criaria ambiguidade.
O backend precisava continuar sendo a autoridade
Introduzir spinSource no frontend não significa que o frontend passa a decidir se um spin é válido.
O frontend pode solicitar:
{
"spinSource": "subscription"
}Mas o backend ainda precisa verificar se o recurso realmente existe e está disponível.
O fluxo do backend passou conceitualmente a ser:
Request
│
▼
Ler spinSource
│
├── courtesy
│ └── validar quota de cortesia
│
├── subscription
│ └── validar subscription extras
│
└── promoGiro
└── validar recurso PromoGiroSomente depois da validação o recurso é consumido.
Isso é importante porque o estado da UI não é uma autoridade.
O navegador pode estar desatualizado.
Uma segunda aba pode possuir outro estado.
Uma requisição pode ser construída manualmente.
Duas requisições podem ocorrer simultaneamente.
Portanto, o backend deve tratar o spinSource solicitado como uma instrução que precisa ser validada, e não como uma prova de que o recurso está disponível.
O primeiro teste de integração revelou o problema real
Essa foi uma das partes mais úteis da implementação.
O frontend já estava enviando:
{
"spinSource": "subscription"
}Mas o backend respondia:
{
"spinSource": "courtesy"
}Inicialmente, isso parecia ser um problema de consumo do recurso.
Não era.
O backend simplesmente não estava lendo o valor.
O endpoint ainda possuía:
let spinSource = SPIN_SOURCES.COURTESY;Então o frontend podia enviar corretamente:
subscriptione o backend silenciosamente substituía por:
courtesyEsse é um bom exemplo de por que testar a própria fronteira de integração é mais valioso do que testar apenas funções individuais.
O helper de domínio podia estar correto.
O frontend podia estar correto.
E a integração ainda assim poderia estar errada.
Corrigindo a fronteira
A solução foi propositalmente pequena.
O backend passou a ler o source solicitado e validá-lo:
const requestedSpinSource = req.body?.spinSource;
if (
requestedSpinSource &&
!Object.values(SPIN_SOURCES).includes(requestedSpinSource)
) {
throw new Error("INVALID_SPIN_SOURCE");
}
const spinSource =
requestedSpinSource || SPIN_SOURCES.COURTESY;Isso fornece três propriedades úteis.
Clientes existentes continuam funcionando
Se um cliente antigo não enviar spinSource, o backend utiliza courtesy como padrão.
Clientes novos podem selecionar explicitamente um recurso
{
"spinSource": "subscription"
}Recursos inválidos são rejeitados
O backend não aceita simplesmente qualquer string arbitrária.
É uma mudança pequena, mas cria a verdadeira fronteira de recursos que estávamos procurando.
O bug na recalculação da quota
O próximo problema apareceu depois do consumo.
O recurso estava sendo corretamente consumido:
updatedSubscriptionExtras = consumeSubscriptionExtra({
subscriptionExtras: userData.subscriptionExtras,
now: nowIso,
});Mas a quota atualizada estava sendo reconstruída incorretamente.
O problema era sutil.
resolveSpinQuota() espera subscriptionExtras como seu próprio argumento:
resolveSpinQuota({
walletData,
userSubscription,
tenantSubscription,
subscriptionExtras,
context,
today,
});A implementação atualizada estava colocando o valor dentro de walletData.
Isso significava que o recurso era atualizado, mas a resposta ainda poderia retornar informações antigas sobre os subscriptionExtras.
A correção foi preservar a separação:
updatedQuota = resolveSpinQuota({
walletData: {
...walletData,
...(spinSource === SPIN_SOURCES.COURTESY && {
spinsToday: newSpinsToday,
lastSpinDate: today,
}),
},
userSubscription: subscription,
tenantSubscription,
subscriptionExtras:
updatedSubscriptionExtras ??
userData.subscriptionExtras,
context,
today,
});Isso acabou reforçando a arquitetura:
walletData
│
└── estado da cortesia
subscriptionExtras
│
└── estado do recurso da assinaturaEles não deveriam ser artificialmente colocados no mesmo objeto apenas porque ambos representam algo que pode ser consumido.
A contabilização dos recursos ficou explícita
Depois que o modelo estava funcionando, as regras de contabilização ficaram muito mais claras.
| Recurso | Quota diária do tenant | Uso semanal/mensal do tenant | Subscription extras | | --- | --- | --- | --- | | Cortesia | Consome | Consome | Não | | Subscription extra | Não consome | Não consome | Consome | | PromoGiro | Não consome | Não consome | Não |
Essa tabela é talvez mais importante do que a própria implementação.
Ela define o significado do sistema.
Por exemplo, um spin de assinatura não deve incrementar:
tenantQuota.spinsTodaynem:
user.tenantUsage.weekCountporque esses contadores representam utilização de cortesia.
Em vez disso, ele incrementa:
subscriptionExtras.usedÉ exatamente por isso que juntar tudo em uma única quota teria sido problemático.
Subindo um nível no frontend
Depois que a fronteira de recursos funcionou de ponta a ponta no backend, o próximo passo foi levar a decisão para a UI.
E foi aqui que outro problema sutil apareceu.
O frontend já possuía um estado:
const [spinSource, setSpinSource] =
useState<SpinSource | null>(null);Seria tentador utilizar esse estado para decidir qual recurso usar no próximo spin.
Mas spinSource representa:
Qual recurso foi usado pelo spin anterior.
Ele não representa:
Qual recurso está disponível para o próximo spin.
São informações fundamentalmente diferentes.
Essa distinção evitou mais uma camada de acoplamento desnecessário.
Os recursos disponíveis devem determinar o próximo source
A UI já possuía os estados necessários:
const tenantRemaining = tenantQuota?.remaining ?? 0;
const subscriptionRemaining =
subscriptionExtrasRemaining ?? 0;Então o próximo source poderia ser derivado diretamente:
const nextSpinSource: SpinSource =
tenantRemaining > 0
? "courtesy"
: subscriptionRemaining > 0
? "subscription"
: "courtesy";O comportamento passa a ser:
Cortesia disponível
│
▼
usar cortesia
Cortesia esgotada
│
▼
Subscription disponível?
│
├── sim → usar subscription
│
└── não → desabilitarO ponto importante é que isso não cria outra abstração de recursos.
É apenas uma decisão de UI baseada em estado que já existe.
Também encontramos um source hardcoded na UI
O trabalho de integração revelou outro problema.
O próprio SpinButton estava chamando:
onClick={() => onSpin("subscription")}Isso significava que um componente de apresentação de baixo nível tinha conhecimento da semântica de assinatura.
Esse tipo de implementação pode funcionar temporariamente, mas começa a ficar problemática conforme a quantidade de tipos de recursos cresce.
O botão não deveria saber sobre:
subscription
courtesy
promoGiroEle deveria saber apenas:
“Quando clicado, execute o callback que recebi.”
Então o componente passa a ser:
<button
onClick={onSpin}
disabled={disabled}
>
{spinning ? "Girando..." : "Girar"}
</button>A decisão passa a pertencer ao nível superior:
SpinInterface
│
├── decide o source
│
▼
SpinButton
│
└── executa o callbackÉ uma separação pequena, mas evita que conceitos de domínio vazem para componentes de apresentação.
Por que não criamos um WalletManager genérico?
Nesse ponto seria muito fácil pensar:
“Agora temos recursos. Vamos criar um
ResourceManager.”
Ou:
WalletService
ResourceManager
ConsumableResource
ResourceRepository
ResourceStrategyIsso poderia parecer arquiteturalmente sofisticado.
Também seria prematuro.
Nós temos apenas alguns conceitos concretos:
Quota de cortesia
Subscription extras
PromoGirosE eles nem sequer possuem exatamente a mesma semântica.
Em vez disso, o sistema utiliza pequenos componentes específicos de domínio:
subscriptionExtras.js
spinSources.js
quota.jsCada um possui uma responsabilidade clara.
Isso nos dá uma propriedade importante:
Podemos introduzir mais abstração quando o domínio demonstrar que precisamos dela, em vez de criar uma abstração para tentar prever um domínio futuro.
Essa é uma diferença importante.
A arquitetura resultante
Depois dessas mudanças, o modelo se parece mais com isto:
USUÁRIO
│
├── Subscription
│
├── Estado de quota / cortesia
│
├── Subscription extras
│ ├── granted
│ ├── used
│ └── remaining
│
└── PromoGirosE o fluxo de spin passa a ser:
Usuário quer girar
│
▼
Frontend determina recurso disponível
│
▼
spinSource
│
▼
Backend valida o recurso
│
├── courtesy
│
├── subscription
│
└── promoGiro
│
▼
Consome o recurso
│
▼
Executa o spin
│
▼
Retorna estado autoritativoO catálogo permanece independente:
catalogSource
│
└── determina o catálogo de produtosenquanto:
spinSource
│
└── determina o recurso consumidoAgora os dois conceitos podem evoluir independentemente.
O que realmente aprendemos
O resultado mais importante desse trabalho não foi o campo subscriptionExtras.
Foi identificar as fronteiras.
1. Uma quota não é necessariamente uma carteira
Uma quota descreve uma capacidade com regras específicas de contabilização.
Um recurso consumível de assinatura pode possuir regras completamente diferentes.
Tentar combiná-los apenas porque ambos são representados por números cria dívida semântica.
2. O estado do recurso deve possuir sua própria lógica de domínio
consumeSubscriptionExtra() não precisa saber nada sobre HTTP ou Firestore.
Isso torna a operação fácil de entender e testar.
3. O backend deve continuar sendo autoritativo
O frontend pode solicitar:
subscriptionmas apenas o backend pode decidir se o recurso realmente pode ser consumido.
4. Estado que descreve o passado não deve decidir o futuro
spinSource nos diz o que aconteceu.
remaining nos diz o que pode acontecer em seguida.
Essa distinção importa.
5. Componentes de apresentação não devem possuir decisões de domínio
Um botão não deveria saber o que é uma assinatura.
Ele deve receber uma ação.
6. Não generalize antes que o domínio exija isso
Agora temos uma fronteira de recursos.
Isso não significa que precisamos de um framework genérico de recursos.
A arquitetura é extensível sem fingir que já conhecemos todos os tipos de recursos futuros.
A próxima extensão
O interessante dessa arquitetura é que o próximo recurso não precisa nos obrigar a redesenhar o sistema de spins.
Os PromoGiros já são conceitualmente separados:
Modo normal
├── Cortesia
└── Subscription
Modo PromoGiro
└── Recurso PromoGiroIsso significa que o trabalho futuro pode se concentrar no próprio recurso, em vez de modificar todos os conceitos de quota existentes.
O mesmo princípio pode eventualmente ser aplicado à capacidade adicional dos tenants:
TENANT
├── Capacidade da assinatura
└── Extendable quotasO ponto importante é que sistemas de pagamento, provedores de assinatura e consumo de recursos não precisam se tornar a mesma responsabilidade.
Um fluxo futuro de pagamento pode ser:
Pagamento
↓
Stripe
↓
Webhook
↓
Aplicação
↓
Conceder recurso
↓
Sistema de spins consome recursoO sistema de spins não precisa saber que o Stripe existiu.
Ele só precisa saber que um recurso está disponível.
Conclusão
O que começou como um pedido para fornecer alguns spins adicionais aos usuários de assinatura acabou expondo uma questão arquitetural mais fundamental:
Afinal, o que exatamente é uma quota de spins?
A resposta acabou sendo:
Nem todo spin faz parte da mesma quota.
Quando isso ficou explícito, a implementação ficou mais simples, e não mais complicada.
Introduzimos um pequeno modelo de domínio para subscriptionExtras, um spinSource explícito, mantivemos o backend como autoridade, separamos a origem do catálogo da origem do recurso e movemos a seleção do recurso para a camada correta do frontend.
Mais importante, resistimos à tentação de criar uma abstração genérica de carteira antes que existisse uma necessidade real para isso.
O resultado não é um framework completo de gerenciamento de recursos.
É algo mais apropriado para o estágio atual do sistema:
uma fronteira clara que dá espaço para a arquitetura evoluir sem forçar o futuro a caber nas abstrações de hoje.