DEV Community

caderaven6851
caderaven6851

Posted on

Entitlement Reads Against Plan Limits: Capping Subscription Tier Spend Before the Invoice

Use two reads at request time — the subscription record that says which plan tier a tenant is on, and a versioned entitlement document that says what that tier allows — and keep both out of your Node.js source as constants. Hardcoding plan limits is the cheap mistake; it's a config change and a deploy. The expensive one is a metering path that can't attribute spend to a single workload, because that failure survives every refactor and only surfaces when the SaaS invoice arrives and nobody can say which job burned the budget.

The system here is a clinical-documentation platform. Ambient audio arrives from exam rooms, a transcription pipeline turns it into draft notes, and each hospital tenant buys a tier that caps transcription minutes and retained audio per month. Finance has one requirement, and it is narrow: stop a runaway workload before it bills, then prove afterwards which workload it was.

Attribution accuracy is the axis. Everything below is chosen to protect it.

What the bill is actually made of

Three terms, and they are nowhere near the same size.

The metered units come first — transcription minutes, stored audio bytes, egress. Then the metering pipeline itself: one usage event per billable operation, retained long enough to defend an invoice line. Then the entitlement reads, which is the term teams worry about in design review and the one that almost never shows up on the statement.

Do the arithmetic before changing anything. At a sustained 40 requests per second across all tenants, one usage event per billable operation is about 3.5 million events a day. At 400 bytes of serialized event — tenant id, workload id, meter name, quantity, idempotency key, two timestamps — that is roughly 1.4 GB a day of raw events, call it 500 GB a year before indexes, and a composite index on the high-cardinality (tenant, workload) pair is routinely larger than the payloads it points at. Against that, the entitlement reads are a few hundred rows behind a cache with a 5 minute TTL. The dominant term is retained metering state, and it grows with traffic whether or not anyone ever queries it.

The change that moves that term is aggregating at the attribution key instead of at the customer. Fold raw events into (tenant, workload, meter, hour) buckets as they land, keep the rollup forever because it's small, and keep raw events only while the billing period is open plus a dispute window. Hourly rollups for a tenant running 200 workloads cost about 4,800 rows a day — three orders of magnitude below the raw stream — and they are still specific enough to answer the only question that matters during a spend investigation.

Aggregate on the workload, not on the account. An account-level meter tells you the bill is wrong; it can't tell you which pipeline did it.

How should a service read subscription entitlements programmatically instead of hardcoding plan limits?

Model the entitlement as a document, not as a number. It carries the tenant, the plan tier, a monotonically increasing version, an effective_at timestamp, a map of meter to allowance, and a fetch time. The request path reads it from a process-local cache with a short TTL and a stale-while-revalidate path, which is ordinary HTTP caching semantics applied to an internal document rather than anything exotic.

Two properties matter more than the schema. The decision must record the entitlement version it used, and the consumed quantity it compares against must come from the same rollup that produces the invoice — not from a separate counter that happens to be convenient.

from dataclasses import dataclass
from datetime import datetime, timezone

@dataclass(frozen=True)
class Entitlement:
    tenant: str
    tier: str
    version: int
    effective_at: datetime
    limits: dict          # meter name -> allowance for the current period
    fetched_at: datetime

    def fresh(self, now: datetime, ttl_seconds: int = 300) -> bool:
        return (now - self.fetched_at).total_seconds() < ttl_seconds

@dataclass(frozen=True)
class Decision:
    allowed: bool
    reason: str
    tier: str
    entitlement_version: int
    workload: str

def decide(ent: Entitlement, meter: str, consumed: float, requested: float,
           workload: str, now: datetime, fail_closed: bool = True) -> Decision:
    def verdict(ok: bool, why: str) -> Decision:
        return Decision(ok, why, ent.tier, ent.version, workload)

    if ent.effective_at > now:
        return verdict(False, "entitlement_not_yet_effective")
    if not ent.fresh(now) and fail_closed:
        return verdict(False, "entitlement_stale")
    allowance = ent.limits.get(meter)
    if allowance is None:
        return verdict(False, "meter_not_entitled")
    if consumed + requested > allowance:
        return verdict(False, "period_allowance_exhausted")
    return verdict(True, "ok")

now = datetime.now(timezone.utc)
ent = Entitlement(
    tenant="tenant_northshore",
    tier="clinical_pro",
    version=41,
    effective_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
    limits={"transcription_minutes": 120_000, "audio_gb_retained": 500},
    fetched_at=now,
)
print(decide(ent, "transcription_minutes", 118_500, 2_000, "wl_batch_night", now))
Enter fullscreen mode Exit fullscreen mode

fail_closed is a per-meter policy, not a global one. Denying a 90 minute transcription batch because the plan service is unreachable is defensible; denying a note lookup for the same reason is how a documentation tool becomes a patient-safety incident report. Split the meters accordingly and write the rule down where the on-call engineer can find it.

Where the limit lives Attribution accuracy Main failure mode Reasonable when
Constant in application code None; the code has no idea who pays Silent drift after a plan change, fixed only by deploy Prototype, single tenant
Cached entitlement document Good, if the workload id rides every call Stale window after an upgrade or downgrade Most request-path checks
Per-request read from the plan service Highest freshness Plan service availability enters the request path Low-volume, high-value operations
Gateway quota counters Counts calls, not billable quantity Gateway units and invoice units diverge Coarse abuse protection
Provider-side usage meters Matches the invoice by construction Aggregated per account, rarely per workload Reconciliation, not enforcement

Where attribution actually breaks

Retries are the first one, and the dullest. A transcription job that times out at the storage layer and retries without a stable idempotency key produces two usage events for one unit of work, and the entitlement check then denies a tenant who is nowhere near their tier limit. The fix is unglamorous: derive the key from the content hash plus the attempt's logical id, and make the metering write idempotent on it.

Then the async hop. The HTTP request knows the tenant, the queue message often doesn't, and by the time a worker uploads a 900 MB audio object the only identity left is the worker's own service credential — so every byte lands under system and the per-workload cap never fires. Treat credentials as access-control identities and nothing more; the OWASP secrets guidance is explicit that a secret is for authentication, and turning it into a billing key means rotation quietly rewrites your ledger. Carry the tenant and workload ids in the message envelope, validate them at the worker boundary, and reject work that arrives without them rather than defaulting to a house account.

Plan changes are the third, and they are the reason effective_at is in the document at all. A tenant who downgrades at 14:00 UTC has requests before and after that boundary, and evaluating both against the current version rewrites history; the check must resolve the version that was in force at the event timestamp, which means keeping old versions rather than updating a row in place.

The fourth is a unit mismatch. A gateway counting requests and an invoice counting minutes will disagree by exactly the amount your users' audio lengths vary, which is a lot.

The fifth is subtler and I see it most often in teams with good observability practice: metering through the metrics pipeline. OpenTelemetry's data model lets you attach attributes to a counter, so attaching tenant and workload feels natural — but a metric stream per (tenant, workload) pair is a cardinality problem with a delivery model that permits loss, and money needs a ledger with exactly-once semantics at the attribution key. Use metrics to alert that a tenant is at 80% of allowance. Use events to decide what they owe.

What you stop keeping, and what that costs

Raw events survive the open billing period plus 30 days. After that, hourly rollups and the decision log, nothing else.

That is a deliberate loss, and it's worth being honest about what it buys and what it costs. In healthtech the retention argument runs both directions: raw usage events sit next to protected health information, the minimum necessary standard pushes you to stop keeping what you don't need, and a smaller raw window is both cheaper and easier to defend in an audit. The cost lands 60 days later when a tenant disputes a line item. With rollups you can show the hour and the workload; you cannot show the individual request, the retry that caused it, or the object key involved. I'd call that trade-off correct for most billing disputes and wrong for fraud investigations, and I'm not sure there's a general answer — it depends on whether your contract obliges per-request evidence.

Keep the decision log longer than the raw events. It's one row per denial with the tier, entitlement version, meter and reason, it's tiny, and it's the artifact auditors and angry customers both ask for. Denials are rare by construction; if they aren't, your caps are wrong.

The rule, and where it doesn't apply

Read the tier, read the entitlement document, decide against a rollup keyed by workload, and log the version that decided. If a workload can't be named at the point of spend, fix that before touching plan limits, because everything downstream inherits the ambiguity.

The catch is operational weight. Versioned entitlements, an idempotent event stream, a rollup job and a dispute window are four moving parts to run, and for a product with two plans and no per-workload spend cap they're overhead you'll resent. Stick with a constants file and a nightly reconciliation job if the entitlement question is only "which features are on" — feature flags with a decent audit trail answer that adequately, and they don't need a metering pipeline at all. This design isn't a good fit either when your plan limits are enforced upstream by the provider you resell, since duplicating their counters just gives you two numbers that drift.

If you need hard spend caps and per-workload attribution, though, the raw event stream is not optional, and neither is the discipline of never letting a request into it without an owner.

Further reading

Top comments (0)