Changeset c7a211d in Klonkt
- Timestamp:
- 07/21/2026 02:57:49 AM (7 weeks ago)
- Branches:
- main
- Children:
- ec288dc
- Parents:
- 9019a3e
- git-author:
- Robin <roboburr@…> (07/21/2026 02:57:46 AM)
- git-committer:
- Robin <roboburr@…> (07/21/2026 02:57:49 AM)
- Files:
-
- 2 edited
-
src/services/PaidPatreonService.js (modified) (2 diffs)
-
test/paid-patron.test.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/PaidPatreonService.js
r9019a3e rc7a211d 135 135 } 136 136 137 // Pure: pick the membership for the owner's campaignout of a Patreon137 // Pure: pick the owner's-campaign membership out of a Patreon 138 138 // identity?include=memberships.campaign response (JSON:API). Returns 139 139 // { status, cents } or null. 140 // 141 // Memberships returned via a creator's OWN OAuth client are already scoped to 142 // that creator's campaign(s), so in practice there is one. We still prefer an 143 // exact campaign_id match (belt and suspenders for multi-campaign creators), 144 // but fall back to the sole membership when the configured campaign_id doesn't 145 // match: a wrong/typo'd campaign_id in the admin must not lock out real patrons. 140 146 export function pickCampaignMembership(identity, campaignId) { 141 147 const inc = (identity && identity.included) || []; 148 const members = []; 142 149 for (const it of inc) { 143 150 if (it.type !== 'member') continue; 144 151 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data; 145 if (!camp || String(camp.id) !== String(campaignId)) continue;146 152 const a = it.attributes || {}; 147 return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 }; 153 members.push({ 154 status: a.patron_status || null, 155 cents: a.currently_entitled_amount_cents || 0, 156 campaignId: camp ? String(camp.id) : null, 157 }); 148 158 } 149 return null; 159 if (!members.length) return null; 160 const exact = members.find((m) => m.campaignId && String(m.campaignId) === String(campaignId)); 161 const pick = exact || (members.length === 1 ? members[0] : null); 162 return pick ? { status: pick.status, cents: pick.cents } : null; 150 163 } 151 164 … … 173 186 if (!idRes.ok) return null; 174 187 const identity = await idRes.json(); 175 return pickCampaignMembership(identity, c.campaignId); // token goes out of scope, discarded 188 const membership = pickCampaignMembership(identity, c.campaignId); // token goes out of scope, discarded 189 // Diagnostic (no identity stored, only shapes): why did a real supporter get 190 // rejected? Logs the memberships Patreon returned vs the configured campaign. 191 // Nothing here is persisted; it's a one-line server log for the owner. 192 if (!membership || membership.status !== 'active_patron') { 193 const seen = ((identity && identity.included) || []) 194 .filter((it) => it.type === 'member') 195 .map((it) => { 196 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data; 197 const a = it.attributes || {}; 198 return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`; 199 }); 200 console.warn(`[paid] verifyPatron: config campaign=${c.campaignId} → memberships seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`); 201 } 202 return membership; 176 203 } 177 204 -
test/paid-patron.test.js
r9019a3e rc7a211d 27 27 }); 28 28 29 test('pickCampaignMembership finds the right campaign, ignores others', async () => {29 test('pickCampaignMembership: exact campaign match wins', async () => { 30 30 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js'); 31 31 const id = identityWith('42', 'active_patron', 500); … … 33 33 assert.equal(m.status, 'active_patron'); 34 34 assert.equal(m.cents, 500); 35 assert.equal(pickCampaignMembership(id, '999'), null); // different campaign 35 }); 36 37 test('pickCampaignMembership: sole membership is used even if campaign_id is wrong (creator-scoped)', async () => { 38 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js'); 39 const id = identityWith('42', 'active_patron', 500); 40 // A typo'd campaign_id must not lock out a real patron: memberships from the 41 // owner's own client are already their campaign, so fall back to the sole one. 42 const m = pickCampaignMembership(id, '999'); 43 assert.equal(m.status, 'active_patron'); 44 assert.equal(m.cents, 500); 45 }); 46 47 test('pickCampaignMembership: multiple memberships + no match → null (no silent grant)', async () => { 48 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js'); 49 const id = { 50 data: { type: 'user', id: 'u', relationships: { memberships: { data: [{ type: 'member', id: 'm1' }, { type: 'member', id: 'm2' }] } } }, 51 included: [ 52 { type: 'member', id: 'm1', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 300 }, relationships: { campaign: { data: { type: 'campaign', id: '42' } } } }, 53 { type: 'member', id: 'm2', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 800 }, relationships: { campaign: { data: { type: 'campaign', id: '77' } } } }, 54 ], 55 }; 56 assert.equal(pickCampaignMembership(id, '999'), null); // ambiguous, refuse 57 assert.equal(pickCampaignMembership(id, '77').cents, 800); // exact still works 58 }); 59 60 test('pickCampaignMembership: no memberships → null', async () => { 61 const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js'); 62 assert.equal(pickCampaignMembership({ data: {}, included: [] }, '42'), null); 36 63 }); 37 64
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)