fix 2
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
263
chat/server.js
263
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<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);
|
||||
|
||||
Reference in New Issue
Block a user