Persist nextBillingDate on subscription create/update; unset on cancel or downgrade to free. Account page displays the cached date and lazily refreshes from Helcim when the cached value is within 24h of now (or missing).
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
import Member from '../../../server/models/member.js'
|
|
import {
|
|
requiresPayment,
|
|
getHelcimPlanId,
|
|
getContributionTierByValue,
|
|
getTierAmount,
|
|
} from '../../../server/config/contributions.js'
|
|
import {
|
|
updateHelcimSubscription,
|
|
getHelcimCustomer,
|
|
listHelcimCustomerCards,
|
|
createHelcimSubscription,
|
|
cancelHelcimSubscription,
|
|
} from '../../../server/utils/helcim.js'
|
|
import handler from '../../../server/api/members/update-contribution.post.js'
|
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
|
|
|
vi.mock('../../../server/models/member.js', () => ({
|
|
default: { findByIdAndUpdate: vi.fn() }
|
|
}))
|
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
|
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
|
vi.mock('../../../server/config/contributions.js', () => ({
|
|
requiresPayment: vi.fn(),
|
|
getHelcimPlanId: vi.fn(),
|
|
getContributionTierByValue: vi.fn(),
|
|
getTierAmount: vi.fn(),
|
|
}))
|
|
vi.mock('../../../server/utils/helcim.js', () => ({
|
|
getHelcimCustomer: vi.fn(),
|
|
listHelcimCustomerCards: vi.fn(),
|
|
createHelcimSubscription: vi.fn(),
|
|
updateHelcimSubscription: vi.fn(),
|
|
cancelHelcimSubscription: vi.fn(),
|
|
generateIdempotencyKey: vi.fn().mockReturnValue('idem-key-123'),
|
|
}))
|
|
|
|
// Nitro auto-imports
|
|
vi.stubGlobal('updateContributionSchema', {})
|
|
|
|
describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Both tiers require payment for all Case 3 tests
|
|
requiresPayment.mockReturnValue(true)
|
|
})
|
|
|
|
// Helper: set requireAuth global to resolve with the given member
|
|
function setMember(mockMember) {
|
|
globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember)
|
|
}
|
|
|
|
it('monthly $5 → $15: calls updateHelcimSubscription with recurringAmount and updates member', async () => {
|
|
const mockMember = {
|
|
_id: 'member-1',
|
|
contributionTier: '5',
|
|
helcimSubscriptionId: 'sub-1',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
|
|
getTierAmount.mockReturnValue(15)
|
|
updateHelcimSubscription.mockResolvedValue({ id: 'sub-1', status: 'active' })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15' },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-1', { recurringAmount: 15 })
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-1',
|
|
{ $set: { contributionTier: '15' } },
|
|
{ runValidators: false }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
expect(result.message).toBe('Successfully updated contribution level')
|
|
})
|
|
|
|
it('annual $5 → $15: calls updateHelcimSubscription with recurringAmount 150', async () => {
|
|
const mockMember = {
|
|
_id: 'member-2',
|
|
contributionTier: '5',
|
|
helcimSubscriptionId: 'sub-2',
|
|
billingCadence: 'annual',
|
|
}
|
|
setMember(mockMember)
|
|
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
|
|
getTierAmount.mockReturnValue(150)
|
|
updateHelcimSubscription.mockResolvedValue({ id: 'sub-2', status: 'active' })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15', cadence: 'annual' },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 150 })
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-2',
|
|
{ $set: { contributionTier: '15' } },
|
|
{ runValidators: false }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 500', async () => {
|
|
const mockMember = {
|
|
_id: 'member-3',
|
|
contributionTier: '15',
|
|
helcimSubscriptionId: 'sub-3',
|
|
billingCadence: 'annual',
|
|
}
|
|
setMember(mockMember)
|
|
getContributionTierByValue.mockReturnValue({ value: '50', amount: '50' })
|
|
getTierAmount.mockReturnValue(500)
|
|
updateHelcimSubscription.mockResolvedValue({ id: 'sub-3', status: 'active' })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '50', cadence: 'annual' },
|
|
})
|
|
|
|
await handler(event)
|
|
|
|
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-3', { recurringAmount: 500 })
|
|
})
|
|
|
|
it('cadence mismatch: monthly member + body cadence annual → 400, no Helcim call, no DB write', async () => {
|
|
const mockMember = {
|
|
_id: 'member-4',
|
|
contributionTier: '5',
|
|
helcimSubscriptionId: 'sub-4',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15', cadence: 'annual' },
|
|
})
|
|
|
|
await expect(handler(event)).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
statusMessage: 'Cadence switch not supported on existing subscription',
|
|
})
|
|
|
|
expect(updateHelcimSubscription).not.toHaveBeenCalled()
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('Helcim PATCH failure → 500, member NOT updated', async () => {
|
|
const mockMember = {
|
|
_id: 'member-5',
|
|
contributionTier: '5',
|
|
helcimSubscriptionId: 'sub-5',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
|
|
getTierAmount.mockReturnValue(15)
|
|
updateHelcimSubscription.mockRejectedValue(new Error('Helcim 400'))
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15' },
|
|
})
|
|
|
|
await expect(handler(event)).rejects.toMatchObject({
|
|
statusCode: 500,
|
|
statusMessage: 'Subscription update failed',
|
|
})
|
|
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('no helcimSubscriptionId → 400 with requiresPaymentSetup, no Helcim call', async () => {
|
|
const mockMember = {
|
|
_id: 'member-6',
|
|
contributionTier: '5',
|
|
helcimSubscriptionId: null,
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15' },
|
|
})
|
|
|
|
await expect(handler(event)).rejects.toMatchObject({
|
|
statusCode: 400,
|
|
data: { requiresPaymentSetup: true },
|
|
})
|
|
|
|
expect(updateHelcimSubscription).not.toHaveBeenCalled()
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('update-contribution endpoint — Case 1 (free→paid)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// old tier = free, new tier = paid
|
|
requiresPayment.mockImplementation((tier) => tier !== '0')
|
|
})
|
|
|
|
function setMember(mockMember) {
|
|
globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember)
|
|
}
|
|
|
|
const freeMember = {
|
|
_id: 'member-c1',
|
|
contributionTier: '0',
|
|
helcimCustomerId: 'cust-1',
|
|
}
|
|
|
|
it('monthly: calls createHelcimSubscription with monthly plan id and recurringAmount 15, persists billingCadence monthly', async () => {
|
|
setMember(freeMember)
|
|
getHelcimPlanId.mockReturnValue('111')
|
|
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
|
|
getTierAmount.mockReturnValue(15)
|
|
getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' })
|
|
listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }])
|
|
createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-new', status: 'active', nextBillingDate: '2026-05-18' }] })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15', cadence: 'monthly' },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(createHelcimSubscription).toHaveBeenCalledWith(
|
|
expect.objectContaining({ paymentPlanId: 111, recurringAmount: 15 }),
|
|
'idem-key-123'
|
|
)
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-c1',
|
|
{ $set: expect.objectContaining({ billingCadence: 'monthly', contributionTier: '15', helcimSubscriptionId: 'sub-new' }) },
|
|
{ runValidators: false }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
expect(result.message).toBe('Successfully upgraded to paid tier')
|
|
})
|
|
|
|
it('annual: calls createHelcimSubscription with annual plan id and recurringAmount 150, persists billingCadence annual', async () => {
|
|
setMember(freeMember)
|
|
getHelcimPlanId.mockReturnValue('222')
|
|
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
|
|
getTierAmount.mockReturnValue(150)
|
|
getHelcimCustomer.mockResolvedValue({ customerCode: 'code-1' })
|
|
listHelcimCustomerCards.mockResolvedValue([{ id: 'card-1' }])
|
|
createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual', status: 'active', nextBillingDate: '2027-04-18' }] })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15', cadence: 'annual' },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(createHelcimSubscription).toHaveBeenCalledWith(
|
|
expect.objectContaining({ paymentPlanId: 222, recurringAmount: 150 }),
|
|
'idem-key-123'
|
|
)
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-c1',
|
|
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionTier: '15', helcimSubscriptionId: 'sub-annual' }) },
|
|
{ runValidators: false }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('missing plan id env → 500, createHelcimSubscription NOT called, member NOT updated', async () => {
|
|
setMember(freeMember)
|
|
getHelcimPlanId.mockReturnValue(null)
|
|
getContributionTierByValue.mockReturnValue({ value: '15', amount: '15' })
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '15', cadence: 'monthly' },
|
|
})
|
|
|
|
await expect(handler(event)).rejects.toMatchObject({
|
|
statusCode: 500,
|
|
statusMessage: 'Monthly plan id not configured',
|
|
})
|
|
|
|
expect(createHelcimSubscription).not.toHaveBeenCalled()
|
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('update-contribution endpoint — Case 2 (paid→free)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// old tier = paid, new tier = free
|
|
requiresPayment.mockImplementation((tier) => tier !== '0')
|
|
})
|
|
|
|
function setMember(mockMember) {
|
|
globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember)
|
|
}
|
|
|
|
it('cancels subscription, resets billingCadence to monthly, clears helcimSubscriptionId', async () => {
|
|
const mockMember = {
|
|
_id: 'member-c2',
|
|
contributionTier: '15',
|
|
helcimSubscriptionId: 'sub-1',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
cancelHelcimSubscription.mockResolvedValue({})
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionTier: '0' },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-c2',
|
|
expect.objectContaining({
|
|
$set: expect.objectContaining({
|
|
contributionTier: '0',
|
|
helcimSubscriptionId: null,
|
|
paymentMethod: 'none',
|
|
billingCadence: 'monthly',
|
|
}),
|
|
$unset: expect.objectContaining({ nextBillingDate: 1 }),
|
|
}),
|
|
{ runValidators: false }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
expect(result.message).toBe('Successfully downgraded to free tier')
|
|
})
|
|
})
|