diff --git a/app/composables/useMemberPayment.js b/app/composables/useMemberPayment.js index 23255a7..fcab6fe 100644 --- a/app/composables/useMemberPayment.js +++ b/app/composables/useMemberPayment.js @@ -25,17 +25,45 @@ export const useMemberPayment = () => { paymentSuccess.value = false try { - // 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) => { + // 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) => { 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 + } + } - 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) { await initializeHelcimPay( diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 3621574..c001e7c 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -133,9 +133,8 @@ 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 5efb6f6..2b0efdf 100644 --- a/app/pages/member/payment-setup.vue +++ b/app/pages/member/payment-setup.vue @@ -85,21 +85,46 @@ const initialize = async () => { } try { - // 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) => { + // 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) => { 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(existing?.cardToken); + }); + probedExistingCard = true; + if (existing?.cardToken) { + customerId.value = memberData.value.helcimCustomerId; + customerCode.value = memberData.value.helcimCustomerCode; + hasExistingCard.value = true; + } + } 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'; } catch (err) { diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index 5978ec7..2ca99c6 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -1,18 +1,32 @@ // 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 { 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."; try { - await sendMagicLink(email); + await sendMagicLink(body.email); return { success: true, message: GENERIC_MESSAGE, diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js index 02316c0..7f0b808 100644 --- a/server/api/auth/member.get.js +++ b/server/api/auth/member.get.js @@ -14,6 +14,7 @@ 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 1a0a6cd..173acb6 100644 --- a/server/api/auth/verify.post.js +++ b/server/api/auth/verify.post.js @@ -1,11 +1,18 @@ // 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 b267ebd..3382b7f 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => { circle: body.circle, contributionAmount: body.contributionAmount, helcimCustomerId: customerData.id, + helcimCustomerCode: customerData.customerCode, status: 'pending_payment', 'agreement.acceptedAt': new Date() } @@ -75,6 +76,7 @@ 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 14405fc..6ec83a6 100644 --- a/server/api/helcim/existing-card.get.js +++ b/server/api/helcim/existing-card.get.js @@ -9,10 +9,7 @@ export default defineEventHandler(async (event) => { return { cardToken: null } } - const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId) - const cards = Array.isArray(cardsResponse) - ? cardsResponse - : (cardsResponse?.cards || cardsResponse?.data || []) + const cards = await listHelcimCustomerCards(member.helcimCustomerId) 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 a23ac35..5a60897 100644 --- a/server/api/helcim/get-or-create-customer.post.js +++ b/server/api/helcim/get-or-create-customer.post.js @@ -18,6 +18,13 @@ 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, @@ -49,10 +56,13 @@ export default defineEventHandler(async (event) => { } if (existingCustomer) { - if (!member.helcimCustomerId) { + if (!member.helcimCustomerId || !member.helcimCustomerCode) { await Member.findByIdAndUpdate( member._id, - { $set: { helcimCustomerId: existingCustomer.id } }, + { $set: { + helcimCustomerId: existingCustomer.id, + helcimCustomerCode: existingCustomer.customerCode + } }, { runValidators: false } ) } @@ -73,7 +83,10 @@ export default defineEventHandler(async (event) => { await Member.findByIdAndUpdate( member._id, - { $set: { helcimCustomerId: customerData.id } }, + { $set: { + helcimCustomerId: customerData.id, + helcimCustomerCode: customerData.customerCode + } }, { runValidators: false } ) diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js index 71996b1..a01b8d0 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 === 'event_ticket' - const isSeriesTicket = metaType === 'series_ticket' + const isEventTicket = metaType === PAYMENT_METADATA_TYPES.EVENT_TICKET + const isSeriesTicket = metaType === PAYMENT_METADATA_TYPES.SERIES_TICKET const isTicket = isEventTicket || isSeriesTicket - const isMembershipSignup = metaType === 'membership_signup' + const isMembershipSignup = metaType === PAYMENT_METADATA_TYPES.MEMBERSHIP_SIGNUP if (!isTicket) { if (isMembershipSignup) { @@ -55,14 +55,7 @@ export default defineEventHandler(async (event) => { if (!seriesId) { throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' }) } - 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 series = await loadPublicSeries(event, seriesId) 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 b5567bc..db4ffb7 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -90,26 +90,22 @@ 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, just update member status - const member = await Member.findOneAndUpdate( + // For free tier, atomically capture pre-update status alongside the write. + // Welcome email only fires on pending_payment → active transitions, not + // on tier upgrades (active → active). + const preMember = await Member.findOneAndUpdate( { helcimCustomerId: body.customerId }, { status: 'active', contributionAmount: body.contributionAmount, subscriptionStartDate: new Date() }, - { new: 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 }) @@ -175,8 +171,10 @@ export default defineEventHandler(async (event) => { ? new Date(subscription.nextBillingDate) : null - // Update member in database - const member = await Member.findOneAndUpdate( + // Atomically capture pre-update status alongside the write so we can + // detect the pending_payment → active transition without a separate read + // (which would race with concurrent webhooks/double-clicks). + const preMember = await Member.findOneAndUpdate( { helcimCustomerId: body.customerId }, { $set: { contributionAmount: body.contributionAmount, @@ -190,8 +188,10 @@ export default defineEventHandler(async (event) => { ? { 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 }) diff --git a/server/api/helcim/update-card.post.js b/server/api/helcim/update-card.post.js index b3dd2d9..1ccd17a 100644 --- a/server/api/helcim/update-card.post.js +++ b/server/api/helcim/update-card.post.js @@ -45,10 +45,7 @@ export default defineEventHandler(async (event) => { const { cardToken } = body // Step 3: verify the submitted token is attached to this member's customer - const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId) - const cards = Array.isArray(cardsResponse) - ? cardsResponse - : (cardsResponse?.cards || cardsResponse?.data || []) + const cards = await listHelcimCustomerCards(member.helcimCustomerId) 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 5f3238a..e00d28d 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 = Array.isArray(cards) && cards.some(card => + const cardExists = 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 9c0686f..f59e838 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 { listHelcimCustomerTransactions } from '../../utils/helcim.js' +import { getHelcimCustomer, 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, helcimSubscriptionId: 1, billingCadence: 1 } + { _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 } ).lean() let txExamined = 0 @@ -65,37 +65,75 @@ export default defineEventHandler(async (event) => { let skipped = 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 try { txs = await listTransactionsWithRetry(member.helcimCustomerId) } catch (err) { - memberErrors++ 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) { - txExamined++ + result.txExamined++ if (!RECONCILABLE_STATUSES.has(tx?.status)) { - skipped++ + result.skipped++ continue } if (!apply) { const existing = await Payment.findOne({ helcimTransactionId: tx.id }) - if (existing) existed++ - else created++ + if (existing) result.existed++ + else result.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 result = await upsertPaymentFromHelcim(member, tx) - if (result.created) created++ - else if (result.payment) existed++ - else skipped++ + 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 } } diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 93b46e6..384ae8f 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -61,6 +61,7 @@ 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 be18bdc..000f9b1 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 Series from "../../models/series.js"; +import { loadPublicSeries } from "../../utils/loadSeries.js"; import { connectDB } from "../../utils/mongoose.js"; 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 - // 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(); + // 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, + }); // 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 cf438ad..f7f0b0b 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,20 +13,7 @@ export default defineEventHandler(async (event) => { const email = query.email; // Fetch series - // 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", - }); - } + const series = await loadPublicSeries(event, seriesId); // 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 0f27b7b..c21d1a9 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,20 +19,7 @@ export default defineEventHandler(async (event) => { const { name, email, paymentId } = body; // Fetch series - // 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", - }); - } + const series = await loadPublicSeries(event, seriesId); // 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 61dd354..3034eb0 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -42,6 +42,7 @@ 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 ca6eb34..a96f630 100644 --- a/server/utils/helcim.js +++ b/server/utils/helcim.js @@ -86,8 +86,10 @@ export const createHelcimCustomer = (payload) => export const updateHelcimCustomer = (id, payload) => helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' }) -export const listHelcimCustomerCards = (id) => - helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup 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 || []) +} /** * Set a customer's default payment method by card token. diff --git a/server/utils/loadSeries.js b/server/utils/loadSeries.js new file mode 100644 index 0000000..33d9018 --- /dev/null +++ b/server/utils/loadSeries.js @@ -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} 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 new file mode 100644 index 0000000..3c833a8 --- /dev/null +++ b/server/utils/paymentTypes.js @@ -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) diff --git a/server/utils/schemas.js b/server/utils/schemas.js index c04a649..cb75944 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -1,5 +1,6 @@ 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() @@ -71,7 +72,7 @@ export const helcimInitializePaymentSchema = z.object({ amount: z.number().min(0).optional(), customerCode: z.string().max(200).optional(), 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(), 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 new file mode 100644 index 0000000..2a5e1dd --- /dev/null +++ b/tests/client/composables/useMemberPayment.test.js @@ -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) + }) +}) diff --git a/tests/server/api/auth-login.test.js b/tests/server/api/auth-login.test.js index 9e98c4b..8c692e7 100644 --- a/tests/server/api/auth-login.test.js +++ b/tests/server/api/auth-login.test.js @@ -1,5 +1,10 @@ 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() } })) @@ -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', () => { beforeEach(() => { vi.clearAllMocks() + resetRateLimit() }) it('returns generic success message for existing member', async () => { @@ -110,4 +112,92 @@ 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 d018f6d..4cdfdec 100644 --- a/tests/server/api/auth-verify.test.js +++ b/tests/server/api/auth-verify.test.js @@ -3,6 +3,7 @@ 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', () => ({ @@ -33,6 +34,7 @@ const baseMember = { describe('auth verify endpoint', () => { beforeEach(() => { vi.clearAllMocks() + resetRateLimit() }) it('rejects missing token with 400', async () => { @@ -302,4 +304,79 @@ 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 049f694..4645d13 100644 --- a/tests/server/api/event-save-validators.test.js +++ b/tests/server/api/event-save-validators.test.js @@ -1,49 +1,139 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync, existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { describe, it, expect, vi, beforeEach } from 'vitest' -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()', () => { - const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8') +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() })) - it('calls eventData.save with validateBeforeSave: false', () => { - expect(source).toContain('eventData.save({ validateBeforeSave: false })') - }) +vi.stubGlobal('waitlistSchema', waitlistSchema) +vi.stubGlobal('waitlistDeleteSchema', waitlistDeleteSchema) - it('does not contain a bare eventData.save() call', () => { - expect(source).not.toMatch(/eventData\.save\(\s*\)/) - }) -}) +// 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) +})) -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('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) - }) - - it('does not contain a bare eventData.save() call', () => { - expect(source).not.toMatch(/eventData\.save\(\s*\)/) - }) +/** + * 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('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) + }) +}) diff --git a/tests/server/api/events/payment-deletion.test.js b/tests/server/api/events/payment-deletion.test.js deleted file mode 100644 index 01e7634..0000000 --- a/tests/server/api/events/payment-deletion.test.js +++ /dev/null @@ -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) - }) -}) diff --git a/tests/server/api/helcim-existing-card.test.js b/tests/server/api/helcim-existing-card.test.js index 4dcf2ce..8cf77ce 100644 --- a/tests/server/api/helcim-existing-card.test.js +++ b/tests/server/api/helcim-existing-card.test.js @@ -82,28 +82,6 @@ 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 1c8d724..d5df7f6 100644 --- a/tests/server/api/helcim-payment.test.js +++ b/tests/server/api/helcim-payment.test.js @@ -3,8 +3,9 @@ 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' @@ -16,12 +17,15 @@ 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) @@ -30,7 +34,7 @@ describe('initialize-payment endpoint', () => { vi.clearAllMocks() getOptionalMember.mockResolvedValue(null) Member.findOne.mockResolvedValue(null) - Series.findOne.mockResolvedValue(null) + loadPublicSeries.mockResolvedValue(null) }) 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 = { amount: 100, // tampered metadata: { type: 'series_ticket', seriesId: 'ser-x' } } globalThis.validateBody.mockResolvedValue(body) - Series.findOne.mockResolvedValue({ + loadPublicSeries.mockResolvedValue({ _id: 'ser-x', title: 'Coop Foundations', tickets: { enabled: true, public: { available: true, price: 7500 } } @@ -215,7 +219,7 @@ describe('initialize-payment endpoint', () => { expect(sentBody.amount).toBe(7500) expect(sentBody.paymentType).toBe('purchase') expect(result.amount).toBe(7500) - expect(Series.findOne).toHaveBeenCalled() + expect(loadPublicSeries).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 7a41e26..24f8737 100644 --- a/tests/server/api/helcim-subscription.test.js +++ b/tests/server/api/helcim-subscription.test.js @@ -5,11 +5,12 @@ 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() } + default: { findOneAndUpdate: vi.fn(), findOne: vi.fn(), findById: vi.fn() } })) vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) vi.mock('../../../server/utils/auth.js', () => ({ @@ -41,8 +42,9 @@ vi.stubGlobal('helcimSubscriptionSchema', {}) describe('helcim subscription endpoint', () => { beforeEach(() => { vi.clearAllMocks() - // Default: first activation from pending_payment - Member.findOne.mockResolvedValue({ status: 'pending_payment' }) + // 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' }) }) it('requires auth', async () => { @@ -77,7 +79,8 @@ describe('helcim subscription endpoint', () => { status: 'active', save: vi.fn() } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) const event = createMockEvent({ method: 'POST', @@ -100,8 +103,9 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, expect.objectContaining({ status: 'active', contributionAmount: 0 }), - { new: true } + { new: false, projection: { status: 1 } } ) + expect(Member.findById).toHaveBeenCalledWith('member-1') expect(createHelcimSubscription).not.toHaveBeenCalled() }) @@ -135,7 +139,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-2', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }] }) @@ -156,8 +161,9 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, { $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 () => { @@ -173,7 +179,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }] }) @@ -194,8 +201,9 @@ describe('helcim subscription endpoint', () => { expect(Member.findOneAndUpdate).toHaveBeenCalledWith( { helcimCustomerId: 'cust-1' }, { $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 () => { @@ -211,7 +219,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 50, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }] }) @@ -283,7 +292,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-9', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }] }) @@ -322,7 +332,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-10', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }] }) @@ -358,7 +369,8 @@ describe('helcim subscription endpoint', () => { contributionAmount: 15, status: 'active', } - Member.findOneAndUpdate.mockResolvedValue(mockMember) + Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-11', status: 'pending_payment' }) + Member.findById.mockResolvedValue(mockMember) createHelcimSubscription.mockResolvedValue({ data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }] }) @@ -376,6 +388,120 @@ 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 9622153..6b7c4c1 100644 --- a/tests/server/api/reconcile-payments-route.test.js +++ b/tests/server/api/reconcile-payments-route.test.js @@ -2,19 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Member from '../../../server/models/member.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 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() } + default: { find: vi.fn(), findByIdAndUpdate: 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', () => ({ @@ -88,6 +89,7 @@ describe('POST /api/internal/reconcile-payments', () => { _id: 'm1', email: 'a@example.com', helcimCustomerId: 'cust-1', + helcimCustomerCode: 'CST-1', helcimSubscriptionId: 'sub-1', billingCadence: 'monthly' } @@ -113,6 +115,7 @@ describe('POST /api/internal/reconcile-payments', () => { { helcimCustomerId: { $exists: true, $ne: null } }, expect.objectContaining({ helcimCustomerId: 1, + helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 }) @@ -134,7 +137,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' } + { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } ])) listHelcimCustomerTransactions.mockResolvedValue([ { 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 () => { Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1' }, - { _id: 'm2', helcimCustomerId: 'cust-2' }, - { _id: 'm3', helcimCustomerId: 'cust-3' } + { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }, + { _id: 'm2', helcimCustomerId: 'cust-2', helcimCustomerCode: 'CST-2' }, + { _id: 'm3', helcimCustomerId: 'cust-3', helcimCustomerCode: 'CST-3' } ])) // m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try. - 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 }]) + // 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')) + }) upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } }) vi.useFakeTimers() @@ -191,7 +194,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' } + { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } ])) listHelcimCustomerTransactions .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 () => { vi.useFakeTimers() Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1' } + { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } ])) 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 () => { Member.find.mockReturnValue(leanResolver([ - { _id: 'm1', helcimCustomerId: 'cust-1' } + { _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' } ])) listHelcimCustomerTransactions.mockResolvedValue([ { id: 'tx-existing', status: 'paid', amount: 10 }, @@ -266,4 +269,63 @@ 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 16483e1..c8f03d5 100644 --- a/tests/server/api/series-tickets-purchase.test.js +++ b/tests/server/api/series-tickets-purchase.test.js @@ -1,98 +1,177 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { describe, it, expect, vi, beforeEach } from 'vitest' -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)', () => { - const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8') +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), +})) - it('uses validateBody with seriesTicketPurchaseSchema', () => { - expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)') +// 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 +} - 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('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') - }) +const baseSeries = () => ({ + _id: 'series-1', + id: 'series-1', + slug: 'series-slug', + title: 'Test Series', + description: 'desc', + type: 'workshop', + registrations: [], }) -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/) +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('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) + 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('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() }) }) diff --git a/tests/server/utils/helcim.test.js b/tests/server/utils/helcim.test.js index d863b99..5fe63b0 100644 --- a/tests/server/utils/helcim.test.js +++ b/tests/server/utils/helcim.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { + listHelcimCustomerCards, listHelcimCustomerTransactions, updateHelcimCustomerDefaultPaymentMethod, 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', () => { beforeEach(() => { vi.clearAllMocks()