From 426f233ccd92943bfe31f4f3a79462458e0a4f7b Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 24 May 2026 15:30:31 +0100 Subject: [PATCH] fix(join): persist billingCadence on /join signup Mirror of the invite-accept fix (c3b1c59) for the self-serve /join path. A joiner who picked annual but abandoned before /api/helcim/subscription ran was left at billingCadence:'monthly' (the model default) with a cadence-unit contributionAmount, rendering $180/mo in admin views. join.vue now sends cadence to /api/helcim/customer; helcimCustomerSchema accepts cadence (defaults 'monthly'); customer.post.js persists billingCadence in both the new-member create branch and the guest-upgrade $set branch, forced to 'monthly' for $0 signups. --- app/pages/join.vue | 1 + server/api/helcim/customer.post.js | 2 + server/utils/schemas.js | 1 + tests/server/api/helcim-customer.test.js | 63 ++++++++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/app/pages/join.vue b/app/pages/join.vue index e2032f0..df1934b 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -432,6 +432,7 @@ const handleSubmit = async () => { email: form.email, circle: form.circle, contributionAmount: form.contributionAmount, + cadence: cadence.value, agreedToGuidelines: form.agreedToGuidelines, billingAddress: form.billingAddress, }, diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 28ceda3..527d67c 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => { name: body.name, circle: body.circle, contributionAmount: body.contributionAmount, + billingCadence: body.contributionAmount === 0 ? 'monthly' : body.cadence, helcimCustomerId: customerData.id, helcimCustomerCode: customerData.customerCode, status: 'pending_payment', @@ -76,6 +77,7 @@ export default defineEventHandler(async (event) => { name: body.name, circle: body.circle, contributionAmount: body.contributionAmount, + billingCadence: body.contributionAmount === 0 ? 'monthly' : body.cadence, helcimCustomerId: customerData.id, helcimCustomerCode: customerData.customerCode, status: 'pending_payment', diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 201e6b6..a60a76a 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -63,6 +63,7 @@ export const helcimCustomerSchema = z.object({ email: z.string().trim().toLowerCase().email(), circle: z.enum(['community', 'founder', 'practitioner']).optional().default('community'), contributionAmount: z.number().int().min(0).optional(), + cadence: z.enum(['monthly', 'annual']).default('monthly'), agreedToGuidelines: z.literal(true) }) diff --git a/tests/server/api/helcim-customer.test.js b/tests/server/api/helcim-customer.test.js index 2aa6ae3..e6a0102 100644 --- a/tests/server/api/helcim-customer.test.js +++ b/tests/server/api/helcim-customer.test.js @@ -385,4 +385,67 @@ describe('POST /api/helcim/customer', () => { expect(setAuthCookie).not.toHaveBeenCalled() }) }) + + // Regression: a joiner who picks annual but abandons before + // /api/helcim/subscription runs must already have billingCadence persisted, + // otherwise contributionAmount (cadence-unit, e.g. 180) renders as "$180/mo". + describe('billingCadence persistence', () => { + it('persists billingCadence "annual" when a new member signs up annual', async () => { + Member.findOne.mockResolvedValue(null) + const event = build({ + body: { + name: 'Annual Joiner', + email: 'annual@example.com', + circle: 'community', + contributionAmount: 180, + cadence: 'annual', + agreedToGuidelines: true + } + }) + await customerHandler(event) + expect(Member.create).toHaveBeenCalledWith( + expect.objectContaining({ contributionAmount: 180, billingCadence: 'annual' }) + ) + }) + + it('persists billingCadence "annual" when upgrading a guest who signs up annual', async () => { + Member.findOne.mockResolvedValue({ + _id: 'guest-annual', + email: 'guest@example.com', + status: 'guest' + }) + const event = build({ + body: { + name: 'Guest Annual', + email: 'guest@example.com', + circle: 'founder', + contributionAmount: 180, + cadence: 'annual', + agreedToGuidelines: true + } + }) + await customerHandler(event) + expect(Member.create).not.toHaveBeenCalled() + const [, updatePayload] = Member.findByIdAndUpdate.mock.calls[0] + expect(updatePayload.$set).toMatchObject({ contributionAmount: 180, billingCadence: 'annual' }) + }) + + it('forces billingCadence "monthly" for a $0 signup even when cadence is annual', async () => { + Member.findOne.mockResolvedValue(null) + const event = build({ + body: { + name: 'Free Joiner', + email: 'free@example.com', + circle: 'community', + contributionAmount: 0, + cadence: 'annual', + agreedToGuidelines: true + } + }) + await customerHandler(event) + expect(Member.create).toHaveBeenCalledWith( + expect.objectContaining({ contributionAmount: 0, billingCadence: 'monthly' }) + ) + }) + }) })