feat(contribution): free-to-paid uses cadence plan id, persists billingCadence

This commit is contained in:
Jennie Robinson Faber 2026-04-18 17:37:35 +01:00
parent e8c81cf062
commit 0eeed94772
2 changed files with 180 additions and 21 deletions

View file

@ -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')
})
})