Repository: ONEARMY/community-platform
Pull request #4153: feat: migrate patreon to supabase
Author: login: mariojsnunes
Created: 2025-03-10T05:15:52Z | Merged: 2025-03-13T05:44:24Z

## PR Checklist

- [x] - Commit [messages are descriptive](https://github.com/ONEARMY/community-platform/blob/master/CONTRIBUTING.md#--commit-style-guide), it will be used in our [Release Notes](https://github.com/ONEARMY/community-platform/releases/)
- [x] - Unit and e2e tests for the changes that have been added (for bug fixes / features)

## PR Type

What kind of change does this PR introduce?

- [x] Feature (adds functionality)


## How does it work?

- It now saves patreon data on supabase in addition to firebase.
  - Still saves in firebase due to map pins.
- Migrated firebase function code to a remix api route and removed the firebase function.
  - Disconnect is also executed server-side in a remix api route.
  - Both endpoints require a logged in user.
- The Profile page PatreonIntegration component gets the patreon data via an API call to /api/patreon (supabase) instead of relying on the firebase profile data.

## Does this PR introduce a DB Schema Change or Migration?

- [x] Yes

## Git Issues

Closes #



Diff:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index c32b8e7828..1d1fd5cfa7 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -363,10 +363,12 @@ jobs:
               --build-secret VITE_QUESTIONS_GUIDELINES_URL="$VITE_QUESTIONS_GUIDELINES_URL" \
               --build-secret VITE_NO_MESSAGING="$VITE_NO_MESSAGING" \
               --build-secret VITE_HIDE_MEMBER_PINS_BY_DEFAULT="$VITE_HIDE_MEMBER_PINS_BY_DEFAULT" \
-              --build-secret TENANT_ID="$TENANT_ID"
+              --build-secret TENANT_ID="$TENANT_ID" \
+              --build-secret PATREON_CLIENT_ID="$PATREON_CLIENT_ID" \
+              --build-secret PATREON_CLIENT_SECRET="$PATREON_CLIENT_SECRET"
       - run:
           name: Set Supabase Secrets
-          command: flyctl -a << parameters.FLY_APP_NAME >> secrets set SUPABASE_API_URL=$SUPABASE_API_URL SUPABASE_KEY=$SUPABASE_KEY SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL RESEND_API_KEY=$RESEND_API_KEY
+          command: flyctl -a << parameters.FLY_APP_NAME >> secrets set SUPABASE_API_URL=$SUPABASE_API_URL SUPABASE_KEY=$SUPABASE_KEY SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL RESEND_API_KEY=$RESEND_API_KEY TENANT_ID=$TENANT_ID PATREON_CLIENT_ID=$PATREON_CLIENT_ID PATREON_CLIENT_SECRET=$PATREON_CLIENT_SECRET
   deploy-supabase:
     docker:
       - image: cimg/node:20.7.0
diff --git a/functions/src/Integrations/index.ts b/functions/src/Integrations/index.ts
index 9766a25b98..c4dcf3f3a5 100644
--- a/functions/src/Integrations/index.ts
+++ b/functions/src/Integrations/index.ts
@@ -1,6 +1,5 @@
 import * as Discord from './firebase-discord'
 import * as Slack from './firebase-slack'
-import * as Patreon from './patreon'
 
 exports.notifyPinAwaitingModeration = Slack.notifyPinAwaitingModeration
 exports.notifyHowtoAwaitingModeration = Slack.notifyHowtoAwaitingModeration
@@ -8,5 +7,3 @@ exports.notifyHowtoAwaitingModeration = Slack.notifyHowtoAwaitingModeration
 exports.notifyPinPublished = Discord.notifyPinPublished
 exports.notifyLibraryItemPublished = Discord.notifyLibraryItemPublished
 exports.notifyResearchUpdatePublished = Discord.notifyResearchUpdatePublished
-
-exports.patreonAuth = Patreon.patreonAuth
diff --git a/functions/src/Integrations/patreon.spec.ts b/functions/src/Integrations/patreon.spec.ts
deleted file mode 100644
index 287a2fdc40..0000000000
--- a/functions/src/Integrations/patreon.spec.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { isSupporter } from './patreon'
-import { PatreonUser } from 'oa-shared/models/user'
-
-jest.mock('../config/config', () => ({
-  CONFIG: {
-    deployment: {
-      site_url: 'https://community.preciousplastic.com',
-    },
-    integrations: {
-      patreon_client_id: '123456789',
-      patreon_client_secret: '123456789',
-    },
-  },
-}))
-
-const mockPatreronUserFactory = (
-  patronStatus: string,
-  tierIds: string[],
-): PatreonUser => ({
-  id: '123456789',
-  attributes: {
-    about: '',
-    created: '',
-    email: '',
-    first_name: '',
-    full_name: '',
-    image_url: '',
-    last_name: '',
-    thumb_url: '',
-    url: '',
-  },
-  link: '',
-  membership: {
-    id: '123456789',
-    attributes: {
-      campaign_lifetime_support_cents: 0,
-      currently_entitled_amount_cents: 0,
-      is_follower: false,
-      last_charge_date: '',
-      last_charge_status: '',
-      lifetime_support_cents: 0,
-      next_charge_date: '',
-      note: '',
-      patron_status: patronStatus,
-      pledge_cadence: '',
-      pledge_relationship_start: '',
-      will_pay_amount_cents: 0,
-    },
-    tiers: tierIds.map((id) => ({
-      id,
-      attributes: {
-        amount_cents: 0,
-        created_at: '',
-        description: '',
-        edited_at: '',
-        image_url: '',
-        patron_count: 0,
-        published: false,
-        published_at: '',
-        title: '',
-        url: '',
-      },
-    })),
-  },
-})
-
-describe('isSupporter', () => {
-  it('should return true for an active supporter of a onearmy tier', () => {
-    const patreonUser = mockPatreronUserFactory('active_patron', ['6369370'])
-    expect(isSupporter(patreonUser)).toBe(true)
-  })
-
-  it('should return true for an active supporter of a platform tier', () => {
-    const patreonUser = mockPatreronUserFactory('active_patron', ['9413328'])
-    expect(isSupporter(patreonUser)).toBe(true)
-  })
-
-  it('should return false for an active supporter of a platform tier on a different site', () => {
-    const patreonUser = mockPatreronUserFactory('active_patron', ['9413287'])
-    expect(isSupporter(patreonUser)).toBe(false)
-  })
-
-  it('should return false for a non-active supporter', () => {
-    const patreonUser = mockPatreronUserFactory('declined_patron', ['3929404'])
-    expect(isSupporter(patreonUser)).toBe(false)
-  })
-})
diff --git a/functions/src/Integrations/patreon.ts b/functions/src/Integrations/patreon.ts
deleted file mode 100644
index 462d3bda26..0000000000
--- a/functions/src/Integrations/patreon.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-import * as functions from 'firebase-functions'
-import { DB_ENDPOINTS } from 'oa-shared/models/db'
-import {
-  IUserDB,
-  PatreonMembershipAttributes,
-  PatreonTierAttributes,
-  PatreonUser,
-  PatreonUserAttributes,
-} from 'oa-shared/models/user'
-import { db } from '../Firebase/firestoreDB'
-import { CONFIG } from '../config/config'
-import { MEMORY_LIMIT_512_MB } from '../consts'
-
-const PATREON_CLIENT_ID = CONFIG.integrations.patreon_client_id
-const PATREON_CLIENT_SECRET = CONFIG.integrations.patreon_client_secret
-const REDIRECT_URI = CONFIG.deployment.site_url + '/patreon'
-
-// See https://www.patreon.com/one_army/membership
-const PP_SUPPORTER_TIER_ID = '9413328'
-const PK_SUPPORTER_TIER_ID = '9413287'
-const FF_SUPPORTER_TIER_ID = '9413329'
-const ONE_ARMY_SUPPORTER_TIER_ID = '3929404'
-const ONE_ARMY_PLUS_SUPPORTER_TIER_ID = '1359808'
-const ONE_ARMY_MAGIC_SUPPORTER_TIER_ID = '6369370'
-
-const isValidTierId = (id: string) => {
-  const oneArmyTierIds = [
-    ONE_ARMY_SUPPORTER_TIER_ID,
-    ONE_ARMY_PLUS_SUPPORTER_TIER_ID,
-    ONE_ARMY_MAGIC_SUPPORTER_TIER_ID,
-  ]
-  if (oneArmyTierIds.includes(id)) return true
-  switch (CONFIG.deployment.site_url) {
-    case 'https://dev.onearmy.world':
-    case 'https://community.preciousplastic.com':
-      return id === PP_SUPPORTER_TIER_ID
-    case 'https://dev.community.projectkamp.com':
-    case 'https://community.projectkamp.com':
-      return id === PK_SUPPORTER_TIER_ID
-    case 'https://dev.community.fixing.fashion':
-    case 'https://community.fixing.fashion':
-      return id === FF_SUPPORTER_TIER_ID
-    default:
-      // Return true for local development.
-      return true
-  }
-}
-
-export const isSupporter = (patreonUser: PatreonUser) => {
-  const supportsValidTier = patreonUser.membership?.tiers
-    .map(({ id }) => id)
-    .some(isValidTierId)
-  return (
-    patreonUser.membership?.attributes.patron_status === 'active_patron' &&
-    supportsValidTier
-  )
-}
-
-const parsePatreonUser = (patreonUser: any): PatreonUser => {
-  // As we do not request the identity.membership scope, we only receive the user's membership to the
-  // One Army Patreon page, not other campaigns they may be part of.
-  const membership =
-    patreonUser.data.relationships.memberships.data.length > 0
-      ? patreonUser.included.find(({ type }) => type === 'member')
-      : undefined
-
-  const tiers = membership?.relationships.currently_entitled_tiers.data
-    .map(({ id }) =>
-      patreonUser.included.find(
-        ({ type, id: includedId }) => type === 'tier' && id === includedId,
-      ),
-    )
-    .map(({ id, attributes }) => ({ id, attributes }))
-
-  const userMembership = membership
-    ? {
-        id: membership.id,
-        attributes: membership.attributes,
-        tiers,
-      }
-    : undefined
-
-  return {
-    id: patreonUser.data.id,
-    attributes: patreonUser.data.attributes,
-    link: patreonUser.links.self,
-    // Only include membership if the user is a member of the One Army Patreon page.
-    ...(userMembership ? { membership: userMembership } : {}),
-  }
-}
-
-/*
- * docs: https://docs.patreon.com/#get-api-oauth2-v2-identity
- * to fetch more user attributes, add them to the include and fields query params
- **/
-const getCurrentPatreonUser = (accessToken: string) => {
-  const userFields: Array<keyof PatreonUserAttributes> = [
-    'about',
-    'created',
-    'email',
-    'first_name',
-    'full_name',
-    'image_url',
-    'last_name',
-    'thumb_url',
-    'url',
-  ]
-
-  const membershipFields: Array<keyof PatreonMembershipAttributes> = [
-    'campaign_lifetime_support_cents',
-    'currently_entitled_amount_cents',
-    'is_follower',
-    'last_charge_date',
-    'last_charge_status',
-    'lifetime_support_cents',
-    'next_charge_date',
-    'note',
-    'patron_status',
-    'pledge_cadence',
-    'pledge_relationship_start',
-    'will_pay_amount_cents',
-  ]
-
-  const tierFields: Array<keyof PatreonTierAttributes> = [
-    'amount_cents',
-    'created_at',
-    'description',
-    'edited_at',
-    'image_url',
-    'patron_count',
-    'published',
-    'published_at',
-    'title',
-    'url',
-  ]
-
-  const url = encodeURI(
-    `https://www.patreon.com/api/oauth2/v2/identity?include=memberships,memberships.currently_entitled_tiers&fields[user]=${userFields.join(
-      ',',
-    )}&fields[member]=${membershipFields.join(
-      ',',
-    )}&fields[tier]=${tierFields.join(',')}`,
-  )
-
-  return fetch(url, {
-    method: 'GET',
-    headers: {
-      Authorization: `Bearer ${accessToken}`,
-    },
-  })
-    .then((res) => res.json())
-    .then((response) => {
-      if (response.errors) {
-        throw Error(response.errors[0].detail)
-      }
-      return response
-    })
-    .catch((err) => {
-      console.log('Error fetching patreon user', err)
-      throw new functions.https.HttpsError(
-        'internal',
-        'Patreon authentication failed.',
-      )
-    })
-}
-
-export const patreonAuth = functions
-  .runWith({ memory: MEMORY_LIMIT_512_MB })
-  .https.onCall(async (data, context) => {
-    if (!context.auth) {
-      throw new functions.https.HttpsError(
-        'failed-precondition',
-        'The function must be called while authenticated.',
-      )
-    }
-    //   Validate user exists before triggering function.
-    const { uid: authId } = context.auth
-    const userSnapshot = await db
-      .collection(DB_ENDPOINTS.users)
-      .where('_authID', '==', authId)
-      .get()
-    const user = userSnapshot.docs[0]?.data() as IUserDB
-    if (!user) {
-      throw new functions.https.HttpsError(
-        'failed-precondition',
-        'User does not exist.',
-      )
-    }
-
-    await fetch(
-      `https://www.patreon.com/api/oauth2/token?code=${data.code}&grant_type=authorization_code&client_id=${PATREON_CLIENT_ID}&client_secret=${PATREON_CLIENT_SECRET}&redirect_uri=${REDIRECT_URI}`,
-      {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/x-www-form-urlencoded',
-        },
-      },
-    )
-      .then((res) => res.json())
-      .then(async ({ access_token, refresh_token, expires_in }) => {
-        const patreonUser = await getCurrentPatreonUser(access_token)
-        const patreonUserParsed = parsePatreonUser(patreonUser)
-        const isSupporterUser = isSupporter(patreonUserParsed)
-        try {
-          await db
-            .collection(DB_ENDPOINTS.users)
-            .doc(user._id)
-            .update({
-              patreon: patreonUserParsed,
-              badges: {
-                supporter: isSupporterUser,
-              },
-            })
-
-          await db
-            .collection(DB_ENDPOINTS.user_integrations)
-            .doc(user._id)
-            .set(
-              {
-                authId,
-                patreon: {
-                  accessToken: access_token,
-                  refreshToken: refresh_token,
-                  expiresAt: expires_in,
-                },
-              },
-              {
-                merge: true,
-              },
-            )
-        } catch (err) {
-          console.log('Error updating patreon user', err)
-          throw new functions.https.HttpsError('internal', 'User update failed')
-        }
-      })
-      .catch((err) => {
-        console.log('Error authenticating patreon user', err)
-        throw new functions.https.HttpsError(
-          'internal',
-          'Patreon authentication failed.',
-        )
-      })
-
-    return { success: true }
-  })
diff --git a/packages/cypress/src/support/seedAccounts.ts b/packages/cypress/src/support/seedAccounts.ts
index 1b1c873ace..3969f7fc8e 100644
--- a/packages/cypress/src/support/seedAccounts.ts
+++ b/packages/cypress/src/support/seedAccounts.ts
@@ -36,6 +36,7 @@ export const seedAccounts = async () => {
       created_at: new Date().toUTCString(),
       display_name: x.username,
       is_verified: true,
+      is_supporter: false,
     }))
 
   return await seedDatabase({ profiles }, tenantId)
diff --git a/shared/models/comment.ts b/shared/models/comment.ts
index c88c7080b3..7f2eec55c7 100644
--- a/shared/models/comment.ts
+++ b/shared/models/comment.ts
@@ -10,6 +10,7 @@ export class DBCommentAuthor {
   readonly photo_url: string
   readonly country: string
   readonly is_verified: boolean
+  readonly is_supporter: boolean
 
   constructor(obj: DBCommentAuthor) {
     Object.assign(this, obj)
@@ -24,6 +25,7 @@ export class CommentAuthor {
   photoUrl: string
   country: string
   isVerified: boolean
+  isSupporter: boolean
 
   constructor(obj: CommentAuthor) {
     Object.assign(this, obj)
@@ -37,6 +39,7 @@ export class CommentAuthor {
       firebaseAuthId: obj.firebase_auth_id,
       photoUrl: obj.photo_url,
       isVerified: obj.is_verified,
+      isSupporter: obj.is_supporter,
       country: obj.country,
     })
   }
diff --git a/shared/models/index.ts b/shared/models/index.ts
index bca764c220..8d28861797 100644
--- a/shared/models/index.ts
+++ b/shared/models/index.ts
@@ -17,3 +17,4 @@ export * from './tags'
 export * from './questions'
 export * from './user'
 export * from './voteUseful'
+export * from './patreonSettings'
diff --git a/shared/models/patreonSettings.ts b/shared/models/patreonSettings.ts
new file mode 100644
index 0000000000..ec90dad87a
--- /dev/null
+++ b/shared/models/patreonSettings.ts
@@ -0,0 +1,12 @@
+export type PatreonTier = {
+  id: string
+  name: string
+  description: string
+}
+
+export type PatreonSettings = {
+  id: string
+  created_at: Date
+  tiers: PatreonTier[]
+  tenant_id: string
+}
diff --git a/src/models/profile.model.ts b/src/models/profile.model.ts
index d3f3664351..e8d0e25f3a 100644
--- a/src/models/profile.model.ts
+++ b/src/models/profile.model.ts
@@ -3,11 +3,71 @@ export class DBProfile {
   username: string
   display_name: string
   is_verified: boolean
+  is_supporter: boolean
   photo_url: string
   country: string
+  patreon: IPatreonUser
   roles: string[] | null
 
   constructor(obj: DBProfile) {
     Object.assign(this, obj)
   }
 }
+
+export interface IPatreonUserAttributes {
+  about: string
+  created: string
+  email: string
+  first_name: string
+  full_name: string
+  image_url: string
+  last_name: string
+  thumb_url: string
+  url: string
+}
+
+export interface IPatreonMembershipAttributes {
+  campaign_lifetime_support_cents: number
+  currently_entitled_amount_cents: number
+  is_follower: boolean
+  last_charge_date: string
+  last_charge_status: string
+  lifetime_support_cents: number
+  next_charge_date: string
+  note: string
+  patron_status: string
+  pledge_cadence: string
+  pledge_relationship_start: string
+  will_pay_amount_cents: number
+}
+
+export interface IPatreonTierAttributes {
+  amount_cents: number
+  created_at: string
+  description: string
+  edited_at: string
+  image_url: string
+  patron_count: number
+  published: boolean
+  published_at: string
+  title: string
+  url: string
+}
+
+interface IPatreonTier {
+  id: string
+  attributes: IPatreonTierAttributes
+}
+
+export interface IPatreonMembership {
+  id: string
+  attributes: IPatreonMembershipAttributes
+  tiers: IPatreonTier[]
+}
+
+export interface IPatreonUser {
+  id: string
+  attributes: IPatreonUserAttributes
+  link: string
+  membership?: IPatreonMembership
+}
diff --git a/src/models/question.model.ts b/src/models/question.model.ts
index 9b609b97f4..d6ffe9bf44 100644
--- a/src/models/question.model.ts
+++ b/src/models/question.model.ts
@@ -13,6 +13,7 @@ export class DBQuestionAuthor {
   readonly photo_url: string
   readonly country: string
   readonly is_verified: boolean
+  readonly is_supporter: boolean
 }
 
 export class QuestionAuthor {
@@ -23,6 +24,7 @@ export class QuestionAuthor {
   photoUrl: string
   country: string
   isVerified: boolean
+  isSupporter: boolean
 
   constructor(obj: QuestionAuthor) {
     Object.assign(this, obj)
@@ -36,6 +38,7 @@ export class QuestionAuthor {
       firebaseAuthId: obj.firebase_auth_id,
       photoUrl: obj.photo_url,
       isVerified: obj.is_verified,
+      isSupporter: obj.is_supporter,
       country: obj.country,
     })
   }
diff --git a/src/pages/Patreon/Patreon.tsx b/src/pages/Patreon/Patreon.tsx
deleted file mode 100644
index cf7898d166..0000000000
--- a/src/pages/Patreon/Patreon.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useEffect, useState } from 'react'
-import { useSearchParams } from '@remix-run/react'
-import { Loader } from 'oa-components'
-import { logger } from 'src/logger'
-import { functions } from 'src/utils/firebase'
-import { Flex, Text } from 'theme-ui'
-
-const Patreon = () => {
-  const [error, setError] = useState<boolean>(false)
-  const [searchParams] = useSearchParams()
-  const patreonCode = searchParams.get('code')
-
-  const triggerAuth = async (patreonCode: string) => {
-    try {
-      const resp = await functions.httpsCallable('integrations-patreonAuth')({
-        code: patreonCode,
-      })
-      if (resp.data?.success) {
-        logger.debug('Successfully authenticated with Patreon')
-        window.location.replace('/settings/account')
-        return
-      }
-      throw new Error(resp.data.error)
-    } catch (error) {
-      logger.debug('Error calling the authentication endpoint', error)
-      setError(true)
-    }
-  }
-
-  useEffect(() => {
-    if (patreonCode) {
-      triggerAuth(patreonCode)
-    }
-  }, [patreonCode])
-
-  return error ? (
-    <Flex
-      sx={{ flexDirection: 'column', maxWidth: '400px', textAlign: 'center' }}
-      mx="auto"
-      mt={15}
-    >
-      <Text>
-        Sorry, we encountered an error integrating your Patreon account. Please
-        try again later!
-      </Text>
-    </Flex>
-  ) : (
-    <Loader />
-  )
-}
-
-export default Patreon
diff --git a/src/pages/UserSettings/SettingsPageAccount.tsx b/src/pages/UserSettings/SettingsPageAccount.tsx
index 100949fea4..205411a557 100644
--- a/src/pages/UserSettings/SettingsPageAccount.tsx
+++ b/src/pages/UserSettings/SettingsPageAccount.tsx
@@ -1,7 +1,5 @@
-import React from 'react'
 import { observer } from 'mobx-react'
 import { ExternalLink } from 'oa-components'
-import { useCommonStores } from 'src/common/hooks/useCommonStores'
 import { DISCORD_INVITE_URL } from 'src/constants'
 import { fields, headings } from 'src/pages/UserSettings/labels'
 import { Flex, Heading, Text } from 'theme-ui'
@@ -13,8 +11,6 @@ import { ChangePasswordForm } from './content/sections/ChangePassword.form'
 export const SettingsPageAccount = observer(() => {
   const { description, title } = fields.deleteAccount
 
-  const { userStore } = useCommonStores().stores
-
   return (
     <Flex
       sx={{
@@ -30,7 +26,7 @@ export const SettingsPageAccount = observer(() => {
         </Text>
       </Flex>
 
-      <PatreonIntegration user={userStore.activeUser} />
+      <PatreonIntegration />
       <ChangePasswordForm />
       <ChangeEmailForm />
 
diff --git a/src/pages/UserSettings/content/fields/PatreonIntegration.test.tsx b/src/pages/UserSettings/content/fields/PatreonIntegration.test.tsx
index 2589630f5d..d2173d2b8b 100644
--- a/src/pages/UserSettings/content/fields/PatreonIntegration.test.tsx
+++ b/src/pages/UserSettings/content/fields/PatreonIntegration.test.tsx
@@ -13,18 +13,25 @@ import {
   UPDATE_BUTTON_TEXT,
 } from './PatreonIntegration'
 
-import type { IUser } from 'oa-shared'
+import type { IPatreonUser } from 'src/models/profile.model'
+import type { Mock } from 'vitest'
+
+// Mock setup first (hoisted to the top by Vitest)
+vi.mock('src/services/patreonService', () => {
+  return {
+    patreonService: {
+      getCurrentUserPatreon: vi.fn(),
+      disconnectUserPatreon: vi.fn(),
+    },
+  }
+})
 
-const mockUser = {
-  userName: 'test',
-} as IUser
+// Import the mocked service *after* vi.mock()
+import { patreonService } from 'src/services/patreonService'
 
 const MOCK_PATREON_TIER_TITLE = 'Patreon Tier Title'
 const mockPatreonSupporter = {
-  ...mockUser,
-  badges: {
-    supporter: true,
-  },
+  isSupporter: true,
   patreon: {
     attributes: {
       thumb_url: 'https://patreon.com',
@@ -40,18 +47,17 @@ const mockPatreonSupporter = {
         },
       ],
     },
-  },
-} as unknown as IUser
+  } as IPatreonUser,
+}
 
 const WRONG_PATREON_TIER_TITLE = 'Wrong Patreon Tier Title'
 const mockPatreonNotSupporter = {
-  ...mockUser,
-  badges: {
-    supporter: false,
-  },
+  isSupporter: false,
   patreon: {
     attributes: {
       thumb_url: 'https://patreon.com',
+    },
+    membership: {
       tiers: [
         {
           id: '456',
@@ -62,28 +68,19 @@ const mockPatreonNotSupporter = {
         },
       ],
     },
-    membership: {
-      tiers: [],
-    },
-  },
-} as unknown as IUser
-
-const mockRemovePatreonConnection = vi.fn()
-
-vi.mock('src/common/hooks/useCommonStores', () => ({
-  useCommonStores: () => ({
-    stores: {
-      userStore: {
-        removePatreonConnection: mockRemovePatreonConnection,
-      },
-    },
-  }),
-}))
+  } as IPatreonUser,
+}
 
 describe('PatreonIntegration', () => {
   describe('not connected', () => {
-    beforeEach(() => {
-      render(<PatreonIntegration user={mockUser} />)
+    beforeEach(async () => {
+      vi.clearAllMocks()
+      // Return null or undefined to simulate not connected
+      ;(patreonService.getCurrentUserPatreon as Mock).mockResolvedValue(null)
+
+      await act(async () => {
+        render(<PatreonIntegration />)
+      })
     })
 
     it('renders correctly', () => {
@@ -92,45 +89,56 @@ describe('PatreonIntegration', () => {
     })
   })
 
-  describe('with supporter', () => {
-    beforeEach(() => {
-      render(<PatreonIntegration user={mockPatreonSupporter} />)
+  describe('not supporter', () => {
+    beforeEach(async () => {
+      vi.clearAllMocks()
+      ;(patreonService.getCurrentUserPatreon as Mock).mockResolvedValue(
+        mockPatreonNotSupporter,
+      )
+
+      await act(async () => {
+        render(<PatreonIntegration />)
+      })
     })
 
     it('displays the connected Patreon account information and buttons', () => {
       expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument()
-      expect(screen.getByText(SUPPORTER_MESSAGE)).toBeInTheDocument()
-      expect(screen.getByText('Patreon Tier Title')).toBeInTheDocument()
       expect(screen.getByText(UPDATE_BUTTON_TEXT)).toBeInTheDocument()
       expect(screen.getByText(REMOVE_BUTTON_TEXT)).toBeInTheDocument()
     })
 
-    it('calls removePatreonConnection when "Remove Connection" button is clicked', () => {
-      act(() => {
-        fireEvent.click(screen.getByText(REMOVE_BUTTON_TEXT))
-      })
-      expect(mockRemovePatreonConnection).toHaveBeenCalledWith(
-        mockUser.userName,
-      )
+    it('does not display supporter message or invalid tier', () => {
+      expect(screen.queryByText(SUPPORTER_MESSAGE)).not.toBeInTheDocument()
+      expect(
+        screen.queryByText(WRONG_PATREON_TIER_TITLE),
+      ).not.toBeInTheDocument()
     })
   })
 
-  describe('not supporter', () => {
-    beforeEach(() => {
-      render(<PatreonIntegration user={mockPatreonNotSupporter} />)
+  describe('with supporter', () => {
+    beforeEach(async () => {
+      vi.clearAllMocks()
+      ;(patreonService.getCurrentUserPatreon as Mock).mockReturnValue(
+        mockPatreonSupporter,
+      )
+      await act(async () => {
+        render(<PatreonIntegration />)
+      })
     })
 
     it('displays the connected Patreon account information and buttons', () => {
       expect(screen.getByText(SUCCESS_MESSAGE)).toBeInTheDocument()
+      expect(screen.getByText(SUPPORTER_MESSAGE)).toBeInTheDocument()
+      expect(screen.getByText('Patreon Tier Title')).toBeInTheDocument()
       expect(screen.getByText(UPDATE_BUTTON_TEXT)).toBeInTheDocument()
       expect(screen.getByText(REMOVE_BUTTON_TEXT)).toBeInTheDocument()
     })
 
-    it('does not display supporter message or invalid tier', () => {
-      expect(screen.queryByText(SUPPORTER_MESSAGE)).not.toBeInTheDocument()
-      expect(
-        screen.queryByText(WRONG_PATREON_TIER_TITLE),
-      ).not.toBeInTheDocument()
+    it('calls removePatreonConnection when "Remove Connection" button is clicked', async () => {
+      await act(async () => {
+        fireEvent.click(screen.getByText(REMOVE_BUTTON_TEXT))
+      })
+      expect(patreonService.disconnectUserPatreon).toHaveBeenCalled()
     })
   })
 })
diff --git a/src/pages/UserSettings/content/fields/PatreonIntegration.tsx b/src/pages/UserSettings/content/fields/PatreonIntegration.tsx
index 40087d485d..03da6d56d5 100644
--- a/src/pages/UserSettings/content/fields/PatreonIntegration.tsx
+++ b/src/pages/UserSettings/content/fields/PatreonIntegration.tsx
@@ -1,9 +1,11 @@
-import * as React from 'react'
+import { useEffect, useState } from 'react'
 import { Button, Icon } from 'oa-components'
-import { useCommonStores } from 'src/common/hooks/useCommonStores'
 import { PATREON_CLIENT_ID } from 'src/config/config'
+import { patreonService } from 'src/services/patreonService'
 import { Flex, Heading, Image, Text } from 'theme-ui'
 
+import type { IPatreonUser } from 'src/models/profile.model'
+
 export const HEADING = 'Patreon'
 export const SUCCESS_MESSAGE = 'Successfully linked Patreon account!'
 export const SUPPORTER_MESSAGE =
@@ -13,21 +15,34 @@ export const CONNECT_BUTTON_TEXT = 'Connect'
 export const UPDATE_BUTTON_TEXT = 'Update'
 export const REMOVE_BUTTON_TEXT = 'Disconnect'
 
-export const PatreonIntegration = ({ user }) => {
-  const { userStore } = useCommonStores().stores
+export const PatreonIntegration = () => {
+  const [patreonUser, setPatreon] = useState<{
+    patreon: IPatreonUser
+    isSupporter: boolean
+  }>()
+
+  useEffect(() => {
+    const fetchPatreonData = async () => {
+      const patreonUser = await patreonService.getCurrentUserPatreon()
 
-  const removePatreonConnection = () => {
-    if (!user) {
-      return
+      if (patreonUser) {
+        setPatreon(patreonUser)
+      }
+    }
+
+    fetchPatreonData()
+  }, [])
+
+  const removePatreonConnection = async () => {
+    const result = await patreonService.disconnectUserPatreon()
+
+    if (result) {
+      setPatreon(undefined)
     }
-    userStore.removePatreonConnection(user.userName)
   }
 
   const patreonRedirect = () => {
-    // TODO: if user has patreon property already set, send request directly to backend, where we
-    // can lookup the existing access code.
-
-    // Otherwise, redirect to patreon to get access code.
+    // Redirect to patreon to get access code.
     const redirectUri = `${window.location.protocol}//${window.location.host}/patreon`
 
     window.location.assign(
@@ -35,10 +50,6 @@ export const PatreonIntegration = ({ user }) => {
     )
   }
 
-  if (!user) {
-    return null
-  }
-
   return (
     <Flex
       sx={{
@@ -57,42 +68,44 @@ export const PatreonIntegration = ({ user }) => {
           <Heading as="h2" variant="small">
             {HEADING}
           </Heading>
-          {user.patreon ? (
+          {patreonUser?.patreon ? (
             <>
               <Text>{SUCCESS_MESSAGE}</Text>
-              {user.badges?.supporter && user.patreon.membership && (
+              {patreonUser?.isSupporter && patreonUser.patreon.membership && (
                 <Flex sx={{ flexDirection: 'column' }}>
                   <Text mt={4}>{SUPPORTER_MESSAGE}</Text>
-                  {user.patreon.membership.tiers.map(({ id, attributes }) => (
-                    <Flex
-                      key={id}
-                      style={{
-                        alignItems: 'center',
-                      }}
-                      mt={4}
-                    >
-                      <div
-                        style={{
-                          width: '40px',
-                          height: '40px',
-                          overflow: 'hidden',
-                          marginRight: '10px',
+                  {patreonUser.patreon.membership.tiers.map(
+                    ({ id, attributes }) => (
+                      <Flex
+                        key={id}
+                        sx={{
+                          alignItems: 'center',
+                          mt: 4,
                         }}
                       >
-                        <Image
-                          src={attributes.image_url}
-                          sx={{
-                            borderRadius: '50%',
-                            width: 'auto',
+                        <div
+                          style={{
+                            width: '40px',
                             height: '40px',
-                            objectFit: 'cover',
-                            objectPosition: 'center',
+                            overflow: 'hidden',
+                            marginRight: '10px',
                           }}
-                        />
-                      </div>
-                      <Text>{attributes.title}</Text>
-                    </Flex>
-                  ))}
+                        >
+                          <Image
+                            src={attributes.image_url}
+                            sx={{
+                              borderRadius: '50%',
+                              width: 'auto',
+                              height: '40px',
+                              objectFit: 'cover',
+                              objectPosition: 'center',
+                            }}
+                          />
+                        </div>
+                        <Text>{attributes.title}</Text>
+                      </Flex>
+                    ),
+                  )}
                 </Flex>
               )}
             </>
@@ -105,11 +118,11 @@ export const PatreonIntegration = ({ user }) => {
         </Flex>
       </Flex>
 
-      <Flex sx={{ flexDirection: 'column', gap: 2 }}>
+      <Flex sx={{ gap: 2 }}>
         <Button type="button" onClick={patreonRedirect} variant="primary">
-          {user.patreon ? UPDATE_BUTTON_TEXT : CONNECT_BUTTON_TEXT}
+          {patreonUser?.patreon ? UPDATE_BUTTON_TEXT : CONNECT_BUTTON_TEXT}
         </Button>
-        {user.patreon && (
+        {patreonUser?.patreon && (
           <Button
             type="button"
             onClick={removePatreonConnection}
diff --git a/src/routes/_.patreon.tsx b/src/routes/_.patreon.tsx
index b2a3892b3c..2931e30e42 100644
--- a/src/routes/_.patreon.tsx
+++ b/src/routes/_.patreon.tsx
@@ -1,16 +1,67 @@
+import { redirect } from '@remix-run/node'
 import Main from 'src/pages/common/Layout/Main'
-import Patreon from 'src/pages/Patreon/Patreon'
-import { SeoTagsUpdateComponent } from 'src/utils/seo'
+import { createSupabaseServerClient } from 'src/repository/supabase.server'
+import { patreonServiceServer } from 'src/services/patreonService.server'
+import { generateTags, mergeMeta } from 'src/utils/seo.utils'
+import { Flex, Text } from 'theme-ui'
+
+import type { LoaderFunctionArgs } from '@remix-run/node'
+
+export async function loader({ request }: LoaderFunctionArgs) {
+  const url = new URL(request.url)
+  const patreonCode = url.searchParams.get('code')
+
+  if (!patreonCode) {
+    return Response.json({ error: 'No code provided' }, { status: 400 })
+  }
+
+  const { client, headers } = createSupabaseServerClient(request)
+
+  try {
+    const {
+      data: { user },
+    } = await client.auth.getUser()
+
+    if (!user) {
+      return redirect('/sign-in', { headers })
+    }
+
+    await patreonServiceServer.verifyAndUpdatePatreonUser(
+      patreonCode,
+      user,
+      client,
+      url.origin,
+    )
+
+    return redirect('/settings/account', { headers })
+  } catch (error) {
+    console.error('Error verifying patreon code', error)
+  }
 
-export async function clientLoader() {
   return null
 }
 
+export const meta = mergeMeta<typeof loader>(() => {
+  return generateTags('Patreon')
+})
+
 export default function Index() {
   return (
-    <Main style={{ flex: 1 }}>
-      <SeoTagsUpdateComponent title="Patreon" />
-      <Patreon />
+    <Main>
+      <Flex
+        sx={{
+          flexDirection: 'column',
+          maxWidth: '400px',
+          textAlign: 'center',
+          mx: 'auto',
+          mt: 15,
+        }}
+      >
+        <Text>
+          Sorry, we encountered an error integrating your Patreon account.
+          Please try again later!
+        </Text>
+      </Flex>
     </Main>
   )
 }
diff --git a/src/routes/api.discussions.$sourceId.comments.ts b/src/routes/api.discussions.$sourceId.comments.ts
index bfea2e9246..dc8512e732 100644
--- a/src/routes/api.discussions.$sourceId.comments.ts
+++ b/src/routes/api.discussions.$sourceId.comments.ts
@@ -35,7 +35,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
       source_type,
       parent_id,
       created_by,
-      profiles(id, firebase_auth_id, display_name, username, is_verified, photo_url, country)
+      profiles(id, firebase_auth_id, display_name, username, is_verified, is_supporter, photo_url, country)
     `,
     )
     .eq(sourceParam, sourceId)
@@ -143,7 +143,7 @@ export async function action({ params, request }: LoaderFunctionArgs) {
       parent_id,
       source_type,
       created_by,
-      profiles(id, firebase_auth_id, display_name, username, is_verified, photo_url, country)
+      profiles(id, firebase_auth_id, display_name, username, is_verified, is_supporter, photo_url, country)
     `,
     )
     .single()
diff --git a/src/routes/api.patreon.ts b/src/routes/api.patreon.ts
new file mode 100644
index 0000000000..4c4f42d712
--- /dev/null
+++ b/src/routes/api.patreon.ts
@@ -0,0 +1,50 @@
+import { createSupabaseServerClient } from 'src/repository/supabase.server'
+
+import { patreonServiceServer } from '../services/patreonService.server'
+
+export const loader = async ({ request }) => {
+  const { client, headers } = createSupabaseServerClient(request)
+
+  const {
+    data: { user },
+  } = await client.auth.getUser()
+
+  if (!user) {
+    return Response.json({}, { headers, status: 401 })
+  }
+
+  const { data } = await client
+    .from('profiles')
+    .select('patreon,is_supporter')
+    .eq('auth_id', user.id)
+    .single()
+
+  return Response.json(
+    { isSupporter: data?.is_supporter, patreon: data?.patreon },
+    { headers, status: 200 },
+  )
+}
+
+export const action = async ({ request }) => {
+  if (request.method !== 'DELETE') {
+    return Response.json({}, { status: 405, statusText: 'method not allowed' })
+  }
+
+  const { client, headers } = createSupabaseServerClient(request)
+
+  const {
+    data: { user },
+  } = await client.auth.getUser()
+
+  if (!user) {
+    return Response.json({}, { headers, status: 401 })
+  }
+
+  try {
+    await patreonServiceServer.disconnectUser(user, client)
+
+    return Response.json({}, { headers, status: 200 })
+  } catch (err) {
+    return Response.json({}, { headers, status: 500 })
+  }
+}
diff --git a/src/routes/api.questions.ts b/src/routes/api.questions.ts
index fac7d0da13..4995b180cb 100644
--- a/src/routes/api.questions.ts
+++ b/src/routes/api.questions.ts
@@ -37,7 +37,7 @@ export const loader = async ({ request }) => {
       tags,
       title,
       total_views,
-      author:profiles(id, display_name, username, is_verified, country)`,
+      author:profiles(id, display_name, username, is_verified, is_supporter, country)`,
     { count: 'exact' },
   )
 
diff --git a/src/services/authService.server.ts b/src/services/authService.server.ts
index e6643c772a..c93cc434b0 100644
--- a/src/services/authService.server.ts
+++ b/src/services/authService.server.ts
@@ -19,6 +19,7 @@ const createUserProfile = async (
     username: args.username,
     display_name: args.username,
     is_verified: false,
+    is_supporter: false,
     firebase_auth_id: '',
     tenant_id: process.env.TENANT_ID,
   })
diff --git a/src/services/patreonService.server.ts b/src/services/patreonService.server.ts
new file mode 100644
index 0000000000..e187e58ff5
--- /dev/null
+++ b/src/services/patreonService.server.ts
@@ -0,0 +1,191 @@
+import { DB_ENDPOINTS } from 'oa-shared'
+import { firestore } from 'src/utils/firebase'
+
+import type { SupabaseClient, User } from '@supabase/supabase-js'
+import type {
+  PatreonMembershipAttributes,
+  PatreonSettings,
+  PatreonTierAttributes,
+  PatreonUser,
+  PatreonUserAttributes,
+} from 'oa-shared'
+
+const isSupporter = async (
+  patreonUser: PatreonUser,
+  client: SupabaseClient,
+) => {
+  if (patreonUser.membership?.attributes.patron_status !== 'active_patron') {
+    return false
+  }
+
+  const result = await client.from('patreon_settings').select('tiers').limit(1)
+  const patreonSettings = result.data?.[0] as PatreonSettings
+
+  const validIds = patreonSettings.tiers.map((x) => x.id)
+
+  return patreonUser.membership?.tiers.some(({ id }) => validIds.includes(id))
+}
+
+const parsePatreonUser = (patreonUser: any): PatreonUser => {
+  // As we do not request the identity.membership scope, we only receive the user's membership to the
+  // One Army Patreon page, not other campaigns they may be part of.
+  const membership =
+    patreonUser.data.relationships.memberships.data.length > 0
+      ? patreonUser.included.find(({ type }) => type === 'member')
+      : undefined
+
+  const tiers = membership?.relationships.currently_entitled_tiers.data
+    .map(({ id }) =>
+      patreonUser.included.find(
+        ({ type, id: includedId }) => type === 'tier' && id === includedId,
+      ),
+    )
+    .map(({ id, attributes }) => ({ id, attributes }))
+
+  const userMembership = membership
+    ? {
+        id: membership.id,
+        attributes: membership.attributes,
+        tiers,
+      }
+    : undefined
+
+  return {
+    id: patreonUser.data.id,
+    attributes: patreonUser.data.attributes,
+    link: patreonUser.links.self,
+    // Only include membership if the user is a member of the One Army Patreon page.
+    ...(userMembership ? { membership: userMembership } : {}),
+  }
+}
+
+/*
+ * docs: https://docs.patreon.com/#get-api-oauth2-v2-identity
+ * to fetch more user attributes, add them to the include and fields query params
+ **/
+const getCurrentPatreonUser = async (accessToken: string) => {
+  const userFields: Array<keyof PatreonUserAttributes> = [
+    'about',
+    'created',
+    'email',
+    'first_name',
+    'full_name',
+    'image_url',
+    'last_name',
+    'thumb_url',
+    'url',
+  ]
+
+  const membershipFields: Array<keyof PatreonMembershipAttributes> = [
+    'campaign_lifetime_support_cents',
+    'currently_entitled_amount_cents',
+    'is_follower',
+    'last_charge_date',
+    'last_charge_status',
+    'lifetime_support_cents',
+    'next_charge_date',
+    'note',
+    'patron_status',
+    'pledge_cadence',
+    'pledge_relationship_start',
+    'will_pay_amount_cents',
+  ]
+
+  const tierFields: Array<keyof PatreonTierAttributes> = [
+    'amount_cents',
+    'created_at',
+    'description',
+    'edited_at',
+    'image_url',
+    'patron_count',
+    'published',
+    'published_at',
+    'title',
+    'url',
+  ]
+
+  const url = encodeURI(
+    `https://www.patreon.com/api/oauth2/v2/identity?include=memberships,memberships.currently_entitled_tiers&fields[user]=${userFields.join(
+      ',',
+    )}&fields[member]=${membershipFields.join(
+      ',',
+    )}&fields[tier]=${tierFields.join(',')}`,
+  )
+
+  const response = await fetch(url, {
+    method: 'GET',
+    headers: {
+      Authorization: `Bearer ${accessToken}`,
+    },
+  })
+  return await response.json()
+}
+
+const verifyAndUpdatePatreonUser = async (
+  code: string,
+  user: User,
+  client: SupabaseClient,
+  origin: string,
+) => {
+  const PATREON_CLIENT_ID = process.env.PATREON_CLIENT_ID
+  const PATREON_CLIENT_SECRET = process.env.PATREON_CLIENT_SECRET
+
+  if (!PATREON_CLIENT_ID || !PATREON_CLIENT_SECRET) {
+    throw new Error('PATREON_CLIENT_ID and PATREON_CLIENT_SECRET must be set')
+  }
+
+  const response = await fetch(
+    `https://www.patreon.com/api/oauth2/token?code=${code}&grant_type=authorization_code&client_id=${PATREON_CLIENT_ID}&client_secret=${PATREON_CLIENT_SECRET}&redirect_uri=${origin}/patreon`,
+    {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+      },
+    },
+  )
+  const { access_token } = await response.json()
+  const patreonUser = await getCurrentPatreonUser(access_token)
+  const patreonUserParsed = parsePatreonUser(patreonUser)
+  const isSupporterUser = await isSupporter(patreonUserParsed, client)
+
+  // Update in supabase
+  await client
+    .from('profiles')
+    .update({
+      patreon: patreonUserParsed,
+      is_supporter: isSupporterUser,
+    })
+    .eq('auth_id', user.id)
+
+  // Update in firebase - need this until we fully migrate profile and map
+  await firestore
+    .doc(DB_ENDPOINTS.users + '/' + user.user_metadata['username'])
+    .update({
+      patreon: patreonUserParsed,
+      badges: {
+        supporter: isSupporterUser,
+      },
+    })
+}
+
+const disconnectUser = async (user: User, client: SupabaseClient) => {
+  await client
+    .from('profiles')
+    .update({ patreon: null, is_supporter: false })
+    .eq('auth_id', user.id)
+
+  // Update in firebase - need this until we fully migrate profile and map
+  await firestore
+    .doc(DB_ENDPOINTS.users + '/' + user.user_metadata['username'])
+    .update({
+      patreon: null,
+      badges: {
+        supporter: false,
+      },
+    })
+}
+
+export const patreonServiceServer = {
+  verifyAndUpdatePatreonUser,
+  disconnectUser,
+}
diff --git a/src/services/patreonService.ts b/src/services/patreonService.ts
new file mode 100644
index 0000000000..8c28a3a9d3
--- /dev/null
+++ b/src/services/patreonService.ts
@@ -0,0 +1,27 @@
+import type { IPatreonUser } from 'src/models/profile.model'
+
+const getCurrentUserPatreon = async () => {
+  try {
+    const patreonData = await fetch('/api/patreon')
+
+    return (await patreonData.json()) as {
+      patreon: IPatreonUser
+      isSupporter: boolean
+    }
+  } catch (err) {
+    console.error(err)
+  }
+
+  return null
+}
+
+const disconnectUserPatreon = async () => {
+  const result = await fetch('/api/patreon', { method: 'DELETE' })
+
+  return result.ok
+}
+
+export const patreonService = {
+  getCurrentUserPatreon,
+  disconnectUserPatreon,
+}
diff --git a/src/services/questionService.server.ts b/src/services/questionService.server.ts
index 8d381cda67..6ef97af44e 100644
--- a/src/services/questionService.server.ts
+++ b/src/services/questionService.server.ts
@@ -19,7 +19,7 @@ const getBySlug = (client: SupabaseClient, slug: string) => {
        total_views,
        tenant_id,
        images,
-       author:profiles(id, firebase_auth_id, display_name, username, is_verified, country)
+       author:profiles(id, firebase_auth_id, display_name, username, is_verified, is_supporter, country)
      `,
     )
     .or(`slug.eq.${slug},previous_slugs.cs.{"${slug}"}`)
diff --git a/src/stores/User/user.store.test.tsx b/src/stores/User/user.store.test.tsx
index e6c1af7519..393064ddd8 100644
--- a/src/stores/User/user.store.test.tsx
+++ b/src/stores/User/user.store.test.tsx
@@ -241,21 +241,6 @@ describe('userStore', () => {
     })
   })
 
-  describe('removePatreonConnection', () => {
-    it('clears patreon/badges', async () => {
-      // Act
-      await store.removePatreonConnection('testUserId')
-
-      // Assert
-      expect(store.db.update).toHaveBeenCalledWith(
-        expect.objectContaining({
-          badges: { supporter: false },
-          patreon: null,
-        }),
-      )
-    })
-  })
-
   describe('updateUserProfile', () => {
     it('sends update only for fields provided', async () => {
       const userProfile = FactoryUser({
diff --git a/src/stores/User/user.store.ts b/src/stores/User/user.store.ts
index c3a8aea908..0657e8b257 100644
--- a/src/stores/User/user.store.ts
+++ b/src/stores/User/user.store.ts
@@ -105,14 +105,6 @@ export class UserStore extends ModuleStore {
     await this._updateUserRequest(userId, { badges })
   }
 
-  public async removePatreonConnection(userId: string) {
-    await this._updateUserRequest(userId, {
-      badges: { supporter: false },
-      patreon: null,
-    })
-    await this.refreshActiveUserDetails()
-  }
-
   public async updateUserProfile(values: PartialUser, trigger: string) {
     const { _id, coverImages, location, userImage } = values
 
@@ -319,10 +311,6 @@ export class UserStore extends ModuleStore {
       })
   }
 
-  private _unsubscribeFromAuthStateChanges() {
-    this.authUnsubscribe()
-  }
-
   public _updateActiveUser(user?: IUserDB | null) {
     this.user = user
   }
diff --git a/src/test/factories/Question.ts b/src/test/factories/Question.ts
index bdf422e388..aef71cdbd7 100644
--- a/src/test/factories/Question.ts
+++ b/src/test/factories/Question.ts
@@ -27,6 +27,7 @@ export const FactoryQuestionItem = (
     name: faker.internet.userName(),
     country: faker.location.countryCode(),
     isVerified: faker.datatype.boolean(),
+    isSupporter: faker.datatype.boolean(),
     firebaseAuthId: faker.string.uuid(),
     photoUrl: faker.image.avatar(),
     username: faker.internet.userName(),
diff --git a/supabase/migrations/20250312063008_patreon.sql b/supabase/migrations/20250312063008_patreon.sql
new file mode 100644
index 0000000000..0dd82ce957
--- /dev/null
+++ b/supabase/migrations/20250312063008_patreon.sql
@@ -0,0 +1,61 @@
+create table "public"."patreon_settings" (
+    "id" bigint generated by default as identity not null,
+    "created_at" timestamp with time zone not null default now(),
+    "tiers" json,
+    "tenant_id" text
+);
+
+alter table "public"."patreon_settings" enable row level security;
+
+CREATE UNIQUE INDEX patreon_settings_pkey ON public.patreon_settings USING btree (id);
+
+alter table "public"."patreon_settings" add constraint "patreon_settings_pkey" PRIMARY KEY using index "patreon_settings_pkey";
+
+grant delete on table "public"."patreon_settings" to "anon";
+
+grant insert on table "public"."patreon_settings" to "anon";
+
+grant references on table "public"."patreon_settings" to "anon";
+
+grant select on table "public"."patreon_settings" to "anon";
+
+grant trigger on table "public"."patreon_settings" to "anon";
+
+grant truncate on table "public"."patreon_settings" to "anon";
+
+grant update on table "public"."patreon_settings" to "anon";
+
+grant delete on table "public"."patreon_settings" to "authenticated";
+
+grant insert on table "public"."patreon_settings" to "authenticated";
+
+grant references on table "public"."patreon_settings" to "authenticated";
+
+grant select on table "public"."patreon_settings" to "authenticated";
+
+grant trigger on table "public"."patreon_settings" to "authenticated";
+
+grant truncate on table "public"."patreon_settings" to "authenticated";
+
+grant update on table "public"."patreon_settings" to "authenticated";
+
+grant delete on table "public"."patreon_settings" to "service_role";
+
+grant insert on table "public"."patreon_settings" to "service_role";
+
+grant references on table "public"."patreon_settings" to "service_role";
+
+grant select on table "public"."patreon_settings" to "service_role";
+
+grant trigger on table "public"."patreon_settings" to "service_role";
+
+grant truncate on table "public"."patreon_settings" to "service_role";
+
+grant update on table "public"."patreon_settings" to "service_role";
+
+create policy "tenant_isolation"
+on "public"."patreon_settings"
+as permissive
+for all
+to public
+using ((tenant_id = ((current_setting('request.headers'::text, true))::json ->> 'x-tenant-id'::text)));


Reviews:
user: login: benfurber
body: Nice. LGTM!

Not sure patreon needs it's own page - could just redirect to the existing settings page with the response, but I don't think it's important enough to change as this has been implemented. 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T09:22:32Z
user: login: benfurber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T10:58:41Z
user: login: mariojsnunes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T06:13:53Z
user: login: benfurber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T09:17:38Z
user: login: mariojsnunes
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:54:08Z
user: login: benfurber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T14:12:12Z

Review comments:
diff_hunk: @@ -3,11 +3,71 @@ export class DBProfile {
   username: string
   display_name: string
   is_verified: boolean
+  is_supporter: boolean
path: src/models/profile.model.ts
user: login: benfurber
body: I think this should match the current is support model which is `user.badges.isSupporter`
created_at: 2025-03-10T10:58:41Z
author_association: MEMBER
diff_hunk: @@ -3,11 +3,71 @@ export class DBProfile {
   username: string
   display_name: string
   is_verified: boolean
+  is_supporter: boolean
path: src/models/profile.model.ts
user: login: mariojsnunes
body: I'd rather not store is_verified and is_supporter in a json column, it will be harder to query.
maybe we could have a badges table? not sure it's worth the work now for just 2 "badges"
created_at: 2025-03-12T06:13:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,11 +3,71 @@ export class DBProfile {
   username: string
   display_name: string
   is_verified: boolean
+  is_supporter: boolean
path: src/models/profile.model.ts
user: login: benfurber
body: Feature creep.

I don't see the query argument. These are fields we always want as a group (e.g. there's no time we want to know if a user is verified but not a supporter) and having them as a group makes extending much easier in the future.

Reminder. The argument I'm making is exactly the one you used earlier in the project when we were doing top level schemas - json is fine for stuff.
created_at: 2025-03-12T09:17:38Z
author_association: MEMBER
diff_hunk: @@ -3,11 +3,71 @@ export class DBProfile {
   username: string
   display_name: string
   is_verified: boolean
+  is_supporter: boolean
path: src/models/profile.model.ts
user: login: mariojsnunes
body: I'm not completely against storing as json, but to explain the query argument...
Let's say we want to have a page to highlight patreon supporters.
`select * from profiles where is_supporter = 1` is much faster than `select * from profiles where badges->is_supporter = 1`. As the latter cannot have an index and every row in the table needs to be parsed.
Even for internal analytics, if you want to know which/how many users are verified/supporters.

Also, my earlier argument was about storing things like website links or other profile query that doesn't need to be queried.

Currently AFAIK these badges don't need to be queried either. But I see some use cases for it.
created_at: 2025-03-12T10:54:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,11 +3,71 @@ export class DBProfile {
   username: string
   display_name: string
   is_verified: boolean
+  is_supporter: boolean
path: src/models/profile.model.ts
user: login: benfurber
body: Had a LONG debate elsewhere, Mario convinced me.
created_at: 2025-03-12T14:12:12Z
author_association: MEMBER

PR comments:
user: login: cypress[bot]
created_at: 2025-03-10T06:43:14Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/4s5zgo?utm_source=github&utm_medium=flaky&utm_campaign=project">onearmy-community-platform</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/overview?utm_source=github&utm_medium=flaky&utm_campaign=run%20number">Run #7019</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/overview?utm_source=github&utm_medium=flaky&utm_campaign=run%20number">#7019</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/ONEARMY/community-platform/commit/9a00fd939024ad8d8c3e5aee4d5c665279db24de">9a00fd9390</a>: Merge branch 'master' into feat/patreon-supabase</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/4s5zgo?utm_source=github&utm_medium=flaky&utm_campaign=project">onearmy-community-platform</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/4s5zgo/branches/feat%2Fpatreon-supabase/review?utm_source=github&utm_medium=flaky&utm_campaign=review%20changes&changeRequest=4153">feat/patreon-supabase</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/overview?utm_source=github&utm_medium=flaky&utm_campaign=run%20number">#7019</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>05m 40s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/ONEARMY/community-platform/commit/9a00fd939024ad8d8c3e5aee4d5c665279db24de">9a00fd9390</a>: Merge branch 'master' into feat/patreon-supabase</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Mário Nunes</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/properties?utm_source=github&utm_medium=flaky&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=flaky&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=flaky&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>2</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/test-results?utm_source=github&utm_medium=flaky&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/test-results?utm_source=github&utm_medium=flaky&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/4s5zgo/runs/7019/overview?utm_source=github&utm_medium=flaky&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>80</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/4s5zgo/branches/feat%2Fpatreon-supabase/review?utm_source=github&utm_medium=flaky&utm_campaign=review%20changes&changeRequest=4153">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE
user: login: benfurber
created_at: 2025-03-10T09:22:58Z
body: @mariojsnunes Assume you'll merge in as data needs migrating?
author_association: MEMBER
user: login: benfurber
created_at: 2025-03-10T11:00:18Z
body: @mariojsnunes Changed my mind! I think the data structure change is important. Left a comment. I think the badges structure is important as the top level of user doesn't get too blutted - been trying to move stuff away from that top level (e.g. all the custom profile values).
author_association: MEMBER
user: login: onearmy-bot
created_at: 2025-03-13T06:13:22Z
body: :tada: This PR is included in version 2.35.0 :tada:

The release is available on [GitHub release](https://github.com/ONEARMY/community-platform/releases/tag/v2.35.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: COLLABORATOR