antiwork/gumroad · error · Purchase::PurchaseInvalid

The bundle's contents have changed. Please refresh the page!

Error message

The bundle's contents have changed. Please refresh the page!

What it means

validate_bundle_products compares params[:bundle_products] against product.bundle_products.alive: every live bundle component must have a submitted row whose product_id (external), variant_id (external, nil allowed for variant-less components) and quantity all match. Any component without a matching row means the bundle was edited after checkout loaded, so the purchase is rejected with 'The bundle's contents have changed.'

Source

Thrown at app/services/purchase/create_service.rb:390

      free_trial_params = params[:perceived_free_trial_duration]
      if product.free_trial_enabled?
        if !free_trial_params.present? || !free_trial_params[:amount].present? || !free_trial_params[:unit].present?
          raise Purchase::PurchaseInvalid, "Invalid free trial information provided. Please try again."
        elsif free_trial_params[:amount].to_i != product.free_trial_duration_amount || free_trial_params[:unit] != product.free_trial_duration_unit
          raise Purchase::PurchaseInvalid, "The product's free trial has changed, please refresh the page!"
        end
      elsif free_trial_params.present?
        raise Purchase::PurchaseInvalid, "Invalid free trial information provided. Please try again."
      end
    end

    def validate_bundle_products
      return unless product.is_bundle?

      product.bundle_products.alive.each do |bundle_product|
        if params[:bundle_products].none? { _1[:product_id] == bundle_product.product.external_id && _1[:variant_id] == bundle_product.variant&.external_id && _1[:quantity].to_i == bundle_product.quantity }
          raise Purchase::PurchaseInvalid, "The bundle's contents have changed. Please refresh the page!"
        end

        validate_bundle_component_inventory(bundle_product)
      end
    end

    # Validated under the bundle's product_inventory lock, before purchase.charge! — refusing here
    # costs the buyer nothing. Only closes the non-concurrent hole (verdict computed then discarded);
    # the multi-lock direct-vs-bundle/bundle-vs-bundle race is gumroad-private#1786 items 2-4.
    def validate_bundle_component_inventory(bundle_product)
      requested_quantity = bundle_product.quantity * purchase.quantity
      component = bundle_product.product.reload

      # A variant can have stock left while its product-wide cap is exhausted (or vice versa),
      # so both must be checked — checking only one lets the other's cap be silently oversold.
      remaining = if bundle_product.variant.present?
        [bundle_product.variant.reload.quantity_left, component.remaining_for_sale_count].compact.min
      else

View on GitHub (pinned to afeacbd394)

Solutions

  1. Refresh the checkout page to reload the bundle's current composition and resubmit.
  2. Integrators: rebuild the bundle_products array from product.bundle_products.alive at submit time rather than caching it.
  3. Handle this raise by restarting checkout - retrying identical params will keep failing.

Example fix

# before: cached bundle contents submitted after the seller edited the bundle
params[:bundle_products] = cached_bundle_rows

# after: rebuild rows from the product's current live composition
params[:bundle_products] = product.bundle_products.alive.map do |bp|
  { product_id: bp.product.external_id, variant_id: bp.variant&.external_id, quantity: bp.quantity }
end
Defensive patterns

Strategy: validation

Validate before calling

if product.is_bundle?
  params[:bundle_products] = product.bundle_products.alive.map do |bp|
    { product_id: bp.product.external_id, variant_id: bp.variant&.external_id, quantity: bp.quantity }
  end
end

Type guard

def bundle_params_current?(params, product)
  product.bundle_products.alive.all? do |bp|
    params[:bundle_products].any? { _1[:product_id] == bp.product.external_id && _1[:variant_id] == bp.variant&.external_id && _1[:quantity].to_i == bp.quantity }
  end
end

Try / catch

begin
  Purchase::CreateService.new(product:, params:, buyer:).perform
rescue Purchase::PurchaseInvalid => e
  # bundle was edited mid-checkout: reload composition and restart checkout
  redirect_to_checkout if e.message == "The bundle's contents have changed. Please refresh the page!"
end

Prevention

When it happens

Trigger: product.is_bundle? and some alive bundle_product has no entry in params[:bundle_products] where _1[:product_id] == bundle_product.product.external_id && _1[:variant_id] == bundle_product.variant&.external_id && _1[:quantity].to_i == bundle_product.quantity.

Common situations: Seller adds/removes bundle components or changes per-bundle quantity while buyers are in checkout; integrations caching the bundle composition; buyer's session holding a bundle snapshot from an earlier edit.

Related errors


AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21). Data as JSON: /api/errors/c011f78a763c979d. Report an issue: GitHub.