├── .gitignore ├── demo.gif ├── app ├── tsconfig.json ├── assets │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ └── adaptive-icon.png ├── babel.config.js ├── .expo-shared │ └── assets.json ├── .gitignore ├── .env.example ├── app.config.js ├── lib │ └── supabase.ts ├── App.tsx ├── app.json ├── package.json └── components │ ├── Auth.tsx │ └── Payment.tsx ├── supabase ├── functions │ ├── .vscode │ │ └── settings.json │ ├── _utils │ │ ├── stripe.ts │ │ ├── db_types.ts │ │ └── supabase.ts │ ├── hello │ │ └── index.ts │ └── payment-sheet │ │ └── index.ts ├── migrations │ └── 20220330083059_init.sql └── config.toml ├── .env.example ├── expo-stripe-payments-with-supabase-functions.code-workspace ├── schema.sql └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # Supabase 4 | **/supabase/.branches 5 | **/supabase/.temp 6 | **/supabase/.env 7 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/expo-stripe-payments-with-supabase-functions/HEAD/demo.gif -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /supabase/functions/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true 5 | } 6 | -------------------------------------------------------------------------------- /app/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/expo-stripe-payments-with-supabase-functions/HEAD/app/assets/icon.png -------------------------------------------------------------------------------- /app/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/expo-stripe-payments-with-supabase-functions/HEAD/app/assets/favicon.png -------------------------------------------------------------------------------- /app/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/expo-stripe-payments-with-supabase-functions/HEAD/app/assets/splash.png -------------------------------------------------------------------------------- /app/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /app/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/expo-stripe-payments-with-supabase-functions/HEAD/app/assets/adaptive-icon.png -------------------------------------------------------------------------------- /app/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | .env 5 | npm-debug.* 6 | *.jks 7 | *.p8 8 | *.p12 9 | *.key 10 | *.mobileprovision 11 | *.orig.* 12 | web-build/ 13 | 14 | # macOS 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Supabase API keys not needed as they are injected via the CLI 2 | 3 | # Stripe API keys - see https://stripe.com/docs/development/quickstart#api-keys 4 | STRIPE_PUBLISHABLE_KEY=pk_test 5 | STRIPE_SECRET_KEY=sk_test 6 | -------------------------------------------------------------------------------- /supabase/functions/_utils/stripe.ts: -------------------------------------------------------------------------------- 1 | // esm.sh is used to compile stripe-node to be compatible with ES modules. 2 | import Stripe from "https://esm.sh/stripe@10.13.0?target=deno&deno-std=0.132.0&no-check"; 3 | 4 | export const stripe = Stripe(Deno.env.get("STRIPE_SECRET_KEY") ?? "", { 5 | // This is needed to use the Fetch API rather than relying on the Node http 6 | // package. 7 | httpClient: Stripe.createFetchHttpClient(), 8 | apiVersion: "2022-08-01", 9 | }); 10 | -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | # WARNING: these are included in your build. Don't expose secrets here! 2 | # https://reactnative.dev/docs/security#storing-sensitive-info 3 | 4 | # NOTE: You will have to restart your Expo app after changing this file! 5 | 6 | # Remove this variable once you've deployed your Supabase Function! 7 | SUPA_FUNCTION_LOCALHOST=true 8 | 9 | # Get these from your Supabase Dashboard: https://app.supabase.io/project/_/settings/api 10 | EXPO_PUBLIC_SUPABASE_URL= 11 | EXPO_PUBLIC_SUPABASE_ANON_KEY= -------------------------------------------------------------------------------- /expo-stripe-payments-with-supabase-functions.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "project-root", 5 | "path": "./" 6 | }, 7 | { 8 | "name": "expo-app", 9 | "path": "app" 10 | }, 11 | { 12 | "name": "supabase-functions", 13 | "path": "supabase/functions" 14 | } 15 | ], 16 | "settings": { 17 | "files.exclude": { 18 | "node_modules/": true, 19 | "app/": true, 20 | "supabase/functions/": true 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | /** 2 | * CUSTOMERS 3 | * Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs. 4 | */ 5 | create table customers ( 6 | -- UUID from auth.users 7 | id uuid references auth.users not null primary key, 8 | -- The user's customer ID in Stripe. User must not be able to update this. 9 | stripe_customer_id text 10 | ); 11 | alter table customers enable row level security; 12 | -- Users can read own customer ID 13 | CREATE POLICY "Can read own data" ON public.customers FOR SELECT USING ((auth.uid() = id)); 14 | -------------------------------------------------------------------------------- /supabase/migrations/20220330083059_init.sql: -------------------------------------------------------------------------------- 1 | /** 2 | * CUSTOMERS 3 | * Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs. 4 | */ 5 | create table customers ( 6 | -- UUID from auth.users 7 | id uuid references auth.users not null primary key, 8 | -- The user's customer ID in Stripe. User must not be able to update this. 9 | stripe_customer_id text 10 | ); 11 | alter table customers enable row level security; 12 | -- Users can read own customer ID 13 | CREATE POLICY "Can read own data" ON public.customers FOR SELECT USING ((auth.uid() = id)); 14 | -------------------------------------------------------------------------------- /app/app.config.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | export default { 4 | name: "Expo Stripe Payments with Supabase FUnctions", 5 | version: "1.0.0", 6 | extra: { 7 | // WARNING: these are included in your build. Don't expose secrets here! 8 | // https://reactnative.dev/docs/security#storing-sensitive-info 9 | EXPO_PUBLIC_SUPABASE_URL: process.env.SUPA_FUNCTION_LOCALHOST 10 | ? "http://localhost:54321" 11 | : process.env.EXPO_PUBLIC_SUPABASE_URL, 12 | EXPO_PUBLIC_SUPABASE_ANON_KEY: process.env.SUPA_FUNCTION_LOCALHOST 13 | ? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs" 14 | : process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /app/lib/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import AsyncStorage from "@react-native-async-storage/async-storage"; 3 | import Constants from "expo-constants"; 4 | 5 | const supabaseUrl = 6 | Constants?.manifest?.extra?.EXPO_PUBLIC_SUPABASE_URL ?? 7 | "http://localhost:54321"; 8 | const supabaseAnonKey = 9 | Constants?.manifest?.extra?.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? 10 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs"; 11 | 12 | export const supabase = createClient(supabaseUrl, supabaseAnonKey, { 13 | auth: { 14 | storage: AsyncStorage as any, 15 | autoRefreshToken: true, 16 | persistSession: true, 17 | detectSessionInUrl: false, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /app/App.tsx: -------------------------------------------------------------------------------- 1 | import "react-native-url-polyfill/auto"; 2 | import { useState, useEffect } from "react"; 3 | import { supabase } from "./lib/supabase"; 4 | import Auth from "./components/Auth"; 5 | import PaymentScreen from "./components/Payment"; 6 | import { View } from "react-native"; 7 | import { Session } from "@supabase/supabase-js"; 8 | 9 | export default function App() { 10 | const [session, setSession] = useState(null); 11 | 12 | useEffect(() => { 13 | supabase.auth 14 | .getSession() 15 | .then(({ data: { session } }) => setSession(session)); 16 | 17 | supabase.auth.onAuthStateChange((_event, session) => { 18 | setSession(session); 19 | }); 20 | }, []); 21 | 22 | return {session && session.user ? : }; 23 | } 24 | -------------------------------------------------------------------------------- /supabase/functions/_utils/db_types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json } 7 | | Json[] 8 | 9 | export interface Database { 10 | public: { 11 | Tables: { 12 | customers: { 13 | Row: { 14 | id: string 15 | stripe_customer_id: string | null 16 | } 17 | Insert: { 18 | id: string 19 | stripe_customer_id?: string | null 20 | } 21 | Update: { 22 | id?: string 23 | stripe_customer_id?: string | null 24 | } 25 | } 26 | } 27 | Views: { 28 | [_ in never]: never 29 | } 30 | Functions: { 31 | [_ in never]: never 32 | } 33 | Enums: { 34 | [_ in never]: never 35 | } 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /app/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-stripe-payments-with-supabase-functions", 4 | "slug": "expo-stripe-payments-with-supabase-functions", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "splash": { 9 | "image": "./assets/splash.png", 10 | "resizeMode": "contain", 11 | "backgroundColor": "#ffffff" 12 | }, 13 | "updates": { 14 | "fallbackToCacheTimeout": 0 15 | }, 16 | "assetBundlePatterns": ["**/*"], 17 | "ios": { 18 | "supportsTablet": true 19 | }, 20 | "android": { 21 | "adaptiveIcon": { 22 | "foregroundImage": "./assets/adaptive-icon.png", 23 | "backgroundColor": "#FFFFFF" 24 | } 25 | }, 26 | "web": { 27 | "favicon": "./assets/favicon.png" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /supabase/functions/hello/index.ts: -------------------------------------------------------------------------------- 1 | // Follow this setup guide to integrate the Deno language server with your editor: 2 | // https://deno.land/manual/getting_started/setup_your_environment 3 | // This enables autocomplete, go to definition, etc. 4 | 5 | import { serve } from "https://deno.land/std@0.131.0/http/server.ts"; 6 | 7 | console.log("Hello from Functions!"); 8 | 9 | serve(async (req) => { 10 | const { name } = await req.json(); 11 | const data = { 12 | message: `Hello ${name}!`, 13 | }; 14 | 15 | return new Response(JSON.stringify(data), { 16 | headers: { "Content-Type": "application/json" }, 17 | }); 18 | }); 19 | 20 | // To invoke: 21 | // curl -i --location --request POST 'http://localhost:54321/functions/v1/' \ 22 | // --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs' \ 23 | // --header 'Content-Type: application/json' \ 24 | // --data '{"name":"Functions"}' 25 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-stripe-payments-with-supabase-functions", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "eject": "expo eject" 11 | }, 12 | "dependencies": { 13 | "@react-native-async-storage/async-storage": "~1.15.0", 14 | "@stripe/stripe-react-native": "0.2.3", 15 | "@supabase/supabase-js": "^2.0.0", 16 | "dotenv": "^16.0.0", 17 | "expo": "~44.0.0", 18 | "expo-status-bar": "~1.2.0", 19 | "react": "17.0.1", 20 | "react-dom": "17.0.1", 21 | "react-native": "0.64.3", 22 | "react-native-elements": "^3.4.2", 23 | "react-native-safe-area-context": "3.3.2", 24 | "react-native-url-polyfill": "^1.3.0", 25 | "react-native-web": "0.17.1" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.12.9", 29 | "@types/jest": "^27.4.1", 30 | "@types/react": "~17.0.21", 31 | "@types/react-native": "^0.64.24", 32 | "@types/react-native-elements": "^0.18.0", 33 | "@types/react-test-renderer": "^17.0.1", 34 | "typescript": "~4.3.5" 35 | }, 36 | "private": true, 37 | "resolutions": { 38 | "react-devtools-core": "4.14.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /supabase/functions/_utils/supabase.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "./db_types.ts"; 2 | import { stripe } from "./stripe.ts"; 3 | // Import Supabase client 4 | import { createClient } from "https://esm.sh/@supabase/supabase-js@2.0.0"; 5 | 6 | // WARNING: The service role key has admin priviliges and should only be used in secure server environments! 7 | const supabaseAdmin = createClient( 8 | Deno.env.get("SUPABASE_URL") ?? "", 9 | Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "" 10 | ); 11 | 12 | export const createOrRetrieveCustomer = async (authHeader: string) => { 13 | // Get JWT from auth header 14 | const jwt = authHeader.replace("Bearer ", ""); 15 | // Get the user object 16 | const { 17 | data: { user }, 18 | } = await supabaseAdmin.auth.getUser(jwt); 19 | if (!user) throw new Error("No user found for JWT!"); 20 | 21 | // Check if the user already has a Stripe customer ID in the Database. 22 | const { data, error } = await supabaseAdmin 23 | .from("customers") 24 | .select("stripe_customer_id") 25 | .eq("id", user?.id); 26 | console.log(data?.length, data, error); 27 | if (error) throw error; 28 | if (data?.length === 1) { 29 | // Exactly one customer found, return it. 30 | const customer = data[0].stripe_customer_id; 31 | console.log(`Found customer id: ${customer}`); 32 | return customer; 33 | } 34 | if (data?.length === 0) { 35 | // Create customer object in Stripe. 36 | const customer = await stripe.customers.create({ 37 | email: user.email, 38 | metadata: { uid: user.id }, 39 | }); 40 | console.log(`New customer "${customer.id}" created for user "${user.id}"`); 41 | // Insert new customer into DB 42 | await supabaseAdmin 43 | .from("customers") 44 | .insert({ id: user.id, stripe_customer_id: customer.id }) 45 | .throwOnError(); 46 | return customer.id; 47 | } else throw new Error(`Unexpected count of customer rows: ${data?.length}`); 48 | }; 49 | -------------------------------------------------------------------------------- /supabase/functions/payment-sheet/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "https://deno.land/std@0.132.0/http/server.ts"; 2 | import { stripe } from "../_utils/stripe.ts"; 3 | import { createOrRetrieveCustomer } from "../_utils/supabase.ts"; 4 | 5 | console.log("payment-sheet handler up and running!"); 6 | serve(async (req) => { 7 | try { 8 | // Get the authorization header from the request. 9 | // When you invoke the function via the client library it will automatically pass the authenticated user's JWT. 10 | const authHeader = req.headers.get("Authorization")!; 11 | 12 | // Retrieve the logged in user's Stripe customer ID or create a new customer object for them. 13 | // See ../_utils/supabase.ts for the implementation. 14 | const customer = await createOrRetrieveCustomer(authHeader); 15 | 16 | // Create an ephermeralKey so that the Stripe SDK can fetch the customer's stored payment methods. 17 | const ephemeralKey = await stripe.ephemeralKeys.create( 18 | { customer: customer }, 19 | { apiVersion: "2020-08-27" } 20 | ); 21 | // Create a PaymentIntent so that the SDK can charge the logged in customer. 22 | const paymentIntent = await stripe.paymentIntents.create({ 23 | amount: 1099, 24 | currency: "usd", 25 | customer: customer, 26 | }); 27 | 28 | // Return the customer details as well as teh Stripe publishable key which we have set in our secrets. 29 | const res = { 30 | stripe_pk: Deno.env.get("STRIPE_PUBLISHABLE_KEY"), 31 | paymentIntent: paymentIntent.client_secret, 32 | ephemeralKey: ephemeralKey.secret, 33 | customer: customer, 34 | }; 35 | return new Response(JSON.stringify(res), { 36 | headers: { "Content-Type": "application/json" }, 37 | status: 200, 38 | }); 39 | } catch (error) { 40 | return new Response(JSON.stringify(error), { 41 | headers: { "Content-Type": "application/json" }, 42 | status: 400, 43 | }); 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the working 2 | # directory name when running `supabase init`. 3 | project_id = "expo" 4 | 5 | [api] 6 | # Port to use for the API URL. 7 | port = 54321 8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 9 | # endpoints. public and storage are always included. 10 | schemas = [] 11 | # Extra schemas to add to the search_path of every request. 12 | extra_search_path = ["extensions"] 13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 14 | # for accidental or malicious requests. 15 | max_rows = 1000 16 | 17 | [db] 18 | # Port to use for the local database URL. 19 | port = 54322 20 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 21 | # server_version;` on the remote database to check. 22 | major_version = 14 23 | 24 | [studio] 25 | # Port to use for Supabase Studio. 26 | port = 54323 27 | 28 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 29 | # are monitored, and you can view the emails that would have been sent from the web interface. 30 | [inbucket] 31 | # Port to use for the email testing server web interface. 32 | port = 54324 33 | 34 | [auth] 35 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 36 | # in emails. 37 | site_url = "http://localhost:3000" 38 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 39 | additional_redirect_urls = ["https://localhost:3000"] 40 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one 41 | # week). 42 | jwt_expiry = 3600 43 | # Allow/disallow new user signups to your project. 44 | enable_signup = true 45 | 46 | [auth.email] 47 | # Allow/disallow new user signups via email to your project. 48 | enable_signup = true 49 | # If enabled, a user will be required to confirm any email change on both the old, and new email 50 | # addresses. If disabled, only the new email is required to confirm. 51 | double_confirm_changes = true 52 | # If enabled, users need to confirm their email address before signing in. 53 | enable_confirmations = false 54 | 55 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 56 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch`, `twitter`, `slack`, `spotify`. 57 | [auth.external.apple] 58 | enabled = false 59 | client_id = "" 60 | secret = "" 61 | -------------------------------------------------------------------------------- /app/components/Auth.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Alert, StyleSheet, View } from "react-native"; 3 | import { supabase } from "../lib/supabase"; 4 | import { Button, Input } from "react-native-elements"; 5 | 6 | export default function Auth() { 7 | const [email, setEmail] = useState(""); 8 | const [password, setPassword] = useState(""); 9 | const [loading, setLoading] = useState(false); 10 | 11 | async function signInWithEmail() { 12 | setLoading(true); 13 | const { error } = await supabase.auth.signInWithPassword({ 14 | email: email, 15 | password: password, 16 | }); 17 | 18 | if (error) { 19 | Alert.alert(error.message); 20 | setLoading(false); 21 | } 22 | } 23 | 24 | async function signUpWithEmail() { 25 | setLoading(true); 26 | const { error } = await supabase.auth.signUp({ 27 | email: email, 28 | password: password, 29 | }); 30 | 31 | if (error) { 32 | Alert.alert(error.message); 33 | setLoading(false); 34 | } 35 | } 36 | 37 | return ( 38 | 39 | 40 | setEmail(text)} 44 | value={email} 45 | placeholder="email@address.com" 46 | autoCapitalize={"none"} 47 | /> 48 | 49 | 50 | setPassword(text)} 54 | value={password} 55 | secureTextEntry={true} 56 | placeholder="Password" 57 | autoCapitalize={"none"} 58 | /> 59 | 60 | 61 |