diff --git a/app/pages/accept-invite.vue b/app/pages/accept-invite.vue index 080ee0d..9946b0d 100644 --- a/app/pages/accept-invite.vue +++ b/app/pages/accept-invite.vue @@ -288,6 +288,7 @@ const handleAccept = async () => { circle: form.circle, motivation: form.motivation || undefined, contributionAmount: form.contributionAmount, + cadence: cadence.value, agreedToGuidelines: form.agreedToGuidelines, token: token.value, }, diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 2d6518e..9a2b7a4 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -59,6 +59,7 @@ export default defineEventHandler(async (event) => { location: body.location || undefined, circle: body.circle, contributionAmount: body.contributionAmount, + billingCadence: body.contributionAmount === 0 ? 'monthly' : body.cadence, bio: body.motivation || undefined, status: body.contributionAmount === 0 ? 'active' : 'pending_payment', helcimCustomerId: helcimCustomer?.id, diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 8504534..201e6b6 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -363,6 +363,7 @@ export const inviteAcceptSchema = z.object({ circle: z.enum(['community', 'founder', 'practitioner']), motivation: z.string().max(5000).optional(), contributionAmount: z.number().int().min(0), + cadence: z.enum(['monthly', 'annual']).default('monthly'), agreedToGuidelines: z.literal(true), token: z.string().min(1) }) diff --git a/tests/server/api/activation-auto-flag.test.js b/tests/server/api/activation-auto-flag.test.js index 2895506..1e39663 100644 --- a/tests/server/api/activation-auto-flag.test.js +++ b/tests/server/api/activation-auto-flag.test.js @@ -209,3 +209,62 @@ describe('POST /api/members/create — auto-flag wiring (3.8)', () => { ) }) }) + +// --------------------------------------------------------------------------- +// billingCadence persistence on /api/invite/accept +// +// Regression: an annual-choosing invitee who abandoned before payment was left +// with billingCadence defaulting to 'monthly' while contributionAmount held an +// annual-unit value (e.g. 180), rendering "$180/mo" in admin views. The handler +// must persist the chosen cadence at Member.create time. +// --------------------------------------------------------------------------- +describe('POST /api/invite/accept — billingCadence persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + PreRegistration.findById.mockResolvedValue({ + _id: 'prereg-1', + email: 'invitee@example.com', + status: 'pending' + }) + PreRegistration.findByIdAndUpdate.mockResolvedValue(undefined) + Member.findOne.mockResolvedValue(null) + Member.create.mockImplementation(async (data) => ({ _id: 'new-member-via-invite', ...data })) + }) + + it('persists billingCadence "annual" for an annual paid invite', async () => { + globalThis.validateBody.mockResolvedValue({ + token: 'tok', + preRegistrationId: 'prereg-1', + name: 'Annual Invitee', + circle: 'community', + contributionAmount: 180, + cadence: 'annual' + }) + createHelcimCustomer.mockResolvedValue({ id: 'cust-annual', customerCode: 'code-annual' }) + + const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) + await inviteAcceptHandler(event) + + expect(Member.create).toHaveBeenCalledWith( + expect.objectContaining({ contributionAmount: 180, billingCadence: 'annual' }) + ) + }) + + it('forces billingCadence "monthly" for a $0 invite even when cadence is annual', async () => { + globalThis.validateBody.mockResolvedValue({ + token: 'tok', + preRegistrationId: 'prereg-1', + name: 'Free Invitee', + circle: 'community', + contributionAmount: 0, + cadence: 'annual' + }) + + const event = createMockEvent({ method: 'POST', path: '/api/invite/accept' }) + await inviteAcceptHandler(event) + + expect(Member.create).toHaveBeenCalledWith( + expect.objectContaining({ contributionAmount: 0, billingCadence: 'monthly' }) + ) + }) +})