From e2abb2ee98b7084125680dca05b2275407125ec0 Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Wed, 11 Feb 2026 20:16:01 +0000 Subject: [PATCH] fix 2 --- chat/public/settings.html | 36 ++++-- chat/server.js | 263 ++++++++++++++++++++++++++++++++------ 2 files changed, 250 insertions(+), 49 deletions(-) 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);