feat(contribution): paid-to-paid tier swap via recurringAmount PATCH
This commit is contained in:
parent
4b5ea9bbd8
commit
e8c81cf062
2 changed files with 224 additions and 18 deletions
|
|
@ -3,6 +3,7 @@ import {
|
||||||
getHelcimPlanId,
|
getHelcimPlanId,
|
||||||
requiresPayment,
|
requiresPayment,
|
||||||
getContributionTierByValue,
|
getContributionTierByValue,
|
||||||
|
getTierAmount,
|
||||||
} from "../../config/contributions.js";
|
} from "../../config/contributions.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
import Member from "../../models/member.js";
|
import Member from "../../models/member.js";
|
||||||
|
|
@ -158,51 +159,49 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// Case 3: Moving between paid tiers
|
// Case 3: Moving between paid tiers
|
||||||
if (oldRequiresPayment && newRequiresPayment) {
|
if (oldRequiresPayment && newRequiresPayment) {
|
||||||
const newPlanId = getHelcimPlanId(newTier);
|
if (!member.helcimSubscriptionId) {
|
||||||
|
|
||||||
if (!newPlanId) {
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: `Plan not configured for tier ${newTier}`,
|
statusMessage: "Payment information required. You'll be redirected to complete payment setup.",
|
||||||
|
data: { requiresPaymentSetup: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!member.helcimSubscriptionId) {
|
const memberCadence = member.billingCadence || 'monthly';
|
||||||
// No subscription exists - they need to go through payment flow
|
if (body.cadence && body.cadence !== memberCadence) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage:
|
statusMessage: 'Cadence switch not supported on existing subscription',
|
||||||
"Payment information required. You'll be redirected to complete payment setup.",
|
|
||||||
data: { requiresPaymentSetup: true },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newTierInfo = getContributionTierByValue(newTier);
|
||||||
|
if (!newTierInfo) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Invalid tier' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscriptionData = await updateHelcimSubscription(
|
const subscriptionData = await updateHelcimSubscription(
|
||||||
member.helcimSubscriptionId,
|
member.helcimSubscriptionId,
|
||||||
{ paymentPlanId: parseInt(newPlanId) },
|
{ recurringAmount: getTierAmount(newTierInfo, memberCadence) }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update member record
|
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
member._id,
|
||||||
{ $set: { contributionTier: newTier } },
|
{ $set: { contributionTier: newTier } },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
logContributionChange()
|
logContributionChange();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Successfully updated contribution level",
|
message: 'Successfully updated contribution level',
|
||||||
subscription: subscriptionData,
|
subscription: subscriptionData,
|
||||||
};
|
};
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
console.error("Error updating Helcim subscription:", updateError);
|
console.error('Error updating Helcim subscription:', updateError);
|
||||||
throw createError({
|
throw createError({ statusCode: 500, statusMessage: 'Subscription update failed' });
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: "Subscription update failed",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
207
tests/server/api/update-contribution.test.js
Normal file
207
tests/server/api/update-contribution.test.js
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import Member from '../../../server/models/member.js'
|
||||||
|
import {
|
||||||
|
requiresPayment,
|
||||||
|
getContributionTierByValue,
|
||||||
|
getTierAmount,
|
||||||
|
} from '../../../server/config/contributions.js'
|
||||||
|
import { updateHelcimSubscription } 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue