A Clean Way to Build a Credit & Top-Up System for Your SaaS
A design approach for credit-based SaaS that models spend authorization, action pricing, top-ups, and low-balance warnings as one declarative ruleset instead of scattered logic.
If you have ever built a product that runs on credits, an AI image generator, a transcription tool, anything where users buy credits and spend them, you know the balance logic tends to leak everywhere.
But most of that sprawl is not really application logic. Deciding what happens for a given balance and action is policy: the kind that changes often and shouldn't be hardwired into your backend code. And policy is what a rule engine is for. This post puts the whole balance flow, pricing, authorization, and the low-balance warning, into a single Nodora ruleset, then wires it into an app.
Where the line sits
Nodora rules are stateless and pure. A rule takes an input object and returns outputs and signals, with no database or clock of its own. That sounds like a problem for something as stateful as a balance, but it is actually the clean part. It forces a split that you want anyway:
- Your app owns the number. The current balance lives in your database. Reading it, and the atomic write that deducts from it, stay on the app side.
- The rule owns the decision. What an action costs, whether a balance covers it, what counts as "low", and whether to warn the user, all of that is policy that changes often.
So the app hands the rule a snapshot, the current balance and what the user is trying to do, and the rule hands back a verdict.
The spend rule
Here is the rule that authorizes a single spend. It lives in a ruleset called Balance on the Nodora platform:
signal BalanceLow(user_id, remaining, threshold)
// credits per action
const COST_IMAGE = 5
const COST_VIDEO = 50
const COST_TEXT = 1
rule AuthorizeSpend {
balance = fallback(input.balance, 0)
out cost = match input.action {
"image" => COST_IMAGE,
"video" => COST_VIDEO,
"text" => COST_TEXT,
_ => 0,
}
out authorized = balance >= cost
out new_balance = if authorized then balance - cost else balance
threshold = match input.plan {
"free" => 10,
"pro" => 50,
_ => 25,
}
out low_balance = new_balance < threshold
crossed_low = balance >= threshold && new_balance < threshold
emit BalanceLow(input.user_id, new_balance, threshold) when crossed_low
}Read it top to bottom. The cost comes from a match on the action, so your price list is one block of text instead of constants scattered across the codebase. authorized is the real verdict: the balance covers the cost. new_balance is what the balance would be afterward, which the rule checks against the threshold and carries in the BalanceLow signal as the remaining amount. The threshold for "low" depends on the plan, because a free user at 10 credits and a pro user at 10 credits are not the same situation. low_balance is a level flag, true whenever the resulting balance is under the threshold, so it stays accurate for an already-low user even on a spend they cannot afford. The BalanceLow signal is stricter: crossed_low fires it only on the spend that actually carries a user from at-or-above the threshold to under it, so you warn once per descent instead of once per low-balance spend.
The outputs answer the synchronous question your API route is waiting on. The signals are the asynchronous "and now go do something" part.
You can run it to see the verdict and the signal together:
echo '{"user_id":"u1","action":"video","balance":55,"plan":"pro"}' \
| nodora eval -f balance.json -r AuthorizeSpend --stdin{
"outputs": {
"authorized": true,
"cost": 50,
"low_balance": true,
"new_balance": 5
},
"emitted_signals": [{ "name": "BalanceLow", "args": ["u1", 5, 50] }]
}A 50-credit video spend against a balance of 55 is authorized, drops the balance to 5, and trips BalanceLow because it carries the user across the pro threshold of 50, from 55 down to 5.
Wiring it into the app
In this example the rule lives on the Nodora platform and the app talks to it through the client SDK. Set up the client and grab the ruleset once, then import it wherever you need it:
// lib/nodora.ts
import { NodoraClient } from "@nodora/client";
const nodora = new NodoraClient({
apiKey: process.env.NODORA_API_KEY!,
});
export const balance = nodora.ruleset("Balance");The client defaults to a stale-while-revalidate cache, so changing a price
or a threshold in the dashboard reaches your servers within the cache TTL
(60s).
The route handler reads the balance, evaluates, and enforces the result:
// app/api/spend/route.ts
import { balance } from "@/lib/nodora";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const { userId, action } = await req.json();
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) {
return Response.json({ error: "user_not_found" }, { status: 404 });
}
const { outputs } = await balance.evaluate("AuthorizeSpend", {
user_id: userId,
action,
balance: user.credits,
plan: user.plan,
});
if (!outputs.authorized) {
return Response.json({ error: "insufficient_credits" }, { status: 402 });
}
// deduct outputs.cost atomically, then do the work
return Response.json({ ok: true });
}The one thing worth being careful about: the rule's authorized is policy, not a lock. Two requests can both read a balance of 50 and both be told yes. So the deduction has to be its own atomic step, a write that only goes through if the balance still covers the cost, and that guard, not the rule, is what keeps the balance from going negative. The rule decides whether a spend is allowed; your storage layer guarantees it happens at most once. How you express that, a conditional update, a transaction, a row lock, depends on your database and library.
Handling the signal
The rule decided the user crossed the line and emitted BalanceLow. Now something has to act on it. Suppose we want to send a warning email when the balance gets low. The obvious place to do that is right in the spend route, but hardwiring it there drags "how we notify users" back into application code, the same coupling we just pulled pricing and the threshold out of.
So handle the side effect the way you handle the policy: keep it off your code path and let the Nodora platform run it. When the client evaluates a rule, it reports the emitted signals back to the platform, which fires whatever actions you have attached to that signal in the dashboard. The most useful is a Webhook action, a single POST with a JSON body shaped like the emission, to any URL you give it.
You wire it up in the ruleset's Actions tab: pick the BalanceLow signal, choose Webhook, set the URL and any auth headers your receiver needs. From then on every emission becomes a request like this:
{
"signal": "BalanceLow",
"args": ["u1", 5, 50]
}Point that at whatever already owns notifications, a queue, an internal service, an existing automation, and what happens on a low balance becomes a dashboard concern. Swapping the email provider is now a simple task.
If the side effect is cheap and lives in the same codebase, you can skip the round trip and handle the signal directly instead. Register a handler on the ruleset once at startup:
balance.on("BalanceLow", async (uid, remaining, threshold) => {
await sendLowBalanceEmail(uid, remaining, threshold);
});Either way, one caveat remains: under concurrency, two spends that read the same pre-crossing balance can both cross and both emit. If a duplicate matters for your channel, dedupe on something stable like uid plus the day.
Top-ups, while we are here
The other half of a credit product is buying credits, and that is policy too. Bonus tiers, "buy 100, get 15 free", are the same kind of thing that you might want to change for a weekend promo. So they go in a rule next to the spend logic:
signal ToppedUp(user_id, paid, granted, balance)
rule Topup {
paid = fallback(input.amount, 0)
balance = fallback(input.balance, 0)
// larger top-ups get a bigger kicker
bonus = match input.amount {
n when n >= 100 => math::floor(n * 0.15),
n when n >= 50 => math::floor(n * 0.10),
_ => 0,
}
out granted = paid + bonus
out new_balance = balance + paid + bonus
emit ToppedUp(input.user_id, paid, paid + bonus, balance + paid + bonus) when paid > 0
}A 100-credit top-up grants 115; a 20-credit one grants 20. Call it from your Stripe webhook after the payment succeeds, credit outputs.granted, and let the ToppedUp signal carry the event off to your analytics or a receipt email. The whole balance lifecycle, pricing, spending, warning, and refilling, now lives in one ruleset you can read in a single screen, and tune without shipping core application code.