From efc6640ab4d4cda3b92809d20f47ff490e4247b2 Mon Sep 17 00:00:00 2001
From: Dominik Hebeler <dominik@hebeler.club>
Date: Sat, 10 Dec 2022 22:59:49 +0100
Subject: [PATCH] started integration for webhooks

---
 pass/app/Order.js              | 115 ++++++++++++++++------------
 pass/config/default.json       |   3 +
 pass/routes/checkout/paypal.js | 132 ++++++++++++++++++++++-----------
 3 files changed, 160 insertions(+), 90 deletions(-)

diff --git a/pass/app/Order.js b/pass/app/Order.js
index 70ff322..2a3a26d 100644
--- a/pass/app/Order.js
+++ b/pass/app/Order.js
@@ -4,6 +4,10 @@ const dayjs = require("dayjs");
 const path = require("path");
 
 class Order {
+  static get STORAGE_MUTEX_KEY_PREFIX() {
+    return "order_mutex";
+  }
+
   static get STORAGE_KEY_PREFIX() {
     return "order_";
   }
@@ -100,47 +104,62 @@ class Order {
   }
 
   static async LOAD_ORDER_FROM_ID(order_id) {
+    let Redis = require("ioredis");
+    let redis_client = new Redis({
+      host: config.get("redis.host"),
+    });
     return new Promise((resolve, reject) => {
-      let Redis = require("ioredis");
-      let redis_client = new Redis({
-        host: config.get("redis.host"),
-      });
-
-      let redis_key = Order.STORAGE_KEY_PREFIX + order_id;
-
-      redis_client.hgetall(redis_key).then((order_data) => {
-        console.log(Object.keys(order_data).length);
-        if (Object.keys(order_data).length === 0) {
-          // Checking FS for order
-          let order_date = dayjs.unix(order_id.substr(0, 10));
-          let order_file = path.join(
-            Order.GET_ORDER_FILE_BASE_PATH(order_date),
-            order_id.toString() + ".json"
-          );
-          let fs = require("fs");
-          console.log("Loading from fs: " + order_file);
-          if (fs.existsSync(order_file)) {
-            order_data = JSON.parse(fs.readFileSync(order_file));
-            console.log(order_data);
+      redis_client
+        .setnx(Order.STORAGE_MUTEX_KEY_PREFIX + order_id, 1)
+        .then((mutex) => {
+          console.log(mutex);
+          if (mutex !== 1) {
+            // Could not acquire lock. Try again
+            throw "LOCK_NOT_ACQUIRED";
           } else {
-            return reject("Could not find Order in our database! Checking FS");
+            return redis_client.expire(Order.STORAGE_MUTEX_KEY_PREFIX, 15);
           }
-        }
-        let loaded_order = new Order(
-          order_data.order_id,
-          order_data.amount,
-          order_data.price
-        );
-        if (order_data.payment_method_link) {
-          loaded_order.setPaymentMethodLink(
-            JSON.parse(order_data.payment_method_link)
+        })
+        .then(() => redis_client.hgetall(Order.STORAGE_KEY_PREFIX + order_id))
+        .then((order_data) => {
+          if (Object.keys(order_data).length === 0) {
+            // Checking FS for order
+            let order_date = dayjs.unix(order_id.substr(0, 10));
+            let order_file = path.join(
+              Order.GET_ORDER_FILE_BASE_PATH(order_date),
+              order_id.toString() + ".json"
+            );
+            let fs = require("fs");
+            if (fs.existsSync(order_file)) {
+              order_data = JSON.parse(fs.readFileSync(order_file));
+            } else {
+              throw "Could not find Order in our database! Checking FS";
+            }
+          }
+          console.log(order_data);
+          let loaded_order = new Order(
+            order_data.order_id,
+            order_data.amount,
+            order_data.price
           );
-        }
-        if (order_data.payment_completed) {
-          loaded_order.setPaymentCompleted(true);
-        }
-        resolve(loaded_order);
-      });
+          if (order_data.payment_method_link) {
+            loaded_order.setPaymentMethodLink(
+              JSON.parse(order_data.payment_method_link)
+            );
+          }
+          if (order_data.payment_completed) {
+            loaded_order.setPaymentCompleted(true);
+          }
+          resolve(loaded_order);
+        })
+        .catch((reason) => {
+          if (reason === "LOCK_NOT_ACQUIRED") {
+            console.log("lock not acquired");
+            setTimeout(Order.LOAD_ORDER_FROM_ID(order_id), 5000);
+          } else {
+            reject(reason);
+          }
+        });
     });
   }
 
@@ -168,12 +187,14 @@ class Order {
       if (!fs.existsSync(path.dirname(order_file))) {
         fs.mkdirSync(path.dirname(order_file), { recursive: true });
       }
-      this.#redis_client.del(redis_key).then(() => {
-        return fs.writeFileSync(
-          order_file,
-          JSON.stringify(stored_data, null, 4)
-        );
-      });
+      let redis_client = this.#redis_client;
+      let mutex_key = Order.STORAGE_MUTEX_KEY_PREFIX + this.#order_id;
+      return this.#redis_client
+        .del(redis_key)
+        .then(() =>
+          fs.writeFileSync(order_file, JSON.stringify(stored_data, null, 4))
+        )
+        .then(() => redis_client.del(mutex_key));
     } else {
       // Store Order in Redis
       let expiration = new dayjs();
@@ -185,6 +206,7 @@ class Order {
         .pipeline()
         .hmset(redis_key, stored_data)
         .expireat(redis_key, expiration.unix())
+        .del(Order.STORAGE_MUTEX_KEY_PREFIX + this.#order_id)
         .exec();
     }
   }
@@ -216,13 +238,10 @@ class Order {
     let order_mutex = Math.floor(Math.random() * 10000);
     do {
       // make sure this order_id is not already registered
-      let order_lock = await redis_connection.setnx(
-        "" + order_base + order_mutex,
-        true
-      );
+      order_id = "" + order_base + order_mutex;
+      let order_lock = await redis_connection.setnx(order_id, true);
       if (order_lock === 1) {
         await redis_connection.expire(order_id, 5);
-        order_id = "" + order_base + order_mutex;
       } else {
         console.log("Couldn't acquire lock");
         order_mutex++;
diff --git a/pass/config/default.json b/pass/config/default.json
index ecaad1b..35f9aa0 100644
--- a/pass/config/default.json
+++ b/pass/config/default.json
@@ -1,4 +1,7 @@
 {
+    "app": {
+        "url": "http://localhost:8080"
+    },
     "price": {
         "per_300": 5
     },
diff --git a/pass/routes/checkout/paypal.js b/pass/routes/checkout/paypal.js
index ada0070..7ec7c3e 100644
--- a/pass/routes/checkout/paypal.js
+++ b/pass/routes/checkout/paypal.js
@@ -121,47 +121,31 @@ router.post("/:funding_source/order/cancel", async (req, res) => {
 
 // capture payment & store order information or fullfill order
 router.post("/:funding_source/order/capture", async (req, res) => {
-  Order.LOAD_ORDER_FROM_ID(req.body.order_id).then((loaded_order) => {
-    loaded_order.setPaymentCompleted(true);
-    let paypal_order = loaded_order.getPaymentMethodLink();
-    if (paypal_order.name !== "paypal") {
-      res
-        .status(400)
-        .json({ errors: [{ msg: "This order is not a PayPal Payment" }] });
-      return;
-    }
-    let key_filled = false;
-    loaded_order
-      .save()
-      .then(() => {
-        return Key.CHARGE_KEY(req.data.key.key, req.data.checkout.amount); // ToDo verify amount
-      })
-      .then((key_charge) => {
-        key_filled = true;
-        return key_charge;
-      })
-      .then((key_charge) => {
-        return capturePayment(paypal_order.order_id);
-      })
-      .then((captureData) => {
-        if (captureData.status === "COMPLETED") {
-          captureData.redirect_url = "/key/" + req.data.key.key;
-          res.json(captureData);
-        } else {
-          res
-            .status(400)
-            .json({ errors: [{ msg: "Couldn't capture the payment" }] });
-        }
-      })
-      .catch((error) => {
-        // We captured a payment but did not successfully update the order
-        if (key_filled) {
-          // Capture failed... Remove searches from key again
-          console.log("Removing searches from key again");
-        }
-        res.status(400).json({ errors: [{ msg: error.toString() }] });
-      });
-  });
+  verifyWebhook()
+    .then(() => {
+      return Order.LOAD_ORDER_FROM_ID(req.body.order_id);
+    })
+    .then((loaded_order) => {
+      let paypal_order = loaded_order.getPaymentMethodLink();
+      capturePayment(paypal_order.order_id)
+        .then((captureData) => {
+          if (captureData.status === "COMPLETED") {
+            captureData.redirect_url = "/key/" + req.data.key.key;
+            res.json(captureData);
+          } else {
+            throw "Couldn't capture the payment";
+          }
+        })
+        .then(() => {
+          loaded_order.setPaymentCompleted(true);
+          return loaded_order.save();
+        })
+        .then(() => Key.CHARGE_KEY(req.data.key.key, loaded_order.getAmount()))
+        .catch((reason) => {
+          console.error(reason);
+          res.status(400).json({ errors: [{ msg: reason.toString() }] });
+        });
+    });
 });
 
 module.exports = router;
@@ -176,7 +160,6 @@ module.exports = router;
 
 async function createOrder(loaded_order) {
   const accessToken = await generateAccessToken();
-  console.log(accessToken);
 
   const url = `${base}/v2/checkout/orders`;
 
@@ -280,6 +263,71 @@ async function capturePayment(orderId) {
   return data;
 }
 
+async function verifyWebhook() {
+  let domain = config.get("app.url").replace(/\/+$/, "");
+
+  if (!domain.startsWith("https://")) {
+    // Do not attempt to register a webhook for localhost etc.
+    return;
+  }
+
+  let Redis = require("ioredis");
+  let redis_client = new Redis({
+    host: config.get("redis.host"),
+  });
+  let webhook_already_registered_key = "paypal_webhook_registered";
+  if (await redis_client.get(webhook_already_registered_key)) {
+    return;
+  }
+  const accessToken = await generateAccessToken();
+  return fetch(`${base}/v1/notifications/webhooks`, {
+    method: "get",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${accessToken}`,
+    },
+  })
+    .then((response) => response.json())
+    .then((webhooks) => {
+      webhooks.webhooks.forEach((webhook) => {
+        if (webhook.url.startsWith(domain)) {
+          throw "WEBHOOK_ALREADY_REGISTERED"; // Webhook already registered
+        }
+      });
+    })
+    .then(async () => {
+      // Webhook does not exist yet
+      return fetch(`${base}/v1/notifications/webhooks`, {
+        method: "post",
+        headers: {
+          "Content-Type": "application/json",
+          Authorization: `Bearer ${accessToken}`,
+        },
+        body: JSON.stringify({
+          event_types: [
+            { name: "PAYMENT.CAPTURE.COMPLETED" },
+            { name: "PAYMENT.CAPTURE.REFUNDED" },
+            { name: "PAYMENT.CAPTURE.REVERSED" },
+          ],
+          url: domain + "/webhooks/paypal",
+        }),
+      }).then((response) => {
+        if (response.status !== 201) {
+          return Promise.reject(response);
+        }
+        redis_client.set(webhook_already_registered_key, true);
+      });
+    })
+    .catch((reason) => {
+      if (reason !== "WEBHOOK_ALREADY_REGISTERED") {
+        console.warning("Could not register Webhook for PayPal.");
+        console.warning(reason);
+      } else {
+        redis_client.set(webhook_already_registered_key, true);
+      }
+    });
+}
+
 async function refundPayment(capture_ids) {
   const accessToken = await generateAccessToken();
   let promises = [];
-- 
GitLab