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.
322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
// HelcimPay.js integration composable
|
|
export const useHelcimPay = () => {
|
|
let checkoutToken = null;
|
|
let secretToken = null;
|
|
|
|
// Initialize HelcimPay.js session (membership signup flow)
|
|
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
|
try {
|
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
|
method: "POST",
|
|
body: {
|
|
customerId,
|
|
customerCode,
|
|
amount,
|
|
// Marks this as a paid-tier signup so the server accepts the
|
|
// payment-bridge cookie set by /api/helcim/customer (the user
|
|
// hasn't clicked their email-verify magic link yet).
|
|
metadata: { type: "membership_signup" },
|
|
},
|
|
});
|
|
|
|
if (response.success) {
|
|
checkoutToken = response.checkoutToken;
|
|
secretToken = response.secretToken;
|
|
return true;
|
|
}
|
|
|
|
throw new Error("Failed to initialize payment session");
|
|
} catch (error) {
|
|
console.error("Payment initialization error:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Initialize payment for event ticket purchase
|
|
const initializeTicketPayment = async (
|
|
eventId,
|
|
email,
|
|
amount,
|
|
eventTitle = null,
|
|
) => {
|
|
try {
|
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
|
method: "POST",
|
|
body: {
|
|
customerId: null,
|
|
customerCode: email, // Use email as customer code for event tickets
|
|
amount,
|
|
metadata: {
|
|
type: "event_ticket",
|
|
eventId,
|
|
email,
|
|
eventTitle,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (response.success) {
|
|
checkoutToken = response.checkoutToken;
|
|
secretToken = response.secretToken;
|
|
return {
|
|
success: true,
|
|
checkoutToken: response.checkoutToken,
|
|
amount: response.amount,
|
|
};
|
|
}
|
|
|
|
throw new Error("Failed to initialize ticket payment session");
|
|
} catch (error) {
|
|
console.error("Ticket payment initialization error:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Initialize payment for series pass purchase
|
|
const initializeSeriesTicketPayment = async (
|
|
seriesId,
|
|
email,
|
|
seriesTitle = null,
|
|
) => {
|
|
try {
|
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
|
method: "POST",
|
|
body: {
|
|
customerId: null,
|
|
customerCode: email,
|
|
metadata: {
|
|
type: "series_ticket",
|
|
seriesId,
|
|
email,
|
|
eventTitle: seriesTitle,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (response.success) {
|
|
checkoutToken = response.checkoutToken;
|
|
secretToken = response.secretToken;
|
|
return {
|
|
success: true,
|
|
checkoutToken: response.checkoutToken,
|
|
amount: response.amount,
|
|
};
|
|
}
|
|
|
|
throw new Error("Failed to initialize series payment session");
|
|
} catch (error) {
|
|
console.error("Series payment initialization error:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Show payment modal
|
|
const showPaymentModal = () => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!checkoutToken) {
|
|
reject(
|
|
new Error("Payment not initialized. Call initializeHelcimPay first."),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Add CSS to style Helcim overlay - needs to target the actual elements Helcim creates
|
|
// Helcim injects a div with inline styles directly into body
|
|
if (!document.getElementById("helcim-overlay-fix")) {
|
|
const style = document.createElement("style");
|
|
style.id = "helcim-overlay-fix";
|
|
style.textContent = `
|
|
/* Style any fixed position div that Helcim creates */
|
|
body > div[style] {
|
|
background-color: rgba(0, 0, 0, 0.75) !important;
|
|
}
|
|
|
|
/* Target specifically iframes from Helcim */
|
|
body > div[style] > iframe[src*="helcim"],
|
|
body > div[style] iframe[src*="secure.helcim.app"] {
|
|
background: white !important;
|
|
border: none !important;
|
|
border-radius: 8px !important;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3) !important;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// Load HelcimPay.js modal script
|
|
if (!window.appendHelcimPayIframe) {
|
|
console.log("HelcimPay script not loaded, loading now...");
|
|
const script = document.createElement("script");
|
|
script.src = "https://secure.helcim.app/helcim-pay/services/start.js";
|
|
script.async = true;
|
|
script.onload = () => {
|
|
console.log("HelcimPay script loaded successfully!");
|
|
console.log(
|
|
"Available functions:",
|
|
Object.keys(window).filter(
|
|
(key) => key.includes("Helcim") || key.includes("helcim"),
|
|
),
|
|
);
|
|
console.log(
|
|
"appendHelcimPayIframe available:",
|
|
typeof window.appendHelcimPayIframe,
|
|
);
|
|
openModal(resolve, reject);
|
|
};
|
|
script.onerror = () => {
|
|
reject(new Error("Failed to load HelcimPay.js"));
|
|
};
|
|
document.head.appendChild(script);
|
|
} else {
|
|
console.log("HelcimPay script already loaded, calling openModal");
|
|
openModal(resolve, reject);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Open the payment modal
|
|
const openModal = (resolve, reject) => {
|
|
try {
|
|
console.log("Trying to open modal with checkoutToken:", checkoutToken);
|
|
|
|
if (typeof window.appendHelcimPayIframe === "function") {
|
|
// Set up event listener for HelcimPay.js responses
|
|
const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
|
|
|
|
const handleHelcimPayEvent = (event) => {
|
|
console.log("Received window message:", event.data);
|
|
|
|
if (event.data.eventName === helcimPayJsIdentifierKey) {
|
|
console.log("HelcimPay event received:", event.data);
|
|
|
|
// Remove event listener to prevent multiple responses
|
|
window.removeEventListener("message", handleHelcimPayEvent);
|
|
|
|
// Close the Helcim modal
|
|
if (typeof window.removeHelcimPayIframe === "function") {
|
|
window.removeHelcimPayIframe();
|
|
}
|
|
|
|
if (event.data.eventStatus === "SUCCESS") {
|
|
console.log("Payment success:", event.data.eventMessage);
|
|
|
|
// Parse the JSON string eventMessage
|
|
let paymentData;
|
|
try {
|
|
paymentData = JSON.parse(event.data.eventMessage);
|
|
console.log("Parsed payment data:", paymentData);
|
|
} catch (parseError) {
|
|
console.error("Failed to parse eventMessage:", parseError);
|
|
reject(new Error("Invalid payment response format"));
|
|
return;
|
|
}
|
|
|
|
// Extract transaction details from nested data structure
|
|
const transactionData = paymentData.data?.data || {};
|
|
console.log("Transaction data:", transactionData);
|
|
|
|
resolve({
|
|
success: true,
|
|
transactionId: transactionData.transactionId,
|
|
cardToken: transactionData.cardToken,
|
|
cardLast4: transactionData.cardNumber
|
|
? transactionData.cardNumber.slice(-4)
|
|
: undefined,
|
|
cardType: transactionData.cardType || "unknown",
|
|
});
|
|
} else if (event.data.eventStatus === "ABORTED") {
|
|
console.log("Payment aborted:", event.data.eventMessage);
|
|
reject(new Error(event.data.eventMessage || "Payment failed"));
|
|
} else if (event.data.eventStatus === "HIDE") {
|
|
console.log("Modal closed without completion");
|
|
reject(new Error("Payment cancelled by user"));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Add event listener
|
|
window.addEventListener("message", handleHelcimPayEvent);
|
|
|
|
// Set up a MutationObserver to fix Helcim's overlay styling immediately
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node.nodeType === 1 && node.tagName === "DIV") {
|
|
const computedStyle = window.getComputedStyle(node);
|
|
// Check if this is Helcim's overlay (fixed position, full screen)
|
|
if (
|
|
computedStyle.position === "fixed" &&
|
|
computedStyle.inset === "0px"
|
|
) {
|
|
// Fix the background to show semi-transparent overlay
|
|
node.style.setProperty(
|
|
"background-color",
|
|
"rgba(0, 0, 0, 0.75)",
|
|
"important",
|
|
);
|
|
// Ensure proper centering
|
|
node.style.setProperty("display", "flex", "important");
|
|
node.style.setProperty("align-items", "center", "important");
|
|
node.style.setProperty(
|
|
"justify-content",
|
|
"center",
|
|
"important",
|
|
);
|
|
observer.disconnect(); // Stop observing once we've found and fixed it
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Start observing body for child additions
|
|
observer.observe(document.body, { childList: true });
|
|
|
|
// Open the HelcimPay iframe modal
|
|
console.log("Calling appendHelcimPayIframe with token:", checkoutToken);
|
|
window.appendHelcimPayIframe(checkoutToken, true);
|
|
console.log(
|
|
"appendHelcimPayIframe called, waiting for window messages...",
|
|
);
|
|
|
|
// Clean up observer after a timeout
|
|
setTimeout(() => observer.disconnect(), 5000);
|
|
|
|
// Add timeout to clean up if no response (10 minutes for manual card entry)
|
|
setTimeout(() => {
|
|
console.log("Payment timeout reached, cleaning up event listener...");
|
|
window.removeEventListener("message", handleHelcimPayEvent);
|
|
reject(new Error("Payment timeout - no response received"));
|
|
}, 600000);
|
|
} else {
|
|
reject(new Error("appendHelcimPayIframe function not available"));
|
|
}
|
|
} catch (error) {
|
|
console.error("Error opening modal:", error);
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
// Process payment verification
|
|
const verifyPayment = async () => {
|
|
try {
|
|
return await showPaymentModal();
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Cleanup tokens
|
|
const cleanup = () => {
|
|
checkoutToken = null;
|
|
secretToken = null;
|
|
};
|
|
|
|
return {
|
|
initializeHelcimPay,
|
|
initializeTicketPayment,
|
|
initializeSeriesTicketPayment,
|
|
verifyPayment,
|
|
cleanup,
|
|
};
|
|
};
|