Compare commits

..

2 commits

Author SHA1 Message Date
426f233ccd fix(join): persist billingCadence on /join signup
All checks were successful
Test / vitest (push) Successful in 11m8s
Test / playwright (push) Successful in 16m14s
Test / Notify on failure (push) Has been skipped
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.
2026-05-24 15:30:31 +01:00
c3b1c59779 fix(invite): persist billingCadence at invite-accept time
Annual-choosing invitees who abandoned between accept and payment were left at billingCadence:'monthly' (the model default) while contributionAmount held an annual-unit value, rendering $180/mo in admin views. Persist the chosen cadence at Member.create time.

accept-invite.vue now sends cadence in the accept POST body; inviteAcceptSchema accepts cadence (defaults 'monthly'); accept.post.js sets billingCadence on create, forced to 'monthly' for $0 members since a free member has no billing relationship.
2026-05-24 15:21:34 +01:00
7 changed files with 129 additions and 0 deletions

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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',

View file

@ -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,

View file

@ -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)
})
@ -363,6 +364,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)
})

View file

@ -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' })
)
})
})

View file

@ -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' })
)
})
})
})