Compare commits
9 commits
4d44e7045c
...
596754acce
| Author | SHA1 | Date | |
|---|---|---|---|
| 596754acce | |||
| 4442c57223 | |||
| 134aef6ab0 | |||
| 27e73e969a | |||
| a2f881e805 | |||
| a6304e1c23 | |||
| 678fdfe388 | |||
| bb3ec5ec6a | |||
| a803afa101 |
34 changed files with 1163 additions and 339 deletions
|
|
@ -25,17 +25,45 @@ export const useMemberPayment = () => {
|
||||||
paymentSuccess.value = false
|
paymentSuccess.value = false
|
||||||
|
|
||||||
try {
|
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) => {
|
||||||
|
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
|
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||||
// to re-save it, breaking retries after a partial-failed signup.
|
// to re-save it, breaking retries after a partial-failed signup.
|
||||||
const [, existing] = await Promise.all([
|
const [, existingFromFull] = await Promise.all([
|
||||||
getOrCreateCustomer(),
|
getOrCreateCustomer(),
|
||||||
$fetch('/api/helcim/existing-card').catch((err) => {
|
probedExistingCard
|
||||||
|
? Promise.resolve(existing)
|
||||||
|
: $fetch('/api/helcim/existing-card').catch((err) => {
|
||||||
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err)
|
||||||
return null
|
return null
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
let cardToken = existing?.cardToken || null
|
cardToken = existingFromFull?.cardToken || null
|
||||||
|
}
|
||||||
|
|
||||||
if (!cardToken) {
|
if (!cardToken) {
|
||||||
await initializeHelcimPay(
|
await initializeHelcimPay(
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,8 @@ const filterOptions = [
|
||||||
const { data: eventsData } = await useFetch("/api/events");
|
const { data: eventsData } = await useFetch("/api/events");
|
||||||
const { data: seriesData } = await useFetch("/api/series");
|
const { data: seriesData } = await useFetch("/api/series");
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const filteredEvents = computed(() => {
|
const filteredEvents = computed(() => {
|
||||||
|
const now = new Date();
|
||||||
if (!eventsData.value) return [];
|
if (!eventsData.value) return [];
|
||||||
return eventsData.value.filter((event) => {
|
return eventsData.value.filter((event) => {
|
||||||
if (!includePastEvents.value && new Date(event.startDate) < now)
|
if (!includePastEvents.value && new Date(event.startDate) < now)
|
||||||
|
|
|
||||||
|
|
@ -85,22 +85,47 @@ const initialize = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExistingCard.value) {
|
||||||
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||||
// to re-save it, breaking retries after a partial-failed signup.
|
// to re-save it, breaking retries after a partial-failed signup.
|
||||||
const [customer, existing] = await Promise.all([
|
const [customer, existingFromFull] = await Promise.all([
|
||||||
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }),
|
$fetch('/api/helcim/get-or-create-customer', { method: 'POST' }),
|
||||||
$fetch('/api/helcim/existing-card').catch((err) => {
|
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);
|
console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
customerId.value = customer.customerId;
|
customerId.value = customer.customerId;
|
||||||
customerCode.value = customer.customerCode;
|
customerCode.value = customer.customerCode;
|
||||||
hasExistingCard.value = Boolean(existing?.cardToken);
|
hasExistingCard.value = Boolean(existingFromFull?.cardToken);
|
||||||
|
|
||||||
if (!hasExistingCard.value) {
|
if (!hasExistingCard.value) {
|
||||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
step.value = 'ready';
|
step.value = 'ready';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Payment setup init failed:', err);
|
console.error('Payment setup init failed:', err);
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
// server/api/auth/login.post.js
|
// server/api/auth/login.post.js
|
||||||
|
import { getRequestIP } from "h3";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
import { validateBody } from "../../utils/validateBody.js";
|
import { validateBody } from "../../utils/validateBody.js";
|
||||||
import { emailSchema } from "../../utils/schemas.js";
|
import { emailSchema } from "../../utils/schemas.js";
|
||||||
import { sendMagicLink } from "../../utils/magicLink.js";
|
import { sendMagicLink } from "../../utils/magicLink.js";
|
||||||
|
import { rateLimit } from "../../utils/rateLimit.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) || "unknown";
|
||||||
|
if (!rateLimit(`auth:login:ip:${ip}`, { max: 5, windowMs: 3600_000 })) {
|
||||||
|
throw createError({ statusCode: 429, statusMessage: "Too many login attempts" });
|
||||||
|
}
|
||||||
|
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
const { email } = await validateBody(event, emailSchema);
|
const body = await validateBody(event, emailSchema);
|
||||||
|
|
||||||
|
if (!rateLimit(`auth:login:email:${body.email}`, { max: 3, windowMs: 3600_000 })) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: "Too many login attempts for this email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendMagicLink(email);
|
await sendMagicLink(body.email);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: GENERIC_MESSAGE,
|
message: GENERIC_MESSAGE,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
|
||||||
contributionAmount: member.contributionAmount,
|
contributionAmount: member.contributionAmount,
|
||||||
billingCadence: member.billingCadence,
|
billingCadence: member.billingCadence,
|
||||||
helcimCustomerId: member.helcimCustomerId,
|
helcimCustomerId: member.helcimCustomerId,
|
||||||
|
helcimCustomerCode: member.helcimCustomerCode,
|
||||||
nextBillingDate: member.nextBillingDate,
|
nextBillingDate: member.nextBillingDate,
|
||||||
membershipLevel: `${member.circle}-${member.contributionAmount}`,
|
membershipLevel: `${member.circle}-${member.contributionAmount}`,
|
||||||
// Profile fields
|
// Profile fields
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
// server/api/auth/verify.post.js
|
// server/api/auth/verify.post.js
|
||||||
|
import { getRequestIP } from 'h3'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { validateBody } from '../../utils/validateBody.js'
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
import { verifyMagicLinkSchema } from '../../utils/schemas.js'
|
import { verifyMagicLinkSchema } from '../../utils/schemas.js'
|
||||||
import { setAuthCookie } from '../../utils/auth.js'
|
import { setAuthCookie } from '../../utils/auth.js'
|
||||||
|
import { rateLimit } from '../../utils/rateLimit.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
|
||||||
|
if (!rateLimit(`auth:verify:ip:${ip}`, { max: 5, windowMs: 3600_000 })) {
|
||||||
|
throw createError({ statusCode: 429, statusMessage: 'Too many verification attempts' })
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = await validateBody(event, verifyMagicLinkSchema)
|
const { token } = await validateBody(event, verifyMagicLinkSchema)
|
||||||
|
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
||||||
circle: body.circle,
|
circle: body.circle,
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
helcimCustomerId: customerData.id,
|
helcimCustomerId: customerData.id,
|
||||||
|
helcimCustomerCode: customerData.customerCode,
|
||||||
status: 'pending_payment',
|
status: 'pending_payment',
|
||||||
'agreement.acceptedAt': new Date()
|
'agreement.acceptedAt': new Date()
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +76,7 @@ export default defineEventHandler(async (event) => {
|
||||||
circle: body.circle,
|
circle: body.circle,
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
helcimCustomerId: customerData.id,
|
helcimCustomerId: customerData.id,
|
||||||
|
helcimCustomerCode: customerData.customerCode,
|
||||||
status: 'pending_payment',
|
status: 'pending_payment',
|
||||||
agreement: { acceptedAt: new Date() }
|
agreement: { acceptedAt: new Date() }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,7 @@ export default defineEventHandler(async (event) => {
|
||||||
return { cardToken: null }
|
return { cardToken: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId)
|
const cards = await listHelcimCustomerCards(member.helcimCustomerId)
|
||||||
const cards = Array.isArray(cardsResponse)
|
|
||||||
? cardsResponse
|
|
||||||
: (cardsResponse?.cards || cardsResponse?.data || [])
|
|
||||||
|
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
return { cardToken: null }
|
return { cardToken: null }
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const customer = await getHelcimCustomer(member.helcimCustomerId)
|
const customer = await getHelcimCustomer(member.helcimCustomerId)
|
||||||
if (customer?.id) {
|
if (customer?.id) {
|
||||||
|
if (!member.helcimCustomerCode && customer.customerCode) {
|
||||||
|
await Member.findByIdAndUpdate(
|
||||||
|
member._id,
|
||||||
|
{ $set: { helcimCustomerCode: customer.customerCode } },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
|
|
@ -49,10 +56,13 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingCustomer) {
|
if (existingCustomer) {
|
||||||
if (!member.helcimCustomerId) {
|
if (!member.helcimCustomerId || !member.helcimCustomerCode) {
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
member._id,
|
||||||
{ $set: { helcimCustomerId: existingCustomer.id } },
|
{ $set: {
|
||||||
|
helcimCustomerId: existingCustomer.id,
|
||||||
|
helcimCustomerCode: existingCustomer.customerCode
|
||||||
|
} },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +83,10 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
member._id,
|
||||||
{ $set: { helcimCustomerId: customerData.id } },
|
{ $set: {
|
||||||
|
helcimCustomerId: customerData.id,
|
||||||
|
helcimCustomerCode: customerData.customerCode
|
||||||
|
} },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import Series from '../../models/series.js'
|
|
||||||
import { loadPublicEvent } from '../../utils/loadEvent.js'
|
import { loadPublicEvent } from '../../utils/loadEvent.js'
|
||||||
|
import { loadPublicSeries } from '../../utils/loadSeries.js'
|
||||||
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
|
import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
|
||||||
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
|
import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
|
||||||
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
||||||
|
|
@ -10,10 +10,10 @@ export default defineEventHandler(async (event) => {
|
||||||
const body = await validateBody(event, helcimInitializePaymentSchema)
|
const body = await validateBody(event, helcimInitializePaymentSchema)
|
||||||
const metaType = body.metadata?.type
|
const metaType = body.metadata?.type
|
||||||
|
|
||||||
const isEventTicket = metaType === 'event_ticket'
|
const isEventTicket = metaType === PAYMENT_METADATA_TYPES.EVENT_TICKET
|
||||||
const isSeriesTicket = metaType === 'series_ticket'
|
const isSeriesTicket = metaType === PAYMENT_METADATA_TYPES.SERIES_TICKET
|
||||||
const isTicket = isEventTicket || isSeriesTicket
|
const isTicket = isEventTicket || isSeriesTicket
|
||||||
const isMembershipSignup = metaType === 'membership_signup'
|
const isMembershipSignup = metaType === PAYMENT_METADATA_TYPES.MEMBERSHIP_SIGNUP
|
||||||
|
|
||||||
if (!isTicket) {
|
if (!isTicket) {
|
||||||
if (isMembershipSignup) {
|
if (isMembershipSignup) {
|
||||||
|
|
@ -55,14 +55,7 @@ export default defineEventHandler(async (event) => {
|
||||||
if (!seriesId) {
|
if (!seriesId) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' })
|
throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' })
|
||||||
}
|
}
|
||||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId)
|
const series = await loadPublicSeries(event, seriesId)
|
||||||
const seriesQuery = isObjectId
|
|
||||||
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
|
|
||||||
: { $or: [{ id: seriesId }, { slug: seriesId }] }
|
|
||||||
const series = await Series.findOne(seriesQuery)
|
|
||||||
if (!series) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Series not found' })
|
|
||||||
}
|
|
||||||
const ticketInfo = calculateSeriesTicketPrice(series, accessMember)
|
const ticketInfo = calculateSeriesTicketPrice(series, accessMember)
|
||||||
if (!ticketInfo) {
|
if (!ticketInfo) {
|
||||||
throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' })
|
throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' })
|
||||||
|
|
|
||||||
|
|
@ -90,26 +90,22 @@ export default defineEventHandler(async (event) => {
|
||||||
await connectDB()
|
await connectDB()
|
||||||
const body = await validateBody(event, helcimSubscriptionSchema)
|
const body = await validateBody(event, helcimSubscriptionSchema)
|
||||||
|
|
||||||
// Only send welcome email when a member transitions from pending_payment
|
|
||||||
// to active for the first time — not on tier upgrades (active → active).
|
|
||||||
const priorMember = await Member.findOne(
|
|
||||||
{ helcimCustomerId: body.customerId },
|
|
||||||
{ status: 1 }
|
|
||||||
)
|
|
||||||
const isFirstActivation = priorMember?.status === 'pending_payment'
|
|
||||||
|
|
||||||
// Check if payment is required
|
// Check if payment is required
|
||||||
if (!requiresPayment(body.contributionAmount)) {
|
if (!requiresPayment(body.contributionAmount)) {
|
||||||
// For free tier, just update member status
|
// For free tier, atomically capture pre-update status alongside the write.
|
||||||
const member = await Member.findOneAndUpdate(
|
// Welcome email only fires on pending_payment → active transitions, not
|
||||||
|
// on tier upgrades (active → active).
|
||||||
|
const preMember = await Member.findOneAndUpdate(
|
||||||
{ helcimCustomerId: body.customerId },
|
{ helcimCustomerId: body.customerId },
|
||||||
{
|
{
|
||||||
status: 'active',
|
status: 'active',
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
subscriptionStartDate: new Date()
|
subscriptionStartDate: new Date()
|
||||||
},
|
},
|
||||||
{ new: true }
|
{ new: false, projection: { status: 1 } }
|
||||||
)
|
)
|
||||||
|
const isFirstActivation = preMember?.status === 'pending_payment'
|
||||||
|
const member = await Member.findById(preMember._id)
|
||||||
|
|
||||||
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
||||||
|
|
||||||
|
|
@ -175,8 +171,10 @@ export default defineEventHandler(async (event) => {
|
||||||
? new Date(subscription.nextBillingDate)
|
? new Date(subscription.nextBillingDate)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Update member in database
|
// Atomically capture pre-update status alongside the write so we can
|
||||||
const member = await Member.findOneAndUpdate(
|
// detect the pending_payment → active transition without a separate read
|
||||||
|
// (which would race with concurrent webhooks/double-clicks).
|
||||||
|
const preMember = await Member.findOneAndUpdate(
|
||||||
{ helcimCustomerId: body.customerId },
|
{ helcimCustomerId: body.customerId },
|
||||||
{ $set: {
|
{ $set: {
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
|
|
@ -190,8 +188,10 @@ export default defineEventHandler(async (event) => {
|
||||||
? { nextBillingDate }
|
? { nextBillingDate }
|
||||||
: {}),
|
: {}),
|
||||||
} },
|
} },
|
||||||
{ new: true, runValidators: false }
|
{ new: false, runValidators: false, projection: { status: 1 } }
|
||||||
)
|
)
|
||||||
|
const isFirstActivation = preMember?.status === 'pending_payment'
|
||||||
|
const member = await Member.findById(preMember._id)
|
||||||
|
|
||||||
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const { cardToken } = body
|
const { cardToken } = body
|
||||||
|
|
||||||
// Step 3: verify the submitted token is attached to this member's customer
|
// Step 3: verify the submitted token is attached to this member's customer
|
||||||
const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId)
|
const cards = await listHelcimCustomerCards(member.helcimCustomerId)
|
||||||
const cards = Array.isArray(cardsResponse)
|
|
||||||
? cardsResponse
|
|
||||||
: (cardsResponse?.cards || cardsResponse?.data || [])
|
|
||||||
|
|
||||||
const matchingCard = cards.find((c) => c?.cardToken === cardToken)
|
const matchingCard = cards.find((c) => c?.cardToken === cardToken)
|
||||||
if (!matchingCard) {
|
if (!matchingCard) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const cards = await listHelcimCustomerCards(body.customerId)
|
const cards = await listHelcimCustomerCards(body.customerId)
|
||||||
|
|
||||||
// Verify the card token exists for this customer
|
// Verify the card token exists for this customer
|
||||||
const cardExists = Array.isArray(cards) && cards.some(card =>
|
const cardExists = cards.some(card =>
|
||||||
card.cardToken === body.cardToken
|
card.cardToken === body.cardToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import Payment from '../../models/payment.js'
|
import Payment from '../../models/payment.js'
|
||||||
import { listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
const members = await Member.find(
|
const members = await Member.find(
|
||||||
{ helcimCustomerId: { $exists: true, $ne: null } },
|
{ helcimCustomerId: { $exists: true, $ne: null } },
|
||||||
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 }
|
{ _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimCustomerCode: 1, helcimSubscriptionId: 1, billingCadence: 1 }
|
||||||
).lean()
|
).lean()
|
||||||
|
|
||||||
let txExamined = 0
|
let txExamined = 0
|
||||||
|
|
@ -65,37 +65,75 @@ export default defineEventHandler(async (event) => {
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let memberErrors = 0
|
let memberErrors = 0
|
||||||
|
|
||||||
for (const member of members) {
|
async function processMember(member) {
|
||||||
|
// Opportunistic backfill: members predating the helcimCustomerCode field
|
||||||
|
// get it filled in here so the daily cron acts as the migration. Only on
|
||||||
|
// the missing path — no overwrite, no extra API call once populated.
|
||||||
|
if (!member.helcimCustomerCode) {
|
||||||
|
try {
|
||||||
|
const customer = await getHelcimCustomer(member.helcimCustomerId)
|
||||||
|
if (customer?.customerCode) {
|
||||||
|
await Member.findByIdAndUpdate(
|
||||||
|
member._id,
|
||||||
|
{ $set: { helcimCustomerCode: customer.customerCode } },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Backfill is best-effort — never fail the reconcile run on it.
|
||||||
|
console.warn(`[reconcile] customerCode backfill failed for member=${member._id}: ${err?.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let txs
|
let txs
|
||||||
try {
|
try {
|
||||||
txs = await listTransactionsWithRetry(member.helcimCustomerId)
|
txs = await listTransactionsWithRetry(member.helcimCustomerId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
memberErrors++
|
|
||||||
console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
|
console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
|
||||||
continue
|
return { error: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = { error: false, txExamined: 0, created: 0, existed: 0, skipped: 0 }
|
||||||
|
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
txExamined++
|
result.txExamined++
|
||||||
if (!RECONCILABLE_STATUSES.has(tx?.status)) {
|
if (!RECONCILABLE_STATUSES.has(tx?.status)) {
|
||||||
skipped++
|
result.skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apply) {
|
if (!apply) {
|
||||||
const existing = await Payment.findOne({ helcimTransactionId: tx.id })
|
const existing = await Payment.findOne({ helcimTransactionId: tx.id })
|
||||||
if (existing) existed++
|
if (existing) result.existed++
|
||||||
else created++
|
else result.created++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: deliberately NOT passing sendConfirmation — cron back-fills must
|
// Note: deliberately NOT passing sendConfirmation — cron back-fills must
|
||||||
// not re-send confirmation emails for transactions the member has already
|
// not re-send confirmation emails for transactions the member has already
|
||||||
// been notified about (or that pre-date Mongo Payment tracking entirely).
|
// been notified about (or that pre-date Mongo Payment tracking entirely).
|
||||||
const result = await upsertPaymentFromHelcim(member, tx)
|
const upsertResult = await upsertPaymentFromHelcim(member, tx)
|
||||||
if (result.created) created++
|
if (upsertResult.created) result.created++
|
||||||
else if (result.payment) existed++
|
else if (upsertResult.payment) result.existed++
|
||||||
else skipped++
|
else result.skipped++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 8
|
||||||
|
for (let i = 0; i < members.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = members.slice(i, i + CHUNK_SIZE)
|
||||||
|
const results = await Promise.all(chunk.map((m) => processMember(m)))
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.error) {
|
||||||
|
memberErrors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txExamined += r.txExamined
|
||||||
|
created += r.created
|
||||||
|
existed += r.existed
|
||||||
|
skipped += r.skipped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export default defineEventHandler(async (event) => {
|
||||||
bio: body.motivation || undefined,
|
bio: body.motivation || undefined,
|
||||||
status: body.contributionAmount === 0 ? 'active' : 'pending_payment',
|
status: body.contributionAmount === 0 ? 'active' : 'pending_payment',
|
||||||
helcimCustomerId: helcimCustomer?.id,
|
helcimCustomerId: helcimCustomer?.id,
|
||||||
|
helcimCustomerCode: helcimCustomer?.customerCode,
|
||||||
agreement: { acceptedAt: new Date() },
|
agreement: { acceptedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Event from "../../models/event.js";
|
import Event from "../../models/event.js";
|
||||||
import Series from "../../models/series.js";
|
import { loadPublicSeries } from "../../utils/loadSeries.js";
|
||||||
import { connectDB } from "../../utils/mongoose.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -15,16 +15,14 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to fetch the Series model first for full ticketing info
|
// Try to fetch the Series model first for full ticketing info.
|
||||||
// Build query conditions based on whether id looks like ObjectId or string
|
// Legacy series may exist only as event metadata (no Series doc), so we
|
||||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
|
// fall through to the events-based path below when no Series doc matches.
|
||||||
const seriesQuery = isObjectId
|
const seriesModel = await loadPublicSeries(event, id, {
|
||||||
? { $or: [{ _id: id }, { id: id }, { slug: id }] }
|
select: "-registrations", // Don't expose registration details
|
||||||
: { $or: [{ id: id }, { slug: id }] };
|
lean: true,
|
||||||
|
allowMissing: true,
|
||||||
const seriesModel = await Series.findOne(seriesQuery)
|
});
|
||||||
.select("-registrations") // Don't expose registration details
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
// Fetch all events in this series
|
// Fetch all events in this series
|
||||||
const events = await Event.find({
|
const events = await Event.find({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Series from "../../../../models/series.js";
|
|
||||||
import Member from "../../../../models/member.js";
|
import Member from "../../../../models/member.js";
|
||||||
|
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
|
||||||
import {
|
import {
|
||||||
calculateSeriesTicketPrice,
|
calculateSeriesTicketPrice,
|
||||||
checkSeriesTicketAvailability,
|
checkSeriesTicketAvailability,
|
||||||
|
|
@ -13,20 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const email = query.email;
|
const email = query.email;
|
||||||
|
|
||||||
// Fetch series
|
// Fetch series
|
||||||
// Build query conditions based on whether seriesId looks like ObjectId or string
|
const series = await loadPublicSeries(event, seriesId);
|
||||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
|
|
||||||
const seriesQuery = isObjectId
|
|
||||||
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
|
|
||||||
: { $or: [{ id: seriesId }, { slug: seriesId }] };
|
|
||||||
|
|
||||||
const series = await Series.findOne(seriesQuery);
|
|
||||||
|
|
||||||
if (!series) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
statusMessage: "Series not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if tickets are enabled
|
// Check if tickets are enabled
|
||||||
if (!series.tickets?.enabled) {
|
if (!series.tickets?.enabled) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Series from "../../../../models/series.js";
|
|
||||||
import Event from "../../../../models/event.js";
|
import Event from "../../../../models/event.js";
|
||||||
import Member from "../../../../models/member.js";
|
import Member from "../../../../models/member.js";
|
||||||
|
import { loadPublicSeries } from "../../../../utils/loadSeries.js";
|
||||||
import {
|
import {
|
||||||
validateSeriesTicketPurchase,
|
validateSeriesTicketPurchase,
|
||||||
calculateSeriesTicketPrice,
|
calculateSeriesTicketPrice,
|
||||||
|
|
@ -19,20 +19,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const { name, email, paymentId } = body;
|
const { name, email, paymentId } = body;
|
||||||
|
|
||||||
// Fetch series
|
// Fetch series
|
||||||
// Build query conditions based on whether seriesId looks like ObjectId or string
|
const series = await loadPublicSeries(event, seriesId);
|
||||||
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
|
|
||||||
const seriesQuery = isObjectId
|
|
||||||
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
|
|
||||||
: { $or: [{ id: seriesId }, { slug: seriesId }] };
|
|
||||||
|
|
||||||
const series = await Series.findOne(seriesQuery);
|
|
||||||
|
|
||||||
if (!series) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
statusMessage: "Series not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check membership — prefer JWT auth for accurate member pricing.
|
// Check membership — prefer JWT auth for accurate member pricing.
|
||||||
// Only members with access (active or pending_payment) get member-tier
|
// Only members with access (active or pending_payment) get member-tier
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const memberSchema = new mongoose.Schema({
|
||||||
default: "pending_payment",
|
default: "pending_payment",
|
||||||
},
|
},
|
||||||
helcimCustomerId: String,
|
helcimCustomerId: String,
|
||||||
|
helcimCustomerCode: String,
|
||||||
helcimSubscriptionId: String,
|
helcimSubscriptionId: String,
|
||||||
billingCadence: {
|
billingCadence: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,10 @@ export const createHelcimCustomer = (payload) =>
|
||||||
export const updateHelcimCustomer = (id, payload) =>
|
export const updateHelcimCustomer = (id, payload) =>
|
||||||
helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' })
|
helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' })
|
||||||
|
|
||||||
export const listHelcimCustomerCards = (id) =>
|
export const listHelcimCustomerCards = async (id) => {
|
||||||
helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' })
|
const raw = await helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' })
|
||||||
|
return Array.isArray(raw) ? raw : (raw?.cards || raw?.data || [])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a customer's default payment method by card token.
|
* Set a customer's default payment method by card token.
|
||||||
|
|
|
||||||
47
server/utils/loadSeries.js
Normal file
47
server/utils/loadSeries.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Series from '../models/series.js'
|
||||||
|
import { connectDB } from './mongoose.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a series by ObjectId, string id, or slug for public endpoints.
|
||||||
|
* Series has three identifier fields (`_id`, `id`, `slug`); this helper
|
||||||
|
* builds the same conditional `$or` query the call sites would otherwise
|
||||||
|
* inline. No isVisible gate today (parity with existing call-site behavior).
|
||||||
|
*
|
||||||
|
* @param {Object} reqEvent - h3 event (reserved for future auth/cookie access)
|
||||||
|
* @param {String} identifier - ObjectId string, string id, or slug
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {Boolean} [options.lean] - apply .lean() to the query
|
||||||
|
* @param {String} [options.select] - apply .select() to the query
|
||||||
|
* @param {Boolean} [options.allowMissing] - return null instead of throwing 404 on miss
|
||||||
|
* @returns {Promise<Object|null>} the series document, or null if allowMissing and not found
|
||||||
|
*/
|
||||||
|
export async function loadPublicSeries(reqEvent, identifier, options = {}) {
|
||||||
|
if (!identifier) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Series identifier is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const { lean = false, select = null, allowMissing = false } = options
|
||||||
|
|
||||||
|
const isObjectId = /^[0-9a-fA-F]{24}$/.test(identifier)
|
||||||
|
const seriesQuery = isObjectId
|
||||||
|
? { $or: [{ _id: identifier }, { id: identifier }, { slug: identifier }] }
|
||||||
|
: { $or: [{ id: identifier }, { slug: identifier }] }
|
||||||
|
|
||||||
|
let query = Series.findOne(seriesQuery)
|
||||||
|
if (select) query = query.select(select)
|
||||||
|
if (lean) query = query.lean()
|
||||||
|
|
||||||
|
const series = await query
|
||||||
|
|
||||||
|
if (!series) {
|
||||||
|
if (allowMissing) return null
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Series not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return series
|
||||||
|
}
|
||||||
15
server/utils/paymentTypes.js
Normal file
15
server/utils/paymentTypes.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Metadata.type values accepted by /api/helcim/initialize-payment.
|
||||||
|
// Shared by the Zod schema (PAYMENT_METADATA_TYPE_VALUES in z.enum) and the
|
||||||
|
// route handler so server-side wire validation stays single-sourced. The client
|
||||||
|
// composable intentionally uses inline string literals — server-side z.enum
|
||||||
|
// rejects any drift as a 400.
|
||||||
|
|
||||||
|
export const PAYMENT_METADATA_TYPES = {
|
||||||
|
EVENT_TICKET: 'event_ticket',
|
||||||
|
SERIES_TICKET: 'series_ticket',
|
||||||
|
SUBSCRIPTION: 'subscription',
|
||||||
|
CARD_VERIFY: 'card_verify',
|
||||||
|
MEMBERSHIP_SIGNUP: 'membership_signup'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAYMENT_METADATA_TYPE_VALUES = Object.values(PAYMENT_METADATA_TYPES)
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js'
|
import { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js'
|
||||||
|
import { PAYMENT_METADATA_TYPE_VALUES } from './paymentTypes.js'
|
||||||
|
|
||||||
export const emailSchema = z.object({
|
export const emailSchema = z.object({
|
||||||
email: z.string().trim().toLowerCase().email()
|
email: z.string().trim().toLowerCase().email()
|
||||||
|
|
@ -71,7 +72,7 @@ export const helcimInitializePaymentSchema = z.object({
|
||||||
amount: z.number().min(0).optional(),
|
amount: z.number().min(0).optional(),
|
||||||
customerCode: z.string().max(200).optional(),
|
customerCode: z.string().max(200).optional(),
|
||||||
metadata: z.object({
|
metadata: z.object({
|
||||||
type: z.enum(['event_ticket', 'series_ticket', 'subscription', 'card_verify', 'membership_signup']).optional(),
|
type: z.enum(PAYMENT_METADATA_TYPE_VALUES).optional(),
|
||||||
eventTitle: z.string().max(500).optional(),
|
eventTitle: z.string().max(500).optional(),
|
||||||
eventId: z.string().max(200).optional(),
|
eventId: z.string().max(200).optional(),
|
||||||
seriesId: z.string().max(200).optional(),
|
seriesId: z.string().max(200).optional(),
|
||||||
|
|
|
||||||
145
tests/client/composables/useMemberPayment.test.js
Normal file
145
tests/client/composables/useMemberPayment.test.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useMemberPayment } from '../../../app/composables/useMemberPayment.js'
|
||||||
|
|
||||||
|
// Stub Vue's ref/readonly as Nuxt auto-imports
|
||||||
|
vi.stubGlobal('ref', ref)
|
||||||
|
vi.stubGlobal('readonly', (v) => v)
|
||||||
|
|
||||||
|
const memberData = ref(null)
|
||||||
|
const checkMemberStatus = vi.fn()
|
||||||
|
vi.stubGlobal('useAuth', () => ({ memberData, checkMemberStatus }))
|
||||||
|
|
||||||
|
const initializeHelcimPay = vi.fn()
|
||||||
|
const verifyPayment = vi.fn()
|
||||||
|
const cleanupHelcimPay = vi.fn()
|
||||||
|
vi.stubGlobal('useHelcimPay', () => ({
|
||||||
|
initializeHelcimPay,
|
||||||
|
verifyPayment,
|
||||||
|
cleanup: cleanupHelcimPay,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const $fetch = vi.fn()
|
||||||
|
vi.stubGlobal('$fetch', $fetch)
|
||||||
|
|
||||||
|
describe('useMemberPayment.initiatePaymentSetup — shortcut path', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
memberData.value = null
|
||||||
|
$fetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips getOrCreateCustomer when both helcim ids are cached AND a card is on file', async () => {
|
||||||
|
memberData.value = {
|
||||||
|
helcimCustomerId: 'cust-123',
|
||||||
|
helcimCustomerCode: 'CST-ABC',
|
||||||
|
contributionAmount: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetch.mockImplementation((path) => {
|
||||||
|
if (path === '/api/helcim/existing-card') {
|
||||||
|
return Promise.resolve({ cardToken: 'tok-xyz' })
|
||||||
|
}
|
||||||
|
if (path === '/api/helcim/subscription') {
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { initiatePaymentSetup } = useMemberPayment()
|
||||||
|
const result = await initiatePaymentSetup()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
// The whole point: get-or-create-customer was NOT called.
|
||||||
|
const calledPaths = $fetch.mock.calls.map((c) => c[0])
|
||||||
|
expect(calledPaths).not.toContain('/api/helcim/get-or-create-customer')
|
||||||
|
expect(calledPaths).not.toContain('/api/helcim/customer-code')
|
||||||
|
|
||||||
|
// We did call existing-card (once) and subscription (once).
|
||||||
|
expect(calledPaths.filter((p) => p === '/api/helcim/existing-card')).toHaveLength(1)
|
||||||
|
expect(calledPaths.filter((p) => p === '/api/helcim/subscription')).toHaveLength(1)
|
||||||
|
|
||||||
|
// HelcimPay modal not opened — card was already on file.
|
||||||
|
expect(initializeHelcimPay).not.toHaveBeenCalled()
|
||||||
|
expect(verifyPayment).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Subscription called with the cached id/code from memberData.
|
||||||
|
const subscriptionCall = $fetch.mock.calls.find((c) => c[0] === '/api/helcim/subscription')
|
||||||
|
expect(subscriptionCall[1].body).toMatchObject({
|
||||||
|
customerId: 'cust-123',
|
||||||
|
customerCode: 'CST-ABC',
|
||||||
|
cardToken: 'tok-xyz',
|
||||||
|
contributionAmount: 15,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls through to get-or-create-customer when helcimCustomerCode is missing', async () => {
|
||||||
|
memberData.value = {
|
||||||
|
helcimCustomerId: 'cust-123',
|
||||||
|
// helcimCustomerCode missing — must NOT take shortcut
|
||||||
|
contributionAmount: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetch.mockImplementation((path) => {
|
||||||
|
if (path === '/api/helcim/customer-code') {
|
||||||
|
return Promise.resolve({ customerId: 'cust-123', customerCode: 'CST-FRESH' })
|
||||||
|
}
|
||||||
|
if (path === '/api/helcim/existing-card') {
|
||||||
|
return Promise.resolve({ cardToken: 'tok-xyz' })
|
||||||
|
}
|
||||||
|
if (path === '/api/helcim/subscription') {
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { initiatePaymentSetup } = useMemberPayment()
|
||||||
|
await initiatePaymentSetup()
|
||||||
|
|
||||||
|
const calledPaths = $fetch.mock.calls.map((c) => c[0])
|
||||||
|
// Existing helcimCustomerId path -> /api/helcim/customer-code is called.
|
||||||
|
expect(calledPaths).toContain('/api/helcim/customer-code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls through to get-or-create-customer when no card is on file', async () => {
|
||||||
|
memberData.value = {
|
||||||
|
helcimCustomerId: 'cust-123',
|
||||||
|
helcimCustomerCode: 'CST-ABC',
|
||||||
|
contributionAmount: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeHelcimPay.mockResolvedValue(undefined)
|
||||||
|
verifyPayment.mockResolvedValue({ success: true, cardToken: 'tok-new' })
|
||||||
|
|
||||||
|
let existingCardCalls = 0
|
||||||
|
$fetch.mockImplementation((path) => {
|
||||||
|
if (path === '/api/helcim/existing-card') {
|
||||||
|
existingCardCalls++
|
||||||
|
return Promise.resolve(null) // no card on file
|
||||||
|
}
|
||||||
|
if (path === '/api/helcim/customer-code') {
|
||||||
|
return Promise.resolve({ customerId: 'cust-123', customerCode: 'CST-ABC' })
|
||||||
|
}
|
||||||
|
if (path === '/api/helcim/verify-payment') {
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
}
|
||||||
|
if (path === '/api/helcim/subscription') {
|
||||||
|
return Promise.resolve({ success: true })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected $fetch call: ${path}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
const { initiatePaymentSetup } = useMemberPayment()
|
||||||
|
await initiatePaymentSetup()
|
||||||
|
|
||||||
|
// Falls into the full flow — modal opens, verify runs.
|
||||||
|
expect(initializeHelcimPay).toHaveBeenCalled()
|
||||||
|
expect(verifyPayment).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const calledPaths = $fetch.mock.calls.map((c) => c[0])
|
||||||
|
expect(calledPaths).toContain('/api/helcim/customer-code')
|
||||||
|
// existing-card was reused from the shortcut probe — should not refetch.
|
||||||
|
expect(existingCardCalls).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import Member from '../../../server/models/member.js'
|
||||||
|
import loginHandler from '../../../server/api/auth/login.post.js'
|
||||||
|
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.js', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() }
|
default: { findOne: vi.fn(), findByIdAndUpdate: vi.fn() }
|
||||||
}))
|
}))
|
||||||
|
|
@ -20,13 +25,10 @@ vi.mock('resend', () => ({
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import Member from '../../../server/models/member.js'
|
|
||||||
import loginHandler from '../../../server/api/auth/login.post.js'
|
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
|
||||||
|
|
||||||
describe('auth login endpoint', () => {
|
describe('auth login endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
resetRateLimit()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns generic success message for existing member', async () => {
|
it('returns generic success message for existing member', async () => {
|
||||||
|
|
@ -110,4 +112,92 @@ describe('auth login endpoint', () => {
|
||||||
statusMessage: 'Validation failed'
|
statusMessage: 'Validation failed'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('rate limiting', () => {
|
||||||
|
it('allows up to 5 login attempts from a single IP', async () => {
|
||||||
|
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' })
|
||||||
|
|
||||||
|
// 5 calls succeed (each with a unique email so we don't hit email limit)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: { email: `u${i}@example.com` },
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
})
|
||||||
|
const result = await loginHandler(event)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rate-limits a single IP after 5 login attempts', async () => {
|
||||||
|
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'ok@example.com' })
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: { email: `u${i}@example.com` },
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
})
|
||||||
|
await loginHandler(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: { email: 'u6@example.com' },
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
})
|
||||||
|
await expect(loginHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 429
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows up to 3 login attempts for a single email', async () => {
|
||||||
|
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' })
|
||||||
|
|
||||||
|
// 3 calls from different IPs succeed
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: { email: 'shared@example.com' },
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
remoteAddress: `10.0.0.${i + 10}`
|
||||||
|
})
|
||||||
|
const result = await loginHandler(event)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rate-limits a single email after 3 login attempts (different IPs)', async () => {
|
||||||
|
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'shared@example.com' })
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: { email: 'shared@example.com' },
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
remoteAddress: `10.0.0.${i + 10}`
|
||||||
|
})
|
||||||
|
await loginHandler(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/login',
|
||||||
|
body: { email: 'shared@example.com' },
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
remoteAddress: '10.0.0.99'
|
||||||
|
})
|
||||||
|
await expect(loginHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 429
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
import verifyHandler from '../../../server/api/auth/verify.post.js'
|
||||||
|
import { resetRateLimit } from '../../../server/utils/rateLimit.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.js', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
|
|
@ -33,6 +34,7 @@ const baseMember = {
|
||||||
describe('auth verify endpoint', () => {
|
describe('auth verify endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
resetRateLimit()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects missing token with 400', async () => {
|
it('rejects missing token with 400', async () => {
|
||||||
|
|
@ -302,4 +304,79 @@ describe('auth verify endpoint', () => {
|
||||||
|
|
||||||
expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' })
|
expect(result).toEqual({ success: true, redirectUrl: '/member/dashboard' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('rate limiting', () => {
|
||||||
|
it('allows up to 5 verify attempts from a single IP', async () => {
|
||||||
|
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||||
|
|
||||||
|
// 5 calls reach jwt.verify (and fail with 401, but not 429)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/verify',
|
||||||
|
body: { token: 'bad-token' },
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
})
|
||||||
|
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 401
|
||||||
|
})
|
||||||
|
}
|
||||||
|
expect(jwt.verify).toHaveBeenCalledTimes(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rate-limits a single IP after 5 verify attempts', async () => {
|
||||||
|
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/verify',
|
||||||
|
body: { token: 'bad-token' },
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
})
|
||||||
|
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 401
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/verify',
|
||||||
|
body: { token: 'bad-token' },
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
})
|
||||||
|
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 429
|
||||||
|
})
|
||||||
|
// Rate limit fires before jwt.verify on the 6th call
|
||||||
|
expect(jwt.verify).toHaveBeenCalledTimes(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not block different IPs (per-IP keying)', async () => {
|
||||||
|
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/verify',
|
||||||
|
body: { token: 'bad-token' },
|
||||||
|
remoteAddress: '10.0.0.1'
|
||||||
|
})
|
||||||
|
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 401
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different IP should still be allowed.
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/auth/verify',
|
||||||
|
body: { token: 'bad-token' },
|
||||||
|
remoteAddress: '10.0.0.2'
|
||||||
|
})
|
||||||
|
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||||
|
statusCode: 401
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,139 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { readFileSync, existsSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
|
|
||||||
const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
|
import Event from '../../../server/models/event.js'
|
||||||
|
import Member from '../../../server/models/member.js'
|
||||||
|
import { waitlistSchema, waitlistDeleteSchema } from '../../../server/utils/schemas.js'
|
||||||
|
import waitlistPostHandler from '../../../server/api/events/[id]/waitlist.post.js'
|
||||||
|
import waitlistDeleteHandler from '../../../server/api/events/[id]/waitlist.delete.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
describe('waitlist.post.js bypasses validators on event.save()', () => {
|
vi.mock('../../../server/models/event.js', () => ({
|
||||||
const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8')
|
default: { findOne: vi.fn() }
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
|
default: { findOne: vi.fn() }
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
|
|
||||||
it('calls eventData.save with validateBeforeSave: false', () => {
|
vi.stubGlobal('waitlistSchema', waitlistSchema)
|
||||||
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
|
vi.stubGlobal('waitlistDeleteSchema', waitlistDeleteSchema)
|
||||||
})
|
|
||||||
|
|
||||||
it('does not contain a bare eventData.save() call', () => {
|
// Override the global validateBody stub so the route actually parses against
|
||||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
// the schema it passed in.
|
||||||
})
|
vi.stubGlobal('validateBody', vi.fn(async (event, schema) => {
|
||||||
})
|
const body = await readBody(event)
|
||||||
|
return schema.parse(body)
|
||||||
|
}))
|
||||||
|
|
||||||
describe('waitlist.delete.js bypasses validators on event.save()', () => {
|
/**
|
||||||
const source = readFileSync(resolve(eventsDir, 'waitlist.delete.js'), 'utf-8')
|
* Build a mock Event document whose `save()` simulates the legacy validator
|
||||||
|
* problem we're protecting against: when called WITHOUT `validateBeforeSave:
|
||||||
it('calls eventData.save with validateBeforeSave: false', () => {
|
* false` it throws (mimicking a stale `location` validator failing on
|
||||||
expect(source).toContain('eventData.save({ validateBeforeSave: false })')
|
* unrelated writes). When called WITH `validateBeforeSave: false` it resolves
|
||||||
})
|
* normally. The route is correct iff it bypasses validators.
|
||||||
|
*/
|
||||||
it('does not contain a bare eventData.save() call', () => {
|
function makeMockEvent(overrides = {}) {
|
||||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
const doc = {
|
||||||
})
|
_id: 'event-1',
|
||||||
})
|
slug: 'event-slug',
|
||||||
|
tickets: {
|
||||||
// payment.post.js cases are handled by Fix #3 (file deletion).
|
waitlist: {
|
||||||
// If the file still exists, it should also pass the validators bypass.
|
enabled: true,
|
||||||
describe.skipIf(!existsSync(resolve(eventsDir, 'payment.post.js')))(
|
maxSize: 10,
|
||||||
'payment.post.js bypasses validators on event.save()',
|
entries: [],
|
||||||
() => {
|
},
|
||||||
const source = existsSync(resolve(eventsDir, 'payment.post.js'))
|
},
|
||||||
? readFileSync(resolve(eventsDir, 'payment.post.js'), 'utf-8')
|
registrations: [],
|
||||||
: ''
|
...overrides,
|
||||||
|
|
||||||
it('has exactly two eventData.save({ validateBeforeSave: false }) calls', () => {
|
|
||||||
const matches = source.match(/eventData\.save\(\{\s*validateBeforeSave:\s*false\s*\}\)/g) || []
|
|
||||||
expect(matches.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not contain a bare eventData.save() call', () => {
|
|
||||||
expect(source).not.toMatch(/eventData\.save\(\s*\)/)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
doc.save = vi.fn(async (options) => {
|
||||||
|
if (!options || options.validateBeforeSave !== false) {
|
||||||
|
const err = new Error('Validation failed: location: legacy field invalid')
|
||||||
|
err.name = 'ValidationError'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
})
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPostEvent(body) {
|
||||||
|
const ev = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/events/event-slug/waitlist',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
ev.context = { params: { id: 'event-slug' } }
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeleteEvent(body) {
|
||||||
|
const ev = createMockEvent({
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/events/event-slug/waitlist',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
ev.context = { params: { id: 'event-slug' } }
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/events/[id]/waitlist — bypasses save validators', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
Member.findOne.mockResolvedValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
|
||||||
|
const mockEvent = makeMockEvent()
|
||||||
|
Event.findOne.mockResolvedValue(mockEvent)
|
||||||
|
|
||||||
|
const result = await waitlistPostHandler(buildPostEvent({
|
||||||
|
name: 'Waiter',
|
||||||
|
email: 'wait@example.com',
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockEvent.save).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
|
||||||
|
// Entry was actually appended.
|
||||||
|
expect(mockEvent.tickets.waitlist.entries).toHaveLength(1)
|
||||||
|
expect(mockEvent.tickets.waitlist.entries[0].email).toBe('wait@example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DELETE /api/events/[id]/waitlist — bypasses save validators', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save() succeeds because the route passes { validateBeforeSave: false }', async () => {
|
||||||
|
const mockEvent = makeMockEvent({
|
||||||
|
tickets: {
|
||||||
|
waitlist: {
|
||||||
|
enabled: true,
|
||||||
|
maxSize: 10,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'Waiter',
|
||||||
|
email: 'wait@example.com',
|
||||||
|
membershipLevel: 'non-member',
|
||||||
|
addedAt: new Date(),
|
||||||
|
notified: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Event.findOne.mockResolvedValue(mockEvent)
|
||||||
|
|
||||||
|
const result = await waitlistDeleteHandler(buildDeleteEvent({
|
||||||
|
email: 'wait@example.com',
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockEvent.save).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockEvent.save).toHaveBeenCalledWith({ validateBeforeSave: false })
|
||||||
|
// Entry was actually removed.
|
||||||
|
expect(mockEvent.tickets.waitlist.entries).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -82,28 +82,6 @@ describe('helcim existing-card endpoint', () => {
|
||||||
expect(result.cardToken).toBe('tok-b')
|
expect(result.cardToken).toBe('tok-b')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('unwraps a { cards: [...] } response envelope', async () => {
|
|
||||||
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
|
|
||||||
listHelcimCustomerCards.mockResolvedValue({
|
|
||||||
cards: [{ cardToken: 'tok-only' }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await existingCardHandler(newEvent())
|
|
||||||
|
|
||||||
expect(result.cardToken).toBe('tok-only')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('unwraps a { data: [...] } response envelope', async () => {
|
|
||||||
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
|
|
||||||
listHelcimCustomerCards.mockResolvedValue({
|
|
||||||
data: [{ cardToken: 'tok-only' }]
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await existingCardHandler(newEvent())
|
|
||||||
|
|
||||||
expect(result.cardToken).toBe('tok-only')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns { cardToken: null } if the resolved card has no cardToken', async () => {
|
it('returns { cardToken: null } if the resolved card has no cardToken', async () => {
|
||||||
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
|
requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 })
|
||||||
listHelcimCustomerCards.mockResolvedValue([{ default: true }])
|
listHelcimCustomerCards.mockResolvedValue([{ default: true }])
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
|
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
|
||||||
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
||||||
import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
|
import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
|
||||||
|
import { loadPublicSeries } from '../../../server/utils/loadSeries.js'
|
||||||
|
import { PAYMENT_METADATA_TYPES } from '../../../server/utils/paymentTypes.js'
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import Series from '../../../server/models/series.js'
|
|
||||||
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
|
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
|
||||||
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
@ -16,12 +17,15 @@ vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||||
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
|
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
|
||||||
|
vi.mock('../../../server/utils/loadSeries.js', () => ({ loadPublicSeries: vi.fn() }))
|
||||||
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
|
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
|
||||||
vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } }))
|
|
||||||
|
|
||||||
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
||||||
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
||||||
|
|
||||||
|
// PAYMENT_METADATA_TYPES is a Nitro auto-import from server/utils/paymentTypes.js
|
||||||
|
vi.stubGlobal('PAYMENT_METADATA_TYPES', PAYMENT_METADATA_TYPES)
|
||||||
|
|
||||||
const mockFetch = vi.fn()
|
const mockFetch = vi.fn()
|
||||||
vi.stubGlobal('fetch', mockFetch)
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
|
@ -30,7 +34,7 @@ describe('initialize-payment endpoint', () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
getOptionalMember.mockResolvedValue(null)
|
getOptionalMember.mockResolvedValue(null)
|
||||||
Member.findOne.mockResolvedValue(null)
|
Member.findOne.mockResolvedValue(null)
|
||||||
Series.findOne.mockResolvedValue(null)
|
loadPublicSeries.mockResolvedValue(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -184,13 +188,13 @@ describe('initialize-payment endpoint', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => {
|
it('re-derives series_ticket price via loadPublicSeries + calculateSeriesTicketPrice', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
amount: 100, // tampered
|
amount: 100, // tampered
|
||||||
metadata: { type: 'series_ticket', seriesId: 'ser-x' }
|
metadata: { type: 'series_ticket', seriesId: 'ser-x' }
|
||||||
}
|
}
|
||||||
globalThis.validateBody.mockResolvedValue(body)
|
globalThis.validateBody.mockResolvedValue(body)
|
||||||
Series.findOne.mockResolvedValue({
|
loadPublicSeries.mockResolvedValue({
|
||||||
_id: 'ser-x',
|
_id: 'ser-x',
|
||||||
title: 'Coop Foundations',
|
title: 'Coop Foundations',
|
||||||
tickets: { enabled: true, public: { available: true, price: 7500 } }
|
tickets: { enabled: true, public: { available: true, price: 7500 } }
|
||||||
|
|
@ -215,7 +219,7 @@ describe('initialize-payment endpoint', () => {
|
||||||
expect(sentBody.amount).toBe(7500)
|
expect(sentBody.amount).toBe(7500)
|
||||||
expect(sentBody.paymentType).toBe('purchase')
|
expect(sentBody.paymentType).toBe('purchase')
|
||||||
expect(result.amount).toBe(7500)
|
expect(result.amount).toBe(7500)
|
||||||
expect(Series.findOne).toHaveBeenCalled()
|
expect(loadPublicSeries).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses member pricing when metadata.email matches an active member', async () => {
|
it('uses member pricing when metadata.email matches an active member', async () => {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ import { requireAuth } from '../../../server/utils/auth.js'
|
||||||
import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js'
|
import { requiresPayment, getHelcimPlanId } from '../../../server/config/contributions.js'
|
||||||
import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
import { createHelcimSubscription, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
||||||
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
||||||
|
import { sendWelcomeEmail } from '../../../server/utils/resend.js'
|
||||||
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
|
import subscriptionHandler from '../../../server/api/helcim/subscription.post.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.js', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
|
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn(), findById: vi.fn() }
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
|
|
@ -41,8 +42,9 @@ vi.stubGlobal('helcimSubscriptionSchema', {})
|
||||||
describe('helcim subscription endpoint', () => {
|
describe('helcim subscription endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// Default: first activation from pending_payment
|
// Default: pre-update doc reflects first activation from pending_payment.
|
||||||
Member.findOne.mockResolvedValue({ status: 'pending_payment' })
|
// findOneAndUpdate returns pre-update doc; findById returns post-update doc.
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-default', status: 'pending_payment' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires auth', async () => {
|
it('requires auth', async () => {
|
||||||
|
|
@ -77,7 +79,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
save: vi.fn()
|
save: vi.fn()
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-1', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
|
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -100,8 +103,9 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
{ helcimCustomerId: 'cust-1' },
|
{ helcimCustomerId: 'cust-1' },
|
||||||
expect.objectContaining({ status: 'active', contributionAmount: 0 }),
|
expect.objectContaining({ status: 'active', contributionAmount: 0 }),
|
||||||
{ new: true }
|
{ new: false, projection: { status: 1 } }
|
||||||
)
|
)
|
||||||
|
expect(Member.findById).toHaveBeenCalledWith('member-1')
|
||||||
expect(createHelcimSubscription).not.toHaveBeenCalled()
|
expect(createHelcimSubscription).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -135,7 +139,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-2', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
data: [{ id: 'sub-monthly-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -156,8 +161,9 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
{ helcimCustomerId: 'cust-1' },
|
{ helcimCustomerId: 'cust-1' },
|
||||||
{ $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) },
|
{ $set: expect.objectContaining({ billingCadence: 'monthly', contributionAmount: 15, status: 'active' }) },
|
||||||
{ new: true, runValidators: false }
|
{ new: false, runValidators: false, projection: { status: 1 } }
|
||||||
)
|
)
|
||||||
|
expect(Member.findById).toHaveBeenCalledWith('member-2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => {
|
it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => {
|
||||||
|
|
@ -173,7 +179,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }]
|
data: [{ id: 'sub-annual-1', status: 'active', nextBillingDate: '2027-04-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -194,8 +201,9 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
{ helcimCustomerId: 'cust-1' },
|
{ helcimCustomerId: 'cust-1' },
|
||||||
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) },
|
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) },
|
||||||
{ new: true, runValidators: false }
|
{ new: false, runValidators: false, projection: { status: 1 } }
|
||||||
)
|
)
|
||||||
|
expect(Member.findById).toHaveBeenCalledWith('member-3')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('annual $50 tier recurringAmount is 600', async () => {
|
it('annual $50 tier recurringAmount is 600', async () => {
|
||||||
|
|
@ -211,7 +219,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 50,
|
contributionAmount: 50,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }]
|
data: [{ id: 'sub-annual-50', status: 'active', nextBillingDate: '2027-04-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -283,7 +292,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-9', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
data: [{ id: 'sub-log-1', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -322,7 +332,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-10', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }]
|
data: [{ id: 'sub-annual-log', status: 'active', nextBillingDate: '2027-04-20' }]
|
||||||
})
|
})
|
||||||
|
|
@ -358,7 +369,8 @@ describe('helcim subscription endpoint', () => {
|
||||||
contributionAmount: 15,
|
contributionAmount: 15,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue(mockMember)
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-11', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
createHelcimSubscription.mockResolvedValue({
|
createHelcimSubscription.mockResolvedValue({
|
||||||
data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }]
|
data: [{ id: 'sub-boom', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
})
|
})
|
||||||
|
|
@ -376,6 +388,120 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
|
expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('first activation (pending_payment → active) sends welcome email on free tier', async () => {
|
||||||
|
requireAuth.mockResolvedValue(undefined)
|
||||||
|
requiresPayment.mockReturnValue(false)
|
||||||
|
|
||||||
|
const mockMember = {
|
||||||
|
_id: 'member-first-free',
|
||||||
|
email: 'newbie@example.com',
|
||||||
|
name: 'Newbie',
|
||||||
|
circle: 'community',
|
||||||
|
contributionAmount: 0,
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
// Pre-update status was pending_payment → first activation
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-free', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/helcim/subscription',
|
||||||
|
body: { customerId: 'cust-first-free', contributionAmount: 0, customerCode: 'code-1' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await subscriptionHandler(event)
|
||||||
|
|
||||||
|
expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('first activation (pending_payment → active) sends welcome email on paid tier', async () => {
|
||||||
|
requireAuth.mockResolvedValue(undefined)
|
||||||
|
requiresPayment.mockReturnValue(true)
|
||||||
|
getHelcimPlanId.mockReturnValue('99999')
|
||||||
|
|
||||||
|
const mockMember = {
|
||||||
|
_id: 'member-first-paid',
|
||||||
|
email: 'newpaid@example.com',
|
||||||
|
name: 'NewPaid',
|
||||||
|
circle: 'founder',
|
||||||
|
contributionAmount: 15,
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-first-paid', status: 'pending_payment' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
|
createHelcimSubscription.mockResolvedValue({
|
||||||
|
data: [{ id: 'sub-first-paid', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/helcim/subscription',
|
||||||
|
body: { customerId: 'cust-first-paid', contributionAmount: 15, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await subscriptionHandler(event)
|
||||||
|
|
||||||
|
expect(sendWelcomeEmail).toHaveBeenCalledWith(mockMember)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('already-active retry (active → active) does NOT send welcome email on free tier', async () => {
|
||||||
|
requireAuth.mockResolvedValue(undefined)
|
||||||
|
requiresPayment.mockReturnValue(false)
|
||||||
|
|
||||||
|
const mockMember = {
|
||||||
|
_id: 'member-retry-free',
|
||||||
|
email: 'existing@example.com',
|
||||||
|
name: 'Existing',
|
||||||
|
circle: 'community',
|
||||||
|
contributionAmount: 0,
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
// Pre-update status was already active → tier upgrade, not first activation
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-free', status: 'active' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/helcim/subscription',
|
||||||
|
body: { customerId: 'cust-retry-free', contributionAmount: 0, customerCode: 'code-1' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await subscriptionHandler(event)
|
||||||
|
|
||||||
|
expect(sendWelcomeEmail).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('already-active retry (active → active) does NOT send welcome email on paid tier', async () => {
|
||||||
|
requireAuth.mockResolvedValue(undefined)
|
||||||
|
requiresPayment.mockReturnValue(true)
|
||||||
|
getHelcimPlanId.mockReturnValue('99999')
|
||||||
|
|
||||||
|
const mockMember = {
|
||||||
|
_id: 'member-retry-paid',
|
||||||
|
email: 'upgrader@example.com',
|
||||||
|
name: 'Upgrader',
|
||||||
|
circle: 'founder',
|
||||||
|
contributionAmount: 25,
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-retry-paid', status: 'active' })
|
||||||
|
Member.findById.mockResolvedValue(mockMember)
|
||||||
|
createHelcimSubscription.mockResolvedValue({
|
||||||
|
data: [{ id: 'sub-retry', status: 'active', nextBillingDate: '2026-05-18' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/helcim/subscription',
|
||||||
|
body: { customerId: 'cust-retry-paid', contributionAmount: 25, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'monthly' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await subscriptionHandler(event)
|
||||||
|
|
||||||
|
expect(sendWelcomeEmail).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('Helcim API failure returns 500 and does NOT activate member', async () => {
|
it('Helcim API failure returns 500 and does NOT activate member', async () => {
|
||||||
requireAuth.mockResolvedValue(undefined)
|
requireAuth.mockResolvedValue(undefined)
|
||||||
requiresPayment.mockReturnValue(true)
|
requiresPayment.mockReturnValue(true)
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import Payment from '../../../server/models/payment.js'
|
import Payment from '../../../server/models/payment.js'
|
||||||
import { listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
import { getHelcimCustomer, listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
|
||||||
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
|
||||||
import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js'
|
import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/models/member.js', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
default: { find: vi.fn() }
|
default: { find: vi.fn(), findByIdAndUpdate: vi.fn() }
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/models/payment.js', () => ({
|
vi.mock('../../../server/models/payment.js', () => ({
|
||||||
default: { findOne: vi.fn() }
|
default: { findOne: vi.fn() }
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/helcim.js', () => ({
|
vi.mock('../../../server/utils/helcim.js', () => ({
|
||||||
|
getHelcimCustomer: vi.fn(),
|
||||||
listHelcimCustomerTransactions: vi.fn()
|
listHelcimCustomerTransactions: vi.fn()
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/payments.js', () => ({
|
vi.mock('../../../server/utils/payments.js', () => ({
|
||||||
|
|
@ -88,6 +89,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
_id: 'm1',
|
_id: 'm1',
|
||||||
email: 'a@example.com',
|
email: 'a@example.com',
|
||||||
helcimCustomerId: 'cust-1',
|
helcimCustomerId: 'cust-1',
|
||||||
|
helcimCustomerCode: 'CST-1',
|
||||||
helcimSubscriptionId: 'sub-1',
|
helcimSubscriptionId: 'sub-1',
|
||||||
billingCadence: 'monthly'
|
billingCadence: 'monthly'
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +115,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
{ helcimCustomerId: { $exists: true, $ne: null } },
|
{ helcimCustomerId: { $exists: true, $ne: null } },
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
helcimCustomerId: 1,
|
helcimCustomerId: 1,
|
||||||
|
helcimCustomerCode: 1,
|
||||||
helcimSubscriptionId: 1,
|
helcimSubscriptionId: 1,
|
||||||
billingCadence: 1
|
billingCadence: 1
|
||||||
})
|
})
|
||||||
|
|
@ -134,7 +137,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
|
|
||||||
it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => {
|
it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => {
|
||||||
Member.find.mockReturnValue(leanResolver([
|
Member.find.mockReturnValue(leanResolver([
|
||||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||||
]))
|
]))
|
||||||
listHelcimCustomerTransactions.mockResolvedValue([
|
listHelcimCustomerTransactions.mockResolvedValue([
|
||||||
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' }
|
{ id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' }
|
||||||
|
|
@ -157,17 +160,17 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
|
|
||||||
it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => {
|
it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => {
|
||||||
Member.find.mockReturnValue(leanResolver([
|
Member.find.mockReturnValue(leanResolver([
|
||||||
{ _id: 'm1', helcimCustomerId: 'cust-1' },
|
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' },
|
||||||
{ _id: 'm2', helcimCustomerId: 'cust-2' },
|
{ _id: 'm2', helcimCustomerId: 'cust-2', helcimCustomerCode: 'CST-2' },
|
||||||
{ _id: 'm3', helcimCustomerId: 'cust-3' }
|
{ _id: 'm3', helcimCustomerId: 'cust-3', helcimCustomerCode: 'CST-3' }
|
||||||
]))
|
]))
|
||||||
// m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try.
|
// m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try.
|
||||||
listHelcimCustomerTransactions
|
// Keyed by customerCode so it works regardless of call order (chunked Promise.all).
|
||||||
.mockResolvedValueOnce([{ id: 'tx1', status: 'paid', amount: 5 }])
|
listHelcimCustomerTransactions.mockImplementation((customerCode) => {
|
||||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
if (customerCode === 'cust-1') return Promise.resolve([{ id: 'tx1', status: 'paid', amount: 5 }])
|
||||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
if (customerCode === 'cust-3') return Promise.resolve([{ id: 'tx3', status: 'paid', amount: 7 }])
|
||||||
.mockRejectedValueOnce(new Error('helcim 503'))
|
return Promise.reject(new Error('helcim 503'))
|
||||||
.mockResolvedValueOnce([{ id: 'tx3', status: 'paid', amount: 7 }])
|
})
|
||||||
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } })
|
upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } })
|
||||||
|
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|
@ -191,7 +194,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => {
|
it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
Member.find.mockReturnValue(leanResolver([
|
Member.find.mockReturnValue(leanResolver([
|
||||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||||
]))
|
]))
|
||||||
listHelcimCustomerTransactions
|
listHelcimCustomerTransactions
|
||||||
.mockRejectedValueOnce(new Error('boom 1'))
|
.mockRejectedValueOnce(new Error('boom 1'))
|
||||||
|
|
@ -219,7 +222,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
it('counts memberErrors when all 3 retry attempts fail', async () => {
|
it('counts memberErrors when all 3 retry attempts fail', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
Member.find.mockReturnValue(leanResolver([
|
Member.find.mockReturnValue(leanResolver([
|
||||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||||
]))
|
]))
|
||||||
listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503'))
|
listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503'))
|
||||||
|
|
||||||
|
|
@ -241,7 +244,7 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
|
|
||||||
it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => {
|
it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => {
|
||||||
Member.find.mockReturnValue(leanResolver([
|
Member.find.mockReturnValue(leanResolver([
|
||||||
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-1' }
|
||||||
]))
|
]))
|
||||||
listHelcimCustomerTransactions.mockResolvedValue([
|
listHelcimCustomerTransactions.mockResolvedValue([
|
||||||
{ id: 'tx-existing', status: 'paid', amount: 10 },
|
{ id: 'tx-existing', status: 'paid', amount: 10 },
|
||||||
|
|
@ -266,4 +269,63 @@ describe('POST /api/internal/reconcile-payments', () => {
|
||||||
existed: 1
|
existed: 1
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('helcimCustomerCode backfill', () => {
|
||||||
|
it('writes helcimCustomerCode when missing on the member doc', async () => {
|
||||||
|
Member.find.mockReturnValue(leanResolver([
|
||||||
|
{ _id: 'm1', helcimCustomerId: 'cust-1' } // no helcimCustomerCode
|
||||||
|
]))
|
||||||
|
getHelcimCustomer.mockResolvedValue({ id: 'cust-1', customerCode: 'CST-NEW' })
|
||||||
|
listHelcimCustomerTransactions.mockResolvedValue([])
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/internal/reconcile-payments',
|
||||||
|
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||||
|
})
|
||||||
|
await reconcileHandler(event)
|
||||||
|
|
||||||
|
expect(getHelcimCustomer).toHaveBeenCalledWith('cust-1')
|
||||||
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
|
'm1',
|
||||||
|
{ $set: { helcimCustomerCode: 'CST-NEW' } },
|
||||||
|
{ runValidators: false }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips backfill when helcimCustomerCode is already present', async () => {
|
||||||
|
Member.find.mockReturnValue(leanResolver([
|
||||||
|
{ _id: 'm1', helcimCustomerId: 'cust-1', helcimCustomerCode: 'CST-EXISTING' }
|
||||||
|
]))
|
||||||
|
listHelcimCustomerTransactions.mockResolvedValue([])
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/internal/reconcile-payments',
|
||||||
|
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||||
|
})
|
||||||
|
await reconcileHandler(event)
|
||||||
|
|
||||||
|
expect(getHelcimCustomer).not.toHaveBeenCalled()
|
||||||
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fail the run when getHelcimCustomer throws during backfill', async () => {
|
||||||
|
Member.find.mockReturnValue(leanResolver([
|
||||||
|
{ _id: 'm1', helcimCustomerId: 'cust-1' }
|
||||||
|
]))
|
||||||
|
getHelcimCustomer.mockRejectedValue(new Error('helcim 503'))
|
||||||
|
listHelcimCustomerTransactions.mockResolvedValue([])
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/internal/reconcile-payments',
|
||||||
|
headers: { 'x-reconcile-token': RECONCILE_TOKEN }
|
||||||
|
})
|
||||||
|
const result = await reconcileHandler(event)
|
||||||
|
|
||||||
|
expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
|
||||||
|
expect(result.memberErrors).toBe(0) // backfill failure is best-effort, not fatal
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,177 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
|
|
||||||
const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]')
|
import Member from '../../../server/models/member.js'
|
||||||
|
import Series from '../../../server/models/series.js'
|
||||||
|
import Event from '../../../server/models/event.js'
|
||||||
|
import {
|
||||||
|
validateSeriesTicketPurchase,
|
||||||
|
completeSeriesTicketPurchase,
|
||||||
|
registerForAllSeriesEvents,
|
||||||
|
hasMemberAccess,
|
||||||
|
} from '../../../server/utils/tickets.js'
|
||||||
|
import { sendSeriesPassConfirmation } from '../../../server/utils/resend.js'
|
||||||
|
import { seriesTicketPurchaseSchema } from '../../../server/utils/schemas.js'
|
||||||
|
import handler from '../../../server/api/series/[id]/tickets/purchase.post.js'
|
||||||
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => {
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8')
|
default: { findOne: vi.fn(), findOneAndUpdate: vi.fn() }
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/models/series.js', () => ({
|
||||||
|
default: { findOne: vi.fn() }
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/models/event.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn(() => ({ sort: vi.fn().mockResolvedValue([]) }))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
|
vi.mock('../../../server/utils/tickets.js', () => ({
|
||||||
|
validateSeriesTicketPurchase: vi.fn(),
|
||||||
|
calculateSeriesTicketPrice: vi.fn(),
|
||||||
|
reserveSeriesTicket: vi.fn(),
|
||||||
|
releaseSeriesTicket: vi.fn(),
|
||||||
|
completeSeriesTicketPurchase: vi.fn().mockResolvedValue(undefined),
|
||||||
|
registerForAllSeriesEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
hasMemberAccess: vi.fn(() => false),
|
||||||
|
}))
|
||||||
|
vi.mock('../../../server/utils/resend.js', () => ({
|
||||||
|
sendSeriesPassConfirmation: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
it('uses validateBody with seriesTicketPurchaseSchema', () => {
|
// Auto-imports the handler relies on but the global setup doesn't stub.
|
||||||
expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
|
const setAuthCookieMock = vi.fn()
|
||||||
|
vi.stubGlobal('setAuthCookie', setAuthCookieMock)
|
||||||
|
vi.stubGlobal('seriesTicketPurchaseSchema', seriesTicketPurchaseSchema)
|
||||||
|
|
||||||
|
// Capture schema passed to validateBody so we can prove the route validates
|
||||||
|
// against seriesTicketPurchaseSchema specifically.
|
||||||
|
const validateBodyCalls = []
|
||||||
|
const validateBodyMock = vi.fn(async (event, schema) => {
|
||||||
|
validateBodyCalls.push(schema)
|
||||||
|
// Mirror real behavior: parse body via the schema so invalid bodies still throw.
|
||||||
|
const body = await readBody(event)
|
||||||
|
return schema.parse(body)
|
||||||
|
})
|
||||||
|
vi.stubGlobal('validateBody', validateBodyMock)
|
||||||
|
|
||||||
|
function buildEvent(body) {
|
||||||
|
const ev = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/series/series-1/tickets/purchase',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
ev.context = { params: { id: 'series-1' } }
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSeries = () => ({
|
||||||
|
_id: 'series-1',
|
||||||
|
id: 'series-1',
|
||||||
|
slug: 'series-slug',
|
||||||
|
title: 'Test Series',
|
||||||
|
description: 'desc',
|
||||||
|
type: 'workshop',
|
||||||
|
registrations: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => {
|
describe('POST /api/series/[id]/tickets/purchase — guest upsert + auth cookie', () => {
|
||||||
// Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in
|
beforeEach(() => {
|
||||||
// checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`.
|
vi.clearAllMocks()
|
||||||
expect(source).toContain('findOneAndUpdate')
|
validateBodyCalls.length = 0
|
||||||
expect(source).toContain('$setOnInsert')
|
// Default: unauthenticated buyer
|
||||||
expect(source).toContain('status: "guest"')
|
globalThis.requireAuth = vi.fn().mockRejectedValue(
|
||||||
expect(source).toContain('upsert: true')
|
Object.assign(new Error('Unauthorized'), { statusCode: 401 })
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SeriesPassPurchase.vue — client auth refresh (Fix #8)', () => {
|
|
||||||
const source = readFileSync(
|
|
||||||
resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'),
|
|
||||||
'utf-8'
|
|
||||||
)
|
)
|
||||||
|
Series.findOne.mockResolvedValue(baseSeries())
|
||||||
it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => {
|
Member.findOne.mockResolvedValue(null)
|
||||||
expect(source).toContain('useAuth().checkMemberStatus()')
|
validateSeriesTicketPurchase.mockReturnValue({
|
||||||
expect(source).toMatch(/purchaseResponse\?\.signedIn/)
|
valid: true,
|
||||||
|
ticketInfo: {
|
||||||
|
ticketType: 'public',
|
||||||
|
price: 0,
|
||||||
|
currency: 'CAD',
|
||||||
|
isFree: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a one-line guest-account hint under the form (no checkbox)', () => {
|
it('upserts a guest Member with $setOnInsert + upsert:true when buyer has no account', async () => {
|
||||||
// Per ALWAYS-CREATE-GUEST decision: hint only, no UI control.
|
Member.findOneAndUpdate.mockResolvedValue({
|
||||||
expect(source).toMatch(/free guest account/i)
|
_id: 'new-member-1',
|
||||||
// Make sure no checkbox was added by mistake.
|
email: 'guest@example.com',
|
||||||
expect(source).not.toMatch(/createAccount/)
|
status: 'guest',
|
||||||
expect(source).not.toMatch(/<input[^>]*type="checkbox"/i)
|
})
|
||||||
|
|
||||||
|
await handler(buildEvent({
|
||||||
|
name: 'Guest Buyer',
|
||||||
|
email: 'guest@example.com',
|
||||||
|
ticketType: 'public',
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(Member.findOneAndUpdate).toHaveBeenCalledTimes(1)
|
||||||
|
const [filter, update, options] = Member.findOneAndUpdate.mock.calls[0]
|
||||||
|
expect(filter).toEqual({ email: 'guest@example.com' })
|
||||||
|
expect(update).toEqual({
|
||||||
|
$setOnInsert: {
|
||||||
|
email: 'guest@example.com',
|
||||||
|
name: 'Guest Buyer',
|
||||||
|
circle: 'community',
|
||||||
|
contributionAmount: 0,
|
||||||
|
status: 'guest',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(options).toEqual({
|
||||||
|
upsert: true,
|
||||||
|
new: true,
|
||||||
|
setDefaultsOnInsert: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets the auth cookie for newly-created guest accounts', async () => {
|
||||||
|
const newMember = {
|
||||||
|
_id: 'new-member-2',
|
||||||
|
email: 'newbie@example.com',
|
||||||
|
status: 'guest',
|
||||||
|
}
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue(newMember)
|
||||||
|
|
||||||
|
const result = await handler(buildEvent({
|
||||||
|
name: 'Newbie',
|
||||||
|
email: 'newbie@example.com',
|
||||||
|
ticketType: 'public',
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(setAuthCookieMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(setAuthCookieMock.mock.calls[0][1]).toBe(newMember)
|
||||||
|
expect(result.signedIn).toBe(true)
|
||||||
|
expect(result.accountCreated).toBe(true)
|
||||||
|
expect(result.requiresSignIn).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates input via seriesTicketPurchaseSchema', async () => {
|
||||||
|
Member.findOneAndUpdate.mockResolvedValue({
|
||||||
|
_id: 'm',
|
||||||
|
email: 'a@b.com',
|
||||||
|
status: 'guest',
|
||||||
|
})
|
||||||
|
|
||||||
|
await handler(buildEvent({
|
||||||
|
name: 'A',
|
||||||
|
email: 'a@b.com',
|
||||||
|
ticketType: 'public',
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(validateBodyMock).toHaveBeenCalled()
|
||||||
|
expect(validateBodyCalls[0]).toBe(seriesTicketPurchaseSchema)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid body (no name) via schema validation', async () => {
|
||||||
|
await expect(
|
||||||
|
handler(buildEvent({ email: 'a@b.com', ticketType: 'public' }))
|
||||||
|
).rejects.toBeDefined()
|
||||||
|
|
||||||
|
expect(Member.findOneAndUpdate).not.toHaveBeenCalled()
|
||||||
|
expect(setAuthCookieMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
listHelcimCustomerCards,
|
||||||
listHelcimCustomerTransactions,
|
listHelcimCustomerTransactions,
|
||||||
updateHelcimCustomerDefaultPaymentMethod,
|
updateHelcimCustomerDefaultPaymentMethod,
|
||||||
updateHelcimSubscriptionPaymentMethod
|
updateHelcimSubscriptionPaymentMethod
|
||||||
|
|
@ -25,6 +26,56 @@ function errResponse(status = 500, body = 'boom') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('listHelcimCustomerCards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through a bare array response', async () => {
|
||||||
|
const cards = [
|
||||||
|
{ id: 1, cardToken: 'tok-a' },
|
||||||
|
{ id: 2, cardToken: 'tok-b' }
|
||||||
|
]
|
||||||
|
mockFetch.mockResolvedValue(okResponse(cards))
|
||||||
|
|
||||||
|
const result = await listHelcimCustomerCards('2488717')
|
||||||
|
|
||||||
|
expect(result).toEqual(cards)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unwraps a { cards: [...] } response envelope', async () => {
|
||||||
|
const cards = [{ id: 1, cardToken: 'tok-a' }]
|
||||||
|
mockFetch.mockResolvedValue(okResponse({ cards }))
|
||||||
|
|
||||||
|
const result = await listHelcimCustomerCards('2488717')
|
||||||
|
|
||||||
|
expect(result).toEqual(cards)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unwraps a { data: [...] } response envelope', async () => {
|
||||||
|
const cards = [{ id: 1, cardToken: 'tok-a' }]
|
||||||
|
mockFetch.mockResolvedValue(okResponse({ data: cards }))
|
||||||
|
|
||||||
|
const result = await listHelcimCustomerCards('2488717')
|
||||||
|
|
||||||
|
expect(result).toEqual(cards)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty array for null, undefined, or unrecognized object responses', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse(null))
|
||||||
|
expect(await listHelcimCustomerCards('2488717')).toEqual([])
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse(undefined))
|
||||||
|
expect(await listHelcimCustomerCards('2488717')).toEqual([])
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse({ unexpected: 'shape' }))
|
||||||
|
expect(await listHelcimCustomerCards('2488717')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('listHelcimCustomerTransactions', () => {
|
describe('listHelcimCustomerTransactions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue