Last active
January 20, 2024 20:29
-
-
Save faustoct1/8d7583cd320dfef30b91031f26da8627 to your computer and use it in GitHub Desktop.
Create firestore user after a purchase (onetime payment + subscriptions)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use strict"; | |
/* | |
* Copyright 2020 Stripe, Inc. | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* https://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { | |
if (k2 === undefined) k2 = k; | |
var desc = Object.getOwnPropertyDescriptor(m, k); | |
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { | |
desc = { enumerable: true, get: function() { return m[k]; } }; | |
} | |
Object.defineProperty(o, k2, desc); | |
}) : (function(o, m, k, k2) { | |
if (k2 === undefined) k2 = k; | |
o[k2] = m[k]; | |
})); | |
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { | |
Object.defineProperty(o, "default", { enumerable: true, value: v }); | |
}) : function(o, v) { | |
o["default"] = v; | |
}); | |
var __importStar = (this && this.__importStar) || function (mod) { | |
if (mod && mod.__esModule) return mod; | |
var result = {}; | |
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); | |
__setModuleDefault(result, mod); | |
return result; | |
}; | |
var __rest = (this && this.__rest) || function (s, e) { | |
var t = {}; | |
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) | |
t[p] = s[p]; | |
if (s != null && typeof Object.getOwnPropertySymbols === "function") | |
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { | |
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) | |
t[p[i]] = s[p[i]]; | |
} | |
return t; | |
}; | |
var __importDefault = (this && this.__importDefault) || function (mod) { | |
return (mod && mod.__esModule) ? mod : { "default": mod }; | |
}; | |
Object.defineProperty(exports, "__esModule", { value: true }); | |
exports.onCustomerDataDeleted = exports.onUserDeleted = exports.handleWebhookEvents = exports.createPortalLink = void 0; | |
const admin = __importStar(require("firebase-admin")); | |
const eventarc_1 = require("firebase-admin/eventarc"); | |
const functions = __importStar(require("firebase-functions")); | |
const stripe_1 = __importDefault(require("stripe")); | |
const logs = __importStar(require("./logs")); | |
const config_1 = __importDefault(require("./config")); | |
const apiVersion = '2020-08-27'; | |
const stripe = new stripe_1.default(config_1.default.stripeSecretKey, { | |
apiVersion, | |
// Register extension as a Stripe plugin | |
// https://stripe.com/docs/building-plugins#setappinfo | |
appInfo: { | |
name: 'Firebase firestore-stripe-payments', | |
version: '0.3.3', | |
}, | |
}); | |
admin.initializeApp(); | |
const eventChannel = process.env.EVENTARC_CHANNEL && | |
(0, eventarc_1.getEventarc)().channel(process.env.EVENTARC_CHANNEL, { | |
allowedEventTypes: process.env.EXT_SELECTED_EVENTS, | |
}); | |
/** | |
* Create a customer object in Stripe when a user is created. | |
*/ | |
const createCustomerRecord = async ({ email, uid, phone, }) => { | |
try { | |
logs.creatingCustomer(uid); | |
const customerData = { | |
metadata: { | |
firebaseUID: uid, | |
}, | |
}; | |
if (email) | |
customerData.email = email; | |
if (phone) | |
customerData.phone = phone; | |
const customer = await stripe.customers.create(customerData); | |
// Add a mapping record in Cloud Firestore. | |
const customerRecord = { | |
email: customer.email, | |
stripeId: customer.id, | |
stripeLink: `https://dashboard.stripe.com${customer.livemode ? '' : '/test'}/customers/${customer.id}`, | |
}; | |
if (phone) | |
customerRecord.phone = phone; | |
await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.doc(uid) | |
.set(customerRecord, { merge: true }); | |
logs.customerCreated(customer.id, customer.livemode); | |
return customerRecord; | |
} | |
catch (error) { | |
logs.customerCreationError(error, uid); | |
return null; | |
} | |
}; | |
exports.createCustomer = functions.auth | |
.user() | |
.onCreate(async (user) => { | |
if (!config_1.default.syncUsersOnCreate) | |
return; | |
const { email, uid, phoneNumber } = user; | |
await createCustomerRecord({ | |
email, | |
uid, | |
phone: phoneNumber, | |
}); | |
}); | |
/** | |
* Create a CheckoutSession or PaymentIntent based on which client is being used. | |
*/ | |
exports.createCheckoutSession = functions | |
.runWith({ | |
minInstances: config_1.default.minCheckoutInstances, | |
}) | |
.firestore.document(`/${config_1.default.customersCollectionPath}/{uid}/checkout_sessions/{id}`) | |
.onCreate(async (snap, context) => { | |
var _a, _b; | |
const { client = 'web', amount, currency, mode = 'subscription', price, success_url, cancel_url, quantity = 1, payment_method_types, shipping_rates = [], metadata = {}, automatic_payment_methods = { enabled: true }, automatic_tax = false, tax_rates = [], tax_id_collection = false, allow_promotion_codes = false, trial_from_plan = true, line_items, billing_address_collection = 'required', collect_shipping_address = false, customer_update = {}, locale = 'auto', promotion_code, client_reference_id, setup_future_usage, after_expiration = {}, consent_collection = {}, expires_at, phone_number_collection = {}, } = snap.data(); | |
try { | |
logs.creatingCheckoutSession(context.params.id); | |
// Get stripe customer id | |
let customerRecord = (await snap.ref.parent.parent.get()).data(); | |
if (!(customerRecord === null || customerRecord === void 0 ? void 0 : customerRecord.stripeId)) { | |
const { email, phoneNumber } = await admin | |
.auth() | |
.getUser(context.params.uid); | |
customerRecord = await createCustomerRecord({ | |
uid: context.params.uid, | |
email, | |
phone: phoneNumber, | |
}); | |
} | |
const customer = customerRecord.stripeId; | |
if (client === 'web') { | |
// Get shipping countries | |
const shippingCountries = collect_shipping_address | |
? (_b = (_a = (await admin | |
.firestore() | |
.collection(config_1.default.stripeConfigCollectionPath || | |
config_1.default.productsCollectionPath) | |
.doc('shipping_countries') | |
.get()).data()) === null || _a === void 0 ? void 0 : _a['allowed_countries']) !== null && _b !== void 0 ? _b : [] | |
: []; | |
const sessionCreateParams = Object.assign({ billing_address_collection, shipping_address_collection: { allowed_countries: shippingCountries }, shipping_rates, | |
customer, | |
customer_update, line_items: line_items | |
? line_items | |
: [ | |
{ | |
price, | |
quantity, | |
}, | |
], mode, | |
success_url, | |
cancel_url, | |
locale, | |
after_expiration, | |
consent_collection, | |
phone_number_collection }, (expires_at && { expires_at })); | |
if (payment_method_types) { | |
sessionCreateParams.payment_method_types = payment_method_types; | |
} | |
if (mode === 'subscription') { | |
sessionCreateParams.subscription_data = { | |
trial_from_plan, | |
metadata, | |
}; | |
if (!automatic_tax) { | |
sessionCreateParams.subscription_data.default_tax_rates = tax_rates; | |
} | |
} | |
else if (mode === 'payment') { | |
sessionCreateParams.payment_intent_data = Object.assign({ metadata }, (setup_future_usage && { setup_future_usage })); | |
} | |
if (automatic_tax) { | |
sessionCreateParams.automatic_tax = { | |
enabled: true, | |
}; | |
sessionCreateParams.customer_update.name = 'auto'; | |
sessionCreateParams.customer_update.address = 'auto'; | |
sessionCreateParams.customer_update.shipping = 'auto'; | |
} | |
if (tax_id_collection) { | |
sessionCreateParams.tax_id_collection = { | |
enabled: true, | |
}; | |
sessionCreateParams.customer_update.name = 'auto'; | |
sessionCreateParams.customer_update.address = 'auto'; | |
sessionCreateParams.customer_update.shipping = 'auto'; | |
} | |
if (promotion_code) { | |
sessionCreateParams.discounts = [{ promotion_code }]; | |
} | |
else { | |
sessionCreateParams.allow_promotion_codes = allow_promotion_codes; | |
} | |
if (client_reference_id) | |
sessionCreateParams.client_reference_id = client_reference_id; | |
const session = await stripe.checkout.sessions.create(sessionCreateParams, { idempotencyKey: context.params.id }); | |
await snap.ref.set({ | |
client, | |
mode, | |
sessionId: session.id, | |
url: session.url, | |
created: admin.firestore.Timestamp.now(), | |
}, { merge: true }); | |
} | |
else if (client === 'mobile') { | |
let paymentIntentClientSecret = null; | |
let setupIntentClientSecret = null; | |
if (mode === 'payment') { | |
if (!amount || !currency) { | |
throw new Error(`When using 'client:mobile' and 'mode:payment' you must specify amount and currency!`); | |
} | |
const paymentIntentCreateParams = Object.assign({ amount, | |
currency, | |
customer, | |
metadata }, (setup_future_usage && { setup_future_usage })); | |
if (payment_method_types) { | |
paymentIntentCreateParams.payment_method_types = | |
payment_method_types; | |
} | |
else { | |
paymentIntentCreateParams.automatic_payment_methods = | |
automatic_payment_methods; | |
} | |
const paymentIntent = await stripe.paymentIntents.create(paymentIntentCreateParams); | |
paymentIntentClientSecret = paymentIntent.client_secret; | |
} | |
else if (mode === 'setup') { | |
const setupIntent = await stripe.setupIntents.create({ | |
customer, | |
metadata, | |
payment_method_types: payment_method_types !== null && payment_method_types !== void 0 ? payment_method_types : ['card'], | |
}); | |
setupIntentClientSecret = setupIntent.client_secret; | |
} | |
else { | |
throw new Error(`Mode '${mode} is not supported for 'client:mobile'!`); | |
} | |
const ephemeralKey = await stripe.ephemeralKeys.create({ customer }, { apiVersion }); | |
await snap.ref.set({ | |
client, | |
mode, | |
customer, | |
created: admin.firestore.Timestamp.now(), | |
ephemeralKeySecret: ephemeralKey.secret, | |
paymentIntentClientSecret, | |
setupIntentClientSecret, | |
}, { merge: true }); | |
} | |
else { | |
throw new Error(`Client ${client} is not supported. Only 'web' or ' mobile' is supported!`); | |
} | |
logs.checkoutSessionCreated(context.params.id); | |
return; | |
} | |
catch (error) { | |
logs.checkoutSessionCreationError(context.params.id, error); | |
await snap.ref.set({ error: { message: error.message } }, { merge: true }); | |
} | |
}); | |
/** | |
* Create a billing portal link | |
*/ | |
exports.createPortalLink = functions.https.onCall(async (data, context) => { | |
var _a; | |
// Checking that the user is authenticated. | |
const uid = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; | |
if (!uid) { | |
// Throwing an HttpsError so that the client gets the error details. | |
throw new functions.https.HttpsError('unauthenticated', 'The function must be called while authenticated!'); | |
} | |
try { | |
const { returnUrl: return_url, locale = 'auto', configuration } = data; | |
// Get stripe customer id | |
const customer = (await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.doc(uid) | |
.get()).data().stripeId; | |
const params = { | |
customer, | |
return_url, | |
locale, | |
}; | |
if (configuration) { | |
params.configuration = configuration; | |
} | |
const session = await stripe.billingPortal.sessions.create(params); | |
logs.createdBillingPortalLink(uid); | |
return session; | |
} | |
catch (error) { | |
logs.billingPortalLinkCreationError(uid, error); | |
throw new functions.https.HttpsError('internal', error.message); | |
} | |
}); | |
/** | |
* Prefix Stripe metadata keys with `stripe_metadata_` to be spread onto Product and Price docs in Cloud Firestore. | |
*/ | |
const prefixMetadata = (metadata) => Object.keys(metadata).reduce((prefixedMetadata, key) => { | |
prefixedMetadata[`stripe_metadata_${key}`] = metadata[key]; | |
return prefixedMetadata; | |
}, {}); | |
/** | |
* Create a Product record in Firestore based on a Stripe Product object. | |
*/ | |
const createProductRecord = async (product) => { | |
var _a; | |
const _b = product.metadata, { firebaseRole } = _b, rawMetadata = __rest(_b, ["firebaseRole"]); | |
const productData = Object.assign({ active: product.active, name: product.name, description: product.description, role: firebaseRole !== null && firebaseRole !== void 0 ? firebaseRole : null, images: product.images, metadata: product.metadata, tax_code: (_a = product.tax_code) !== null && _a !== void 0 ? _a : null }, prefixMetadata(rawMetadata)); | |
await admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(product.id) | |
.set(productData, { merge: true }); | |
logs.firestoreDocCreated(config_1.default.productsCollectionPath, product.id); | |
}; | |
/** | |
* Create a price (billing price plan) and insert it into a subcollection in Products. | |
*/ | |
const insertPriceRecord = async (price) => { | |
var _a, _b, _c, _d, _e, _f, _g, _h; | |
if (price.billing_scheme === 'tiered') | |
// Tiers aren't included by default, we need to retireve and expand. | |
price = await stripe.prices.retrieve(price.id, { expand: ['tiers'] }); | |
const priceData = Object.assign({ active: price.active, billing_scheme: price.billing_scheme, tiers_mode: price.tiers_mode, tiers: (_a = price.tiers) !== null && _a !== void 0 ? _a : null, currency: price.currency, description: price.nickname, type: price.type, unit_amount: price.unit_amount, recurring: price.recurring, interval: (_c = (_b = price.recurring) === null || _b === void 0 ? void 0 : _b.interval) !== null && _c !== void 0 ? _c : null, interval_count: (_e = (_d = price.recurring) === null || _d === void 0 ? void 0 : _d.interval_count) !== null && _e !== void 0 ? _e : null, trial_period_days: (_g = (_f = price.recurring) === null || _f === void 0 ? void 0 : _f.trial_period_days) !== null && _g !== void 0 ? _g : null, transform_quantity: price.transform_quantity, tax_behavior: (_h = price.tax_behavior) !== null && _h !== void 0 ? _h : null, metadata: price.metadata, product: price.product }, prefixMetadata(price.metadata)); | |
const dbRef = admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(price.product) | |
.collection('prices'); | |
await dbRef.doc(price.id).set(priceData, { merge: true }); | |
logs.firestoreDocCreated('prices', price.id); | |
}; | |
/** | |
* Insert tax rates into the products collection in Cloud Firestore. | |
*/ | |
const insertTaxRateRecord = async (taxRate) => { | |
const taxRateData = Object.assign(Object.assign({}, taxRate), prefixMetadata(taxRate.metadata)); | |
delete taxRateData.metadata; | |
await admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc('tax_rates') | |
.collection('tax_rates') | |
.doc(taxRate.id) | |
.set(taxRateData); | |
logs.firestoreDocCreated('tax_rates', taxRate.id); | |
}; | |
/** | |
* Copies the billing details from the payment method to the customer object. | |
*/ | |
const copyBillingDetailsToCustomer = async (payment_method) => { | |
const customer = payment_method.customer; | |
const { name, phone, address } = payment_method.billing_details; | |
await stripe.customers.update(customer, { name, phone, address }); | |
}; | |
/** | |
* Manage subscription status changes. | |
*/ | |
const manageSubscriptionStatusChange = async (subscriptionId, checkoutSession, createAction) => { | |
var _a, _b; | |
// Get customer's UID from Firestore | |
/*const customersSnap = await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.where('stripeId', '==', customerId) | |
.get();*/ | |
let customersSnap = await admin | |
.firestore() | |
.collection('users') | |
.where('phone', '==', checkoutSession.customer_details.phone) | |
.get(); | |
if (customersSnap.size === 0 /*customersSnap.size !== 1*/) { | |
//throw new Error('User not found!'); | |
//it'll create a user instead. | |
await admin.firestore().collection('users').doc().set({phone: checkoutSession.customer_details.phone, name: checkoutSession.customer_details.name, email: checkoutSession.customer_details.email}, { merge: true }); | |
customersSnap = await admin | |
.firestore() | |
.collection('users') | |
.where('phone', '==', checkoutSession.customer_details.phone) | |
.get(); | |
} | |
const uid = customersSnap.docs[0].id; | |
// Retrieve latest subscription status and write it to the Firestore | |
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { | |
expand: ['default_payment_method', 'items.data.price.product'], | |
}); | |
const price = subscription.items.data[0].price; | |
const prices = []; | |
for (const item of subscription.items.data) { | |
prices.push(admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(item.price.product.id) | |
.collection('prices') | |
.doc(item.price.id)); | |
} | |
const product = price.product; | |
const role = (_a = product.metadata.firebaseRole) !== null && _a !== void 0 ? _a : null; | |
// Get reference to subscription doc in Cloud Firestore. | |
const subsDbRef = customersSnap.docs[0].ref | |
.collection('subscriptions') | |
.doc(subscription.id); | |
// Update with new Subscription status | |
const subscriptionData = { | |
metadata: subscription.metadata, | |
role, | |
status: subscription.status, | |
stripeLink: `https://dashboard.stripe.com${subscription.livemode ? '' : '/test'}/subscriptions/${subscription.id}`, | |
product: admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(product.id), | |
price: admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(product.id) | |
.collection('prices') | |
.doc(price.id), | |
prices, | |
quantity: (_b = subscription.items.data[0].quantity) !== null && _b !== void 0 ? _b : null, | |
items: subscription.items.data, | |
cancel_at_period_end: subscription.cancel_at_period_end, | |
cancel_at: subscription.cancel_at | |
? admin.firestore.Timestamp.fromMillis(subscription.cancel_at * 1000) | |
: null, | |
canceled_at: subscription.canceled_at | |
? admin.firestore.Timestamp.fromMillis(subscription.canceled_at * 1000) | |
: null, | |
current_period_start: admin.firestore.Timestamp.fromMillis(subscription.current_period_start * 1000), | |
current_period_end: admin.firestore.Timestamp.fromMillis(subscription.current_period_end * 1000), | |
created: admin.firestore.Timestamp.fromMillis(subscription.created * 1000), | |
ended_at: subscription.ended_at | |
? admin.firestore.Timestamp.fromMillis(subscription.ended_at * 1000) | |
: null, | |
trial_start: subscription.trial_start | |
? admin.firestore.Timestamp.fromMillis(subscription.trial_start * 1000) | |
: null, | |
trial_end: subscription.trial_end | |
? admin.firestore.Timestamp.fromMillis(subscription.trial_end * 1000) | |
: null, | |
}; | |
await subsDbRef.set(subscriptionData); | |
logs.firestoreDocCreated('subscriptions', subscription.id); | |
// Update their custom claims | |
if (role) { | |
try { | |
// Get existing claims for the user | |
const { customClaims } = await admin.auth().getUser(uid); | |
// Set new role in custom claims as long as the subs status allows | |
if (['trialing', 'active'].includes(subscription.status)) { | |
logs.userCustomClaimSet(uid, 'stripeRole', role); | |
await admin | |
.auth() | |
.setCustomUserClaims(uid, Object.assign(Object.assign({}, customClaims), { stripeRole: role })); | |
} | |
else { | |
logs.userCustomClaimSet(uid, 'stripeRole', 'null'); | |
await admin | |
.auth() | |
.setCustomUserClaims(uid, Object.assign(Object.assign({}, customClaims), { stripeRole: null })); | |
} | |
} | |
catch (error) { | |
// User has been deleted, simply return. | |
return; | |
} | |
} | |
// NOTE: This is a costly operation and should happen at the very end. | |
// Copy the billing deatils to the customer object. | |
if (createAction && subscription.default_payment_method) { | |
await copyBillingDetailsToCustomer(subscription.default_payment_method); | |
} | |
return; | |
}; | |
/** | |
* Add invoice objects to Cloud Firestore. | |
*/ | |
const insertInvoiceRecord = async (invoice) => { | |
var _a; | |
// Get customer's UID from Firestore | |
const customersSnap = await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.where('stripeId', '==', invoice.customer) | |
.get(); | |
if (customersSnap.size !== 1) { | |
throw new Error('User not found!'); | |
} | |
// Write to invoice to a subcollection on the subscription doc. | |
await customersSnap.docs[0].ref | |
.collection('subscriptions') | |
.doc(invoice.subscription) | |
.collection('invoices') | |
.doc(invoice.id) | |
.set(invoice); | |
const prices = []; | |
for (const item of invoice.lines.data) { | |
prices.push(admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(item.price.product) | |
.collection('prices') | |
.doc(item.price.id)); | |
} | |
// An Invoice object does not always have an associated Payment Intent | |
const recordId = (_a = invoice.payment_intent) !== null && _a !== void 0 ? _a : invoice.id; | |
// Update subscription payment with price data | |
await customersSnap.docs[0].ref | |
.collection('payments') | |
.doc(recordId) | |
.set({ prices }, { merge: true }); | |
logs.firestoreDocCreated('invoices', invoice.id); | |
}; | |
/** | |
* Add PaymentIntent objects to Cloud Firestore for one-time payments. | |
*/ | |
const insertPaymentRecord = async (payment, checkoutSession) => { | |
// Get customer's UID from Firestore | |
/*const customersSnap = await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.where('stripeId', '==', payment.customer) | |
.get();*/ | |
let customersSnap = await admin | |
.firestore() | |
.collection('users') | |
.where('phone', '==', checkoutSession.customer_details.phone) | |
.get(); | |
if (customersSnap.size === 0 /*customersSnap.size !== 1*/) { | |
//throw new Error('User not found!'); | |
//it'll create a user instead. | |
await admin.firestore().collection('users').doc().set({phone: checkoutSession.customer_details.phone, name: checkoutSession.customer_details.name, email: checkoutSession.customer_details.email}, { merge: true }); | |
customersSnap = await admin | |
.firestore() | |
.collection('users') | |
.where('phone', '==', checkoutSession.customer_details.phone) | |
.get(); | |
} | |
if (checkoutSession) { | |
const lineItems = await stripe.checkout.sessions.listLineItems(checkoutSession.id); | |
const prices = []; | |
for (const item of lineItems.data) { | |
prices.push(admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(item.price.product) | |
.collection('prices') | |
.doc(item.price.id)); | |
} | |
payment['prices'] = prices; | |
payment['items'] = lineItems.data; | |
} | |
// Write to invoice to a subcollection on the subscription doc. | |
await customersSnap.docs[0].ref | |
.collection('payments') | |
.doc(payment.id) | |
.set(payment, { merge: true }); | |
logs.firestoreDocCreated('payments', payment.id); | |
}; | |
/** | |
* A webhook handler function for the relevant Stripe events. | |
*/ | |
exports.handleWebhookEvents = functions.handler.https.onRequest(async (req, resp) => { | |
var _a; | |
const relevantEvents = new Set([ | |
'product.created', | |
'product.updated', | |
'product.deleted', | |
'price.created', | |
'price.updated', | |
'price.deleted', | |
'checkout.session.completed', | |
'checkout.session.async_payment_succeeded', | |
'checkout.session.async_payment_failed', | |
'customer.subscription.created', | |
'customer.subscription.updated', | |
'customer.subscription.deleted', | |
'tax_rate.created', | |
'tax_rate.updated', | |
'invoice.paid', | |
'invoice.payment_succeeded', | |
'invoice.payment_failed', | |
'invoice.upcoming', | |
'invoice.marked_uncollectible', | |
'invoice.payment_action_required', | |
'payment_intent.processing', | |
'payment_intent.succeeded', | |
'payment_intent.canceled', | |
'payment_intent.payment_failed', | |
]); | |
let event; | |
// Instead of getting the `Stripe.Event` | |
// object directly from `req.body`, | |
// use the Stripe webhooks API to make sure | |
// this webhook call came from a trusted source | |
try { | |
event = stripe.webhooks.constructEvent(req.rawBody, req.headers['stripe-signature'], config_1.default.stripeWebhookSecret); | |
} | |
catch (error) { | |
logs.badWebhookSecret(error); | |
resp.status(401).send('Webhook Error: Invalid Secret'); | |
return; | |
} | |
if (relevantEvents.has(event.type)) { | |
logs.startWebhookEventProcessing(event.id, event.type); | |
try { | |
switch (event.type) { | |
case 'product.created': | |
case 'product.updated': | |
await createProductRecord(event.data.object); | |
break; | |
case 'price.created': | |
case 'price.updated': | |
await insertPriceRecord(event.data.object); | |
break; | |
case 'product.deleted': | |
await deleteProductOrPrice(event.data.object); | |
break; | |
case 'price.deleted': | |
await deleteProductOrPrice(event.data.object); | |
break; | |
case 'tax_rate.created': | |
case 'tax_rate.updated': | |
await insertTaxRateRecord(event.data.object); | |
break; | |
case 'customer.subscription.created': | |
case 'customer.subscription.updated': | |
case 'customer.subscription.deleted': | |
const subscription = event.data.object; | |
await manageSubscriptionStatusChange(subscription.id, subscription.customer, event.type === 'customer.subscription.created'); | |
break; | |
case 'checkout.session.completed': | |
case 'checkout.session.async_payment_succeeded': | |
case 'checkout.session.async_payment_failed': | |
const checkoutSession = event.data | |
.object; | |
if (checkoutSession.mode === 'subscription') { | |
const subscriptionId = checkoutSession.subscription; | |
await manageSubscriptionStatusChange(subscriptionId, checkoutSession, true); | |
} | |
else { | |
const paymentIntentId = checkoutSession.payment_intent; | |
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId); | |
await insertPaymentRecord(paymentIntent, checkoutSession); | |
} | |
if ((_a = checkoutSession.tax_id_collection) === null || _a === void 0 ? void 0 : _a.enabled) { | |
const customersSnap = await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.where('stripeId', '==', checkoutSession.customer) | |
.get(); | |
if (customersSnap.size === 1) { | |
customersSnap.docs[0].ref.set(checkoutSession.customer_details, { merge: true }); | |
} | |
} | |
break; | |
case 'invoice.paid': | |
case 'invoice.payment_succeeded': | |
case 'invoice.payment_failed': | |
case 'invoice.upcoming': | |
case 'invoice.marked_uncollectible': | |
case 'invoice.payment_action_required': | |
const invoice = event.data.object; | |
await insertInvoiceRecord(invoice); | |
break; | |
case 'payment_intent.processing': | |
case 'payment_intent.succeeded': | |
case 'payment_intent.canceled': | |
case 'payment_intent.payment_failed': | |
const paymentIntent = event.data.object; | |
await insertPaymentRecord(paymentIntent); | |
break; | |
default: | |
logs.webhookHandlerError(new Error('Unhandled relevant event!'), event.id, event.type); | |
} | |
if (eventChannel) { | |
await eventChannel.publish({ | |
type: `com.stripe.v1.${event.type}`, | |
data: event.data.object, | |
}); | |
} | |
logs.webhookHandlerSucceeded(event.id, event.type); | |
} | |
catch (error) { | |
logs.webhookHandlerError(error, event.id, event.type); | |
resp.json({ | |
error: 'Webhook handler failed. View function logs in Firebase.', | |
}); | |
return; | |
} | |
} | |
// Return a response to Stripe to acknowledge receipt of the event. | |
resp.json({ received: true }); | |
}); | |
const deleteProductOrPrice = async (pr) => { | |
if (pr.object === 'product') { | |
await admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(pr.id) | |
.delete(); | |
logs.firestoreDocDeleted(config_1.default.productsCollectionPath, pr.id); | |
} | |
if (pr.object === 'price') { | |
await admin | |
.firestore() | |
.collection(config_1.default.productsCollectionPath) | |
.doc(pr.product) | |
.collection('prices') | |
.doc(pr.id) | |
.delete(); | |
logs.firestoreDocDeleted('prices', pr.id); | |
} | |
}; | |
const deleteStripeCustomer = async ({ uid, stripeId, }) => { | |
try { | |
// Delete their customer object. | |
// Deleting the customer object will immediately cancel all their active subscriptions. | |
await stripe.customers.del(stripeId); | |
logs.customerDeleted(stripeId); | |
// Mark all their subscriptions as cancelled in Firestore. | |
const update = { | |
status: 'canceled', | |
ended_at: admin.firestore.Timestamp.now(), | |
}; | |
// Set all subscription records to canceled. | |
const subscriptionsSnap = await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.doc(uid) | |
.collection('subscriptions') | |
.where('status', 'in', ['trialing', 'active']) | |
.get(); | |
subscriptionsSnap.forEach((doc) => { | |
doc.ref.set(update, { merge: true }); | |
}); | |
} | |
catch (error) { | |
logs.customerDeletionError(error, uid); | |
} | |
}; | |
/* | |
* The `onUserDeleted` deletes their customer object in Stripe which immediately cancels all their subscriptions. | |
*/ | |
exports.onUserDeleted = functions.auth.user().onDelete(async (user) => { | |
if (!config_1.default.autoDeleteUsers) | |
return; | |
// Get the Stripe customer id. | |
const customer = (await admin | |
.firestore() | |
.collection(config_1.default.customersCollectionPath) | |
.doc(user.uid) | |
.get()).data(); | |
// If you use the `delete-user-data` extension it could be the case that the customer record is already deleted. | |
// In that case, the `onCustomerDataDeleted` function below takes care of deleting the Stripe customer object. | |
if (customer) { | |
await deleteStripeCustomer({ uid: user.uid, stripeId: customer.stripeId }); | |
} | |
}); | |
/* | |
* The `onCustomerDataDeleted` deletes their customer object in Stripe which immediately cancels all their subscriptions. | |
*/ | |
exports.onCustomerDataDeleted = functions.firestore | |
.document(`/${config_1.default.customersCollectionPath}/{uid}`) | |
.onDelete(async (snap, context) => { | |
if (!config_1.default.autoDeleteUsers) | |
return; | |
const { stripeId } = snap.data(); | |
await deleteStripeCustomer({ uid: context.params.uid, stripeId }); | |
}); | |
//# sourceMappingURL=index.js.map |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment