diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js index 57f7fdc..fd236f8 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -53,6 +53,20 @@ export default defineEventHandler(async (event) => { }); } + // 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; @@ -61,7 +75,7 @@ export default defineEventHandler(async (event) => { throw new Error("No customer code found"); } - // Check for saved cards (FIX: use the correct endpoint) + // Check for saved cards const cards = await listHelcimCustomerCards(member.helcimCustomerId); const hasCards = Array.isArray(cards) && cards.length > 0; @@ -69,27 +83,14 @@ export default defineEventHandler(async (event) => { throw new Error("No saved payment methods"); } - // Create new subscription with saved payment method - const newPlanId = getHelcimPlanId(newTier); - - if (!newPlanId) { - throw createError({ - statusCode: 400, - statusMessage: `Plan not configured for tier ${newTier}`, - }); - } - const idempotencyKey = generateIdempotencyKey(); - // Get tier amount - const tierInfo = getContributionTierByValue(newTier); - const subscriptionData = await createHelcimSubscription( { dateActivated: new Date().toISOString().split("T")[0], - paymentPlanId: parseInt(newPlanId), - customerCode: customerCode, - recurringAmount: parseFloat(tierInfo.amount), + paymentPlanId: parseInt(paymentPlanId), + customerCode, + recurringAmount: getTierAmount(tierInfo, cadence), paymentMethod: "card", }, idempotencyKey, @@ -104,11 +105,17 @@ export default defineEventHandler(async (event) => { // Update member record await Member.findByIdAndUpdate( member._id, - { $set: { contributionTier: newTier, helcimSubscriptionId: subscription.id, paymentMethod: "card", status: "active" } }, + { $set: { + contributionTier: newTier, + helcimSubscriptionId: subscription.id, + paymentMethod: "card", + status: "active", + billingCadence: cadence, + } }, { runValidators: false } ); - logContributionChange() + logContributionChange(); return { success: true, @@ -145,7 +152,7 @@ export default defineEventHandler(async (event) => { // Update member to free tier await Member.findByIdAndUpdate( member._id, - { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none" } }, + { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } }, { runValidators: false } ); diff --git a/tests/server/api/update-contribution.test.js b/tests/server/api/update-contribution.test.js index f1a4a45..38e18c1 100644 --- a/tests/server/api/update-contribution.test.js +++ b/tests/server/api/update-contribution.test.js @@ -3,10 +3,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.js' import { requiresPayment, + getHelcimPlanId, getContributionTierByValue, getTierAmount, } from '../../../server/config/contributions.js' -import { updateHelcimSubscription } from '../../../server/utils/helcim.js' +import { + updateHelcimSubscription, + getHelcimCustomer, + listHelcimCustomerCards, + createHelcimSubscription, + cancelHelcimSubscription, +} from '../../../server/utils/helcim.js' import handler from '../../../server/api/members/update-contribution.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -205,3 +212,148 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() }) }) + +describe('update-contribution endpoint — Case 1 (free→paid)', () => { + beforeEach(() => { + vi.clearAllMocks() + // old tier = free, new tier = paid + requiresPayment.mockImplementation((tier) => tier !== '0') + }) + + function setMember(mockMember) { + globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) + } + + const freeMember = { + _id: 'member-c1', + contributionTier: '0', + helcimCustomerId: 'cust-1', + } + + it('monthly: calls createHelcimSubscription with monthly plan id and recurringAmount 15, persists billingCadence monthly', async () => { + setMember(freeMember) + getHelcimPlanId.mockReturnValue('111') + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(15) + getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) + listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) + createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-new', status: 'active', nextBillingDate: '2026-05-18' }] }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'monthly' }, + }) + + const result = await handler(event) + + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 111, recurringAmount: 15 }), + 'idem-key-123' + ) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-c1', + { $set: expect.objectContaining({ billingCadence: 'monthly', contributionTier: '15', helcimSubscriptionId: 'sub-new' }) }, + { runValidators: false } + ) + expect(result.success).toBe(true) + expect(result.message).toBe('Successfully upgraded to paid tier') + }) + + it('annual: calls createHelcimSubscription with annual plan id and recurringAmount 150, persists billingCadence annual', async () => { + setMember(freeMember) + getHelcimPlanId.mockReturnValue('222') + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(150) + getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) + listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) + createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual', status: 'active', nextBillingDate: '2027-04-18' }] }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'annual' }, + }) + + const result = await handler(event) + + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 222, recurringAmount: 150 }), + 'idem-key-123' + ) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-c1', + { $set: expect.objectContaining({ billingCadence: 'annual', contributionTier: '15', helcimSubscriptionId: 'sub-annual' }) }, + { runValidators: false } + ) + expect(result.success).toBe(true) + }) + + it('missing plan id env → 500, createHelcimSubscription NOT called, member NOT updated', async () => { + setMember(freeMember) + getHelcimPlanId.mockReturnValue(null) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'monthly' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Monthly plan id not configured', + }) + + expect(createHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) +}) + +describe('update-contribution endpoint — Case 2 (paid→free)', () => { + beforeEach(() => { + vi.clearAllMocks() + // old tier = paid, new tier = free + requiresPayment.mockImplementation((tier) => tier !== '0') + }) + + function setMember(mockMember) { + globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) + } + + it('cancels subscription, resets billingCadence to monthly, clears helcimSubscriptionId', async () => { + const mockMember = { + _id: 'member-c2', + contributionTier: '15', + helcimSubscriptionId: 'sub-1', + billingCadence: 'monthly', + } + setMember(mockMember) + cancelHelcimSubscription.mockResolvedValue({}) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '0' }, + }) + + const result = await handler(event) + + expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1') + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-c2', + { $set: expect.objectContaining({ + contributionTier: '0', + helcimSubscriptionId: null, + paymentMethod: 'none', + billingCadence: 'monthly', + }) }, + { runValidators: false } + ) + expect(result.success).toBe(true) + expect(result.message).toBe('Successfully downgraded to free tier') + }) +})