Most SaaS billing is a ladder. Free, then Pro, then Business, each tier a superset of the one below. One column, one comparison, and "can this user do X" is tier >= required.
CogniPrep is not shaped like that, and trying to force it into a ladder produced the two most persistent support questions we had.
Here is what a user can actually own:
export interface AdminAccountEntitlements {
planTier: 'free' | 'premium';
unlockedProviders: string[]; // which assessment vendors' tests they can play
providerTokens: number; // spendable unlocks, 1 token buys 1 provider
assessmentCentreUnlocked: boolean;
interviewCredits: number; // a ledger balance
emailNotificationsEnabled: boolean;
}
Five axes that grant capability, and no two of them imply each other. Someone can own three vendors' test suites and have no feedback reports. Someone else can have Premium and have unlocked nothing to use it on. Both of those are coherent, paid-for, supported states.
The axis that keeps surprising people
The two that look like they should interact are the plan tier and the free play budget:
/**
* Free plays a user gets across ALL of a provider's games before the wall.
*
* The trial is scoped per PROVIDER, not per game: a user can sample a provider's
* whole suite and still hit a single wall that sells that provider's unlock.
* Premium deliberately does NOT lift this budget - Premium buys scores and
* feedback reports, and unlocking a provider is what buys unlimited plays.
*/
export const FREE_PLAY_BUDGET_PER_PROVIDER = 15;
Note the scoping decision before the tier one. The budget is per provider, not per game. If it were per game, a candidate could sample fifteen plays of every test in a suite and never meet a wall that means anything. Per provider, they can explore the whole suite and then hit a single wall that sells exactly the thing they have been exploring.
And Premium does not lift it. That reads as a missed upsell until you say the two products out loud: Premium buys scores and feedback, a provider unlock buys unlimited plays. They are answers to different questions, so bundling one into the other would mean a customer who wanted feedback on one test silently bought unlimited access to twenty four vendors' worth of content.
The signature keeps the ghost of the old model visible:
export function getPlayLimitForGame(
gameId: string,
_tier: PlanTier, // accepted for call-site compatibility, no longer used
unlockedProviders: string[] = []
): number | null {
An underscore prefixed parameter with a comment explaining why it survives is better than a breaking change across every call site, and it documents the decision at the place where someone would otherwise reintroduce it.
Axes need a two dimensional support tool
Once you admit the model is not a ladder, your support tooling has to stop pretending it is. "Upgrade this user" is not an operation that exists here. The real questions are "what has this person actually paid for" and "which single axis is wrong".
Refunds that did not revoke, webhooks that never landed, goodwill unlocks after a support conversation: all of these are adjustments to one axis, and every one of them used to be a hand written SQL statement.
The admin screen that replaced those statements follows two rules, and both are about the fact that the operator is editing someone else's data.
A balance is never written blind. The interview credits axis is a ledger, not a number:
Deliberately not a plain `SET balance = target`. Every other balance mutation
in the app writes an immutable transaction row first, and a support adjustment
that skipped that would leave a balance the ledger cannot explain. The delta is
recorded as a grant when credits are added and as a spend when they are taken
away.
If your product has an auditable balance, the support tool is exactly where that audit trail gets broken, because it is the one writer that did not come from a purchase. It has to go through the same ledger as everything else, or the ledger is no longer a complete explanation of the balance.
Every change writes an audit row, against the user, not the operator.
await logAuditAction({
userId, // the account that was changed
action: 'admin_entitlement_change',
details: { changedBy: actor.email, changedById: actor.id, changes },
});
The row is filed against the account that changed, so it surfaces in that person's own data export alongside their other events. Somebody altering your entitlements is something you are entitled to see.
Three details from the mutation path
Only real differences are written.
function track<T>(column: string, from: T, to: T | undefined, equal = Object.is): void {
if (to === undefined || equal(from, to)) return;
changes.push({ field: column, from, to });
userUpdate[column] = to;
}
undefined means "not in the patch" and an equal value means "not a change", so re-saving an unchanged form is a genuine no-op rather than an audit row that says nothing happened. An audit log full of empty changes is an audit log nobody reads.
The audit field and the database column name are deliberately the same string, so a change recorded in someone's activity log names the column an operator would go looking at.
The row is locked for the whole adjustment.
const [current] = await tx.select().from(usersTable)
.where(eq(usersTable.id, userId)).limit(1).for('update');
The credit ledger reads the current balance and writes a delta against it, so two operators saving at once could otherwise interleave and lose one of the two edits. Read-modify-write on a balance needs the lock, and a support tool is exactly where two people act on the same account within the same minute, because they are both responding to the same ticket.
The change list is collected inside the transaction and handed back out.
const changes = await db.transaction(async (tx): Promise<FieldChange[] | null> => {
// ...
return changes;
});
Not assigned to a variable in the outer scope. A rolled back transaction must not leave a list of changes behind for the audit log to report as though they had happened. It is a two line difference and it is the difference between an audit log and a log of intentions.
Read the axes
The public surface of this model is the pricing page, where the products are sold separately rather than as tiers, and the help page, whose FAQ spells out the one thing people get wrong: unlocking a provider removes the play limit for that provider's tests, and Premium is what shows you scores and feedback. Two purchases, two different capabilities, either order.
If your own entitlements have quietly become more than one dimension, the tell is in your support workflow. Count how many of your fixes are still a hand written UPDATE, and check whether any of them touch a balance that something else is supposed to be able to explain.
Top comments (0)