---
title: "Stacking Multiple Credit Packs"
description: "How credit blocks from multiple purchases coexist and burn in deterministic priority order — no merging, no special handling."
order: 2
---

# 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.

**Pattern:** MULTIPLE BLOCKS · AUTO-BURN-DOWN

> **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"

## 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:

| 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

```bash
curl -s \
  -H "X-API-Key: $API_KEY" \
  "https://api.quotastack.io/v1/customers/$CUSTOMER_ID/credits?include_blocks=true"
```

Response:

```json
{
  "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:

```typescript
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.

```typescript
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:

```typescript
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

- [Credits](/docs/concepts/credits)
- [Topups & Wallets](/docs/concepts/topups-and-wallets)
