Patreon v2 sync reports "N pledges, 0 users synced" — member email read from wrong resource in extract

Disclaimer: Yes I used Claude to help me fix this issue:

Running Discourse core at commit 5ee8e24 (2026-08-19). The Patreon sync completes without error but syncs zero users:

Patreon sync complete: 63 pledges, 0 users synced

API responses are all status=200, so the requests themselves are fine. Tracing it in the rails console, the members request the plugin builds is correct and does ask for email on the member resource:

fields[member]=full_name,last_charge_date,last_charge_status,currently_entitled_amount_cents,patron_status,email

Calling that endpoint directly with a properly scoped creator token (campaigns.members[email] granted), the email is present on the member object, not on the user object in included. A single-member fetch confirms it:

data.attributes.email        => "patron@example.com"   # present
included[].attributes.email  => (absent)               # user objects carry full_name only

So on the campaign members list, member.attributes.email is populated (46 of my 63 members; the rest have restricted sharing or are free followers), while the included user entries have no email field at all.

The problem is in Patreon::ApiVersion::V2.extract (lib/api_version/v2.rb). It builds the users map from the included user objects:

ruby

(member_data["included"] || []).each do |entry|
  if entry["type"] == "user" && entry["attributes"]["email"].present?
    users[entry["id"]] = entry["attributes"]["email"].downcase
  end
end

Because those user objects never carry email, the map comes out empty and every member fails email matching, hence 0 synced.

Reading email from the member entry instead (keyed by the user id already available in member.relationships.user.data.id, which is the same patron_id used for pledges) fixed it on my install: 0 became 46 users synced. The change I applied:

diff

+            users[patron_id] = attrs["email"].downcase if attrs["email"].present?
             pledges[patron_id] = attrs["currently_entitled_amount_cents"]
             declines[patron_id] = attrs["last_charge_date"] if attrs["last_charge_status"] == "Declined"
           end
-
-          (member_data["included"] || []).each do |entry|
-            if entry["type"] == "user" && entry["attributes"]["email"].present?
-              users[entry["id"]] = entry["attributes"]["email"].downcase
-            end
-          end

I’m not certain removing the included loop entirely is the right call for every case — if some setups do receive email on the user object, you may want to keep it as a fallback rather than replace it. Posting the observed behaviour and the minimal change that worked here in case it’s useful or points at the right fix.