App Store Server Notifications and Firebase Cloud Functions

Viewed 654

When I explored different options for my lockdown-development project, I considered using App Store Server Notifications to inform my Firebase data base about subscriptions purchased in the App. Cloud Functions were supposed to receive the notification JSONs from Apple, interpret them, update the data base, and to respond to Apple. Apple and Google provide a lot of information about App Store Server Notifications and Cloud Functions, but linking them together can still be time consuming. Both companies describe well how to set their services up, and you will find an example of my code below. I ended up not using it, and Google migrated Cloud Functions to Node.js 10 in the meantime, but my code might still be useful to somebody:

// The Cloud Functions for Firebase SDK to create Cloud Functions and setup triggers.
const functions = require('firebase-functions');

// The Firebase Admin SDK to access the Firebase Realtime Database.
const admin = require('firebase-admin');
admin.initializeApp();

// The Express Functions to create an Express application.
const express = require('express');
const app = express();

// Add a generic JSON and URL-encoded parser as top-level middleware.
const bodyParser = require('body-parser'); 

// Parse application/x-www-form-urlencoded.
app.use(bodyParser.urlencoded({ extended: false }));
 
// Parse application/json.
app.use(bodyParser.json());

// Empty function for Timeout
function wait(ms) { 

        var d = new Date();
        var d2 = null;
        do { d2 = new Date(); }
        while(d2-d < ms);

}

// Parse JSON and return relevant notification data.
function parseJSON(request) {

    // returnValue[0] = environment
    // returnValue[1] = notification_type
    // returnValue[2] = original_transaction_id
    // returnValue[3] = transaction_id
    // returnValue[4] = latest_expired_receipt
    // returnValue[5] = purchase_date_ms
    // returnValue[6] = cancellation_date_ms
    // returnValue[7] = expires_date
    // returnValue[8] = grace_period_expires_date_ms
    // returnValue[9] = product_id


    // Carefully unwrap JSON
    if(request) {
        try {
            // Declare local variables
            const obj = JSON.parse(request.rawBody);
            var original_transaction_id = 'Empty';
            var transaction_id = 'Empty';
            var purchase_date_ms = 'Empty';
            var expires_date = 'Empty';
            var grace_period_expires_date_ms = 'Empty';
            var product_id = 'Empty';

            // Unwrap variables in responseBody
            var environment = (obj.environment) ? obj.environment : 'Empty';
            var notification_type = (obj.notification_type) ? obj.notification_type : 'Empty';
            var latest_expired_receipt = (obj.latest_expired_receipt) ? obj.latest_expired_receipt : 'Empty';
            var cancellation_date_ms = (obj.cancellation_date_ms) ? obj.cancellation_date_ms : 'Empty';

            // Make sure latest_receipt_info is there before unwrapping
            if (obj.latest_receipt_info) {
                original_transaction_id = (obj.latest_receipt_info.original_transaction_id) ? obj.latest_receipt_info.original_transaction_id : 'Empty';
                transaction_id = (obj.latest_receipt_info.transaction_id) ? obj.latest_receipt_info.transaction_id : 'Empty';
                purchase_date_ms = (obj.latest_receipt_info.purchase_date_ms) ? obj.latest_receipt_info.purchase_date_ms : 'Empty';
                expires_date = (obj.latest_receipt_info.expires_date) ? obj.latest_receipt_info.expires_date : 'Empty';
                product_id = (obj.latest_receipt_info.product_id) ? obj.latest_receipt_info.product_id : 'Empty';
            }

            // Make sure pending_renewal_info is there before unwrapping
            if (obj.pending_renewal_info) {
                grace = (obj.pending_renewal_info.grace_period_expires_date_ms) ? obj.pending_renewal_info.grace_period_expires_date_ms : 'Empty';
            } 

            return returnValue = [environment, notification_type, original_transaction_id, transaction_id, latest_expired_receipt, purchase_date_ms, cancellation_date_ms,                          expires_date, grace_period_expires_date_ms, product_id];

            } catch (error) {
                throw (error);
            }
        }

    return returnValue = ['Empty', 'Empty', 'Empty', 'Empty', 'Empty', 'Empty', 'Empty', 'Empty', 'Empty', 'Empty'];

}

// Function passes request to app
exports.iapStatusUpdate = functions.https.onRequest(async (request, response) => {

    // Constants
    const userID = [];
    const currentDate = admin.firestore.FieldValue.serverTimestamp();
    const collectionUsers = 'myData';
    const collectionNotifications = 'myNotifications';

    // Parse JSON and get relevant notification data.
    try {
        var parsedJSON = parseJSON(request);
    } catch (error) {
        // The server cannot or will not process the request due to an apparent client error.
        response.status(400).send(error);
    }

    // Save relevant notification data.
    try {
        const writeResult = await admin.firestore().collection(collectionNotifications).add(    {"environment": parsedJSON[0], 
                                                "notification_type": parsedJSON[1], 
                                                "original_transaction_id": parsedJSON[2],
                                                "transaction_id": parsedJSON[3],  
                                                "latest_expired_receipt": parsedJSON[4],
                                                "purchase_date_ms": parsedJSON[5],
                                                "cancellation_date_ms": parsedJSON[6],
                                                "expires_date": parsedJSON[7],
                                                "grace_period_expires_date_ms": parsedJSON[8],
                                                "product_id": parsedJSON[9],
                                                "date": currentDate});
    } catch (error) {
        // A generic error message (Write failed)
        response.status(500).send(error);
    }

    // Query document from database
    try {

        // In the case of an initial buy, pause execution for 3 seconds to allow Firebase to update record first
        if (parsedJSON[1].includes("INITIAL_BUY")) {
            wait(3000);
        }

        // Query document from database and write document id in userID array
        const snapshot = await admin.firestore().collection(collectionUsers).where("original_transaction_id", '==', parsedJSON[2]).get();
        snapshot.docs.map(doc => userID.push(doc.id));

    } catch (error) {

        // This failure does not necessarily mean that a real error occurred 
        // INITIAL_BUY: The App Store notification could have arrived before Firebase saved the original_transaction_id 
        // In all other cases: The original_transaction_id should be known and a user with this id should exist
        if (parsedJSON[1].includes("INITIAL_BUY")) {
            response.status(200).send('OK');
        } else {
            response.status(404).send(error);
        }   

    }   

    // Update database
    try {
        // If the notification type is cancel, downgrade user from premium to free
        if (parsedJSON[1].includes("CANCEL")) {
            const writeEvent = await admin.firestore().collection(collectionUsers).doc(userID[0]).set(  {"canceled": true, 
                                                        "cancellation_date_ms": parsedJSON[6], 
                                                        "premium": false}, { merge: true });
        } else if (parsedJSON[1].includes("INITIAL_BUY")) {
            const writeEvent = await admin.firestore().collection(collectionUsers).doc(userID[0]).set(  {"confirmedPurchase": true, 
                                                        "confirmedPurchaseOn": parsedJSON[5], 
                                                        "purchase_date_ms": parsedJSON[5],
                                                        "expires_date": parsedJSON[7],
                                                        "premium": true}, { merge: true });
        } else if (parsedJSON[1].includes("INTERACTIVE_RENEWAL") || parsedJSON[1].includes("DID_RECOVER") ) {
            const writeEvent = await admin.firestore().collection(collectionUsers).doc(userID[0]).set(  {"renewedPurchaseOn": parsedJSON[5], 
                                                        "billingRetryPeriod": true, 
                                                        "expires_date": parsedJSON[7],
                                                        "premium": true}, { merge: true });
        } else if (parsedJSON[1].includes("DID_FAIL_TO_RENEW")) {
            const writeEvent = await admin.firestore().collection(collectionUsers).doc(userID[0]).set(  {"billingRetryPeriod": true, 
                                                        "grace_period_expires_date_ms": returnValue[8],
                                                        "premium": true}, { merge: true });
       }
   } catch (error) {
       // A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
       response.status(500).send(error);
   }

    // Send final response
    response.status(200).send('OK');

});

Improvements and suggestions are most welcome.

0 Answers
Related