node.jsmongodbjwtpaypal-rest-sdkpaypal-subscriptions

How to Link JWT User ID with PayPal Custom ID After Payment in Node.js?


I'm developing a feature in my Node.js application where I need to associate a user's ID, obtained from a JWT (JSON Web Token), with a custom_id from PayPal after a successful payment. The user ID and the custom_id serve different purposes in my system: the user ID identifies the logged-in user, while the custom_id is generated by PayPal once a payment is successful. My objective is to store both these identifiers in my database for each transaction to link subscription payments directly to user accounts.

The process flow I envision is as follows:

  1. A user logs in, generating a JWT that includes their user ID.
  2. The user initiates a PayPal subscription payment.
  3. Upon successful payment, PayPal provides a custom_id (e.g., "I-YXA1XYR62EKE") for the transaction.
  4. I need to capture this custom_id along with the user's ID from the JWT and store both in my database, associating the payment with the user.

Here is how I'm currently handling authentication and payment initiation. However, I'm stuck on effectively capturing and linking the custom_id from PayPal with the user ID in my database after payment success.

Code Snippet

middlewares/ auth.js

const jwt = require("jsonwebtoken");

const auth = async (req, res, next) => {
  try {
    const token = req.header("x-auth-token");

    if (!token)
      return res.status(401).json({ msg: "No auth token, access denied." });

    const verified = jwt.verify(token,  process.env.ACCESS_TOKEN_SECRET);

    if (!verified)
      return res
        .status(401)
        .json({ msg: "Token verification failed, authorization denied." });

    req.user = verified.id;
    req.token = token;
    next();
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
};

module.exports = auth;

routes/paypal.js

paypalRouter.post("/create-subscription", auth, async (req,res) => {
  try {
    const userId = req.user;
    const subscriptionDetails = {
      plan_id: process.env.PAYPAL_SANDBOX_BUSSINESS_SUBSCRIPTION_PLAN_ID,
      custom_id: userId, 
      application_context: {
        brand_name: "brand.com",
        return_url: "http://localhost:3001/paypal/subscriptions/webhook",
        cancel_url: "http://localhost:3001/paypal/cancel-subscription",
      }
    };

    const params = new URLSearchParams();
    params.append("grant_type", "client_credentials");
    const authResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/oauth2/token",
      params,
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        auth: {
          username: process.env.PAYPAL_CLIENT_ID,
          password: process.env.PAYPAL_SECRET,
        },
      }
    );

    const access_token = authResponse.data.access_token;

    const response = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/billing/subscriptions",
      subscriptionDetails,
      {
        headers: {
          Authorization: `Bearer ${access_token}`,
        },
      }
    );
    

   

    console.log("subscriptionId from PAYPAL (e.g "I-YXA1XYR62EKE")" + response.data.id);
    console.error();
    return res.json({ subscriptionId: response.data.id, ...response.data });

  } catch (error) {
    console.log(error);
    return res.status(500).json("Something goes wrong");
  }
});


paypalRouter.post("/subscriptions/webhook", async (req, res) => {
  try {
    const webhookEvent = req.body;

    const verification = {
      auth_algo: req.headers['paypal-auth-algo'],
      cert_url: req.headers['paypal-cert-url'],
      transmission_id: req.headers['paypal-transmission-id'],
      transmission_sig: req.headers['paypal-transmission-sig'],
      transmission_time: req.headers['paypal-transmission-time'],
      webhook_id: process.env.Webhook_ID,
      webhook_event: webhookEvent
    };

    const params = new URLSearchParams(); 
    params.append("grant_type", "client_credentials");

    const authResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/oauth2/token",
      params,
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        auth: {
          username: process.env.PAYPAL_CLIENT_ID,
          password: process.env.PAYPAL_SECRET,
        },
      }
    );

    const accessToken = authResponse.data.access_token;

    const verifyResponse = await axios.post(
      "https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature",
      verification,
      {
        headers: {
          Authorization: `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        },
      }
    );

    if (verifyResponse.data.verification_status === "SUCCESS") {
    
      if (webhookEvent.event_type === "PAYMENT.SALE.COMPLETED") {
        console.log("Payment sale completed event received", webhookEvent);
        
        const userId = webhookEvent.resource.custom_id; // 
        const subscriptionId = webhookEvent.resource.id; //
  
        try {
          await User.findByIdAndUpdate(userId, { subscriptionId: subscriptionId });
          console.log("User subscription ID updated successfully");
        } catch (error) {
          console.error("Failed to update user subscription ID:", error);
          return res.status(500).send("Failed to update user subscription ID.");
        }      


        return res.status(200).send('Event processed');
      }
    } else {
      console.log("Failed to verify webhook signature:", verifyResponse.data);
      return res.status(401).send('Webhook signature verification failed');
    }
  } catch (error) {
    console.error("Error handling webhook event:", error.response ? error.response.data : error.message);
    return res.status(500).send("An error occurred while handling the webhook event.");
  }
});

I'm unsure about the best practice for capturing the custom_id after payment success and associating it with the user's ID in my database. Specifically, I'm looking for guidance on the following:

Any insights or examples of handling similar scenarios in Node.js applications would be greatly appreciated.


Solution

  • PayPal provides a custom_id (e.g., "I-YXA1XYR62EKE")

    Such a value is usually called the subscription's id in PayPal, not a custom_id.

    Looking at your code, it seems you're actually storing your userId value in the custom_id field when creating the subscription.

    paypalRouter.post("/create-subscription", auth, async (req,res) => {
      try {
        const userId = req.user;
        //...
        custom_id: userId, 
    

    That'll do fine.

    When you receive a webhook, you're already grabbing both values perfectly fine:

          if (webhookEvent.event_type === "PAYMENT.SALE.COMPLETED") {
            console.log("Payment sale completed event received", webhookEvent);
            
            const userId = webhookEvent.resource.custom_id; // 
            const subscriptionId = webhookEvent.resource.id; //
    

    So, your question asks about "best practices" for what's next in relation to some JWT. Near as I can tell the answer is..whatever makes sense to do. You have all the relevant data, do with it what you will. If you're still unsure I'd like to see a more specific question about what isn't clear.


    Perhaps it bears mentioning that webhook posts happen from PayPal to your server. Session and auth variables do not exist at that time, the user and their browser are not involved in any way.