diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index b5567bc..db4ffb7 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -90,26 +90,22 @@ export default defineEventHandler(async (event) => { await connectDB() const body = await validateBody(event, helcimSubscriptionSchema) - // Only send welcome email when a member transitions from pending_payment - // to active for the first time — not on tier upgrades (active → active). - const priorMember = await Member.findOne( - { helcimCustomerId: body.customerId }, - { status: 1 } - ) - const isFirstActivation = priorMember?.status === 'pending_payment' - // Check if payment is required if (!requiresPayment(body.contributionAmount)) { - // For free tier, just update member status - const member = await Member.findOneAndUpdate( + // For free tier, atomically capture pre-update status alongside the write. + // Welcome email only fires on pending_payment → active transitions, not + // on tier upgrades (active → active). + const preMember = await Member.findOneAndUpdate( { helcimCustomerId: body.customerId }, { status: 'active', contributionAmount: body.contributionAmount, subscriptionStartDate: new Date() }, - { new: true } + { new: false, projection: { status: 1 } } ) + const isFirstActivation = preMember?.status === 'pending_payment' + const member = await Member.findById(preMember._id) logActivity(member._id, 'subscription_created', { amount: body.contributionAmount }) @@ -175,8 +171,10 @@ export default defineEventHandler(async (event) => { ? new Date(subscription.nextBillingDate) : null - // Update member in database - const member = await Member.findOneAndUpdate( + // Atomically capture pre-update status alongside the write so we can + // detect the pending_payment → active transition without a separate read + // (which would race with concurrent webhooks/double-clicks). + const preMember = await Member.findOneAndUpdate( { helcimCustomerId: body.customerId }, { $set: { contributionAmount: body.contributionAmount, @@ -190,8 +188,10 @@ export default defineEventHandler(async (event) => { ? { nextBillingDate } : {}), } }, - { new: true, runValidators: false } + { new: false, runValidators: false, projection: { status: 1 } } ) + const isFirstActivation = preMember?.status === 'pending_payment' + const member = await Member.findById(preMember._id) logActivity(member._id, 'subscription_created', { amount: body.contributionAmount }) diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js index 7a41e26..24f8737 100644 --- a/tests/server/api/helcim-subscription.test.js +++ b/tests/server/api/helcim-subscription.test.js @@ -5,11 +5,12 @@ 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() } + 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', () => ({ @@ -41,8 +42,9 @@ vi.stubGlobal('helcimSubscriptionSchema', {}) describe('helcim subscription endpoint', () => { beforeEach(() => { vi.clearAllMocks() - // Default: first activation from pending_payment - Member.findOne.mockResolvedValue({ status: 'pending_payment' }) + // 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 () => { @@ -77,7 +79,8 @@ describe('helcim subscription endpoint', () => { status: 'active', save: vi.fn() } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) const event = createMockEvent({ method: 'POST', @@ -100,8 +103,9 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, expect.objectContaining({ status: 'active', contributionAmount: 0 }), - { new: true } + { new: false, projection: { status: 1 } } ) + expect(Member.findById).toHaveBeenCalledWith('member-1') expect(createHelcimSubscription).not.toHaveBeenCalled() }) @@ -135,7 +139,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + 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' }] }) @@ -156,8 +161,9 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, { $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) }, - { new: true, runValidators: false } + { 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 () => { @@ -173,7 +179,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + 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' }] }) @@ -194,8 +201,9 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, { $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) }, - { new: true, runValidators: false } + { new: false, runValidators: false, projection: { status: 1 } } ) + expect(Member.findById).toHaveBeenCalledWith('member-3') }) it('annual $50 tier recurringAmount is 600', async () => { @@ -211,7 +219,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 50, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + 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' }] }) @@ -283,7 +292,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + 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' }] }) @@ -322,7 +332,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + 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' }] }) @@ -358,7 +369,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + 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' }] }) @@ -376,6 +388,120 @@ describe('helcim subscription endpoint', () => { 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)