This commit is contained in:
southseact-3d
2026-02-11 20:16:01 +00:00
parent a66c983360
commit e2abb2ee98
2 changed files with 250 additions and 49 deletions

View File

@@ -4977,6 +4977,8 @@ async function serializeAccount(user) {
billingStatus: user.billingStatus || DEFAULT_BILLING_STATUS,
billingEmail: user.billingEmail || user.email || '',
subscriptionRenewsAt: user.subscriptionRenewsAt || null,
billingCycle: user.billingCycle || null,
subscriptionCurrency: user.subscriptionCurrency || user.currency || 'usd',
referredByAffiliateCode: sanitizeAffiliateCode(user.referredByAffiliateCode),
affiliateAttributionAt: user.affiliateAttributionAt || null,
affiliatePayouts: normalizedPayouts,
@@ -4991,6 +4993,7 @@ async function serializeAccount(user) {
tokenUsage: getTokenUsageSummary(user.id, user.plan || DEFAULT_PLAN),
externalTestingUsage: getExternalTestUsageSummary(user.id, user.plan || DEFAULT_PLAN),
paymentMethod,
scheduledPlanChange: user.scheduledPlanChange || null,
};
}
@@ -6646,31 +6649,39 @@ async function dodoRequest(pathname, { method = 'GET', body, query } = {}) {
return payload;
}
// Attempt immediate cancellation first (DELETE), then fall back to end-of-period cancellation (PATCH).
const DODO_SUBSCRIPTION_CANCEL_ATTEMPTS = [
{ method: 'DELETE', body: undefined, label: 'immediate' },
{ method: 'PATCH', body: { cancel_at_next_billing_date: true }, label: 'period_end' },
];
/**
* Cancel a Dodo subscription with a best-effort fallback strategy.
* Tries immediate cancellation first, then schedules cancellation at period end.
* Cancel a Dodo subscription with support for immediate or end-of-period cancellation.
* @param {object} user - User record containing dodoSubscriptionId.
* @param {string} reason - Cancellation reason for logging.
* @param {object} options - Behavior options.
* @param {boolean} options.clearOnFailure - Whether to clear the subscription ID on failure.
* @param {string} options.mode - Cancellation mode: 'immediate' or 'at_period_end'. Default: 'immediate'.
* @returns {Promise<boolean>} True if a cancellation request succeeded.
*/
async function cancelDodoSubscription(user, reason = 'manual', { clearOnFailure = false } = {}) {
async function cancelDodoSubscription(user, reason = 'manual', { clearOnFailure = false, mode = 'immediate' } = {}) {
if (!user?.dodoSubscriptionId) return false;
const subscriptionId = user.dodoSubscriptionId;
for (const attempt of DODO_SUBSCRIPTION_CANCEL_ATTEMPTS) {
// Determine cancellation strategy based on mode
const attempts = mode === 'at_period_end'
? [{ method: 'PATCH', body: { cancel_at_next_billing_date: true }, label: 'period_end' }]
: [
{ method: 'DELETE', body: undefined, label: 'immediate' },
{ method: 'PATCH', body: { cancel_at_next_billing_date: true }, label: 'period_end' },
];
for (const attempt of attempts) {
try {
await dodoRequest(`/subscriptions/${subscriptionId}`, attempt);
log('Dodo subscription cancelled', { userId: user.id, subscriptionId, reason, method: attempt.method, mode: attempt.label });
user.dodoSubscriptionId = '';
await persistUsersDb();
// Only clear subscription ID immediately for immediate cancellation
// For period-end cancellation, keep it until webhook confirms actual cancellation
if (mode === 'immediate') {
user.dodoSubscriptionId = '';
await persistUsersDb();
}
return true;
} catch (error) {
log('Failed to cancel Dodo subscription', {
@@ -6683,6 +6694,7 @@ async function cancelDodoSubscription(user, reason = 'manual', { clearOnFailure
});
}
}
if (clearOnFailure) {
user.dodoSubscriptionId = '';
await persistUsersDb();
@@ -12101,16 +12113,35 @@ async function handleAccountSettingsUpdate(req, res) {
if (requestedPlan && requestedPlan !== user.plan) {
const isPaidToFree = PAID_PLANS.has(user.plan) && requestedPlan === 'hobby';
const isPaidToPaid = PAID_PLANS.has(user.plan) && PAID_PLANS.has(requestedPlan);
const isFreeToPaid = user.plan === 'hobby' && PAID_PLANS.has(requestedPlan);
// Cancel Dodo subscription when changing from paid to free hobby plan
// Schedule cancellation at period end when changing from paid to free hobby plan
// User keeps premium features until the end of their billing period
if (isPaidToFree && user.dodoSubscriptionId) {
await cancelDodoSubscription(user, 'paid_to_free', { clearOnFailure: true });
user.plan = requestedPlan;
user.billingStatus = DEFAULT_BILLING_STATUS;
user.subscriptionRenewsAt = null;
user.billingCycle = null;
user.subscriptionCurrency = null;
updated = true;
const cancelSuccess = await cancelDodoSubscription(user, 'paid_to_free', {
clearOnFailure: true,
mode: 'at_period_end'
});
if (cancelSuccess) {
// Mark as scheduled for cancellation but keep plan details until period ends
user.scheduledPlanChange = {
fromPlan: user.plan,
toPlan: 'hobby',
scheduledAt: new Date().toISOString(),
};
user.billingStatus = 'cancellation_scheduled';
// Keep subscriptionRenewsAt and other details until actual cancellation
updated = true;
log('Scheduled subscription cancellation at period end', {
userId: user.id,
subscriptionId: user.dodoSubscriptionId,
renewsAt: user.subscriptionRenewsAt
});
} else {
return sendJson(res, 400, { error: 'Failed to schedule subscription cancellation' });
}
}
// For paid-to-paid changes, use Dodo's Change Plan API if subscription exists
else if (isPaidToPaid && user.dodoSubscriptionId) {
@@ -12127,19 +12158,112 @@ async function handleAccountSettingsUpdate(req, res) {
user.billingCycle = targetBillingCycle;
user.subscriptionCurrency = targetCurrency;
user.billingStatus = DEFAULT_BILLING_STATUS;
user.subscriptionRenewsAt = computeRenewalDate(targetBillingCycle);
// Keep the same billing cycle end date - Dodo maintains this
// user.subscriptionRenewsAt stays the same
updated = true;
// Clear any scheduled plan change if exists
if (user.scheduledPlanChange) {
delete user.scheduledPlanChange;
}
} catch (error) {
log('Failed to change plan via Dodo API', { userId: user.id, error: String(error) });
return sendJson(res, 400, { error: error.message || 'Unable to change subscription plan' });
}
}
// For free-to-paid or when no subscription exists, redirect to checkout
else if (!user.dodoSubscriptionId && PAID_PLANS.has(requestedPlan)) {
return sendJson(res, 400, {
error: 'Please use the checkout flow to subscribe to a paid plan',
requiresCheckout: true
});
// For free-to-paid upgrades, create a checkout session and return the URL
else if (isFreeToPaid || (!user.dodoSubscriptionId && PAID_PLANS.has(requestedPlan))) {
try {
const targetBillingCycle = requestedBillingCycle || 'monthly';
const targetCurrency = requestedCurrency || user.currency || 'usd';
// Validate the subscription selection
if (!validateSubscriptionSelection(requestedPlan, targetBillingCycle, targetCurrency)) {
return sendJson(res, 400, { error: 'Invalid plan, billing cycle, or currency combination' });
}
const product = resolveSubscriptionProduct(requestedPlan, targetBillingCycle, targetCurrency);
if (!product) {
return sendJson(res, 400, { error: 'Subscription product not available' });
}
// Create a unique session ID for this checkout
const checkoutSessionId = randomUUID();
// Create checkout session with enhanced metadata for session tracking
const checkoutBody = {
product_cart: [{
product_id: product.productId,
quantity: 1,
}],
customer: {
email: user.billingEmail || user.email,
name: user.billingEmail || user.email,
},
metadata: {
type: 'subscription',
orderId: String(checkoutSessionId),
userId: String(user.id),
sessionId: String(checkoutSessionId),
plan: String(requestedPlan),
billingCycle: String(targetBillingCycle),
currency: String(targetCurrency),
amount: String(product.price),
isUpgrade: 'true',
},
settings: {
allow_payment_methods: ['card'],
redirect_immediately: true,
},
};
const returnUrl = `${getBaseUrl()}/subscription-success`;
checkoutBody.return_url = returnUrl;
const checkoutSession = await dodoRequest('/checkouts', {
method: 'POST',
body: checkoutBody,
});
const sessionId = checkoutSession?.session_id || checkoutSession?.id || '';
if (!sessionId || !checkoutSession?.checkout_url) {
throw new Error('Dodo checkout session was not created');
}
// Store pending subscription
pendingSubscriptions[sessionId] = {
userId: user.id,
orderId: checkoutSessionId,
plan: requestedPlan,
billingCycle: targetBillingCycle,
currency: targetCurrency,
productId: product.productId,
price: product.price,
checkoutSessionId,
createdAt: new Date().toISOString(),
isUpgrade: true,
};
await persistPendingSubscriptions();
log('Created checkout session for free-to-paid upgrade', {
userId: user.id,
plan: requestedPlan,
sessionId,
});
return sendJson(res, 200, {
ok: true,
requiresCheckout: true,
checkoutUrl: checkoutSession.checkout_url,
sessionId,
message: 'Redirecting to checkout to complete your upgrade',
});
} catch (error) {
log('Failed to create checkout for free-to-paid upgrade', { userId: user.id, error: String(error) });
return sendJson(res, 400, {
error: 'Unable to start checkout process. Please try again or contact support.'
});
}
}
// Simple plan update for free plans or special cases
else {
@@ -13562,20 +13686,37 @@ async function handleSubscriptionCancel(req, res) {
return sendJson(res, 400, { error: 'No active subscription to cancel' });
}
// Cancel Dodo subscription
// Schedule cancellation at period end
if (user.dodoSubscriptionId) {
await cancelDodoSubscription(user, 'subscription_cancel', { clearOnFailure: true });
const cancelSuccess = await cancelDodoSubscription(user, 'manual_cancel', {
mode: 'at_period_end'
});
if (cancelSuccess) {
// Mark as scheduled for cancellation
user.scheduledPlanChange = {
fromPlan: user.plan,
toPlan: 'hobby',
scheduledAt: new Date().toISOString(),
};
user.billingStatus = 'cancellation_scheduled';
// Keep subscriptionRenewsAt until actual cancellation
log('Scheduled subscription cancellation at period end (manual)', {
userId: user.id,
subscriptionId: user.dodoSubscriptionId,
renewsAt: user.subscriptionRenewsAt
});
} else {
return sendJson(res, 400, { error: 'Failed to schedule subscription cancellation' });
}
}
user.billingStatus = 'cancelled';
await persistUsersDb();
log('subscription cancelled', { userId: user.id, email: user.email, plan: user.plan });
const accountData = await serializeAccount(user);
return sendJson(res, 200, {
ok: true,
message: 'Subscription cancelled. Access will continue until the end of the billing period.',
message: 'Subscription will be cancelled at the end of your billing period. You will continue to have access until then.',
account: accountData,
});
}
@@ -14015,13 +14156,31 @@ async function handleSubscriptionCanceled(event) {
return;
}
// Check if this was a planned downgrade
const wasScheduledDowngrade = user.scheduledPlanChange && user.scheduledPlanChange.toPlan === 'hobby';
// Downgrade user to hobby plan
user.plan = 'hobby';
user.billingStatus = 'cancelled';
user.dodoSubscriptionId = '';
user.subscriptionRenewsAt = '';
user.subscriptionRenewsAt = null;
user.billingCycle = null;
user.subscriptionCurrency = null;
// Clear scheduled plan change if it exists
if (user.scheduledPlanChange) {
delete user.scheduledPlanChange;
}
await persistUsersDb();
log('subscription_canceled: user downgraded to hobby', { userId: user.id, email: user.email, subscriptionId, eventId: event.id });
log('subscription_canceled: user downgraded to hobby', {
userId: user.id,
email: user.email,
subscriptionId,
eventId: event.id,
wasScheduledDowngrade,
});
if (user.email) {
await sendSubscriptionCancelledEmail(user, data);
@@ -14362,13 +14521,45 @@ async function handleSubscriptionPlanChanged(event) {
return;
}
// Determine the new plan from the subscription data
const newPlan = data.plan || data.metadata?.plan;
const oldPlan = user.plan;
if (newPlan && USER_PLANS.includes(newPlan.toLowerCase())) {
user.plan = newPlan.toLowerCase();
user.billingStatus = 'active';
// Update billing cycle if available
if (data.billing_cycle) {
user.billingCycle = data.billing_cycle;
}
// Update currency if available
if (data.currency) {
user.subscriptionCurrency = data.currency;
}
// Update renewal date
if (data.renews_at || data.current_period_end) {
user.subscriptionRenewsAt = data.renews_at || data.current_period_end;
}
// Clear any scheduled plan change
if (user.scheduledPlanChange) {
delete user.scheduledPlanChange;
}
}
await persistUsersDb();
log('subscription_plan_changed: plan changed', { userId: user.id, subscriptionId, newPlan: user.plan, eventId: event.id });
log('subscription_plan_changed: plan changed', {
userId: user.id,
subscriptionId,
oldPlan,
newPlan: user.plan,
billingStatus: user.billingStatus,
eventId: event.id
});
if (user.email) {
await sendSubscriptionPlanChangedEmail(user, data);