diff --git a/server/api/helcim/get-or-create-customer.post.js b/server/api/helcim/get-or-create-customer.post.js index 7264246..a23ac35 100644 --- a/server/api/helcim/get-or-create-customer.post.js +++ b/server/api/helcim/get-or-create-customer.post.js @@ -1,17 +1,47 @@ // Get existing or create new Helcim customer (for upgrading members) +import Member from '../../models/member.js' import { requireAuth } from '../../utils/auth.js' -import { findHelcimCustomerByEmail, createHelcimCustomer } from '../../utils/helcim.js' +import { + getHelcimCustomer, + findHelcimCustomerByEmail, + createHelcimCustomer +} from '../../utils/helcim.js' export default defineEventHandler(async (event) => { try { const member = await requireAuth(event) - // First, try to find an existing customer + // 1. Short-circuit: member already has a Helcim customer ID on file. + // Verify it still resolves before falling through — otherwise we'd + // silently orphan the old customer and create a duplicate. + if (member.helcimCustomerId) { + try { + const customer = await getHelcimCustomer(member.helcimCustomerId) + if (customer?.id) { + return { + success: true, + customerId: customer.id, + customerCode: customer.customerCode, + existing: true + } + } + } catch (err) { + // Only fall through on 404 (customer was deleted in Helcim). + // Any other error must propagate — silently recreating was the + // original duplicate-customer bug. + if (err?.statusCode !== 404) throw err + } + } + + // 2. Fall back to search by email (case-insensitive match). let existingCustomer = null try { const searchData = await findHelcimCustomerByEmail(member.email) - if (searchData.customers && searchData.customers.length > 0) { - existingCustomer = searchData.customers.find(c => c.email === member.email) || null + const memberEmail = member.email.toLowerCase() + if (searchData.customers?.length) { + existingCustomer = searchData.customers.find( + c => c.email?.toLowerCase() === memberEmail + ) || null } } catch (searchError) { console.error('Error searching for customer:', searchError) @@ -20,8 +50,11 @@ export default defineEventHandler(async (event) => { if (existingCustomer) { if (!member.helcimCustomerId) { - member.helcimCustomerId = existingCustomer.id - await member.save() + await Member.findByIdAndUpdate( + member._id, + { $set: { helcimCustomerId: existingCustomer.id } }, + { runValidators: false } + ) } return { success: true, @@ -31,15 +64,18 @@ export default defineEventHandler(async (event) => { } } - // No existing customer found — create one + // 3. No match anywhere — create a fresh customer. const customerData = await createHelcimCustomer({ contactName: member.name, businessName: member.name, email: member.email }) - member.helcimCustomerId = customerData.id - await member.save() + await Member.findByIdAndUpdate( + member._id, + { $set: { helcimCustomerId: customerData.id } }, + { runValidators: false } + ) return { success: true, diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index c4faba2..76f9faf 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -5,6 +5,7 @@ import { connectDB } from '../../utils/mongoose.js' import { getSlackService } from '../../utils/slack.ts' import { requireAuth } from '../../utils/auth.js' import { createHelcimSubscription, generateIdempotencyKey } from '../../utils/helcim.js' +import { sendWelcomeEmail } from '../../utils/resend.js' // Function to invite member to Slack async function inviteToSlack(member) { @@ -23,20 +24,23 @@ async function inviteToSlack(member) { ) if (inviteResult.success) { - // Update member record based on the actual result - if (inviteResult.status === 'existing_user_added_to_channel' || - inviteResult.status === 'user_already_in_channel' || + const update = {} + if (inviteResult.status === 'existing_user_added_to_channel' || + inviteResult.status === 'user_already_in_channel' || inviteResult.status === 'new_user_invited_to_workspace') { - member.slackInviteStatus = 'sent' - member.slackUserId = inviteResult.userId - member.slackInvited = true + update.slackInviteStatus = 'sent' + update.slackUserId = inviteResult.userId + update.slackInvited = true } else { - // Manual invitation required - member.slackInviteStatus = 'pending' - member.slackInvited = false + update.slackInviteStatus = 'pending' + update.slackInvited = false } - await member.save() - + await Member.findByIdAndUpdate( + member._id, + { $set: update }, + { runValidators: false } + ) + // Send notification to vetting channel await slackService.notifyNewMember( member.name, @@ -45,23 +49,27 @@ async function inviteToSlack(member) { member.contributionTier, inviteResult.status ) - + console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`) } else { - // Update member record to reflect failed invitation - member.slackInviteStatus = 'failed' - await member.save() - + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'failed' } }, + { runValidators: false } + ) + console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`) // Don't throw error - subscription creation should still succeed } } catch (error) { console.error('Error during Slack invitation process:', error) - - // Update member record to reflect failed invitation + try { - member.slackInviteStatus = 'failed' - await member.save() + await Member.findByIdAndUpdate( + member._id, + { $set: { slackInviteStatus: 'failed' } }, + { runValidators: false } + ) } catch (saveError) { console.error('Failed to update member Slack status:', saveError) } @@ -76,6 +84,14 @@ export default defineEventHandler(async (event) => { await connectDB() const body = await validateBody(event, helcimSubscriptionSchema) + // Only send welcome email when a member transitions from pending_payment + // to active for the first time — not on tier upgrades (active → active). + const priorMember = await Member.findOne( + { helcimCustomerId: body.customerId }, + { status: 1 } + ) + const isFirstActivation = priorMember?.status === 'pending_payment' + // Check if payment is required if (!requiresPayment(body.contributionTier)) { // For free tier, just update member status @@ -91,8 +107,8 @@ export default defineEventHandler(async (event) => { logActivity(member._id, 'subscription_created', { tier: body.contributionTier }) - // Send Slack invitation for free tier members await inviteToSlack(member) + if (isFirstActivation) await sendWelcomeEmail(member) return { success: true, @@ -134,8 +150,8 @@ export default defineEventHandler(async (event) => { { new: true } ) - // Send Slack invitation even when no plan is configured await inviteToSlack(member) + if (isFirstActivation) await sendWelcomeEmail(member) return { success: true, @@ -194,8 +210,8 @@ export default defineEventHandler(async (event) => { logActivity(member._id, 'subscription_created', { tier: body.contributionTier }) - // Send Slack invitation for paid tier members await inviteToSlack(member) + if (isFirstActivation) await sendWelcomeEmail(member) return { success: true, @@ -236,8 +252,8 @@ export default defineEventHandler(async (event) => { { new: true } ) - // Send Slack invitation even when subscription setup fails await inviteToSlack(member) + if (isFirstActivation) await sendWelcomeEmail(member) return { success: true, diff --git a/server/utils/helcim.js b/server/utils/helcim.js index dac997a..6cb068f 100644 --- a/server/utils/helcim.js +++ b/server/utils/helcim.js @@ -97,7 +97,11 @@ export const cancelHelcimSubscription = (id) => helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' }) export const updateHelcimSubscription = (id, payload) => - helcimFetch(`/subscriptions/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Subscription update failed' }) + helcimFetch('/subscriptions', { + method: 'PATCH', + body: { subscriptions: [{ id: String(id), ...payload }] }, + errorMessage: 'Subscription update failed' + }) // ---- Payment plans (admin) ---- diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js index 6fc36d7..ee4577c 100644 --- a/tests/server/api/helcim-subscription.test.js +++ b/tests/server/api/helcim-subscription.test.js @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Member from '../../../server/models/member.js' +import { requireAuth } from '../../../server/utils/auth.js' +import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js' +import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + vi.mock('../../../server/models/member.js', () => ({ - default: { findOneAndUpdate: vi.fn() } + default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() })) @@ -13,12 +19,9 @@ vi.mock('../../../server/config/contributions.js', () => ({ getHelcimPlanId: vi.fn(), getContributionTierByValue: vi.fn() })) - -import Member from '../../../server/models/member.js' -import { requireAuth } from '../../../server/utils/auth.js' -import { requiresPayment, getHelcimPlanId, getContributionTierByValue } from '../../../server/config/contributions.js' -import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' -import { createMockEvent } from '../helpers/createMockEvent.js' +vi.mock('../../../server/utils/resend.js', () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) +})) // helcimSubscriptionSchema is a Nitro auto-import used by validateBody vi.stubGlobal('helcimSubscriptionSchema', {})