feathersjs/feathers-stripe

View on GitHub
src/services/webhook.ts

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint-disable @typescript-eslint/no-explicit-any */
// Heavily inspired from https://github.com/fixate/feathers-stripe-webhooks
import { BaseService } from "./base";

import type { Application, Params } from "@feathersjs/feathers";
import type Stripe from "stripe";
import type { Application as ExpressApplication } from "@feathersjs/express";
import type { NextFunction, Request, Response } from "express";

export interface IWebhookService {
  _find: never;
  _get: never;
  _create: (data: any, params: any) => Promise<any>;
  _update: never;
  _patch: never;
  _remove: never;
}

export type WebHookHandler = (options: {
  object: Stripe.Event.Data.Object;
  event: Stripe.Event;
  params: Params;
  app: Application;
}) => any;

export interface WebHookHandlers {
  [key: string]: {
    [key: string]: WebHookHandler;
  };
}

export interface WebHookOptions {
  app: Application;
  handlers: WebHookHandlers;
  stripe: Stripe;
}

export class WebhookService
  extends BaseService<IWebhookService>
  implements IWebhookService
{
  app: Application;
  handlers: WebHookHandlers;
  declare options: WebHookOptions;

  isExpressSetup = false;

  constructor(options: WebHookOptions) {
    super(options);

    if (!options.app) {
      throw new Error("options.app is required");
    }

    this.app = options.app;
    this.handlers = options.handlers || {};
    this.options = options;
  }

  /**
   * registers an express route
   * @param route
   * @param endpointSecret
   * @returns
   */
  setupExpress(route: string, endpointSecret: string) {
    if (this.isExpressSetup) {
      return;
    }

    (this.app as ExpressApplication).post(
      route,
      (req: Request, res: Response, next: NextFunction) => {
        const signature = req.headers["stripe-signature"];
        if (!signature) {
          res.status(400).end("Bad signature");
          return;
        }

        try {
          this.stripe.webhooks.constructEvent(
            // @ts-expect-error - rawBody is not defined in express
            req.rawBody,
            signature,
            endpointSecret
          );
        } catch (err) {
          res.status(400).end(err);
          return;
        }

        next();
      }
    );

    this.isExpressSetup = true;
  }

  getHandler(event: Stripe.Event): WebHookHandler | null {
    const parts = event.type.split(".");
    let node = this.handlers;

    for (const p of parts) {
      // @ts-expect-error - node is not a string
      node = node[parts[p]];

      if (!node) {
        return null;
      }
    }

    return node as any as WebHookHandler;
  }

  _create(event: Stripe.Event, params: Params) {
    const handler = this.getHandler(event);
    if (!handler) {
      // No handler, nothing to do
      return Promise.resolve({});
    }

    return handler({ object: event.data.object, event, params, app: this.app });
  }

  _find: never;
  _get: never;
  _update: never;
  _patch: never;
  _remove: never;
}

export type SetupWebHookOptions = {
  endpointSecret: string;
  stripe: Stripe;
  handlers: WebHookHandlers;
};

export function setupWebhook(
  app: Application,
  route: string,
  { endpointSecret, stripe, handlers }: SetupWebHookOptions
) {
  const service = new WebhookService({
    handlers,
    stripe,
    app
  });

  service.setupExpress(route, endpointSecret);

  return service;
}