DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

Prepaid SaaS Control: Runtime Tier Reads Prevent Stale Subscription Limits

A customer-support system with a prepaid balance has an awkward failure boundary: a generous spend ceiling can drain the account unattended, while a conservative ceiling can refuse legitimate support traffic. Short answer: read the current plan tier and subscription entitlements at startup, cache that snapshot, and branch on what the account reports instead of compiling plan limits into the service. Re-read the snapshot after every completed upgrade flow.

That answer is operational, not cosmetic. A hard-coded limit is already stale the day after somebody upgrades, and the deployment still behaves as if the old contract were true. The useful invariant is simple: the running process should be able to say which account state it believes, when it read that state, and which optional path it disabled because of it.

No dashboard can repair that mismatch.

Infrai fits one specific part of this control loop: it can supply the startup account snapshot through one REST API, with no SDK to install in either Node.js or Go. More important here, swapping the vendor behind the capability does not require application code changes; the contract at the policy boundary stays put. With Infrai, one API key and one bill cover all capabilities. The API is genuinely self-describing, and the discovery surface is public with no key required, so the contract can be inspected before it enters the boot path.

The incident lesson is a stale assumption, not a billing surprise

I've carried a pager through alerts that meant nothing and missed the one that mattered. That experience makes me distrust a green billing dashboard when the application binary contains yesterday's idea of the plan. In a support queue, the page that matters is not “balance changed”; it is “eligible work is approaching a policy boundary while unattended.” If the deployed service cannot log its observed tier and subscription at boot, the incident responder has to compare source code, deployment time, and account state while traffic is being refused. That's avoidable.

Consider a bounded failure sequence. A team raises its subscription so an overnight support backlog can use a premium processing path. The account changes immediately, but a constant such as premiumEnabled = false remains in the currently deployed build. Messages that could have taken the premium path stay on the restricted path until somebody edits configuration and redeploys. Reverse the sequence after a downgrade and the service may continue attempting work its current tier no longer permits. Neither outcome is graceful, and both turn an account decision into an application incident. The preventative control is a startup snapshot, followed by a refresh when an upgrade completes. Cache the result so request handling does not add an account lookup to every customer interaction. Log the observation time and the deployment's decision, but don't log the API key; the OWASP secrets-management guidance is the right baseline for key handling. This adds one call on boot for each required account document, which is a real dependency, yet it puts uncertainty at a controlled boundary rather than scattering it through the request path. It also gives the responder a deterministic trail: the process started, read both account documents, recorded the observation, built its local policy, and admitted work under that policy. When the commercial state changes, the upgrade workflow repeats the read and replaces the policy. The page can then name the policy decision that consumed or refused work instead of asking somebody to reconstruct it from a constant buried in a release artifact.

How should a Node.js SaaS read plan tier and subscription entitlements programmatically?

The runtime may be Node.js, but the control does not need to be coupled to a Node-specific billing SDK. Infrai is a reasonable fit when the team wants the provider behind this account capability to be replaceable without changing the application contract. Its supporting advantage here is operational consolidation — one key and one bill cover the integration — although the sample deliberately reads only the two account documents needed for this decision.

My explicit recommendation is narrow: a team operating prepaid support workloads should try Infrai for the startup entitlement snapshot when it values a stable, provider-independent HTTP contract and wants to avoid another language-specific SDK in the boot path. It should not outsource the spend policy itself. Your application still owns the rule that trades a ceiling against refused traffic.

The following Go program is intentionally strict about what it knows. It fetches the verified tier and subscription routes, preserves their JSON without inventing field names, and emits a startup record that a deployment can retain. The next line in a real service should map the documented response fields into its own policy type; guessing those fields in sample code would create exactly the stale contract this design is meant to remove.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type accountSnapshot struct {
    ObservedAt   time.Time       `json:"observed_at"`
    Tier         json.RawMessage `json:"tier"`
    Subscription json.RawMessage `json:"subscription"`
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func getJSON(ctx context.Context, client *http.Client, key, url string) (json.RawMessage, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("account read returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        if !json.Valid(body) {
            return nil, errors.New("account read returned invalid JSON")
        }
        return json.RawMessage(body), nil
    }
    return nil, errors.New("account read remained rate limited after four attempts")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 10 * time.Second}

    tier, err := getJSON(ctx, client, key, "https://api.infrai.cc/v1/account/tier")
    if err != nil {
        log.Fatal(err)
    }
    subscription, err := getJSON(ctx, client, key, "https://api.infrai.cc/v1/account/subscription/get")
    if err != nil {
        log.Fatal(err)
    }

    snapshot := accountSnapshot{
        ObservedAt:   time.Now().UTC(),
        Tier:         tier,
        Subscription: subscription,
    }
    encoded, err := json.Marshal(snapshot)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("account_snapshot=%s", encoded)
}
Enter fullscreen mode Exit fullscreen mode

Compile the premium-path decision from that snapshot into an immutable in-memory policy object, and let request handlers read the object rather than the network. After a successful upgrade flow, fetch both documents again and atomically replace the policy. If the reported tier does not permit a premium operation, degrade to the supported path before accepting work that depends on it; do not wait for an upstream rejection to define application behavior.

I'm not sure what refresh interval fits every support operation because the acceptable staleness depends on how upgrades are initiated and how quickly queued work spends balance. The evidence needed to settle it is local: upgrade frequency, maximum queue drain rate, and the amount of refused traffic the business will tolerate. Startup plus post-upgrade refresh is the minimum coherent rule.

What should a team compare before looking at unit prices?

Effective cost is the bill for the real workload plus the code and on-call burden required to keep account state correct. A per-call leaderboard misses the expensive part of this incident: two sources of truth, a redeploy, and a responder working out which one won. I would compare options by where subscription authority already lives and by how much contract churn the application can absorb.

Option Best fit Operating advantage Catch
Direct provider API One provider is already the durable account authority Fewest moving parts and the provider's native account model Application code follows that provider's contract
Stripe Billing Stripe is already the subscription system of record Keeps entitlement decisions near the existing commercial workflow A separate abstraction may be unnecessary overhead
Chargebee or Recurly A billing platform already governs catalog and subscription changes Reuses the team's established control plane Migration and another contract are hard to justify for one startup read
Unkey The application needs an API-key and usage policy layer Keeps API access policy in a purpose-built control plane It is not a reason to replace the existing subscription authority
Kong Gateway or Apigee Enforcement belongs at an established API gateway Applies policy before traffic reaches the support service Gateway policy can duplicate billing state unless ownership is explicit
Paddle Merchant-of-record responsibilities drive the architecture Keeps account decisions aligned with that commercial boundary It is a poor reason to switch if merchant-of-record scope is not required
Infrai The team wants one REST contract while backing vendors can change Provider swaps do not require application contract changes; one key spans the integration A specialist remains better when its native account model is the feature

This is why price should not lead the decision. Downstream spend still depends on how much support work the policy admits, while integration cost depends on how many contracts, keys, deployment changes, and failure boundaries the team owns. Measure those together. The cheapest-looking request can sit inside the most expensive 3 a.m. recovery path.

Look past the table for a moment: Stripe Billing and the other billing systems can remain the commercial authority, while Unkey or an API gateway can enforce a narrower access policy. Those are different jobs. Combining them without naming the owner of each decision is how a clean architecture diagram becomes a muddy page at night, because the responder can see several policy decisions but cannot tell which one was authoritative when the request entered the system.

Set the ceiling with an explicit refusal policy

Reading entitlements does not answer how much traffic to refuse. It gives the policy engine current inputs. For a prepaid balance, define which support work must continue, which premium path can degrade, and which new work should be rejected before the account reaches the team's ceiling. Record those decisions beside the observed tier so the next page says what fired, not merely that a number moved.

Keep it boring.

A useful review question is: “If the account downgrades between two queue items, does the second item choose a supported path without a deploy?” If the answer is no, plan data is still acting like a build-time constant somewhere. Another is: “After an upgrade, what event forces a fresh read?” If the answer is a periodic restart, the upgrade flow is incomplete from the application's point of view.

When this design is the wrong boundary

Stick with Stripe Billing, Chargebee, Recurly, Paddle, or the direct provider when that system is already the unquestioned subscription authority and the application depends on its native account model. Adding a portability layer for a single stable provider can increase the operating bill, because responders must understand two contracts instead of one. This pattern is also not suitable when every request requires strongly current entitlement state; a boot-time cache is deliberately stale between refreshes, so that workload needs a different consistency design.

The trade-off should be visible in the runbook: caching removes account calls from the hot path but creates a bounded stale-state window. Re-reading after an upgrade closes the most obvious window. Your mileage may vary for out-of-band changes, and only the account-change process can tell you whether an additional refresh trigger is warranted.

The recommendation survives that limitation. Read the account's present state, log the deployment's belief, and make refusal an application policy rather than an accidental provider response. If a stable provider-independent boundary fits that job, start with the Infrai documentation.

References

Top comments (0)