Prelude

If you miss the beginning of my series, here’s my article about how to build an e-learning platform on AWS. Otherwise, if you are following this serie, the last time we build our backend to safely serve our HLS contents (if you miss it, follow the link).If you want to sell premium content you can leverage Stripe to handle multiple payments. This article will delve into the mechanisms of leveraging AWS services and signed cookies to deliver premium content seamlessly.

Step 1: Setting Up Stripe

If you haven’t already, sign up for a Stripe account at stripe.com and navigate to the Dashboard to retrieve your API keys. Then install the Stripe JavaScript SDK:

npm install --save @stripe/stripe-js

Step 2: Create a Stripe session in the backend

Let’s imagine you build a cart with multiple items, with multiple options. You then want to provide a page with all these informations and a single checkout count. To do that you can leverage stripe custom session. To create a custom session in the backend, you can use the code below as reference. This is an example of an AWS Amplify Serverless based Lambda:

const express = require('express')
const bodyParser = require('body-parser')
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware')
const { DynamoDBClient, GetItemCommand, PutItemCommand } = require('@aws-sdk/client-dynamodb');
const { marshall } = require("@aws-sdk/util-dynamodb");
const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');

const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' });
const ssmClient = new SSMClient();

// fetch stripe secrets from SSM secured parameters
async function fetchStripeSSMSecrets(secretName) {
  try {
    const command = new GetParameterCommand({
      Name: secretName,
      WithDecryption: true
    });
    const response = await ssmClient.send(command);
    return response.Parameter.Value;
  } catch (err) {
    throw err;
  }
}

// get the product base price without options
async function getProductById(productId) {
  const params = {
    TableName: process.env.DYNAMODB_TABLE,
    Key: {
      PK: { S: productId },
      SK: { S: "metadata:product" }
    }
  };

  try {
    const command = new GetItemCommand(params);
    const data = await dynamoDBClient.send(command);
    if (!data.Item) {
      throw new Error(`Product with ID ${productId} not found`);
    }
    return data.Item;
  } catch (error) {
    console.error(error);
    throw error;
  }
}

// calculate the total price of the product
function calculateTotalPrice(product, options) {
  let price = parseFloat(product.price.N)
  let title = product.title.S
  let desc = product.description.S
  options.forEach((optionId) => {
    product.options.L.forEach((option) => {
      if (option.M.id.N === "" + optionId) {
        price += parseFloat(option.M.price.N)
        title += " + " + option.M.title.S
        desc += " " + option.M.description.S
      }
    })
  })
  return [title, desc, price];
}

const app = express()
app.use(bodyParser.json())
app.use(awsServerlessExpressMiddleware.eventContext())

app.post('/*', async function (req, res) {
  // get the stripe secret key
  const stripeKey = await fetchStripeSSMSecrets(process.env.STRIPE_SECRET_KEY_NAME)
  const stripe = require('stripe')(stripeKey);
  const productsWithOptions = req.body; // Assuming products is an array of objects with options
  const lineItems = await Promise.all(
    productsWithOptions.map(async (product) => {
      let productObj = await getProductById(`product:${product['uuid']}`)
      let [title, desc, totalPrice] = calculateTotalPrice(productObj, product.options);
      let lineItem = {
        quantity: 1,
        price_data: {
          currency: 'usd',
          product_data: {
            name: title,
            description: desc,
            metadata: {
              product_id: `product:${product['uuid']}`,
              selected_options: product.options.join(',')
            }
          },
          unit_amount: Math.round(totalPrice * 100)
        }
      };
      return lineItem;
    })
  );
  let stripePayload = {
    payment_method_types: ['card'],
    line_items: lineItems,
    mode: 'payment',
    success_url: 'http://localhost:3000/',
    cancel_url: 'http://localhost:3000/'
  }
  await stripe.checkout.sessions.create(stripePayload, (err, session) => {
    if (err) {
      res.status(500).json({ error: err.message });
    } else {
      const purchase = {
        "PK": `user:${req.apiGateway.event.requestContext.authorizer.claims.sub}`,
        "SK": `purchase:${session.id}`,
        "items": lineItems,
        "paid": false,
        "stripeSession": session.url,
        "insertionTime": new Date().toISOString(),
        "paymentTime": new Date().toISOString(),
      }
      const marshalledData = marshall(purchase, {
        removeUndefinedValues: false,
      });
      const putItemParams = {
        TableName: process.env.DYNAMODB_TABLE,
        Item: marshalledData,
      };
      const putItemCommand = new PutItemCommand(putItemParams);
      dynamoDBClient.send(putItemCommand)
        .then((response) => {
          console.log("purchase request saved:", response);
          res.status(200).json({ session: session });
        })
        .catch((error) => {
          console.error("error during purchase request persist:", error);
          res.status(500).json({ error: err.message });
        });
    }
  });
});

app.listen(3000, function () {
  console.log("App started")
});

module.exports = app

Step 2: Configure AWS Amplify Backend

Utilize AWS Lambda functions to handle payment-related operations securely. Define a function to handle Stripe webhooks for processing payments asynchronously. You can take the following example as a starting point:

const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');
const { SQSClient, SendMessageCommand } = require("@aws-sdk/client-sqs");
const { Stripe } = require("stripe");
const ssmClient = new SSMClient();
const sqsClient = new SQSClient({ region: "eu-west-1" });

// fetch stripe secrets from SSM secured parameters
async function fetchStripeSSMSecrets(secretName) {
  try {
    const command = new GetParameterCommand({
      Name: secretName,
      WithDecryption: true
    });
    const response = await ssmClient.send(command);
    return response.Parameter.Value;
  } catch (err) {
    throw err;
  }
}

exports.handler = async function (event) {
  const { headers, body } = event;
  let content = "";
  try {
    if (!headers || !body)
      throw new Error('request not wellformed');
    if (!headers['Stripe-Signature'])
      throw new Error('Stripe-Signature');
    let sig = headers['Stripe-Signature'];
    if (Array.isArray(sig) && sig.length > 0)
      sig = sig[0];
    const stripeKey = await fetchStripeSSMSecrets(process.env.STRIPE_SECRET_KEY_NAME)
    const endpointSecret = await fetchStripeSSMSecrets(process.env.STRIPE_WEBHOOK_CHECKOUT_COMPLETE_NAME)
    const stripe = new Stripe(stripeKey);
    const webhookEvent = stripe.webhooks.constructEvent(event.body, sig, endpointSecret);
    const params = {
      QueueUrl: process.env.STRIPE_EVENT_QUEUE,
      MessageBody: JSON.stringify(webhookEvent),
    };
    const response = await sqsClient.send(new SendMessageCommand(params));
    statusCode = 200;
    content = response;
  } catch (e) {
    statusCode = 400;
    content = e.message;
  }
  const response = {
    statusCode: statusCode,
    body: JSON.stringify(content),
  };
  return response;
};

This Lambda is going to be exposed through an API and secured by a provided key. The Stripe signature is checked and a new message to process the purchase is done. You can use a Stripe page or create a custom session.

Step 3: Finally process async the purchase

A Stripe Event handler Lambda can handle messages of purchase confirmed by the webhook by Stripe. This will secure the payment and save the purchase accordingly. Perhaps, you can leverage DynamoDB Global Secondary Indexes to better store your information in your backend. I’m gonna deep dive into how you can leverage these indexes accordingly.

const { DynamoDBClient, QueryCommand, UpdateItemCommand } = require("@aws-sdk/client-dynamodb");
const { SQSClient, ReceiveMessageCommand, DeleteMessageBatchCommand } = require("@aws-sdk/client-sqs");
const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' });
const sqsClient = new SQSClient({ region: 'eu-west-1' });
const STRIPE_EVENT_QUEUE = process.env.STRIPE_EVENT_QUEUE;
const DYNAMODB_TABLE = process.env.DYNAMODB_TABLE;
const INDEX_NAME = 'inversion';

exports.handler = async (event) => {
    const messages = event.Records;
    for (const message of messages) {
        const body = JSON.parse(message.body);
        const sessionId = body.data.object.id
        const queryParams = {
            TableName: DYNAMODB_TABLE,
            IndexName: INDEX_NAME,
            KeyConditionExpression: 'SK = :SK',
            ExpressionAttributeValues: {
                ':SK': { 'S': `purchase:${sessionId}` }
            }
        };

        try {
            const queryCommand = new QueryCommand(queryParams);
            const queryResult = await dynamoDBClient.send(queryCommand);

            if (queryResult.Items.length > 0) {
                const item = queryResult.Items[0];
                const updateParams = {
                    TableName: process.env.DYNAMODB_TABLE,
                    Key: {
                        "PK": item.PK,
                        "SK": item.SK,
                    },
                    UpdateExpression: "set paid = :newPaid, paymentTime = :newTime",
                    ExpressionAttributeValues: {
                        ":newPaid": { BOOL: true },
                        ":newTime": { S: new Date().toISOString() }
                    },
                    ReturnValues: "ALL_NEW",
                };
                const updateCommand = new UpdateItemCommand(updateParams);
                await dynamoDBClient.send(updateCommand);
            }
        } catch (error) {
            console.error('Error processing message:', error);
        }
    }

    const receiptHandles = messages.map((message) => message.receiptHandle);
    const deleteParams = {
        QueueUrl: STRIPE_EVENT_QUEUE,
        Entries: receiptHandles.map((receiptHandle, index) => ({
            Id: `${index}`,
            ReceiptHandle: receiptHandle
        }))
    };

    try {
        const deleteCommand = new DeleteMessageBatchCommand(deleteParams);
        await sqsClient.send(deleteCommand);
    } catch (error) {
        console.error('Error deleting messages:', error);
    }
};

Conclusion

Integrating Stripe payments into your web application backed by AWS Amplify opens up a world of possibilities for monetization and enhancing user experience. By following the steps outlined in this guide and leveraging the power of Stripe, AWS Amplify, and DynamoDB you can seamlessly incorporate payment processing functionality into your application, enabling secure transactions and driving business growth.

With this robust payment infrastructure in place, you’re well-equipped to provide users with a smooth and reliable payment experience, thereby maximizing revenue potential and fostering customer satisfaction.

I hope you enjoy this serie! Have a look at my previous posts if you wanna read more!