File

apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-webhook.service.ts

Description

Stripe Webhook Service

For setup instructions, see: WEBHOOK_SETUP_GUIDE.md

Quick setup (local):

  1. Install Stripe CLI: brew install stripe/stripe-cli/stripe
  2. Login: stripe login
  3. Forward webhooks: stripe listen --forward-to localhost:3001/api/stripe/webhook
  4. Copy the whsec_... from stripe listen into STRIPE_WEBHOOK_SECRET (each CLI run gets a new secret). You may use comma-separated secrets to keep both Dashboard endpoint + CLI: whsec_dash,whsec_cli
  5. Restart server

For subscription renewals, forward at least: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed (and checkout / payment_intent events). If you only forward invoice.created, the system will create a local PENDING invoice row as a fallback. customer.subscription.updated is required so portal/Billing changes to “cancel at period end” sync to our DB.

Index

Properties
Methods

Constructor

constructor(stripeService: StripeService, paymentService: StripePaymentService, stripeConfig: StripeConfig)
Parameters :
Name Type Optional
stripeService StripeService No
paymentService StripePaymentService No
stripeConfig StripeConfig No

Methods

Private Async dispatchWebhookEvent
dispatchWebhookEvent(eventType: string, dataObject: unknown)
Parameters :
Name Type Optional
eventType string No
dataObject unknown No
Returns : Promise<void>
Async processWebhook
processWebhook(payload: string | Buffer, signature: string)

Process incoming webhook from Stripe

Parameters :
Name Type Optional
payload string | Buffer No
signature string No
Returns : Promise<literal type>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(StripeWebhookService.name)
import { optionalEnv } from "@bish-nest/core";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import Stripe from "stripe";
import { StripeConfig } from "../../../../config/stripe.config";
import { StripeService } from "./stripe.service";
import { StripePaymentService } from "./stripe-payment.service";

/**
 * Stripe Webhook Service
 *
 * For setup instructions, see: WEBHOOK_SETUP_GUIDE.md
 *
 * Quick setup (local):
 * 1. Install Stripe CLI: brew install stripe/stripe-cli/stripe
 * 2. Login: stripe login
 * 3. Forward webhooks: stripe listen --forward-to localhost:3001/api/stripe/webhook
 * 4. Copy the `whsec_...` from `stripe listen` into STRIPE_WEBHOOK_SECRET (each CLI run gets a new secret).
 *    You may use comma-separated secrets to keep both Dashboard endpoint + CLI: `whsec_dash,whsec_cli`
 * 5. Restart server
 *
 * For subscription renewals, forward at least: `customer.subscription.created`, `customer.subscription.updated`,
 * `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed` (and checkout / payment_intent events).
 * If you only forward `invoice.created`, the system will create a local `PENDING` invoice row as a fallback.
 * `customer.subscription.updated` is required so portal/Billing changes to “cancel at period end” sync to our DB.
 */
@Injectable()
export class StripeWebhookService {
  private readonly logger = new Logger(StripeWebhookService.name);

  constructor(
    private readonly stripeService: StripeService,
    private readonly paymentService: StripePaymentService,
    private readonly stripeConfig: StripeConfig,
  ) {}

  /**
   * Process incoming webhook from Stripe
   */
  async processWebhook(payload: string | Buffer, signature: string): Promise<{ received: boolean }> {
    this.logger.log("=== WEBHOOK RECEIVED ===");
    this.logger.log(`Payload type: ${typeof payload}, Signature: ${signature ? "present" : "missing"}`);
    this.logger.log(`Environment: ${optionalEnv("NODE_ENV", "unknown")}`);
    this.logger.log(`Timestamp: ${new Date().toISOString()}`);

    const webhookSecretConfigured = this.stripeConfig.getWebhookSecrets().length > 0;
    const isProd = optionalEnv("NODE_ENV", "").toLowerCase() === "production";

    const dispatchFromRawPayload = async (): Promise<{ received: boolean }> => {
      // In local/dev we may accept webhook events even without signature verification.
      const rawPayload = Buffer.isBuffer(payload) ? payload : Buffer.from(payload as string, "utf8");
      try {
        const basicEventInfo = JSON.parse(rawPayload.toString("utf8")) as {
          type?: string;
          data?: { object?: unknown };
        };

        const eventType = basicEventInfo?.type;
        const dataObject = basicEventInfo?.data?.object;

        if (!eventType) {
          this.logger.warn(`Webhook: could not extract event type from raw payload (no signature).`);
          return { received: true };
        }
        if (!dataObject) {
          this.logger.warn(`Webhook: could not extract event data.object for event=${eventType} (no signature).`);
        }

        this.logger.warn(`Webhook: dispatching without signature verification (event=${eventType}).`);
        await this.dispatchWebhookEvent(eventType, dataObject);
        return { received: true };
      } catch (e) {
        this.logger.warn(
          `Webhook: failed to parse raw payload for unsigned/unverified dispatch: ${
            e instanceof Error ? e.message : String(e)
          }`,
        );
        return { received: true };
      }
    };

    if (!signature) {
      this.logger.error("Missing stripe-signature header");
      if (isProd) {
        throw new BadRequestException("Missing stripe-signature header");
      }
      // Local/dev ergonomics: don't fail Stripe CLI forwarding just because the signature header was not present.
      return await dispatchFromRawPayload();
    }

    if (!webhookSecretConfigured) {
      this.logger.error(
        "STRIPE_WEBHOOK_SECRET is not configured. Webhook signature verification will be skipped (non-production only).",
      );
      if (isProd) {
        throw new BadRequestException(
          "Stripe webhook secret is not configured (STRIPE_WEBHOOK_SECRET). Cannot verify signature.",
        );
      }
      // Local/dev: accept and dispatch events so local DB stays consistent.
      return await dispatchFromRawPayload();
    }

    try {
      // Ensure payload is a Buffer for Stripe signature verification
      const rawPayload = Buffer.isBuffer(payload) ? payload : Buffer.from(payload as string, "utf8");
      this.logger.log(`Raw payload length: ${rawPayload.length}`);

      // Parse the payload to get basic event info before signature verification
      let basicEventInfo:
        | {
            type?: string;
            id?: string;
            data?: { object?: unknown };
          }
        | null = null;
      try {
        const payloadStr = rawPayload.toString("utf8");
        basicEventInfo = JSON.parse(payloadStr);
        this.logger.log(`Event type (before verification): ${basicEventInfo?.type ?? "(unknown)"}`);
        this.logger.log(`Event ID (before verification): ${basicEventInfo?.id ?? "(unknown)"}`);
      } catch (parseError) {
        const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
        this.logger.warn(`Could not parse payload before verification: ${errorMessage}`);
      }

      // Verify webhook signature
      const event = this.stripeService.verifyWebhookSignature(rawPayload, signature);
      this.logger.log(`✅ Signature verification successful`);

      this.logger.log(`=== Processing webhook event: ${event.type} ===`);
      this.logger.log(`Event ID: ${event.id}`);
      const dataObj = event.data?.object;
      const dataKeys =
        dataObj && typeof dataObj === "object" && !Array.isArray(dataObj)
          ? Object.keys(dataObj).join(", ")
          : "(n/a)";
      this.logger.log(`Event data keys: ${dataKeys}`);

      await this.dispatchWebhookEvent(event.type, dataObj);

      this.logger.log(`=== Webhook event ${event.type} processed successfully ===`);
      return { received: true };
    } catch (error) {
      this.logger.error("=== WEBHOOK PROCESSING FAILED ===");
      this.logger.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
      this.logger.error(`Stack: ${error instanceof Error ? error.stack : "N/A"}`);
      throw new BadRequestException("Webhook processing failed");
    }
  }

  private async dispatchWebhookEvent(eventType: string, dataObject: unknown): Promise<void> {
    const obj = (dataObject && typeof dataObject === "object" ? (dataObject as Record<string, unknown>) : null) as
      | Record<string, unknown>
      | null;
    const objId = typeof obj?.["id"] === "string" ? obj["id"] : "(no-id)";
    const objType = typeof obj?.["object"] === "string" ? obj["object"] : "(no-object)";
    // Use WARN so it shows even when Nest log level hides .log()
    this.logger.warn(`[stripe-webhook-debug] dispatch event=${eventType} object=${objType} id=${objId}`);

    const nestedId = (v: unknown): string | null => {
      if (typeof v === "string") return v;
      if (v && typeof v === "object") {
        const r = v as Record<string, unknown>;
        if (typeof r["id"] === "string") return r["id"];
      }
      return null;
    };

    // Handle different event types
    switch (eventType) {
      case "checkout.session.completed":
        this.logger.log("Handling checkout.session.completed event");
        await this.paymentService.handleCheckoutSessionCompleted(dataObject as Stripe.Checkout.Session);
        break;

      case "payment_intent.succeeded":
        this.logger.log("Handling payment_intent.succeeded event");
        this.logger.warn(
          `[stripe-webhook-debug] payment_intent.succeeded id=${objId} invoice=${
            nestedId(obj?.["invoice"]) ?? "(missing)"
          } customer=${
            nestedId(obj?.["customer"]) ?? "(missing)"
          } metadataKeys=${
            obj?.["metadata"] && typeof obj["metadata"] === "object"
              ? Object.keys(obj["metadata"] as Record<string, unknown>).join(",")
              : "(none)"
          }`,
        );
        await this.paymentService.handlePaymentSucceeded(dataObject as Stripe.PaymentIntent);
        break;

      case "payment_intent.payment_failed":
        this.logger.log("Handling payment_intent.payment_failed event");
        await this.paymentService.handlePaymentFailed(dataObject as Stripe.PaymentIntent);
        break;

      case "payment_intent.created":
        // Informational/no-op for our domain flow; keep explicit to avoid noisy "Unhandled" warnings.
        this.logger.log("Ignoring payment_intent.created event");
        break;

      case "payment_method.attached":
        // Stripe may emit both payment_method.attached and setup_intent.succeeded for the same card add.
        // To avoid double-attempting payment, we only auto-pay on setup_intent.succeeded.
        this.logger.log("Ignoring payment_method.attached event (auto-pay handled by setup_intent.succeeded)");
        break;

      case "setup_intent.succeeded":
        this.logger.log("Handling setup_intent.succeeded event (auto-pay latest invoice)");
        await this.paymentService.handleSetupIntentSucceeded(dataObject as Stripe.SetupIntent);
        break;

      case "customer.subscription.created":
        this.logger.log("Handling customer.subscription.created event");
        await this.paymentService.handleCustomerSubscriptionCreated(dataObject as Stripe.Subscription);
        break;

      case "customer.subscription.updated":
        this.logger.log("Handling customer.subscription.updated event");
        if (this.stripeService.isConfigured() && typeof obj?.["id"] === "string") {
          try {
            const freshSub = await this.stripeService.retrieveSubscription(obj["id"]);
            this.logger.warn(
              `[stripe-webhook-debug] customer.subscription.updated expanded id=${freshSub.id} customer=${
                typeof freshSub.customer === "string"
                  ? freshSub.customer
                  : freshSub.customer && typeof freshSub.customer === "object"
                    ? freshSub.customer.id
                    : "(missing)"
              } cancel_at_period_end=${freshSub.cancel_at_period_end === true} cancel_at=${
                typeof freshSub.cancel_at === "number" ? freshSub.cancel_at : "(null)"
              } status=${freshSub.status}`,
            );
            await this.paymentService.handleCustomerSubscriptionUpdated(freshSub);
            break;
          } catch (e) {
            this.logger.warn(
              `[stripe-webhook-debug] customer.subscription.updated: retrieveSubscription failed for ${obj["id"]}: ${
                e instanceof Error ? e.message : String(e)
              }`,
            );
          }
        }
        await this.paymentService.handleCustomerSubscriptionUpdated(dataObject as Stripe.Subscription);
        break;

      case "customer.subscription.deleted":
        this.logger.log("Handling customer.subscription.deleted event");
        if (this.stripeService.isConfigured() && typeof obj?.["id"] === "string") {
          try {
            const freshSub = await this.stripeService.retrieveSubscription(obj["id"]);
            this.logger.warn(
              `[stripe-webhook-debug] customer.subscription.deleted expanded id=${freshSub.id} customer=${
                typeof freshSub.customer === "string"
                  ? freshSub.customer
                  : freshSub.customer && typeof freshSub.customer === "object"
                    ? freshSub.customer.id
                    : "(missing)"
              } cancel_at_period_end=${freshSub.cancel_at_period_end === true} cancel_at=${
                typeof freshSub.cancel_at === "number" ? freshSub.cancel_at : "(null)"
              } status=${freshSub.status}`,
            );
            await this.paymentService.handleCustomerSubscriptionUpdated(freshSub);
            break;
          } catch (e) {
            this.logger.warn(
              `[stripe-webhook-debug] customer.subscription.deleted: retrieveSubscription failed for ${obj["id"]}: ${
                e instanceof Error ? e.message : String(e)
              }`,
            );
          }
        }
        await this.paymentService.handleCustomerSubscriptionUpdated(dataObject as Stripe.Subscription);
        break;

      case "invoice.created":
        this.logger.log("Handling invoice.created event (create pending invoice / paid alias)");
        this.logger.warn(
          `[stripe-webhook-debug] invoice.created id=${objId} number=${typeof obj?.["number"] === "string" ? obj["number"] : "(none)"} subscription=${
            nestedId(obj?.["subscription"]) ?? "(missing)"
          } status=${typeof obj?.["status"] === "string" ? obj["status"] : "(none)"} billing_reason=${
            typeof obj?.["billing_reason"] === "string" ? obj["billing_reason"] : "(none)"
          } payment_intent=${
            nestedId(obj?.["payment_intent"]) ?? "(missing)"
          }`,
        );
        // Stripe webhook payloads frequently omit expandable fields (like `subscription`).
        // Re-fetch an expanded invoice when possible so downstream reconciliation can always resolve subscription id.
        if (this.stripeService.isConfigured() && typeof obj?.["id"] === "string") {
          try {
            const invFull = await this.stripeService.getInvoiceExpanded(obj["id"]);
            const invSub = nestedId((invFull as unknown as Record<string, unknown>)["subscription"]) ?? "(missing)";
            const linesObj = (invFull as unknown as Record<string, unknown>)["lines"];
            const linesLen =
              linesObj && typeof linesObj === "object" && Array.isArray((linesObj as Record<string, unknown>)["data"])
                ? ((linesObj as Record<string, unknown>)["data"] as unknown[]).length
                : null;
            this.logger.warn(
              `[stripe-webhook-debug] invoice.created expanded id=${invFull.id} subscription=${invSub} lines=${
                typeof linesLen === "number" ? String(linesLen) : "(n/a)"
              }`,
            );
            await this.paymentService.handleInvoiceCreated(invFull);
            break;
          } catch (e) {
            this.logger.warn(
              `[stripe-webhook-debug] invoice.created: getInvoiceExpanded failed for ${obj["id"]}: ${
                e instanceof Error ? e.message : String(e)
              }`,
            );
          }
        }
        await this.paymentService.handleInvoiceCreated(dataObject as Stripe.Invoice);
        break;

      case "invoice.paid":
        this.logger.log("Handling invoice.paid event");
        this.logger.warn(
          `[stripe-webhook-debug] invoice.paid id=${objId} number=${typeof obj?.["number"] === "string" ? obj["number"] : "(none)"} subscription=${
            nestedId(obj?.["subscription"]) ?? "(missing)"
          } amount_paid=${typeof obj?.["amount_paid"] === "number" ? obj["amount_paid"] : "(n/a)"} billing_reason=${
            typeof obj?.["billing_reason"] === "string" ? obj["billing_reason"] : "(none)"
          }`,
        );
        if (this.stripeService.isConfigured() && typeof obj?.["id"] === "string") {
          try {
            const invFull = await this.stripeService.getInvoiceExpanded(obj["id"]);
            const invSub = nestedId((invFull as unknown as Record<string, unknown>)["subscription"]) ?? "(missing)";
            const linesObj = (invFull as unknown as Record<string, unknown>)["lines"];
            const linesLen =
              linesObj && typeof linesObj === "object" && Array.isArray((linesObj as Record<string, unknown>)["data"])
                ? ((linesObj as Record<string, unknown>)["data"] as unknown[]).length
                : null;
            this.logger.warn(
              `[stripe-webhook-debug] invoice.paid expanded id=${invFull.id} subscription=${invSub} lines=${
                typeof linesLen === "number" ? String(linesLen) : "(n/a)"
              }`,
            );
            await this.paymentService.handleInvoicePaid(invFull);
            break;
          } catch (e) {
            this.logger.warn(
              `[stripe-webhook-debug] invoice.paid: getInvoiceExpanded failed for ${obj["id"]}: ${
                e instanceof Error ? e.message : String(e)
              }`,
            );
          }
        }
        await this.paymentService.handleInvoicePaid(dataObject as Stripe.Invoice);
        break;

      case "invoice_payment.paid":
        // Some Stripe/local test flows emit this alias. Resolve the invoice and reuse handleInvoicePaid.
        this.logger.log("Handling invoice_payment.paid event (alias -> resolve invoice and handlePaid)");
        await this.paymentService.handleInvoicePaymentPaid(dataObject);
        break;

      case "invoice.payment_succeeded":
        // Stripe commonly emits BOTH `invoice.paid` and `invoice.payment_succeeded` for the same invoice.
        // We handle `invoice.paid` as the canonical event to avoid double-processing/duplicate subscription rotations.
        this.logger.log("Ignoring invoice.payment_succeeded event (handled by invoice.paid)");
        break;

      case "invoice.payment_failed":
        this.logger.log("Handling invoice.payment_failed event");
        if (this.stripeService.isConfigured() && typeof obj?.["id"] === "string") {
          try {
            const invFull = await this.stripeService.getInvoiceExpanded(obj["id"]);
            this.logger.warn(
              `[stripe-webhook-debug] invoice.payment_failed expanded id=${invFull.id} subscription=${
                nestedId((invFull as unknown as Record<string, unknown>)["subscription"]) ?? "(missing)"
              }`,
            );
            await this.paymentService.handleInvoicePaymentFailed(invFull);
            break;
          } catch (e) {
            this.logger.warn(
              `[stripe-webhook-debug] invoice.payment_failed: getInvoiceExpanded failed for ${obj["id"]}: ${
                e instanceof Error ? e.message : String(e)
              }`,
            );
          }
        }
        await this.paymentService.handleInvoicePaymentFailed(dataObject as Stripe.Invoice);
        break;

      case "refund.created":
        this.logger.log("Handling refund.created event");
        await this.paymentService.handleRefundCreated(dataObject as Stripe.Refund);
        break;

      case "refund.updated":
        this.logger.log("Handling refund.updated event");
        await this.paymentService.handleRefundCreated(dataObject as Stripe.Refund);
        break;

      case "charge.refunded": {
        this.logger.log("Handling charge.refunded event");
        const ch = dataObject as Stripe.Charge;
        const chargeId = typeof ch?.id === "string" ? ch.id : null;
        if (chargeId) {
          const refunds = await this.stripeService.listRefundsForCharge({ chargeId, max: 50 });
          for (const r of refunds) {
            await this.paymentService.handleRefundCreated(r);
          }
        }
        break;
      }

      case "charge.failed": {
        this.logger.log("Handling charge.failed event");
        const ch = dataObject as Stripe.Charge;
        const paymentIntentId =
          typeof ch?.payment_intent === "string"
            ? ch.payment_intent
            : ch?.payment_intent && typeof ch.payment_intent === "object" && "id" in ch.payment_intent
              ? String(ch.payment_intent.id)
              : null;
        if (paymentIntentId && this.stripeService.isConfigured()) {
          try {
            const pi = await this.stripeService.retrievePaymentIntent(paymentIntentId, ["invoice"]);
            await this.paymentService.handlePaymentFailed(pi);
          } catch (e) {
            this.logger.warn(
              `charge.failed: could not retrieve payment_intent ${paymentIntentId}: ${e instanceof Error ? e.message : String(e)}`,
            );
          }
        }
        break;
      }

      case "charge.succeeded":
      case "charge.updated":
      case "billing_portal.session.created":
      case "customer.created":
      case "setup_intent.canceled":
      case "payment_intent.canceled":
      case "customer.updated":
      case "setup_intent.created":
      case "invoice.updated":
      case "invoice.finalized":
      case "invoice.voided":
      case "invoice.upcoming":
      case "test_helpers.test_clock.created":
      case "test_helpers.test_clock.ready":
      case "test_helpers.test_clock.advancing":
      case "customer.subscription.trial_will_end":
      case "invoiceitem.created":
      case "product.created":
      case "plan.created":
      case "price.created":
        // No-op: informational/noisy for our current domain flow.
        // Keeping explicit prevents spammy "Unhandled event type" warnings.
        this.logger.log(`Ignoring ${eventType} event`);
        break;

      default:
        this.logger.warn(`Unhandled event type: ${eventType}`);
    }
  }
}

results matching ""

    No results matching ""