Streamlining Subscription Payments with Stripe: A Node.js Integration

Cover Image for Streamlining Subscription Payments with Stripe: A Node.js Integration
Avatar

JealousGx

2024-08-04

import { SubscriptionPeriod, Tier } from '@prisma/client';
import { APIGatewayProxyEventV2 } from 'aws-lambda';
import Stripe from 'stripe';
 
import { HTTP_CODE } from '@libs/constants';
import db from '@libs/db';
import { stripe } from '@libs/stripe';
 
export async function _stripeWebhooksHandler(_event: APIGatewayProxyEventV2) {
  const stripeSignature = _event.headers['stripe-signature'];
 
  if (!stripeSignature) {
    return {
      status: HTTP_CODE.BAD_REQUEST,
      message: 'No Stripe signature provided',
    };
  }
 
  try {
    const stripeEvent = await stripe.webhooks.constructEventAsync(
      // we need to pass the raw body of the event. Converting it into JSON or any other form will break the code.
      // Luckily, we get raw body in `_event` by default, so, we do not need to do anything extra.
      _event.body!,
      stripeSignature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
 
    switch (stripeEvent.type) {
      // For my particular case, I only needed to take into account the following event types.
      // For you, it might be different. Please refer to https://docs.stripe.com/api/events/types
      case 'customer.subscription.created':
        return handleSubscriptionEvent(stripeEvent, 'created');
      case 'customer.subscription.updated':
        return handleSubscriptionEvent(stripeEvent, 'updated');
      case 'customer.subscription.deleted':
        return handleSubscriptionEvent(stripeEvent, 'deleted');
      case 'invoice.payment_succeeded':
        return handleInvoiceEvent(stripeEvent, 'succeeded');
      case 'invoice.payment_failed':
        return handleInvoiceEvent(stripeEvent, 'failed');
      case 'checkout.session.completed':
        return handleCheckoutSessionCompleted(stripeEvent);
      default:
        return {
          status: HTTP_CODE.BAD_REQUEST,
          message: 'Unhandled event type',
        };
    }
  } catch (err) {
    console.error('Error constructing Stripe event:', err);
    return {
      status: HTTP_CODE.INTERNAL_SERVER_ERROR,
      message: 'Webhook Error: Invalid Signature',
    };
  }
}
 
async function handleSubscriptionEvent(
  event: Stripe.Event,
  type: 'created' | 'updated' | 'deleted',
) {
  const subscription = event.data.object as Stripe.Subscription;
  const customerEmail = await getCustomerEmail(subscription.customer as string);
 
  if (!customerEmail) {
    return {
      status: HTTP_CODE.BAD_REQUEST,
      message: 'Missing userId, customer email, or plan in subscription',
    };
  }
 
  if (subscription.cancel_at_period_end) {
    await db.subscriptions
      .update({
        where: {
          subscriptionId: subscription.id,
        },
        data: {
          status: 'scheduled_for_cancellation',
        },
      })
      .catch((err) => {
        console.error('Error updating subscription status: ', err);
 
        throw {
          status: HTTP_CODE.INTERNAL_SERVER_ERROR,
          message: 'Error updating subscription status',
        };
      });
 
    return {
      status: HTTP_CODE.OK,
      message:
        'Subscription scheduled to be cancelled at the period end successfully',
    };
  } else if (type === 'deleted') {
    const result = await db.subscriptions
      .update({
        where: {
          subscriptionId: subscription.id,
        },
        data: {
          status: 'cancelled',
        },
      })
      .catch((err) => {
        console.error('Error updating subscription status: ', err);
 
        throw {
          status: HTTP_CODE.INTERNAL_SERVER_ERROR,
          message: 'Error updating subscription status',
        };
      });
 
    if (result) {
      await db.user
        .update({
          where: {
            email: customerEmail,
          },
          data: {
            Tier: Tier.Free,
          },
        })
        .catch((err) => {
          console.error('Error updating user subscription status: ', err);
 
          throw {
            status: HTTP_CODE.INTERNAL_SERVER_ERROR,
            message: 'Error updating user subscription status',
          };
        });
    }
 
    return {
      status: HTTP_CODE.OK,
      message: 'Subscription cancelled successfully',
    };
  } else {
    const createdAt = new Date(subscription.created * 1000).toISOString();
 
    const subscriptionData: any = {
      subscriptionId: subscription.id,
      stripeUserId: subscription.customer,
      status: subscription.status,
      startDate: createdAt,
      email: customerEmail,
      planId: subscription.items.data[0]?.price.id,
      defaultPaymentMethodId: subscription.default_payment_method as string,
      createdAt,
    };
 
    await db.subscriptions
      .upsert({
        where: {
          subscriptionId: subscription.id,
        },
        create: subscriptionData,
        update: subscriptionData,
      })
      .catch((err) => {
        console.error(`Error during subscription ${type}: ${err}`);
 
        throw {
          status: HTTP_CODE.INTERNAL_SERVER_ERROR,
          message: `Error during subscription ${type}`,
        };
      });
 
    return {
      status: HTTP_CODE.OK,
      message: 'Subscription updated successfully',
    };
  }
}
 
async function getCustomerEmail(customerId: string) {
  try {
    const customer = await stripe.customers.retrieve(customerId);
    return (customer as Stripe.Customer).email;
  } catch (error) {
    console.error('Error fetching customer:', error);
    return null;
  }
}
 
async function handleInvoiceEvent(
  event: Stripe.Event,
  status: 'succeeded' | 'failed',
) {
  const invoice = event.data.object as Stripe.Invoice;
 
  const customerEmail = await getCustomerEmail(invoice.customer as string);
 
  if (!customerEmail) {
    return {
      status: HTTP_CODE.BAD_REQUEST,
      message: 'Customer email could not be fetched',
    };
  }
 
  try {
    const invoiceData: any = {
      invoiceId: invoice.id,
      subscriptionId: invoice.subscription as string,
      amountPaid:
        status === 'succeeded' ? (invoice.amount_paid / 100).toString() : '0',
      amountDue:
        status === 'failed' ? (invoice.amount_due / 100).toString() : '0',
      currency: invoice.currency,
      invoiceUrl: invoice.hosted_invoice_url,
      status,
    };
 
    await db.invoices.upsert({
      where: {
        invoiceId: invoice.id,
      },
      create: invoiceData,
      update: invoiceData,
    });
 
    return {
      status: HTTP_CODE.OK,
      message: `Invoice payment ${status}: ${invoiceData.invoiceUrl}`,
    };
  } catch (err) {
    console.error('Error handling invoice event:', err);
 
    return {
      status: HTTP_CODE.INTERNAL_SERVER_ERROR,
      message: 'Error handling invoice event',
    };
  }
}
 
async function handleCheckoutSessionCompleted(event: Stripe.Event) {
  const session = event.data.object as Stripe.Checkout.Session;
 
  const metadata = session.metadata;
  const userId = metadata?.userId;
  const isMonthly =
    metadata?.isMonthly === 'false'
      ? SubscriptionPeriod.yearly
      : SubscriptionPeriod.monthly;
  const plan = metadata?.plan as Tier;
 
  const endDate = new Date();
 
  if (isMonthly === SubscriptionPeriod.monthly) {
    endDate.setMonth(endDate.getMonth() + 1);
  } else if (isMonthly === SubscriptionPeriod.yearly) {
    endDate.setFullYear(endDate.getFullYear() + 1);
  } else {
    return {
      status: HTTP_CODE.BAD_REQUEST,
      message: 'Invalid subscription period',
    };
  }
 
  try {
    const subscriptionId = session.subscription as string;
 
    await stripe.subscriptions.update(subscriptionId as string, { metadata });
 
    await db.subscriptions.update({
      where: {
        subscriptionId,
      },
      data: {
        planPeriod: isMonthly,
        endDate: endDate.toISOString(),
        plan,
      },
    });
 
    await db.user.update({
      where: {
        id: userId,
      },
      data: {
        Tier: plan,
      },
    });
 
    return {
      status: HTTP_CODE.OK,
      message: 'Subscription metadata updated successfully',
    };
  } catch (err) {
    console.error(`Error handling checkout session: ${err}`);
 
    return {
      status: HTTP_CODE.INTERNAL_SERVER_ERROR,
      message: 'Error handling checkout session',
    };
  }
}
 

Tags:

web developmentnodejsstripesubscriptiontypescriptapi gatewayprisma