feat(members): use contributionAmount in update-contribution route, inline ×12

This commit is contained in:
Jennie Robinson Faber 2026-04-19 18:38:14 +01:00
parent 613d077eaa
commit 7a2acd4628
2 changed files with 47 additions and 74 deletions

View file

@ -2,8 +2,6 @@
import { import {
getHelcimPlanId, getHelcimPlanId,
requiresPayment, requiresPayment,
getContributionTierByValue,
getTierAmount,
} from "../../config/contributions.js"; } from "../../config/contributions.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import Member from "../../models/member.js"; import Member from "../../models/member.js";
@ -22,24 +20,24 @@ export default defineEventHandler(async (event) => {
await connectDB(); await connectDB();
const body = await validateBody(event, updateContributionSchema); const body = await validateBody(event, updateContributionSchema);
const oldTier = member.contributionTier; const oldAmount = member.contributionAmount;
const newTier = body.contributionTier; const newAmount = body.contributionAmount;
// If same tier, nothing to do // If same amount, nothing to do
if (oldTier === newTier) { if (oldAmount === newAmount) {
return { return {
success: true, 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) // Log contribution change (fire-and-forget, at the top so it logs regardless of which case path executes)
const logContributionChange = () => { 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 oldRequiresPayment = requiresPayment(oldAmount);
const newRequiresPayment = requiresPayment(newTier); const newRequiresPayment = requiresPayment(newAmount);
// Case 1: Moving from free to paid tier // Case 1: Moving from free to paid tier
if (!oldRequiresPayment && newRequiresPayment) { if (!oldRequiresPayment && newRequiresPayment) {
@ -65,8 +63,6 @@ export default defineEventHandler(async (event) => {
}); });
} }
const tierInfo = getContributionTierByValue(newTier);
try { try {
const customerData = await getHelcimCustomer(member.helcimCustomerId); const customerData = await getHelcimCustomer(member.helcimCustomerId);
const customerCode = customerData.customerCode; const customerCode = customerData.customerCode;
@ -90,7 +86,7 @@ export default defineEventHandler(async (event) => {
dateActivated: new Date().toISOString().split("T")[0], dateActivated: new Date().toISOString().split("T")[0],
paymentPlanId: parseInt(paymentPlanId), paymentPlanId: parseInt(paymentPlanId),
customerCode, customerCode,
recurringAmount: getTierAmount(tierInfo, cadence), recurringAmount: cadence === 'annual' ? newAmount * 12 : newAmount,
paymentMethod: "card", paymentMethod: "card",
}, },
idempotencyKey, idempotencyKey,
@ -106,7 +102,7 @@ export default defineEventHandler(async (event) => {
await Member.findByIdAndUpdate( await Member.findByIdAndUpdate(
member._id, member._id,
{ $set: { { $set: {
contributionTier: newTier, contributionAmount: newAmount,
helcimSubscriptionId: subscription.id, helcimSubscriptionId: subscription.id,
paymentMethod: "card", paymentMethod: "card",
status: "active", status: "active",
@ -152,7 +148,7 @@ export default defineEventHandler(async (event) => {
// Update member to free tier // Update member to free tier
await Member.findByIdAndUpdate( await Member.findByIdAndUpdate(
member._id, member._id,
{ $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } }, { $set: { contributionAmount: newAmount, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } },
{ runValidators: false } { 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 { try {
const subscriptionData = await updateHelcimSubscription( const subscriptionData = await updateHelcimSubscription(
member.helcimSubscriptionId, member.helcimSubscriptionId,
{ recurringAmount: getTierAmount(newTierInfo, memberCadence) } { recurringAmount: memberCadence === 'annual' ? newAmount * 12 : newAmount }
); );
await Member.findByIdAndUpdate( await Member.findByIdAndUpdate(
member._id, member._id,
{ $set: { contributionTier: newTier } }, { $set: { contributionAmount: newAmount } },
{ runValidators: false } { runValidators: false }
); );
@ -215,7 +206,7 @@ export default defineEventHandler(async (event) => {
// Case 4: Moving between free tiers (shouldn't happen but handle it) // Case 4: Moving between free tiers (shouldn't happen but handle it)
await Member.findByIdAndUpdate( await Member.findByIdAndUpdate(
member._id, member._id,
{ $set: { contributionTier: newTier } }, { $set: { contributionAmount: newAmount } },
{ runValidators: false } { runValidators: false }
); );

View file

@ -4,8 +4,6 @@ import Member from '../../../server/models/member.js'
import { import {
requiresPayment, requiresPayment,
getHelcimPlanId, getHelcimPlanId,
getContributionTierByValue,
getTierAmount,
} from '../../../server/config/contributions.js' } from '../../../server/config/contributions.js'
import { import {
updateHelcimSubscription, updateHelcimSubscription,
@ -25,8 +23,6 @@ vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
vi.mock('../../../server/config/contributions.js', () => ({ vi.mock('../../../server/config/contributions.js', () => ({
requiresPayment: vi.fn(), requiresPayment: vi.fn(),
getHelcimPlanId: vi.fn(), getHelcimPlanId: vi.fn(),
getContributionTierByValue: vi.fn(),
getTierAmount: vi.fn(),
})) }))
vi.mock('../../../server/utils/helcim.js', () => ({ vi.mock('../../../server/utils/helcim.js', () => ({
getHelcimCustomer: vi.fn(), 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 () => { it('monthly $5 → $15: calls updateHelcimSubscription with recurringAmount and updates member', async () => {
const mockMember = { const mockMember = {
_id: 'member-1', _id: 'member-1',
contributionTier: '5', contributionAmount: 5,
helcimSubscriptionId: 'sub-1', helcimSubscriptionId: 'sub-1',
billingCadence: 'monthly', billingCadence: 'monthly',
} }
setMember(mockMember) setMember(mockMember)
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
getTierAmount.mockReturnValue(15)
updateHelcimSubscription.mockResolvedValue({ id: 'sub-1', status: 'active' }) updateHelcimSubscription.mockResolvedValue({ id: 'sub-1', status: 'active' })
Member.findByIdAndUpdate.mockResolvedValue({}) Member.findByIdAndUpdate.mockResolvedValue({})
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15' }, body: { contributionAmount: 15 },
}) })
const result = await handler(event) 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(updateHelcimSubscription).toHaveBeenCalledWith('sub-1', { recurringAmount: 15 })
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-1', 'member-1',
{ $set: { contributionTier: '15' } }, { $set: { contributionAmount: 15 } },
{ runValidators: false } { runValidators: false }
) )
expect(result.success).toBe(true) expect(result.success).toBe(true)
expect(result.message).toBe('Successfully updated contribution level') 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 = { const mockMember = {
_id: 'member-2', _id: 'member-2',
contributionTier: '5', contributionAmount: 5,
helcimSubscriptionId: 'sub-2', helcimSubscriptionId: 'sub-2',
billingCadence: 'annual', billingCadence: 'annual',
} }
setMember(mockMember) setMember(mockMember)
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
getTierAmount.mockReturnValue(150)
updateHelcimSubscription.mockResolvedValue({ id: 'sub-2', status: 'active' }) updateHelcimSubscription.mockResolvedValue({ id: 'sub-2', status: 'active' })
Member.findByIdAndUpdate.mockResolvedValue({}) Member.findByIdAndUpdate.mockResolvedValue({})
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15', cadence: 'annual' }, body: { contributionAmount: 15, cadence: 'annual' },
}) })
const result = await handler(event) const result = await handler(event)
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 150 }) expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 180 })
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-2', 'member-2',
{ $set: { contributionTier: '15' } }, { $set: { contributionAmount: 15 } },
{ runValidators: false } { runValidators: false }
) )
expect(result.success).toBe(true) 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 = { const mockMember = {
_id: 'member-3', _id: 'member-3',
contributionTier: '15', contributionAmount: 15,
helcimSubscriptionId: 'sub-3', helcimSubscriptionId: 'sub-3',
billingCadence: 'annual', billingCadence: 'annual',
} }
setMember(mockMember) setMember(mockMember)
getContributionTierByValue.mockReturnValue({ value: '50', amount: '50' })
getTierAmount.mockReturnValue(500)
updateHelcimSubscription.mockResolvedValue({ id: 'sub-3', status: 'active' }) updateHelcimSubscription.mockResolvedValue({ id: 'sub-3', status: 'active' })
Member.findByIdAndUpdate.mockResolvedValue({}) Member.findByIdAndUpdate.mockResolvedValue({})
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '50', cadence: 'annual' }, body: { contributionAmount: 50, cadence: 'annual' },
}) })
await handler(event) 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 () => { it('cadence mismatch: monthly member + body cadence annual → 400, no Helcim call, no DB write', async () => {
const mockMember = { const mockMember = {
_id: 'member-4', _id: 'member-4',
contributionTier: '5', contributionAmount: 5,
helcimSubscriptionId: 'sub-4', helcimSubscriptionId: 'sub-4',
billingCadence: 'monthly', billingCadence: 'monthly',
} }
setMember(mockMember) setMember(mockMember)
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15', cadence: 'annual' }, body: { contributionAmount: 15, cadence: 'annual' },
}) })
await expect(handler(event)).rejects.toMatchObject({ 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 () => { it('Helcim PATCH failure → 500, member NOT updated', async () => {
const mockMember = { const mockMember = {
_id: 'member-5', _id: 'member-5',
contributionTier: '5', contributionAmount: 5,
helcimSubscriptionId: 'sub-5', helcimSubscriptionId: 'sub-5',
billingCadence: 'monthly', billingCadence: 'monthly',
} }
setMember(mockMember) setMember(mockMember)
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
getTierAmount.mockReturnValue(15)
updateHelcimSubscription.mockRejectedValue(new Error('Helcim 400')) updateHelcimSubscription.mockRejectedValue(new Error('Helcim 400'))
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15' }, body: { contributionAmount: 15 },
}) })
await expect(handler(event)).rejects.toMatchObject({ 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 () => { it('no helcimSubscriptionId → 400 with requiresPaymentSetup, no Helcim call', async () => {
const mockMember = { const mockMember = {
_id: 'member-6', _id: 'member-6',
contributionTier: '5', contributionAmount: 5,
helcimSubscriptionId: null, helcimSubscriptionId: null,
billingCadence: 'monthly', billingCadence: 'monthly',
} }
@ -200,7 +187,7 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15' }, body: { contributionAmount: 15 },
}) })
await expect(handler(event)).rejects.toMatchObject({ 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)', () => { describe('update-contribution endpoint — Case 1 (free→paid)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// old tier = free, new tier = paid // old amount = 0 (free), new amount > 0 (paid)
requiresPayment.mockImplementation((tier) => tier !== '0') requiresPayment.mockImplementation((amount) => amount !== 0)
}) })
function setMember(mockMember) { function setMember(mockMember) {
@ -226,15 +213,13 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => {
const freeMember = { const freeMember = {
_id: 'member-c1', _id: 'member-c1',
contributionTier: '0', contributionAmount: 0,
helcimCustomerId: 'cust-1', helcimCustomerId: 'cust-1',
} }
it('monthly: calls createHelcimSubscription with monthly plan id and recurringAmount 15, persists billingCadence monthly', async () => { it('monthly: calls createHelcimSubscription with monthly plan id and recurringAmount 15, persists billingCadence monthly', async () => {
setMember(freeMember) setMember(freeMember)
getHelcimPlanId.mockReturnValue('111') getHelcimPlanId.mockReturnValue('111')
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
getTierAmount.mockReturnValue(15)
getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' })
listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }])
createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-new', status: 'active', nextBillingDate: '2026-05-18' }] }) 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({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15', cadence: 'monthly' }, body: { contributionAmount: 15, cadence: 'monthly' },
}) })
const result = await handler(event) const result = await handler(event)
@ -254,18 +239,16 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => {
) )
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-c1', 'member-c1',
{ $set: expect.objectContaining({ billingCadence: 'monthly', contributionTier: '15', helcimSubscriptionId: 'sub-new' }) }, { $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, helcimSubscriptionId: 'sub-new' }) },
{ runValidators: false } { runValidators: false }
) )
expect(result.success).toBe(true) expect(result.success).toBe(true)
expect(result.message).toBe('Successfully upgraded to paid tier') 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) setMember(freeMember)
getHelcimPlanId.mockReturnValue('222') getHelcimPlanId.mockReturnValue('222')
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
getTierAmount.mockReturnValue(150)
getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' }) getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' })
listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }]) listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }])
createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual', status: 'active', nextBillingDate: '2027-04-18' }] }) 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({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15', cadence: 'annual' }, body: { contributionAmount: 15, cadence: 'annual' },
}) })
const result = await handler(event) const result = await handler(event)
expect(createHelcimSubscription).toHaveBeenCalledWith( expect(createHelcimSubscription).toHaveBeenCalledWith(
expect.objectContaining({ paymentPlanId: 222, recurringAmount: 150 }), expect.objectContaining({ paymentPlanId: 222, recurringAmount: 180 }),
'idem-key-123' 'idem-key-123'
) )
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-c1', 'member-c1',
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionTier: '15', helcimSubscriptionId: 'sub-annual' }) }, { $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, helcimSubscriptionId: 'sub-annual' }) },
{ runValidators: false } { runValidators: false }
) )
expect(result.success).toBe(true) 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 () => { it('missing plan id env → 500, createHelcimSubscription NOT called, member NOT updated', async () => {
setMember(freeMember) setMember(freeMember)
getHelcimPlanId.mockReturnValue(null) getHelcimPlanId.mockReturnValue(null)
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '15', cadence: 'monthly' }, body: { contributionAmount: 15, cadence: 'monthly' },
}) })
await expect(handler(event)).rejects.toMatchObject({ 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)', () => { describe('update-contribution endpoint — Case 2 (paid→free)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// old tier = paid, new tier = free // old amount > 0 (paid), new amount = 0 (free)
requiresPayment.mockImplementation((tier) => tier !== '0') requiresPayment.mockImplementation((amount) => amount !== 0)
}) })
function setMember(mockMember) { 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 () => { it('cancels subscription, resets billingCadence to monthly, clears helcimSubscriptionId', async () => {
const mockMember = { const mockMember = {
_id: 'member-c2', _id: 'member-c2',
contributionTier: '15', contributionAmount: 15,
helcimSubscriptionId: 'sub-1', helcimSubscriptionId: 'sub-1',
billingCadence: 'monthly', billingCadence: 'monthly',
} }
@ -337,7 +319,7 @@ describe('update-contribution endpoint — Case 2 (paid→free)', () => {
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/members/update-contribution', path: '/api/members/update-contribution',
body: { contributionTier: '0' }, body: { contributionAmount: 0 },
}) })
const result = await handler(event) const result = await handler(event)
@ -346,7 +328,7 @@ describe('update-contribution endpoint — Case 2 (paid→free)', () => {
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-c2', 'member-c2',
{ $set: expect.objectContaining({ { $set: expect.objectContaining({
contributionTier: '0', contributionAmount: 0,
helcimSubscriptionId: null, helcimSubscriptionId: null,
paymentMethod: 'none', paymentMethod: 'none',
billingCadence: 'monthly', billingCadence: 'monthly',