additional stripe changes and Billing info cleanup
This commit is contained in:
+72
-10
@@ -70,6 +70,7 @@ import {
|
||||
} from './repositories/workspaceRepository.js';
|
||||
import type {
|
||||
AuthContext,
|
||||
BillingInterval,
|
||||
BillingPlan,
|
||||
BirdGender,
|
||||
BirdRow,
|
||||
@@ -140,6 +141,7 @@ const switchWorkspaceSchema = z.object({
|
||||
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
||||
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
||||
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
||||
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
||||
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
||||
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
||||
@@ -149,6 +151,7 @@ const workspaceSchema = z.object({
|
||||
workspaceType: workspaceTypeSchema,
|
||||
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
||||
billingPlan: billingPlanSchema.optional(),
|
||||
billingInterval: billingIntervalSchema.optional(),
|
||||
});
|
||||
|
||||
const createWorkspaceSchema = z.object({
|
||||
@@ -156,6 +159,7 @@ const createWorkspaceSchema = z.object({
|
||||
workspaceType: workspaceTypeSchema,
|
||||
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
||||
billingPlan: billingPlanSchema.optional(),
|
||||
billingInterval: billingIntervalSchema.optional(),
|
||||
});
|
||||
|
||||
const workspaceMemberSchema = z.object({
|
||||
@@ -241,10 +245,22 @@ 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 stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
|
||||
household_basic: {
|
||||
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() || '',
|
||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY?.trim() ?? '',
|
||||
},
|
||||
household_plus: {
|
||||
monthly:
|
||||
process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY?.trim() ||
|
||||
process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ||
|
||||
'',
|
||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '',
|
||||
},
|
||||
household_macaw: {
|
||||
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() || '',
|
||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY?.trim() ?? '',
|
||||
},
|
||||
};
|
||||
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
||||
const adminEmails = new Set(
|
||||
@@ -286,6 +302,7 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
|
||||
workspaceType: row.workspace_type,
|
||||
billingEmail: row.billing_email,
|
||||
billingPlan: row.billing_plan,
|
||||
billingInterval: row.billing_interval,
|
||||
subscriptionStatus: row.subscription_status,
|
||||
stripeCustomerId: row.stripe_customer_id,
|
||||
stripeSubscriptionId: row.stripe_subscription_id,
|
||||
@@ -451,11 +468,14 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }
|
||||
|
||||
if (workspaceId && subscriptionId) {
|
||||
const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId);
|
||||
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
|
||||
await setWorkspaceStripeSubscription({
|
||||
workspaceId,
|
||||
stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id),
|
||||
stripeSubscriptionId: subscription.id,
|
||||
subscriptionStatus: mapStripeSubscriptionStatus(subscription.status),
|
||||
billingPlan: billingSelection.billingPlan,
|
||||
billingInterval: billingSelection.billingInterval,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -469,6 +489,7 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }
|
||||
const workspaceId = Number(subscription.metadata?.workspaceId ?? 0);
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status);
|
||||
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
|
||||
|
||||
if (workspaceId) {
|
||||
await setWorkspaceStripeSubscription({
|
||||
@@ -476,9 +497,16 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
subscriptionStatus,
|
||||
billingPlan: billingSelection.billingPlan,
|
||||
billingInterval: billingSelection.billingInterval,
|
||||
});
|
||||
} else {
|
||||
await setWorkspaceSubscriptionStatusByStripeSubscriptionId(subscription.id, subscriptionStatus);
|
||||
await setWorkspaceSubscriptionStatusByStripeSubscriptionId(
|
||||
subscription.id,
|
||||
subscriptionStatus,
|
||||
billingSelection.billingPlan,
|
||||
billingSelection.billingInterval,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +529,7 @@ const normalizeWorkspaceMembershipList = async (userId: string) =>
|
||||
workspace_type: row.workspace_type,
|
||||
billing_email: row.billing_email,
|
||||
billing_plan: row.billing_plan,
|
||||
billing_interval: row.billing_interval,
|
||||
subscription_status: row.subscription_status,
|
||||
stripe_customer_id: row.stripe_customer_id,
|
||||
stripe_subscription_id: row.stripe_subscription_id,
|
||||
@@ -536,16 +565,43 @@ const getStripeClient = () => {
|
||||
return stripe;
|
||||
};
|
||||
|
||||
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan) => {
|
||||
const priceId = stripePriceByBillingPlan[billingPlan]?.trim() ?? '';
|
||||
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
|
||||
if (billingPlan === 'rescue_free') {
|
||||
throw new Error('Rescue flocks do not use Stripe billing.');
|
||||
}
|
||||
|
||||
const priceId = stripePriceByBillingPlanAndInterval[billingPlan]?.[billingInterval]?.trim() ?? '';
|
||||
|
||||
if (!priceId) {
|
||||
throw new Error(`Stripe price is not configured for ${billingPlan}.`);
|
||||
throw new Error(`Stripe price is not configured for ${billingPlan} (${billingInterval}).`);
|
||||
}
|
||||
|
||||
return priceId;
|
||||
};
|
||||
|
||||
const getBillingSelectionForStripePrice = (priceId: string) => {
|
||||
for (const [billingPlan, intervals] of Object.entries(stripePriceByBillingPlanAndInterval)) {
|
||||
for (const [billingInterval, configuredPriceId] of Object.entries(intervals ?? {})) {
|
||||
if (configuredPriceId && configuredPriceId === priceId) {
|
||||
return {
|
||||
billingPlan: billingPlan as BillingPlan,
|
||||
billingInterval: billingInterval as BillingInterval,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
billingPlan: null,
|
||||
billingInterval: null,
|
||||
};
|
||||
};
|
||||
|
||||
const getBillingSelectionForStripeSubscription = (subscription: Stripe.Subscription) => {
|
||||
const priceId = subscription.items.data[0]?.price.id ?? '';
|
||||
return getBillingSelectionForStripePrice(priceId);
|
||||
};
|
||||
|
||||
const createAuthSession = async (userId: string, activeWorkspaceId: number) => {
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashToken(token);
|
||||
@@ -1233,7 +1289,7 @@ app.post(
|
||||
requireSessionAuth,
|
||||
requireWorkspaceRole(['owner', 'assistant']),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const parsed = z.object({ billingPlan: billingPlanSchema.optional() }).safeParse(req.body ?? {});
|
||||
const parsed = z.object({ billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional() }).safeParse(req.body ?? {});
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid billing payload', details: parsed.error.flatten() });
|
||||
@@ -1249,7 +1305,8 @@ app.post(
|
||||
}
|
||||
|
||||
const billingPlan = parsed.data.billingPlan ?? workspace.billing_plan;
|
||||
const priceId = getStripePriceIdForBillingPlan(billingPlan);
|
||||
const billingInterval = parsed.data.billingInterval ?? workspace.billing_interval;
|
||||
const priceId = getStripePriceIdForBillingPlan(billingPlan, billingInterval);
|
||||
let stripeCustomerId = workspace.stripe_customer_id;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
@@ -1276,11 +1333,13 @@ app.post(
|
||||
metadata: {
|
||||
workspaceId: String(workspace.id),
|
||||
billingPlan,
|
||||
billingInterval,
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
workspaceId: String(workspace.id),
|
||||
billingPlan,
|
||||
billingInterval,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1410,6 +1469,7 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
||||
workspaceType: parsed.data.workspaceType,
|
||||
billingEmail: emptyToNull(parsed.data.billingEmail),
|
||||
billingPlan,
|
||||
billingInterval: parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? 'monthly'),
|
||||
owner: req.auth!.user,
|
||||
});
|
||||
|
||||
@@ -1447,6 +1507,7 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(
|
||||
workspaceType: parsed.data.workspaceType,
|
||||
billingEmail: emptyToNull(parsed.data.billingEmail),
|
||||
billingPlan,
|
||||
billingInterval: parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? req.auth!.workspace.billing_interval),
|
||||
});
|
||||
|
||||
if (workspace?.workspace_type === 'rescue' && req.auth!.workspace.workspace_type !== 'rescue') {
|
||||
@@ -1480,6 +1541,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
||||
workspaceType: 'standard',
|
||||
billingEmail: req.auth!.user.email,
|
||||
billingPlan: 'household_basic',
|
||||
billingInterval: 'monthly',
|
||||
owner: req.auth!.user,
|
||||
});
|
||||
nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId;
|
||||
|
||||
Reference in New Issue
Block a user