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);
+12
View File
@@ -19,6 +19,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
billing_email VARCHAR(255),
billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
subscription_status VARCHAR(32) NOT NULL DEFAULT 'active',
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -31,8 +33,18 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(32) NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required';
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_stripe_subscription_id
ON workspaces (stripe_subscription_id)
WHERE stripe_subscription_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_workspaces_stripe_customer_id
ON workspaces (stripe_customer_id)
WHERE stripe_customer_id IS NOT NULL;
UPDATE workspaces
SET rescue_verification_status = 'pending'
WHERE workspace_type = 'rescue'
@@ -39,6 +39,8 @@ const mapSessionAuthRow = (
workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_stripe_customer_id: string | null;
workspace_stripe_subscription_id: string | null;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string;
workspace_updated_at: string;
@@ -75,6 +77,8 @@ const mapSessionAuthRow = (
billing_email: row.workspace_billing_email,
billing_plan: row.workspace_billing_plan,
subscription_status: row.workspace_subscription_status,
stripe_customer_id: row.workspace_stripe_customer_id,
stripe_subscription_id: row.workspace_stripe_subscription_id,
rescue_verification_status: row.workspace_rescue_verification_status,
created_at: row.workspace_created_at,
updated_at: row.workspace_updated_at,
@@ -120,6 +124,8 @@ const mapIntegrationTokenAuthRow = (
workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_stripe_customer_id: string | null;
workspace_stripe_subscription_id: string | null;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string;
workspace_updated_at: string;
@@ -156,6 +162,8 @@ const mapIntegrationTokenAuthRow = (
billing_email: row.workspace_billing_email,
billing_plan: row.workspace_billing_plan,
subscription_status: row.workspace_subscription_status,
stripe_customer_id: row.workspace_stripe_customer_id,
stripe_subscription_id: row.workspace_stripe_subscription_id,
rescue_verification_status: row.workspace_rescue_verification_status,
created_at: row.workspace_created_at,
updated_at: row.workspace_updated_at,
@@ -338,6 +346,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => {
workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_stripe_customer_id: string | null;
workspace_stripe_subscription_id: string | null;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string;
workspace_updated_at: string;
@@ -369,6 +379,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => {
workspaces.billing_email AS workspace_billing_email,
workspaces.billing_plan AS workspace_billing_plan,
workspaces.subscription_status AS workspace_subscription_status,
workspaces.stripe_customer_id AS workspace_stripe_customer_id,
workspaces.stripe_subscription_id AS workspace_stripe_subscription_id,
workspaces.rescue_verification_status AS workspace_rescue_verification_status,
workspaces.created_at AS workspace_created_at,
workspaces.updated_at AS workspace_updated_at,
@@ -422,6 +434,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri
workspace_billing_email: string | null;
workspace_billing_plan: BillingPlan;
workspace_subscription_status: SubscriptionStatus;
workspace_stripe_customer_id: string | null;
workspace_stripe_subscription_id: string | null;
workspace_rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string;
workspace_updated_at: string;
@@ -458,6 +472,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri
workspaces.billing_email AS workspace_billing_email,
workspaces.billing_plan AS workspace_billing_plan,
workspaces.subscription_status AS workspace_subscription_status,
workspaces.stripe_customer_id AS workspace_stripe_customer_id,
workspaces.stripe_subscription_id AS workspace_stripe_subscription_id,
workspaces.rescue_verification_status AS workspace_rescue_verification_status,
workspaces.created_at AS workspace_created_at,
workspaces.updated_at AS workspace_updated_at,
@@ -1,5 +1,5 @@
import { db } from '../db/client.js';
import type { BillingPlan, RescueVerificationStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js';
import type { BillingPlan, RescueVerificationStatus, SubscriptionStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js';
export const getNextWorkspaceId = async () => {
const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces');
@@ -8,7 +8,7 @@ export const getNextWorkspaceId = async () => {
export const getWorkspaceById = async (workspaceId: number) => {
const result = await db.query<WorkspaceRow>(
`SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at
`SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at
FROM workspaces
WHERE id = $1`,
[workspaceId],
@@ -37,6 +37,8 @@ export const listMembershipsForUser = async (userId: string) => {
billing_email: string | null;
billing_plan: BillingPlan;
subscription_status: WorkspaceRow['subscription_status'];
stripe_customer_id: string | null;
stripe_subscription_id: string | null;
rescue_verification_status: RescueVerificationStatus;
workspace_created_at: string;
workspace_updated_at: string;
@@ -56,6 +58,8 @@ export const listMembershipsForUser = async (userId: string) => {
workspaces.billing_email,
workspaces.billing_plan,
workspaces.subscription_status,
workspaces.stripe_customer_id,
workspaces.stripe_subscription_id,
workspaces.rescue_verification_status,
workspaces.created_at AS workspace_created_at,
workspaces.updated_at AS workspace_updated_at
@@ -217,7 +221,7 @@ export const updateWorkspace = async ({
updated_at = CURRENT_TIMESTAMP
FROM input
WHERE workspaces.id = input.workspace_id
RETURNING workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at`,
RETURNING workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at`,
[workspaceId, name, workspaceType, billingEmail, billingPlan],
);
@@ -252,7 +256,7 @@ export const findAlternateWorkspaceForUser = async (userId: string, excludeWorks
export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => {
const result = await db.query<WorkspaceRow>(
`SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at
`SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at
FROM workspace_members
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
WHERE LOWER(COALESCE(workspace_members.invite_email, workspace_members.email)) = LOWER($1)
@@ -353,6 +357,8 @@ export const listRescueWorkspacesForAdmin = async () => {
workspaces.billing_email,
workspaces.billing_plan,
workspaces.subscription_status,
workspaces.stripe_customer_id,
workspaces.stripe_subscription_id,
workspaces.rescue_verification_status,
workspaces.created_at,
workspaces.updated_at,
@@ -398,7 +404,7 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND workspace_type = 'rescue'
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`,
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
[workspaceId, status],
);
@@ -415,13 +421,67 @@ export const cancelRescueVerificationRequest = async (workspaceId: number) => {
WHERE id = $1
AND workspace_type = 'rescue'
AND rescue_verification_status = 'pending'
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`,
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
[workspaceId],
);
return result.rows[0] ?? null;
};
export const setWorkspaceStripeCustomerId = async (workspaceId: number, stripeCustomerId: string) => {
const result = await db.query<WorkspaceRow>(
`UPDATE workspaces
SET stripe_customer_id = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
[workspaceId, stripeCustomerId],
);
return result.rows[0] ?? null;
};
export const setWorkspaceStripeSubscription = async ({
workspaceId,
stripeCustomerId,
stripeSubscriptionId,
subscriptionStatus,
}: {
workspaceId: number;
stripeCustomerId: string | null;
stripeSubscriptionId: string;
subscriptionStatus: SubscriptionStatus;
}) => {
const result = await db.query<WorkspaceRow>(
`UPDATE workspaces
SET stripe_customer_id = COALESCE($2, stripe_customer_id),
stripe_subscription_id = $3,
subscription_status = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
[workspaceId, stripeCustomerId, stripeSubscriptionId, subscriptionStatus],
);
return result.rows[0] ?? null;
};
export const setWorkspaceSubscriptionStatusByStripeSubscriptionId = async (
stripeSubscriptionId: string,
subscriptionStatus: SubscriptionStatus,
) => {
const result = await db.query<WorkspaceRow>(
`UPDATE workspaces
SET subscription_status = $2,
updated_at = CURRENT_TIMESTAMP
WHERE stripe_subscription_id = $1
RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
[stripeSubscriptionId, subscriptionStatus],
);
return result.rows[0] ?? null;
};
export const getPlatformAdminSummary = async () => {
const result = await db.query<{
total_birds: number;
+2
View File
@@ -22,6 +22,8 @@ export type WorkspaceRow = {
billing_email: string | null;
billing_plan: BillingPlan;
subscription_status: SubscriptionStatus;
stripe_customer_id: string | null;
stripe_subscription_id: string | null;
rescue_verification_status: RescueVerificationStatus;
created_at: string;
updated_at: string;