ghostguild-org/server/api/members/update-contribution.post.js
Jennie Robinson Faber 955217a941 chore(admin): rename pending_payment label and tier→contribution
Backlog cleanup from docs/LAUNCH_READINESS.md:
- B4: admin status filter + form options + STATUS_LABELS now read
  "Payment setup incomplete" so admins stop conflating with membership state
- CSV import preview header "Tier" → "Contribution"
- handleUpdateTier → handleUpdateContribution on /member/account
- update-contribution error log "tier" → "amount"
2026-04-29 17:54:53 +01:00

259 lines
8.4 KiB
JavaScript

// Update member's contribution tier
import {
getHelcimPlanId,
requiresPayment,
} from "../../config/contributions.js";
import { connectDB } from "../../utils/mongoose.js";
import Member from "../../models/member.js";
import {
getHelcimCustomer,
listHelcimCustomerCards,
createHelcimSubscription,
updateHelcimSubscription,
cancelHelcimSubscription,
generateIdempotencyKey,
listHelcimCustomerTransactions,
} from "../../utils/helcim.js";
import { upsertPaymentFromHelcim } from "../../utils/payments.js";
export default defineEventHandler(async (event) => {
try {
const member = await requireAuth(event);
await connectDB();
const body = await validateBody(event, updateContributionSchema);
const oldAmount = member.contributionAmount;
const newAmount = body.contributionAmount;
// If same amount, nothing to do
if (oldAmount === newAmount) {
return {
success: true,
message: "Already contributing this amount",
};
}
// 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: oldAmount, to: newAmount })
}
const oldRequiresPayment = requiresPayment(oldAmount);
const newRequiresPayment = requiresPayment(newAmount);
// 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',
});
}
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: cadence === 'annual' ? newAmount * 12 : newAmount,
paymentMethod: "card",
},
idempotencyKey,
);
const subscription = subscriptionData.data?.[0];
if (!subscription) {
throw new Error("No subscription returned in response");
}
const rawNextBilling = subscription.dateBilling || subscription.nextBillingDate || null;
const nextBillingDate = rawNextBilling ? new Date(rawNextBilling) : null;
// Update member record
await Member.findByIdAndUpdate(
member._id,
{ $set: {
contributionAmount: newAmount,
helcimSubscriptionId: subscription.id,
paymentMethod: "card",
status: "active",
billingCadence: cadence,
...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime())
? { nextBillingDate }
: {}),
} },
{ runValidators: false }
);
try {
const txs = await listHelcimCustomerTransactions(customerCode);
const latestPaid = txs.find((t) => t.status === 'paid');
if (latestPaid) {
await upsertPaymentFromHelcim(
{
_id: member._id,
email: member.email,
name: member.name,
helcimCustomerId: member.helcimCustomerId,
helcimSubscriptionId: subscription.id,
billingCadence: cadence,
},
latestPaid,
{ paymentType: cadence, sendConfirmation: true }
);
}
} catch (err) {
console.error('[payments] free→paid charge log failed, will be picked up by reconciliation:', err?.message || err);
}
logContributionChange();
return {
success: true,
message: "Successfully upgraded to paid tier",
subscription: {
subscriptionId: subscription.id,
status: subscription.status,
nextBillingDate: rawNextBilling,
},
};
} 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: { contributionAmount: newAmount, 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',
});
}
try {
const subscriptionData = await updateHelcimSubscription(
member.helcimSubscriptionId,
{ recurringAmount: memberCadence === 'annual' ? newAmount * 12 : newAmount }
);
await Member.findByIdAndUpdate(
member._id,
{ $set: { contributionAmount: newAmount } },
{ 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: { contributionAmount: newAmount } },
{ runValidators: false }
);
logContributionChange()
return {
success: true,
message: "Successfully updated contribution level",
};
} catch (error) {
if (error.statusCode) throw error;
console.error("Error updating contribution amount:", error);
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
});
}
});