From 8d43804c7f04a824fb4282525afe9edd43bab44a Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:25:14 +0100 Subject: [PATCH] feat(helcim): create subscription by cadence with recurringAmount Replace tier-based plan lookup with cadence-keyed lookup, compute recurringAmount via getTierAmount, persist billingCadence on member. Delete both manual-fallback blocks; Helcim failure now surfaces as 500. --- server/api/helcim/subscription.post.js | 173 +++++----------- tests/server/api/helcim-subscription.test.js | 198 ++++++++++++++++--- 2 files changed, 218 insertions(+), 153 deletions(-) diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index 76f9faf..5de619d 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -1,5 +1,5 @@ // Create a Helcim subscription -import { getHelcimPlanId, requiresPayment, getContributionTierByValue } from '../../config/contributions.js' +import { getHelcimPlanId, requiresPayment, getContributionTierByValue, getTierAmount } from '../../config/contributions.js' import Member from '../../models/member.js' import { connectDB } from '../../utils/mongoose.js' import { getSlackService } from '../../utils/slack.ts' @@ -124,9 +124,6 @@ export default defineEventHandler(async (event) => { } } - // Get the Helcim plan ID - const planId = getHelcimPlanId(body.contributionTier) - // Validate card token is provided if (!body.cardToken) { throw createError({ @@ -135,142 +132,70 @@ export default defineEventHandler(async (event) => { }) } - // Check if we have a configured plan for this tier - if (!planId) { - const member = await Member.findOneAndUpdate( - { helcimCustomerId: body.customerId }, - { - status: 'active', - contributionTier: body.contributionTier, - subscriptionStartDate: new Date(), - paymentMethod: 'card', - cardToken: body.cardToken, - notes: `Payment successful but no Helcim plan configured for tier ${body.contributionTier}` - }, - { new: true } - ) + const tierInfo = getContributionTierByValue(body.contributionTier) + const cadence = body.cadence + const paymentPlanId = getHelcimPlanId(cadence) - await inviteToSlack(member) - if (isFirstActivation) await sendWelcomeEmail(member) - - return { - success: true, - subscription: { - subscriptionId: 'manual-' + Date.now(), - status: 'needs_plan_setup', - nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }, - member: { - id: member._id, - email: member.email, - name: member.name, - circle: member.circle, - contributionTier: member.contributionTier, - status: member.status - }, - warning: `Payment successful but recurring plan needs to be set up in Helcim for the ${body.contributionTier} tier` - } + if (!paymentPlanId) { + throw createError({ + statusCode: 500, + statusMessage: cadence === 'annual' + ? 'Annual plan id not configured' + : 'Monthly plan id not configured', + }) } - // Try to create subscription in Helcim const idempotencyKey = generateIdempotencyKey() - // Get contribution tier details to set recurring amount - const tierInfo = getContributionTierByValue(body.contributionTier) - const subscriptionPayload = { - dateActivated: new Date().toISOString().split('T')[0], // Today in YYYY-MM-DD format - paymentPlanId: parseInt(planId), + dateActivated: new Date().toISOString().split('T')[0], + paymentPlanId: parseInt(paymentPlanId), customerCode: body.customerCode, - recurringAmount: parseFloat(tierInfo.amount), - paymentMethod: 'card' + recurringAmount: getTierAmount(tierInfo, cadence), + paymentMethod: 'card', } - try { - const subscriptionData = await createHelcimSubscription(subscriptionPayload, idempotencyKey) + const subscriptionData = await createHelcimSubscription(subscriptionPayload, idempotencyKey) - // Extract the first subscription from the response array - const subscription = subscriptionData.data?.[0] - if (!subscription) { - throw new Error('No subscription returned in response') - } + // Extract the first subscription from the response array + const subscription = subscriptionData.data?.[0] + if (!subscription) { + throw createError({ statusCode: 500, statusMessage: 'Subscription creation failed' }) + } - // Update member in database - const member = await Member.findOneAndUpdate( - { helcimCustomerId: body.customerId }, - { - status: 'active', + // Update member in database + const member = await Member.findOneAndUpdate( + { helcimCustomerId: body.customerId }, + { $set: { contributionTier: body.contributionTier, helcimSubscriptionId: subscription.id, - subscriptionStartDate: new Date(), - paymentMethod: 'card' - }, - { new: true } - ) - - logActivity(member._id, 'subscription_created', { tier: body.contributionTier }) - - await inviteToSlack(member) - if (isFirstActivation) await sendWelcomeEmail(member) - - return { - success: true, - subscription: { - subscriptionId: subscription.id, - status: subscription.status, - nextBillingDate: subscription.nextBillingDate - }, - member: { - id: member._id, - email: member.email, - name: member.name, - circle: member.circle, - contributionTier: member.contributionTier, - status: member.status - } - } - } catch (helcimError) { - // The helper throws createError on non-OK responses (statusCode = upstream HTTP status) - // and lets network errors propagate. We treat 400/404 from upstream AND any network - // error as the "manual setup needed" fallback. Re-throw other upstream errors (e.g. 5xx). - if (helcimError.statusCode && helcimError.statusCode !== 400 && helcimError.statusCode !== 404) { - throw helcimError - } - console.error('Error during subscription creation:', helcimError) - - // Still mark member as active since payment was successful - const member = await Member.findOneAndUpdate( - { helcimCustomerId: body.customerId }, - { - status: 'active', - contributionTier: body.contributionTier, - subscriptionStartDate: new Date(), + helcimCustomerId: body.customerId, paymentMethod: 'card', - cardToken: body.cardToken, - notes: `Payment successful but subscription creation failed: ${helcimError.message || 'unknown error'}` - }, - { new: true } - ) + billingCadence: cadence, + status: 'active', + } }, + { new: true, runValidators: false } + ) - await inviteToSlack(member) - if (isFirstActivation) await sendWelcomeEmail(member) + logActivity(member._id, 'subscription_created', { tier: body.contributionTier }) - return { - success: true, - subscription: { - subscriptionId: 'manual-' + Date.now(), - status: 'needs_setup', - nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }, - member: { - id: member._id, - email: member.email, - name: member.name, - circle: member.circle, - contributionTier: member.contributionTier, - status: member.status - }, - warning: 'Payment successful but recurring subscription needs manual setup' + await inviteToSlack(member) + if (isFirstActivation) await sendWelcomeEmail(member) + + return { + success: true, + subscription: { + subscriptionId: subscription.id, + status: subscription.status, + nextBillingDate: subscription.nextBillingDate + }, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status } } } catch (error) { diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js index ee4577c..c41b881 100644 --- a/tests/server/api/helcim-subscription.test.js +++ b/tests/server/api/helcim-subscription.test.js @@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import Member from '../../../server/models/member.js' import { requireAuth } from '../../../server/utils/auth.js' -import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js' +import { requiresPayment, getHelcimPlanId, getContributionTierByValue, getTierAmount } from '../../../server/config/contributions.js' +import { createHelcimSubscription } from '../../../server/utils/helcim.js' import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -17,25 +18,25 @@ vi.mock('../../../server/utils/slack.ts', () => ({ vi.mock('../../../server/config/contributions.js', () => ({ requiresPayment: vi.fn(), getHelcimPlanId: vi.fn(), - getContributionTierByValue: vi.fn() + getContributionTierByValue: vi.fn(), + getTierAmount: vi.fn(), })) vi.mock('../../../server/utils/resend.js', () => ({ sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('../../../server/utils/helcim.js', () => ({ + createHelcimSubscription: vi.fn(), + generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-123'), +})) // helcimSubscriptionSchema is a Nitro auto-import used by validateBody vi.stubGlobal('helcimSubscriptionSchema', {}) describe('helcim subscription endpoint', () => { - const savedFetch = globalThis.fetch - beforeEach(() => { vi.clearAllMocks() - }) - - afterEach(() => { - // Restore fetch in case a test stubbed it - globalThis.fetch = savedFetch + // Default: first activation from pending_payment + Member.findOne.mockResolvedValue({ status: 'pending_payment' }) }) it('requires auth', async () => { @@ -95,17 +96,18 @@ describe('helcim subscription endpoint', () => { expect.objectContaining({ status: 'active', contributionTier: '0' }), { new: true } ) + expect(createHelcimSubscription).not.toHaveBeenCalled() }) it('paid tier without cardToken returns 400', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) - getHelcimPlanId.mockReturnValue('plan-123') + getHelcimPlanId.mockReturnValue('99999') const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', - body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1' } + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cadence: 'monthly' } }) await expect(subscriptionHandler(event)).rejects.toMatchObject({ @@ -114,11 +116,12 @@ describe('helcim subscription endpoint', () => { }) }) - it('Helcim API failure still activates member', async () => { + it('monthly $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) - getHelcimPlanId.mockReturnValue('plan-123') + getHelcimPlanId.mockReturnValue('99999') getContributionTierByValue.mockReturnValue({ amount: '15' }) + getTierAmount.mockReturnValue(15) const mockMember = { _id: 'member-2', @@ -127,11 +130,156 @@ describe('helcim subscription endpoint', () => { circle: 'founder', contributionTier: '15', status: 'active', - save: vi.fn() } Member.findOneAndUpdate.mockResolvedValue(mockMember) + createHelcimSubscription.mockResolvedValue({ + data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }] + }) - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))) + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' } + }) + + const result = await subscriptionHandler(event) + + expect(result.success).toBe(true) + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 99999, recurringAmount: 15 }), + 'idem-key-123' + ) + expect(Member.findOneAndUpdate).toHaveBeenCalledWith( + { helcimCustomerId: 'cust-1' }, + { $set: expect.objectContaining({ billingCadence: 'monthly', contributionTier: '15', status: 'active' }) }, + { new: true, runValidators: false } + ) + }) + + it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue('88888') + getContributionTierByValue.mockReturnValue({ amount: '15' }) + getTierAmount.mockReturnValue(150) + + const mockMember = { + _id: 'member-3', + email: 'annual@example.com', + name: 'Annual User', + circle: 'founder', + contributionTier: '15', + status: 'active', + } + Member.findOneAndUpdate.mockResolvedValue(mockMember) + createHelcimSubscription.mockResolvedValue({ + data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }] + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'annual' } + }) + + const result = await subscriptionHandler(event) + + expect(result.success).toBe(true) + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 88888, recurringAmount: 150 }), + 'idem-key-123' + ) + expect(Member.findOneAndUpdate).toHaveBeenCalledWith( + { helcimCustomerId: 'cust-1' }, + { $set: expect.objectContaining({ billingCadence: 'annual', contributionTier: '15', status: 'active' }) }, + { new: true, runValidators: false } + ) + }) + + it('annual $50 tier recurringAmount is 500', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue('88888') + getContributionTierByValue.mockReturnValue({ amount: '50' }) + getTierAmount.mockReturnValue(500) + + const mockMember = { + _id: 'member-4', + email: 'top@example.com', + name: 'Top Tier', + circle: 'practitioner', + contributionTier: '50', + status: 'active', + } + Member.findOneAndUpdate.mockResolvedValue(mockMember) + createHelcimSubscription.mockResolvedValue({ + data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }] + }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-2', contributionTier: '50', customerCode: 'code-2', cardToken: 'tok-456', cadence: 'annual' } + }) + + await subscriptionHandler(event) + + expect(createHelcimSubscription).toHaveBeenCalledWith( + expect.objectContaining({ paymentPlanId: 88888, recurringAmount: 500 }), + 'idem-key-123' + ) + }) + + it('missing monthly plan id returns 500 with message, no Helcim call, no Mongo write', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue(null) + getContributionTierByValue.mockReturnValue({ amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' } + }) + + await expect(subscriptionHandler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Monthly plan id not configured', + }) + + expect(createHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findOneAndUpdate).not.toHaveBeenCalled() + }) + + it('missing annual plan id returns 500 with message, no Helcim call, no Mongo write', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue(null) + getContributionTierByValue.mockReturnValue({ amount: '15' }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + body: { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', cardToken: 'tok-123', cadence: 'annual' } + }) + + await expect(subscriptionHandler(event)).rejects.toMatchObject({ + statusCode: 500, + statusMessage: 'Annual plan id not configured', + }) + + expect(createHelcimSubscription).not.toHaveBeenCalled() + expect(Member.findOneAndUpdate).not.toHaveBeenCalled() + }) + + it('Helcim API failure returns 500 and does NOT activate member', async () => { + requireAuth.mockResolvedValue(undefined) + requiresPayment.mockReturnValue(true) + getHelcimPlanId.mockReturnValue('99999') + getContributionTierByValue.mockReturnValue({ amount: '15' }) + getTierAmount.mockReturnValue(15) + + createHelcimSubscription.mockRejectedValue(new Error('Network error')) const event = createMockEvent({ method: 'POST', @@ -140,23 +288,15 @@ describe('helcim subscription endpoint', () => { customerId: 'cust-1', contributionTier: '15', customerCode: 'code-1', - cardToken: 'tok-123' + cardToken: 'tok-123', + cadence: 'monthly', } }) - const result = await subscriptionHandler(event) + await expect(subscriptionHandler(event)).rejects.toMatchObject({ + statusCode: 500, + }) - expect(result.success).toBe(true) - expect(result.warning).toBeTruthy() - expect(result.member.status).toBe('active') - expect(Member.findOneAndUpdate).toHaveBeenCalledWith( - { helcimCustomerId: 'cust-1' }, - expect.objectContaining({ status: 'active', contributionTier: '15' }), - { new: true } - ) - - vi.unstubAllGlobals() - // Re-stub the schema global after unstubAllGlobals - vi.stubGlobal('helcimSubscriptionSchema', {}) + expect(Member.findOneAndUpdate).not.toHaveBeenCalled() }) })