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 } from '../../../server/config/contributions.js' import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js' import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js' import { sendWelcomeEmail } from '../../../server/utils/resend.js' import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => ({ default: { findOneAndUpdate: vi.fn(), findOne: vi.fn(), findById: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn(), getPaymentBridgeMember: vi.fn().mockResolvedValue(null) })) vi.mock('../../../server/utils/slack.ts', () => ({ getSlackService: vi.fn().mockReturnValue(null) })) vi.mock('../../../server/config/contributions.js', () => ({ requiresPayment: vi.fn(), getHelcimPlanId: 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'), listHelcimCustomerTransactions: vi.fn().mockResolvedValue([]), })) vi.mock('../../../server/utils/payments.js', () => ({ upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true, payment: { _id: 'p1' } }) })) // helcimSubscriptionSchema is a Nitro auto-import used by validateBody vi.stubGlobal('helcimSubscriptionSchema', {}) describe('helcim subscription endpoint', () => { beforeEach(() => { vi.clearAllMocks() // Default: pre-update doc reflects first activation from pending_payment. // findOneAndUpdate returns pre-update doc; findById returns post-update doc. Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-default', status: 'pending_payment' }) }) it('requires auth', async () => { requireAuth.mockRejectedValue( createError({ statusCode: 401, statusMessage: 'Unauthorized' }) ) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 0, customerCode: 'code-1' } }) await expect(subscriptionHandler(event)).rejects.toMatchObject({ statusCode: 401, statusMessage: 'Unauthorized' }) expect(requireAuth).toHaveBeenCalledWith(event) }) it('free tier skips Helcim and activates member', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(false) const mockMember = { _id: 'member-1', email: 'test@example.com', name: 'Test', circle: 'community', contributionAmount: 0, status: 'active', save: vi.fn() } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 0, customerCode: 'code-1' } }) const result = await subscriptionHandler(event) expect(result.success).toBe(true) expect(result.subscription).toBeNull() expect(result.member).toEqual({ id: 'member-1', email: 'test@example.com', name: 'Test', circle: 'community', contributionAmount: 0, status: 'active' }) expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, expect.objectContaining({ status: 'active', contributionAmount: 0 }), { new: false, projection: { status: 1 } } ) expect(Member.findById).toHaveBeenCalledWith('member-1') expect(createHelcimSubscription).not.toHaveBeenCalled() }) it('paid tier without cardToken returns 400', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('99999') const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 15, customerCode: 'code-1', cadence: 'monthly' } }) await expect(subscriptionHandler(event)).rejects.toMatchObject({ statusCode: 400, statusMessage: 'Payment information is required for a paid contribution' }) }) it('monthly $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('99999') const mockMember = { _id: 'member-2', email: 'paid@example.com', name: 'Paid User', circle: 'founder', contributionAmount: 15, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-2', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }] }) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 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', contributionAmount: 15, status: 'active' }) }, { new: false, runValidators: false, projection: { status: 1 } } ) expect(Member.findById).toHaveBeenCalledWith('member-2') }) it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('88888') const mockMember = { _id: 'member-3', email: 'annual@example.com', name: 'Annual User', circle: 'founder', contributionAmount: 15, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' }) Member.findById.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', contributionAmount: 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: 180 }), 'idem-key-123' ) expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, { $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) }, { new: false, runValidators: false, projection: { status: 1 } } ) expect(Member.findById).toHaveBeenCalledWith('member-3') }) it('annual $50 tier recurringAmount is 600', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('88888') const mockMember = { _id: 'member-4', email: 'top@example.com', name: 'Top Tier', circle: 'practitioner', contributionAmount: 50, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' }) Member.findById.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', contributionAmount: 50, customerCode: 'code-2', cardToken: 'tok-456', cadence: 'annual' } }) await subscriptionHandler(event) expect(createHelcimSubscription).toHaveBeenCalledWith( expect.objectContaining({ paymentPlanId: 88888, recurringAmount: 600 }), '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) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 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) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 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('logs the newest paid Helcim transaction to Payment on paid monthly creation', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('99999') const mockMember = { _id: 'member-9', email: 'log@example.com', name: 'Logger', circle: 'founder', contributionAmount: 15, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-9', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }] }) listHelcimCustomerTransactions.mockResolvedValueOnce([ { id: 'tx-newest', date: '2026-04-20', amount: 15, status: 'paid', currency: 'CAD' }, { id: 'tx-older', date: '2026-04-19', amount: 15, status: 'paid', currency: 'CAD' } ]) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-log', contributionAmount: 15, customerCode: 'code-log', cardToken: 'tok', cadence: 'monthly' } }) const result = await subscriptionHandler(event) expect(result.success).toBe(true) expect(listHelcimCustomerTransactions).toHaveBeenCalledWith('code-log') expect(upsertPaymentFromHelcim).toHaveBeenCalledWith( mockMember, expect.objectContaining({ id: 'tx-newest' }), { paymentType: 'monthly', sendConfirmation: true } ) }) it('uses cadence=annual when logging the initial charge on annual creation', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('88888') const mockMember = { _id: 'member-10', email: 'annuallog@example.com', name: 'AnnualLogger', circle: 'founder', contributionAmount: 15, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-10', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }] }) listHelcimCustomerTransactions.mockResolvedValueOnce([ { id: 'tx-annual', date: '2026-04-20', amount: 180, status: 'paid', currency: 'CAD' } ]) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-a', contributionAmount: 15, customerCode: 'code-a', cardToken: 'tok', cadence: 'annual' } }) await subscriptionHandler(event) expect(upsertPaymentFromHelcim).toHaveBeenCalledWith( mockMember, expect.objectContaining({ id: 'tx-annual' }), { paymentType: 'annual', sendConfirmation: true } ) }) it('still returns success when payment logging throws (reconciliation will catch)', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('99999') const mockMember = { _id: 'member-11', email: 'boom@example.com', name: 'Boom', circle: 'founder', contributionAmount: 15, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-11', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }] }) listHelcimCustomerTransactions.mockRejectedValueOnce(new Error('helcim down')) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-boom', contributionAmount: 15, customerCode: 'code-boom', cardToken: 'tok', cadence: 'monthly' } }) const result = await subscriptionHandler(event) expect(result.success).toBe(true) expect(upsertPaymentFromHelcim).not.toHaveBeenCalled() }) it('first activation (pending_payment → active) sends welcome email on free tier', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(false) const mockMember = { _id: 'member-first-free', email: 'newbie@example.com', name: 'Newbie', circle: 'community', contributionAmount: 0, status: 'active', } // Pre-update status was pending_payment → first activation Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-free', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-first-free', contributionAmount: 0, customerCode: 'code-1' } }) await subscriptionHandler(event) expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember) }) it('first activation (pending_payment → active) sends welcome email on paid tier', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('99999') const mockMember = { _id: 'member-first-paid', email: 'newpaid@example.com', name: 'NewPaid', circle: 'founder', contributionAmount: 15, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-paid', status: 'pending_payment' }) Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-first-paid', status: 'active', nextBillingDate: '2026-05-18' }] }) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-first-paid', contributionAmount: 15, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' } }) await subscriptionHandler(event) expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember) }) it('already-active retry (active → active) does NOT send welcome email on free tier', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(false) const mockMember = { _id: 'member-retry-free', email: 'existing@example.com', name: 'Existing', circle: 'community', contributionAmount: 0, status: 'active', } // Pre-update status was already active → tier upgrade, not first activation Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-free', status: 'active' }) Member.findById.mockResolvedValue(mockMember) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-retry-free', contributionAmount: 0, customerCode: 'code-1' } }) await subscriptionHandler(event) expect(sendWelcomeEmail).not.toHaveBeenCalled() }) it('already-active retry (active → active) does NOT send welcome email on paid tier', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('99999') const mockMember = { _id: 'member-retry-paid', email: 'upgrader@example.com', name: 'Upgrader', circle: 'founder', contributionAmount: 25, status: 'active', } Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-paid', status: 'active' }) Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-retry', status: 'active', nextBillingDate: '2026-05-18' }] }) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-retry-paid', contributionAmount: 25, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' } }) await subscriptionHandler(event) expect(sendWelcomeEmail).not.toHaveBeenCalled() }) it('Helcim API failure returns 500 and does NOT activate member', async () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) getHelcimPlanId.mockReturnValue('99999') createHelcimSubscription.mockRejectedValue(new Error('Network error')) const event = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', body: { customerId: 'cust-1', contributionAmount: 15, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly', } }) await expect(subscriptionHandler(event)).rejects.toMatchObject({ statusCode: 500, }) expect(Member.findOneAndUpdate).not.toHaveBeenCalled() }) })