Replaces the per-file inviteToSlack helpers with a single auto-flag call. Self-serve activation paths now check for pre-existing workspace membership (silent on miss) instead of attempting an admin-only invite. - helcim/subscription.post.js: removed local inviteToSlack; both free- and paid-tier activation branches now call the helper, then notifyNewMember with the canonical 'manual_invitation_required' arg. - members/create.post.js: same shape — helper + canonical notify arg. - invite/accept.post.js (free-tier branch): added the helper call after member creation. Free-tier had no prior Slack call (audit confirmed); paid-tier remains untouched and activates via the Helcim webhook. Admin-created and CSV-imported members intentionally do NOT call the helper — admins flip the flag manually after sending the invite. Test stub for autoFlagPreExistingSlackAccess added to server setup.
194 lines
No EOL
6.6 KiB
JavaScript
194 lines
No EOL
6.6 KiB
JavaScript
// Create a Helcim subscription
|
|
import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
|
|
import Member from '../../models/member.js'
|
|
import { connectDB } from '../../utils/mongoose.js'
|
|
import { getSlackService } from '../../utils/slack.ts'
|
|
import { requireAuth, getPaymentBridgeMember } from '../../utils/auth.js'
|
|
import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
|
import { sendWelcomeEmail } from '../../utils/resend.js'
|
|
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
try {
|
|
// Membership signup completes subscription before email verify; allow the
|
|
// payment-bridge cookie set by /api/helcim/customer to satisfy auth here.
|
|
const bridgeMember = await getPaymentBridgeMember(event)
|
|
if (!bridgeMember) {
|
|
await requireAuth(event)
|
|
}
|
|
await connectDB()
|
|
const body = await validateBody(event, helcimSubscriptionSchema)
|
|
|
|
// Check if payment is required
|
|
if (!requiresPayment(body.contributionAmount)) {
|
|
// For free tier, atomically capture pre-update status alongside the write.
|
|
// Welcome email only fires on pending_payment → active transitions, not
|
|
// on tier upgrades (active → active).
|
|
const preMember = await Member.findOneAndUpdate(
|
|
{ helcimCustomerId: body.customerId },
|
|
{
|
|
status: 'active',
|
|
contributionAmount: body.contributionAmount,
|
|
subscriptionStartDate: new Date()
|
|
},
|
|
{ new: false, projection: { status: 1 } }
|
|
)
|
|
const isFirstActivation = preMember?.status === 'pending_payment'
|
|
const member = await Member.findById(preMember._id)
|
|
|
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
|
|
|
await autoFlagPreExistingSlackAccess(member)
|
|
try {
|
|
const slackService = getSlackService()
|
|
if (slackService) {
|
|
await slackService.notifyNewMember(
|
|
member.name,
|
|
member.email,
|
|
member.circle,
|
|
member.contributionAmount,
|
|
'manual_invitation_required'
|
|
)
|
|
}
|
|
} catch (err) {
|
|
console.error('[slack] notifyNewMember failed:', err)
|
|
}
|
|
if (isFirstActivation) await sendWelcomeEmail(member)
|
|
|
|
return {
|
|
success: true,
|
|
subscription: null,
|
|
member: {
|
|
id: member._id,
|
|
email: member.email,
|
|
name: member.name,
|
|
circle: member.circle,
|
|
contributionAmount: member.contributionAmount,
|
|
status: member.status
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate card token is provided
|
|
if (!body.cardToken) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Payment information is required for a paid contribution'
|
|
})
|
|
}
|
|
|
|
const cadence = body.cadence
|
|
const paymentPlanId = getHelcimPlanId(cadence)
|
|
const recurringAmount = cadence === 'annual'
|
|
? body.contributionAmount * 12
|
|
: body.contributionAmount
|
|
|
|
if (!paymentPlanId) {
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: cadence === 'annual'
|
|
? 'Annual plan id not configured'
|
|
: 'Monthly plan id not configured',
|
|
})
|
|
}
|
|
|
|
const idempotencyKey = generateIdempotencyKey()
|
|
|
|
const subscriptionPayload = {
|
|
dateActivated: new Date().toISOString().split('T')[0],
|
|
paymentPlanId: parseInt(paymentPlanId),
|
|
customerCode: body.customerCode,
|
|
recurringAmount,
|
|
paymentMethod: 'card',
|
|
}
|
|
|
|
const subscriptionData = await createHelcimSubscription(subscriptionPayload, idempotencyKey)
|
|
|
|
// Extract the first subscription from the response array
|
|
const subscription = subscriptionData.data?.[0]
|
|
if (!subscription) {
|
|
throw createError({ statusCode: 500, statusMessage: 'Subscription creation failed' })
|
|
}
|
|
|
|
const rawNextBilling = subscription.dateBilling || subscription.nextBillingDate || null
|
|
const nextBillingDate = rawNextBilling ? new Date(rawNextBilling) : null
|
|
|
|
// Atomically capture pre-update status alongside the write so we can
|
|
// detect the pending_payment → active transition without a separate read
|
|
// (which would race with concurrent webhooks/double-clicks).
|
|
const preMember = await Member.findOneAndUpdate(
|
|
{ helcimCustomerId: body.customerId },
|
|
{ $set: {
|
|
contributionAmount: body.contributionAmount,
|
|
helcimSubscriptionId: subscription.id,
|
|
helcimCustomerId: body.customerId,
|
|
paymentMethod: 'card',
|
|
billingCadence: cadence,
|
|
subscriptionStartDate: new Date(),
|
|
status: 'active',
|
|
...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime())
|
|
? { nextBillingDate }
|
|
: {}),
|
|
} },
|
|
{ new: false, runValidators: false, projection: { status: 1 } }
|
|
)
|
|
const isFirstActivation = preMember?.status === 'pending_payment'
|
|
const member = await Member.findById(preMember._id)
|
|
|
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
|
|
|
try {
|
|
const txs = await listHelcimCustomerTransactions(body.customerCode)
|
|
const latestPaid = txs.find((t) => t.status === 'paid')
|
|
if (latestPaid) {
|
|
await upsertPaymentFromHelcim(member, latestPaid, {
|
|
paymentType: cadence,
|
|
sendConfirmation: true
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('[payments] initial charge log failed, will be picked up by reconciliation:', err?.message || err)
|
|
}
|
|
|
|
await autoFlagPreExistingSlackAccess(member)
|
|
try {
|
|
const slackService = getSlackService()
|
|
if (slackService) {
|
|
await slackService.notifyNewMember(
|
|
member.name,
|
|
member.email,
|
|
member.circle,
|
|
member.contributionAmount,
|
|
'manual_invitation_required'
|
|
)
|
|
}
|
|
} catch (err) {
|
|
console.error('[slack] notifyNewMember failed:', err)
|
|
}
|
|
if (isFirstActivation) await sendWelcomeEmail(member)
|
|
|
|
return {
|
|
success: true,
|
|
subscription: {
|
|
subscriptionId: subscription.id,
|
|
status: subscription.status,
|
|
nextBillingDate: rawNextBilling
|
|
},
|
|
member: {
|
|
id: member._id,
|
|
email: member.email,
|
|
name: member.name,
|
|
circle: member.circle,
|
|
contributionAmount: member.contributionAmount,
|
|
status: member.status
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error.statusCode) throw error
|
|
console.error('Error creating Helcim subscription:', error)
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'An unexpected error occurred'
|
|
})
|
|
}
|
|
}) |