{"record":{"id":"127cc812af7c1b14","repo":"antiwork/gumroad","slug":"content-contains-invalid-upsell-data","errorCode":null,"errorMessage":"Content contains invalid upsell data.","messagePattern":"Content contains invalid upsell data\\.","errorType":"exception","errorClass":"ActiveRecord::RecordInvalid","httpStatus":null,"severity":"error","filePath":"app/services/save_content_upsells_service.rb","lineNumber":159,"sourceCode":"      raise_invalid_upsell!(:base, \"Content contains invalid upsell data.\") unless valid\n\n      discount[amount_key] = amount\n      discount\n    rescue JSON::ParserError\n      raise_invalid_upsell!(:base, \"Content contains invalid upsell data.\")\n    end\n\n    def parse_integer(value)\n      return value if value.is_a?(Integer)\n      return unless value.is_a?(String) && value.match?(/\\A\\d+\\z/)\n\n      Integer(value, 10)\n    end\n\n    def raise_invalid_upsell!(attribute, message = \"is invalid\")\n      upsell = Upsell.new\n      upsell.errors.add(attribute, message)\n      raise ActiveRecord::RecordInvalid, upsell\n    end\nend\n","sourceCodeStart":141,"sourceCodeEnd":162,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/services/save_content_upsells_service.rb#L141-L162","documentation":"SaveContentUpsellsService mints Upsell records from upsell-card nodes in HTML/rich content. raise_invalid_upsell! attaches the message to a fresh unsaved Upsell's errors and raises ActiveRecord::RecordInvalid — so \"Content contains invalid upsell data.\" surfaces as a RecordInvalid whose record.errors[:base] carries the message. It fires from parse_discount (discount attr not a Hash/Parameters, type neither fixed nor percent, percents outside 0-100, cents negative or above 2**31-1, unparseable JSON) and from create_upsell! (productId/variantId not decrypting to existing records).","triggerScenarios":"Saving product description or profile rich content containing an upsellCard node whose discount attribute is malformed JSON, has a wrong type, an out-of-range amount, or whose productId/variantId fail ObfuscateIds.decrypt or point at missing Link/BaseVariant rows.","commonSituations":"Editor extension or manual HTML edit corrupting the discount attr; stale content referencing a deleted product or variant; frontend writing a string where an object is expected; copy-pasting upsell markup between sellers so the obfuscated ids no longer decrypt.","solutions":["Inspect the submitted content's upsellCard nodes: discount must be JSON like {\"type\":\"percent\",\"percents\":10} or {\"type\":\"fixed\",\"cents\":500}, and productId must be a valid external id of this seller's product.","Re-insert the upsell card via the editor's upsell picker (it mints valid attrs and ids) instead of hand-editing markup.","Remove nodes referencing deleted products/variants and re-add them against live products."],"exampleFix":"// before: hand-authored node attrs\n{ \"type\": \"upsellCard\", \"attrs\": { \"productId\": \"stale-foreign-id\", \"discount\": \"{\\\"type\\\":\\\"percent\\\",\\\"percents\\\":\\\"150\\\"}\" } }\n\n// after: valid attrs (percent within 0-100, this seller's product id)\n{ \"type\": \"upsellCard\", \"attrs\": { \"productId\": product.external_id, \"discount\": \"{\\\"type\\\":\\\"percent\\\",\\\"percents\\\":50}\" } }","handlingStrategy":"validation","validationCode":"# Validate upsell node attrs before saving content\ndef valid_upsell_discount?(raw)\n  d = raw.is_a?(String) ? (JSON.parse(raw) rescue nil) : raw\n  return false unless d.is_a?(Hash) || d.is_a?(ActionController::Parameters)\n  case d[\"type\"]\n  when \"percent\" then d[\"percents\"].to_s.match?(/\\A\\d+\\z/) && d[\"percents\"].to_i.between?(0, 100)\n  when \"fixed\"   then d[\"cents\"].to_s.match?(/\\A\\d+\\z/) && d[\"cents\"].to_i.between?(0, 2**31 - 1)\n  else false\n  end\nend","typeGuard":"# @param node [Hash] a rich-content node\n# @return [Boolean] true when the node is a well-formed upsellCard the service can mint\ndef upsell_node_valid?(node)\n  node[\"type\"] == \"upsellCard\" &&\n    node[\"attrs\"].is_a?(Hash) &&\n    Link.exists?(id: ObfuscateIds.decrypt(node[\"attrs\"][\"productId\"]) rescue nil) &&\n    (node[\"attrs\"][\"discount\"].nil? || valid_upsell_discount?(node[\"attrs\"][\"discount\"]))\nend","tryCatchPattern":"begin\n  SaveContentUpsellsService.new(seller:, content:, old_content:).from_rich_content\nrescue ActiveRecord::RecordInvalid => e\n  # e.record.errors.full_messages includes \"Content contains invalid upsell data.\"\n  return { error: e.record.errors.full_messages }\nend","preventionTips":["Build upsell nodes exclusively through the editor's picker so ids and discount attrs are minted server-side.","Validate discount payloads (type/percent 0-100/cents bounds) in the editor before insert.","Never copy upsell markup between seller accounts — obfuscated product ids do not decrypt cross-account."],"tags":["content","upsell","rich-content","validation","record-invalid"],"backgroundTag":"content-validation-failed","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}