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.

ArquiteturaGerenciamento de RecursosBackendFrontendReactNext.jsNode.jsEngenharia de Software
Tempo Estimado de Leitura: 15 min

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:

text
Usuário
 │
 └── Quota do tenant
       │
       ├── spinsToday
       └── utilização do tenant

Isso 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:

text
quota do tenant = spins de cortesia + spins da assinatura

Mas 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:

text
Tenant
└── Quota de cortesia
      ├── used
      ├── remaining
      └── limit

Os subscriptionExtras possuem semântica diferente:

text
Usuário
└── Subscription extras
      ├── granted
      ├── used
      ├── remaining
      ├── plan
      └── updatedAt

O ponto importante é que essas duas estruturas não precisam ser matematicamente combinadas.

Um usuário pode ter:

text
Cortesia:              3
Subscription extras:  10

Isso não significa necessariamente:

text
Quota total = 13

porque esses 13 spins não possuem as mesmas regras de contabilização.

Em vez disso, temos dois recursos:

text
Cortesia
└── 3 disponíveis

Assinatura
└── 10 disponíveis

Parece 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:

js
{
  granted: 10,
  used: 0,
  plan: "premium",
  updatedAt: "..."
}

com remaining sendo derivado de:

text
remaining = granted - used

A camada de domínio fornece operações para esse recurso.

Por exemplo, consumir um extra é propositalmente simples:

js
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:

js
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:

text
premium
global = 20
tenantMultiplier = 0.5

20 × 0.5 = 10 subscription extras

E:

text
premium+
30 × 1 = 30 subscription extras

Então a relação real é:

text
Plano do usuário
   │
   ├── entitlement global
   │
   └── multiplicador de tenant
           │
           ▼
   subscription extras

Essa 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:

js
const SPIN_SOURCES = {
  COURTESY: "courtesy",
  SUBSCRIPTION: "subscription",
  PROMO_GIRO: "promoGiro",
};

Isso dá ao request de spin um significado semântico explícito:

json
{
  "spinSource": "subscription"
}

Agora o backend consegue distinguir:

text
courtesy
subscription
promoGiro

sem 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:

text
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:

json
{
  "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:

text
Request
  │
  ▼
Ler spinSource
  │
  ├── courtesy
  │      └── validar quota de cortesia
  │
  ├── subscription
  │      └── validar subscription extras
  │
  └── promoGiro
         └── validar recurso PromoGiro

Somente 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:

json
{
  "spinSource": "subscription"
}

Mas o backend respondia:

json
{
  "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:

js
let spinSource = SPIN_SOURCES.COURTESY;

Então o frontend podia enviar corretamente:

text
subscription

e o backend silenciosamente substituía por:

text
courtesy

Esse é 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:

js
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

json
{
  "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:

js
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:

js
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:

js
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:

text
walletData
    │
    └── estado da cortesia

subscriptionExtras
    │
    └── estado do recurso da assinatura

Eles 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:

text
tenantQuota.spinsToday

nem:

text
user.tenantUsage.weekCount

porque esses contadores representam utilização de cortesia.

Em vez disso, ele incrementa:

text
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:

tsx
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:

tsx
const tenantRemaining = tenantQuota?.remaining ?? 0;
const subscriptionRemaining =
  subscriptionExtrasRemaining ?? 0;

Então o próximo source poderia ser derivado diretamente:

tsx
const nextSpinSource: SpinSource =
  tenantRemaining > 0
    ? "courtesy"
    : subscriptionRemaining > 0
      ? "subscription"
      : "courtesy";

O comportamento passa a ser:

text
Cortesia disponível
      │
      ▼
  usar cortesia

Cortesia esgotada
      │
      ▼
Subscription disponível?
      │
      ├── sim → usar subscription
      │
      └── não → desabilitar

O 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:

tsx
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:

text
subscription
courtesy
promoGiro

Ele deveria saber apenas:

“Quando clicado, execute o callback que recebi.”

Então o componente passa a ser:

tsx
<button
  onClick={onSpin}
  disabled={disabled}
>
  {spinning ? "Girando..." : "Girar"}
</button>

A decisão passa a pertencer ao nível superior:

text
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:

text
WalletService
ResourceManager
ConsumableResource
ResourceRepository
ResourceStrategy

Isso poderia parecer arquiteturalmente sofisticado.

Também seria prematuro.

Nós temos apenas alguns conceitos concretos:

text
Quota de cortesia
Subscription extras
PromoGiros

E eles nem sequer possuem exatamente a mesma semântica.

Em vez disso, o sistema utiliza pequenos componentes específicos de domínio:

text
subscriptionExtras.js
spinSources.js
quota.js

Cada 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:

text
USUÁRIO
│
├── Subscription
│
├── Estado de quota / cortesia
│
├── Subscription extras
│      ├── granted
│      ├── used
│      └── remaining
│
└── PromoGiros

E o fluxo de spin passa a ser:

text
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 autoritativo

O catálogo permanece independente:

text
catalogSource
     │
     └── determina o catálogo de produtos

enquanto:

text
spinSource
     │
     └── determina o recurso consumido

Agora 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:

text
subscription

mas 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:

text
Modo normal
├── Cortesia
└── Subscription

Modo PromoGiro
└── Recurso PromoGiro

Isso 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:

text
TENANT
├── Capacidade da assinatura
└── Extendable quotas

O 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:

text
Pagamento
   ↓
Stripe
   ↓
Webhook
   ↓
Aplicação
   ↓
Conceder recurso
   ↓
Sistema de spins consome recurso

O 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.