Wednesday, 2 June 2021

How can I make an Idempotent Callable Function with Firebase Firestore?

Sometimes I'm getting duplicated documents from a callable function that looks like this:

const { default: Big } = require('big.js');
const { firestore } = require('firebase-admin');
const functions = require('firebase-functions');
const { createLog } = require('./utils/createLog');
const { payCart } = require('./utils/payCart');
const { unlockCart } = require('./utils/unlockCart');

exports.completeRechargedTransaction = functions.https.onCall(
  async (data, context) => {
    try {
      if (!context.auth) {
        throw new functions.https.HttpsError(
          'unauthenticated',
          'unauthenticated'
        );
      }

      const requiredProperties = [
        'foo',
        'bar',
        'etc'
      ];

      const isDataValid = requiredProperties.every(prop => {
        return Object.keys(data).includes(prop);
      });

      if (!isDataValid) {
        throw new functions.https.HttpsError(
          'failed-precondition',
          'failed-precondition'
        );
      }

      const transactionRef = firestore()
        .collection('transactions')
        .doc(data.transactionID);

      const userRef = firestore().collection('users').doc(data.paidBy.userID);

      let currentTransaction = null;

      await firestore().runTransaction(async transaction => {
        try {
          const transactionSnap = await transaction.get(transactionRef);

          if (!transactionSnap.exists) {
            throw new functions.https.HttpsError(
              'not-found',
              'not-found'
            );
          }

          const transactionData = transactionSnap.data();

          if (transactionData.status !== 'recharged') {
            throw new functions.https.HttpsError(
              'invalid-argument',
              'invalid-argument'
            );
          }

          if (transactionData.type !== 'recharge') {
            throw new functions.https.HttpsError(
              'invalid-argument',
              'invalid-argument'
            );
          }

          if (transactionData.paidBy === null) {
            throw new functions.https.HttpsError(
              'invalid-argument',
              'invalid-argument',
            );
          }

          const userSnap = await transaction.get(userRef);

          if (!userSnap.exists) {
            throw new functions.https.HttpsError(
              'not-found',
              'not-found',
            );
          }

          const userData = userSnap.data();

          const newUserPoints = new Big(userData.points).plus(data.points);

          if (!data.isGoldUser) {
            transaction.update(userRef, {
              points: parseFloat(newUserPoints.toFixed(2))
            });
          }

          currentTransaction = {
            ...data,
            remainingBalance: parseFloat(newUserPoints.toFixed(2)),
            status: 'completed'
          };

          transaction.update(transactionRef, currentTransaction);
        } catch (error) {
          console.error(error);
          throw error;
        }
      });

      const { paymentMethod } = data.rechargeDetails;

      let cashAmount = 0;

      if (paymentMethod && paymentMethod.paymentMethod === 'cash') {
        cashAmount = data.points;
      }

      let cartResponse = null;

      if (
        data.rechargeDetails.isProcessingCart &&
        Boolean(data.paidBy.userID) &&
        !data.isGoldUser
      ) {
        cartResponse = await payCart(context, data.paidBy.userID, cashAmount); 
        // This is the function that does all the writes and for some reason it is getting
        // called twice or thrice in some rare cases, and I'm pretty much sure that 
        // The Angular Client is only calling this function "completeRechargedTransaction " once.
      }

      await createLog({
        message: 'Success',
        createdAt: new Date(),
        type: 'activity',
        collectionName: 'transactions',
        callerID: context.auth.uid || null,
        docID: transactionRef.id
      });

      return {
        code: 200,
        message: 'Success',
        transaction: currentTransaction,
        cartResponse
      };
    } catch (error) {
      console.error(error);

      await unlockCart(data.paidBy.userID);

      await createLog({
        message: error.message,
        createdAt: new Date(),
        type: 'error',
        collectionName: 'transactions',
        callerID: context.auth.uid || null,
        docID: data.transactionID,
        errorSource:
          'completeRechargedTransaction'
      });

      throw error;
    }
  }
);

I'm reading a lot of firebase documentation, but I can't find a solution to implement idempotency on my callable functions, the context parameter in callable function is very different from background functions and triggers, the callable context looks like this:

https://firebase.google.com/docs/reference/functions/providers_https_.callablecontext

I did find a helpful blogpost to implement idempotency with firebase triggers:

Cloud Functions pro tips: Building idempotent functions

But I don't fully understand this approach because I think it's assuming that the document writes are made on the client aka the front end application, and I don't really think that's a good approach because is it too reliant on the client and I'm afraid of security issues as well.

So yeah, I would like to know is there's a way to implement Idempotency on Callable Functions, I need something like an EventID but for callable functions to safely implement payments on my app and third party apis, such as stripe.

I will appreciate any help or hint you can give me.



from How can I make an Idempotent Callable Function with Firebase Firestore?

No comments:

Post a Comment