{"record":{"id":"3fbe93b0fbe5a53c","repo":"antiwork/gumroad","slug":"the-product-needs-to-be-shippable-to-at-least-one","errorCode":null,"errorMessage":"The product needs to be shippable to at least one destination.","messagePattern":"The product needs to be shippable to at least one destination\\.","errorType":"exception","errorClass":"Link::LinkInvalid","httpStatus":null,"severity":"error","filePath":"app/models/link.rb","lineNumber":958,"sourceCode":"      []\n    end\n  end\n\n  def save_shipping_destinations!(shipping_destinations)\n    shipping_destinations ||= []\n    deduped_destinations = shipping_destinations.uniq { |destination| destination[\"country_code\"] }\n\n    if deduped_destinations.size != shipping_destinations.size\n      errors.add(:base, \"Sorry, shipping destinations have to be unique.\")\n      raise LinkInvalid, \"Sorry, shipping destinations have to be unique.\"\n    end\n\n    remaining_shipping_destinations = self.shipping_destinations.alive.pluck(:id)\n\n    # Cannot empty out shipping destinations for a published physical product\n    if alive? && (shipping_destinations.empty? || shipping_destinations.first == \"\")\n      errors.add(:base, \"The product needs to be shippable to at least one destination.\")\n      raise LinkInvalid, \"The product needs to be shippable to at least one destination.\"\n    end\n\n    shipping_destinations.each do |destination|\n      next if destination.try(:[], \"country_code\").blank?\n\n      shipping_destination = ShippingDestination.find_or_create_by(country_code: destination[\"country_code\"], link_id: id)\n      # TODO: :product_edit_react cleanup\n      one_item_rate_cents = destination[\"one_item_rate_cents\"]\n      multiple_items_rate_cents = destination[\"multiple_items_rate_cents\"]\n      one_item_rate_cents ||= string_to_price_cents(price_currency_type, destination[\"one_item_rate\"])\n      multiple_items_rate_cents ||= string_to_price_cents(price_currency_type, destination[\"multiple_items_rate\"])\n\n      begin\n        shipping_destination.one_item_rate_cents = one_item_rate_cents\n        shipping_destination.multiple_items_rate_cents = multiple_items_rate_cents\n        shipping_destination.deleted_at = nil\n        shipping_destination.is_virtual_country = ShippingDestination::Destinations::VIRTUAL_COUNTRY_CODES.include?(shipping_destination.country_code)\n","sourceCodeStart":940,"sourceCodeEnd":976,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/models/link.rb#L940-L976","documentation":"Raised as Link::LinkInvalid by Link#save_shipping_destinations! when a published (alive?) physical product submits an empty shipping_destinations array (or a single blank string). A published physical product must remain shippable to at least one destination, so clearing all destinations in one save is rejected after the uniqueness check.","triggerScenarios":"Updating a published, alive physical product with shipping_destinations: [] or ['']. Unpublished/deleted products may clear destinations; the guard keys on alive?.","commonSituations":"Seller deletes every destination row in the shipping table and hits save while the product is live; front-end sends an empty array when the last row is removed; converting product content while forgetting the shipping requirement.","solutions":["Keep at least one destination row for published physical products — enforce '≥1 row' in the form before submit.","If the seller truly wants no shipping, unpublish the product first (the guard only applies to alive? products) or convert it to a non-physical product.","Rescue Link::LinkInvalid and surface 'The product needs to be shippable to at least one destination.'"],"exampleFix":"# before\nlink.save_shipping_destinations!([]) # LinkInvalid: published physical product\n\n# after\nif link.alive? && link.is_physical && destinations.blank?\n  return { error: \"The product needs to be shippable to at least one destination.\" }\nend\nlink.save_shipping_destinations!(destinations)","handlingStrategy":"validation","validationCode":"if link.alive? && link.is_physical && destinations.select { |d| d['country_code'].present? }.empty?\n  return { error: 'Published physical products need >=1 destination.' }\nend","typeGuard":null,"tryCatchPattern":"begin\n  link.save_shipping_destinations!(destinations)\nrescue Link::LinkInvalid\n  render json: { errors: link.errors.full_messages }, status: :unprocessable_entity\nend","preventionTips":["Block deletion of the last destination row for published physical products.","Offer unpublish-first flow when the seller wants to strip all destinations.","Remember the guard keys on alive? — unpublished products may clear destinations."],"tags":["links","shipping","physical-products","rails","validation","linkinvalid"],"backgroundTag":"required-attribute-missing","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}