Stacking Multiple Credit Packs
How credit blocks from multiple purchases coexist and burn in deterministic priority order — no merging, no special handling.
Buy a second pack while the first is still active and QuotaStack doesn't merge them — it just keeps two blocks and burns them down in priority + expiry order. For simple cases you write zero extra code. For plan stacking with queued activation, you compute the expiry client-side.
existing_block.expires_at + duration client-sideexpires_at to show "expires next" and "expires after that"Stacking Multiple Credit Packs
When a user buys a new credit pack while an existing one is still active, QuotaStack doesn’t merge them. Each purchase creates an independent credit block. The burn-down engine handles the rest: highest priority first, then soonest expiry, then oldest.
No special handling needed for simple cases. For plan stacking with queued activation, you compute the expiry client-side.
How burn-down ordering works
QuotaStack burns credit blocks in this order:
- Highest priority first (priority 10 before priority 0)
- Soonest expiry first (Apr 18 before May 11, both before “never”)
- Oldest first (created_at tiebreaker)
This means time-limited credits always burn before permanent ones, and credits expiring sooner burn before those expiring later. Users never lose credits unnecessarily.
Example: three stacked blocks
A user has these credit blocks:
| Block | Amount | Priority | Expires | Source |
|---|---|---|---|---|
| Free signup bonus | 3,000 mc (3 looks) | 0 | never | signup grant |
| Weekly pack | 24,000 mc (24 looks) | 10 | Apr 18 | pack purchase |
| Monthly pack | 100,000 mc (100 looks) | 10 | May 11 | pack purchase |
Burn order: Weekly pack (priority 10, soonest expiry) then monthly pack (priority 10, later expiry) then free bonus (priority 0, no expiry).
The free bonus sits at the bottom as a permanent fallback. The weekly pack burns first because it expires soonest. The monthly pack burns next. The user sees a combined balance of 127,000 mc.
Checking the block breakdown
curl -s \
-H "X-API-Key: $API_KEY" \
"https://api.quotastack.io/v1/customers/$CUSTOMER_ID/credits?include_blocks=true"
Response:
{
"customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
"external_customer_id": "user42:companion7",
"balance": 127000,
"reserved_balance": 0,
"effective_balance": 127000,
"blocks": [
{
"id": "blk_free",
"original_amount": 3000,
"remaining_amount": 3000,
"priority": 0,
"expires_at": null,
"metadata": { "source": "signup_grant" }
},
{
"id": "blk_weekly",
"original_amount": 24000,
"remaining_amount": 24000,
"priority": 10,
"expires_at": "2026-04-18T00:00:00Z",
"metadata": { "source": "pack_purchase", "pack": "weekly" }
},
{
"id": "blk_monthly",
"original_amount": 100000,
"remaining_amount": 100000,
"priority": 10,
"expires_at": "2026-05-11T00:00:00Z",
"metadata": { "source": "pack_purchase", "pack": "monthly" }
}
]
}
The blocks array is returned in creation order. The burn-down engine sorts them internally — you don’t need to sort client-side to understand what will be consumed next.
Reading blocks for UI display
To show the user which pack is active and what’s remaining, filter and sort the blocks array:
interface CreditBlock {
id: string;
original_amount: number;
remaining_amount: number;
priority: number;
expires_at: string | null;
metadata: Record<string, string>;
}
function getActivePacks(blocks: CreditBlock[]): CreditBlock[] {
return blocks
.filter(b => b.remaining_amount > 0)
.sort((a, b) => {
// Sort by priority desc, then expiry asc (nulls last), then created order
if (b.priority !== a.priority) return b.priority - a.priority;
if (a.expires_at && b.expires_at) {
return new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime();
}
if (a.expires_at && !b.expires_at) return -1;
if (!a.expires_at && b.expires_at) return 1;
return 0;
});
}
// Usage:
// const packs = getActivePacks(balance.blocks);
// packs[0] is the block currently being consumed
Advanced: plan stacking with queued activation
When a user buys a second plan while the first is still active, you often want the new plan to start after the current one expires — not overlap.
The trick: compute the new plan’s expires_at as max(existing_plan_expiry, now) + duration. QuotaStack doesn’t do this for you — it’s a client-side calculation before calling the grant API.
function computePlanExpiry(
existingBlocks: CreditBlock[],
planDurationMs: number
): string {
// Find the latest expiry among active plan blocks (priority > 0)
const planBlocks = existingBlocks.filter(
b => b.priority > 0 && b.remaining_amount > 0 && b.expires_at
);
let startFrom = Date.now();
if (planBlocks.length > 0) {
const latestExpiry = Math.max(
...planBlocks.map(b => new Date(b.expires_at!).getTime())
);
startFrom = Math.max(latestExpiry, Date.now());
}
return new Date(startFrom + planDurationMs).toISOString();
}
Example flow:
async function buyStackedPlan(
customerID: string,
planType: string,
orderId: string
): Promise<void> {
const plan = PLANS[planType];
// Get current blocks to compute queued expiry
const balanceRes = await fetch(
`${API_BASE}/customers/${customerID}/credits?include_blocks=true`,
{ headers: { "X-API-Key": API_KEY } }
);
const balance = await balanceRes.json();
// Compute expiry that starts after existing plans end
const expiresAt = computePlanExpiry(balance.blocks, plan.durationMs);
// Debit wallet (same as the standard buyPlan flow)
await fetch(`${API_BASE}/usage`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Idempotency-Key": `plan-debit:${planType}:${orderId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: customerID,
billable_metric_key: plan.metricKey,
units: plan.debitUnits,
}),
});
// Grant with queued expiry
await fetch(`${API_BASE}/topup/grant`, {
method: "POST",
headers: {
"X-API-Key": API_KEY,
"Idempotency-Key": `plan-grant:${planType}:${orderId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_customer_id: customerID,
credits: plan.grantCredits,
price_paid: 0,
currency: "mc",
expires_at: expiresAt,
priority: 10,
metadata: { source: "plan_grant", plan_type: planType, order_id: orderId },
}),
});
}
With this approach, buying a weekly plan while a weekly plan is already active gives the user 2 weeks of credits total, with the second week’s block expiring 7 days after the first.
Gotchas
- Each block is independent. Don’t try to merge blocks or “top up” an existing block. QuotaStack doesn’t support partial block modifications — each grant creates a new block. This is by design: it keeps the ledger clean and auditable.
- Burn order is deterministic but not configurable per-block pair. You can’t say “burn block A before block B” if they have the same priority and expiry. The engine uses creation time as the final tiebreaker.
- Expired blocks vanish. When a block’s
expires_atpasses, its remaining credits are zeroed out by the expiry sweep. If the user had 50 remaining credits in an expired weekly pack, those 50 credits are gone — they don’t roll over to the next block. - Don’t compute “stacked” start times in QuotaStack. The
expires_atfield is an absolute timestamp, not a duration. Your backend computes the queued start time and passes the absolute expiry. QuotaStack just stores and enforces it. - The balance endpoint returns the sum. The top-level
balancefield is the sum of allremaining_amountvalues across all active blocks. To show per-pack breakdown in your UI, read theblocksarray.