Docs / Cookbook / Stacking Multiple Credit Packs
MULTIPLE BLOCKS · AUTO-BURN-DOWN

Stacking Multiple Credit Packs

How credit blocks from multiple purchases coexist and burn in deterministic priority order — no merging, no special handling.

Mental Model

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.

Quick Take
New purchases create independent credit blocks — no merging
Burn order: priority DESC → expiry ASC → created ASC
For queued plan activation, compute expiry as existing_block.expires_at + duration client-side
UI tip: fetch blocks sorted by expires_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:

  1. Highest priority first (priority 10 before priority 0)
  2. Soonest expiry first (Apr 18 before May 11, both before “never”)
  3. 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:

BlockAmountPriorityExpiresSource
Free signup bonus3,000 mc (3 looks)0neversignup grant
Weekly pack24,000 mc (24 looks)10Apr 18pack purchase
Monthly pack100,000 mc (100 looks)10May 11pack 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_at passes, 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_at field 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 balance field is the sum of all remaining_amount values across all active blocks. To show per-pack breakdown in your UI, read the blocks array.

Concepts used in this recipe

🤖
Building with an AI agent?
Get this page as markdown: /docs/cookbook/pack-stacking.md · Full index: /llms.txt