Go Serverless, Part 2: Manage Payment Refunds in your Apps with Cloud Functions for Firebase
“I want my money back!”
Note: This is the second part of my blog on managing payments using Cloud Functions and Stripe. If you haven’t checked out the first part yet, you can find it here.
Every business wants their customers to be 100% satisfied. But unfortunately, there will always be times where things don’t quite go right. If your application manages payments, there will most certainly be times where you’ll have to handle refunds. In my previous post, I outlined how to use Cloud Functions for Firebase and Stripe to manage payments. In this post, I’ll show you one way to handle refunds using this model.
Adding Payment Managers
When a customer requests a refund from my (entirely made-up) Goat Landscaping business, a write is made to a Cloud Firestore collection called refund_requests
. Any user can write to this collection and view their own requests, but only a designated refunder can update it; I don’t want users to be able to approve their own refunds.
There are a few ways that I could designate a refund approval handler. In this case, I’ve chosen to use Firebase Auth custom claims, which are key/value pairs that are found in the user’s Firebase ID token.
Shameless plug: I made a blog and video on Firebase Auth custom claims, so I’d suggest you check out one of those or the Firebase guides to better understand how I implement claims.
I use an HTTP callable Cloud Function to be able to add new refunders from the client.
exports.createModerator = functions.https.onCall(async (data, context) => {})
This enables me to access relevant information about the user who is making the call, as this information is passed automatically in the request. It also returns a response, so I can find out if the desired action was successful. You may want to use some sort of error codes in your response, but for my example, I’m just going to send back a string to inform the client of the result.
I’ve set up my code so that only a current refunder can designate new refunders by checking the refunder
claim on the user’s token before proceeding. I could call this claim anything, and you may feel that a title like approver
or owner
makes more sense to you. Maybe even spaghetti-chef
. I haven’t met you, so who knows? Use names for your claims that are descriptive to you as the developer. I just happen to like this one.
If the user making the request is not a refunder, I return a response with the description detailing the error.
if (context.auth.token.refunder !== true) {
return {
response: “Permission denied. User is not a refunder”
};
}
I get the email of the user being upgraded to refunder. The client passes this information as part of its call to the function. If this property is missing, I return a response to the client letting them know.
const email = data.email;
if (!email) {
return {
response: “please include user’s email”
};
}
I use the Firebase Auth Admin SDK function getUserByEmail
to get the Firebase user who has the given email. If no user is found, again I return a response for this.
if (!user) {
return {
response: `no user found for ${email}`
};
}
Finally, I add the refunder claim to the user with the Admin SDK’s setCustomUserClaims
method. I end by returning the response to the client.
const uid = user.uid;await admin.auth().setCustomUserClaims(uid, {refunder: true});
return {
response: `${email} is now a refunder`
};
Here you can see the complete code example:
exports.createModerator = functions.https.onCall(async (data, context) => {
if (context.auth.token.refunder !== true) {
return {
response: “Permission denied. User is not a refunder”
};
} const email = data.email;
if (!email) {
return {
response: “please include user’s email”
};
} const user = await admin.auth().getUserByEmail(email);
if (!user) {
return {
response: `no user found for ${email}`
};
} const uid = user.uid;
await admin.auth().setCustomUserClaims(uid, {refunder: true});
return {
response: `${email} is now a refunder`
};
});
Making a Refund Request
Now that refunders can be designated, let’s move on to the refund process. First, customers need a way to request a refund if they feel they have been wronged. (But to be fair, if they didn’t want my goats to eat the begonias, they should have said so.)
Refund requests enter a queue in a collection called refund_requests
. Each document has a unique document ID created by Cloud Firestore and has the following fields:
amount:
approvedAt:
chargeId:
closedAt:
request_details:
userId:
userRequestedAt:
The initial refund request doesn’t require a Cloud Function as I have it set up now. It could be neat to set up a Cloud Function that triggers on the creation of a request and notifies the refunders that a new refund request came in. Honestly, I just didn’t feel like writing it unless you were really interested in seeing it. Time is money and money is nachos, and I’ll only spend my nachos upon request. Comment below if you’d like to see a function notifying refunders of requests, and I’ll put that in a future blog.
Refunders can see the refund_requests
queue and approve requests. I could use an onUpdate()
Cloud Function which triggers when changes have been made to the request, but there are a couple reasons I’m not doing this. For one, the auth
property is not included in the context of Cloud Firestore triggers, so it’s tougher to verify that the change was properly approved. For another, an onUpdate()
function will trigger on any changes to the document, and since I want to write additional information back to the same document, I don’t want to run the function if I don’t need to. I can return out of the function and prevent infinite calls, but I’m still getting charged for that second call. Maybe that makes me a miser, but as Scrooge always says, bah, humbug! I’d rather not spend the money if I don’t have to. As I said, money is nachos and I like to eat!
exports.refundCharge = functions.https.onCall(async (data, context) => {});
First, I verify that the user who approved the request is a refunder.
if (context.auth.token.refunder !== true) {
return {
response: “refund could not be processed. User not a refunder”
};
}
Next, I get the ID of the charge to be refunded.
const chargeId = data.chargeId;
if (!chargeId) {
return {
response: “please include the ID of the charge to refund”
};
}
Finally, I complete the refund using the Stripe API,
const response = await stripe.refunds.create({charge: data.chargeId});
and send a response back to the user letting them know if it was successful. I also write back some data about the refund to Cloud Firestore so I can keep track of what has been refunded.
if (response.status === “succeeded”) {
await change.after.ref.set({refundedAt: Date.now(), response}, {merge: true});
return {
response: “refund successful”
};
} else {
await change.after.ref.set({attempedAt: Date.now(), response}, {merge: true});
return {
response: “refund could not be processed”
};
}
Here’s the whole shebang to check out:
exports.refundCharge = functions.https.onCall(async (data, context) => {
if (context.auth.token.refunder !== true) {
return {
response: “refund could not be processed. User not a refunder”
};
} const chargeId = data.chargeId;
if (!chargeId) {
return {
response: “please include the ID of the charge to refund”
};
} const response = await stripe.refunds.create({charge: data.chargeId});
if (response.status === “succeeded”) {
await change.after.ref.set({refundedAt: Date.now(), response}, {merge: true});
return {
response: “refund successful”
};
} else {
await change.after.ref.set({attempedAt: Date.now(), response}, {merge: true});
return {
response: “refund could not be processed”
};
}
});
Cloud Firestore Rules!
I love talking about security in Cloud Firestore because it’s easy to make puns about the word “rules”. That’s why I couldn’t miss this opportunity to mention security!
Actually, I mention security because rules really do rule. Designing rules that limit data access is never optional. When designing the structure of my database, I always start with security in mind.
It can be really tempting just to set the most basic rules to start with so that you can dive into coding, but I promise you this will be a mistake. If you’ve created a database structure that makes it hard to incorporate rules, and then try to add rules in later, it’s going to be really difficult. It’ll be like needing socks, buying gloves instead, and then trying to get the gloves to fit on your feet. It’s much easier to start by evaluating what you need before you go shopping. Rules are like that. Sort of.
With that in mind, let’s talk about how I’ve set up my rules. I have rules implemented for other parts of the database, but I’d like to focus on the refund_requests
collection. The rules are as follows:
service cloud.firestore {
match /databases/{database}/documents {
// …
match /refund_requests/{refundId} {
allow read: if request.auth.uid == resource.data.userId || request.auth.token.refunder == true;
allow create: if request.auth.uid == request.resource.data.userId && request.resource.data.approvedAt == null;
}
}
}
The first rule allows users to read a request if it belongs to them. That is, the user requesting to read the data has a Firebase user ID equal to the userId field of the document they want to read. A user can also read a request if they are a designated refunder. I can check for the presence of the refunder claim just like I did in the Cloud Function: using request.auth.token.refunder
. This lets refunders to see and manage all requests.
The second rule allows a user to create a document if the document they are trying to create includes a userId
field equal to their Firebase user ID, and has an approvedAt
field that is set to null. This ensures that users are unable to falsely write that the request was approved, and also serves to ensure all requests include identification of the user making the request.
Notice that I don’t allow any users to update or delete from this collection at all. Updates to the collection occur in a Cloud Function, as I showed earlier. The client doesn’t ever need to have this kind of access.
Here are all of my security rules related to making payments:
service cloud.firestore {
match /databases/{database}/documents {
match /stripe_customers/{uid} {
allow read: if request.auth.uid == uid;
allow write: if request.auth.uid == uid match /sources/{sourceId} {
allow read: if request.auth.uid == uid;
} match /tokens/{sourceId} {
allow read,write: if request.auth.uid == uid;
}
match /charges/{chargeId} {
allow read, write: if request.auth.uid == uid;
}
} match /refund_requests/{refundId} {
allow read: if request.auth.uid == resource.data.userId || request.auth.token.refunder == true;
allow create: if request.auth.uid == request.resource.data.userId && request.resource.data.approvedAt == null;
}
}
}
You may have noticed that all of the subcollections of stripe_customers
have the same rules for read access. If you’re familiar with rules already, you might suggest that I create a cascading wildcard rule that applies to all of the subcollections, like this:
match /stripe_customers/{uid=**} {
allow read: if request.auth.uid = uid;
}
However, I would advise against this. Whenever possible, start with the most limited rules, and whitelist collections as needed. As my app gets more complex, I could end up adding some subcollection of stripe_customers
that I wouldn’t want to have read access. I would have a security risk if I didn’t notice that my rules were granting access to the new collection. It may seem obvious when my rules are short like the ones above, but if I have lots of collections of documents with their own subcollections, it’s not so obvious anymore. I prefer to err on the side of caution. Except when I’m dancing. Then I throw caution to the wind.
Putting it all together
In this two-part blog, I showed you how you can manage payments using Cloud Firestore, Cloud Functions, and Stripe. You’ve seen how Firebase Auth works with Cloud Firestore to identify users and secure data. Have you used Firebase with a payment processing system? Do you have a suggestion for a future blog post? Or do you just want to chat about all things Firebase-y? I want to hear about it! You can find me on Twitter at @ThatJenPerson.