In the Case 1 (free→paid) branch of update-contribution, after the subscription is created and the member row is updated, fetch the newest paid Helcim transaction and upsert a Payment with paymentType=cadence and sendConfirmation=true. Paid→paid (Case 3) is intentionally NOT wired — no new transaction occurs at amount change; the next recurring charge is captured by the reconciliation script.
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
import Member from '../../../server/models/member.js'
|
|
import {
|
|
requiresPayment,
|
|
getHelcimPlanId,
|
|
} from '../../../server/config/contributions.js'
|
|
import {
|
|
updateHelcimSubscription,
|
|
getHelcimCustomer,
|
|
listHelcimCustomerCards,
|
|
createHelcimSubscription,
|
|
cancelHelcimSubscription,
|
|
listHelcimCustomerTransactions,
|
|
} from '../../../server/utils/helcim.js'
|
|
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.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(),
|
|
}))
|
|
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'),
|
|
listHelcimCustomerTransactions: vi.fn().mockResolvedValue([]),
|
|
}))
|
|
vi.mock('../../../server/utils/payments.js', () => ({
|
|
upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true, payment: { _id: 'p1' } })
|
|
}))
|
|
|
|
// 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',
|
|
contributionAmount: 5,
|
|
helcimSubscriptionId: 'sub-1',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
updateHelcimSubscription.mockResolvedValue({ id: 'sub-1', status: 'active' })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 15 },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-1', { recurringAmount: 15 })
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-1',
|
|
{ $set: { contributionAmount: 15 } },
|
|
{ runValidators: false }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
expect(result.message).toBe('Successfully updated contribution level')
|
|
})
|
|
|
|
it('annual $5 → $15: calls updateHelcimSubscription with recurringAmount 180', async () => {
|
|
const mockMember = {
|
|
_id: 'member-2',
|
|
contributionAmount: 5,
|
|
helcimSubscriptionId: 'sub-2',
|
|
billingCadence: 'annual',
|
|
}
|
|
setMember(mockMember)
|
|
updateHelcimSubscription.mockResolvedValue({ id: 'sub-2', status: 'active' })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 15, cadence: 'annual' },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 180 })
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-2',
|
|
{ $set: { contributionAmount: 15 } },
|
|
{ runValidators: false }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 600', async () => {
|
|
const mockMember = {
|
|
_id: 'member-3',
|
|
contributionAmount: 15,
|
|
helcimSubscriptionId: 'sub-3',
|
|
billingCadence: 'annual',
|
|
}
|
|
setMember(mockMember)
|
|
updateHelcimSubscription.mockResolvedValue({ id: 'sub-3', status: 'active' })
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 50, cadence: 'annual' },
|
|
})
|
|
|
|
await handler(event)
|
|
|
|
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-3', { recurringAmount: 600 })
|
|
})
|
|
|
|
it('cadence mismatch: monthly member + body cadence annual → 400, no Helcim call, no DB write', async () => {
|
|
const mockMember = {
|
|
_id: 'member-4',
|
|
contributionAmount: 5,
|
|
helcimSubscriptionId: 'sub-4',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 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',
|
|
contributionAmount: 5,
|
|
helcimSubscriptionId: 'sub-5',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
updateHelcimSubscription.mockRejectedValue(new Error('Helcim 400'))
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 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',
|
|
contributionAmount: 5,
|
|
helcimSubscriptionId: null,
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 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 amount = 0 (free), new amount > 0 (paid)
|
|
requiresPayment.mockImplementation((amount) => amount !== 0)
|
|
})
|
|
|
|
function setMember(mockMember) {
|
|
globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember)
|
|
}
|
|
|
|
const freeMember = {
|
|
_id: 'member-c1',
|
|
contributionAmount: 0,
|
|
helcimCustomerId: 'cust-1',
|
|
}
|
|
|
|
it('monthly: calls createHelcimSubscription with monthly plan id and recurringAmount 15, persists billingCadence monthly', async () => {
|
|
setMember(freeMember)
|
|
getHelcimPlanId.mockReturnValue('111')
|
|
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({})
|
|
listHelcimCustomerTransactions.mockResolvedValueOnce([
|
|
{ id: 'tx-upgrade', date: '2026-04-20', amount: 15, status: 'paid', currency: 'CAD' }
|
|
])
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 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', contributionAmount: 15, helcimSubscriptionId: 'sub-new' }) },
|
|
{ runValidators: false }
|
|
)
|
|
expect(listHelcimCustomerTransactions).toHaveBeenCalledWith('code-1')
|
|
expect(upsertPaymentFromHelcim).toHaveBeenCalledWith(
|
|
expect.objectContaining({ _id: 'member-c1', helcimSubscriptionId: 'sub-new', billingCadence: 'monthly' }),
|
|
expect.objectContaining({ id: 'tx-upgrade' }),
|
|
{ paymentType: 'monthly', sendConfirmation: true }
|
|
)
|
|
expect(result.success).toBe(true)
|
|
expect(result.message).toBe('Successfully upgraded to paid tier')
|
|
})
|
|
|
|
it('annual: calls createHelcimSubscription with annual plan id and recurringAmount 180, persists billingCadence annual', async () => {
|
|
setMember(freeMember)
|
|
getHelcimPlanId.mockReturnValue('222')
|
|
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: { contributionAmount: 15, cadence: 'annual' },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(createHelcimSubscription).toHaveBeenCalledWith(
|
|
expect.objectContaining({ paymentPlanId: 222, recurringAmount: 180 }),
|
|
'idem-key-123'
|
|
)
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-c1',
|
|
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 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)
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 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 amount > 0 (paid), new amount = 0 (free)
|
|
requiresPayment.mockImplementation((amount) => amount !== 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',
|
|
contributionAmount: 15,
|
|
helcimSubscriptionId: 'sub-1',
|
|
billingCadence: 'monthly',
|
|
}
|
|
setMember(mockMember)
|
|
cancelHelcimSubscription.mockResolvedValue({})
|
|
Member.findByIdAndUpdate.mockResolvedValue({})
|
|
|
|
const event = createMockEvent({
|
|
method: 'POST',
|
|
path: '/api/members/update-contribution',
|
|
body: { contributionAmount: 0 },
|
|
})
|
|
|
|
const result = await handler(event)
|
|
|
|
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
|
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
|
'member-c2',
|
|
expect.objectContaining({
|
|
$set: expect.objectContaining({
|
|
contributionAmount: 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')
|
|
})
|
|
})
|