From e8c81cf062b42a95347e286661321a5131e3d2fe Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:32:22 +0100 Subject: [PATCH] feat(contribution): paid-to-paid tier swap via recurringAmount PATCH --- .../api/members/update-contribution.post.js | 35 ++- tests/server/api/update-contribution.test.js | 207 ++++++++++++++++++ 2 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 tests/server/api/update-contribution.test.js diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js index fdd0bec..57f7fdc 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -3,6 +3,7 @@ import { getHelcimPlanId, requiresPayment, getContributionTierByValue, + getTierAmount, } from "../../config/contributions.js"; import { connectDB } from "../../utils/mongoose.js"; import Member from "../../models/member.js"; @@ -158,51 +159,49 @@ export default defineEventHandler(async (event) => { // Case 3: Moving between paid tiers if (oldRequiresPayment && newRequiresPayment) { - const newPlanId = getHelcimPlanId(newTier); - - if (!newPlanId) { + if (!member.helcimSubscriptionId) { throw createError({ statusCode: 400, - statusMessage: `Plan not configured for tier ${newTier}`, + statusMessage: "Payment information required. You'll be redirected to complete payment setup.", + data: { requiresPaymentSetup: true }, }); } - if (!member.helcimSubscriptionId) { - // No subscription exists - they need to go through payment flow + const memberCadence = member.billingCadence || 'monthly'; + if (body.cadence && body.cadence !== memberCadence) { throw createError({ statusCode: 400, - statusMessage: - "Payment information required. You'll be redirected to complete payment setup.", - data: { requiresPaymentSetup: true }, + 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, - { paymentPlanId: parseInt(newPlanId) }, + { recurringAmount: getTierAmount(newTierInfo, memberCadence) } ); - // Update member record await Member.findByIdAndUpdate( member._id, { $set: { contributionTier: newTier } }, { runValidators: false } ); - logContributionChange() + logContributionChange(); return { success: true, - message: "Successfully updated contribution level", + message: 'Successfully updated contribution level', subscription: subscriptionData, }; } catch (updateError) { - console.error("Error updating Helcim subscription:", updateError); - throw createError({ - statusCode: 500, - statusMessage: "Subscription update failed", - }); + console.error('Error updating Helcim subscription:', updateError); + throw createError({ statusCode: 500, statusMessage: 'Subscription update failed' }); } } diff --git a/tests/server/api/update-contribution.test.js b/tests/server/api/update-contribution.test.js new file mode 100644 index 0000000..f1a4a45 --- /dev/null +++ b/tests/server/api/update-contribution.test.js @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import Member from '../../../server/models/member.js' +import { + requiresPayment, + getContributionTierByValue, + getTierAmount, +} from '../../../server/config/contributions.js' +import { updateHelcimSubscription } from '../../../server/utils/helcim.js' +import handler from '../../../server/api/members/update-contribution.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +vi.mock('../../../server/models/member.js', () => ({ + default: { findByIdAndUpdate: vi.fn() } +})) +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() })) +vi.mock('../../../server/config/contributions.js', () => ({ + requiresPayment: vi.fn(), + getHelcimPlanId: vi.fn(), + getContributionTierByValue: vi.fn(), + getTierAmount: vi.fn(), +})) +vi.mock('../../../server/utils/helcim.js', () => ({ + getHelcimCustomer: vi.fn(), + listHelcimCustomerCards: vi.fn(), + createHelcimSubscription: vi.fn(), + updateHelcimSubscription: vi.fn(), + cancelHelcimSubscription: vi.fn(), + generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-123'), +})) + +// Nitro auto-imports +vi.stubGlobal('updateContributionSchema', {}) + +describe('update-contribution endpoint — Case 3 (paid→paid)', () => { + beforeEach(() => { + vi.clearAllMocks() + // Both tiers require payment for all Case 3 tests + requiresPayment.mockReturnValue(true) + }) + + // Helper: set requireAuth global to resolve with the given member + function setMember(mockMember) { + globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember) + } + + it('monthly $5 → $15: calls updateHelcimSubscription with recurringAmount and updates member', async () => { + const mockMember = { + _id: 'member-1', + contributionTier: '5', + helcimSubscriptionId: 'sub-1', + billingCadence: 'monthly', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(15) + updateHelcimSubscription.mockResolvedValue({ id: 'sub-1', status: 'active' }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15' }, + }) + + const result = await handler(event) + + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-1', { recurringAmount: 15 }) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-1', + { $set: { contributionTier: '15' } }, + { runValidators: false } + ) + expect(result.success).toBe(true) + expect(result.message).toBe('Successfully updated contribution level') + }) + + it('annual $5 → $15: calls updateHelcimSubscription with recurringAmount 150', async () => { + const mockMember = { + _id: 'member-2', + contributionTier: '5', + helcimSubscriptionId: 'sub-2', + billingCadence: 'annual', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(150) + updateHelcimSubscription.mockResolvedValue({ id: 'sub-2', status: 'active' }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'annual' }, + }) + + const result = await handler(event) + + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 150 }) + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-2', + { $set: { contributionTier: '15' } }, + { runValidators: false } + ) + expect(result.success).toBe(true) + }) + + it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 500', async () => { + const mockMember = { + _id: 'member-3', + contributionTier: '15', + helcimSubscriptionId: 'sub-3', + billingCadence: 'annual', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '50', amount: '50' }) + getTierAmount.mockReturnValue(500) + updateHelcimSubscription.mockResolvedValue({ id: 'sub-3', status: 'active' }) + Member.findByIdAndUpdate.mockResolvedValue({}) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '50', cadence: 'annual' }, + }) + + await handler(event) + + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-3', { recurringAmount: 500 }) + }) + + it('cadence mismatch: monthly member + body cadence annual → 400, no Helcim call, no DB write', async () => { + const mockMember = { + _id: 'member-4', + contributionTier: '5', + helcimSubscriptionId: 'sub-4', + billingCadence: 'monthly', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15', cadence: 'annual' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 400, + statusMessage: 'Cadence switch not supported on existing subscription', + }) + + expect(updateHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('Helcim PATCH failure → 500, member NOT updated', async () => { + const mockMember = { + _id: 'member-5', + contributionTier: '5', + helcimSubscriptionId: 'sub-5', + billingCadence: 'monthly', + } + setMember(mockMember) + getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' }) + getTierAmount.mockReturnValue(15) + updateHelcimSubscription.mockRejectedValue(new Error('Helcim 400')) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Subscription update failed', + }) + + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('no helcimSubscriptionId → 400 with requiresPaymentSetup, no Helcim call', async () => { + const mockMember = { + _id: 'member-6', + contributionTier: '5', + helcimSubscriptionId: null, + billingCadence: 'monthly', + } + setMember(mockMember) + + const event = createMockEvent({ + method: 'POST', + path: '/api/members/update-contribution', + body: { contributionTier: '15' }, + }) + + await expect(handler(event)).rejects.toMatchObject({ + statusCode: 400, + data: { requiresPaymentSetup: true }, + }) + + expect(updateHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) +})