From Quota to Resources: Designing Subscription Extras Without Overengineering the Spin System

How a seemingly simple subscription feature exposed the boundaries between quotas, consumable resources, frontend state, and backend authority.

ArchitectureResource ManagementBackendFrontendQuota SystemsSubscription SystemsReactNext.jsNode.jsSoftware Engineering
Estimated Reading Time: 14 min

There is a particular kind of engineering problem that starts deceptively small.

You have a quota.

Then you need a few more spins.

So you add a field.

Then the field needs to behave differently from the original quota.

Then the frontend needs to know which one to use.

Then the backend needs to know which one was actually consumed.

And suddenly, what looked like:

“Let's give premium users some extra spins.”

has turned into a question about resource modeling.

This was exactly what happened while extending the spin system in PromoBet.

The interesting part wasn't adding subscriptionExtras.

The interesting part was figuring out what subscriptionExtras actually was, where it belonged, and how to introduce it without turning the existing spin system into a generic resource-management framework before we actually needed one.


The Starting Point

The existing system already had a quota concept.

For a tenant experience, a user could have a certain number of courtesy spins available. Those spins were associated with the tenant and participated in the tenant's usage limits.

Conceptually, the system looked roughly like this:

text
User
 │
 └── Tenant quota
       │
       ├── spinsToday
       └── tenant usage

That worked because there was only one meaningful resource.

The problem appeared when subscription plans needed to provide additional spins.

At first glance, it is tempting to simply increase the quota:

text
tenant quota = courtesy spins + subscription spins

But that immediately creates semantic problems.

Subscription spins were supposed to behave differently:

  • They are granted by the user's subscription.
  • They are shared across the user's tenant experience.
  • They should not consume the tenant's courtesy quota.
  • They should not consume the user's weekly/monthly tenant usage.
  • They should be independently tracked and consumed.
  • Eventually, other types of consumable resources could exist as well.

So the question became:

Is this really a larger quota, or is it a different resource?

That distinction ended up driving most of the architecture.


The First Important Distinction: Quota Is Not Every Available Spin

The first conceptual correction was separating capacity from resources.

The existing tenant quota represents a courtesy capacity:

text
Tenant
└── Courtesy quota
      ├── used
      ├── remaining
      └── limit

Subscription extras have different semantics:

text
User
└── Subscription extras
      ├── granted
      ├── used
      ├── remaining
      ├── plan
      └── updatedAt

The important part is that these two structures don't need to be mathematically merged.

A user might have:

text
Courtesy:              3
Subscription extras:  10

That does not necessarily mean:

text
Total quota = 13

because those 13 spins don't have the same accounting rules.

Instead, we have two resources:

text
Courtesy
└── 3 available

Subscription
└── 10 available

This sounds like a small distinction, but it prevents a lot of complexity later.


Modeling subscriptionExtras

The first implementation introduced a dedicated domain module.

The resource is represented as:

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

with remaining derived from:

text
remaining = granted - used

The domain layer provides operations for this resource.

For example, consuming an extra is intentionally simple:

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,
  };
}

There is an important architectural choice here.

This function knows about the resource.

It does not know about:

  • Firestore
  • HTTP requests
  • Express
  • authentication
  • tenants
  • Stripe
  • the spin endpoint

It simply answers:

“Given this resource state, what does it look like after consuming one unit?”

That makes the resource logic deterministic and easy to reason about.


Subscription Plans Were Another Source of Confusion

There was another subtle distinction that initially caused confusion.

The user plan contains a global value and a tenantMultiplier.

For example:

js
const USER_SPIN_PLANS = {
  free: {
    global: 10,
    tenantMultiplier: 0,
  },

  premium: {
    global: 20,
    tenantMultiplier: 0.5,
  },

  "premium+": {
    global: 30,
    tenantMultiplier: 1,
  },
};

It would be easy to interpret global as the subscription extras themselves.

But that isn't what it represents.

The subscription extra entitlement is derived from the plan:

text
premium
global = 20
tenantMultiplier = 0.5

20 × 0.5 = 10 subscription extras

And:

text
premium+
30 × 1 = 30 subscription extras

So the actual relationship is:

text
User plan
   │
   ├── global entitlement
   │
   └── tenant multiplier
           │
           ▼
   subscription extras

This distinction matters because otherwise the quota system starts mixing unrelated concepts.


The Second Problem: What Does a Spin Actually Consume?

Once the resource existed, the next question was:

How does the spin engine know which resource the user wants to consume?

The first instinct could be to add more conditions around the existing quota.

But that would still leave the spin endpoint guessing.

Instead, a SpinSource vocabulary was introduced:

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

This gave the spin request an explicit semantic meaning:

json
{
  "spinSource": "subscription"
}

Now the backend can distinguish:

text
courtesy
subscription
promoGiro

without confusing that information with the catalog source.

That distinction was particularly important because the existing source concept already referred to where the product catalog came from.

Those are two different questions:

text
catalogSource
→ Where did these products come from?

spinSource
→ Which resource paid for this spin?

Reusing one field for both would have created ambiguity.


The Backend Had to Remain Authoritative

Introducing spinSource into the frontend doesn't mean the frontend gets to decide whether a spin is valid.

The frontend can request:

json
{
  "spinSource": "subscription"
}

But the backend still has to verify that the resource exists.

The backend flow became conceptually:

text
Request
  │
  ▼
Read spinSource
  │
  ├── courtesy
  │      └── validate courtesy quota
  │
  ├── subscription
  │      └── validate subscription extras
  │
  └── promoGiro
         └── validate PromoGiro resource

Then, only after validation, the resource is consumed.

This is important because UI state is not authoritative.

A browser can be stale.

A second tab can have different state.

A request can be manually constructed.

Two requests can race.

The backend must therefore treat the requested source as an instruction to validate, not as proof that the resource is available.


The First Integration Test Exposed the Real Problem

This was one of the most useful parts of the implementation.

The frontend was already sending:

json
{
  "spinSource": "subscription"
}

The backend responded:

json
{
  "spinSource": "courtesy"
}

At first, this looked like a resource-consumption problem.

It wasn't.

The backend simply wasn't reading the value.

The spin function still had:

js
let spinSource = SPIN_SOURCES.COURTESY;

So the frontend could correctly send:

text
subscription

and the backend would silently replace it with:

text
courtesy

This is a good example of why testing the seam itself is more valuable than only testing individual functions.

The domain helper could be perfectly correct.

The frontend could be perfectly correct.

And the integration could still be wrong.


Fixing the Seam

The solution was intentionally small.

The backend reads the requested source and validates it:

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;

This gives us three useful properties.

Existing clients still work

If an older client sends no spinSource, the backend defaults to courtesy.

New clients can explicitly select a resource

json
{
  "spinSource": "subscription"
}

Invalid resources are rejected

The backend doesn't blindly trust arbitrary strings.

This is a small change, but it creates the actual resource boundary we were looking for.


The Quota Recalculation Bug

The next issue appeared after consumption.

The resource was correctly consumed:

js
updatedSubscriptionExtras = consumeSubscriptionExtra({
  subscriptionExtras: userData.subscriptionExtras,
  now: nowIso,
});

But the updated quota was being reconstructed incorrectly.

The problem was subtle.

resolveSpinQuota() expects subscriptionExtras as its own argument:

js
resolveSpinQuota({
  walletData,
  userSubscription,
  tenantSubscription,
  subscriptionExtras,
  context,
  today,
});

The updated implementation was accidentally placing it inside walletData.

That meant the resource itself was updated, but the response could still report stale subscription information.

The fix was to preserve the separation:

js
updatedQuota = resolveSpinQuota({
  walletData: {
    ...walletData,

    ...(spinSource === SPIN_SOURCES.COURTESY && {
      spinsToday: newSpinsToday,
      lastSpinDate: today,
    }),
  },

  userSubscription: subscription,
  tenantSubscription,

  subscriptionExtras:
    updatedSubscriptionExtras ??
    userData.subscriptionExtras,

  context,
  today,
});

This ended up reinforcing the architecture:

text
walletData
    │
    └── courtesy state

subscriptionExtras
    │
    └── subscription resource state

They shouldn't be artificially stuffed into the same object just because both represent something that can be consumed.


Resource Accounting Became Explicit

Once the model was working, the accounting rules became much clearer.

| Resource | Tenant daily quota | Weekly/monthly tenant usage | Subscription extras | | --- | --- | --- | --- | | Courtesy | Consumes | Consumes | No | | Subscription | No | No | Consumes | | PromoGiro | No | No | No |

This table is arguably more important than the implementation itself.

It tells us what the system means.

For example, a subscription spin should not increment:

text
tenantQuota.spinsToday

and should not increment:

text
user.tenantUsage.weekCount

because those counters describe courtesy usage.

Instead, it increments:

text
subscriptionExtras.used

This is exactly why merging everything into a single quota would have been problematic.


Moving Up to the Frontend

Once the backend resource seam worked end-to-end, the next step was the UI.

This was where another subtle issue appeared.

The frontend had a spinSource state:

tsx
const [spinSource, setSpinSource] =
  useState<SpinSource | null>(null);

It was tempting to use that state to decide which resource should be used next.

But spinSource represents:

What was used by the previous spin.

It does not represent:

What is available for the next spin.

Those are fundamentally different pieces of information.

This distinction prevented another layer of accidental state coupling.


Available Resources Should Determine the Next Source

The UI already had the actual resource state:

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

So the next source could be derived directly:

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

The behavior becomes:

text
Courtesy available
      │
      ▼
  use courtesy

Courtesy exhausted
      │
      ▼
Subscription available?
      │
      ├── yes → use subscription
      │
      └── no → disabled

The important part is that this is not another resource abstraction.

It's just a UI decision based on already available state.


We Also Found a Hardcoded Source in the UI

The integration work exposed another issue.

The SpinButton itself was calling:

tsx
onClick={() => onSpin("subscription")}

That meant a low-level presentation component had knowledge of subscription semantics.

This is exactly the kind of thing that works temporarily but becomes painful as the number of resource types grows.

The button shouldn't know about:

text
subscription
courtesy
promoGiro

It should only know:

“When clicked, invoke my callback.”

So the component becomes:

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

The decision belongs higher up:

text
SpinInterface
      │
      ├── decides source
      │
      ▼
SpinButton
      │
      └── invokes callback

This is a small separation, but it prevents domain concepts from leaking into presentation components.


Why We Didn't Build a Generic Wallet Manager

At this point it would have been very easy to say:

“We have resources now. Let's create a ResourceManager.”

Or:

text
WalletService
ResourceManager
ConsumableResource
ResourceRepository
ResourceStrategy

That would sound architecturally sophisticated.

It would also have been premature.

We only had a small number of concrete concepts:

text
Courtesy quota
Subscription extras
PromoGiros

And they don't even have identical semantics.

Instead, the system uses small domain-specific pieces:

text
subscriptionExtras.js
spinSources.js
quota.js

Each one has a focused responsibility.

This gives us a useful property:

We can introduce more abstraction when the domain demonstrates that we need it, rather than creating an abstraction to predict a future domain.

That's an important difference.


The Resulting Architecture

After the changes, the model looks more like this:

text
USER
│
├── Subscription
│
├── Courtesy / quota state
│
├── Subscription extras
│      ├── granted
│      ├── used
│      └── remaining
│
└── PromoGiros

And the spin flow becomes:

text
User wants to spin
       │
       ▼
Frontend determines available resource
       │
       ▼
spinSource
       │
       ▼
Backend validates resource
       │
       ├── courtesy
       │
       ├── subscription
       │
       └── PromoGiro
       │
       ▼
Consume resource
       │
       ▼
Execute spin
       │
       ▼
Return authoritative state

The catalog remains independent:

text
catalogSource
     │
     └── determines product catalog

while:

text
spinSource
     │
     └── determines resource consumed

These two concepts can now evolve independently.


What We Actually Learned

The most useful outcome of this work wasn't the subscriptionExtras field.

It was identifying the boundaries.

1. A quota is not necessarily a wallet

A quota describes a capacity with specific accounting rules.

A consumable subscription resource can have completely different rules.

Trying to merge them just because both are represented by numbers creates semantic debt.

2. Resource state should have its own domain logic

consumeSubscriptionExtra() doesn't need to know anything about HTTP or Firestore.

That makes the operation easy to reason about and test.

3. The backend should remain authoritative

The frontend can request:

text
subscription

but only the backend can decide whether the resource can actually be consumed.

4. State describing the past shouldn't decide the future

spinSource tells us what happened.

remaining tells us what can happen next.

That distinction matters.

5. Presentation components shouldn't own domain decisions

A button shouldn't know what a subscription is.

It should receive an action.

6. Don't generalize before the domain requires it

We now have a resource seam.

That doesn't mean we need a generic resource framework.

The architecture is extensible without pretending we already know every future resource type.


The Next Extension

The interesting thing about this architecture is that the next resource doesn't have to force us to redesign the spin engine.

PromoGiros are already conceptually separate:

text
Normal mode
├── Courtesy
└── Subscription

PromoGiro mode
└── PromoGiro resource

That means future work can focus on the resource itself rather than modifying every existing quota concept.

The same principle can eventually apply to tenant-side capacity extensions:

text
TENANT
├── Subscription capacity
└── Extendable quotas

The important part is that payment systems, subscription providers, and resource consumption don't need to become the same concern.

A future payment flow can look like:

text
Payment
   ↓
Stripe
   ↓
Webhook
   ↓
Application
   ↓
Grant resource
   ↓
Spin system consumes resource

The spin engine doesn't need to know that Stripe existed.

It only needs to know that a resource is available.


Conclusion

What started as a request to give subscription users some additional spins ended up exposing a more fundamental architectural question:

What exactly is a spin quota?

The answer turned out to be:

Not every spin is part of the same quota.

Once that became explicit, the implementation became simpler rather than more complicated.

We introduced a small domain model for subscriptionExtras, an explicit spinSource, kept the backend authoritative, separated catalog source from resource source, and moved resource selection into the appropriate frontend layer.

Most importantly, we resisted the temptation to build a generic wallet abstraction before there was a real need for one.

The result is not a finished resource-management framework.

It's something better for the current stage of the system:

a clear boundary that gives the architecture room to evolve without forcing the future into today's abstractions.