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

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.

Appendix: 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)