Persist nextBillingDate on subscription create/update; unset on cancel or downgrade to free. Account page displays the cached date and lazily refreshes from Helcim when the cached value is within 24h of now (or missing).
246 lines
7.6 KiB
JavaScript
246 lines
7.6 KiB
JavaScript
// Update member's contribution tier
|
|
import {
|
|
getHelcimPlanId,
|
|
requiresPayment,
|
|
getContributionTierByValue,
|
|
getTierAmount,
|
|
} from "../../config/contributions.js";
|
|
import { connectDB } from "../../utils/mongoose.js";
|
|
import Member from "../../models/member.js";
|
|
import {
|
|
getHelcimCustomer,
|
|
listHelcimCustomerCards,
|
|
createHelcimSubscription,
|
|
updateHelcimSubscription,
|
|
cancelHelcimSubscription,
|
|
generateIdempotencyKey,
|
|
} from "../../utils/helcim.js";
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
try {
|
|
const member = await requireAuth(event);
|
|
await connectDB();
|
|
const body = await validateBody(event, updateContributionSchema);
|
|
|
|
const oldTier = member.contributionTier;
|
|
const newTier = body.contributionTier;
|
|
|
|
// If same tier, nothing to do
|
|
if (oldTier === newTier) {
|
|
return {
|
|
success: true,
|
|
message: "Already on this tier",
|
|
};
|
|
}
|
|
|
|
// Log contribution change (fire-and-forget, at the top so it logs regardless of which case path executes)
|
|
const logContributionChange = () => {
|
|
logActivity(member._id, 'contribution_changed', { from: oldTier, to: newTier })
|
|
}
|
|
|
|
const oldRequiresPayment = requiresPayment(oldTier);
|
|
const newRequiresPayment = requiresPayment(newTier);
|
|
|
|
// Case 1: Moving from free to paid tier
|
|
if (!oldRequiresPayment && newRequiresPayment) {
|
|
// Check if member has Helcim customer ID with saved payment method
|
|
if (!member.helcimCustomerId) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage:
|
|
"Please use the subscription creation flow to upgrade to a paid tier",
|
|
data: { requiresPaymentSetup: true },
|
|
});
|
|
}
|
|
|
|
// Resolve plan id before entering the try/catch (so missing plan → 500, not swallowed 400)
|
|
const cadence = body.cadence; // defaulted to 'monthly' by Zod
|
|
const paymentPlanId = getHelcimPlanId(cadence);
|
|
if (!paymentPlanId) {
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: cadence === 'annual'
|
|
? 'Annual plan id not configured'
|
|
: 'Monthly plan id not configured',
|
|
});
|
|
}
|
|
|
|
const tierInfo = getContributionTierByValue(newTier);
|
|
|
|
try {
|
|
const customerData = await getHelcimCustomer(member.helcimCustomerId);
|
|
const customerCode = customerData.customerCode;
|
|
|
|
if (!customerCode) {
|
|
throw new Error("No customer code found");
|
|
}
|
|
|
|
// Check for saved cards
|
|
const cards = await listHelcimCustomerCards(member.helcimCustomerId);
|
|
const hasCards = Array.isArray(cards) && cards.length > 0;
|
|
|
|
if (!hasCards) {
|
|
throw new Error("No saved payment methods");
|
|
}
|
|
|
|
const idempotencyKey = generateIdempotencyKey();
|
|
|
|
const subscriptionData = await createHelcimSubscription(
|
|
{
|
|
dateActivated: new Date().toISOString().split("T")[0],
|
|
paymentPlanId: parseInt(paymentPlanId),
|
|
customerCode,
|
|
recurringAmount: getTierAmount(tierInfo, cadence),
|
|
paymentMethod: "card",
|
|
},
|
|
idempotencyKey,
|
|
);
|
|
|
|
const subscription = subscriptionData.data?.[0];
|
|
|
|
if (!subscription) {
|
|
throw new Error("No subscription returned in response");
|
|
}
|
|
|
|
const nextBillingDate = subscription.nextBillingDate
|
|
? new Date(subscription.nextBillingDate)
|
|
: null;
|
|
|
|
// Update member record
|
|
await Member.findByIdAndUpdate(
|
|
member._id,
|
|
{ $set: {
|
|
contributionTier: newTier,
|
|
helcimSubscriptionId: subscription.id,
|
|
paymentMethod: "card",
|
|
status: "active",
|
|
billingCadence: cadence,
|
|
...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime())
|
|
? { nextBillingDate }
|
|
: {}),
|
|
} },
|
|
{ runValidators: false }
|
|
);
|
|
|
|
logContributionChange();
|
|
|
|
return {
|
|
success: true,
|
|
message: "Successfully upgraded to paid tier",
|
|
subscription: {
|
|
subscriptionId: subscription.id,
|
|
status: subscription.status,
|
|
nextBillingDate: subscription.nextBillingDate,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error("Error creating subscription with saved payment:", error);
|
|
// If we can't use saved payment, require new payment setup
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage:
|
|
"Payment information required. You'll be redirected to complete payment setup.",
|
|
data: { requiresPaymentSetup: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Case 2: Moving from paid to free tier (cancel subscription)
|
|
if (oldRequiresPayment && !newRequiresPayment) {
|
|
if (member.helcimSubscriptionId) {
|
|
try {
|
|
await cancelHelcimSubscription(member.helcimSubscriptionId);
|
|
} catch (cancelError) {
|
|
console.error("Error canceling Helcim subscription:", cancelError);
|
|
// Continue anyway - we'll update the member record
|
|
}
|
|
}
|
|
|
|
// Update member to free tier
|
|
await Member.findByIdAndUpdate(
|
|
member._id,
|
|
{
|
|
$set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" },
|
|
$unset: { nextBillingDate: 1 },
|
|
},
|
|
{ runValidators: false }
|
|
);
|
|
|
|
logContributionChange()
|
|
|
|
return {
|
|
success: true,
|
|
message: "Successfully downgraded to free tier",
|
|
};
|
|
}
|
|
|
|
// Case 3: Moving between paid tiers
|
|
if (oldRequiresPayment && newRequiresPayment) {
|
|
if (!member.helcimSubscriptionId) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: "Payment information required. You'll be redirected to complete payment setup.",
|
|
data: { requiresPaymentSetup: true },
|
|
});
|
|
}
|
|
|
|
const memberCadence = member.billingCadence || 'monthly';
|
|
if (body.cadence && body.cadence !== memberCadence) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Cadence switch not supported on existing subscription',
|
|
});
|
|
}
|
|
|
|
const newTierInfo = getContributionTierByValue(newTier);
|
|
if (!newTierInfo) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Invalid tier' });
|
|
}
|
|
|
|
try {
|
|
const subscriptionData = await updateHelcimSubscription(
|
|
member.helcimSubscriptionId,
|
|
{ recurringAmount: getTierAmount(newTierInfo, memberCadence) }
|
|
);
|
|
|
|
await Member.findByIdAndUpdate(
|
|
member._id,
|
|
{ $set: { contributionTier: newTier } },
|
|
{ runValidators: false }
|
|
);
|
|
|
|
logContributionChange();
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Successfully updated contribution level',
|
|
subscription: subscriptionData,
|
|
};
|
|
} catch (updateError) {
|
|
console.error('Error updating Helcim subscription:', updateError);
|
|
throw createError({ statusCode: 500, statusMessage: 'Subscription update failed' });
|
|
}
|
|
}
|
|
|
|
// Case 4: Moving between free tiers (shouldn't happen but handle it)
|
|
await Member.findByIdAndUpdate(
|
|
member._id,
|
|
{ $set: { contributionTier: newTier } },
|
|
{ runValidators: false }
|
|
);
|
|
|
|
logContributionChange()
|
|
|
|
return {
|
|
success: true,
|
|
message: "Successfully updated contribution level",
|
|
};
|
|
} catch (error) {
|
|
if (error.statusCode) throw error;
|
|
console.error("Error updating contribution tier:", error);
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: "An unexpected error occurred",
|
|
});
|
|
}
|
|
});
|