apps/recallassess/recallassess-api/src/api/shared/stripe/services/stripe-webhook.service.ts
Stripe Webhook Service
For setup instructions, see: WEBHOOK_SETUP_GUIDE.md
Quick setup (local):
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_cliFor 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.
Properties |
|
Methods |
|
constructor(stripeService: StripeService, paymentService: StripePaymentService, stripeConfig: StripeConfig)
|
||||||||||||
|
Parameters :
|
| Private Async dispatchWebhookEvent |
dispatchWebhookEvent(eventType: string, dataObject: unknown)
|
|
Returns :
Promise<void>
|
| Async processWebhook | |||||||||
processWebhook(payload: string | Buffer, signature: string)
|
|||||||||
|
Process incoming webhook from Stripe
Parameters :
Returns :
Promise<literal type>
|
| 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}`);
}
}
}