Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.idea
**/node_modules/*
**/.firebase
**/.firebaserc
*/npm-debug.log
lerna-debug.log
Expand Down
73 changes: 51 additions & 22 deletions stripe/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,60 @@
# Create Stripe customers and charge them on Cloud Firestore write

This sample shows how to create Stripe customers and charge them when Cloud Firestore is written to.
This sample shows you how to create Stripe customers when your users sign up, secure collect and store their payment details, and charge them when a new document is written to your Firestore.

Further reading:
- Stripe Node API: https://stripe.com/docs/api/node
- Firebase SDK: https://firebase.google.com/docs/functions
### Features

## Functions Code
- Create a customer object in Stripe when a new users signs up. ([view code](./functions/index.js#L29)).
- Securel collect a customers card details with Stripe Elements and set them up for future usage. ([view code](./public/javascript/app.js#L69)).
- Create a payment on the customer's card when a new document is written to the `payments` collection. ([view code](./functions/index.js#L75)).
- **NOTE:** Note that this example creates the payment document on the client with amount and currency inputted by the user. In a real application you need to validate price details in your function, e.g. based on product information stored in your Firestore.
- Handle 3D Secure authentication if required by the card issuer. Read more about 3D Secure and SCA [here](https://stripe.com/payments/strong-customer-authentication). ([view code](./functions/index.js#L114))

See file [functions/index.js](functions/index.js) for the code.
#### Further reading:

The dependencies are listed in [functions/package.json](functions/package.json).
- Stripe docs: https://stripe.com/docs/payments/save-and-reuse
- 3D Secure and SCA regulation: https://stripe.com/payments/strong-customer-authentication
- Firebase docs: https://firebase.google.com/docs/functions

## Demo

- https://cloud-functions-stripe-sample.web.app/

![Firebase Stripe demo gif](./demo.gif)

## Deploy and test

To test this integration:
- Create a Firebase Project using the [Firebase Developer Console](https://console.firebase.google.com)
- Enable billing on your project by switching to the Blaze or Flame plan. See [pricing](https://firebase.google.com/pricing/) for more details. This is required to be able to do requests to non-Google services.
- [Enable Google sign on your Firebase project ](https://console.firebase.google.com/project/_/authentication/providers)
- Install [Firebase CLI Tools](https://github.com/firebase/firebase-tools) if you have not already and log in with `firebase login`.
- Configure this sample to use your project using `firebase use --add` and select your project.
- Install dependencies locally by running: `cd functions; npm install; cd -`
- [Add your Stripe API Secret Key](https://dashboard.stripe.com/account/apikeys) to firebase config:
```bash
firebase functions:config:set stripe.token=<YOUR STRIPE API KEY>
```
- *Optional:* change your default currency `firebase functions:config:set stripe.currency=GBP`
- Pass your [Stripe publishable key](https://dashboard.stripe.com/account/apikeys) to the `Stripe.setPublishableKey` call in `public/index.html`
- Deploy your project using `firebase deploy`
- Test your Stripe integration by viewing your deployed site `firebase open hosting:site`
- Create a Firebase Project using the [Firebase Developer Console](https://console.firebase.google.com)
- Enable billing on your project by switching to the Blaze or Flame plan. See [pricing](https://firebase.google.com/pricing/) for more details. This is required to be able to do requests to non-Google services.
- Enable Google & Email sign-in in your [authentication provider settings](https://console.firebase.google.com/project/_/authentication/providers).
- Install [Firebase CLI Tools](https://github.com/firebase/firebase-tools) if you have not already and log in with `firebase login`.
- Configure this sample to use your project using `firebase use --add` and select your project.
- Install dependencies locally by running: `cd functions; npm install; cd -`
- [Add your Stripe API Secret Key](https://dashboard.stripe.com/account/apikeys) to firebase config:
```bash
firebase functions:config:set stripe.secret=<YOUR STRIPE SECRET KEY>
```
- Set your [Stripe publishable key](https://dashboard.stripe.com/account/apikeys) for the `STRIPE_PUBLISHABLE_KEY` const in [`/public/javascript/app.js`](./public/javascript/app.js#L16)
- Deploy your project using `firebase deploy`
- Test your Stripe integration by viewing your deployed site `firebase open hosting:site`

### Run the client locally

Since this project uses Firebase Auth triggers, the functions need to be deployed. However, when making changes to your client application in the `/public` folder, you can serve it locally to quickly preview changes.

```
firebase deploy --only functions ## only needs to be run when you make changes to your functions

firebase serve --only hosting
```

## Accepting live payments

Once you’re ready to go live, you will need to exchange your test keys for your live keys. See the [Stripe docs](https://stripe.com/docs/keys) for further details.

- Update your Stripe secret config:
```bash
firebase functions:config:set stripe.secret=<YOUR STRIPE LIVE SECRET KEY>
```
- Set your [live publishable key](https://dashboard.stripe.com/account/apikeys) for the `STRIPE_PUBLISHABLE_KEY` const in [`/public/javascript/app.js`](./public/javascript/app.js#L16).
- Redeploy both functions and hosting for the changes to take effect `firebase deploy`.
Binary file added stripe/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 1 addition & 5 deletions stripe/firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@
},
"hosting": {
"public": "public",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
]
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"]
}
}
8 changes: 3 additions & 5 deletions stripe/firestore.rules
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /stripe_customers/{uid} {
allow read, write: if request.auth.uid == uid;

match /sources/{sourceId} {
allow read: if request.auth.uid == uid;
}
match /tokens/{sourceId} {
match /payment_methods/{id} {
allow read,write: if request.auth.uid == uid;
}
match /charges/{chargeId} {
match /payments/{id} {
allow read, write: if request.auth.uid == uid;
}

Expand Down
202 changes: 135 additions & 67 deletions stripe/functions/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2016 Google Inc. All Rights Reserved.
* Copyright 2020 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -18,77 +18,142 @@
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();
const logging = require('@google-cloud/logging')();
const stripe = require('stripe')(functions.config().stripe.token);
const currency = functions.config().stripe.currency || 'USD';

// [START chargecustomer]
// Charge the Stripe customer whenever an amount is created in Cloud Firestore
exports.createStripeCharge = functions.firestore.document('stripe_customers/{userId}/charges/{id}').onCreate(async (snap, context) => {
const val = snap.data();
try {
// Look up the Stripe customer id written in createStripeCustomer
const snapshot = await admin.firestore().collection(`stripe_customers`).doc(context.params.userId).get()
const snapval = snapshot.data();
const customer = snapval.customer_id
// Create a charge using the pushId as the idempotency key
// protecting against double charges
const amount = val.amount;
const idempotencyKey = context.params.id;
const charge = {amount, currency, customer};
if (val.source !== null) {
charge.source = val.source;
}
const response = await stripe.charges.create(charge, {idempotency_key: idempotencyKey});
// If the result is successful, write it back to the database
return snap.ref.set(response, { merge: true });
} catch(error) {
// We want to capture errors and render them in a user-friendly way, while
// still logging an exception with StackDriver
console.log(error);
await snap.ref.set({error: userFacingMessage(error)}, { merge: true });
return reportError(error, {user: context.params.userId});
}
});
// [END chargecustomer]]
const { Logging } = require('@google-cloud/logging');
const logging = new Logging({
projectId: process.env.GCLOUD_PROJECT,
});
const stripe = require('stripe')(functions.config().stripe.secret, {
apiVersion: '2020-03-02',
});

// When a user is created, register them with Stripe
/**
* When a user is created, create a Stripe customer object for them.
*
* @see https://stripe.com/docs/payments/save-and-reuse#web-create-customer
*/
exports.createStripeCustomer = functions.auth.user().onCreate(async (user) => {
const customer = await stripe.customers.create({email: user.email});
return admin.firestore().collection('stripe_customers').doc(user.uid).set({customer_id: customer.id});
const customer = await stripe.customers.create({ email: user.email });
const intent = await stripe.setupIntents.create({
customer: customer.id,
});
await admin.firestore().collection('stripe_customers').doc(user.uid).set({
customer_id: customer.id,
setup_secret: intent.client_secret,
});
return;
});

// Add a payment source (card) for a user by writing a stripe payment source token to Cloud Firestore
exports.addPaymentSource = functions.firestore.document('/stripe_customers/{userId}/tokens/{pushId}').onCreate(async (snap, context) => {
const source = snap.data();
const token = source.token;
if (source === null){
return null;
}
/**
* When adding the payment method ID on the client,
* this function is triggered to retrieve the payment method details.
*/
exports.addPaymentMethodDetails = functions.firestore
.document('/stripe_customers/{userId}/payment_methods/{pushId}')
.onCreate(async (snap, context) => {
try {
const paymentMethodId = snap.data().id;
const paymentMethod = await stripe.paymentMethods.retrieve(
paymentMethodId
);
await snap.ref.set(paymentMethod);
// Create a new SetupIntent so the customer can add a new method next time.
const intent = await stripe.setupIntents.create({
customer: paymentMethod.customer,
});
await snap.ref.parent.parent.set(
{
setup_secret: intent.client_secret,
},
{ merge: true }
);
return;
} catch (error) {
await snap.ref.set({ error: userFacingMessage(error) }, { merge: true });
reportError(error, { user: context.params.userId });
return;
}
});

try {
const snapshot = await admin.firestore().collection('stripe_customers').doc(context.params.userId).get();
const customer = snapshot.data().customer_id;
const response = await stripe.customers.createSource(customer, {source: token});
return admin.firestore().collection('stripe_customers').doc(context.params.userId).collection("sources").doc(response.fingerprint).set(response, {merge: true});
} catch (error) {
await snap.ref.set({'error':userFacingMessage(error)},{merge:true});
return reportError(error, {user: context.params.userId});
}
});
/**
* When a payment document is written on the client,
* this function is triggered to create the payment in Stripe.
*
* @see https://stripe.com/docs/payments/save-and-reuse#web-create-payment-intent-off-session
*/
exports.createStripePayment = functions.firestore
.document('stripe_customers/{userId}/payments/{pushId}')
.onCreate(async (snap, context) => {
const { amount, currency, payment_method } = snap.data();
try {
// Look up the Stripe customer id.
const customer = (await snap.ref.parent.parent.get()).data().customer_id;
// Create a charge using the pushId as the idempotency key
// to protect against double charges.
const idempotencyKey = context.params.pushId;
const payment = await stripe.paymentIntents.create(
{
amount,
currency,
customer,
payment_method,
off_session: false,
confirm: true,
confirmation_method: 'manual',
},
{ idempotencyKey }
);
// If the result is successful, write it back to the database.
await snap.ref.set(payment);
return;
} catch (error) {
// We want to capture errors and render them in a user-friendly way, while
// still logging an exception with StackDriver
console.log(error);
await snap.ref.set({ error: userFacingMessage(error) }, { merge: true });
reportError(error, { user: context.params.userId });
return;
}
});

/**
* When 3D Secure is performed, we need to reconfirm the payment
* after authentication has been performed.
*
* @see https://stripe.com/docs/payments/accept-a-payment-synchronously#web-confirm-payment
*/
exports.confirmStripePayment = functions.firestore
.document('stripe_customers/{userId}/payments/{pushId}')
.onUpdate(async (change, context) => {
if (change.after.data().status === 'requires_confirmation') {
const payment = await stripe.paymentIntents.confirm(
change.after.data().id
);
change.after.ref.set(payment);
}
});

// When a user deletes their account, clean up after them
/**
* When a user deletes their account, clean up after them
*/
exports.cleanupUser = functions.auth.user().onDelete(async (user) => {
const snapshot = await admin.firestore().collection('stripe_customers').doc(user.uid).get();
const customer = snapshot.data();
const dbRef = admin.firestore().collection('stripe_customers');
const customer = (await dbRef.doc(user.uid).get()).data();
await stripe.customers.del(customer.customer_id);
return admin.firestore().collection('stripe_customers').doc(user.uid).delete();
// Delete the customers payments & payment methods in firestore.
const snapshot = await dbRef
.doc(user.uid)
.collection('payment_methods')
.get();
snapshot.forEach((snap) => snap.ref.delete());
await dbRef.doc(user.uid).delete();
return;
});

// To keep on top of errors, we should raise a verbose error report with Stackdriver rather
// than simply relying on console.error. This will calculate users affected + send you email
// alerts, if you've opted into receiving them.
// [START reporterror]
/**
* To keep on top of errors, we should raise a verbose error report with Stackdriver rather
* than simply relying on console.error. This will calculate users affected + send you email
* alerts, if you've opted into receiving them.
*/
function reportError(err, context = {}) {
// This is the name of the StackDriver log stream that will receive the log
// entry. This name can be any valid log stream name, but must contain "err"
Expand All @@ -100,7 +165,7 @@ function reportError(err, context = {}) {
const metadata = {
resource: {
type: 'cloud_function',
labels: {function_name: process.env.FUNCTION_NAME},
labels: { function_name: process.env.FUNCTION_NAME },
},
};

Expand All @@ -118,15 +183,18 @@ function reportError(err, context = {}) {
return new Promise((resolve, reject) => {
log.write(log.entry(metadata, errorEvent), (error) => {
if (error) {
return reject(error);
return reject(error);
}
return resolve();
});
});
}
// [END reporterror]

// Sanitize the error message for the user
/**
* Sanitize the error message for the user.
*/
function userFacingMessage(error) {
return error.type ? error.message : 'An error occurred, developers have been alerted';
return error.type
? error.message
: 'An error occurred, developers have been alerted';
}
4 changes: 2 additions & 2 deletions stripe/functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"description": "Stripe Firebase Functions",
"dependencies": {
"@google-cloud/logging": "^7.2.3",
"stripe": "^8.24.0",
"firebase-admin": "~8.9.2",
"firebase-functions": "^3.3.0"
"firebase-functions": "^3.3.0",
"stripe": "^8.56.0"
},
"devDependencies": {
"eslint": "^6.8.0",
Expand Down
Loading