diff --git a/app/composables/useMemberPayment.js b/app/composables/useMemberPayment.js index fcab6fe..23255a7 100644 --- a/app/composables/useMemberPayment.js +++ b/app/composables/useMemberPayment.js @@ -25,45 +25,17 @@ export const useMemberPayment = () => { paymentSuccess.value = false try { - // Fast-path: when both Helcim ids are already cached on the member doc - // AND a card's on file, we can skip the paid getOrCreateCustomer round - // trip entirely and go straight to subscription creation. - const hasCachedHelcimIds = Boolean( - 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) => { + // Skip HelcimPay verify if a card's already on file — Helcim refuses + // to re-save it, breaking retries after a partial-failed signup. + const [, existing] = await Promise.all([ + getOrCreateCustomer(), + $fetch('/api/helcim/existing-card').catch((err) => { console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err) return null - }) - probedExistingCard = true - if (existing?.cardToken) { - customerId.value = memberData.value.helcimCustomerId - customerCode.value = memberData.value.helcimCustomerCode - cardToken = existing.cardToken - } - } + }), + ]) - 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 - } + let cardToken = existing?.cardToken || null if (!cardToken) { await initializeHelcimPay( diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index c001e7c..3621574 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -133,8 +133,9 @@ const filterOptions = [ const { data: eventsData } = await useFetch("/api/events"); const { data: seriesData } = await useFetch("/api/series"); +const now = new Date(); + const filteredEvents = computed(() => { - const now = new Date(); if (!eventsData.value) return []; return eventsData.value.filter((event) => { if (!includePastEvents.value && new Date(event.startDate) < now) diff --git a/app/pages/member/payment-setup.vue b/app/pages/member/payment-setup.vue index 2b0efdf..5efb6f6 100644 --- a/app/pages/member/payment-setup.vue +++ b/app/pages/member/payment-setup.vue @@ -85,46 +85,21 @@ const initialize = async () => { } try { - // Fast-path: when both Helcim ids are already cached on the member doc - // AND a card's on file, skip the paid get-or-create-customer round trip. - const hasCachedHelcimIds = Boolean( - memberData.value?.helcimCustomerId && memberData.value?.helcimCustomerCode - ); - - let existing = null; - let probedExistingCard = false; - if (hasCachedHelcimIds) { - existing = await $fetch('/api/helcim/existing-card').catch((err) => { + // 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, existing] = await Promise.all([ + $fetch('/api/helcim/get-or-create-customer', { method: 'POST' }), + $fetch('/api/helcim/existing-card').catch((err) => { console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err); return null; - }); - probedExistingCard = true; - if (existing?.cardToken) { - customerId.value = memberData.value.helcimCustomerId; - customerCode.value = memberData.value.helcimCustomerCode; - hasExistingCard.value = true; - } - } + }), + ]); + customerId.value = customer.customerId; + customerCode.value = customer.customerCode; + hasExistingCard.value = Boolean(existing?.cardToken); if (!hasExistingCard.value) { - // 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); - } + await initializeHelcimPay(customerId.value, customerCode.value, 0); } step.value = 'ready'; } catch (err) { diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index 2ca99c6..5978ec7 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -1,32 +1,18 @@ // server/api/auth/login.post.js -import { getRequestIP } from "h3"; import { connectDB } from "../../utils/mongoose.js"; import { validateBody } from "../../utils/validateBody.js"; import { emailSchema } from "../../utils/schemas.js"; import { sendMagicLink } from "../../utils/magicLink.js"; -import { rateLimit } from "../../utils/rateLimit.js"; 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(); - 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 { email } = await validateBody(event, emailSchema); const GENERIC_MESSAGE = "If this email is registered, we've sent a login link."; try { - await sendMagicLink(body.email); + await sendMagicLink(email); return { success: true, message: GENERIC_MESSAGE, diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js index 7f0b808..02316c0 100644 --- a/server/api/auth/member.get.js +++ b/server/api/auth/member.get.js @@ -14,7 +14,6 @@ export default defineEventHandler(async (event) => { contributionAmount: member.contributionAmount, billingCadence: member.billingCadence, helcimCustomerId: member.helcimCustomerId, - helcimCustomerCode: member.helcimCustomerCode, nextBillingDate: member.nextBillingDate, membershipLevel: `${member.circle}-${member.contributionAmount}`, // Profile fields diff --git a/server/api/auth/verify.post.js b/server/api/auth/verify.post.js index 173acb6..1a0a6cd 100644 --- a/server/api/auth/verify.post.js +++ b/server/api/auth/verify.post.js @@ -1,18 +1,11 @@ // server/api/auth/verify.post.js -import { getRequestIP } from 'h3' import jwt from 'jsonwebtoken' import Member from '../../models/member.js' import { validateBody } from '../../utils/validateBody.js' import { verifyMagicLinkSchema } from '../../utils/schemas.js' import { setAuthCookie } from '../../utils/auth.js' -import { rateLimit } from '../../utils/rateLimit.js' 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 config = useRuntimeConfig(event) diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 3382b7f..b267ebd 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -62,7 +62,6 @@ export default defineEventHandler(async (event) => { circle: body.circle, contributionAmount: body.contributionAmount, helcimCustomerId: customerData.id, - helcimCustomerCode: customerData.customerCode, status: 'pending_payment', 'agreement.acceptedAt': new Date() } @@ -76,7 +75,6 @@ export default defineEventHandler(async (event) => { circle: body.circle, contributionAmount: body.contributionAmount, helcimCustomerId: customerData.id, - helcimCustomerCode: customerData.customerCode, status: 'pending_payment', agreement: { acceptedAt: new Date() } }) diff --git a/server/api/helcim/existing-card.get.js b/server/api/helcim/existing-card.get.js index 6ec83a6..14405fc 100644 --- a/server/api/helcim/existing-card.get.js +++ b/server/api/helcim/existing-card.get.js @@ -9,7 +9,10 @@ export default defineEventHandler(async (event) => { return { cardToken: null } } - const cards = await listHelcimCustomerCards(member.helcimCustomerId) + const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId) + const cards = Array.isArray(cardsResponse) + ? cardsResponse + : (cardsResponse?.cards || cardsResponse?.data || []) if (!cards.length) { return { cardToken: null } diff --git a/server/api/helcim/get-or-create-customer.post.js b/server/api/helcim/get-or-create-customer.post.js index 5a60897..a23ac35 100644 --- a/server/api/helcim/get-or-create-customer.post.js +++ b/server/api/helcim/get-or-create-customer.post.js @@ -18,13 +18,6 @@ export default defineEventHandler(async (event) => { try { const customer = await getHelcimCustomer(member.helcimCustomerId) if (customer?.id) { - if (!member.helcimCustomerCode && customer.customerCode) { - await Member.findByIdAndUpdate( - member._id, - { $set: { helcimCustomerCode: customer.customerCode } }, - { runValidators: false } - ) - } return { success: true, customerId: customer.id, @@ -56,13 +49,10 @@ export default defineEventHandler(async (event) => { } if (existingCustomer) { - if (!member.helcimCustomerId || !member.helcimCustomerCode) { + if (!member.helcimCustomerId) { await Member.findByIdAndUpdate( member._id, - { $set: { - helcimCustomerId: existingCustomer.id, - helcimCustomerCode: existingCustomer.customerCode - } }, + { $set: { helcimCustomerId: existingCustomer.id } }, { runValidators: false } ) } @@ -83,10 +73,7 @@ export default defineEventHandler(async (event) => { await Member.findByIdAndUpdate( member._id, - { $set: { - helcimCustomerId: customerData.id, - helcimCustomerCode: customerData.customerCode - } }, + { $set: { helcimCustomerId: customerData.id } }, { runValidators: false } ) diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js index a01b8d0..71996b1 100644 --- a/server/api/helcim/initialize-payment.post.js +++ b/server/api/helcim/initialize-payment.post.js @@ -1,6 +1,6 @@ import Member from '../../models/member.js' +import Series from '../../models/series.js' import { loadPublicEvent } from '../../utils/loadEvent.js' -import { loadPublicSeries } from '../../utils/loadSeries.js' import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js' import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js' import { initializeHelcimPaySession } from '../../utils/helcim.js' @@ -10,10 +10,10 @@ export default defineEventHandler(async (event) => { const body = await validateBody(event, helcimInitializePaymentSchema) const metaType = body.metadata?.type - const isEventTicket = metaType === PAYMENT_METADATA_TYPES.EVENT_TICKET - const isSeriesTicket = metaType === PAYMENT_METADATA_TYPES.SERIES_TICKET + const isEventTicket = metaType === 'event_ticket' + const isSeriesTicket = metaType === 'series_ticket' const isTicket = isEventTicket || isSeriesTicket - const isMembershipSignup = metaType === PAYMENT_METADATA_TYPES.MEMBERSHIP_SIGNUP + const isMembershipSignup = metaType === 'membership_signup' if (!isTicket) { if (isMembershipSignup) { @@ -55,7 +55,14 @@ export default defineEventHandler(async (event) => { if (!seriesId) { throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' }) } - 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' }) + } const ticketInfo = calculateSeriesTicketPrice(series, accessMember) if (!ticketInfo) { throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' }) diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js index db4ffb7..b5567bc 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -90,22 +90,26 @@ export default defineEventHandler(async (event) => { await connectDB() const body = await validateBody(event, helcimSubscriptionSchema) + // Only send welcome email when a member transitions from pending_payment + // to active for the first time — not on tier upgrades (active → active). + const priorMember = await Member.findOne( + { helcimCustomerId: body.customerId }, + { status: 1 } + ) + const isFirstActivation = priorMember?.status === 'pending_payment' + // Check if payment is required if (!requiresPayment(body.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( + // For free tier, just update member status + const member = await Member.findOneAndUpdate( { helcimCustomerId: body.customerId }, { status: 'active', contributionAmount: body.contributionAmount, subscriptionStartDate: new Date() }, - { new: false, projection: { status: 1 } } + { new: true } ) - const isFirstActivation = preMember?.status === 'pending_payment' - const member = await Member.findById(preMember._id) logActivity(member._id, 'subscription_created', { amount: body.contributionAmount }) @@ -171,10 +175,8 @@ export default defineEventHandler(async (event) => { ? new Date(subscription.nextBillingDate) : 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( + // Update member in database + const member = await Member.findOneAndUpdate( { helcimCustomerId: body.customerId }, { $set: { contributionAmount: body.contributionAmount, @@ -188,10 +190,8 @@ export default defineEventHandler(async (event) => { ? { nextBillingDate } : {}), } }, - { new: false, runValidators: false, projection: { status: 1 } } + { new: true, runValidators: false } ) - const isFirstActivation = preMember?.status === 'pending_payment' - const member = await Member.findById(preMember._id) logActivity(member._id, 'subscription_created', { amount: body.contributionAmount }) diff --git a/server/api/helcim/update-card.post.js b/server/api/helcim/update-card.post.js index 1ccd17a..b3dd2d9 100644 --- a/server/api/helcim/update-card.post.js +++ b/server/api/helcim/update-card.post.js @@ -45,7 +45,10 @@ export default defineEventHandler(async (event) => { const { cardToken } = body // Step 3: verify the submitted token is attached to this member's customer - const cards = await listHelcimCustomerCards(member.helcimCustomerId) + const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId) + const cards = Array.isArray(cardsResponse) + ? cardsResponse + : (cardsResponse?.cards || cardsResponse?.data || []) const matchingCard = cards.find((c) => c?.cardToken === cardToken) if (!matchingCard) { diff --git a/server/api/helcim/verify-payment.post.js b/server/api/helcim/verify-payment.post.js index e00d28d..5f3238a 100644 --- a/server/api/helcim/verify-payment.post.js +++ b/server/api/helcim/verify-payment.post.js @@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => { const cards = await listHelcimCustomerCards(body.customerId) // Verify the card token exists for this customer - const cardExists = cards.some(card => + const cardExists = Array.isArray(cards) && cards.some(card => card.cardToken === body.cardToken ) diff --git a/server/api/internal/reconcile-payments.post.js b/server/api/internal/reconcile-payments.post.js index f59e838..9c0686f 100644 --- a/server/api/internal/reconcile-payments.post.js +++ b/server/api/internal/reconcile-payments.post.js @@ -8,7 +8,7 @@ */ import Member from '../../models/member.js' import Payment from '../../models/payment.js' -import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../utils/helcim.js' +import { listHelcimCustomerTransactions } from '../../utils/helcim.js' import { connectDB } from '../../utils/mongoose.js' import { upsertPaymentFromHelcim } from '../../utils/payments.js' @@ -56,7 +56,7 @@ export default defineEventHandler(async (event) => { const members = await Member.find( { helcimCustomerId: { $exists: true, $ne: null } }, - { _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 } + { _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 } ).lean() let txExamined = 0 @@ -65,75 +65,37 @@ export default defineEventHandler(async (event) => { let skipped = 0 let memberErrors = 0 - 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}`) - } - } - + for (const member of members) { let txs try { txs = await listTransactionsWithRetry(member.helcimCustomerId) } catch (err) { + memberErrors++ console.error(`[reconcile] member=${member._id}: ${err?.message || err}`) - return { error: true } + continue } - const result = { error: false, txExamined: 0, created: 0, existed: 0, skipped: 0 } - for (const tx of txs) { - result.txExamined++ + txExamined++ if (!RECONCILABLE_STATUSES.has(tx?.status)) { - result.skipped++ + skipped++ continue } if (!apply) { const existing = await Payment.findOne({ helcimTransactionId: tx.id }) - if (existing) result.existed++ - else result.created++ + if (existing) existed++ + else created++ continue } // Note: deliberately NOT passing sendConfirmation — cron back-fills must // not re-send confirmation emails for transactions the member has already // been notified about (or that pre-date Mongo Payment tracking entirely). - const upsertResult = await upsertPaymentFromHelcim(member, tx) - if (upsertResult.created) result.created++ - else if (upsertResult.payment) result.existed++ - 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 + const result = await upsertPaymentFromHelcim(member, tx) + if (result.created) created++ + else if (result.payment) existed++ + else skipped++ } } diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 384ae8f..93b46e6 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -61,7 +61,6 @@ export default defineEventHandler(async (event) => { bio: body.motivation || undefined, status: body.contributionAmount === 0 ? 'active' : 'pending_payment', helcimCustomerId: helcimCustomer?.id, - helcimCustomerCode: helcimCustomer?.customerCode, agreement: { acceptedAt: new Date() }, }) diff --git a/server/api/series/[id].get.js b/server/api/series/[id].get.js index 000f9b1..be18bdc 100644 --- a/server/api/series/[id].get.js +++ b/server/api/series/[id].get.js @@ -1,5 +1,5 @@ import Event from "../../models/event.js"; -import { loadPublicSeries } from "../../utils/loadSeries.js"; +import Series from "../../models/series.js"; import { connectDB } from "../../utils/mongoose.js"; export default defineEventHandler(async (event) => { @@ -15,14 +15,16 @@ export default defineEventHandler(async (event) => { }); } - // Try to fetch the Series model first for full ticketing info. - // Legacy series may exist only as event metadata (no Series doc), so we - // fall through to the events-based path below when no Series doc matches. - const seriesModel = await loadPublicSeries(event, id, { - select: "-registrations", // Don't expose registration details - lean: true, - allowMissing: true, - }); + // Try to fetch the Series model first for full ticketing info + // Build query conditions based on whether id looks like ObjectId or string + const isObjectId = /^[0-9a-fA-F]{24}$/.test(id); + const seriesQuery = isObjectId + ? { $or: [{ _id: id }, { id: id }, { slug: id }] } + : { $or: [{ id: id }, { slug: id }] }; + + const seriesModel = await Series.findOne(seriesQuery) + .select("-registrations") // Don't expose registration details + .lean(); // Fetch all events in this series const events = await Event.find({ diff --git a/server/api/series/[id]/tickets/available.get.js b/server/api/series/[id]/tickets/available.get.js index f7f0b0b..cf438ad 100644 --- a/server/api/series/[id]/tickets/available.get.js +++ b/server/api/series/[id]/tickets/available.get.js @@ -1,5 +1,5 @@ +import Series from "../../../../models/series.js"; import Member from "../../../../models/member.js"; -import { loadPublicSeries } from "../../../../utils/loadSeries.js"; import { calculateSeriesTicketPrice, checkSeriesTicketAvailability, @@ -13,7 +13,20 @@ export default defineEventHandler(async (event) => { const email = query.email; // Fetch series - const series = await loadPublicSeries(event, seriesId); + // Build query conditions based on whether seriesId looks like ObjectId or string + 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 if (!series.tickets?.enabled) { diff --git a/server/api/series/[id]/tickets/purchase.post.js b/server/api/series/[id]/tickets/purchase.post.js index c21d1a9..0f27b7b 100644 --- a/server/api/series/[id]/tickets/purchase.post.js +++ b/server/api/series/[id]/tickets/purchase.post.js @@ -1,6 +1,6 @@ +import Series from "../../../../models/series.js"; import Event from "../../../../models/event.js"; import Member from "../../../../models/member.js"; -import { loadPublicSeries } from "../../../../utils/loadSeries.js"; import { validateSeriesTicketPurchase, calculateSeriesTicketPrice, @@ -19,7 +19,20 @@ export default defineEventHandler(async (event) => { const { name, email, paymentId } = body; // Fetch series - const series = await loadPublicSeries(event, seriesId); + // Build query conditions based on whether seriesId looks like ObjectId or string + 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. // Only members with access (active or pending_payment) get member-tier diff --git a/server/models/member.js b/server/models/member.js index 3034eb0..61dd354 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -42,7 +42,6 @@ const memberSchema = new mongoose.Schema({ default: "pending_payment", }, helcimCustomerId: String, - helcimCustomerCode: String, helcimSubscriptionId: String, billingCadence: { type: String, diff --git a/server/utils/helcim.js b/server/utils/helcim.js index a96f630..ca6eb34 100644 --- a/server/utils/helcim.js +++ b/server/utils/helcim.js @@ -86,10 +86,8 @@ export const createHelcimCustomer = (payload) => export const updateHelcimCustomer = (id, payload) => helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' }) -export const listHelcimCustomerCards = async (id) => { - const raw = await helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' }) - return Array.isArray(raw) ? raw : (raw?.cards || raw?.data || []) -} +export const listHelcimCustomerCards = (id) => + helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' }) /** * Set a customer's default payment method by card token. diff --git a/server/utils/loadSeries.js b/server/utils/loadSeries.js deleted file mode 100644 index 33d9018..0000000 --- a/server/utils/loadSeries.js +++ /dev/null @@ -1,47 +0,0 @@ -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} 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 -} diff --git a/server/utils/paymentTypes.js b/server/utils/paymentTypes.js deleted file mode 100644 index 3c833a8..0000000 --- a/server/utils/paymentTypes.js +++ /dev/null @@ -1,15 +0,0 @@ -// 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) diff --git a/server/utils/schemas.js b/server/utils/schemas.js index cb75944..c04a649 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -1,6 +1,5 @@ import * as z from 'zod' import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js' -import { PAYMENT_METADATA_TYPE_VALUES } from './paymentTypes.js' export const emailSchema = z.object({ email: z.string().trim().toLowerCase().email() @@ -72,7 +71,7 @@ export const helcimInitializePaymentSchema = z.object({ amount: z.number().min(0).optional(), customerCode: z.string().max(200).optional(), metadata: z.object({ - type: z.enum(PAYMENT_METADATA_TYPE_VALUES).optional(), + type: z.enum(['event_ticket', 'series_ticket', 'subscription', 'card_verify', 'membership_signup']).optional(), eventTitle: z.string().max(500).optional(), eventId: z.string().max(200).optional(), seriesId: z.string().max(200).optional(), diff --git a/tests/client/composables/useMemberPayment.test.js b/tests/client/composables/useMemberPayment.test.js deleted file mode 100644 index 2a5e1dd..0000000 --- a/tests/client/composables/useMemberPayment.test.js +++ /dev/null @@ -1,145 +0,0 @@ -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) - }) -}) diff --git a/tests/server/api/auth-login.test.js b/tests/server/api/auth-login.test.js index 8c692e7..9e98c4b 100644 --- a/tests/server/api/auth-login.test.js +++ b/tests/server/api/auth-login.test.js @@ -1,10 +1,5 @@ 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', () => ({ default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() } })) @@ -25,10 +20,13 @@ 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', () => { beforeEach(() => { vi.clearAllMocks() - resetRateLimit() }) it('returns generic success message for existing member', async () => { @@ -112,92 +110,4 @@ describe('auth login endpoint', () => { 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 - }) - }) - }) }) diff --git a/tests/server/api/auth-verify.test.js b/tests/server/api/auth-verify.test.js index 4cdfdec..d018f6d 100644 --- a/tests/server/api/auth-verify.test.js +++ b/tests/server/api/auth-verify.test.js @@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import jwt from 'jsonwebtoken' import Member from '../../../server/models/member.js' import verifyHandler from '../../../server/api/auth/verify.post.js' -import { resetRateLimit } from '../../../server/utils/rateLimit.js' import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => ({ @@ -34,7 +33,6 @@ const baseMember = { describe('auth verify endpoint', () => { beforeEach(() => { vi.clearAllMocks() - resetRateLimit() }) it('rejects missing token with 400', async () => { @@ -304,79 +302,4 @@ describe('auth verify endpoint', () => { 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 - }) - }) - }) }) diff --git a/tests/server/api/event-save-validators.test.js b/tests/server/api/event-save-validators.test.js index 4645d13..049f694 100644 --- a/tests/server/api/event-save-validators.test.js +++ b/tests/server/api/event-save-validators.test.js @@ -1,139 +1,49 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' -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' +const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]') -vi.mock('../../../server/models/event.js', () => ({ - default: { findOne: vi.fn() } -})) -vi.mock('../../../server/models/member.js', () => ({ - default: { findOne: vi.fn() } -})) -vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +describe('waitlist.post.js bypasses validators on event.save()', () => { + const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8') -vi.stubGlobal('waitlistSchema', waitlistSchema) -vi.stubGlobal('waitlistDeleteSchema', waitlistDeleteSchema) - -// Override the global validateBody stub so the route actually parses against -// the schema it passed in. -vi.stubGlobal('validateBody', vi.fn(async (event, schema) => { - const body = await readBody(event) - return schema.parse(body) -})) - -/** - * Build a mock Event document whose `save()` simulates the legacy validator - * problem we're protecting against: when called WITHOUT `validateBeforeSave: - * false` it throws (mimicking a stale `location` validator failing on - * unrelated writes). When called WITH `validateBeforeSave: false` it resolves - * normally. The route is correct iff it bypasses validators. - */ -function makeMockEvent(overrides = {}) { - const doc = { - _id: 'event-1', - slug: 'event-slug', - tickets: { - waitlist: { - enabled: true, - maxSize: 10, - entries: [], - }, - }, - registrations: [], - ...overrides, - } - 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('calls eventData.save with validateBeforeSave: false', () => { + expect(source).toContain('eventData.save({ validateBeforeSave: false })') }) - 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') + it('does not contain a bare eventData.save() call', () => { + expect(source).not.toMatch(/eventData\.save\(\s*\)/) }) }) -describe('DELETE /api/events/[id]/waitlist — bypasses save validators', () => { - beforeEach(() => { - vi.clearAllMocks() +describe('waitlist.delete.js bypasses validators on event.save()', () => { + const source = readFileSync(resolve(eventsDir, 'waitlist.delete.js'), 'utf-8') + + it('calls eventData.save with validateBeforeSave: false', () => { + expect(source).toContain('eventData.save({ validateBeforeSave: false })') }) - 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, - }, - ], - }, - }, + it('does not contain a bare eventData.save() call', () => { + expect(source).not.toMatch(/eventData\.save\(\s*\)/) + }) +}) + +// payment.post.js cases are handled by Fix #3 (file deletion). +// If the file still exists, it should also pass the validators bypass. +describe.skipIf(!existsSync(resolve(eventsDir, 'payment.post.js')))( + 'payment.post.js bypasses validators on event.save()', + () => { + const source = existsSync(resolve(eventsDir, 'payment.post.js')) + ? readFileSync(resolve(eventsDir, 'payment.post.js'), 'utf-8') + : '' + + 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) }) - 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) - }) -}) + it('does not contain a bare eventData.save() call', () => { + expect(source).not.toMatch(/eventData\.save\(\s*\)/) + }) + } +) diff --git a/tests/server/api/events/payment-deletion.test.js b/tests/server/api/events/payment-deletion.test.js new file mode 100644 index 0000000..01e7634 --- /dev/null +++ b/tests/server/api/events/payment-deletion.test.js @@ -0,0 +1,31 @@ +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) + }) +}) diff --git a/tests/server/api/helcim-existing-card.test.js b/tests/server/api/helcim-existing-card.test.js index 8cf77ce..4dcf2ce 100644 --- a/tests/server/api/helcim-existing-card.test.js +++ b/tests/server/api/helcim-existing-card.test.js @@ -82,6 +82,28 @@ describe('helcim existing-card endpoint', () => { 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 () => { requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) listHelcimCustomerCards.mockResolvedValue([{ default: true }]) diff --git a/tests/server/api/helcim-payment.test.js b/tests/server/api/helcim-payment.test.js index d5df7f6..1c8d724 100644 --- a/tests/server/api/helcim-payment.test.js +++ b/tests/server/api/helcim-payment.test.js @@ -3,9 +3,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js' import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.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 Series from '../../../server/models/series.js' import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js' import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -17,15 +16,12 @@ vi.mock('../../../server/utils/auth.js', () => ({ vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() })) vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} })) 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/series.js', () => ({ default: { findOne: vi.fn() } })) // helcimInitializePaymentSchema is a Nitro auto-import used by validateBody 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() vi.stubGlobal('fetch', mockFetch) @@ -34,7 +30,7 @@ describe('initialize-payment endpoint', () => { vi.clearAllMocks() getOptionalMember.mockResolvedValue(null) Member.findOne.mockResolvedValue(null) - loadPublicSeries.mockResolvedValue(null) + Series.findOne.mockResolvedValue(null) }) afterEach(() => { @@ -188,13 +184,13 @@ describe('initialize-payment endpoint', () => { }) }) - it('re-derives series_ticket price via loadPublicSeries + calculateSeriesTicketPrice', async () => { + it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => { const body = { amount: 100, // tampered metadata: { type: 'series_ticket', seriesId: 'ser-x' } } globalThis.validateBody.mockResolvedValue(body) - loadPublicSeries.mockResolvedValue({ + Series.findOne.mockResolvedValue({ _id: 'ser-x', title: 'Coop Foundations', tickets: { enabled: true, public: { available: true, price: 7500 } } @@ -219,7 +215,7 @@ describe('initialize-payment endpoint', () => { expect(sentBody.amount).toBe(7500) expect(sentBody.paymentType).toBe('purchase') expect(result.amount).toBe(7500) - expect(loadPublicSeries).toHaveBeenCalled() + expect(Series.findOne).toHaveBeenCalled() }) it('uses member pricing when metadata.email matches an active member', async () => { diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js index 24f8737..7a41e26 100644 --- a/tests/server/api/helcim-subscription.test.js +++ b/tests/server/api/helcim-subscription.test.js @@ -5,12 +5,11 @@ import { requireAuth } from '../../../server/utils/auth.js' import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js' import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.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 { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => ({ - default: { findOneAndUpdate: vi.fn(), findOne: vi.fn(), findById: vi.fn() } + default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ @@ -42,9 +41,8 @@ vi.stubGlobal('helcimSubscriptionSchema', {}) describe('helcim subscription endpoint', () => { beforeEach(() => { vi.clearAllMocks() - // Default: pre-update doc reflects first activation from pending_payment. - // findOneAndUpdate returns pre-update doc; findById returns post-update doc. - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-default', status: 'pending_payment' }) + // Default: first activation from pending_payment + Member.findOne.mockResolvedValue({ status: 'pending_payment' }) }) it('requires auth', async () => { @@ -79,8 +77,7 @@ describe('helcim subscription endpoint', () => { status: 'active', save: vi.fn() } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue(mockMember) const event = createMockEvent({ method: 'POST', @@ -103,9 +100,8 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, expect.objectContaining({ status: 'active', contributionAmount: 0 }), - { new: false, projection: { status: 1 } } + { new: true } ) - expect(Member.findById).toHaveBeenCalledWith('member-1') expect(createHelcimSubscription).not.toHaveBeenCalled() }) @@ -139,8 +135,7 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-2', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }] }) @@ -161,9 +156,8 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, { $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) }, - { new: false, runValidators: false, projection: { status: 1 } } + { new: true, runValidators: false } ) - expect(Member.findById).toHaveBeenCalledWith('member-2') }) it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => { @@ -179,8 +173,7 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }] }) @@ -201,9 +194,8 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, { $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) }, - { new: false, runValidators: false, projection: { status: 1 } } + { new: true, runValidators: false } ) - expect(Member.findById).toHaveBeenCalledWith('member-3') }) it('annual $50 tier recurringAmount is 600', async () => { @@ -219,8 +211,7 @@ describe('helcim subscription endpoint', () => { contributionAmount: 50, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }] }) @@ -292,8 +283,7 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-9', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }] }) @@ -332,8 +322,7 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-10', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }] }) @@ -369,8 +358,7 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-11', status: 'pending_payment' }) - Member.findById.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }] }) @@ -388,120 +376,6 @@ describe('helcim subscription endpoint', () => { 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 () => { requireAuth.mockResolvedValue(undefined) requiresPayment.mockReturnValue(true) diff --git a/tests/server/api/reconcile-payments-route.test.js b/tests/server/api/reconcile-payments-route.test.js index 6b7c4c1..9622153 100644 --- a/tests/server/api/reconcile-payments-route.test.js +++ b/tests/server/api/reconcile-payments-route.test.js @@ -2,20 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.js' import Payment from '../../../server/models/payment.js' -import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js' +import { listHelcimCustomerTransactions } from '../../../server/utils/helcim.js' import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js' import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js' import { createMockEvent } from '../helpers/createMockEvent.js' vi.mock('../../../server/models/member.js', () => ({ - default: { find: vi.fn(), findByIdAndUpdate: vi.fn() } + default: { find: vi.fn() } })) vi.mock('../../../server/models/payment.js', () => ({ default: { findOne: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/helcim.js', () => ({ - getHelcimCustomer: vi.fn(), listHelcimCustomerTransactions: vi.fn() })) vi.mock('../../../server/utils/payments.js', () => ({ @@ -89,7 +88,6 @@ describe('POST /api/internal/reconcile-payments', () => { _id: 'm1', email: 'a@example.com', helcimCustomerId: 'cust-1', - helcimCustomerCode: 'CST-1', helcimSubscriptionId: 'sub-1', billingCadence: 'monthly' } @@ -115,7 +113,6 @@ describe('POST /api/internal/reconcile-payments', () => { { helcimCustomerId: { $exists: true, $ne: null } }, expect.objectContaining({ helcimCustomerId: 1, - helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 }) @@ -137,7 +134,7 @@ describe('POST /api/internal/reconcile-payments', () => { it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => { Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } + { _id: 'm1', helcimCustomerId: 'cust-1' } ])) listHelcimCustomerTransactions.mockResolvedValue([ { id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' } @@ -160,17 +157,17 @@ describe('POST /api/internal/reconcile-payments', () => { it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => { Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }, - { _id: 'm2', helcimCustomerId: 'cust-2', helcimCustomerCode: 'CST-2' }, - { _id: 'm3', helcimCustomerId: 'cust-3', helcimCustomerCode: 'CST-3' } + { _id: 'm1', helcimCustomerId: 'cust-1' }, + { _id: 'm2', helcimCustomerId: 'cust-2' }, + { _id: 'm3', helcimCustomerId: 'cust-3' } ])) // m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try. - // Keyed by customerCode so it works regardless of call order (chunked Promise.all). - listHelcimCustomerTransactions.mockImplementation((customerCode) => { - if (customerCode === 'cust-1') return Promise.resolve([{ id: 'tx1', status: 'paid', amount: 5 }]) - if (customerCode === 'cust-3') return Promise.resolve([{ id: 'tx3', status: 'paid', amount: 7 }]) - return Promise.reject(new Error('helcim 503')) - }) + listHelcimCustomerTransactions + .mockResolvedValueOnce([{ id: 'tx1', status: 'paid', amount: 5 }]) + .mockRejectedValueOnce(new Error('helcim 503')) + .mockRejectedValueOnce(new Error('helcim 503')) + .mockRejectedValueOnce(new Error('helcim 503')) + .mockResolvedValueOnce([{ id: 'tx3', status: 'paid', amount: 7 }]) upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } }) vi.useFakeTimers() @@ -194,7 +191,7 @@ describe('POST /api/internal/reconcile-payments', () => { it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => { vi.useFakeTimers() Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } + { _id: 'm1', helcimCustomerId: 'cust-1' } ])) listHelcimCustomerTransactions .mockRejectedValueOnce(new Error('boom 1')) @@ -222,7 +219,7 @@ describe('POST /api/internal/reconcile-payments', () => { it('counts memberErrors when all 3 retry attempts fail', async () => { vi.useFakeTimers() Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } + { _id: 'm1', helcimCustomerId: 'cust-1' } ])) listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503')) @@ -244,7 +241,7 @@ describe('POST /api/internal/reconcile-payments', () => { it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => { Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } + { _id: 'm1', helcimCustomerId: 'cust-1' } ])) listHelcimCustomerTransactions.mockResolvedValue([ { id: 'tx-existing', status: 'paid', amount: 10 }, @@ -269,63 +266,4 @@ describe('POST /api/internal/reconcile-payments', () => { 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 - }) - }) }) diff --git a/tests/server/api/series-tickets-purchase.test.js b/tests/server/api/series-tickets-purchase.test.js index c8f03d5..16483e1 100644 --- a/tests/server/api/series-tickets-purchase.test.js +++ b/tests/server/api/series-tickets-purchase.test.js @@ -1,177 +1,98 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' -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' +const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]') -vi.mock('../../../server/models/member.js', () => ({ - 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), -})) +describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => { + const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8') -// Auto-imports the handler relies on but the global setup doesn't stub. -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 -} - -const baseSeries = () => ({ - _id: 'series-1', - id: 'series-1', - slug: 'series-slug', - title: 'Test Series', - description: 'desc', - type: 'workshop', - registrations: [], -}) - -describe('POST /api/series/[id]/tickets/purchase — guest upsert + auth cookie', () => { - beforeEach(() => { - vi.clearAllMocks() - validateBodyCalls.length = 0 - // Default: unauthenticated buyer - globalThis.requireAuth = vi.fn().mockRejectedValue( - Object.assign(new Error('Unauthorized'), { statusCode: 401 }) - ) - Series.findOne.mockResolvedValue(baseSeries()) - Member.findOne.mockResolvedValue(null) - validateSeriesTicketPurchase.mockReturnValue({ - valid: true, - ticketInfo: { - ticketType: 'public', - price: 0, - currency: 'CAD', - isFree: true, - }, - }) + it('uses validateBody with seriesTicketPurchaseSchema', () => { + expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)') }) - it('upserts a guest Member with $setOnInsert + upsert:true when buyer has no account', async () => { - Member.findOneAndUpdate.mockResolvedValue({ - _id: 'new-member-1', - email: 'guest@example.com', - status: 'guest', - }) - - 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('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => { + // Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in + // checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`. + expect(source).toContain('findOneAndUpdate') + expect(source).toContain('$setOnInsert') + expect(source).toContain('status: "guest"') + expect(source).toContain('upsert: true') + expect(source).toContain('circle: "community"') + expect(source).toContain('contributionAmount: 0') + // ALWAYS-CREATE — must NOT gate on a createAccount flag + expect(source).not.toContain('body.createAccount') }) - 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('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('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('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('rejects invalid body (no name) via schema validation', async () => { - await expect( - handler(buildEvent({ email: 'a@b.com', ticketType: 'public' })) - ).rejects.toBeDefined() + 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') + }) - expect(Member.findOneAndUpdate).not.toHaveBeenCalled() - expect(setAuthCookieMock).not.toHaveBeenCalled() + 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)', () => { + const source = readFileSync( + resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'), + 'utf-8' + ) + + it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => { + expect(source).toContain('useAuth().checkMemberStatus()') + expect(source).toMatch(/purchaseResponse\?\.signedIn/) + }) + + it('shows a one-line guest-account hint under the form (no checkbox)', () => { + // Per ALWAYS-CREATE-GUEST decision: hint only, no UI control. + expect(source).toMatch(/free guest account/i) + // Make sure no checkbox was added by mistake. + expect(source).not.toMatch(/createAccount/) + expect(source).not.toMatch(/]*type="checkbox"/i) }) }) diff --git a/tests/server/utils/helcim.test.js b/tests/server/utils/helcim.test.js index 5fe63b0..d863b99 100644 --- a/tests/server/utils/helcim.test.js +++ b/tests/server/utils/helcim.test.js @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { - listHelcimCustomerCards, listHelcimCustomerTransactions, updateHelcimCustomerDefaultPaymentMethod, updateHelcimSubscriptionPaymentMethod @@ -26,56 +25,6 @@ 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', () => { beforeEach(() => { vi.clearAllMocks()