Add stripe integration

This commit is contained in:
Corey Blais
2026-04-16 20:25:21 -04:00
parent 3a0e30085c
commit c757132cbd
13 changed files with 462 additions and 12 deletions
+214
View File
@@ -6,6 +6,7 @@ import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import morgan from 'morgan';
import nodemailer from 'nodemailer';
import Stripe from 'stripe';
import { z } from 'zod';
import { ensureSchema } from './db/schema.js';
@@ -60,6 +61,9 @@ import {
listRescueWorkspacesForAdmin,
listMembershipsForUser,
listWorkspaceMembers,
setWorkspaceStripeCustomerId,
setWorkspaceStripeSubscription,
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
updateRescueVerificationStatus,
updateWorkspace,
upsertWorkspaceMember,
@@ -72,6 +76,7 @@ import type {
IntegrationTokenRow,
ProviderKey,
RescueVerificationStatus,
SubscriptionStatus,
UserRow,
VetVisitRow,
WeightRow,
@@ -231,6 +236,17 @@ const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`;
const stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`;
const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl;
const stripePriceByBillingPlan: Partial<Record<BillingPlan, string>> = {
household_basic: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() ?? '',
household_plus: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ?? '',
household_macaw: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() ?? '',
};
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
const adminEmails = new Set(
(process.env.ADMIN_EMAILS ?? '')
.split(',')
@@ -271,6 +287,8 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
billingEmail: row.billing_email,
billingPlan: row.billing_plan,
subscriptionStatus: row.subscription_status,
stripeCustomerId: row.stripe_customer_id,
stripeSubscriptionId: row.stripe_subscription_id,
rescueVerificationStatus: row.rescue_verification_status,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -402,6 +420,74 @@ app.use(
legacyHeaders: false,
}),
);
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
if (!stripeWebhookSecret) {
res.status(503).json({ error: 'Stripe webhook is not configured.' });
return;
}
const signature = req.headers['stripe-signature'];
if (!signature) {
res.status(400).json({ error: 'Missing Stripe signature.' });
return;
}
let event: Stripe.Event;
try {
event = getStripeClient().webhooks.constructEvent(req.body, signature, stripeWebhookSecret);
} catch (error) {
res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid Stripe webhook signature.' });
return;
}
try {
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const workspaceId = Number(session.metadata?.workspaceId ?? session.client_reference_id ?? 0);
const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
if (workspaceId && subscriptionId) {
const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId);
await setWorkspaceStripeSubscription({
workspaceId,
stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id),
stripeSubscriptionId: subscription.id,
subscriptionStatus: mapStripeSubscriptionStatus(subscription.status),
});
}
}
if (
event.type === 'customer.subscription.created' ||
event.type === 'customer.subscription.updated' ||
event.type === 'customer.subscription.deleted'
) {
const subscription = event.data.object as Stripe.Subscription;
const workspaceId = Number(subscription.metadata?.workspaceId ?? 0);
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status);
if (workspaceId) {
await setWorkspaceStripeSubscription({
workspaceId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscription.id,
subscriptionStatus,
});
} else {
await setWorkspaceSubscriptionStatusByStripeSubscriptionId(subscription.id, subscriptionStatus);
}
}
res.json({ received: true });
} catch (error) {
console.error('Stripe webhook handling failed', error);
res.status(500).json({ error: 'Unable to process Stripe webhook.' });
}
});
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: false }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
@@ -416,6 +502,8 @@ const normalizeWorkspaceMembershipList = async (userId: string) =>
billing_email: row.billing_email,
billing_plan: row.billing_plan,
subscription_status: row.subscription_status,
stripe_customer_id: row.stripe_customer_id,
stripe_subscription_id: row.stripe_subscription_id,
rescue_verification_status: row.rescue_verification_status,
created_at: row.workspace_created_at,
updated_at: row.workspace_updated_at,
@@ -432,6 +520,32 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
};
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
return status;
}
return 'none';
};
const getStripeClient = () => {
if (!stripe) {
throw new Error('Stripe is not configured.');
}
return stripe;
};
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan) => {
const priceId = stripePriceByBillingPlan[billingPlan]?.trim() ?? '';
if (!priceId) {
throw new Error(`Stripe price is not configured for ${billingPlan}.`);
}
return priceId;
};
const createAuthSession = async (userId: string, activeWorkspaceId: number) => {
const token = createSessionToken();
const tokenHash = hashToken(token);
@@ -1113,6 +1227,106 @@ app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessi
}
});
app.post(
'/api/billing/checkout-session',
requireAuth,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
const parsed = z.object({ billingPlan: billingPlanSchema.optional() }).safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ error: 'Invalid billing payload', details: parsed.error.flatten() });
return;
}
try {
const workspace = req.auth!.workspace;
if (workspace.workspace_type === 'rescue') {
res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' });
return;
}
const billingPlan = parsed.data.billingPlan ?? workspace.billing_plan;
const priceId = getStripePriceIdForBillingPlan(billingPlan);
let stripeCustomerId = workspace.stripe_customer_id;
if (!stripeCustomerId) {
const customer = await getStripeClient().customers.create({
email: workspace.billing_email ?? req.auth!.user.email,
name: workspace.name,
metadata: {
workspaceId: String(workspace.id),
userId: req.auth!.user.id,
},
});
stripeCustomerId = customer.id;
await setWorkspaceStripeCustomerId(workspace.id, stripeCustomerId);
}
const checkoutSession = await getStripeClient().checkout.sessions.create({
mode: 'subscription',
customer: stripeCustomerId,
client_reference_id: String(workspace.id),
line_items: [{ price: priceId, quantity: 1 }],
success_url: stripeCheckoutSuccessUrl,
cancel_url: stripeCheckoutCancelUrl,
allow_promotion_codes: true,
metadata: {
workspaceId: String(workspace.id),
billingPlan,
},
subscription_data: {
metadata: {
workspaceId: String(workspace.id),
billingPlan,
},
},
});
if (!checkoutSession.url) {
throw new Error('Stripe did not return a checkout URL.');
}
res.json({ url: checkoutSession.url });
} catch (error) {
next(error);
}
},
);
app.post(
'/api/billing/portal-session',
requireAuth,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
try {
const workspace = req.auth!.workspace;
if (workspace.workspace_type === 'rescue') {
res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' });
return;
}
if (!workspace.stripe_customer_id) {
res.status(409).json({ error: 'Start a subscription before opening the billing portal.' });
return;
}
const portalSession = await getStripeClient().billingPortal.sessions.create({
customer: workspace.stripe_customer_id,
return_url: stripePortalReturnUrl,
});
res.json({ url: portalSession.url });
} catch (error) {
next(error);
}
},
);
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);