{"record":{"id":"c011f78a763c979d","repo":"antiwork/gumroad","slug":"the-bundle-s-contents-have-changed-please-refresh","errorCode":null,"errorMessage":"The bundle's contents have changed. Please refresh the page!","messagePattern":"The bundle's contents have changed\\. Please refresh the page!","errorType":"exception","errorClass":"Purchase::PurchaseInvalid","httpStatus":null,"severity":"error","filePath":"app/services/purchase/create_service.rb","lineNumber":390,"sourceCode":"\n      free_trial_params = params[:perceived_free_trial_duration]\n      if product.free_trial_enabled?\n        if !free_trial_params.present? || !free_trial_params[:amount].present? || !free_trial_params[:unit].present?\n          raise Purchase::PurchaseInvalid, \"Invalid free trial information provided. Please try again.\"\n        elsif free_trial_params[:amount].to_i != product.free_trial_duration_amount || free_trial_params[:unit] != product.free_trial_duration_unit\n          raise Purchase::PurchaseInvalid, \"The product's free trial has changed, please refresh the page!\"\n        end\n      elsif free_trial_params.present?\n        raise Purchase::PurchaseInvalid, \"Invalid free trial information provided. Please try again.\"\n      end\n    end\n\n    def validate_bundle_products\n      return unless product.is_bundle?\n\n      product.bundle_products.alive.each do |bundle_product|\n        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 }\n          raise Purchase::PurchaseInvalid, \"The bundle's contents have changed. Please refresh the page!\"\n        end\n\n        validate_bundle_component_inventory(bundle_product)\n      end\n    end\n\n    # Validated under the bundle's product_inventory lock, before purchase.charge! — refusing here\n    # costs the buyer nothing. Only closes the non-concurrent hole (verdict computed then discarded);\n    # the multi-lock direct-vs-bundle/bundle-vs-bundle race is gumroad-private#1786 items 2-4.\n    def validate_bundle_component_inventory(bundle_product)\n      requested_quantity = bundle_product.quantity * purchase.quantity\n      component = bundle_product.product.reload\n\n      # A variant can have stock left while its product-wide cap is exhausted (or vice versa),\n      # so both must be checked — checking only one lets the other's cap be silently oversold.\n      remaining = if bundle_product.variant.present?\n        [bundle_product.variant.reload.quantity_left, component.remaining_for_sale_count].compact.min\n      else","sourceCodeStart":372,"sourceCodeEnd":408,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/services/purchase/create_service.rb#L372-L408","documentation":"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.'","triggerScenarios":"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.","commonSituations":"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.","solutions":["Refresh the checkout page to reload the bundle's current composition and resubmit.","Integrators: rebuild the bundle_products array from product.bundle_products.alive at submit time rather than caching it.","Handle this raise by restarting checkout - retrying identical params will keep failing."],"exampleFix":"# before: cached bundle contents submitted after the seller edited the bundle\nparams[:bundle_products] = cached_bundle_rows\n\n# after: rebuild rows from the product's current live composition\nparams[:bundle_products] = product.bundle_products.alive.map do |bp|\n  { product_id: bp.product.external_id, variant_id: bp.variant&.external_id, quantity: bp.quantity }\nend","handlingStrategy":"validation","validationCode":"if product.is_bundle?\n  params[:bundle_products] = product.bundle_products.alive.map do |bp|\n    { product_id: bp.product.external_id, variant_id: bp.variant&.external_id, quantity: bp.quantity }\n  end\nend","typeGuard":"def bundle_params_current?(params, product)\n  product.bundle_products.alive.all? do |bp|\n    params[:bundle_products].any? { _1[:product_id] == bp.product.external_id && _1[:variant_id] == bp.variant&.external_id && _1[:quantity].to_i == bp.quantity }\n  end\nend","tryCatchPattern":"begin\n  Purchase::CreateService.new(product:, params:, buyer:).perform\nrescue Purchase::PurchaseInvalid => e\n  # bundle was edited mid-checkout: reload composition and restart checkout\n  redirect_to_checkout if e.message == \"The bundle's contents have changed. Please refresh the page!\"\nend","preventionTips":["Rebuild the bundle_products array from product.bundle_products.alive at submit time.","Match on all three fields (product, variant, quantity) - a partial match still fails validation."],"tags":["bundle","stale-state","checkout","gumroad"],"backgroundTag":"bundle-content-mismatch","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}