refactor(helcim): collapse redundant Member queries in subscription.post.js
This commit is contained in:
parent
ac5e979c78
commit
c1367ebd29
2 changed files with 153 additions and 27 deletions
|
|
@ -90,26 +90,22 @@ export default defineEventHandler(async (event) => {
|
||||||
await connectDB()
|
await connectDB()
|
||||||
const body = await validateBody(event, helcimSubscriptionSchema)
|
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
|
// Check if payment is required
|
||||||
if (!requiresPayment(body.contributionAmount)) {
|
if (!requiresPayment(body.contributionAmount)) {
|
||||||
// For free tier, just update member status
|
// For free tier, atomically capture pre-update status alongside the write.
|
||||||
const member = await Member.findOneAndUpdate(
|
// Welcome email only fires on pending_payment → active transitions, not
|
||||||
|
// on tier upgrades (active → active).
|
||||||
|
const preMember = await Member.findOneAndUpdate(
|
||||||
{ helcimCustomerId: body.customerId },
|
{ helcimCustomerId: body.customerId },
|
||||||
{
|
{
|
||||||
status: 'active',
|
status: 'active',
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
subscriptionStartDate: new Date()
|
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 })
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
||||||
|
|
||||||
|
|
@ -175,8 +171,10 @@ export default defineEventHandler(async (event) => {
|
||||||
? new Date(subscription.nextBillingDate)
|
? new Date(subscription.nextBillingDate)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Update member in database
|
// Atomically capture pre-update status alongside the write so we can
|
||||||
const member = await Member.findOneAndUpdate(
|
// 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 },
|
{ helcimCustomerId: body.customerId },
|
||||||
{ $set: {
|
{ $set: {
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
|
|
@ -190,8 +188,10 @@ export default defineEventHandler(async (event) => {
|
||||||
? { nextBillingDate }
|
? { 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 })
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ import { requireAuth } from '../../../server/utils/auth.js'
|
||||||
import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js'
|
import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js'
|
||||||
import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
||||||
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.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 subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.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/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
|
|
@ -41,8 +42,9 @@ vi.stubGlobal('helcimSubscriptionSchema', {})
|
||||||
describe('helcim subscription endpoint', () => {
|
describe('helcim subscription endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// Default: first activation from pending_payment
|
// Default: pre-update doc reflects first activation from pending_payment.
|
||||||
Member.findOne.mockResolvedValue({ status: '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 () => {
|
it('requires auth', async () => {
|
||||||
|
|
@ -77,7 +79,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
save: vi.fn()
|
save: vi.fn()
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
|
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -100,8 +103,9 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
{ helcimCustomerId: 'cust-1' },
|
{ helcimCustomerId: 'cust-1' },
|
||||||
expect.objectContaining({ status: 'active', contributionAmount: 0 }),
|
expect.objectContaining({ status: 'active', contributionAmount: 0 }),
|
||||||
{ new: true }
|
{ new: false, projection: { status: 1 } }
|
||||||
)
|
)
|
||||||
|
expect(Member.findById).toHaveBeenCalledWith('member-1')
|
||||||
expect(createHelcimSubscription).not.toHaveBeenCalled()
|
expect(createHelcimSubscription).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -135,7 +139,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-2', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -156,8 +161,9 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
{ helcimCustomerId: 'cust-1' },
|
{ helcimCustomerId: 'cust-1' },
|
||||||
{ $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) },
|
{ $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 () => {
|
it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => {
|
||||||
|
|
@ -173,7 +179,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }]
|
data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -194,8 +201,9 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
{ helcimCustomerId: 'cust-1' },
|
{ helcimCustomerId: 'cust-1' },
|
||||||
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) },
|
{ $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 () => {
|
it('annual $50 tier recurringAmount is 600', async () => {
|
||||||
|
|
@ -211,7 +219,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 50,
|
contributionAmount: 50,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }]
|
data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -283,7 +292,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-9', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -322,7 +332,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-10', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }]
|
data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }]
|
||||||
})
|
})
|
||||||
|
|
@ -358,7 +369,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-11', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }]
|
data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -376,6 +388,120 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
|
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 () => {
|
it('Helcim API failure returns 500 and does NOT activate member', async () => {
|
||||||
requireAuth.mockResolvedValue(undefined)
|
requireAuth.mockResolvedValue(undefined)
|
||||||
requiresPayment.mockReturnValue(true)
|
requiresPayment.mockReturnValue(true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue