feat(contribution): free-to-paid uses cadence plan id, persists billingCadence
This commit is contained in:
parent
e8c81cf062
commit
0eeed94772
2 changed files with 180 additions and 21 deletions
|
|
@ -53,6 +53,20 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve plan id before entering the try/catch (so missing plan → 500, not swallowed 400)
|
||||||
|
const cadence = body.cadence; // defaulted to 'monthly' by Zod
|
||||||
|
const paymentPlanId = getHelcimPlanId(cadence);
|
||||||
|
if (!paymentPlanId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: cadence === 'annual'
|
||||||
|
? 'Annual plan id not configured'
|
||||||
|
: 'Monthly plan id not configured',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierInfo = getContributionTierByValue(newTier);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const customerData = await getHelcimCustomer(member.helcimCustomerId);
|
const customerData = await getHelcimCustomer(member.helcimCustomerId);
|
||||||
const customerCode = customerData.customerCode;
|
const customerCode = customerData.customerCode;
|
||||||
|
|
@ -61,7 +75,7 @@ export default defineEventHandler(async (event) => {
|
||||||
throw new Error("No customer code found");
|
throw new Error("No customer code found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for saved cards (FIX: use the correct endpoint)
|
// Check for saved cards
|
||||||
const cards = await listHelcimCustomerCards(member.helcimCustomerId);
|
const cards = await listHelcimCustomerCards(member.helcimCustomerId);
|
||||||
const hasCards = Array.isArray(cards) && cards.length > 0;
|
const hasCards = Array.isArray(cards) && cards.length > 0;
|
||||||
|
|
||||||
|
|
@ -69,27 +83,14 @@ export default defineEventHandler(async (event) => {
|
||||||
throw new Error("No saved payment methods");
|
throw new Error("No saved payment methods");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new subscription with saved payment method
|
|
||||||
const newPlanId = getHelcimPlanId(newTier);
|
|
||||||
|
|
||||||
if (!newPlanId) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: `Plan not configured for tier ${newTier}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const idempotencyKey = generateIdempotencyKey();
|
const idempotencyKey = generateIdempotencyKey();
|
||||||
|
|
||||||
// Get tier amount
|
|
||||||
const tierInfo = getContributionTierByValue(newTier);
|
|
||||||
|
|
||||||
const subscriptionData = await createHelcimSubscription(
|
const subscriptionData = await createHelcimSubscription(
|
||||||
{
|
{
|
||||||
dateActivated: new Date().toISOString().split("T")[0],
|
dateActivated: new Date().toISOString().split("T")[0],
|
||||||
paymentPlanId: parseInt(newPlanId),
|
paymentPlanId: parseInt(paymentPlanId),
|
||||||
customerCode: customerCode,
|
customerCode,
|
||||||
recurringAmount: parseFloat(tierInfo.amount),
|
recurringAmount: getTierAmount(tierInfo, cadence),
|
||||||
paymentMethod: "card",
|
paymentMethod: "card",
|
||||||
},
|
},
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
|
|
@ -104,11 +105,17 @@ export default defineEventHandler(async (event) => {
|
||||||
// Update member record
|
// Update member record
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
member._id,
|
||||||
{ $set: { contributionTier: newTier, helcimSubscriptionId: subscription.id, paymentMethod: "card", status: "active" } },
|
{ $set: {
|
||||||
|
contributionTier: newTier,
|
||||||
|
helcimSubscriptionId: subscription.id,
|
||||||
|
paymentMethod: "card",
|
||||||
|
status: "active",
|
||||||
|
billingCadence: cadence,
|
||||||
|
} },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
logContributionChange()
|
logContributionChange();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -145,7 +152,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Update member to free tier
|
// Update member to free tier
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
member._id,
|
||||||
{ $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none" } },
|
{ $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import {
|
import {
|
||||||
requiresPayment,
|
requiresPayment,
|
||||||
|
getHelcimPlanId,
|
||||||
getContributionTierByValue,
|
getContributionTierByValue,
|
||||||
getTierAmount,
|
getTierAmount,
|
||||||
} from '../../../server/config/contributions.js'
|
} from '../../../server/config/contributions.js'
|
||||||
import { updateHelcimSubscription } from '../../../server/utils/helcim.js'
|
import {
|
||||||
|
updateHelcimSubscription,
|
||||||
|
getHelcimCustomer,
|
||||||
|
listHelcimCustomerCards,
|
||||||
|
createHelcimSubscription,
|
||||||
|
cancelHelcimSubscription,
|
||||||
|
} from '../../../server/utils/helcim.js'
|
||||||
import handler from '../../../server/api/members/update-contribution.post.js'
|
import handler from '../../../server/api/members/update-contribution.post.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
|
|
@ -205,3 +212,148 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
||||||
expect(Member.findByIdAndUpdate).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',
|
||||||
|
{ $set: expect.objectContaining({
|
||||||
|
contributionTier: '0',
|
||||||
|
helcimSubscriptionId: null,
|
||||||
|
paymentMethod: 'none',
|
||||||
|
billingCadence: 'monthly',
|
||||||
|
}) },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.message).toBe('Successfully downgraded to free tier')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue