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.
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:
User
│
└── Tenant quota
│
├── spinsToday
└── tenant usageThat 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:
tenant quota = courtesy spins + subscription spinsBut 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:
Tenant
└── Courtesy quota
├── used
├── remaining
└── limitSubscription extras have different semantics:
User
└── Subscription extras
├── granted
├── used
├── remaining
├── plan
└── updatedAtThe important part is that these two structures don't need to be mathematically merged.
A user might have:
Courtesy: 3
Subscription extras: 10That does not necessarily mean:
Total quota = 13because those 13 spins don't have the same accounting rules.
Instead, we have two resources:
Courtesy
└── 3 available
Subscription
└── 10 availableThis 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:
{
granted: 10,
used: 0,
plan: "premium",
updatedAt: "..."
}with remaining derived from:
remaining = granted - usedThe domain layer provides operations for this resource.
For example, consuming an extra is intentionally simple:
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:
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:
premium
global = 20
tenantMultiplier = 0.5
20 × 0.5 = 10 subscription extrasAnd:
premium+
30 × 1 = 30 subscription extrasSo the actual relationship is:
User plan
│
├── global entitlement
│
└── tenant multiplier
│
▼
subscription extrasThis 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:
const SPIN_SOURCES = {
COURTESY: "courtesy",
SUBSCRIPTION: "subscription",
PROMO_GIRO: "promoGiro",
};This gave the spin request an explicit semantic meaning:
{
"spinSource": "subscription"
}Now the backend can distinguish:
courtesy
subscription
promoGirowithout 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:
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:
{
"spinSource": "subscription"
}But the backend still has to verify that the resource exists.
The backend flow became conceptually:
Request
│
▼
Read spinSource
│
├── courtesy
│ └── validate courtesy quota
│
├── subscription
│ └── validate subscription extras
│
└── promoGiro
└── validate PromoGiro resourceThen, 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:
{
"spinSource": "subscription"
}The backend responded:
{
"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:
let spinSource = SPIN_SOURCES.COURTESY;So the frontend could correctly send:
subscriptionand the backend would silently replace it with:
courtesyThis 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:
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
{
"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:
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:
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:
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:
walletData
│
└── courtesy state
subscriptionExtras
│
└── subscription resource stateThey 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:
tenantQuota.spinsTodayand should not increment:
user.tenantUsage.weekCountbecause those counters describe courtesy usage.
Instead, it increments:
subscriptionExtras.usedThis 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:
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:
const tenantRemaining = tenantQuota?.remaining ?? 0;
const subscriptionRemaining =
subscriptionExtrasRemaining ?? 0;So the next source could be derived directly:
const nextSpinSource: SpinSource =
tenantRemaining > 0
? "courtesy"
: subscriptionRemaining > 0
? "subscription"
: "courtesy";The behavior becomes:
Courtesy available
│
▼
use courtesy
Courtesy exhausted
│
▼
Subscription available?
│
├── yes → use subscription
│
└── no → disabledThe 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:
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:
subscription
courtesy
promoGiroIt should only know:
“When clicked, invoke my callback.”
So the component becomes:
<button
onClick={onSpin}
disabled={disabled}
>
{spinning ? "Girando..." : "Girar"}
</button>The decision belongs higher up:
SpinInterface
│
├── decides source
│
▼
SpinButton
│
└── invokes callbackThis 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:
WalletService
ResourceManager
ConsumableResource
ResourceRepository
ResourceStrategyThat would sound architecturally sophisticated.
It would also have been premature.
We only had a small number of concrete concepts:
Courtesy quota
Subscription extras
PromoGirosAnd they don't even have identical semantics.
Instead, the system uses small domain-specific pieces:
subscriptionExtras.js
spinSources.js
quota.jsEach 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:
USER
│
├── Subscription
│
├── Courtesy / quota state
│
├── Subscription extras
│ ├── granted
│ ├── used
│ └── remaining
│
└── PromoGirosAnd the spin flow becomes:
User wants to spin
│
▼
Frontend determines available resource
│
▼
spinSource
│
▼
Backend validates resource
│
├── courtesy
│
├── subscription
│
└── PromoGiro
│
▼
Consume resource
│
▼
Execute spin
│
▼
Return authoritative stateThe catalog remains independent:
catalogSource
│
└── determines product catalogwhile:
spinSource
│
└── determines resource consumedThese 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:
subscriptionbut 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:
Normal mode
├── Courtesy
└── Subscription
PromoGiro mode
└── PromoGiro resourceThat 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:
TENANT
├── Subscription capacity
└── Extendable quotasThe 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:
Payment
↓
Stripe
↓
Webhook
↓
Application
↓
Grant resource
↓
Spin system consumes resourceThe 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.