From 7a2acd4628d3b13869b510c7242a568addc13940 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 18:38:14 +0100 Subject: [PATCH] =?UTF-8?q?feat(members):=20use=20contributionAmount=20in?= =?UTF-8?q?=20update-contribution=20route,=20inline=20=C3=9712?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/members/update-contribution.post.js | 37 ++++---- tests/server/api/update-contribution.test.js | 84 ++++++++----------- 2 files changed, 47 insertions(+), 74 deletions(-) diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js index fd236f8..26f257f 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -2,8 +2,6 @@ import { getHelcimPlanId, requiresPayment, - getContributionTierByValue, - getTierAmount, } from "../../config/contributions.js"; import { connectDB } from "../../utils/mongoose.js"; import Member from "../../models/member.js"; @@ -22,24 +20,24 @@ export default defineEventHandler(async (event) => { await connectDB(); const body = await validateBody(event, updateContributionSchema); - const oldTier = member.contributionTier; - const newTier = body.contributionTier; + const oldAmount = member.contributionAmount; + const newAmount = body.contributionAmount; - // If same tier, nothing to do - if (oldTier === newTier) { + // If same amount, nothing to do + if (oldAmount === newAmount) { return { success: true, - message: "Already on this tier", + 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: oldTier, to: newTier }) + logActivity(member._id, 'contribution_changed', { from: oldAmount, to: newAmount }) } - const oldRequiresPayment = requiresPayment(oldTier); - const newRequiresPayment = requiresPayment(newTier); + const oldRequiresPayment = requiresPayment(oldAmount); + const newRequiresPayment = requiresPayment(newAmount); // Case 1: Moving from free to paid tier if (!oldRequiresPayment && newRequiresPayment) { @@ -65,8 +63,6 @@ export default defineEventHandler(async (event) => { }); } - const tierInfo = getContributionTierByValue(newTier); - try { const customerData = await getHelcimCustomer(member.helcimCustomerId); const customerCode = customerData.customerCode; @@ -90,7 +86,7 @@ export default defineEventHandler(async (event) => { dateActivated: new Date().toISOString().split("T")[0], paymentPlanId: parseInt(paymentPlanId), customerCode, - recurringAmount: getTierAmount(tierInfo, cadence), + recurringAmount: cadence === 'annual' ? newAmount * 12 : newAmount, paymentMethod: "card", }, idempotencyKey, @@ -106,7 +102,7 @@ export default defineEventHandler(async (event) => { await Member.findByIdAndUpdate( member._id, { $set: { - contributionTier: newTier, + contributionAmount: newAmount, helcimSubscriptionId: subscription.id, paymentMethod: "card", status: "active", @@ -152,7 +148,7 @@ export default defineEventHandler(async (event) => { // Update member to free tier await Member.findByIdAndUpdate( member._id, - { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } }, + { $set: { contributionAmount: newAmount, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } }, { runValidators: false } ); @@ -182,20 +178,15 @@ export default defineEventHandler(async (event) => { }); } - const newTierInfo = getContributionTierByValue(newTier); - if (!newTierInfo) { - throw createError({ statusCode: 400, statusMessage: 'Invalid tier' }); - } - try { const subscriptionData = await updateHelcimSubscription( member.helcimSubscriptionId, - { recurringAmount: getTierAmount(newTierInfo, memberCadence) } + { recurringAmount: memberCadence === 'annual' ? newAmount * 12 : newAmount } ); await Member.findByIdAndUpdate( member._id, - { $set: { contributionTier: newTier } }, + { $set: { contributionAmount: newAmount } }, { runValidators: false } ); @@ -215,7 +206,7 @@ export default defineEventHandler(async (event) => { // Case 4: Moving between free tiers (shouldn't happen but handle it) await Member.findByIdAndUpdate( member._id, - { $set: { contributionTier: newTier } }, + { $set: { contributionAmount: newAmount } }, { runValidators: false } ); diff --git a/tests/server/api/update-contribution.test.js b/tests/server/api/update-contribution.test.js index 38e18c1..797c29b 100644 --- a/tests/server/api/update-contribution.test.js +++ b/tests/server/api/update-contribution.test.js @@ -4,8 +4,6 @@ import Member from '../../../server/models/member.js' import { requiresPayment, getHelcimPlanId, - getContributionTierByValue, - getTierAmount, } from '../../../server/config/contributions.js' import { updateHelcimSubscription, @@ -25,8 +23,6 @@ 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(), @@ -55,20 +51,18 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { it('monthly $5 → $15: calls updateHelcimSubscription with recurringAmount and updates member', async () => { const mockMember = { _id: 'member-1', - contributionTier: '5', + contributionAmount: 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' }, + body: { contributionAmount: 15 }, }) const result = await handler(event) @@ -76,81 +70,76 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-1', { recurringAmount: 15 }) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-1', - { $set: { contributionTier: '15' } }, + { $set: { contributionAmount: 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 () => { + it('annual $5 → $15: calls updateHelcimSubscription with recurringAmount 180', async () => { const mockMember = { _id: 'member-2', - contributionTier: '5', + contributionAmount: 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' }, + body: { contributionAmount: 15, cadence: 'annual' }, }) const result = await handler(event) - expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 150 }) + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 180 }) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-2', - { $set: { contributionTier: '15' } }, + { $set: { contributionAmount: 15 } }, { runValidators: false } ) expect(result.success).toBe(true) }) - it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 500', async () => { + it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 600', async () => { const mockMember = { _id: 'member-3', - contributionTier: '15', + contributionAmount: 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' }, + body: { contributionAmount: 50, cadence: 'annual' }, }) await handler(event) - expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-3', { recurringAmount: 500 }) + expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-3', { recurringAmount: 600 }) }) it('cadence mismatch: monthly member + body cadence annual → 400, no Helcim call, no DB write', async () => { const mockMember = { _id: 'member-4', - contributionTier: '5', + contributionAmount: 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' }, + body: { contributionAmount: 15, cadence: 'annual' }, }) await expect(handler(event)).rejects.toMatchObject({ @@ -165,19 +154,17 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { it('Helcim PATCH failure → 500, member NOT updated', async () => { const mockMember = { _id: 'member-5', - contributionTier: '5', + contributionAmount: 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' }, + body: { contributionAmount: 15 }, }) await expect(handler(event)).rejects.toMatchObject({ @@ -191,7 +178,7 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { it('no helcimSubscriptionId → 400 with requiresPaymentSetup, no Helcim call', async () => { const mockMember = { _id: 'member-6', - contributionTier: '5', + contributionAmount: 5, helcimSubscriptionId: null, billingCadence: 'monthly', } @@ -200,7 +187,7 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', - body: { contributionTier: '15' }, + body: { contributionAmount: 15 }, }) await expect(handler(event)).rejects.toMatchObject({ @@ -216,8 +203,8 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => { describe('update-contribution endpoint — Case 1 (free→paid)', () => { beforeEach(() => { vi.clearAllMocks() - // old tier = free, new tier = paid - requiresPayment.mockImplementation((tier) => tier !== '0') + // old amount = 0 (free), new amount > 0 (paid) + requiresPayment.mockImplementation((amount) => amount !== 0) }) function setMember(mockMember) { @@ -226,15 +213,13 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => { const freeMember = { _id: 'member-c1', - contributionTier: '0', + contributionAmount: 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' }] }) @@ -243,7 +228,7 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => { const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', - body: { contributionTier: '15', cadence: 'monthly' }, + body: { contributionAmount: 15, cadence: 'monthly' }, }) const result = await handler(event) @@ -254,18 +239,16 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => { ) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-c1', - { $set: expect.objectContaining({ billingCadence: 'monthly', contributionTier: '15', helcimSubscriptionId: 'sub-new' }) }, + { $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 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 () => { + it('annual: calls createHelcimSubscription with annual plan id and recurringAmount 180, 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' }] }) @@ -274,18 +257,18 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => { const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', - body: { contributionTier: '15', cadence: 'annual' }, + body: { contributionAmount: 15, cadence: 'annual' }, }) const result = await handler(event) expect(createHelcimSubscription).toHaveBeenCalledWith( - expect.objectContaining({ paymentPlanId: 222, recurringAmount: 150 }), + expect.objectContaining({ paymentPlanId: 222, recurringAmount: 180 }), 'idem-key-123' ) expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-c1', - { $set: expect.objectContaining({ billingCadence: 'annual', contributionTier: '15', helcimSubscriptionId: 'sub-annual' }) }, + { $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, helcimSubscriptionId: 'sub-annual' }) }, { runValidators: false } ) expect(result.success).toBe(true) @@ -294,12 +277,11 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => { 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' }, + body: { contributionAmount: 15, cadence: 'monthly' }, }) await expect(handler(event)).rejects.toMatchObject({ @@ -315,8 +297,8 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => { describe('update-contribution endpoint — Case 2 (paid→free)', () => { beforeEach(() => { vi.clearAllMocks() - // old tier = paid, new tier = free - requiresPayment.mockImplementation((tier) => tier !== '0') + // old amount > 0 (paid), new amount = 0 (free) + requiresPayment.mockImplementation((amount) => amount !== 0) }) function setMember(mockMember) { @@ -326,7 +308,7 @@ describe('update-contribution endpoint — Case 2 (paid→free)', () => { it('cancels subscription, resets billingCadence to monthly, clears helcimSubscriptionId', async () => { const mockMember = { _id: 'member-c2', - contributionTier: '15', + contributionAmount: 15, helcimSubscriptionId: 'sub-1', billingCadence: 'monthly', } @@ -337,7 +319,7 @@ describe('update-contribution endpoint — Case 2 (paid→free)', () => { const event = createMockEvent({ method: 'POST', path: '/api/members/update-contribution', - body: { contributionTier: '0' }, + body: { contributionAmount: 0 }, }) const result = await handler(event) @@ -346,7 +328,7 @@ describe('update-contribution endpoint — Case 2 (paid→free)', () => { expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( 'member-c2', { $set: expect.objectContaining({ - contributionTier: '0', + contributionAmount: 0, helcimSubscriptionId: null, paymentMethod: 'none', billingCadence: 'monthly',