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

@@ -1381,14 +1381,17 @@
return;
}
// Handle paid-to-free downgrade (cancel subscription)
// Handle paid-to-free downgrade (schedule cancellation at period end)
if (isPaidToFree) {
const renewsAt = account?.subscriptionRenewsAt;
const dateStr = renewsAt ? new Date(renewsAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : 'the end of your billing period';
showConfirmModal({
title: 'Downgrade to Free Plan',
body: 'You are moving to the free Hobby plan. Your subscription will be cancelled and you will lose premium features and limits. Are you sure you want to continue?',
body: `You are moving to the free Hobby plan. Your subscription will be cancelled at the end of your current billing period (${dateStr}). You will continue to have access to premium features and your current token allowance until then.`,
icon: 'warning',
onConfirm: async () => {
setStatus('Cancelling subscription...');
setStatus('Scheduling cancellation...');
try {
const resp = await fetch('/api/account', {
method: 'POST',
@@ -1409,7 +1412,7 @@
if (!resp.ok) throw new Error(data.error || 'Unable to downgrade plan');
renderAccount(data.account || {});
setStatus('Downgraded to free plan successfully', 'success');
setStatus(`Downgrade scheduled. You will lose premium access on ${dateStr}`, 'success');
} catch (err) {
setStatus(err.message, 'error');
}
@@ -1418,7 +1421,7 @@
return;
}
// Handle free-to-paid upgrade (requires checkout)
// Handle free-to-paid upgrade (requires checkout - now called via /api/account)
if (isPlanChange && isNewPaidPlan && !isPaidPlan) {
showConfirmModal({
title: 'Upgrade to Paid Plan',
@@ -1427,7 +1430,7 @@
onConfirm: async () => {
setStatus('Starting checkout...');
try {
const resp = await fetch('/api/subscription/checkout', {
const resp = await fetch('/api/account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1438,20 +1441,27 @@
plan: newPlan,
billingCycle: newBillingCycle,
currency: newCurrency,
inline: true,
billingEmail: newBillingEmail,
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || 'Unable to start checkout');
if (data.inlineCheckoutUrl) {
openInlineCheckout(data.inlineCheckoutUrl, data.sessionId, newPlan, newBillingCycle, newCurrency);
} else if (data.checkoutUrl) {
openInlineCheckout(data.checkoutUrl, data.sessionId, newPlan, newBillingCycle, newCurrency);
} else {
throw new Error('Checkout URL not returned');
// Handle seamless checkout redirect
if (data.requiresCheckout && data.checkoutUrl) {
// Redirect to checkout without showing error
window.location.href = data.checkoutUrl;
return;
}
// For inline checkout (fallback)
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
return;
}
throw new Error('Checkout URL not returned');
} catch (err) {
setStatus(err.message, 'error');
}

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);