├── .gitattributes ├── paddle_flow.png ├── firebase-login.png ├── paddle-subscription.png ├── subscriber-content.png ├── non-subscriber-content.png ├── paddle-paymentstep-1.png ├── paddle-paymentstep-2.png ├── payment_app ├── fb_functions │ ├── requirements.txt │ └── main.py ├── firebase-database.png ├── paddle-webhook-test.png ├── cloud-function-paddle.png └── README.md ├── .gitignore ├── shiny ├── www │ └── profile.css ├── ui.R ├── LICENSE ├── global.R └── server.R ├── firebase.Rproj ├── setup.R ├── diagram.R └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /paddle_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/paddle_flow.png -------------------------------------------------------------------------------- /firebase-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/firebase-login.png -------------------------------------------------------------------------------- /paddle-subscription.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/paddle-subscription.png -------------------------------------------------------------------------------- /subscriber-content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/subscriber-content.png -------------------------------------------------------------------------------- /non-subscriber-content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/non-subscriber-content.png -------------------------------------------------------------------------------- /paddle-paymentstep-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/paddle-paymentstep-1.png -------------------------------------------------------------------------------- /paddle-paymentstep-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/paddle-paymentstep-2.png -------------------------------------------------------------------------------- /payment_app/fb_functions/requirements.txt: -------------------------------------------------------------------------------- 1 | pycryptodome==3.19.1 2 | phpserialize==1.3 3 | google-cloud-firestore==1.7.0 4 | -------------------------------------------------------------------------------- /payment_app/firebase-database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/payment_app/firebase-database.png -------------------------------------------------------------------------------- /payment_app/paddle-webhook-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/payment_app/paddle-webhook-test.png -------------------------------------------------------------------------------- /payment_app/cloud-function-paddle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkEdmondson1234/Shiny-R-SaaS/HEAD/payment_app/cloud-function-paddle.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Renviron 2 | .Rproj.user 3 | .Rhistory 4 | shiny/firebase.rds 5 | firebase-reader-auth-key.json 6 | shiny/rsconnect/shinyapps.io/mark/r-saas.dcf 7 | -------------------------------------------------------------------------------- /shiny/www/profile.css: -------------------------------------------------------------------------------- 1 | .card { 2 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); 3 | max-width: 300px; 4 | margin: auto; 5 | text-align: center; 6 | } 7 | 8 | .email { 9 | color: grey; 10 | font-size: 18px; 11 | } 12 | -------------------------------------------------------------------------------- /firebase.Rproj: -------------------------------------------------------------------------------- 1 | Version: 1.0 2 | 3 | RestoreWorkspace: Default 4 | SaveWorkspace: Default 5 | AlwaysSaveHistory: Default 6 | 7 | EnableCodeIndexing: Yes 8 | UseSpacesForTab: Yes 9 | NumSpacesForTab: 2 10 | Encoding: UTF-8 11 | 12 | RnwWeave: Sweave 13 | LaTeX: pdfLaTeX 14 | 15 | AutoAppendNewline: Yes 16 | StripTrailingWhitespace: Yes 17 | -------------------------------------------------------------------------------- /setup.R: -------------------------------------------------------------------------------- 1 | # setup firebase R package 2 | 3 | #remotes::install_github("JohnCoene/firebase") 4 | library(firebase) 5 | firebase_config(api_key = Sys.getenv("FIREBASE_API_KEY"), 6 | project_id = Sys.getenv("FIREBASE_PROJECT")) 7 | file.copy("firebase.rds", "shiny/firebase.rds") 8 | 9 | # setup a firestore auth key 10 | library(googleAuthR) 11 | 12 | if(Sys.getenv("GAR_CLIENT_JSON") == ""){ 13 | stop("Need to set a GAR_CLIENT_JSON env arg to a clientID JSON file") 14 | } 15 | # creates firebase-reader-auth-key.json file 16 | gar_service_provision("firebase-reader", "roles/datastore.viewer") 17 | -------------------------------------------------------------------------------- /shiny/ui.R: -------------------------------------------------------------------------------- 1 | library(firebase) 2 | library(shiny) 3 | 4 | fluidPage( 5 | theme="profile.css", 6 | usePaddle(), 7 | useFirebase(), 8 | titlePanel("R SaaS - Bootstrapping paid Shiny applications with Firebase & Paddle"), 9 | 10 | sidebarLayout( 11 | sidebarPanel( 12 | useFirebaseUI(), 13 | # will only display upon login 14 | uiOutput("user_out"), 15 | uiOutput("subscriber"), 16 | p("See the app code ", a(href="https://github.com/MarkEdmondson1234/Shiny-R-SaaS", "on GitHub")) 17 | ), 18 | mainPanel( 19 | 20 | # will only display if paddle subscription active 21 | plotOutput("paid_content") 22 | ) 23 | ) 24 | ) 25 | -------------------------------------------------------------------------------- /shiny/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sunholo Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /diagram.R: -------------------------------------------------------------------------------- 1 | library(DiagrammeR) 2 | 3 | mermaid(" 4 | sequenceDiagram 5 | User->>Shiny App: Load app 6 | Shiny App->>Firebase: Firebase Auth Login 7 | Firebase->>Shiny App: Firebase UserId 8 | Shiny App->>Firebase subscriptions: check subscription database 9 | Firebase subscriptions->>Shiny App: subscription status 10 | alt No Subscription 11 | Shiny App->>Paddle Button: JS library makes button 12 | Paddle Button->>Paddle: User puts in credit card 13 | Paddle->>Cloud Function: Webhook update 14 | Cloud Function->>Firebase subscriptions: update with Firebase UserId 15 | Shiny App->>User: Reload 16 | else Active Subscription 17 | Firebase subscriptions->>Shiny App: Sends subscription status 18 | User->>Paid Content: User can use paid content 19 | Shiny App->>Paddle Button: JS library makes cancel and update buttons 20 | end 21 | alt Monthly subscription payment fails 22 | Paddle->>Cloud Function: Webhook update 23 | Cloud Function->>Firebase subscriptions: update with Firebase UserId 24 | Firebase subscriptions->>User: User loses access to paid content 25 | else User Cancel 26 | Shiny App->>Paddle Button: User cancels subscription 27 | Paddle Button->>Paddle: Cancel 28 | Paddle->>Cloud Function: Webhook update 29 | Cloud Function->>Firebase subscriptions: update with Firebase UserId 30 | Firebase subscriptions->>User: User loses access to paid content 31 | end 32 | ") 33 | -------------------------------------------------------------------------------- /shiny/global.R: -------------------------------------------------------------------------------- 1 | library(shiny) 2 | library(firebase) 3 | library(googleAuthR) 4 | 5 | gar_auth_service("firebase-reader-auth-key.json", 6 | scope = "https://www.googleapis.com/auth/datastore") 7 | 8 | #' Gets a Firebase data entry. Will return NULL if it can not 9 | fb_document_get <- function(document_path, 10 | database_id = "(default)", 11 | project_id = Sys.getenv("FIREBASE_PROJECT")){ 12 | 13 | the_url <- sprintf("https://firestore.googleapis.com/v1/projects/%s/databases/%s/documents/%s", 14 | project_id, database_id, document_path) 15 | 16 | f <- gar_api_generator(the_url, 17 | "GET", 18 | data_parse_function = function(x) x) 19 | 20 | o <- tryCatch(f(), 21 | error = function(err){ 22 | message("Couldn't find entry - ", err$message) 23 | NULL 24 | }) 25 | str(o) 26 | o 27 | } 28 | 29 | 30 | usePaddle <- function(vendor_id = Sys.getenv("PADDLE_VENDOR")){ 31 | if(vendor_id == ""){ 32 | stop("Paddle vendor_id is not set - ENV var PADDLE_VENDOR missing?") 33 | } 34 | singleton( 35 | tags$head( 36 | tags$script(src="https://cdn.paddle.com/paddle/paddle.js"), 37 | tags$script(sprintf("Paddle.Setup({ vendor: %s });", vendor_id)) 38 | ) 39 | ) 40 | } 41 | 42 | #' Create paddle Subscribe button 43 | pdle_subscribe <- function(product_id, 44 | user_id, 45 | email = NULL, 46 | success = "/"){ 47 | 48 | if(!is.null(email)){ 49 | email_line = sprintf('email: "%s"', email) 50 | } else { 51 | email_line = "email: null" 52 | } 53 | 54 | tagList( 55 | shiny::tags$button(href="#",id="buy", "Subscribe!", icon = icon("shopping-bag"), class = "btn-success"), 56 | tags$script(sprintf( 57 | 'function openCheckout() { 58 | Paddle.Checkout.open({ product: %s, 59 | %s, 60 | passthrough: "{\\"uid\\":\\"%s\\"}", 61 | success: "%s" 62 | }); 63 | } 64 | document.getElementById("buy").addEventListener("click", openCheckout, false);', 65 | product_id, email_line, user_id, success 66 | )) 67 | ) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /payment_app/README.md: -------------------------------------------------------------------------------- 1 | ## Payment app 2 | 3 | This holds code for handling the transactions: Firebase Auth handles the userId, Paddle the subscription status and Cloud Functions handles the communication between them. 4 | 5 | ### Paddle 6 | 7 | Payments are using [Paddle.com](https://paddle.com/) - when a payment goes through paddle it will send a webhook request to the Firebase functions as described in folder `payment_app/fb_functions/` 8 | 9 | The [Paddle webhook docs](https://developer.paddle.com/webhook-reference/intro) give some detail on what they do. 10 | 11 | Each time a user subscribes via the JavaScript library, a webhook will be sent to the URL you get when you deploy the Cloud Function. 12 | 13 | ### Cloud Function 14 | 15 | The python3.7 code in `payment_app/fb_functions/` will create a webhook endpoint. Create it in the same project as the Firebase authentication if used. 16 | 17 | See [here on how to deploy the code to Google Cloud Functions](https://cloud.google.com/functions/docs/concepts/python-runtime) - you can use `gcloud` or paste it straight into the web console. The function to execute is `paddle` 18 | 19 | ![](cloud-function-paddle.png) 20 | 21 | The HTTP endpoint will be given to you after deployed - use that within Paddle as the webhook. It covers the Paddle events: 22 | 23 | ```python 24 | sub_events = ['subscription_created', 25 | 'subscription_updated', 26 | 'subscription_cancelled'] 27 | ``` 28 | 29 | The Cloud Function creates a Firebase dataset called "subscriptions" with the document Id of the uid found in the "passthrough" field in the webhook. This passthrough will be set to a JSON string like so: 30 | 31 | ```json 32 | {"uid":"your-user-id"} 33 | ``` 34 | 35 | This is the format the R code uses to identify which Firebase user has which subscription. 36 | 37 | The code includes a verification step, which verifies the webhook is coming from Paddle. To pass the step, you need to paste in your Public key as detailed in [verifying webhooks](https://developer.paddle.com/webhook-reference/verifying-webhooks) and is found in your seller dashboard. 38 | 39 | ## Testing 40 | 41 | You can test the webhook here: https://vendors.paddle.com/webhook-alert-test 42 | 43 | The tests will need the `passthrough` field filled in with a uid json string as above. The webhook triggers for subscription created, subscription updated and subscription cancelled webhooks. 44 | 45 | ![](paddle-webhook-test.png) 46 | 47 | If working you should see the subscription data propagate to your Firebase console database at https://console.firebase.google.com/ 48 | 49 | The example below is for the test webhook given above: 50 | 51 | ![](firebase-database.png) 52 | 53 | This database is read from the Shiny app to check a user's subscription. 54 | 55 | There is also an `event` collection for each user which contains all historic updates (creation, updates, cancellations). 56 | 57 | The Shiny app will check that the status is not "deleted" indicating a lapsed subscription. 58 | -------------------------------------------------------------------------------- /shiny/server.R: -------------------------------------------------------------------------------- 1 | library(firebase) 2 | 3 | PADDLE_PRODUCT_ID <- 597736 4 | 5 | function(input, output, session) { 6 | 7 | # https://firebase.john-coene.com/articles/ui.html 8 | f <- FirebaseUI$new()$set_providers( 9 | email = TRUE, 10 | google = TRUE, 11 | twitter = TRUE, 12 | github = TRUE 13 | )$launch() 14 | 15 | 16 | firebase_user <- reactive({ 17 | f$req_sign_in() 18 | f$get_signed_in()$response 19 | }) 20 | 21 | 22 | observeEvent(input$signout, { 23 | f$sign_out() 24 | }) 25 | 26 | 27 | output$user_out <- renderUI({ 28 | req(firebase_user()) 29 | 30 | user <- firebase_user() 31 | tagList( 32 | div(class="card", 33 | img(src = user$photoURL, style="width:100%"), 34 | h2(paste("Welcome", user$displayName)), 35 | 36 | p(class="email", user$email), 37 | p(paste("Last login:", 38 | as.POSIXct(as.numeric(user$lastLoginAt)/1000, 39 | origin = "1970-01-01"))), 40 | p(paste("Created: ", 41 | as.POSIXct(as.numeric(user$createdAt)/1000, 42 | origin = "1970-01-01"))), 43 | p(actionButton("signout", "Sign out", class = "btn-danger")), 44 | p() 45 | )) 46 | 47 | }) 48 | 49 | # NULL if no subscription 50 | subscriber <- reactive({ 51 | req(firebase_user()) 52 | 53 | o <- fb_document_get(paste0("subscriptions/", firebase_user()$uid)) 54 | 55 | if(!is.null(o) && o$fields$status$stringValue == "deleted"){ 56 | # has subscription entry but it is cancelled 57 | return(NULL) 58 | } 59 | 60 | o 61 | 62 | }) 63 | 64 | subscriber_details <- reactive({ 65 | req(subscriber()) 66 | ss <- subscriber() 67 | 68 | tagList( 69 | div(class="card", 70 | h3("Subscription details"), 71 | p("Status:", ss$fields$status$stringValue), 72 | p(class="email", ss$fields$email$stringValue), 73 | p("Last update: ", ss$fields$event_time$stringValue), 74 | p("Next bill date:", ss$fields$next_bill_date$stringValue), 75 | p(a(href=ss$fields$update_url$stringValue, 76 | "Update your subscription", 77 | icon = icon("credit-card"), 78 | class = "btn-info")), 79 | " ", 80 | p(a(href=ss$fields$cancel_url$stringValue, 81 | "Cancel your subscription", 82 | icon = icon("window-close"), 83 | class = "btn-danger")) 84 | ) 85 | ) 86 | 87 | }) 88 | 89 | output$subscriber <- renderUI({ 90 | req(firebase_user()) 91 | 92 | subscriber <- subscriber() 93 | if(is.null(subscriber)){ 94 | return(tagList( 95 | div(class="card", 96 | p("To see paid content please subscribe:", 97 | pdle_subscribe(PADDLE_PRODUCT_ID, 98 | user_id = firebase_user()$uid, 99 | email = firebase_user()$email), 100 | helpText("If you have subscribed already, make sure you have logged in with a method using the same email as your subscription"), 101 | helpText("Use coupon code 'test123' to get 100% discount") 102 | 103 | ))) 104 | ) 105 | } 106 | 107 | subscriber_details() 108 | 109 | }) 110 | 111 | output$paid_content <- renderPlot({ 112 | req(subscriber()) 113 | 114 | plot(iris) 115 | 116 | }) 117 | 118 | 119 | 120 | 121 | } 122 | -------------------------------------------------------------------------------- /payment_app/fb_functions/main.py: -------------------------------------------------------------------------------- 1 | # Your Paddle public key. 2 | public_key = '''-----BEGIN PUBLIC KEY----- 3 | YOUR_PUBLIC_KEY_HERE 4 | -----END PUBLIC KEY-----''' 5 | 6 | import collections 7 | import base64 8 | import logging 9 | from google.cloud import firestore 10 | import json 11 | 12 | def update_firebase(data, collection, doc_id): 13 | logging.info('fb update - collection {} doc_id {}'.format(collection, doc_id)) 14 | 15 | db = firestore.Client() 16 | doc_ref = db.collection(collection).document(doc_id) 17 | doc_ref.set(data) 18 | 19 | # add the event 20 | event_ref = db.collection(collection).document(doc_id).collection('event').document(data['alert_id']) 21 | event_ref.set(data) 22 | 23 | 24 | 25 | def verify(input_data): 26 | # Crypto can be found at https://pypi.org/project/pycryptodome/ 27 | from Crypto.PublicKey import RSA 28 | try: 29 | from Crypto.Hash import SHA1 30 | except ImportError: 31 | # Maybe it's called SHA 32 | logging.debug('Import SHA') 33 | from Crypto.Hash import SHA as SHA1 34 | try: 35 | from Crypto.Signature import PKCS1_v1_5 36 | except ImportError: 37 | # Maybe it's called pkcs1_15 38 | logging.debug('Import pksc1_15') 39 | from Crypto.Signature import pkcs1_15 as PKCS1_v1_5 40 | import hashlib 41 | import phpserialize 42 | 43 | # Convert key from PEM to DER - Strip the first and last lines and newlines, and decode 44 | public_key_encoded = public_key[26:-25].replace('\n', '') 45 | public_key_der = base64.b64decode(public_key_encoded) 46 | 47 | # input_data represents all of the POST fields sent with the request 48 | # Get the p_signature parameter & base64 decode it. 49 | signature = input_data['p_signature'] 50 | 51 | # Remove the p_signature parameter 52 | del input_data['p_signature'] 53 | 54 | # Ensure all the data fields are strings 55 | for field in input_data: 56 | input_data[field] = str(input_data[field]) 57 | 58 | # Sort the data 59 | sorted_data = collections.OrderedDict(sorted(input_data.items())) 60 | 61 | # and serialize the fields 62 | serialized_data = phpserialize.dumps(sorted_data) 63 | 64 | # verify the data 65 | key = RSA.importKey(public_key_der) 66 | digest = SHA1.new() 67 | digest.update(serialized_data) 68 | verifier = PKCS1_v1_5.new(key) 69 | signature = base64.b64decode(signature) 70 | if verifier.verify(digest, signature): 71 | print('Signature is valid') 72 | return True 73 | else: 74 | print('The signature is invalid!') 75 | return False 76 | 77 | def paddle(request): 78 | """Responds to any HTTP request. 79 | Args: 80 | request (flask.Request): HTTP request object. 81 | Returns: 82 | The response text or any set of values that can be turned into a 83 | Response object using 84 | `make_response `. 85 | """ 86 | request_data = request.form.to_dict() 87 | 88 | logging.info(request_data) 89 | 90 | verified = False 91 | try: 92 | verified = verify(request_data) 93 | except Exception as ex: 94 | print(ex) 95 | 96 | if not verified: 97 | return "Not Verified!" 98 | 99 | uid = None 100 | try: 101 | # should contain json of form {"uid":"an_id"} 102 | passthrough = request_data.get('passthrough') 103 | 104 | if passthrough: 105 | p_obj = json.loads(passthrough) 106 | else: 107 | return "No passthrough" 108 | 109 | uid = p_obj.get('uid') 110 | 111 | if not uid: 112 | return "No passthrough['uid']" 113 | 114 | except Exception as ex: 115 | print(ex) 116 | return "Error fetching passthrough uid" 117 | 118 | logging.info('passthrough uid: ' + uid) 119 | 120 | alert = request_data['alert_name'] 121 | 122 | sub_events = ['subscription_created', 123 | 'subscription_updated', 124 | 'subscription_cancelled'] 125 | 126 | if alert in sub_events and uid: 127 | update_firebase(request_data, 'subscriptions', uid) 128 | return "Subscription: " + alert 129 | 130 | return 'No update made' 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Creating a paid R SaaS with Firebase, Paddle and Shiny 2 | 3 | Create a template for R users to create paid subscription services for Shiny Apps. 4 | 5 | ## Demo 6 | 7 | An example is deployed to shinyapps.io here: 8 | 9 | https://mark.shinyapps.io/r-saas/ 10 | 11 | Login with one of the providers (more are available), use the coupon code "test123" to test the paid subscription service of Paddle. The paid content is a woeful plot. 12 | 13 | ## Many thanks to... 14 | 15 | This project is derived from: 16 | 17 | * https://www.tychobra.com/posts/2019-01-03-firebasse-auth-wtih-shiny/ 18 | * Firebase [AuthUI](https://firebaseopensource.com/projects/firebase/firebaseui-web/) 19 | * An early iteration inspired some of this package https://github.com/JohnCoene/firebase which it now uses for firebase auth 20 | * Some guy on medium.com who did the cloud function in PHP but I can't find it now 21 | 22 | ## Screenshots 23 | 24 | * Firebase auth on login - select which services to support in Firebase UI 25 | 26 | ![](firebase-login.png) 27 | 28 | * Once you login but have not subscribed yet - Paddle creates a login button 29 | 30 | ![](non-subscriber-content.png) 31 | 32 | * Paddle takes care of subscription and credit card details 33 | 34 | ![](paddle-paymentstep-1.png) 35 | ![](paddle-paymentstep-2.png) 36 | 37 | * Subscription appears in Paddle UI 38 | 39 | ![](paddle-subscription.png) 40 | 41 | * If a paid subscriber already - see the great Shiny content 42 | 43 | ![](subscriber-content.png) 44 | 45 | * User authentication and payment history kept in a Firebase Auth and Firestore 46 | 47 | ![](payment_app/firebase-database.png) 48 | 49 | * Sync between Paddle and Firebase uses a Google Cloud Function in Python3.7 50 | 51 | ![](payment_app/cloud-function-paddle.png) 52 | 53 | ## Payment strategy 54 | 55 | A diagram on how the app handles payments 56 | 57 | ![](paddle_flow.png) 58 | 59 | ## Steps to have working demo bootstrap 60 | 61 | 1. Download or clone this repository 62 | 2. Create a [Firebase](https://firebase.google.com/) account and setup as per https://firebase.john-coene.com/articles/get-started.html - get your firebase API key and project-id 63 | 3. Create a [Paddle](https://paddle.com) account and [setup](https://developer.paddle.com/getting-started/intro) and get your [Paddle Vendor Id](https://vendors.paddle.com/authentication) and create a test catalog [subscription plan to get a Paddle Plan Id](https://vendors.paddle.com/subscriptions/plans). Its helpful to also create [a coupon](https://vendors.paddle.com/coupons) with 100% discount for testing. 64 | 4. Download a [clientId JSON file for your GCP project](https://console.cloud.google.com/apis/credentials/oauthclient) (same as Firebase project), application type "Desktop app" 65 | 5. Create env args via `.Renviron` or otherwise: 66 | 67 | ``` 68 | FIREBASE_API_KEY=your-api-key 69 | FIREBASE_PROJECT=your-firebase-project 70 | GAR_CLIENT_JSON=file-location-of-client-id 71 | PADDLE_VENDOR=paddle-vendor-id 72 | ``` 73 | 74 | 6. When you create a Paddle subscription it gives you a productId - this should be unique for each Shiny app and is placed at the top of server.R in the `PADDLE_PRODUCT_ID` global arg. 75 | 7. Deploy the Cloud Function in `payment_app/fb_functions` in the same Firebase project via the GCP console. This handles communication between Firebase and Paddle webhooks. You can do this via `gcloud functions deploy` if you have `gcloud` installed or copy-paste into the web UI for Cloud Functions. 76 | 8. Create a firebase client auth key with "roles/datastore.viewer" role - with googleAuthR this can be done via: 77 | 78 | ```r 79 | library(googleAuthR) 80 | 81 | # creates firebase-reader-auth-key.json file 82 | gar_service_provision("firebase-reader", "roles/datastore.viewer") 83 | ``` 84 | 9. Run the Shiny app on `http://localhost:PORT` to test locally (`http://127.0.0.1:PORT` doesn't work with Firebase login) - I launch Shiny in Viewer pane then visit `http://localhost` in my browser 85 | 10. Deploy the test Shiny app in `shiny/` with the client auth key and `.Renviron` in the same folder 86 | 11. See the `global.R` for the payment functions that are used in the demo app, and you can adapt to your own use: 87 | * `fb_document_get()` gets entries to the Firebase database, Firestore 88 | * `usePaddle()` is placed at the top of pages you want to use Paddle in, and loads the Paddle JS library 89 | * `pdle_subscribe()` creates a subscription button - you can select which product_id, user_id (the firebaseId is suggested), email to pre-populate in the form and the URL redirect that will be visited after payment 90 | 91 | 92 | ## Running the payment app 93 | 94 | The Shiny App will offer to link to the payment popup via Paddle after login with Firebase Auth. The firebase auth ID is used to verify if the user has an existing subscription, and if not creates a payment button to do so. If a user does have a subscription, then they see the paid content. 95 | 96 | If a subscription fails (the credit card is cacnelled or similar) then Paddle updates. 97 | 98 | The Firebase databsae "subscriptions" is used to keep track of whether a user has paid or not. The communication between Firebase and PAddle is done via the Python cloud function in the `payment_app/` folder - see its [README for details](https://github.com/MarkEdmondson1234/Shiny-R-SaaS/tree/master/payment_app). 99 | 100 | 101 | --------------------------------------------------------------------------------