diff --git a/chat/public/settings.html b/chat/public/settings.html
index fe78ac4..664f6c8 100644
--- a/chat/public/settings.html
+++ b/chat/public/settings.html
@@ -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');
}
diff --git a/chat/server.js b/chat/server.js
index 96b7044..f530219 100644
--- a/chat/server.js
+++ b/chat/server.js
@@ -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} 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);