Compare commits

...

9 commits

34 changed files with 1163 additions and 339 deletions

View file

@ -25,17 +25,45 @@ export const useMemberPayment = () => {
paymentSuccess.value = false paymentSuccess.value = false
try { try {
// Skip HelcimPay verify if a card's already on file — Helcim refuses // Fast-path: when both Helcim ids are already cached on the member doc
// to re-save it, breaking retries after a partial-failed signup. // AND a card's on file, we can skip the paid getOrCreateCustomer round
const [, existing] = await Promise.all([ // trip entirely and go straight to subscription creation.
getOrCreateCustomer(), const hasCachedHelcimIds = Boolean(
$fetch('/api/helcim/existing-card').catch((err) => { memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
)
let existing = null
let probedExistingCard = false
let cardToken = null
if (hasCachedHelcimIds) {
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err) console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
return null return null
}), })
]) probedExistingCard = true
if (existing?.cardToken) {
customerId.value = memberData.value.helcimCustomerId
customerCode.value = memberData.value.helcimCustomerCode
cardToken = existing.cardToken
}
}
let cardToken = existing?.cardToken || null if (!cardToken) {
// Skip HelcimPay verify if a card's already on file — Helcim refuses
// to re-save it, breaking retries after a partial-failed signup.
const [, existingFromFull] = await Promise.all([
getOrCreateCustomer(),
probedExistingCard
? Promise.resolve(existing)
: $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
return null
}),
])
cardToken = existingFromFull?.cardToken || null
}
if (!cardToken) { if (!cardToken) {
await initializeHelcimPay( await initializeHelcimPay(

View file

@ -133,9 +133,8 @@ const filterOptions = [
const { data: eventsData } = await useFetch("/api/events"); const { data: eventsData } = await useFetch("/api/events");
const { data: seriesData } = await useFetch("/api/series"); const { data: seriesData } = await useFetch("/api/series");
const now = new Date();
const filteredEvents = computed(() => { const filteredEvents = computed(() => {
const now = new Date();
if (!eventsData.value) return []; if (!eventsData.value) return [];
return eventsData.value.filter((event) => { return eventsData.value.filter((event) => {
if (!includePastEvents.value && new Date(event.startDate) < now) if (!includePastEvents.value && new Date(event.startDate) < now)

View file

@ -85,21 +85,46 @@ const initialize = async () => {
} }
try { try {
// Skip HelcimPay verify if a card's already on file Helcim refuses // Fast-path: when both Helcim ids are already cached on the member doc
// to re-save it, breaking retries after a partial-failed signup. // AND a card's on file, skip the paid get-or-create-customer round trip.
const [customer, existing] = await Promise.all([ const hasCachedHelcimIds = Boolean(
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }), memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode
$fetch('/api/helcim/existing-card').catch((err) => { );
let existing = null;
let probedExistingCard = false;
if (hasCachedHelcimIds) {
existing = await $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err); console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
return null; return null;
}), });
]); probedExistingCard = true;
customerId.value = customer.customerId; if (existing?.cardToken) {
customerCode.value = customer.customerCode; customerId.value = memberData.value.helcimCustomerId;
hasExistingCard.value = Boolean(existing?.cardToken); customerCode.value = memberData.value.helcimCustomerCode;
hasExistingCard.value = true;
}
}
if (!hasExistingCard.value) { if (!hasExistingCard.value) {
await initializeHelcimPay(customerId.value, customerCode.value, 0); // Skip HelcimPay verify if a card's already on file Helcim refuses
// to re-save it, breaking retries after a partial-failed signup.
const [customer, existingFromFull] = await Promise.all([
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }),
probedExistingCard
? Promise.resolve(existing)
: $fetch('/api/helcim/existing-card').catch((err) => {
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
return null;
}),
]);
customerId.value = customer.customerId;
customerCode.value = customer.customerCode;
hasExistingCard.value = Boolean(existingFromFull?.cardToken);
if (!hasExistingCard.value) {
await initializeHelcimPay(customerId.value, customerCode.value, 0);
}
} }
step.value = 'ready'; step.value = 'ready';
} catch (err) { } catch (err) {

View file

@ -1,18 +1,32 @@
// server/api/auth/login.post.js // server/api/auth/login.post.js
import { getRequestIP } from "h3";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import { validateBody } from "../../utils/validateBody.js"; import { validateBody } from "../../utils/validateBody.js";
import { emailSchema } from "../../utils/schemas.js"; import { emailSchema } from "../../utils/schemas.js";
import { sendMagicLink } from "../../utils/magicLink.js"; import { sendMagicLink } from "../../utils/magicLink.js";
import { rateLimit } from "../../utils/rateLimit.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true }) || "unknown";
if (!rateLimit(`auth:login:ip:${ip}`, { max: 5, windowMs: 3600_000 })) {
throw createError({ statusCode: 429, statusMessage: "Too many login attempts" });
}
await connectDB(); await connectDB();
const { email } = await validateBody(event, emailSchema); const body = await validateBody(event, emailSchema);
if (!rateLimit(`auth:login:email:${body.email}`, { max: 3, windowMs: 3600_000 })) {
throw createError({
statusCode: 429,
statusMessage: "Too many login attempts for this email",
});
}
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link."; const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
try { try {
await sendMagicLink(email); await sendMagicLink(body.email);
return { return {
success: true, success: true,
message: GENERIC_MESSAGE, message: GENERIC_MESSAGE,

View file

@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
contributionAmount: member.contributionAmount, contributionAmount: member.contributionAmount,
billingCadence: member.billingCadence, billingCadence: member.billingCadence,
helcimCustomerId: member.helcimCustomerId, helcimCustomerId: member.helcimCustomerId,
helcimCustomerCode: member.helcimCustomerCode,
nextBillingDate: member.nextBillingDate, nextBillingDate: member.nextBillingDate,
membershipLevel: `${member.circle}-${member.contributionAmount}`, membershipLevel: `${member.circle}-${member.contributionAmount}`,
// Profile fields // Profile fields

View file

@ -1,11 +1,18 @@
// server/api/auth/verify.post.js // server/api/auth/verify.post.js
import { getRequestIP } from 'h3'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { validateBody } from '../../utils/validateBody.js' import { validateBody } from '../../utils/validateBody.js'
import { verifyMagicLinkSchema } from '../../utils/schemas.js' import { verifyMagicLinkSchema } from '../../utils/schemas.js'
import { setAuthCookie } from '../../utils/auth.js' import { setAuthCookie } from '../../utils/auth.js'
import { rateLimit } from '../../utils/rateLimit.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
if (!rateLimit(`auth:verify:ip:${ip}`, { max: 5, windowMs: 3600_000 })) {
throw createError({ statusCode: 429, statusMessage: 'Too many verification attempts' })
}
const { token } = await validateBody(event, verifyMagicLinkSchema) const { token } = await validateBody(event, verifyMagicLinkSchema)
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)

View file

@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
circle: body.circle, circle: body.circle,
contributionAmount: body.contributionAmount, contributionAmount: body.contributionAmount,
helcimCustomerId: customerData.id, helcimCustomerId: customerData.id,
helcimCustomerCode: customerData.customerCode,
status: 'pending_payment', status: 'pending_payment',
'agreement.acceptedAt': new Date() 'agreement.acceptedAt': new Date()
} }
@ -75,6 +76,7 @@ export default defineEventHandler(async (event) => {
circle: body.circle, circle: body.circle,
contributionAmount: body.contributionAmount, contributionAmount: body.contributionAmount,
helcimCustomerId: customerData.id, helcimCustomerId: customerData.id,
helcimCustomerCode: customerData.customerCode,
status: 'pending_payment', status: 'pending_payment',
agreement: { acceptedAt: new Date() } agreement: { acceptedAt: new Date() }
}) })

View file

@ -9,10 +9,7 @@ export default defineEventHandler(async (event) => {
return { cardToken: null } return { cardToken: null }
} }
const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId) const cards = await listHelcimCustomerCards(member.helcimCustomerId)
const cards = Array.isArray(cardsResponse)
? cardsResponse
: (cardsResponse?.cards || cardsResponse?.data || [])
if (!cards.length) { if (!cards.length) {
return { cardToken: null } return { cardToken: null }

View file

@ -18,6 +18,13 @@ export default defineEventHandler(async (event) => {
try { try {
const customer = await getHelcimCustomer(member.helcimCustomerId) const customer = await getHelcimCustomer(member.helcimCustomerId)
if (customer?.id) { if (customer?.id) {
if (!member.helcimCustomerCode && customer.customerCode) {
await Member.findByIdAndUpdate(
member._id,
{ $set: { helcimCustomerCode: customer.customerCode } },
{ runValidators: false }
)
}
return { return {
success: true, success: true,
customerId: customer.id, customerId: customer.id,
@ -49,10 +56,13 @@ export default defineEventHandler(async (event) => {
} }
if (existingCustomer) { if (existingCustomer) {
if (!member.helcimCustomerId) { if (!member.helcimCustomerId || !member.helcimCustomerCode) {
await Member.findByIdAndUpdate( await Member.findByIdAndUpdate(
member._id, member._id,
{ $set: { helcimCustomerId: existingCustomer.id } }, { $set: {
helcimCustomerId: existingCustomer.id,
helcimCustomerCode: existingCustomer.customerCode
} },
{ runValidators: false } { runValidators: false }
) )
} }
@ -73,7 +83,10 @@ export default defineEventHandler(async (event) => {
await Member.findByIdAndUpdate( await Member.findByIdAndUpdate(
member._id, member._id,
{ $set: { helcimCustomerId: customerData.id } }, { $set: {
helcimCustomerId: customerData.id,
helcimCustomerCode: customerData.customerCode
} },
{ runValidators: false } { runValidators: false }
) )

View file

@ -1,6 +1,6 @@
import Member from '../../models/member.js' import Member from '../../models/member.js'
import Series from '../../models/series.js'
import { loadPublicEvent } from '../../utils/loadEvent.js' import { loadPublicEvent } from '../../utils/loadEvent.js'
import { loadPublicSeries } from '../../utils/loadSeries.js'
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js' import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js' import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
import { initializeHelcimPaySession } from '../../utils/helcim.js' import { initializeHelcimPaySession } from '../../utils/helcim.js'
@ -10,10 +10,10 @@ export default defineEventHandler(async (event) => {
const body = await validateBody(event, helcimInitializePaymentSchema) const body = await validateBody(event, helcimInitializePaymentSchema)
const metaType = body.metadata?.type const metaType = body.metadata?.type
const isEventTicket = metaType === 'event_ticket' const isEventTicket = metaType === PAYMENT_METADATA_TYPES.EVENT_TICKET
const isSeriesTicket = metaType === 'series_ticket' const isSeriesTicket = metaType === PAYMENT_METADATA_TYPES.SERIES_TICKET
const isTicket = isEventTicket || isSeriesTicket const isTicket = isEventTicket || isSeriesTicket
const isMembershipSignup = metaType === 'membership_signup' const isMembershipSignup = metaType === PAYMENT_METADATA_TYPES.MEMBERSHIP_SIGNUP
if (!isTicket) { if (!isTicket) {
if (isMembershipSignup) { if (isMembershipSignup) {
@ -55,14 +55,7 @@ export default defineEventHandler(async (event) => {
if (!seriesId) { if (!seriesId) {
throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' }) throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' })
} }
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId) const series = await loadPublicSeries(event, seriesId)
const seriesQuery = isObjectId
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
: { $or: [{ id: seriesId }, { slug: seriesId }] }
const series = await Series.findOne(seriesQuery)
if (!series) {
throw createError({ statusCode: 404, statusMessage: 'Series not found' })
}
const ticketInfo = calculateSeriesTicketPrice(series, accessMember) const ticketInfo = calculateSeriesTicketPrice(series, accessMember)
if (!ticketInfo) { if (!ticketInfo) {
throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' }) throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' })

View file

@ -90,26 +90,22 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
const body = await validateBody(event, helcimSubscriptionSchema) 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 // Check if payment is required
if (!requiresPayment(body.contributionAmount)) { if (!requiresPayment(body.contributionAmount)) {
// For free tier, just update member status // For free tier, atomically capture pre-update status alongside the write.
const member = await Member.findOneAndUpdate( // Welcome email only fires on pending_payment → active transitions, not
// on tier upgrades (active → active).
const preMember = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId }, { helcimCustomerId: body.customerId },
{ {
status: 'active', status: 'active',
contributionAmount: body.contributionAmount, contributionAmount: body.contributionAmount,
subscriptionStartDate: new Date() subscriptionStartDate: new Date()
}, },
{ new: true } { 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 }) logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
@ -175,8 +171,10 @@ export default defineEventHandler(async (event) => {
? new Date(subscription.nextBillingDate) ? new Date(subscription.nextBillingDate)
: null : null
// Update member in database // Atomically capture pre-update status alongside the write so we can
const member = await Member.findOneAndUpdate( // 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 }, { helcimCustomerId: body.customerId },
{ $set: { { $set: {
contributionAmount: body.contributionAmount, contributionAmount: body.contributionAmount,
@ -190,8 +188,10 @@ export default defineEventHandler(async (event) => {
? { nextBillingDate } ? { nextBillingDate }
: {}), : {}),
} }, } },
{ new: true, runValidators: false } { 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 }) logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })

View file

@ -45,10 +45,7 @@ export default defineEventHandler(async (event) => {
const { cardToken } = body const { cardToken } = body
// Step 3: verify the submitted token is attached to this member's customer // Step 3: verify the submitted token is attached to this member's customer
const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId) const cards = await listHelcimCustomerCards(member.helcimCustomerId)
const cards = Array.isArray(cardsResponse)
? cardsResponse
: (cardsResponse?.cards || cardsResponse?.data || [])
const matchingCard = cards.find((c) => c?.cardToken === cardToken) const matchingCard = cards.find((c) => c?.cardToken === cardToken)
if (!matchingCard) { if (!matchingCard) {

View file

@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
const cards = await listHelcimCustomerCards(body.customerId) const cards = await listHelcimCustomerCards(body.customerId)
// Verify the card token exists for this customer // Verify the card token exists for this customer
const cardExists = Array.isArray(cards) && cards.some(card => const cardExists = cards.some(card =>
card.cardToken === body.cardToken card.cardToken === body.cardToken
) )

View file

@ -8,7 +8,7 @@
*/ */
import Member from '../../models/member.js' import Member from '../../models/member.js'
import Payment from '../../models/payment.js' import Payment from '../../models/payment.js'
import { listHelcimCustomerTransactions } from '../../utils/helcim.js' import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../utils/helcim.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { upsertPaymentFromHelcim } from '../../utils/payments.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js'
@ -56,7 +56,7 @@ export default defineEventHandler(async (event) => {
const members = await Member.find( const members = await Member.find(
{ helcimCustomerId: { $exists: true, $ne: null } }, { helcimCustomerId: { $exists: true, $ne: null } },
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 } { _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 }
).lean() ).lean()
let txExamined = 0 let txExamined = 0
@ -65,37 +65,75 @@ export default defineEventHandler(async (event) => {
let skipped = 0 let skipped = 0
let memberErrors = 0 let memberErrors = 0
for (const member of members) { async function processMember(member) {
// Opportunistic backfill: members predating the helcimCustomerCode field
// get it filled in here so the daily cron acts as the migration. Only on
// the missing path — no overwrite, no extra API call once populated.
if (!member.helcimCustomerCode) {
try {
const customer = await getHelcimCustomer(member.helcimCustomerId)
if (customer?.customerCode) {
await Member.findByIdAndUpdate(
member._id,
{ $set: { helcimCustomerCode: customer.customerCode } },
{ runValidators: false }
)
}
} catch (err) {
// Backfill is best-effort — never fail the reconcile run on it.
console.warn(`[reconcile] customerCode backfill failed for member=${member._id}: ${err?.message || err}`)
}
}
let txs let txs
try { try {
txs = await listTransactionsWithRetry(member.helcimCustomerId) txs = await listTransactionsWithRetry(member.helcimCustomerId)
} catch (err) { } catch (err) {
memberErrors++
console.error(`[reconcile] member=${member._id}: ${err?.message || err}`) console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
continue return { error: true }
} }
const result = { error: false, txExamined: 0, created: 0, existed: 0, skipped: 0 }
for (const tx of txs) { for (const tx of txs) {
txExamined++ result.txExamined++
if (!RECONCILABLE_STATUSES.has(tx?.status)) { if (!RECONCILABLE_STATUSES.has(tx?.status)) {
skipped++ result.skipped++
continue continue
} }
if (!apply) { if (!apply) {
const existing = await Payment.findOne({ helcimTransactionId: tx.id }) const existing = await Payment.findOne({ helcimTransactionId: tx.id })
if (existing) existed++ if (existing) result.existed++
else created++ else result.created++
continue continue
} }
// Note: deliberately NOT passing sendConfirmation — cron back-fills must // Note: deliberately NOT passing sendConfirmation — cron back-fills must
// not re-send confirmation emails for transactions the member has already // not re-send confirmation emails for transactions the member has already
// been notified about (or that pre-date Mongo Payment tracking entirely). // been notified about (or that pre-date Mongo Payment tracking entirely).
const result = await upsertPaymentFromHelcim(member, tx) const upsertResult = await upsertPaymentFromHelcim(member, tx)
if (result.created) created++ if (upsertResult.created) result.created++
else if (result.payment) existed++ else if (upsertResult.payment) result.existed++
else skipped++ else result.skipped++
}
return result
}
const CHUNK_SIZE = 8
for (let i = 0; i < members.length; i += CHUNK_SIZE) {
const chunk = members.slice(i, i + CHUNK_SIZE)
const results = await Promise.all(chunk.map((m) => processMember(m)))
for (const r of results) {
if (r.error) {
memberErrors++
continue
}
txExamined += r.txExamined
created += r.created
existed += r.existed
skipped += r.skipped
} }
} }

View file

@ -61,6 +61,7 @@ export default defineEventHandler(async (event) => {
bio: body.motivation || undefined, bio: body.motivation || undefined,
status: body.contributionAmount === 0 ? 'active' : 'pending_payment', status: body.contributionAmount === 0 ? 'active' : 'pending_payment',
helcimCustomerId: helcimCustomer?.id, helcimCustomerId: helcimCustomer?.id,
helcimCustomerCode: helcimCustomer?.customerCode,
agreement: { acceptedAt: new Date() }, agreement: { acceptedAt: new Date() },
}) })

View file

@ -1,5 +1,5 @@
import Event from "../../models/event.js"; import Event from "../../models/event.js";
import Series from "../../models/series.js"; import { loadPublicSeries } from "../../utils/loadSeries.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -15,16 +15,14 @@ export default defineEventHandler(async (event) => {
}); });
} }
// Try to fetch the Series model first for full ticketing info // Try to fetch the Series model first for full ticketing info.
// Build query conditions based on whether id looks like ObjectId or string // Legacy series may exist only as event metadata (no Series doc), so we
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id); // fall through to the events-based path below when no Series doc matches.
const seriesQuery = isObjectId const seriesModel = await loadPublicSeries(event, id, {
? { $or: [{ _id: id }, { id: id }, { slug: id }] } select: "-registrations", // Don't expose registration details
: { $or: [{ id: id }, { slug: id }] }; lean: true,
allowMissing: true,
const seriesModel = await Series.findOne(seriesQuery) });
.select("-registrations") // Don't expose registration details
.lean();
// Fetch all events in this series // Fetch all events in this series
const events = await Event.find({ const events = await Event.find({

View file

@ -1,5 +1,5 @@
import Series from "../../../../models/series.js";
import Member from "../../../../models/member.js"; import Member from "../../../../models/member.js";
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
import { import {
calculateSeriesTicketPrice, calculateSeriesTicketPrice,
checkSeriesTicketAvailability, checkSeriesTicketAvailability,
@ -13,20 +13,7 @@ export default defineEventHandler(async (event) => {
const email = query.email; const email = query.email;
// Fetch series // Fetch series
// Build query conditions based on whether seriesId looks like ObjectId or string const series = await loadPublicSeries(event, seriesId);
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
const seriesQuery = isObjectId
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
: { $or: [{ id: seriesId }, { slug: seriesId }] };
const series = await Series.findOne(seriesQuery);
if (!series) {
throw createError({
statusCode: 404,
statusMessage: "Series not found",
});
}
// Check if tickets are enabled // Check if tickets are enabled
if (!series.tickets?.enabled) { if (!series.tickets?.enabled) {

View file

@ -1,6 +1,6 @@
import Series from "../../../../models/series.js";
import Event from "../../../../models/event.js"; import Event from "../../../../models/event.js";
import Member from "../../../../models/member.js"; import Member from "../../../../models/member.js";
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
import { import {
validateSeriesTicketPurchase, validateSeriesTicketPurchase,
calculateSeriesTicketPrice, calculateSeriesTicketPrice,
@ -19,20 +19,7 @@ export default defineEventHandler(async (event) => {
const { name, email, paymentId } = body; const { name, email, paymentId } = body;
// Fetch series // Fetch series
// Build query conditions based on whether seriesId looks like ObjectId or string const series = await loadPublicSeries(event, seriesId);
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
const seriesQuery = isObjectId
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
: { $or: [{ id: seriesId }, { slug: seriesId }] };
const series = await Series.findOne(seriesQuery);
if (!series) {
throw createError({
statusCode: 404,
statusMessage: "Series not found",
});
}
// Check membership — prefer JWT auth for accurate member pricing. // Check membership — prefer JWT auth for accurate member pricing.
// Only members with access (active or pending_payment) get member-tier // Only members with access (active or pending_payment) get member-tier

View file

@ -42,6 +42,7 @@ const memberSchema = new mongoose.Schema({
default: "pending_payment", default: "pending_payment",
}, },
helcimCustomerId: String, helcimCustomerId: String,
helcimCustomerCode: String,
helcimSubscriptionId: String, helcimSubscriptionId: String,
billingCadence: { billingCadence: {
type: String, type: String,

View file

@ -86,8 +86,10 @@ export const createHelcimCustomer = (payload) =>
export const updateHelcimCustomer = (id, payload) => export const updateHelcimCustomer = (id, payload) =>
helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' }) helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' })
export const listHelcimCustomerCards = (id) => export const listHelcimCustomerCards = async (id) => {
helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' }) const raw = await helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' })
return Array.isArray(raw) ? raw : (raw?.cards || raw?.data || [])
}
/** /**
* Set a customer's default payment method by card token. * Set a customer's default payment method by card token.

View file

@ -0,0 +1,47 @@
import Series from '../models/series.js'
import { connectDB } from './mongoose.js'
/**
* Load a series by ObjectId, string id, or slug for public endpoints.
* Series has three identifier fields (`_id`, `id`, `slug`); this helper
* builds the same conditional `$or` query the call sites would otherwise
* inline. No isVisible gate today (parity with existing call-site behavior).
*
* @param {Object} reqEvent - h3 event (reserved for future auth/cookie access)
* @param {String} identifier - ObjectId string, string id, or slug
* @param {Object} [options]
* @param {Boolean} [options.lean] - apply .lean() to the query
* @param {String} [options.select] - apply .select() to the query
* @param {Boolean} [options.allowMissing] - return null instead of throwing 404 on miss
* @returns {Promise<Object|null>} the series document, or null if allowMissing and not found
*/
export async function loadPublicSeries(reqEvent, identifier, options = {}) {
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: 'Series identifier is required'
})
}
await connectDB()
const { lean = false, select = null, allowMissing = false } = options
const isObjectId = /^[0-9a-fA-F]{24}$/.test(identifier)
const seriesQuery = isObjectId
? { $or: [{ _id: identifier }, { id: identifier }, { slug: identifier }] }
: { $or: [{ id: identifier }, { slug: identifier }] }
let query = Series.findOne(seriesQuery)
if (select) query = query.select(select)
if (lean) query = query.lean()
const series = await query
if (!series) {
if (allowMissing) return null
throw createError({ statusCode: 404, statusMessage: 'Series not found' })
}
return series
}

View file

@ -0,0 +1,15 @@
// Metadata.type values accepted by /api/helcim/initialize-payment.
// Shared by the Zod schema (PAYMENT_METADATA_TYPE_VALUES in z.enum) and the
// route handler so server-side wire validation stays single-sourced. The client
// composable intentionally uses inline string literals — server-side z.enum
// rejects any drift as a 400.
export const PAYMENT_METADATA_TYPES = {
EVENT_TICKET: 'event_ticket',
SERIES_TICKET: 'series_ticket',
SUBSCRIPTION: 'subscription',
CARD_VERIFY: 'card_verify',
MEMBERSHIP_SIGNUP: 'membership_signup'
}
export const PAYMENT_METADATA_TYPE_VALUES = Object.values(PAYMENT_METADATA_TYPES)

View file

@ -1,5 +1,6 @@
import * as z from 'zod' import * as z from 'zod'
import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js' import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js'
import { PAYMENT_METADATA_TYPE_VALUES } from './paymentTypes.js'
export const emailSchema = z.object({ export const emailSchema = z.object({
email: z.string().trim().toLowerCase().email() email: z.string().trim().toLowerCase().email()
@ -71,7 +72,7 @@ export const helcimInitializePaymentSchema = z.object({
amount: z.number().min(0).optional(), amount: z.number().min(0).optional(),
customerCode: z.string().max(200).optional(), customerCode: z.string().max(200).optional(),
metadata: z.object({ metadata: z.object({
type: z.enum(['event_ticket', 'series_ticket', 'subscription', 'card_verify', 'membership_signup']).optional(), type: z.enum(PAYMENT_METADATA_TYPE_VALUES).optional(),
eventTitle: z.string().max(500).optional(), eventTitle: z.string().max(500).optional(),
eventId: z.string().max(200).optional(), eventId: z.string().max(200).optional(),
seriesId: z.string().max(200).optional(), seriesId: z.string().max(200).optional(),

View file

@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { useMemberPayment } from '../../../app/composables/useMemberPayment.js'
// Stub Vue's ref/readonly as Nuxt auto-imports
vi.stubGlobal('ref', ref)
vi.stubGlobal('readonly', (v) => v)
const memberData = ref(null)
const checkMemberStatus = vi.fn()
vi.stubGlobal('useAuth', () => ({ memberData, checkMemberStatus }))
const initializeHelcimPay = vi.fn()
const verifyPayment = vi.fn()
const cleanupHelcimPay = vi.fn()
vi.stubGlobal('useHelcimPay', () => ({
initializeHelcimPay,
verifyPayment,
cleanup: cleanupHelcimPay,
}))
const $fetch = vi.fn()
vi.stubGlobal('$fetch', $fetch)
describe('useMemberPayment.initiatePaymentSetup — shortcut path', () => {
beforeEach(() => {
vi.clearAllMocks()
memberData.value = null
$fetch.mockReset()
})
it('skips getOrCreateCustomer when both helcim ids are cached AND a card is on file', async () => {
memberData.value = {
helcimCustomerId: 'cust-123',
helcimCustomerCode: 'CST-ABC',
contributionAmount: 15,
}
$fetch.mockImplementation((path) => {
if (path === '/api/helcim/existing-card') {
return Promise.resolve({ cardToken: 'tok-xyz' })
}
if (path === '/api/helcim/subscription') {
return Promise.resolve({ success: true })
}
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
})
const { initiatePaymentSetup } = useMemberPayment()
const result = await initiatePaymentSetup()
expect(result.success).toBe(true)
// The whole point: get-or-create-customer was NOT called.
const calledPaths = $fetch.mock.calls.map((c) => c[0])
expect(calledPaths).not.toContain('/api/helcim/get-or-create-customer')
expect(calledPaths).not.toContain('/api/helcim/customer-code')
// We did call existing-card (once) and subscription (once).
expect(calledPaths.filter((p) => p === '/api/helcim/existing-card')).toHaveLength(1)
expect(calledPaths.filter((p) => p === '/api/helcim/subscription')).toHaveLength(1)
// HelcimPay modal not opened — card was already on file.
expect(initializeHelcimPay).not.toHaveBeenCalled()
expect(verifyPayment).not.toHaveBeenCalled()
// Subscription called with the cached id/code from memberData.
const subscriptionCall = $fetch.mock.calls.find((c) => c[0] === '/api/helcim/subscription')
expect(subscriptionCall[1].body).toMatchObject({
customerId: 'cust-123',
customerCode: 'CST-ABC',
cardToken: 'tok-xyz',
contributionAmount: 15,
})
})
it('falls through to get-or-create-customer when helcimCustomerCode is missing', async () => {
memberData.value = {
helcimCustomerId: 'cust-123',
// helcimCustomerCode missing — must NOT take shortcut
contributionAmount: 15,
}
$fetch.mockImplementation((path) => {
if (path === '/api/helcim/customer-code') {
return Promise.resolve({ customerId: 'cust-123', customerCode: 'CST-FRESH' })
}
if (path === '/api/helcim/existing-card') {
return Promise.resolve({ cardToken: 'tok-xyz' })
}
if (path === '/api/helcim/subscription') {
return Promise.resolve({ success: true })
}
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
})
const { initiatePaymentSetup } = useMemberPayment()
await initiatePaymentSetup()
const calledPaths = $fetch.mock.calls.map((c) => c[0])
// Existing helcimCustomerId path -> /api/helcim/customer-code is called.
expect(calledPaths).toContain('/api/helcim/customer-code')
})
it('falls through to get-or-create-customer when no card is on file', async () => {
memberData.value = {
helcimCustomerId: 'cust-123',
helcimCustomerCode: 'CST-ABC',
contributionAmount: 15,
}
initializeHelcimPay.mockResolvedValue(undefined)
verifyPayment.mockResolvedValue({ success: true, cardToken: 'tok-new' })
let existingCardCalls = 0
$fetch.mockImplementation((path) => {
if (path === '/api/helcim/existing-card') {
existingCardCalls++
return Promise.resolve(null) // no card on file
}
if (path === '/api/helcim/customer-code') {
return Promise.resolve({ customerId: 'cust-123', customerCode: 'CST-ABC' })
}
if (path === '/api/helcim/verify-payment') {
return Promise.resolve({ success: true })
}
if (path === '/api/helcim/subscription') {
return Promise.resolve({ success: true })
}
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
})
const { initiatePaymentSetup } = useMemberPayment()
await initiatePaymentSetup()
// Falls into the full flow — modal opens, verify runs.
expect(initializeHelcimPay).toHaveBeenCalled()
expect(verifyPayment).toHaveBeenCalled()
const calledPaths = $fetch.mock.calls.map((c) => c[0])
expect(calledPaths).toContain('/api/helcim/customer-code')
// existing-card was reused from the shortcut probe — should not refetch.
expect(existingCardCalls).toBe(1)
})
})

View file

@ -1,5 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js'
import loginHandler from '../../../server/api/auth/login.post.js'
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() } default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() }
})) }))
@ -20,13 +25,10 @@ vi.mock('resend', () => ({
} }
})) }))
import Member from '../../../server/models/member.js'
import loginHandler from '../../../server/api/auth/login.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('auth login endpoint', () => { describe('auth login endpoint', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
resetRateLimit()
}) })
it('returns generic success message for existing member', async () => { it('returns generic success message for existing member', async () => {
@ -110,4 +112,92 @@ describe('auth login endpoint', () => {
statusMessage: 'Validation failed' statusMessage: 'Validation failed'
}) })
}) })
describe('rate limiting', () => {
it('allows up to 5 login attempts from a single IP', async () => {
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' })
// 5 calls succeed (each with a unique email so we don't hit email limit)
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: `u${i}@example.com` },
headers: { host: 'localhost:3000' },
remoteAddress: '10.0.0.1'
})
const result = await loginHandler(event)
expect(result.success).toBe(true)
}
})
it('rate-limits a single IP after 5 login attempts', async () => {
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' })
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: `u${i}@example.com` },
headers: { host: 'localhost:3000' },
remoteAddress: '10.0.0.1'
})
await loginHandler(event)
}
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'u6@example.com' },
headers: { host: 'localhost:3000' },
remoteAddress: '10.0.0.1'
})
await expect(loginHandler(event)).rejects.toMatchObject({
statusCode: 429
})
})
it('allows up to 3 login attempts for a single email', async () => {
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' })
// 3 calls from different IPs succeed
for (let i = 0; i < 3; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'shared@example.com' },
headers: { host: 'localhost:3000' },
remoteAddress: `10.0.0.${i + 10}`
})
const result = await loginHandler(event)
expect(result.success).toBe(true)
}
})
it('rate-limits a single email after 3 login attempts (different IPs)', async () => {
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' })
for (let i = 0; i < 3; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'shared@example.com' },
headers: { host: 'localhost:3000' },
remoteAddress: `10.0.0.${i + 10}`
})
await loginHandler(event)
}
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'shared@example.com' },
headers: { host: 'localhost:3000' },
remoteAddress: '10.0.0.99'
})
await expect(loginHandler(event)).rejects.toMatchObject({
statusCode: 429
})
})
})
}) })

View file

@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import Member from '../../../server/models/member.js' import Member from '../../../server/models/member.js'
import verifyHandler from '../../../server/api/auth/verify.post.js' import verifyHandler from '../../../server/api/auth/verify.post.js'
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
import { createMockEvent } from '../helpers/createMockEvent.js' import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/models/member.js', () => ({
@ -33,6 +34,7 @@ const baseMember = {
describe('auth verify endpoint', () => { describe('auth verify endpoint', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
resetRateLimit()
}) })
it('rejects missing token with 400', async () => { it('rejects missing token with 400', async () => {
@ -302,4 +304,79 @@ describe('auth verify endpoint', () => {
expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' }) expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' })
}) })
describe('rate limiting', () => {
it('allows up to 5 verify attempts from a single IP', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
// 5 calls reach jwt.verify (and fail with 401, but not 429)
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'bad-token' },
remoteAddress: '10.0.0.1'
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401
})
}
expect(jwt.verify).toHaveBeenCalledTimes(5)
})
it('rate-limits a single IP after 5 verify attempts', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'bad-token' },
remoteAddress: '10.0.0.1'
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401
})
}
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'bad-token' },
remoteAddress: '10.0.0.1'
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 429
})
// Rate limit fires before jwt.verify on the 6th call
expect(jwt.verify).toHaveBeenCalledTimes(5)
})
it('does not block different IPs (per-IP keying)', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'bad-token' },
remoteAddress: '10.0.0.1'
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401
})
}
// A different IP should still be allowed.
const event = createMockEvent({
method: 'POST',
path: '/api/auth/verify',
body: { token: 'bad-token' },
remoteAddress: '10.0.0.2'
})
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 401
})
})
})
}) })

View file

@ -1,49 +1,139 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]') import Event from '../../../server/models/event.js'
import Member from '../../../server/models/member.js'
import { waitlistSchema, waitlistDeleteSchema } from '../../../server/utils/schemas.js'
import waitlistPostHandler from '../../../server/api/events/[id]/waitlist.post.js'
import waitlistDeleteHandler from '../../../server/api/events/[id]/waitlist.delete.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('waitlist.post.js bypasses validators on event.save()', () => { vi.mock('../../../server/models/event.js', () => ({
const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8') default: { findOne: vi.fn() }
}))
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
it('calls eventData.save with validateBeforeSave: false', () => { vi.stubGlobal('waitlistSchema', waitlistSchema)
expect(source).toContain('eventData.save({ validateBeforeSave: false })') vi.stubGlobal('waitlistDeleteSchema', waitlistDeleteSchema)
})
it('does not contain a bare eventData.save() call', () => { // Override the global validateBody stub so the route actually parses against
expect(source).not.toMatch(/eventData\.save\(\s*\)/) // the schema it passed in.
}) vi.stubGlobal('validateBody', vi.fn(async (event, schema) => {
}) const body = await readBody(event)
return schema.parse(body)
}))
describe('waitlist.delete.js bypasses validators on event.save()', () => { /**
const source = readFileSync(resolve(eventsDir, 'waitlist.delete.js'), 'utf-8') * Build a mock Event document whose `save()` simulates the legacy validator
* problem we're protecting against: when called WITHOUT `validateBeforeSave:
it('calls eventData.save with validateBeforeSave: false', () => { * false` it throws (mimicking a stale `location` validator failing on
expect(source).toContain('eventData.save({ validateBeforeSave: false })') * unrelated writes). When called WITH `validateBeforeSave: false` it resolves
}) * normally. The route is correct iff it bypasses validators.
*/
it('does not contain a bare eventData.save() call', () => { function makeMockEvent(overrides = {}) {
expect(source).not.toMatch(/eventData\.save\(\s*\)/) const doc = {
}) _id: 'event-1',
}) slug: 'event-slug',
tickets: {
// payment.post.js cases are handled by Fix #3 (file deletion). waitlist: {
// If the file still exists, it should also pass the validators bypass. enabled: true,
describe.skipIf(!existsSync(resolve(eventsDir, 'payment.post.js')))( maxSize: 10,
'payment.post.js bypasses validators on event.save()', entries: [],
() => { },
const source = existsSync(resolve(eventsDir, 'payment.post.js')) },
? readFileSync(resolve(eventsDir, 'payment.post.js'), 'utf-8') registrations: [],
: '' ...overrides,
it('has exactly two eventData.save({ validateBeforeSave: false }) calls', () => {
const matches = source.match(/eventData\.save\(\{\s*validateBeforeSave:\s*false\s*\}\)/g) || []
expect(matches.length).toBe(2)
})
it('does not contain a bare eventData.save() call', () => {
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
})
} }
) doc.save = vi.fn(async (options) => {
if (!options || options.validateBeforeSave !== false) {
const err = new Error('Validation failed: location: legacy field invalid')
err.name = 'ValidationError'
throw err
}
return doc
})
return doc
}
function buildPostEvent(body) {
const ev = createMockEvent({
method: 'POST',
path: '/api/events/event-slug/waitlist',
body,
})
ev.context = { params: { id: 'event-slug' } }
return ev
}
function buildDeleteEvent(body) {
const ev = createMockEvent({
method: 'DELETE',
path: '/api/events/event-slug/waitlist',
body,
})
ev.context = { params: { id: 'event-slug' } }
return ev
}
describe('POST /api/events/[id]/waitlist — bypasses save validators', () => {
beforeEach(() => {
vi.clearAllMocks()
Member.findOne.mockResolvedValue(null)
})
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
const mockEvent = makeMockEvent()
Event.findOne.mockResolvedValue(mockEvent)
const result = await waitlistPostHandler(buildPostEvent({
name: 'Waiter',
email: 'wait@example.com',
}))
expect(result.success).toBe(true)
expect(mockEvent.save).toHaveBeenCalledTimes(1)
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
// Entry was actually appended.
expect(mockEvent.tickets.waitlist.entries).toHaveLength(1)
expect(mockEvent.tickets.waitlist.entries[0].email).toBe('wait@example.com')
})
})
describe('DELETE /api/events/[id]/waitlist — bypasses save validators', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
const mockEvent = makeMockEvent({
tickets: {
waitlist: {
enabled: true,
maxSize: 10,
entries: [
{
name: 'Waiter',
email: 'wait@example.com',
membershipLevel: 'non-member',
addedAt: new Date(),
notified: false,
},
],
},
},
})
Event.findOne.mockResolvedValue(mockEvent)
const result = await waitlistDeleteHandler(buildDeleteEvent({
email: 'wait@example.com',
}))
expect(result.success).toBe(true)
expect(mockEvent.save).toHaveBeenCalledTimes(1)
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
// Entry was actually removed.
expect(mockEvent.tickets.waitlist.entries).toHaveLength(0)
})
})

View file

@ -1,31 +0,0 @@
import { describe, it, expect } from 'vitest'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
/**
* Regression: `events/[id]/payment.post.js` was deleted because its
* unauthenticated POST allowed any caller to spam-register an existing
* member to any paid event by supplying their email. See
* docs/superpowers/specs/2026-04-25-fix-3.md.
*
* With the route file gone, Nitro's filesystem router will not register
* a handler at `/api/events/{id}/payment`, so a POST returns 404 the
* spam-register attack surface no longer exists at the network layer.
*/
describe('events/[id]/payment route deletion', () => {
it('the payment.post.js route file no longer exists', () => {
const routePath = resolve(
import.meta.dirname,
'../../../../server/api/events/[id]/payment.post.js'
)
expect(existsSync(routePath)).toBe(false)
})
it('the secure replacement at tickets/purchase.post.js still exists', () => {
const replacementPath = resolve(
import.meta.dirname,
'../../../../server/api/events/[id]/tickets/purchase.post.js'
)
expect(existsSync(replacementPath)).toBe(true)
})
})

View file

@ -82,28 +82,6 @@ describe('helcim existing-card endpoint', () => {
expect(result.cardToken).toBe('tok-b') expect(result.cardToken).toBe('tok-b')
}) })
it('unwraps a { cards: [...] } response envelope', async () => {
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
listHelcimCustomerCards.mockResolvedValue({
cards: [{ cardToken: 'tok-only' }]
})
const result = await existingCardHandler(newEvent())
expect(result.cardToken).toBe('tok-only')
})
it('unwraps a { data: [...] } response envelope', async () => {
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
listHelcimCustomerCards.mockResolvedValue({
data: [{ cardToken: 'tok-only' }]
})
const result = await existingCardHandler(newEvent())
expect(result.cardToken).toBe('tok-only')
})
it('returns { cardToken: null } if the resolved card has no cardToken', async () => { it('returns { cardToken: null } if the resolved card has no cardToken', async () => {
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
listHelcimCustomerCards.mockResolvedValue([{ default: true }]) listHelcimCustomerCards.mockResolvedValue([{ default: true }])

View file

@ -3,8 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js' import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js' import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
import { loadPublicEvent } from '../../../server/utils/loadEvent.js' import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
import { loadPublicSeries } from '../../../server/utils/loadSeries.js'
import { PAYMENT_METADATA_TYPES } from '../../../server/utils/paymentTypes.js'
import Member from '../../../server/models/member.js' import Member from '../../../server/models/member.js'
import Series from '../../../server/models/series.js'
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js' import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js' import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js' import { createMockEvent } from '../helpers/createMockEvent.js'
@ -16,12 +17,15 @@ vi.mock('../../../server/utils/auth.js', () => ({
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} })) vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() })) vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
vi.mock('../../../server/utils/loadSeries.js', () => ({ loadPublicSeries: vi.fn() }))
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } })) vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } }))
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody // helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
vi.stubGlobal('helcimInitializePaymentSchema', {}) vi.stubGlobal('helcimInitializePaymentSchema', {})
// PAYMENT_METADATA_TYPES is a Nitro auto-import from server/utils/paymentTypes.js
vi.stubGlobal('PAYMENT_METADATA_TYPES', PAYMENT_METADATA_TYPES)
const mockFetch = vi.fn() const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch) vi.stubGlobal('fetch', mockFetch)
@ -30,7 +34,7 @@ describe('initialize-payment endpoint', () => {
vi.clearAllMocks() vi.clearAllMocks()
getOptionalMember.mockResolvedValue(null) getOptionalMember.mockResolvedValue(null)
Member.findOne.mockResolvedValue(null) Member.findOne.mockResolvedValue(null)
Series.findOne.mockResolvedValue(null) loadPublicSeries.mockResolvedValue(null)
}) })
afterEach(() => { afterEach(() => {
@ -184,13 +188,13 @@ describe('initialize-payment endpoint', () => {
}) })
}) })
it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => { it('re-derives series_ticket price via loadPublicSeries + calculateSeriesTicketPrice', async () => {
const body = { const body = {
amount: 100, // tampered amount: 100, // tampered
metadata: { type: 'series_ticket', seriesId: 'ser-x' } metadata: { type: 'series_ticket', seriesId: 'ser-x' }
} }
globalThis.validateBody.mockResolvedValue(body) globalThis.validateBody.mockResolvedValue(body)
Series.findOne.mockResolvedValue({ loadPublicSeries.mockResolvedValue({
_id: 'ser-x', _id: 'ser-x',
title: 'Coop Foundations', title: 'Coop Foundations',
tickets: { enabled: true, public: { available: true, price: 7500 } } tickets: { enabled: true, public: { available: true, price: 7500 } }
@ -215,7 +219,7 @@ describe('initialize-payment endpoint', () => {
expect(sentBody.amount).toBe(7500) expect(sentBody.amount).toBe(7500)
expect(sentBody.paymentType).toBe('purchase') expect(sentBody.paymentType).toBe('purchase')
expect(result.amount).toBe(7500) expect(result.amount).toBe(7500)
expect(Series.findOne).toHaveBeenCalled() expect(loadPublicSeries).toHaveBeenCalled()
}) })
it('uses member pricing when metadata.email matches an active member', async () => { it('uses member pricing when metadata.email matches an active member', async () => {

View file

@ -5,11 +5,12 @@ import { requireAuth } from '../../../server/utils/auth.js'
import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js' import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js'
import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js' import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js' import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
import { sendWelcomeEmail } from '../../../server/utils/resend.js'
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js' import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/models/member.js', () => ({
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() } default: { findOneAndUpdate: vi.fn(), findOne: vi.fn(), findById: vi.fn() }
})) }))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/auth.js', () => ({
@ -41,8 +42,9 @@ vi.stubGlobal('helcimSubscriptionSchema', {})
describe('helcim subscription endpoint', () => { describe('helcim subscription endpoint', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Default: first activation from pending_payment // Default: pre-update doc reflects first activation from pending_payment.
Member.findOne.mockResolvedValue({ status: 'pending_payment' }) // findOneAndUpdate returns pre-update doc; findById returns post-update doc.
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-default', status: 'pending_payment' })
}) })
it('requires auth', async () => { it('requires auth', async () => {
@ -77,7 +79,8 @@ describe('helcim subscription endpoint', () => {
status: 'active', status: 'active',
save: vi.fn() save: vi.fn()
} }
Member.findOneAndUpdate.mockResolvedValue(mockMember) Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
@ -100,8 +103,9 @@ describe('helcim subscription endpoint', () => {
expect(Member.findOneAndUpdate).toHaveBeenCalledWith( expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
{ helcimCustomerId: 'cust-1' }, { helcimCustomerId: 'cust-1' },
expect.objectContaining({ status: 'active', contributionAmount: 0 }), expect.objectContaining({ status: 'active', contributionAmount: 0 }),
{ new: true } { new: false, projection: { status: 1 } }
) )
expect(Member.findById).toHaveBeenCalledWith('member-1')
expect(createHelcimSubscription).not.toHaveBeenCalled() expect(createHelcimSubscription).not.toHaveBeenCalled()
}) })
@ -135,7 +139,8 @@ describe('helcim subscription endpoint', () => {
contributionAmount: 15, contributionAmount: 15,
status: 'active', status: 'active',
} }
Member.findOneAndUpdate.mockResolvedValue(mockMember) Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-2', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({ createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }] data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }]
}) })
@ -156,8 +161,9 @@ describe('helcim subscription endpoint', () => {
expect(Member.findOneAndUpdate).toHaveBeenCalledWith( expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
{ helcimCustomerId: 'cust-1' }, { helcimCustomerId: 'cust-1' },
{ $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) }, { $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) },
{ new: true, runValidators: false } { new: false, runValidators: false, projection: { status: 1 } }
) )
expect(Member.findById).toHaveBeenCalledWith('member-2')
}) })
it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => {
@ -173,7 +179,8 @@ describe('helcim subscription endpoint', () => {
contributionAmount: 15, contributionAmount: 15,
status: 'active', status: 'active',
} }
Member.findOneAndUpdate.mockResolvedValue(mockMember) Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({ createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }] data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }]
}) })
@ -194,8 +201,9 @@ describe('helcim subscription endpoint', () => {
expect(Member.findOneAndUpdate).toHaveBeenCalledWith( expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
{ helcimCustomerId: 'cust-1' }, { helcimCustomerId: 'cust-1' },
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) }, { $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) },
{ new: true, runValidators: false } { new: false, runValidators: false, projection: { status: 1 } }
) )
expect(Member.findById).toHaveBeenCalledWith('member-3')
}) })
it('annual $50 tier recurringAmount is 600', async () => { it('annual $50 tier recurringAmount is 600', async () => {
@ -211,7 +219,8 @@ describe('helcim subscription endpoint', () => {
contributionAmount: 50, contributionAmount: 50,
status: 'active', status: 'active',
} }
Member.findOneAndUpdate.mockResolvedValue(mockMember) Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({ createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }] data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }]
}) })
@ -283,7 +292,8 @@ describe('helcim subscription endpoint', () => {
contributionAmount: 15, contributionAmount: 15,
status: 'active', status: 'active',
} }
Member.findOneAndUpdate.mockResolvedValue(mockMember) Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-9', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({ createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }] data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }]
}) })
@ -322,7 +332,8 @@ describe('helcim subscription endpoint', () => {
contributionAmount: 15, contributionAmount: 15,
status: 'active', status: 'active',
} }
Member.findOneAndUpdate.mockResolvedValue(mockMember) Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-10', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({ createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }] data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }]
}) })
@ -358,7 +369,8 @@ describe('helcim subscription endpoint', () => {
contributionAmount: 15, contributionAmount: 15,
status: 'active', status: 'active',
} }
Member.findOneAndUpdate.mockResolvedValue(mockMember) Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-11', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({ createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }] data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }]
}) })
@ -376,6 +388,120 @@ describe('helcim subscription endpoint', () => {
expect(upsertPaymentFromHelcim).not.toHaveBeenCalled() expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
}) })
it('first activation (pending_payment → active) sends welcome email on free tier', async () => {
requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(false)
const mockMember = {
_id: 'member-first-free',
email: 'newbie@example.com',
name: 'Newbie',
circle: 'community',
contributionAmount: 0,
status: 'active',
}
// Pre-update status was pending_payment → first activation
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-free', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: { customerId: 'cust-first-free', contributionAmount: 0, customerCode: 'code-1' }
})
await subscriptionHandler(event)
expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember)
})
it('first activation (pending_payment → active) sends welcome email on paid tier', async () => {
requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(true)
getHelcimPlanId.mockReturnValue('99999')
const mockMember = {
_id: 'member-first-paid',
email: 'newpaid@example.com',
name: 'NewPaid',
circle: 'founder',
contributionAmount: 15,
status: 'active',
}
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-paid', status: 'pending_payment' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-first-paid', status: 'active', nextBillingDate: '2026-05-18' }]
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: { customerId: 'cust-first-paid', contributionAmount: 15, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' }
})
await subscriptionHandler(event)
expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember)
})
it('already-active retry (active → active) does NOT send welcome email on free tier', async () => {
requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(false)
const mockMember = {
_id: 'member-retry-free',
email: 'existing@example.com',
name: 'Existing',
circle: 'community',
contributionAmount: 0,
status: 'active',
}
// Pre-update status was already active → tier upgrade, not first activation
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-free', status: 'active' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: { customerId: 'cust-retry-free', contributionAmount: 0, customerCode: 'code-1' }
})
await subscriptionHandler(event)
expect(sendWelcomeEmail).not.toHaveBeenCalled()
})
it('already-active retry (active → active) does NOT send welcome email on paid tier', async () => {
requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(true)
getHelcimPlanId.mockReturnValue('99999')
const mockMember = {
_id: 'member-retry-paid',
email: 'upgrader@example.com',
name: 'Upgrader',
circle: 'founder',
contributionAmount: 25,
status: 'active',
}
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-paid', status: 'active' })
Member.findById.mockResolvedValue(mockMember)
createHelcimSubscription.mockResolvedValue({
data: [{ id: 'sub-retry', status: 'active', nextBillingDate: '2026-05-18' }]
})
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/subscription',
body: { customerId: 'cust-retry-paid', contributionAmount: 25, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' }
})
await subscriptionHandler(event)
expect(sendWelcomeEmail).not.toHaveBeenCalled()
})
it('Helcim API failure returns 500 and does NOT activate member', async () => { it('Helcim API failure returns 500 and does NOT activate member', async () => {
requireAuth.mockResolvedValue(undefined) requireAuth.mockResolvedValue(undefined)
requiresPayment.mockReturnValue(true) requiresPayment.mockReturnValue(true)

View file

@ -2,19 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import Member from '../../../server/models/member.js' import Member from '../../../server/models/member.js'
import Payment from '../../../server/models/payment.js' import Payment from '../../../server/models/payment.js'
import { listHelcimCustomerTransactions } from '../../../server/utils/helcim.js' import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js' import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js' import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js' import { createMockEvent } from '../helpers/createMockEvent.js'
vi.mock('../../../server/models/member.js', () => ({ vi.mock('../../../server/models/member.js', () => ({
default: { find: vi.fn() } default: { find: vi.fn(), findByIdAndUpdate: vi.fn() }
})) }))
vi.mock('../../../server/models/payment.js', () => ({ vi.mock('../../../server/models/payment.js', () => ({
default: { findOne: vi.fn() } default: { findOne: vi.fn() }
})) }))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/helcim.js', () => ({ vi.mock('../../../server/utils/helcim.js', () => ({
getHelcimCustomer: vi.fn(),
listHelcimCustomerTransactions: vi.fn() listHelcimCustomerTransactions: vi.fn()
})) }))
vi.mock('../../../server/utils/payments.js', () => ({ vi.mock('../../../server/utils/payments.js', () => ({
@ -88,6 +89,7 @@ describe('POST /api/internal/reconcile-payments', () => {
_id: 'm1', _id: 'm1',
email: 'a@example.com', email: 'a@example.com',
helcimCustomerId: 'cust-1', helcimCustomerId: 'cust-1',
helcimCustomerCode: 'CST-1',
helcimSubscriptionId: 'sub-1', helcimSubscriptionId: 'sub-1',
billingCadence: 'monthly' billingCadence: 'monthly'
} }
@ -113,6 +115,7 @@ describe('POST /api/internal/reconcile-payments', () => {
{ helcimCustomerId: { $exists: true, $ne: null } }, { helcimCustomerId: { $exists: true, $ne: null } },
expect.objectContaining({ expect.objectContaining({
helcimCustomerId: 1, helcimCustomerId: 1,
helcimCustomerCode: 1,
helcimSubscriptionId: 1, helcimSubscriptionId: 1,
billingCadence: 1 billingCadence: 1
}) })
@ -134,7 +137,7 @@ describe('POST /api/internal/reconcile-payments', () => {
it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => { it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => {
Member.find.mockReturnValue(leanResolver([ Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' } { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
])) ]))
listHelcimCustomerTransactions.mockResolvedValue([ listHelcimCustomerTransactions.mockResolvedValue([
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' } { id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' }
@ -157,17 +160,17 @@ describe('POST /api/internal/reconcile-payments', () => {
it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => { it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => {
Member.find.mockReturnValue(leanResolver([ Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' }, { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' },
{ _id: 'm2', helcimCustomerId: 'cust-2' }, { _id: 'm2', helcimCustomerId: 'cust-2', helcimCustomerCode: 'CST-2' },
{ _id: 'm3', helcimCustomerId: 'cust-3' } { _id: 'm3', helcimCustomerId: 'cust-3', helcimCustomerCode: 'CST-3' }
])) ]))
// m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try. // m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try.
listHelcimCustomerTransactions // Keyed by customerCode so it works regardless of call order (chunked Promise.all).
.mockResolvedValueOnce([{ id: 'tx1', status: 'paid', amount: 5 }]) listHelcimCustomerTransactions.mockImplementation((customerCode) => {
.mockRejectedValueOnce(new Error('helcim 503')) if (customerCode === 'cust-1') return Promise.resolve([{ id: 'tx1', status: 'paid', amount: 5 }])
.mockRejectedValueOnce(new Error('helcim 503')) if (customerCode === 'cust-3') return Promise.resolve([{ id: 'tx3', status: 'paid', amount: 7 }])
.mockRejectedValueOnce(new Error('helcim 503')) return Promise.reject(new Error('helcim 503'))
.mockResolvedValueOnce([{ id: 'tx3', status: 'paid', amount: 7 }]) })
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } }) upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } })
vi.useFakeTimers() vi.useFakeTimers()
@ -191,7 +194,7 @@ describe('POST /api/internal/reconcile-payments', () => {
it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => { it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => {
vi.useFakeTimers() vi.useFakeTimers()
Member.find.mockReturnValue(leanResolver([ Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' } { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
])) ]))
listHelcimCustomerTransactions listHelcimCustomerTransactions
.mockRejectedValueOnce(new Error('boom 1')) .mockRejectedValueOnce(new Error('boom 1'))
@ -219,7 +222,7 @@ describe('POST /api/internal/reconcile-payments', () => {
it('counts memberErrors when all 3 retry attempts fail', async () => { it('counts memberErrors when all 3 retry attempts fail', async () => {
vi.useFakeTimers() vi.useFakeTimers()
Member.find.mockReturnValue(leanResolver([ Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' } { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
])) ]))
listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503')) listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503'))
@ -241,7 +244,7 @@ describe('POST /api/internal/reconcile-payments', () => {
it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => { it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => {
Member.find.mockReturnValue(leanResolver([ Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' } { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
])) ]))
listHelcimCustomerTransactions.mockResolvedValue([ listHelcimCustomerTransactions.mockResolvedValue([
{ id: 'tx-existing', status: 'paid', amount: 10 }, { id: 'tx-existing', status: 'paid', amount: 10 },
@ -266,4 +269,63 @@ describe('POST /api/internal/reconcile-payments', () => {
existed: 1 existed: 1
}) })
}) })
describe('helcimCustomerCode backfill', () => {
it('writes helcimCustomerCode when missing on the member doc', async () => {
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' } // no helcimCustomerCode
]))
getHelcimCustomer.mockResolvedValue({ id: 'cust-1', customerCode: 'CST-NEW' })
listHelcimCustomerTransactions.mockResolvedValue([])
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
await reconcileHandler(event)
expect(getHelcimCustomer).toHaveBeenCalledWith('cust-1')
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'm1',
{ $set: { helcimCustomerCode: 'CST-NEW' } },
{ runValidators: false }
)
})
it('skips backfill when helcimCustomerCode is already present', async () => {
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-EXISTING' }
]))
listHelcimCustomerTransactions.mockResolvedValue([])
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
await reconcileHandler(event)
expect(getHelcimCustomer).not.toHaveBeenCalled()
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
})
it('does not fail the run when getHelcimCustomer throws during backfill', async () => {
Member.find.mockReturnValue(leanResolver([
{ _id: 'm1', helcimCustomerId: 'cust-1' }
]))
getHelcimCustomer.mockRejectedValue(new Error('helcim 503'))
listHelcimCustomerTransactions.mockResolvedValue([])
const event = createMockEvent({
method: 'POST',
path: '/api/internal/reconcile-payments',
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
})
const result = await reconcileHandler(event)
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
expect(result.memberErrors).toBe(0) // backfill failure is best-effort, not fatal
})
})
}) })

View file

@ -1,98 +1,177 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]') import Member from '../../../server/models/member.js'
import Series from '../../../server/models/series.js'
import Event from '../../../server/models/event.js'
import {
validateSeriesTicketPurchase,
completeSeriesTicketPurchase,
registerForAllSeriesEvents,
hasMemberAccess,
} from '../../../server/utils/tickets.js'
import { sendSeriesPassConfirmation } from '../../../server/utils/resend.js'
import { seriesTicketPurchaseSchema } from '../../../server/utils/schemas.js'
import handler from '../../../server/api/series/[id]/tickets/purchase.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => { vi.mock('../../../server/models/member.js', () => ({
const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8') default: { findOne: vi.fn(), findOneAndUpdate: vi.fn() }
}))
vi.mock('../../../server/models/series.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/models/event.js', () => ({
default: {
find: vi.fn(() => ({ sort: vi.fn().mockResolvedValue([]) }))
}
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
vi.mock('../../../server/utils/tickets.js', () => ({
validateSeriesTicketPurchase: vi.fn(),
calculateSeriesTicketPrice: vi.fn(),
reserveSeriesTicket: vi.fn(),
releaseSeriesTicket: vi.fn(),
completeSeriesTicketPurchase: vi.fn().mockResolvedValue(undefined),
registerForAllSeriesEvents: vi.fn().mockResolvedValue([]),
hasMemberAccess: vi.fn(() => false),
}))
vi.mock('../../../server/utils/resend.js', () => ({
sendSeriesPassConfirmation: vi.fn().mockResolvedValue(undefined),
}))
it('uses validateBody with seriesTicketPurchaseSchema', () => { // Auto-imports the handler relies on but the global setup doesn't stub.
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)') const setAuthCookieMock = vi.fn()
vi.stubGlobal('setAuthCookie', setAuthCookieMock)
vi.stubGlobal('seriesTicketPurchaseSchema', seriesTicketPurchaseSchema)
// Capture schema passed to validateBody so we can prove the route validates
// against seriesTicketPurchaseSchema specifically.
const validateBodyCalls = []
const validateBodyMock = vi.fn(async (event, schema) => {
validateBodyCalls.push(schema)
// Mirror real behavior: parse body via the schema so invalid bodies still throw.
const body = await readBody(event)
return schema.parse(body)
})
vi.stubGlobal('validateBody', validateBodyMock)
function buildEvent(body) {
const ev = createMockEvent({
method: 'POST',
path: '/api/series/series-1/tickets/purchase',
body,
}) })
ev.context = { params: { id: 'series-1' } }
return ev
}
it('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => { const baseSeries = () => ({
// Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in _id: 'series-1',
// checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`. id: 'series-1',
expect(source).toContain('findOneAndUpdate') slug: 'series-slug',
expect(source).toContain('$setOnInsert') title: 'Test Series',
expect(source).toContain('status: "guest"') description: 'desc',
expect(source).toContain('upsert: true') type: 'workshop',
expect(source).toContain('circle: "community"') registrations: [],
expect(source).toContain('contributionAmount: 0')
// ALWAYS-CREATE — must NOT gate on a createAccount flag
expect(source).not.toContain('body.createAccount')
})
it('Case 3 (idempotency): upsert pattern handles concurrent same-email registrations atomically', () => {
// findOneAndUpdate with $setOnInsert + upsert:true is the idempotent pattern;
// email has a unique index. No duplicate Member doc created on retry.
expect(source).toMatch(/findOneAndUpdate\(\s*\{\s*email:/)
expect(source).toContain('upsert: true')
expect(source).toContain('new: true')
expect(source).toContain('setDefaultsOnInsert: true')
})
it('Case 4 (existing real member): does not auto-login real members entered via public form', () => {
// Auto-login only for newly-created accounts and existing guests.
// Real members (active/pending_payment) get requiresSignIn: true instead.
expect(source).toContain('accountCreated || member.status === "guest"')
expect(source).toContain('requiresSignIn = true')
})
it('Case 5 (authenticated guest): sets auth cookie on signedIn:true response', () => {
// setAuthCookie fires for both new accounts and returning guests.
expect(source).toContain('setAuthCookie(event, member)')
expect(source).toContain('signedIn = true')
})
it('Case 6 (missing fields): relies on schema validation to reject missing name/email', () => {
// No new validation logic added — existing seriesTicketPurchaseSchema
// already requires name+email; validateBody throws 400 if missing.
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
})
it('includes accountCreated, signedIn, and requiresSignIn in response (parity with event endpoint)', () => {
expect(source).toContain('accountCreated,')
expect(source).toContain('signedIn,')
expect(source).toContain('requiresSignIn,')
})
it('still uses hasMemberAccess to gate member pricing (guest/suspended/cancelled treated as non-members)', () => {
expect(source).toContain('hasMemberAccess(member)')
})
it('preserves try/catch around requireAuth so unauthenticated callers fall through', () => {
// Required for unauth guest-purchase flow to work at all.
expect(source).toMatch(/try\s*\{[^}]*requireAuth\(event\)[^}]*\}\s*catch/s)
})
it('does not block purchase when confirmation email fails', () => {
const emailCallIndex = source.indexOf('await sendSeriesPassConfirmation')
expect(emailCallIndex).toBeGreaterThan(-1)
const afterEmail = source.slice(emailCallIndex)
const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
expect(catchBlock).not.toBeNull()
expect(catchBlock[0]).toContain('console.error')
})
}) })
describe('SeriesPassPurchase.vue — client auth refresh (Fix #8)', () => { describe('POST /api/series/[id]/tickets/purchase — guest upsert + auth cookie', () => {
const source = readFileSync( beforeEach(() => {
resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'), vi.clearAllMocks()
'utf-8' validateBodyCalls.length = 0
) // Default: unauthenticated buyer
globalThis.requireAuth = vi.fn().mockRejectedValue(
it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => { Object.assign(new Error('Unauthorized'), { statusCode: 401 })
expect(source).toContain('useAuth().checkMemberStatus()') )
expect(source).toMatch(/purchaseResponse\?\.signedIn/) Series.findOne.mockResolvedValue(baseSeries())
Member.findOne.mockResolvedValue(null)
validateSeriesTicketPurchase.mockReturnValue({
valid: true,
ticketInfo: {
ticketType: 'public',
price: 0,
currency: 'CAD',
isFree: true,
},
})
}) })
it('shows a one-line guest-account hint under the form (no checkbox)', () => { it('upserts a guest Member with $setOnInsert + upsert:true when buyer has no account', async () => {
// Per ALWAYS-CREATE-GUEST decision: hint only, no UI control. Member.findOneAndUpdate.mockResolvedValue({
expect(source).toMatch(/free guest account/i) _id: 'new-member-1',
// Make sure no checkbox was added by mistake. email: 'guest@example.com',
expect(source).not.toMatch(/createAccount/) status: 'guest',
expect(source).not.toMatch(/<input[^>]*type="checkbox"/i) })
await handler(buildEvent({
name: 'Guest Buyer',
email: 'guest@example.com',
ticketType: 'public',
}))
expect(Member.findOneAndUpdate).toHaveBeenCalledTimes(1)
const [filter, update, options] = Member.findOneAndUpdate.mock.calls[0]
expect(filter).toEqual({ email: 'guest@example.com' })
expect(update).toEqual({
$setOnInsert: {
email: 'guest@example.com',
name: 'Guest Buyer',
circle: 'community',
contributionAmount: 0,
status: 'guest',
},
})
expect(options).toEqual({
upsert: true,
new: true,
setDefaultsOnInsert: true,
})
})
it('sets the auth cookie for newly-created guest accounts', async () => {
const newMember = {
_id: 'new-member-2',
email: 'newbie@example.com',
status: 'guest',
}
Member.findOneAndUpdate.mockResolvedValue(newMember)
const result = await handler(buildEvent({
name: 'Newbie',
email: 'newbie@example.com',
ticketType: 'public',
}))
expect(setAuthCookieMock).toHaveBeenCalledTimes(1)
expect(setAuthCookieMock.mock.calls[0][1]).toBe(newMember)
expect(result.signedIn).toBe(true)
expect(result.accountCreated).toBe(true)
expect(result.requiresSignIn).toBe(false)
})
it('validates input via seriesTicketPurchaseSchema', async () => {
Member.findOneAndUpdate.mockResolvedValue({
_id: 'm',
email: 'a@b.com',
status: 'guest',
})
await handler(buildEvent({
name: 'A',
email: 'a@b.com',
ticketType: 'public',
}))
expect(validateBodyMock).toHaveBeenCalled()
expect(validateBodyCalls[0]).toBe(seriesTicketPurchaseSchema)
})
it('rejects invalid body (no name) via schema validation', async () => {
await expect(
handler(buildEvent({ email: 'a@b.com', ticketType: 'public' }))
).rejects.toBeDefined()
expect(Member.findOneAndUpdate).not.toHaveBeenCalled()
expect(setAuthCookieMock).not.toHaveBeenCalled()
}) })
}) })

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { import {
listHelcimCustomerCards,
listHelcimCustomerTransactions, listHelcimCustomerTransactions,
updateHelcimCustomerDefaultPaymentMethod, updateHelcimCustomerDefaultPaymentMethod,
updateHelcimSubscriptionPaymentMethod updateHelcimSubscriptionPaymentMethod
@ -25,6 +26,56 @@ function errResponse(status = 500, body = 'boom') {
} }
} }
describe('listHelcimCustomerCards', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
mockFetch.mockReset()
})
it('passes through a bare array response', async () => {
const cards = [
{ id: 1, cardToken: 'tok-a' },
{ id: 2, cardToken: 'tok-b' }
]
mockFetch.mockResolvedValue(okResponse(cards))
const result = await listHelcimCustomerCards('2488717')
expect(result).toEqual(cards)
})
it('unwraps a { cards: [...] } response envelope', async () => {
const cards = [{ id: 1, cardToken: 'tok-a' }]
mockFetch.mockResolvedValue(okResponse({ cards }))
const result = await listHelcimCustomerCards('2488717')
expect(result).toEqual(cards)
})
it('unwraps a { data: [...] } response envelope', async () => {
const cards = [{ id: 1, cardToken: 'tok-a' }]
mockFetch.mockResolvedValue(okResponse({ data: cards }))
const result = await listHelcimCustomerCards('2488717')
expect(result).toEqual(cards)
})
it('returns an empty array for null, undefined, or unrecognized object responses', async () => {
mockFetch.mockResolvedValueOnce(okResponse(null))
expect(await listHelcimCustomerCards('2488717')).toEqual([])
mockFetch.mockResolvedValueOnce(okResponse(undefined))
expect(await listHelcimCustomerCards('2488717')).toEqual([])
mockFetch.mockResolvedValueOnce(okResponse({ unexpected: 'shape' }))
expect(await listHelcimCustomerCards('2488717')).toEqual([])
})
})
describe('listHelcimCustomerTransactions', () => { describe('listHelcimCustomerTransactions', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()