Jake Baker

Per-item voucher discounts with remaining-limit tracking

12 November 2024

A sponsor gives out a voucher code good for 3 uses. An attendee buys 5 tickets and enters the code. How many tickets get the discount?

The answer should be 3. But if you apply the discount as a bulk percentage across all line items, all 5 get discounted. The voucher’s remaining-use limit only matters if you iterate per item and count as you go.

I ran into this building a convention registration platform with Stripe payments. There were six ticket types (3-day passes, 2-day combos, single days), each with category-based pricing. Sponsor vouchers applied a percentage discount but had usage limits tracked in the event management system.

The core of the solution is a voucherUses counter that increments per individual ticket item, not per ticket type:

const orderSummary = computed(() => {
  const promo = activePromo.value
  let voucherUses = 0
  const result = []
  let total = 0
  let discountedTotal = 0

  for (const ticket of selectedTickets.value) {
    const quantity = Number(formData.value[ticket.quantityField]) || 0
    const price = ticket.prices?.find(p => p.name === attendeeCategory)
    if (!price || quantity === 0) continue

    const items = []

    for (let i = 0; i < quantity; i++) {
      let unitAmount = price.unitAmount
      total += unitAmount

      if (promo && voucherUses < promo.remaining) {
        const discountPercent = Number(promo.discount)
        const discountAmount = (unitAmount * discountPercent) / 100
        unitAmount = Math.round(unitAmount - discountAmount)
        voucherUses++
      }

      discountedTotal += unitAmount
      items.push({ unitAmount })
    }

    result.push({ label: ticket.label, items, total: items.reduce((s, i) => s + i.unitAmount, 0) })
  }

  return { items: result, total, discountedTotal }
})

The inner loop runs once per individual ticket. If I’m buying 2 three-day passes and 3 single-day passes, the loop runs 5 times total. The voucherUses counter increments on each discounted item. Once it hits promo.remaining, subsequent items pay full price.

This matters because the alternative — applying the discount at the ticket-type level — would discount all tickets of a given type if any uses remain. A voucher with 1 remaining use applied to 3 three-day passes would discount all 3 if you calculate at the type level. Per-item iteration gets it right: only the first one gets the discount.

The voucher validation itself happens server-side against the event configuration’s field options. The code encodes client, discount percentage, and ID in a structured format:

// Server-side validation
export default defineEventHandler(async (event) => {
  const { code } = await readBody(event)
  const formattedKey = code.replace(/-/g, '_').toLowerCase()
  const formattedLabel = code.replace(/_/g, '-').toUpperCase()

  const [client, discount, id] = formattedLabel.split('-')

  const eventData = await fetchEventConfig(eventId)
  const voucherField = eventData.fields.find(f => f.key === 'voucher_code')
  const voucher = voucherField.options.find(o => o.key === formattedKey)

  if (voucher.limit === null) {
    return { valid: true, remaining: 999, promo: { client, discount, id } }
  }

  return {
    valid: !!voucher.remainingLimit,
    remaining: voucher.remainingLimit,
    promo: { client, discount, id },
  }
})

No separate voucher database. The event management system already tracks field option limits with remaining counts, so the voucher is just another field option with a usage cap. When the remaining limit hits zero, validation returns valid: false and the client shows an error.

The checkout composable then builds Stripe line items from the order summary. Each item becomes its own line item at its final price (discounted or not), and Stripe sees individual charges rather than a bulk discount. This means the Stripe invoice shows the actual amount per ticket, which is what the association’s finance team needed for their records.

The whole flow: validate the code server-side, write the promo into client state, recompute the order summary reactively (it’s a computed), and build Stripe line items from the result. The per-item iteration is about 10 lines. It’s not clever code, but it handles the edge case that bulk discounts silently get wrong.