antiwork/gumroad · error · ActiveRecord::RecordInvalid

Content contains invalid upsell data.

Error message

Content contains invalid upsell data.

What it means

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

Source

Thrown at app/services/save_content_upsells_service.rb:159

      raise_invalid_upsell!(:base, "Content contains invalid upsell data.") unless valid

      discount[amount_key] = amount
      discount
    rescue JSON::ParserError
      raise_invalid_upsell!(:base, "Content contains invalid upsell data.")
    end

    def parse_integer(value)
      return value if value.is_a?(Integer)
      return unless value.is_a?(String) && value.match?(/\A\d+\z/)

      Integer(value, 10)
    end

    def raise_invalid_upsell!(attribute, message = "is invalid")
      upsell = Upsell.new
      upsell.errors.add(attribute, message)
      raise ActiveRecord::RecordInvalid, upsell
    end
end

View on GitHub (pinned to afeacbd394)

Solutions

  1. 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.
  2. Re-insert the upsell card via the editor's upsell picker (it mints valid attrs and ids) instead of hand-editing markup.
  3. Remove nodes referencing deleted products/variants and re-add them against live products.

Example fix

// before: hand-authored node attrs
{ "type": "upsellCard", "attrs": { "productId": "stale-foreign-id", "discount": "{\"type\":\"percent\",\"percents\":\"150\"}" } }

// after: valid attrs (percent within 0-100, this seller's product id)
{ "type": "upsellCard", "attrs": { "productId": product.external_id, "discount": "{\"type\":\"percent\",\"percents\":50}" } }
Defensive patterns

Strategy: validation

Validate before calling

# Validate upsell node attrs before saving content
def valid_upsell_discount?(raw)
  d = raw.is_a?(String) ? (JSON.parse(raw) rescue nil) : raw
  return false unless d.is_a?(Hash) || d.is_a?(ActionController::Parameters)
  case d["type"]
  when "percent" then d["percents"].to_s.match?(/\A\d+\z/) && d["percents"].to_i.between?(0, 100)
  when "fixed"   then d["cents"].to_s.match?(/\A\d+\z/) && d["cents"].to_i.between?(0, 2**31 - 1)
  else false
  end
end

Type guard

# @param node [Hash] a rich-content node
# @return [Boolean] true when the node is a well-formed upsellCard the service can mint
def upsell_node_valid?(node)
  node["type"] == "upsellCard" &&
    node["attrs"].is_a?(Hash) &&
    Link.exists?(id: ObfuscateIds.decrypt(node["attrs"]["productId"]) rescue nil) &&
    (node["attrs"]["discount"].nil? || valid_upsell_discount?(node["attrs"]["discount"]))
end

Try / catch

begin
  SaveContentUpsellsService.new(seller:, content:, old_content:).from_rich_content
rescue ActiveRecord::RecordInvalid => e
  # e.record.errors.full_messages includes "Content contains invalid upsell data."
  return { error: e.record.errors.full_messages }
end

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


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