From 3343e891bdbcfee7d17d73a0d7fb181652e7c262 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 1 Apr 2026 18:07:08 +0200 Subject: [PATCH] feat: Disable projects on failed payment --- apps/api/src/controllers/Webhooks.ts | 45 ++++++++++- apps/api/src/services/NtfyService.ts | 11 +++ .../src/emails/ProjectDisabledPayment.tsx | 78 +++++++++++++++++++ packages/email/src/emails/index.ts | 1 + 4 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 packages/email/src/emails/ProjectDisabledPayment.tsx diff --git a/apps/api/src/controllers/Webhooks.ts b/apps/api/src/controllers/Webhooks.ts index 5d85c16..3e80cb0 100644 --- a/apps/api/src/controllers/Webhooks.ts +++ b/apps/api/src/controllers/Webhooks.ts @@ -5,12 +5,16 @@ import type {Request, Response} from 'express'; import signale from 'signale'; import type Stripe from 'stripe'; -import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; +import {ProjectDisabledPaymentEmail, sendPlatformEmail} from '@plunk/email'; +import React from 'react'; + +import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; import {stripe} from '../app/stripe.js'; import {prisma} from '../database/prisma.js'; import {BillingLimitService} from '../services/BillingLimitService.js'; import {ContactService} from '../services/ContactService.js'; import {EventService} from '../services/EventService.js'; +import {MembershipService} from '../services/MembershipService.js'; import {MeterService} from '../services/MeterService.js'; import {NtfyService} from '../services/NtfyService.js'; import {SecurityService} from '../services/SecurityService.js'; @@ -519,6 +523,16 @@ export class Webhooks { const invoice = event.data.object; const customerId = invoice.customer as string; + // Only disable projects that are already consuming (recurring billing). + // If billing_reason is 'subscription_create', this is a first-time payment + // attempt and the project has never had an active subscription — don't disable. + if (invoice.billing_reason === 'subscription_create') { + signale.info( + `[WEBHOOK] Payment failed on initial subscription attempt for customer ${customerId}, skipping disable`, + ); + break; + } + // Find project by customer ID const project = await prisma.project.findUnique({ where: {customer: customerId}, @@ -529,10 +543,33 @@ export class Webhooks { break; } - signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`); + signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id}), disabling project`); - // Send notification about payment failure - await NtfyService.notifyPaymentFailed(project.name, project.id); + await prisma.project.update({ + where: {id: project.id}, + data: {disabled: true}, + }); + + await NtfyService.notifyProjectDisabledForPayment(project.name, project.id); + + // Send email notification to project members + try { + const members = await MembershipService.getMembers(project.id); + const emails = members.map(m => m.email); + if (emails.length > 0) { + const template = React.createElement(ProjectDisabledPaymentEmail, { + projectName: project.name, + projectId: project.id, + dashboardUrl: DASHBOARD_URI, + landingUrl: LANDING_URI, + }); + await Promise.all( + emails.map(email => sendPlatformEmail(email, 'Project Disabled - Payment Failed', template)), + ); + } + } catch (emailError) { + signale.error(`[WEBHOOK] Failed to send project disabled email:`, emailError); + } break; } diff --git a/apps/api/src/services/NtfyService.ts b/apps/api/src/services/NtfyService.ts index c7b6c44..283f99d 100644 --- a/apps/api/src/services/NtfyService.ts +++ b/apps/api/src/services/NtfyService.ts @@ -170,6 +170,17 @@ export class NtfyService { ]); } + /** + * Notify about project disabled due to payment failure + */ + public static async notifyProjectDisabledForPayment(projectName: string, projectId: string): Promise { + await this.sendUrgent( + 'Project Disabled - Payment Failed', + `Project "${projectName}" (${projectId}) was automatically disabled due to a failed recurring payment`, + [NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.ERROR], + ); + } + /** * Notify about successful invoice payment - MIN priority (routine) */ diff --git a/packages/email/src/emails/ProjectDisabledPayment.tsx b/packages/email/src/emails/ProjectDisabledPayment.tsx new file mode 100644 index 0000000..7d80e43 --- /dev/null +++ b/packages/email/src/emails/ProjectDisabledPayment.tsx @@ -0,0 +1,78 @@ +import {Heading, Link, Section, Text} from '@react-email/components'; +import * as React from 'react'; +import {EmailLayout} from '../common/EmailLayout'; +import {Footer} from '../common/Footer'; +import {Header} from '../common/Header'; + +interface ProjectDisabledPaymentEmailProps { + projectName: string; + projectId: string; + dashboardUrl?: string; + landingUrl?: string; +} + +export function ProjectDisabledPaymentEmail({ + projectName = 'My Project', + projectId = 'proj_example123', + dashboardUrl = 'https://next-app.useplunk.com', + landingUrl = 'https://www.useplunk.com', +}: ProjectDisabledPaymentEmailProps) { + return ( + +
+ +
+ Project disabled + + + Your project {projectName} has been automatically + disabled because a recurring payment could not be processed. + + +
+ + All email sending is currently blocked. Please update your payment method to re-enable the project. + +
+ + How to restore your project + +
+
+ Update your payment method + + Go to your billing settings and add a valid payment method + +
+ +
+ Re-enable your project + + Once your payment is resolved, contact support to re-enable your project + +
+ +
+ Contact support + + Need help? Our team is available to assist you + +
+
+ +
+ + Update payment method + +
+
+ +