Add stripe integration
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user