Day-of-launch deep-dive audit and remediation. 11 issues fixed across security, correctness, and reliability. Tests: 698 → 758 passing (+60), 0 failing, 2 skipped. CRITICAL (security) Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead useHelcim.js deleted. Production token MUST BE ROTATED post-deploy (was previously exposed in window.__NUXT__ payload). Fix #2 — /api/helcim/customer gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated setAuthCookie). Adds payment-bridge token for paid-tier signup so users can complete Helcim checkout before email verify. New utils: server/utils/{magicLink,rateLimit}.js. UX: signup success copy now prompts user to check email. Fix #3 — /api/events/[id]/payment deleted (dead code with unauth member-spoof bypass — processHelcimPayment was a permanent stub). Removes processHelcimPayment export and eventPaymentSchema. Fix #4 — /api/helcim/initialize-payment re-derives ticket amount server-side via calculateTicketPrice and calculateSeriesTicketPrice. Adds new series_ticket metadata type (was being shoved through event_ticket with seriesId in metadata.eventId). Fix #5 — /api/helcim/customer upgrades existing status:guest members in place rather than rejecting with 409. Lowercases email at lookup; preserves _id so prior event registrations stay linked. HIGH (correctness / reliability) Fix #6 — Daily reconciliation cron via Netlify scheduled function (@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs, server/api/internal/reconcile-payments.post.js. Shared-secret auth via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff on Helcim transactions API. Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist endpoints) to dodge legacy location validators. Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest Member when caller is unauthenticated, mirrors event-ticket flow byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and client auth refresh on signedIn:true response. Fix #9 — /api/members/cancel-subscription leaves status active per ratified bylaws (was pending_payment). Adds lastCancelledAt audit field on Member model. Indirectly fixes false-positive detectStuckPendingPayment admin alert for cancelled members. Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema (verifyMagicLinkSchema, max 2000 chars). Fix #11 — 8 vitest cases for cancel-subscription handler (was uncovered). Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md. LAUNCH_READINESS.md updated with new test count, 3 deploy-time tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify Netlify scheduled function), and Fixed-2026-04-25 fix log.
357 lines
10 KiB
Vue
357 lines
10 KiB
Vue
<template>
|
|
<div class="series-pass-purchase">
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="text-center py-8">
|
|
<div
|
|
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
|
|
/>
|
|
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div
|
|
v-else-if="error"
|
|
class="p-6 bg-ember-900/20 rounded-xl border border-ember-800"
|
|
>
|
|
<h3 class="text-lg font-semibold text-ember-300 mb-2">
|
|
Unable to Load Series Pass
|
|
</h3>
|
|
<p class="text-ember-400">{{ error }}</p>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div v-else-if="passInfo">
|
|
<!-- Already Registered State -->
|
|
<div v-if="passInfo.alreadyRegistered" class="dashed-box p-6">
|
|
<div class="section-label mb-2">Series Pass</div>
|
|
<p class="text-[--text]">You're registered for this series.</p>
|
|
<p v-if="passInfo.registration?.eventsIncluded !== undefined" class="text-[--text-dim] text-sm mt-1">
|
|
Registered for {{ passInfo.registration.eventsIncluded }} event{{ passInfo.registration.eventsIncluded !== 1 ? 's' : '' }} in this series.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Series Pass Card (only when ticket data is available) -->
|
|
<EventSeriesTicketCard
|
|
v-else-if="passInfo.ticket"
|
|
:ticket="passInfo.ticket"
|
|
:availability="passInfo.availability"
|
|
:available="passInfo.available"
|
|
:already-registered="false"
|
|
:is-member="passInfo.memberInfo?.isMember"
|
|
:total-events="seriesInfo.totalEvents"
|
|
:events="seriesEvents"
|
|
:public-price="passInfo.publicPrice"
|
|
class="mb-8"
|
|
@join-waitlist="handleJoinWaitlist"
|
|
/>
|
|
|
|
<!-- Registration Form -->
|
|
<div
|
|
v-if="passInfo.available && !passInfo.alreadyRegistered"
|
|
class="bg-guild-800/50 dark:bg-guild-700/30 rounded-xl border border-guild-600 dark:border-guild-600 p-6"
|
|
>
|
|
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
|
|
{{
|
|
passInfo.ticket.isFree
|
|
? "Register for Series"
|
|
: "Purchase Series Pass"
|
|
}}
|
|
</h3>
|
|
|
|
<form class="space-y-6" @submit.prevent="handleSubmit">
|
|
<!-- Name Field -->
|
|
<div>
|
|
<label
|
|
for="name"
|
|
class="block text-sm font-medium text-[--ui-text] mb-2"
|
|
>
|
|
Full Name
|
|
</label>
|
|
<UInput
|
|
id="name"
|
|
v-model="form.name"
|
|
type="text"
|
|
required
|
|
placeholder="Enter your full name"
|
|
:disabled="processing"
|
|
size="lg"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Email Field -->
|
|
<div>
|
|
<label
|
|
for="email"
|
|
class="block text-sm font-medium text-[--ui-text] mb-2"
|
|
>
|
|
Email Address
|
|
</label>
|
|
<UInput
|
|
id="email"
|
|
v-model="form.email"
|
|
type="email"
|
|
required
|
|
placeholder="Enter your email"
|
|
:disabled="processing || isLoggedIn"
|
|
size="lg"
|
|
/>
|
|
<p v-if="isLoggedIn" class="text-xs text-[--ui-text-muted] mt-2">
|
|
Using your member email
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Member Benefits Notice -->
|
|
<div
|
|
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
|
|
class="p-4 bg-candlelight-900/20 border border-candlelight-700/30 rounded-lg"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<Icon
|
|
name="heroicons:sparkles"
|
|
class="w-5 h-5 text-candlelight-400 flex-shrink-0 mt-0.5"
|
|
/>
|
|
<div>
|
|
<div class="font-semibold text-candlelight-300 mb-1">
|
|
Member Benefit
|
|
</div>
|
|
<div class="text-sm text-candlelight-400">
|
|
This series pass is free for Ghost Guild members!
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit Button -->
|
|
<UButton
|
|
type="submit"
|
|
block
|
|
size="xl"
|
|
:disabled="processing || !form.name || !form.email"
|
|
:loading="processing"
|
|
>
|
|
<template v-if="processing">
|
|
{{ paymentProcessing ? "Processing Payment..." : "Registering..." }}
|
|
</template>
|
|
<template v-else>
|
|
{{
|
|
passInfo.ticket.isFree
|
|
? "Complete Registration"
|
|
: `Pay ${formatPrice(passInfo.ticket.price, passInfo.ticket.currency)}`
|
|
}}
|
|
</template>
|
|
</UButton>
|
|
|
|
<p class="text-xs text-[--ui-text-muted] text-center">
|
|
By registering, you'll be automatically registered for all
|
|
{{ seriesInfo.totalEvents }} events in this series.
|
|
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { useHelcimPay } from "~/composables/useHelcimPay";
|
|
|
|
const props = defineProps({
|
|
seriesId: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
seriesInfo: {
|
|
type: Object,
|
|
required: true,
|
|
// Expected: { id, title, totalEvents, type }
|
|
},
|
|
seriesEvents: {
|
|
type: Array,
|
|
default: () => [],
|
|
// Expected: Array of event objects
|
|
},
|
|
userEmail: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
userName: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
|
|
|
const toast = useToast();
|
|
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
|
|
|
|
// State
|
|
const loading = ref(true);
|
|
const processing = ref(false);
|
|
const paymentProcessing = ref(false);
|
|
const error = ref(null);
|
|
const passInfo = ref(null);
|
|
|
|
const form = ref({
|
|
name: props.userName || "",
|
|
email: props.userEmail || "",
|
|
});
|
|
|
|
const isLoggedIn = computed(() => !!props.userEmail);
|
|
|
|
// Fetch series pass info on mount, then re-fetch if userEmail becomes available (auth loads after mount)
|
|
onMounted(async () => {
|
|
await fetchPassInfo();
|
|
});
|
|
|
|
watch(() => props.userEmail, async (newEmail, oldEmail) => {
|
|
if (newEmail && !oldEmail) {
|
|
form.value.email = newEmail;
|
|
form.value.name = props.userName || form.value.name;
|
|
await fetchPassInfo();
|
|
}
|
|
});
|
|
|
|
const fetchPassInfo = async () => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
|
|
try {
|
|
const email = form.value.email || props.userEmail;
|
|
const url = email
|
|
? `/api/series/${props.seriesId}/tickets/available?email=${encodeURIComponent(email)}`
|
|
: `/api/series/${props.seriesId}/tickets/available`;
|
|
const response = await $fetch(url);
|
|
|
|
passInfo.value = response;
|
|
|
|
// Pre-fill form if member info available
|
|
if (response.memberInfo?.isMember) {
|
|
form.value.name = response.memberInfo.name || form.value.name;
|
|
form.value.email = response.memberInfo.email || form.value.email;
|
|
}
|
|
|
|
// Also fetch public price for comparison
|
|
if (response.memberInfo?.isMember && response.ticket?.type === "member") {
|
|
// Make another request to get public pricing
|
|
try {
|
|
const publicResponse = await $fetch(
|
|
`/api/series/${props.seriesId}/tickets/available?forcePublic=true`
|
|
);
|
|
if (publicResponse.ticket?.price) {
|
|
passInfo.value.publicPrice = publicResponse.ticket.price;
|
|
}
|
|
} catch (err) {
|
|
console.warn("Could not fetch public price for comparison");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Error fetching series pass info:", err);
|
|
error.value =
|
|
err.data?.statusMessage || "Failed to load series pass information";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
processing.value = true;
|
|
|
|
try {
|
|
let transactionId = null;
|
|
|
|
// If payment is required, initialize Helcim and process payment
|
|
if (!passInfo.value.ticket.isFree) {
|
|
paymentProcessing.value = true;
|
|
|
|
// Initialize Helcim payment for series pass
|
|
await initializeSeriesTicketPayment(
|
|
props.seriesId,
|
|
form.value.email,
|
|
props.seriesInfo.title,
|
|
);
|
|
|
|
// Show Helcim modal and complete payment
|
|
const paymentResult = await verifyPayment();
|
|
|
|
if (!paymentResult.success) {
|
|
throw new Error("Payment was not completed");
|
|
}
|
|
|
|
transactionId = paymentResult.transactionId;
|
|
paymentProcessing.value = false;
|
|
}
|
|
|
|
// Complete series pass purchase
|
|
const purchaseBody = {
|
|
name: form.value.name,
|
|
email: form.value.email,
|
|
ticketType: passInfo.value.ticket.type,
|
|
};
|
|
if (transactionId) purchaseBody.paymentId = transactionId;
|
|
|
|
const purchaseResponse = await $fetch(
|
|
`/api/series/${props.seriesId}/tickets/purchase`,
|
|
{
|
|
method: "POST",
|
|
body: purchaseBody,
|
|
}
|
|
);
|
|
|
|
// Refresh client auth state if server signed us in (guest upgrade)
|
|
if (purchaseResponse?.signedIn) {
|
|
await useAuth().checkMemberStatus();
|
|
}
|
|
|
|
// Show success message
|
|
toast.add({
|
|
title: "Series Pass Purchased!",
|
|
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
|
|
color: "green",
|
|
timeout: 5000,
|
|
});
|
|
|
|
// Emit success event
|
|
emit("purchase-success", purchaseResponse);
|
|
|
|
// Refresh pass info to show registered state
|
|
await fetchPassInfo();
|
|
} catch (err) {
|
|
console.error("Error purchasing series pass:", err);
|
|
|
|
const errorMessage =
|
|
err.data?.statusMessage ||
|
|
err.message ||
|
|
"Failed to complete series pass purchase";
|
|
|
|
toast.add({
|
|
title: "Purchase Failed",
|
|
description: errorMessage,
|
|
color: "red",
|
|
timeout: 5000,
|
|
});
|
|
|
|
emit("purchase-error", errorMessage);
|
|
} finally {
|
|
processing.value = false;
|
|
paymentProcessing.value = false;
|
|
}
|
|
};
|
|
|
|
const handleJoinWaitlist = async () => {
|
|
// TODO: Implement waitlist functionality
|
|
toast.add({
|
|
title: "Waitlist Coming Soon",
|
|
description: "The waitlist feature is coming soon!",
|
|
color: "blue",
|
|
});
|
|
};
|
|
|
|
const formatPrice = (price, currency = "CAD") => {
|
|
if (price === 0) return "Free";
|
|
return new Intl.NumberFormat("en-CA", {
|
|
style: "currency",
|
|
currency,
|
|
}).format(price);
|
|
};
|
|
</script>
|