Back to engineering

Engineering

A +25GB storage pack is just a quantity line: billing add-ons as Stripe subscription items

The Bookatu engineering team6 min read

How Bookatu turns a photographer's "+25GB storage pack" into a quantity line on the org's existing platform subscription, finds or creates a versioned price, prorates the change, and keeps the in-app storage cap from drifting away from what Stripe actually bills.

The short version

  • A photographer's storefront has private per-client galleries, and storage is metered. Buying a +25GB pack does not start a new subscription. It adds a quantity to a line on the org's existing platform subscription.
  • We keep one versioned Stripe price per pack and find it by a stable lookup_key, creating it only the first time.
  • Buying, changing, or dropping packs maps to create, update, or delete on a single subscription item, with proration so the org pays only for the days it holds the extra space.
  • Stripe is the source of truth for what was billed. A nightly reconciliation job pulls the item quantity back into our in-app cap so the two never drift.
  • Idempotency keys and webhook-driven cap updates stop double-charges and stale limits.

Why an add-on is just a line item

Bookatu is one booking platform with per-industry verticals. The photographer vertical leans on private per-client galleries. The photographer uploads high-res files, and we auto-generate a low-res, EXIF-stripped, watermark-baked preview at 1080px. That preview pipeline plus the originals adds up, so gallery storage is metered and sellable in +25GB packs.

Each org already pays us a platform subscription. The cleanest model for an add-on is not a second subscription and not a one-off charge. It is an extra line on the subscription the org already has. Stripe calls that line a subscription item. A storage pack becomes a subscription item whose quantity is the number of packs. Buy three packs, quantity is 3. The Stripe quantities guide describes exactly this licensed, per-unit billing where the line cost is unit price times quantity.

Find or create a versioned price

We do not create a fresh Stripe Price on every purchase. We keep one Price per pack version and look it up by a stable lookup_key. Versioning the key (v1, v2, and so on) means a price change for new buyers never silently re-prices existing orgs. Stripe's transfer_lookup_key moves the key onto the new price when we cut a version, so the lookup stays a one-liner. Amounts below are illustrative, not a published Bookatu price.

typescript
// One Price per pack version, found by a stable lookup_key.
async function findOrCreatePackPrice(version: string): Promise<Stripe.Price> {
  const lookupKey = `storage_pack_25gb_${version}`;

  const existing = await stripe.prices.list({
    lookup_keys: [lookupKey],
    active: true,
    limit: 1,
  });
  if (existing.data[0]) return existing.data[0];

  // First buyer on this version creates the Price once.
  return stripe.prices.create({
    lookup_key: lookupKey,
    currency: "usd",
    unit_amount: 500,           // example: per 25GB pack, per month
    recurring: { interval: "month" },
    product: packProductId,     // the pack's Stripe product
    transfer_lookup_key: true,  // claim the key from any prior price
  });
}

Create, update, or delete the item

Setting the pack count is one function with three branches. If the org wants packs and has no item yet, create the item. If it has one, update the quantity. If it drops to zero, delete the item. We pass proration_behavior so Stripe bills the difference for the remaining days, and an idempotency key so a retried request never charges twice.

typescript
async function setPackQuantity(orgId: string, packs: number) {
  const sub = await getPlatformSubscription(orgId);
  const price = await findOrCreatePackPrice(currentPackVersion);
  const item = sub.items.data.find(
    (i) => i.price.lookup_key?.startsWith("storage_pack_25gb_"),
  );

  // Same desired state, same key. Retries collapse to one charge.
  const opts = { idempotencyKey: `packs:${orgId}:${sub.id}:${packs}` };
  const proration = { proration_behavior: "create_prorations" as const };

  if (packs === 0) {
    if (item) await stripe.subscriptionItems.del(item.id, proration);
    return;
  }
  if (!item) {
    await stripe.subscriptionItems.create(
      { subscription: sub.id, price: price.id, quantity: packs, ...proration },
      opts,
    );
    return;
  }
  await stripe.subscriptionItems.update(
    item.id,
    { quantity: packs, ...proration },
    opts,
  );
}

One sharp edge from Stripe's change-price guide: if you update a subscription with a new price but do not name the item, Stripe adds a second item instead of replacing the first, leaving both active. We always target the item by id, so a version bump replaces in place rather than stacking a duplicate line.

Proration: what the org actually pays

Storage is bought mid-cycle. Proration is what makes that fair. Stripe's prorations guide explains it with a clean example: change a plan halfway through the period and you get a credit for unused time on the old amount plus a charge for the remaining time on the new amount. For our add-on the same math runs at the item level. Add a pack with fifteen days left in the month and the org is billed for roughly half a pack now, then the full pack on the next invoice.

Negative prorations are not automatically refunded and positive prorations are not immediately billed. That default is a feature: a photographer who tries a pack for a busy weekend gets a credit on the next invoice, not a flurry of tiny charges and refunds.

If you do need to collect immediately, Stripe supports proration_behavior set to always_invoice, which finalizes an invoice on the spot. We keep the default create_prorations because storage packs are small and recurring, and folding the delta into the next invoice is calmer for the tenant.

Keeping the cap and the quantity from drifting

Two numbers describe the same fact. Stripe holds the billed quantity. Our app holds the storage cap that the upload pipeline enforces. If those disagree, either the photographer is paying for space they cannot use, or they are using space they are not paying for. Both are bugs. Stripe is the source of truth for money, so the cap follows the quantity, never the other way around.

We close the gap from two directions:

  • Webhooks, live. On customer.subscription.updated we recompute the cap from the item quantity. As the Hookdeck webhook guide recommends, the handler verifies the signature, dedupes on the event id, and stays small so a slow database write never times out Stripe's delivery.
  • Reconciliation, nightly. A scheduled job re-reads each subscription and rewrites the cap from what Stripe billed. Stripe's own reconciliation writeup frames this as the safety net: even if a webhook is lost or the handler was down, the batch pass catches the drift.
typescript
// Nightly: Stripe's billed quantity wins, the in-app cap follows.
async function reconcileStorageCap(orgId: string) {
  const sub = await getPlatformSubscription(orgId);
  const item = sub.items.data.find(
    (i) => i.price.lookup_key?.startsWith("storage_pack_25gb_"),
  );
  const billedPacks = item?.quantity ?? 0;
  const capGb = (await includedGbForPlan(orgId)) + billedPacks * 25;

  const org = await db.org.get(orgId);
  if (org.storageCapGb !== capGb) {
    await db.org.update(orgId, { storageCapGb: capGb });
    log.warn("storage cap drift corrected", {
      orgId, was: org.storageCapGb, now: capGb, billedPacks,
    });
  }
}

What we learned

  • Model the add-on as a quantity, not a product catalog. One item, one number to move up and down.
  • Version the price by lookup_key so a future price change cannot re-price orgs that already bought.
  • Always target the subscription item by id when replacing a price, or Stripe leaves two live lines on the bill.
  • Pick one source of truth for the billed amount, make the in-app limit derive from it, and run reconciliation so a dropped webhook is an inconvenience rather than a refund ticket.

Sources

  • Stripe Docs: Change the price of existing subscriptions: https://docs.stripe.com/billing/subscriptions/change-price
  • Stripe Docs: Prorations: https://docs.stripe.com/billing/subscriptions/prorations
  • Stripe Docs: Set product or subscription quantities: https://docs.stripe.com/billing/subscriptions/quantities
  • Stripe Dev Blog: Real-time vs batch reconciliation: https://stripe.dev/blog/database-reconciliation-growing-businesses-part-3
  • Hookdeck: Guide to Stripe Webhooks, features and best practices: https://hookdeck.com/webhooks/platforms/guide-to-stripe-webhooks-features-and-best-practices
stripebillingsubscriptionsprorationreconciliationphotographer

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs