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
- 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.
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
- 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.
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
- Cannot update product-level rich content while in per-varian
- Cannot switch to shared content: both product-level and vari
- Cannot switch to shared content: multiple variants have dist
- Cannot switch to per-variant content: the product has no var
- Cannot update variant rich content while the product uses sh
AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21).
Data as JSON: /api/errors/127cc812af7c1b14.
Report an issue: GitHub.