By registering, you'll be automatically registered for all
{{ seriesInfo.totalEvents }} events in this series.
+ We'll create a free guest account so you can access your pass.
- We've sent a confirmation email to {{ summary?.email }}. Redirecting
- you to your dashboard...
+ Check {{ summary?.email }} for a sign-in link to finish setting up
+ your account. The link expires in 15 minutes.
-
-
- Go to Dashboard Now
-
-
diff --git a/app/composables/useHelcim.js b/app/composables/useHelcim.js
deleted file mode 100644
index efc96b1..0000000
--- a/app/composables/useHelcim.js
+++ /dev/null
@@ -1,90 +0,0 @@
-// Helcim API integration composable
-export const useHelcim = () => {
- const config = useRuntimeConfig()
- const helcimToken = config.public.helcimToken
-
- // Base URL for Helcim API
- const HELCIM_API_BASE = 'https://api.helcim.com/v2'
-
- // Helper function to make API requests
- const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => {
- try {
- const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, {
- method,
- headers: {
- 'accept': 'application/json',
- 'content-type': 'application/json',
- 'api-token': helcimToken
- },
- body: body ? JSON.stringify(body) : undefined
- })
- return response
- } catch (error) {
- console.error('Helcim API error:', error)
- throw error
- }
- }
-
- // Create a customer
- const createCustomer = async (customerData) => {
- return await makeHelcimRequest('/customers', 'POST', {
- customerType: 'PERSON',
- contactName: customerData.name,
- email: customerData.email,
- billingAddress: customerData.billingAddress || {}
- })
- }
-
- // Create a subscription
- const createSubscription = async (customerId, planId, cardToken) => {
- return await makeHelcimRequest('/recurring/subscriptions', 'POST', {
- customerId,
- planId,
- cardToken,
- startDate: new Date().toISOString().split('T')[0] // Today's date
- })
- }
-
- // Get customer details
- const getCustomer = async (customerId) => {
- return await makeHelcimRequest(`/customers/${customerId}`)
- }
-
- // Get subscription details
- const getSubscription = async (subscriptionId) => {
- return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`)
- }
-
- // Update subscription
- const updateSubscription = async (subscriptionId, updates) => {
- return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates)
- }
-
- // Cancel subscription
- const cancelSubscription = async (subscriptionId) => {
- return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE')
- }
-
- // Get payment plans
- const getPaymentPlans = async () => {
- return await makeHelcimRequest('/recurring/plans')
- }
-
- // Verify card token (for testing)
- const verifyCardToken = async (cardToken) => {
- return await makeHelcimRequest('/cards/verify', 'POST', {
- cardToken
- })
- }
-
- return {
- createCustomer,
- createSubscription,
- getCustomer,
- getSubscription,
- updateSubscription,
- cancelSubscription,
- getPaymentPlans,
- verifyCardToken
- }
-}
\ No newline at end of file
diff --git a/app/composables/useHelcimPay.js b/app/composables/useHelcimPay.js
index 3703295..e696d10 100644
--- a/app/composables/useHelcimPay.js
+++ b/app/composables/useHelcimPay.js
@@ -3,7 +3,7 @@ export const useHelcimPay = () => {
let checkoutToken = null;
let secretToken = null;
- // Initialize HelcimPay.js session
+ // Initialize HelcimPay.js session (membership signup flow)
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
@@ -12,6 +12,10 @@ export const useHelcimPay = () => {
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" },
},
});
@@ -57,6 +61,7 @@ export const useHelcimPay = () => {
return {
success: true,
checkoutToken: response.checkoutToken,
+ amount: response.amount,
};
}
@@ -67,6 +72,44 @@ export const useHelcimPay = () => {
}
};
+ // 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) => {
@@ -272,6 +315,7 @@ export const useHelcimPay = () => {
return {
initializeHelcimPay,
initializeTicketPayment,
+ initializeSeriesTicketPayment,
verifyPayment,
cleanup,
};
diff --git a/app/pages/join.vue b/app/pages/join.vue
index 82e4f00..32cc816 100644
--- a/app/pages/join.vue
+++ b/app/pages/join.vue
@@ -555,10 +555,9 @@ const createSubscription = async (cardToken = null) => {
flowState.value = "success";
successMessage.value = "Your membership is active.";
- // Check member status to ensure user is properly authenticated
- await checkMemberStatus();
-
- navigateTo("/welcome");
+ // Sign-in cookie is now issued by the email-verify magic link
+ // (see /api/helcim/customer). Don't auto-navigate to a gated page —
+ // the success state instructs the user to check their inbox.
} else {
throw new Error("Subscription creation failed - response not successful");
}
diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md
index de220a2..feae0ca 100644
--- a/docs/LAUNCH_READINESS.md
+++ b/docs/LAUNCH_READINESS.md
@@ -8,7 +8,7 @@ Single source of truth for work remaining before cutover. P0 blocks launch; P1 i
## Current state
-- Vitest on `main`: **652/658 passing**. 6 pre-existing failures in `tests/server/api/helcim-payment.test.js` — unrelated to launch-blocking work, flagged in the Deploy checklist.
+- Vitest snapshot 2026-04-25 ~18:23 local: **703 passing / 8 failing / 2 skipped (713 total)**. The previously-flagged 6 helcim-payment failures are now green. The 8 current failures are in `tests/server/api/auth-verify.test.js` and `tests/server/api/cancel-subscription.smoke.test.js`, both belonging to in-flight Phase 5 fixes (#10 and #9) being landed by parallel impl subagents — they will resolve as those branches merge.
- All launch code is on local `main`: Helcim plan consolidation, contribution-amount redesign, cadence UX unification, and receipts Phase 1. Not pushed — site is not on Netlify yet.
- Helcim plan consolidation migration **ran against prod 2026-04-18** (Monthly plan id `50302`, Annual plan id `50303`).
- Contribution-amount migration has **NOT** yet been run against prod.
@@ -36,11 +36,13 @@ Applies when the site is connected to Netlify / production hosting. Nothing here
- [ ] **Run `node scripts/migrate-contribution-amount.cjs --apply` against prod Mongo BEFORE the new code serves traffic.** Idempotent; dry-run on local counted 34 members. Requires `MONGODB_URI` in env. The script writes `contributionAmount` (Number) derived from existing `contributionTier` (String) on every Member doc; the old field is left intact for a window.
- [ ] Set `NUXT_HELCIM_MONTHLY_PLAN_ID=50302` in production env.
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` in production env.
-- [ ] Decide on the 6 failing tests in `tests/server/api/helcim-payment.test.js` — either fix or consciously accept. Not launch-blocking, but pre-existing red tests tend to mask new regressions.
- [ ] **Run `node scripts/reconcile-helcim-payments.mjs --apply` against prod Mongo AFTER the new code serves traffic** to backfill Payment records for pre-existing members. Idempotent (unique `helcimTransactionId`); safe to re-run as a nightly reconciliation job post-launch.
- [ ] **Prod audit for pre-fix series-pass bypass registrations.** Fixed in `f34b062` + `4e1888a` (2026-04-20). Before that, child events of pass-only series (`tickets.requiresSeriesTicket=true && tickets.allowIndividualEventTickets=false`) accepted drop-in registrations from non-pass-holders. For every such series, list its child-event `registrations` where the registrant is not in the parent series' pass-holder list, filter to `registeredAt < 2026-04-20`, and decide per-case: grandfather (keep + notify), refund + unregister, or silently unregister. Local Mongo was scrubbed of 2 such rows on 2026-04-20; prod was intentionally untouched.
- [ ] **Helcim dashboard: disable the default payment-confirmation email for plans 50302 + 50303.** We send our own CRA-safe confirmation via Resend (`server/emails/paymentConfirmation.js`) triggered from `upsertPaymentFromHelcim`; leaving Helcim's default on = duplicate emails.
- [ ] **Run one real test charge on staging** via the cloudflared tunnel and verify (a) a Payment doc in Mongo with `amount`, `paymentType`, `status: 'success'`, and (b) exactly one CRA-compliant confirmation email (charity name + "not an official donation receipt" disclaimer; no banned assertive phrasing).
+- [ ] **Rotate HELCIM_API_TOKEN** in the Helcim merchant portal and update the prod env var. The token was previously exposed in `window.__NUXT__` payload until today's deploy.
+- [ ] **Set NUXT_RECONCILE_TOKEN** in production env (any 32+ char random string). Used as shared secret between Netlify scheduled function and the internal reconcile route.
+- [ ] **Verify Netlify scheduled function `reconcile-payments` is enabled** in the Netlify dashboard. Schedule: daily.
**Env vars required in production (reference):**
- `MONGODB_URI`
@@ -53,6 +55,32 @@ Applies when the site is connected to Netlify / production hosting. Nothing here
- `BASE_URL`
- `OIDC_COOKIE_SECRET`
- `NUXT_PUBLIC_HELCIM_PORTAL_URL`
+- `NUXT_RECONCILE_TOKEN`
+
+---
+
+## Fixed 2026-04-25
+
+Day-of-launch security and correctness audit. All commit shas TBD until Phase 5.
+
+### CRITICAL (security)
+- **Fix #1** — `HELCIM_API_TOKEN` removed from public runtime config + dead `useHelcim.js` deleted. **Token must be rotated post-deploy** (was previously exposed via `window.__NUXT__`).
+- **Fix #2** — `/api/helcim/customer` gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated `setAuthCookie`).
+- **Fix #3** — `/api/events/[id]/payment` deleted (dead code with auth bypass). `processHelcimPayment` stub + `eventPaymentSchema` removed.
+- **Fix #4** — `/api/helcim/initialize-payment` re-derives ticket amount server-side via `calculateTicketPrice`; new `series_ticket` metadata type.
+- **Fix #5** — `/api/helcim/customer` upgrades existing `status:guest` members in place rather than rejecting with 409.
+
+### HIGH (correctness)
+- **Fix #6** — Recurring reconciliation: Netlify scheduled function calls `/api/internal/reconcile-payments` daily. Requires `NUXT_RECONCILE_TOKEN` env var.
+- **Fix #7** — `validateBeforeSave: false` added to event subdoc saves (waitlist endpoints) to dodge legacy location validators.
+- **Fix #8** — Series-pass purchase always creates a guest Member when caller is unauthenticated, mirroring event-ticket flow.
+- **Fix #9** — `cancel-subscription` leaves status `active` (per ratified bylaws); adds `lastCancelledAt` audit field.
+- **Fix #10** — `/api/auth/verify` uses `validateBody` with `.strict()` Zod schema.
+- **Fix #11** — Added 8 vitest cases for `cancel-subscription.post.js` (was uncovered).
+
+### Side-quests
+- Visual audit Phase 4 changes (events/series surface)
+- Per-fix branch verification: see `docs/superpowers/specs/2026-04-25-fix-*.md`
---
@@ -100,6 +128,19 @@ See `docs/TODO.md` for:
- **Subscription cache fed wrong field on CREATE.** `subscription.post.js` and `update-contribution.post.js` read `subscription.nextBillingDate` from Helcim's CREATE response, but Helcim returns `dateBilling`. The lazy refresh in `subscription.get.js` masks this (handles both shapes), so next-charge rendering works — but the cache starts empty. Fix at the CREATE sites so the cache is correct from first write.
- **Admin edit does not sync Helcim `recurringAmount`.** `/admin/members/[id]` PUT writes `contributionAmount` direct to Mongo by design. Admins must PATCH Helcim manually. Worth surfacing in admin UI or docs.
- **Cadence switch rejected on active subscriptions.** `update-contribution.post.js:184-189` refuses cadence changes mid-subscription; no UI toggle exists on `/member/account`. Adding cadence switch would require a Helcim subscription replacement flow, not a plain update.
+- **`SeriesPassPurchase.vue` doesn't auto-refresh after purchase.** (Observed 2026-04-21 during Phase 4 series-pass functional tests.) Component's local `$fetch` to `/api/series/{id}/tickets/available` fires on mount + `userEmail` watch, but isn't re-invoked after a successful purchase — the "already registered" state only appears on next navigation. Parent page calls `refreshNuxtData()` but the component doesn't participate in it. Fix: call `fetchPassInfo()` after the success toast in `handleSubmit`, or lift the fetch to `useAsyncData` so it can be refreshed from outside.
+- **S2 test fixture `id`/`slug` inconsistency.** (Local dev only.) Seeded S2 series has `id: 'test-s2-drop-in-allowed'` but `slug: 'test-s2-drop-in-allowed-series'`. Doesn't affect prod — fix the seed script if anyone re-runs fixtures and is confused why `id`-based Mongo queries return empty.
+
+### Events-surface visual audit — deferred items (2026-04-21)
+
+Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
+
+- **Success-state color convention (4 instances).** "You're Registered!" blocks use `--candle` (gold) instead of `--green`. Touches `EventSeriesTicketCard.vue:186-196` (still uses phantom `candlelight-*` classes — preserved byte-for-byte pending decision) and registered-state wrappers in `SeriesPassPurchase.vue`. Needs a UX call on whether success should render gold (zine-consistent) or green (semantic). Once decided, finish the phantom-palette removal on those 4 lines.
+- **Sidebar breakpoint unverified.** `app/layouts/default.vue:89` hides the sidebar at ≤1024px per spec. Browser `resize_window` tool refused viewport changes during the audit, so the actual crossover and any layout shift at 1023–1025px was never visually confirmed. Do a manual responsive check before declaring the sidebar pattern shipped.
+- **`EventTicketPurchase.vue:469` magic padding.** `.consent-hint { padding-left: 24px; }` is a hardcoded offset to align the hint under the checkbox text. Cosmetic; swap for a gap/grid approach when touching the consent block next.
+- **Toast API rename unverified.** Nuxt UI v4 may have renamed `toast.add({ timeout })` → `{ duration }`. Current `SeriesPassPurchase.vue` toasts still pass `timeout`. No visible breakage, but worth confirming against current Nuxt UI docs.
+- **`.section-label` extraction candidate.** Several audited files repeat the same uppercase/letter-spaced small label pattern inline. Low-priority refactor into a utility class in `main.css`.
+- **Past-events toggle component.** Existing, untouched this pass; noted in findings doc as a future consistency check.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
index 0000000..9c0d9fd
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,13 @@
+[build]
+ command = "npm run build"
+ publish = "dist"
+
+[functions]
+ directory = "netlify/functions"
+
+# Daily reconciliation cron — invokes the protected Nitro route to upsert
+# Payment docs from Helcim transactions. Schedule is also pinned inline in
+# netlify/functions/reconcile-payments.mjs (Netlify accepts either; both is
+# harmless).
+[functions."reconcile-payments"]
+ schedule = "@daily"
diff --git a/netlify/functions/reconcile-payments.mjs b/netlify/functions/reconcile-payments.mjs
new file mode 100644
index 0000000..57994bc
--- /dev/null
+++ b/netlify/functions/reconcile-payments.mjs
@@ -0,0 +1,40 @@
+/**
+ * Netlify scheduled function — daily reconciliation of Helcim payments.
+ *
+ * Calls the protected Nitro route `/api/internal/reconcile-payments` with the
+ * shared-secret header. Heavy lifting (Mongo queries, Helcim API calls, retry
+ * logic) lives in the Nitro handler so it can use auto-imported utils.
+ *
+ * Required env (set in Netlify dashboard):
+ * - URL (set automatically by Netlify)
+ * - RECONCILE_TOKEN (must match NUXT_RECONCILE_TOKEN in Nitro runtime config)
+ *
+ * Schedule: @daily (00:00 UTC). Also pinned in netlify.toml.
+ */
+
+export default async () => {
+ const url = `${process.env.URL}/api/internal/reconcile-payments`
+ const token = process.env.RECONCILE_TOKEN
+
+ if (!token) {
+ const msg = '[reconcile] RECONCILE_TOKEN not configured; aborting'
+ console.error(msg)
+ return new Response(msg, { status: 500 })
+ }
+
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'X-Reconcile-Token': token }
+ })
+ const body = await res.text()
+ if (!res.ok) {
+ console.error('[reconcile] route failed', res.status, body)
+ return new Response(body, { status: res.status })
+ }
+ console.log('[reconcile] ok', body)
+ return new Response(body, { status: 200 })
+}
+
+export const config = {
+ schedule: '@daily'
+}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index d04c3f7..7757b4c 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -95,7 +95,7 @@ export default defineNuxtConfig({
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
jwtSecret: process.env.JWT_SECRET || "",
resendApiKey: process.env.RESEND_API_KEY || "",
- helcimApiToken: process.env.HELCIM_API_TOKEN || "", // also exposed to client via public.helcimToken
+ helcimApiToken: process.env.HELCIM_API_TOKEN || "",
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
slackAdminBotToken: process.env.SLACK_ADMIN_BOT_TOKEN || "",
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
@@ -106,10 +106,10 @@ export default defineNuxtConfig({
outlineApiKey: process.env.OUTLINE_API_KEY || "",
helcimMonthlyPlanId: process.env.NUXT_HELCIM_MONTHLY_PLAN_ID || "",
helcimAnnualPlanId: process.env.NUXT_HELCIM_ANNUAL_PLAN_ID || "",
+ reconcileToken: process.env.NUXT_RECONCILE_TOKEN || "",
// Public keys (available on client-side)
public: {
- helcimToken: process.env.HELCIM_API_TOKEN || "",
helcimAccountId: process.env.NUXT_PUBLIC_HELCIM_ACCOUNT_ID || "",
cloudinaryCloudName:
process.env.NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME || "divzuumlr",
diff --git a/server/api/auth/verify.post.js b/server/api/auth/verify.post.js
index 3cc4b65..4ada236 100644
--- a/server/api/auth/verify.post.js
+++ b/server/api/auth/verify.post.js
@@ -1,17 +1,11 @@
// server/api/auth/verify.post.js
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
+import { validateBody } from '../../utils/validateBody.js'
+import { verifyMagicLinkSchema } from '../../utils/schemas.js'
export default defineEventHandler(async (event) => {
- const body = await readBody(event)
- const token = body?.token
-
- if (!token) {
- throw createError({
- statusCode: 400,
- statusMessage: 'Token is required',
- })
- }
+ const { token } = await validateBody(event, verifyMagicLinkSchema)
const config = useRuntimeConfig(event)
diff --git a/server/api/events/[id]/payment.post.js b/server/api/events/[id]/payment.post.js
deleted file mode 100644
index 26b0d7f..0000000
--- a/server/api/events/[id]/payment.post.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import Event from '../../../models/event.js'
-import Member from '../../../models/member.js'
-import { connectDB } from '../../../utils/mongoose.js'
-import { processHelcimPayment } from '../../../utils/helcim.js'
-import mongoose from 'mongoose'
-
-export default defineEventHandler(async (event) => {
- try {
- await connectDB()
- const identifier = getRouterParam(event, 'id')
- const body = await validateBody(event, eventPaymentSchema)
-
- if (!identifier) {
- throw createError({
- statusCode: 400,
- statusMessage: 'Event identifier is required'
- })
- }
-
- // Fetch the event
- let eventData
- if (mongoose.Types.ObjectId.isValid(identifier)) {
- eventData = await Event.findById(identifier)
- }
- if (!eventData) {
- eventData = await Event.findOne({ slug: identifier })
- }
-
- if (!eventData) {
- throw createError({
- statusCode: 404,
- statusMessage: 'Event not found'
- })
- }
-
- // Check if event requires payment
- if (eventData.pricing.isFree || !eventData.pricing.paymentRequired) {
- throw createError({
- statusCode: 400,
- statusMessage: 'This event does not require payment'
- })
- }
-
- // Check if user is already registered
- const existingRegistration = eventData.registrations.find(
- reg => reg.email.toLowerCase() === body.email.toLowerCase()
- )
-
- if (existingRegistration) {
- throw createError({
- statusCode: 400,
- statusMessage: 'You are already registered for this event'
- })
- }
-
- // Check if user is a member (members get free access)
- const member = await Member.findOne({ email: body.email.toLowerCase() })
-
- if (member) {
- // Members get free access - register directly without payment
- eventData.registrations.push({
- name: body.name,
- email: body.email.toLowerCase(),
- membershipLevel: `${member.circle}-${member.contributionAmount}`,
- isMember: true,
- paymentStatus: 'not_required',
- amountPaid: 0
- })
-
- await eventData.save()
-
- return {
- success: true,
- message: 'Successfully registered as a member (no payment required)',
- registration: eventData.registrations[eventData.registrations.length - 1]
- }
- }
-
- // Process payment for non-members
- const paymentResult = await processHelcimPayment({
- amount: eventData.pricing.publicPrice,
- paymentToken: body.paymentToken,
- customerData: {
- name: body.name,
- email: body.email
- }
- })
-
- if (!paymentResult.success) {
- throw createError({
- statusCode: 400,
- statusMessage: paymentResult.message || 'Payment failed'
- })
- }
-
- // Add registration with successful payment
- eventData.registrations.push({
- name: body.name,
- email: body.email.toLowerCase(),
- membershipLevel: 'non-member',
- isMember: false,
- paymentStatus: 'completed',
- paymentId: paymentResult.transactionId,
- amountPaid: eventData.pricing.publicPrice
- })
-
- await eventData.save()
-
- return {
- success: true,
- message: 'Payment successful and registered for event',
- paymentId: paymentResult.transactionId,
- registration: eventData.registrations[eventData.registrations.length - 1]
- }
-
- } catch (error) {
- console.error('Error processing event payment:', error)
-
- if (error.statusCode) {
- throw error
- }
-
- throw createError({
- statusCode: 500,
- statusMessage: 'Failed to process payment and registration'
- })
- }
-})
\ No newline at end of file
diff --git a/server/api/events/[id]/waitlist.delete.js b/server/api/events/[id]/waitlist.delete.js
index 9e7c9d2..2bfa90d 100644
--- a/server/api/events/[id]/waitlist.delete.js
+++ b/server/api/events/[id]/waitlist.delete.js
@@ -32,7 +32,8 @@ export default defineEventHandler(async (event) => {
}
eventData.tickets.waitlist.entries.splice(waitlistIndex, 1);
- await eventData.save();
+ // Skip validators to avoid tripping on legacy location data unrelated to this write.
+ await eventData.save({ validateBeforeSave: false });
return {
success: true,
diff --git a/server/api/events/[id]/waitlist.post.js b/server/api/events/[id]/waitlist.post.js
index 8cfd984..f6ddffa 100644
--- a/server/api/events/[id]/waitlist.post.js
+++ b/server/api/events/[id]/waitlist.post.js
@@ -87,7 +87,8 @@ export default defineEventHandler(async (event) => {
notified: false,
});
- await eventData.save();
+ // Skip validators to avoid tripping on legacy location data unrelated to this write.
+ await eventData.save({ validateBeforeSave: false });
// Get position in waitlist
const position = eventData.tickets.waitlist.entries.length;
diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js
index 4bbcc80..f41abb6 100644
--- a/server/api/helcim/customer.post.js
+++ b/server/api/helcim/customer.post.js
@@ -1,49 +1,117 @@
-// Create a Helcim customer
+// Public signup endpoint. Creates a Helcim customer + Member record and
+// dispatches a magic link for email verification. The full session cookie
+// is set when the user clicks the magic link (see /api/auth/verify); paid-tier
+// signups also receive a short-lived payment-bridge cookie so they can complete
+// Helcim checkout in the same tab without verifying email first.
+import { getRequestHeader, getRequestIP } from 'h3'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { createHelcimCustomer } from '../../utils/helcim.js'
+import { sendMagicLink } from '../../utils/magicLink.js'
+import { setPaymentBridgeCookie } from '../../utils/auth.js'
+import { rateLimit } from '../../utils/rateLimit.js'
export default defineEventHandler(async (event) => {
try {
+ // --- Origin check (CSRF defense in depth on top of SameSite=Lax) ---
+ const origin = getRequestHeader(event, 'origin')
+ const allowed = process.env.BASE_URL
+ if (!origin || (allowed && origin !== allowed)) {
+ throw createError({ statusCode: 403, statusMessage: 'Invalid origin' })
+ }
+
+ // --- Per-IP rate limit (5 / hour) ---
+ const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
+ if (!rateLimit(`signup:ip:${ip}`, { max: 5, windowMs: 3600_000 })) {
+ throw createError({ statusCode: 429, statusMessage: 'Too many signup attempts' })
+ }
+
await connectDB()
const body = await validateBody(event, helcimCustomerSchema)
- // Check if member already exists
- const existingMember = await Member.findOne({ email: body.email })
- if (existingMember) {
+ // --- Per-email rate limit (3 / hour) ---
+ if (!rateLimit(`signup:email:${body.email}`, { max: 3, windowMs: 3600_000 })) {
+ throw createError({
+ statusCode: 429,
+ statusMessage: 'Too many signup attempts for this email'
+ })
+ }
+
+ // Check if member already exists. Lowercase the lookup so guest docs
+ // created via the public ticket-purchase path (which lowercases on insert)
+ // are actually found by mixed-case submissions.
+ const normalizedEmail = body.email.toLowerCase()
+ const existingMember = await Member.findOne({ email: normalizedEmail })
+ if (existingMember && existingMember.status !== 'guest') {
throw createError({
statusCode: 409,
statusMessage: 'A member with this email already exists'
})
}
- // Create customer in Helcim
+ // Create customer in Helcim (guest docs have no helcimCustomerId yet).
const customerData = await createHelcimCustomer({
customerType: 'PERSON',
contactName: body.name,
email: body.email
})
- // Create member in database
- const member = await Member.create({
- email: body.email,
- name: body.name,
- circle: body.circle,
- contributionAmount: body.contributionAmount,
- helcimCustomerId: customerData.id,
- status: 'pending_payment',
- agreement: { acceptedAt: new Date() }
+ // If the lookup matched a guest doc, upgrade in place to preserve _id,
+ // memberNumber (if any), emailHistory, and the event-registration
+ // references that point at this _id. Use findByIdAndUpdate with
+ // runValidators:false per the project's member-save-risks pattern.
+ let member
+ if (existingMember) {
+ member = await Member.findByIdAndUpdate(
+ existingMember._id,
+ {
+ $set: {
+ name: body.name,
+ circle: body.circle,
+ contributionAmount: body.contributionAmount,
+ helcimCustomerId: customerData.id,
+ status: 'pending_payment',
+ 'agreement.acceptedAt': new Date()
+ }
+ },
+ { new: true, runValidators: false }
+ )
+ } else {
+ member = await Member.create({
+ email: normalizedEmail,
+ name: body.name,
+ circle: body.circle,
+ contributionAmount: body.contributionAmount,
+ helcimCustomerId: customerData.id,
+ status: 'pending_payment',
+ agreement: { acceptedAt: new Date() }
+ })
+ }
+
+ // Issue a magic link instead of an immediate session — the auth-token
+ // cookie is set when the user clicks through, proving email ownership.
+ // Use the normalized email so guest upgrades (which may not project
+ // the email field back) still get a magic link.
+ await sendMagicLink(normalizedEmail, {
+ subject: 'Verify your Ghost Guild signup',
+ intro: 'Verify your email to finish your Ghost Guild signup:'
})
- setAuthCookie(event, member)
+ // Paid-tier signups need to complete Helcim checkout in the same tab
+ // before the magic link can be clicked. Issue a short-lived, payment-only
+ // bridge cookie so /api/helcim/initialize-payment accepts the request.
+ if (body.contributionAmount > 0) {
+ setPaymentBridgeCookie(event, member)
+ }
return {
success: true,
customerId: customerData.id,
customerCode: customerData.customerCode,
+ verificationEmailSent: true,
member: {
id: member._id,
- email: member.email,
+ email: normalizedEmail,
name: member.name,
circle: member.circle,
contributionAmount: member.contributionAmount,
diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js
index 26f9e94..33adbec 100644
--- a/server/api/helcim/initialize-payment.post.js
+++ b/server/api/helcim/initialize-payment.post.js
@@ -1,23 +1,84 @@
// Initialize HelcimPay.js session
-import { requireAuth } from '../../utils/auth.js'
+import Member from '../../models/member.js'
+import Series from '../../models/series.js'
+import { loadPublicEvent } from '../../utils/loadEvent.js'
+import { calculateTicketPrice, calculateSeriesTicketPrice, hasMemberAccess } from '../../utils/tickets.js'
+import { requireAuth, getOptionalMember, getPaymentBridgeMember } from '../../utils/auth.js'
import { initializeHelcimPaySession } from '../../utils/helcim.js'
export default defineEventHandler(async (event) => {
try {
const body = await validateBody(event, helcimInitializePaymentSchema)
+ const metaType = body.metadata?.type
- // Event ticket purchases can be made without authentication
- const isEventTicket = body.metadata?.type === 'event_ticket'
- if (!isEventTicket) {
- await requireAuth(event)
+ const isEventTicket = metaType === 'event_ticket'
+ const isSeriesTicket = metaType === 'series_ticket'
+ const isTicket = isEventTicket || isSeriesTicket
+ // Membership signup uses a short-lived payment-bridge cookie (set by
+ // /api/helcim/customer) so the user can complete checkout before clicking
+ // their email-verification magic link.
+ const isMembershipSignup = metaType === 'membership_signup'
+
+ if (!isTicket) {
+ if (isMembershipSignup) {
+ const bridgeMember = await getPaymentBridgeMember(event)
+ if (!bridgeMember) {
+ await requireAuth(event)
+ }
+ } else {
+ await requireAuth(event)
+ }
}
- const amount = body.amount || 0
+ let amount = 0
+ let description = body.metadata?.eventTitle
- // For event tickets with amount > 0, we do a purchase
- // For subscriptions or card verification, we do verify
- const paymentType = isEventTicket && amount > 0 ? 'purchase' : 'verify'
+ if (isTicket) {
+ // Re-derive price server-side; never trust body.amount for ticket flows.
+ const caller = await getOptionalMember(event).catch(() => null)
+ const lookupEmail = body.metadata?.email?.toLowerCase()
+ const member = caller || (lookupEmail
+ ? await Member.findOne({ email: lookupEmail })
+ : null)
+ const accessMember = hasMemberAccess(member) ? member : null
+ if (isEventTicket) {
+ const eventId = body.metadata?.eventId
+ if (!eventId) {
+ throw createError({ statusCode: 400, statusMessage: 'metadata.eventId is required for event_ticket' })
+ }
+ const eventDoc = await loadPublicEvent(event, eventId)
+ const ticketInfo = calculateTicketPrice(eventDoc, accessMember)
+ if (!ticketInfo) {
+ throw createError({ statusCode: 403, statusMessage: 'No tickets available for your membership status' })
+ }
+ amount = ticketInfo.price
+ description = description || eventDoc.title
+ } else {
+ const seriesId = body.metadata?.seriesId
+ if (!seriesId) {
+ throw createError({ statusCode: 400, statusMessage: 'metadata.seriesId is required for series_ticket' })
+ }
+ const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId)
+ const seriesQuery = isObjectId
+ ? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
+ : { $or: [{ id: seriesId }, { slug: seriesId }] }
+ const series = await Series.findOne(seriesQuery)
+ if (!series) {
+ throw createError({ statusCode: 404, statusMessage: 'Series not found' })
+ }
+ const ticketInfo = calculateSeriesTicketPrice(series, accessMember)
+ if (!ticketInfo) {
+ throw createError({ statusCode: 403, statusMessage: 'No series passes available for your membership status' })
+ }
+ amount = ticketInfo.price
+ description = description || series.title
+ }
+ } else {
+ amount = body.amount || 0
+ }
+
+ const paymentType = isTicket && amount > 0 ? 'purchase' : 'verify'
const requestBody = {
paymentType,
amount: paymentType === 'purchase' ? amount : 0,
@@ -25,20 +86,15 @@ export default defineEventHandler(async (event) => {
paymentMethod: 'cc'
}
- // For subscription setup (verify mode), include customer code if provided
- // For one-time purchases (event tickets), don't include customer code
- // as the customer may not exist in Helcim yet
if (body.customerCode && paymentType === 'verify') {
requestBody.customerCode = body.customerCode
}
- // Add product/event information for better display in Helcim modal
- if (body.metadata?.eventTitle) {
- // Some Helcim accounts don't support invoice numbers in initialization
- // Try multiple fields that might display in the modal
- requestBody.description = body.metadata.eventTitle
- requestBody.notes = body.metadata.eventTitle
- requestBody.orderNumber = `${body.metadata.eventId}`
+ if (description) {
+ requestBody.description = description
+ requestBody.notes = description
+ const orderId = body.metadata?.eventId || body.metadata?.seriesId
+ if (orderId) requestBody.orderNumber = `${orderId}`
}
const paymentData = await initializeHelcimPaySession(requestBody)
@@ -46,7 +102,9 @@ export default defineEventHandler(async (event) => {
return {
success: true,
checkoutToken: paymentData.checkoutToken,
- secretToken: paymentData.secretToken
+ secretToken: paymentData.secretToken,
+ // Echo derived amount so the client can sanity-check before opening modal.
+ amount
}
} catch (error) {
if (error.statusCode) throw error
diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js
index 8afd7be..b5567bc 100644
--- a/server/api/helcim/subscription.post.js
+++ b/server/api/helcim/subscription.post.js
@@ -3,7 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts'
-import { requireAuth } from '../../utils/auth.js'
+import { requireAuth, getPaymentBridgeMember } from '../../utils/auth.js'
import { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js'
import { sendWelcomeEmail } from '../../utils/resend.js'
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
@@ -81,7 +81,12 @@ async function inviteToSlack(member) {
export default defineEventHandler(async (event) => {
try {
- await requireAuth(event)
+ // Membership signup completes subscription before email verify; allow the
+ // payment-bridge cookie set by /api/helcim/customer to satisfy auth here.
+ const bridgeMember = await getPaymentBridgeMember(event)
+ if (!bridgeMember) {
+ await requireAuth(event)
+ }
await connectDB()
const body = await validateBody(event, helcimSubscriptionSchema)
diff --git a/server/api/internal/reconcile-payments.post.js b/server/api/internal/reconcile-payments.post.js
new file mode 100644
index 0000000..6502ead
--- /dev/null
+++ b/server/api/internal/reconcile-payments.post.js
@@ -0,0 +1,116 @@
+/**
+ * Reconciliation cron route — invoked by `netlify/functions/reconcile-payments.mjs`
+ * on a daily schedule. Mirrors the loop in `scripts/reconcile-helcim-payments.mjs`
+ * but lives inside Nitro so it can use auto-imported utils + the runtime config.
+ *
+ * Auth: shared-secret header `X-Reconcile-Token` matched against
+ * `runtimeConfig.reconcileToken` (env: NUXT_RECONCILE_TOKEN). Machine-to-machine
+ * only — no user session involved.
+ *
+ * Behavior:
+ * - For every Member with a helcimCustomerId, list Helcim transactions and
+ * upsert Payment docs (idempotent via `helcimTransactionId` unique index).
+ * - Transient Helcim API errors are retried up to 3 times with exponential
+ * backoff (250ms / 500ms / 1000ms). On final failure the member is counted
+ * as `memberErrors` and the loop continues.
+ * - Never passes `sendConfirmation: true` — the cron back-fills history and
+ * must not re-send confirmation emails.
+ * - `?apply=false` switches to dry-run: counts what WOULD be created via
+ * Payment.findOne, no writes.
+ *
+ * Returns a JSON summary; logs `[reconcile] done ` to stdout.
+ */
+import Member from '../../models/member.js'
+import Payment from '../../models/payment.js'
+import { listHelcimCustomerTransactions } from '../../utils/helcim.js'
+import { upsertPaymentFromHelcim } from '../../utils/payments.js'
+
+const RETRY_ATTEMPTS = 3
+const BASE_DELAY_MS = 250
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+async function listTransactionsWithRetry(customerCode) {
+ let lastErr
+ for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) {
+ try {
+ return await listHelcimCustomerTransactions(customerCode)
+ } catch (err) {
+ lastErr = err
+ if (attempt < RETRY_ATTEMPTS) {
+ await sleep(BASE_DELAY_MS * 2 ** (attempt - 1))
+ }
+ }
+ }
+ throw lastErr
+}
+
+export default defineEventHandler(async (event) => {
+ const config = useRuntimeConfig()
+ const expected = config.reconcileToken
+ const provided = getHeader(event, 'x-reconcile-token')
+ if (!expected || provided !== expected) {
+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
+ }
+
+ const apply = getQuery(event).apply !== 'false'
+
+ const members = await Member.find(
+ { helcimCustomerId: { $exists: true, $ne: null } },
+ { _id: 1, email: 1, name: 1, helcimCustomerId: 1, helcimSubscriptionId: 1, billingCadence: 1 }
+ ).lean()
+
+ let txExamined = 0
+ let created = 0
+ let existed = 0
+ let skipped = 0
+ let memberErrors = 0
+
+ for (const member of members) {
+ let txs
+ try {
+ txs = await listTransactionsWithRetry(member.helcimCustomerId)
+ } catch (err) {
+ memberErrors++
+ console.error(`[reconcile] member=${member._id}: ${err?.message || err}`)
+ continue
+ }
+
+ for (const tx of txs) {
+ txExamined++
+ if (tx.status === 'other') {
+ skipped++
+ continue
+ }
+
+ if (!apply) {
+ const existing = await Payment.findOne({ helcimTransactionId: tx.id })
+ if (existing) existed++
+ else created++
+ continue
+ }
+
+ // Note: deliberately NOT passing sendConfirmation — cron back-fills must
+ // not re-send confirmation emails for transactions the member has already
+ // been notified about (or that pre-date Mongo Payment tracking entirely).
+ const result = await upsertPaymentFromHelcim(member, tx)
+ if (result.created) created++
+ else if (result.payment) existed++
+ else skipped++
+ }
+ }
+
+ const summary = {
+ membersScanned: members.length,
+ txExamined,
+ created,
+ existed,
+ skipped,
+ memberErrors,
+ apply
+ }
+ console.log('[reconcile] done', summary)
+ return summary
+})
diff --git a/server/api/members/cancel-subscription.post.js b/server/api/members/cancel-subscription.post.js
index 4e311a9..83fccba 100644
--- a/server/api/members/cancel-subscription.post.js
+++ b/server/api/members/cancel-subscription.post.js
@@ -23,16 +23,17 @@ export default defineEventHandler(async (event) => {
// Continue anyway - we'll update the member record
}
- // Update member status — pending_payment (not cancelled) so member can re-subscribe
+ // Update member: drop to free tier (status stays 'active' — they're still a member)
await Member.findByIdAndUpdate(
member._id,
{
$set: {
- status: 'pending_payment',
+ status: 'active',
contributionAmount: 0,
helcimSubscriptionId: null,
paymentMethod: 'none',
subscriptionEndDate: new Date(),
+ lastCancelledAt: new Date(),
},
$unset: { nextBillingDate: 1 },
},
@@ -46,7 +47,7 @@ export default defineEventHandler(async (event) => {
return {
success: true,
message: "Subscription cancelled successfully",
- status: 'pending_payment',
+ status: 'active',
contributionAmount: 0,
};
} catch (error) {
diff --git a/server/api/series/[id]/tickets/purchase.post.js b/server/api/series/[id]/tickets/purchase.post.js
index 71f3fda..0f27b7b 100644
--- a/server/api/series/[id]/tickets/purchase.post.js
+++ b/server/api/series/[id]/tickets/purchase.post.js
@@ -38,6 +38,7 @@ export default defineEventHandler(async (event) => {
// Only members with access (active or pending_payment) get member-tier
// pricing; guest, suspended, and cancelled are treated as non-members.
let member = null;
+ let accountCreated = false;
try {
member = await requireAuth(event);
} catch {
@@ -83,6 +84,29 @@ export default defineEventHandler(async (event) => {
});
}
+ // If no Member yet, atomically create a guest Member. Series passes grant
+ // access to a bundle of events over time — a buyer without an account
+ // can't view their registrations or be recognized by per-event pages,
+ // so we always create the guest (unlike single-event tickets which are
+ // opt-in). findOneAndUpdate with $setOnInsert handles concurrent
+ // registrations on the same email (email has a unique index).
+ if (!member) {
+ member = await Member.findOneAndUpdate(
+ { email: canonicalEmail },
+ {
+ $setOnInsert: {
+ email: canonicalEmail,
+ name,
+ circle: "community",
+ contributionAmount: 0,
+ status: "guest",
+ },
+ },
+ { upsert: true, new: true, setDefaultsOnInsert: true }
+ );
+ accountCreated = true;
+ }
+
// Create series registration
const registration = {
memberId: member?._id,
@@ -102,6 +126,17 @@ export default defineEventHandler(async (event) => {
series.registrations.push(registration);
await completeSeriesTicketPurchase(series, ticketInfo.ticketType);
+ // Decide on auto-login: safe for new accounts and existing guests, not for
+ // real members (stranger could hijack by typing email into a public form).
+ let signedIn = false;
+ let requiresSignIn = false;
+ if (member && (accountCreated || member.status === "guest")) {
+ setAuthCookie(event, member);
+ signedIn = true;
+ } else if (member) {
+ requiresSignIn = true;
+ }
+
// Get the newly created registration
const newRegistration =
series.registrations[series.registrations.length - 1];
@@ -172,6 +207,9 @@ export default defineEventHandler(async (event) => {
success: r.success,
reason: r.reason,
})),
+ accountCreated,
+ signedIn,
+ requiresSignIn,
};
} catch (error) {
console.error("Error purchasing series pass:", error);
diff --git a/server/models/member.js b/server/models/member.js
index 65949a4..61dd354 100644
--- a/server/models/member.js
+++ b/server/models/member.js
@@ -56,6 +56,7 @@ const memberSchema = new mongoose.Schema({
subscriptionStartDate: Date,
subscriptionEndDate: Date,
nextBillingDate: Date,
+ lastCancelledAt: Date,
slackInvited: { type: Boolean, default: false },
slackInviteStatus: {
type: String,
diff --git a/server/utils/auth.js b/server/utils/auth.js
index 1856152..1876636 100644
--- a/server/utils/auth.js
+++ b/server/utils/auth.js
@@ -22,6 +22,59 @@ export function setAuthCookie(event, member) {
})
}
+/**
+ * Issue a 30-minute payment-bridge cookie scoped to membership-signup checkout.
+ *
+ * The signup flow (POST /api/helcim/customer) defers the full session cookie
+ * to email-verify (magic link). For paid tiers the user still needs to complete
+ * Helcim checkout in the same browser tab — this short-lived, payment-only
+ * token lets `/api/helcim/initialize-payment` accept the call without a full
+ * session. The cookie is NOT honored by requireAuth and grants nothing else.
+ */
+export function setPaymentBridgeCookie(event, member) {
+ const token = jwt.sign(
+ {
+ memberId: member._id.toString(),
+ email: member.email,
+ scope: 'payment_bridge'
+ },
+ useRuntimeConfig(event).jwtSecret,
+ { expiresIn: '30m' }
+ )
+
+ setCookie(event, 'payment-bridge', token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ path: '/',
+ maxAge: 60 * 30
+ })
+}
+
+/**
+ * Verify a payment-bridge cookie and return the associated Member, or null.
+ * Used by /api/helcim/initialize-payment to allow the membership-signup
+ * checkout to proceed before email verification.
+ */
+export async function getPaymentBridgeMember(event) {
+ const token = getCookie(event, 'payment-bridge')
+ if (!token) return null
+
+ let decoded
+ try {
+ decoded = jwt.verify(token, useRuntimeConfig(event).jwtSecret)
+ } catch {
+ return null
+ }
+
+ if (decoded.scope !== 'payment_bridge') return null
+
+ await connectDB()
+ const member = await Member.findById(decoded.memberId)
+ if (!member) return null
+ return member
+}
+
/**
* Verify JWT from cookie and return the decoded member.
* Throws 401 if token is missing or invalid.
diff --git a/server/utils/helcim.js b/server/utils/helcim.js
index c62c628..ca6eb34 100644
--- a/server/utils/helcim.js
+++ b/server/utils/helcim.js
@@ -262,15 +262,3 @@ export function generateIdempotencyKey() {
}
return key
}
-
-/**
- * Legacy stub — kept alive ONLY so `server/api/events/[id]/payment.post.js`
- * still imports cleanly. The direct purchase API was never implemented.
- * Always returns `{ success: false }`; callers surface the message to the user.
- */
-export async function processHelcimPayment(_paymentData) {
- return {
- success: false,
- message: 'Direct purchase API not implemented; use HelcimPay.js flow'
- }
-}
diff --git a/server/utils/magicLink.js b/server/utils/magicLink.js
new file mode 100644
index 0000000..fc617b4
--- /dev/null
+++ b/server/utils/magicLink.js
@@ -0,0 +1,66 @@
+// Send a magic-link verification email. Mirrors the token/email logic in
+// server/api/auth/login.post.js so callers (signup, login, etc.) can request
+// a verification link with their own subject/intro copy.
+import jwt from 'jsonwebtoken'
+import { randomUUID } from 'crypto'
+import { Resend } from 'resend'
+import Member from '../models/member.js'
+
+const resend = new Resend(process.env.RESEND_API_KEY)
+
+/**
+ * Issue a 15-minute magic-link JWT for `email` and email it.
+ *
+ * @param {string} email
+ * @param {object} [options]
+ * @param {string} [options.subject] - Email subject (default: "Your Ghost Guild login link")
+ * @param {string} [options.intro] - Optional one-line intro before the link.
+ * @returns {Promise<{ sent: boolean }>} - sent=false when no member exists for the email
+ * (caller can decide whether to surface that; the auth/login endpoint hides it for
+ * anti-enumeration, signup knows the member was just created).
+ */
+export async function sendMagicLink(email, options = {}) {
+ const baseUrl = process.env.BASE_URL
+ if (!baseUrl) {
+ throw createError({
+ statusCode: 500,
+ statusMessage: 'BASE_URL environment variable is not set'
+ })
+ }
+
+ const member = await Member.findOne({ email })
+ if (!member) return { sent: false }
+
+ const jti = randomUUID()
+ const token = jwt.sign(
+ { memberId: member._id, jti },
+ useRuntimeConfig().jwtSecret,
+ { expiresIn: '15m' }
+ )
+
+ await Member.findByIdAndUpdate(
+ member._id,
+ { $set: { magicLinkJti: jti, magicLinkJtiUsed: false } },
+ { runValidators: false }
+ )
+
+ const magicLink = `${baseUrl}/verify#${token}`
+ const subject = options.subject || 'Your Ghost Guild login link'
+ const intro = options.intro || 'Sign in to Ghost Guild:'
+ const text = `Hi,\n\n${intro}\n${magicLink}\n\nThis link expires in 15 minutes. If you didn't request it, ignore this email.`
+
+ await resend.emails.send({
+ from: 'Ghost Guild ',
+ to: email,
+ subject,
+ text
+ })
+
+ logActivity(member._id, 'email_sent', {
+ emailType: 'magic_link',
+ subject,
+ body: text
+ })
+
+ return { sent: true }
+}
diff --git a/server/utils/rateLimit.js b/server/utils/rateLimit.js
new file mode 100644
index 0000000..da15519
--- /dev/null
+++ b/server/utils/rateLimit.js
@@ -0,0 +1,18 @@
+// Tiny in-memory sliding-window rate limiter.
+// Acceptable for single-instance Nitro on Netlify; swap to Mongo/Upstash if
+// we move to multi-instance.
+const buckets = new Map()
+
+export function rateLimit(key, { max, windowMs }) {
+ const now = Date.now()
+ const arr = (buckets.get(key) || []).filter((t) => now - t < windowMs)
+ if (arr.length >= max) return false
+ arr.push(now)
+ buckets.set(key, arr)
+ return true
+}
+
+// Test helper — clears all buckets so each test starts clean.
+export function resetRateLimit() {
+ buckets.clear()
+}
diff --git a/server/utils/schemas.js b/server/utils/schemas.js
index 0444622..c04a649 100644
--- a/server/utils/schemas.js
+++ b/server/utils/schemas.js
@@ -5,6 +5,10 @@ export const emailSchema = z.object({
email: z.string().trim().toLowerCase().email()
})
+export const verifyMagicLinkSchema = z.object({
+ token: z.string().min(1).max(2000)
+}).strict()
+
export const memberCreateSchema = z.object({
email: z.string().trim().toLowerCase().email(),
name: z.string().min(1).max(200),
@@ -62,12 +66,16 @@ export const helcimCustomerSchema = z.object({
})
export const helcimInitializePaymentSchema = z.object({
+ // amount is accepted but IGNORED for ticket types (server re-derives).
+ // Kept for verify-mode (subscription card-on-file) where 0 is sent.
amount: z.number().min(0).optional(),
customerCode: z.string().max(200).optional(),
metadata: z.object({
- type: z.string().max(100).optional(),
+ type: z.enum(['event_ticket', 'series_ticket', 'subscription', 'card_verify', 'membership_signup']).optional(),
eventTitle: z.string().max(500).optional(),
- eventId: z.string().max(200).optional()
+ eventId: z.string().max(200).optional(),
+ seriesId: z.string().max(200).optional(),
+ email: z.string().trim().toLowerCase().email().optional()
}).optional()
})
@@ -131,12 +139,6 @@ export const checkRegistrationSchema = z.object({
email: z.string().trim().toLowerCase().email()
})
-export const eventPaymentSchema = z.object({
- name: z.string().min(1).max(200),
- email: z.string().trim().toLowerCase().email(),
- paymentToken: z.string().min(1).max(500)
-})
-
// --- Member schemas ---
export const updateContributionSchema = z.object({
diff --git a/tests/server/api/auth-verify.test.js b/tests/server/api/auth-verify.test.js
index a16234d..d018f6d 100644
--- a/tests/server/api/auth-verify.test.js
+++ b/tests/server/api/auth-verify.test.js
@@ -1,5 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
+import jwt from 'jsonwebtoken'
+import Member from '../../../server/models/member.js'
+import verifyHandler from '../../../server/api/auth/verify.post.js'
+import { createMockEvent } from '../helpers/createMockEvent.js'
+
vi.mock('../../../server/models/member.js', () => ({
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
}))
@@ -15,11 +20,6 @@ vi.mock('jsonwebtoken', () => ({
}
}))
-import jwt from 'jsonwebtoken'
-import Member from '../../../server/models/member.js'
-import verifyHandler from '../../../server/api/auth/verify.post.js'
-import { createMockEvent } from '../helpers/createMockEvent.js'
-
const baseMember = {
_id: 'member-123',
email: 'test@example.com',
@@ -44,10 +44,94 @@ describe('auth verify endpoint', () => {
await expect(verifyHandler(event)).rejects.toMatchObject({
statusCode: 400,
- statusMessage: 'Token is required'
+ statusMessage: 'Validation failed'
})
})
+ it('rejects non-string number token with 400 before jwt.verify', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/auth/verify',
+ body: { token: 123 }
+ })
+
+ await expect(verifyHandler(event)).rejects.toMatchObject({
+ statusCode: 400,
+ statusMessage: 'Validation failed'
+ })
+ expect(jwt.verify).not.toHaveBeenCalled()
+ })
+
+ it('rejects non-string boolean token with 400 before jwt.verify', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/auth/verify',
+ body: { token: true }
+ })
+
+ await expect(verifyHandler(event)).rejects.toMatchObject({
+ statusCode: 400,
+ statusMessage: 'Validation failed'
+ })
+ expect(jwt.verify).not.toHaveBeenCalled()
+ })
+
+ it('rejects empty string token with 400 before jwt.verify', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/auth/verify',
+ body: { token: '' }
+ })
+
+ await expect(verifyHandler(event)).rejects.toMatchObject({
+ statusCode: 400,
+ statusMessage: 'Validation failed'
+ })
+ expect(jwt.verify).not.toHaveBeenCalled()
+ })
+
+ it('rejects oversized token (>2000 chars) with 400 before jwt.verify', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/auth/verify',
+ body: { token: 'x'.repeat(2001) }
+ })
+
+ await expect(verifyHandler(event)).rejects.toMatchObject({
+ statusCode: 400,
+ statusMessage: 'Validation failed'
+ })
+ expect(jwt.verify).not.toHaveBeenCalled()
+ })
+
+ it('rejects null body with 400 before jwt.verify', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/auth/verify',
+ body: null
+ })
+
+ await expect(verifyHandler(event)).rejects.toMatchObject({
+ statusCode: 400,
+ statusMessage: 'Validation failed'
+ })
+ expect(jwt.verify).not.toHaveBeenCalled()
+ })
+
+ it('rejects unknown extra keys with 400 before jwt.verify (strict mode)', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/auth/verify',
+ body: { token: 'valid-token', email: 'extra@example.com' }
+ })
+
+ await expect(verifyHandler(event)).rejects.toMatchObject({
+ statusCode: 400,
+ statusMessage: 'Validation failed'
+ })
+ expect(jwt.verify).not.toHaveBeenCalled()
+ })
+
it('rejects invalid JWT with 401', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
diff --git a/tests/server/api/cancel-subscription.test.js b/tests/server/api/cancel-subscription.test.js
new file mode 100644
index 0000000..bd509fb
--- /dev/null
+++ b/tests/server/api/cancel-subscription.test.js
@@ -0,0 +1,201 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+import Member from '../../../server/models/member.js'
+import { cancelHelcimSubscription } from '../../../server/utils/helcim.js'
+import handler from '../../../server/api/members/cancel-subscription.post.js'
+import { createMockEvent } from '../helpers/createMockEvent.js'
+
+vi.mock('../../../server/models/member.js', () => ({
+ default: { findByIdAndUpdate: vi.fn() }
+}))
+vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
+vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
+vi.mock('../../../server/utils/helcim.js', () => ({
+ cancelHelcimSubscription: vi.fn(),
+}))
+
+// Nitro auto-imports — stub as globals
+const logActivityMock = vi.fn()
+vi.stubGlobal('logActivity', logActivityMock)
+
+function setMember(mockMember) {
+ globalThis.requireAuth = vi.fn().mockResolvedValue(mockMember)
+}
+
+function buildEvent() {
+ return createMockEvent({
+ method: 'POST',
+ path: '/api/members/cancel-subscription',
+ body: {},
+ })
+}
+
+describe('cancel-subscription endpoint', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('happy path: active paid member → cancels Helcim, drops to free tier (status stays active), returns success', async () => {
+ const mockMember = {
+ _id: 'member-1',
+ status: 'active',
+ contributionAmount: 15,
+ helcimSubscriptionId: 'sub-1',
+ }
+ setMember(mockMember)
+ cancelHelcimSubscription.mockResolvedValue({})
+ Member.findByIdAndUpdate.mockResolvedValue({})
+
+ const result = await handler(buildEvent())
+
+ expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
+ expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
+ 'member-1',
+ expect.objectContaining({
+ $set: expect.objectContaining({
+ status: 'active',
+ contributionAmount: 0,
+ helcimSubscriptionId: null,
+ paymentMethod: 'none',
+ }),
+ $unset: expect.objectContaining({ nextBillingDate: 1 }),
+ }),
+ { runValidators: false }
+ )
+ // Per Fix #9: lastCancelledAt must be set as a Date
+ const updateArg = Member.findByIdAndUpdate.mock.calls[0][1]
+ expect(updateArg.$set.lastCancelledAt).toBeInstanceOf(Date)
+ expect(result).toEqual(expect.objectContaining({
+ success: true,
+ message: 'Subscription cancelled successfully',
+ status: 'active',
+ contributionAmount: 0,
+ }))
+ })
+
+ it('persists subscriptionEndDate as a Date instance', async () => {
+ setMember({
+ _id: 'member-2',
+ status: 'active',
+ contributionAmount: 5,
+ helcimSubscriptionId: 'sub-2',
+ })
+ cancelHelcimSubscription.mockResolvedValue({})
+ Member.findByIdAndUpdate.mockResolvedValue({})
+
+ await handler(buildEvent())
+
+ const call = Member.findByIdAndUpdate.mock.calls[0]
+ expect(call[1].$set.subscriptionEndDate).toBeInstanceOf(Date)
+ })
+
+ it('emits subscription_cancelled activity log entry', async () => {
+ setMember({
+ _id: 'member-3',
+ status: 'active',
+ contributionAmount: 15,
+ helcimSubscriptionId: 'sub-3',
+ })
+ cancelHelcimSubscription.mockResolvedValue({})
+ Member.findByIdAndUpdate.mockResolvedValue({})
+
+ await handler(buildEvent())
+
+ expect(logActivityMock).toHaveBeenCalledWith(
+ 'member-3',
+ 'subscription_cancelled',
+ expect.objectContaining({ effectiveDate: expect.any(String) })
+ )
+ })
+
+ it('free-tier member (contributionAmount=0): no Helcim call, no DB write, returns no-op success', async () => {
+ const mockMember = {
+ _id: 'member-4',
+ status: 'active',
+ contributionAmount: 0,
+ helcimSubscriptionId: null,
+ }
+ setMember(mockMember)
+
+ const result = await handler(buildEvent())
+
+ expect(cancelHelcimSubscription).not.toHaveBeenCalled()
+ expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
+ expect(logActivityMock).not.toHaveBeenCalled()
+ expect(result).toEqual({
+ success: true,
+ message: 'No active subscription to cancel',
+ status: 'active',
+ contributionAmount: 0,
+ })
+ })
+
+ it('member without helcimSubscriptionId (data inconsistency): no Helcim call, no DB write', async () => {
+ setMember({
+ _id: 'member-5',
+ status: 'active',
+ contributionAmount: 15,
+ helcimSubscriptionId: null,
+ })
+
+ const result = await handler(buildEvent())
+
+ expect(cancelHelcimSubscription).not.toHaveBeenCalled()
+ expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
+ expect(result.success).toBe(true)
+ expect(result.message).toBe('No active subscription to cancel')
+ })
+
+ it('Helcim cancel failure: swallows error, still updates Member to free tier (status stays active)', async () => {
+ setMember({
+ _id: 'member-6',
+ status: 'active',
+ contributionAmount: 15,
+ helcimSubscriptionId: 'sub-6',
+ })
+ cancelHelcimSubscription.mockRejectedValue(new Error('Helcim 503'))
+ Member.findByIdAndUpdate.mockResolvedValue({})
+
+ const result = await handler(buildEvent())
+
+ expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-6')
+ expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
+ 'member-6',
+ expect.objectContaining({
+ $set: expect.objectContaining({ status: 'active', helcimSubscriptionId: null }),
+ }),
+ { runValidators: false }
+ )
+ expect(result.success).toBe(true)
+ expect(result.status).toBe('active')
+ })
+
+ it('unauthenticated: requireAuth throws → handler rejects with that statusCode', async () => {
+ globalThis.requireAuth = vi.fn().mockRejectedValue(
+ Object.assign(new Error('Unauthorized'), { statusCode: 401 })
+ )
+
+ await expect(handler(buildEvent())).rejects.toMatchObject({
+ statusCode: 401,
+ })
+
+ expect(cancelHelcimSubscription).not.toHaveBeenCalled()
+ expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
+ })
+
+ it('Mongo update failure: rejects with 500', async () => {
+ setMember({
+ _id: 'member-7',
+ status: 'active',
+ contributionAmount: 15,
+ helcimSubscriptionId: 'sub-7',
+ })
+ cancelHelcimSubscription.mockResolvedValue({})
+ Member.findByIdAndUpdate.mockRejectedValue(new Error('Mongo down'))
+
+ await expect(handler(buildEvent())).rejects.toMatchObject({
+ statusCode: 500,
+ statusMessage: 'Mongo down',
+ })
+ })
+})
diff --git a/tests/server/api/event-save-validators.test.js b/tests/server/api/event-save-validators.test.js
new file mode 100644
index 0000000..049f694
--- /dev/null
+++ b/tests/server/api/event-save-validators.test.js
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest'
+import { readFileSync, existsSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const eventsDir = resolve(import.meta.dirname, '../../../server/api/events/[id]')
+
+describe('waitlist.post.js bypasses validators on event.save()', () => {
+ const source = readFileSync(resolve(eventsDir, 'waitlist.post.js'), 'utf-8')
+
+ it('calls eventData.save with validateBeforeSave: false', () => {
+ expect(source).toContain('eventData.save({ validateBeforeSave: false })')
+ })
+
+ it('does not contain a bare eventData.save() call', () => {
+ expect(source).not.toMatch(/eventData\.save\(\s*\)/)
+ })
+})
+
+describe('waitlist.delete.js bypasses validators on event.save()', () => {
+ const source = readFileSync(resolve(eventsDir, 'waitlist.delete.js'), 'utf-8')
+
+ it('calls eventData.save with validateBeforeSave: false', () => {
+ expect(source).toContain('eventData.save({ validateBeforeSave: false })')
+ })
+
+ it('does not contain a bare eventData.save() call', () => {
+ expect(source).not.toMatch(/eventData\.save\(\s*\)/)
+ })
+})
+
+// payment.post.js cases are handled by Fix #3 (file deletion).
+// If the file still exists, it should also pass the validators bypass.
+describe.skipIf(!existsSync(resolve(eventsDir, 'payment.post.js')))(
+ 'payment.post.js bypasses validators on event.save()',
+ () => {
+ const source = existsSync(resolve(eventsDir, 'payment.post.js'))
+ ? readFileSync(resolve(eventsDir, 'payment.post.js'), 'utf-8')
+ : ''
+
+ it('has exactly two eventData.save({ validateBeforeSave: false }) calls', () => {
+ const matches = source.match(/eventData\.save\(\{\s*validateBeforeSave:\s*false\s*\}\)/g) || []
+ expect(matches.length).toBe(2)
+ })
+
+ it('does not contain a bare eventData.save() call', () => {
+ expect(source).not.toMatch(/eventData\.save\(\s*\)/)
+ })
+ }
+)
diff --git a/tests/server/api/events/payment-deletion.test.js b/tests/server/api/events/payment-deletion.test.js
new file mode 100644
index 0000000..01e7634
--- /dev/null
+++ b/tests/server/api/events/payment-deletion.test.js
@@ -0,0 +1,31 @@
+import { describe, it, expect } from 'vitest'
+import { existsSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+/**
+ * Regression: `events/[id]/payment.post.js` was deleted because its
+ * unauthenticated POST allowed any caller to spam-register an existing
+ * member to any paid event by supplying their email. See
+ * docs/superpowers/specs/2026-04-25-fix-3.md.
+ *
+ * With the route file gone, Nitro's filesystem router will not register
+ * a handler at `/api/events/{id}/payment`, so a POST returns 404 — the
+ * spam-register attack surface no longer exists at the network layer.
+ */
+describe('events/[id]/payment route deletion', () => {
+ it('the payment.post.js route file no longer exists', () => {
+ const routePath = resolve(
+ import.meta.dirname,
+ '../../../../server/api/events/[id]/payment.post.js'
+ )
+ expect(existsSync(routePath)).toBe(false)
+ })
+
+ it('the secure replacement at tickets/purchase.post.js still exists', () => {
+ const replacementPath = resolve(
+ import.meta.dirname,
+ '../../../../server/api/events/[id]/tickets/purchase.post.js'
+ )
+ expect(existsSync(replacementPath)).toBe(true)
+ })
+})
diff --git a/tests/server/api/helcim-customer.test.js b/tests/server/api/helcim-customer.test.js
new file mode 100644
index 0000000..cba7df5
--- /dev/null
+++ b/tests/server/api/helcim-customer.test.js
@@ -0,0 +1,385 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+import Member from '../../../server/models/member.js'
+import { createHelcimCustomer } from '../../../server/utils/helcim.js'
+import { sendMagicLink } from '../../../server/utils/magicLink.js'
+import { setAuthCookie, setPaymentBridgeCookie } from '../../../server/utils/auth.js'
+import customerHandler from '../../../server/api/helcim/customer.post.js'
+import { resetRateLimit } from '../../../server/utils/rateLimit.js'
+import { createMockEvent } from '../helpers/createMockEvent.js'
+
+// --- Mocks ---
+vi.mock('../../../server/models/member.js', () => ({
+ default: { findOne: vi.fn(), create: vi.fn(), findByIdAndUpdate: vi.fn() }
+}))
+vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
+vi.mock('../../../server/utils/helcim.js', () => ({
+ createHelcimCustomer: vi.fn()
+}))
+vi.mock('../../../server/utils/magicLink.js', () => ({
+ sendMagicLink: vi.fn().mockResolvedValue(undefined)
+}))
+vi.mock('../../../server/utils/auth.js', () => ({
+ setAuthCookie: vi.fn(),
+ setPaymentBridgeCookie: vi.fn()
+}))
+
+// helcimCustomerSchema is auto-imported in the handler — stub it to a passthrough
+vi.stubGlobal('helcimCustomerSchema', {})
+
+// Helper to build a same-origin request body+headers
+const ALLOWED_ORIGIN = 'https://ghostguild.test'
+
+function build(opts = {}) {
+ const {
+ origin = ALLOWED_ORIGIN,
+ remoteAddress = '127.0.0.1',
+ body = {
+ name: 'Test User',
+ email: 'test@example.com',
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ } = opts
+ const headers = {}
+ if (origin !== null) headers.origin = origin
+ return createMockEvent({
+ method: 'POST',
+ path: '/api/helcim/customer',
+ body,
+ headers,
+ remoteAddress
+ })
+}
+
+describe('POST /api/helcim/customer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ process.env.BASE_URL = ALLOWED_ORIGIN
+ resetRateLimit()
+ Member.findOne.mockResolvedValue(null)
+ Member.create.mockImplementation(async (doc) => ({ _id: 'mem-1', ...doc }))
+ Member.findByIdAndUpdate.mockImplementation(async (id, update) => ({
+ _id: id,
+ ...(update?.$set || {})
+ }))
+ createHelcimCustomer.mockResolvedValue({ id: 'cust-1', customerCode: 'CUST-1' })
+ })
+
+ describe('origin check', () => {
+ it('rejects requests with missing Origin header', async () => {
+ const event = build({ origin: null })
+ await expect(customerHandler(event)).rejects.toMatchObject({
+ statusCode: 403,
+ statusMessage: 'Invalid origin'
+ })
+ expect(Member.create).not.toHaveBeenCalled()
+ })
+
+ it('rejects requests with foreign Origin header', async () => {
+ const event = build({ origin: 'https://attacker.example' })
+ await expect(customerHandler(event)).rejects.toMatchObject({
+ statusCode: 403,
+ statusMessage: 'Invalid origin'
+ })
+ expect(Member.create).not.toHaveBeenCalled()
+ })
+
+ it('accepts requests with matching Origin header', async () => {
+ const event = build()
+ const result = await customerHandler(event)
+ expect(result.success).toBe(true)
+ })
+ })
+
+ describe('rate limiting', () => {
+ it('rate-limits a single IP after 5 signup attempts', async () => {
+ // 5 calls succeed (each with a unique email so we don't hit email limit
+ // and don't hit the dedupe 409)
+ for (let i = 0; i < 5; i++) {
+ Member.findOne.mockResolvedValueOnce(null)
+ const event = build({
+ remoteAddress: '10.0.0.1',
+ body: {
+ name: 'User',
+ email: `u${i}@example.com`,
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await customerHandler(event)
+ }
+ // 6th call returns 429
+ const event = build({
+ remoteAddress: '10.0.0.1',
+ body: {
+ name: 'User',
+ email: 'u6@example.com',
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await expect(customerHandler(event)).rejects.toMatchObject({
+ statusCode: 429
+ })
+ })
+
+ it('rate-limits a single email after 3 signup attempts (different IPs)', async () => {
+ const email = 'shared@example.com'
+ for (let i = 0; i < 3; i++) {
+ Member.findOne.mockResolvedValueOnce(null)
+ const event = build({
+ remoteAddress: `10.0.0.${i + 10}`,
+ body: {
+ name: 'User',
+ email,
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await customerHandler(event)
+ }
+ // 4th call returns 429
+ const event = build({
+ remoteAddress: '10.0.0.99',
+ body: {
+ name: 'User',
+ email,
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await expect(customerHandler(event)).rejects.toMatchObject({
+ statusCode: 429
+ })
+ })
+ })
+
+ describe('existing-member dedupe (Fix #5)', () => {
+ it('brand-new email succeeds and creates a pending_payment member', async () => {
+ Member.findOne.mockResolvedValue(null)
+ const event = build({
+ body: {
+ name: 'Brand New',
+ email: 'brandnew@example.com',
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ const result = await customerHandler(event)
+ expect(result.success).toBe(true)
+ expect(result.customerId).toBe('cust-1')
+ expect(result.member.status).toBe('pending_payment')
+ expect(Member.create).toHaveBeenCalledTimes(1)
+ expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 for an existing active member', async () => {
+ Member.findOne.mockResolvedValue({
+ _id: 'mem-active',
+ email: 'active@example.com',
+ status: 'active'
+ })
+ const event = build({
+ body: {
+ name: 'Active User',
+ email: 'active@example.com',
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await expect(customerHandler(event)).rejects.toMatchObject({
+ statusCode: 409,
+ statusMessage: 'A member with this email already exists'
+ })
+ expect(Member.create).not.toHaveBeenCalled()
+ expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
+ expect(createHelcimCustomer).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 for an existing pending_payment member (re-submit guard)', async () => {
+ Member.findOne.mockResolvedValue({
+ _id: 'mem-pending',
+ email: 'pending@example.com',
+ status: 'pending_payment'
+ })
+ const event = build({
+ body: {
+ name: 'Pending User',
+ email: 'pending@example.com',
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await expect(customerHandler(event)).rejects.toMatchObject({
+ statusCode: 409
+ })
+ expect(Member.create).not.toHaveBeenCalled()
+ expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
+ expect(createHelcimCustomer).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 for suspended and cancelled members', async () => {
+ for (const status of ['suspended', 'cancelled']) {
+ vi.clearAllMocks()
+ resetRateLimit()
+ Member.findOne.mockResolvedValue({ _id: `mem-${status}`, status })
+ const event = build({
+ body: {
+ name: 'User',
+ email: `${status}@example.com`,
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await expect(customerHandler(event)).rejects.toMatchObject({
+ statusCode: 409
+ })
+ expect(Member.create).not.toHaveBeenCalled()
+ expect(Member.findByIdAndUpdate).not.toHaveBeenCalled()
+ expect(createHelcimCustomer).not.toHaveBeenCalled()
+ }
+ })
+
+ it('upgrades an existing guest member in place (preserves _id)', async () => {
+ const guestId = 'guest-1'
+ Member.findOne.mockResolvedValue({
+ _id: guestId,
+ email: 'guest@example.com',
+ name: 'Old Guest Name',
+ circle: 'community',
+ contributionAmount: 0,
+ status: 'guest'
+ })
+ const event = build({
+ body: {
+ name: 'New Member Name',
+ email: 'guest@example.com',
+ circle: 'founder',
+ contributionAmount: 25,
+ agreedToGuidelines: true
+ }
+ })
+ const result = await customerHandler(event)
+
+ // No new member doc created — existing guest is reused.
+ expect(Member.create).not.toHaveBeenCalled()
+
+ // Helcim customer created for the upgraded member.
+ expect(createHelcimCustomer).toHaveBeenCalledWith({
+ customerType: 'PERSON',
+ contactName: 'New Member Name',
+ email: 'guest@example.com'
+ })
+
+ // findByIdAndUpdate called with guest's _id (preservation) and the form fields.
+ expect(Member.findByIdAndUpdate).toHaveBeenCalledTimes(1)
+ const [updateId, updatePayload, updateOpts] = Member.findByIdAndUpdate.mock.calls[0]
+ expect(updateId).toBe(guestId)
+ expect(updatePayload.$set).toMatchObject({
+ name: 'New Member Name',
+ circle: 'founder',
+ contributionAmount: 25,
+ helcimCustomerId: 'cust-1',
+ status: 'pending_payment'
+ })
+ expect(updatePayload.$set['agreement.acceptedAt']).toBeInstanceOf(Date)
+ expect(updateOpts).toMatchObject({ new: true, runValidators: false })
+
+ // Magic link still issued, paid-tier bridge cookie still set.
+ expect(sendMagicLink).toHaveBeenCalledWith(
+ 'guest@example.com',
+ expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
+ )
+ expect(setPaymentBridgeCookie).toHaveBeenCalled()
+ expect(setAuthCookie).not.toHaveBeenCalled()
+
+ // Response shape mirrors new-signup case AND surfaces the preserved _id.
+ expect(result.success).toBe(true)
+ expect(result.member.id).toBe(guestId)
+ expect(result.member.status).toBe('pending_payment')
+ expect(result.customerId).toBe('cust-1')
+ })
+
+ it('lowercases mixed-case email at the existence lookup', async () => {
+ Member.findOne.mockResolvedValue({
+ _id: 'guest-mixed',
+ email: 'foo@example.com',
+ name: 'Existing Guest',
+ circle: 'community',
+ contributionAmount: 0,
+ status: 'guest'
+ })
+ const event = build({
+ body: {
+ name: 'Foo Bar',
+ email: 'Foo@Example.com',
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await customerHandler(event)
+ expect(Member.findOne).toHaveBeenCalledWith({ email: 'foo@example.com' })
+ expect(Member.findByIdAndUpdate).toHaveBeenCalledTimes(1)
+ expect(Member.create).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('no auth cookie + magic link', () => {
+ it('does not set auth-token cookie on free-tier signup', async () => {
+ const event = build()
+ await customerHandler(event)
+ expect(setAuthCookie).not.toHaveBeenCalled()
+ const cookieHeader = event._testSetHeaders['set-cookie']
+ const cookies = Array.isArray(cookieHeader) ? cookieHeader.join(';') : (cookieHeader || '')
+ expect(cookies).not.toContain('auth-token=')
+ })
+
+ it('sends a magic link to the new member email', async () => {
+ const event = build({
+ body: {
+ name: 'New User',
+ email: 'newuser@example.com',
+ circle: 'community',
+ contributionAmount: 0,
+ agreedToGuidelines: true
+ }
+ })
+ await customerHandler(event)
+ expect(sendMagicLink).toHaveBeenCalledWith(
+ 'newuser@example.com',
+ expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
+ )
+ })
+
+ it('sets a payment-bridge cookie on paid-tier signup so checkout can proceed', async () => {
+ const event = build({
+ body: {
+ name: 'Paid User',
+ email: 'paid@example.com',
+ circle: 'community',
+ contributionAmount: 25,
+ agreedToGuidelines: true
+ }
+ })
+ await customerHandler(event)
+ expect(setPaymentBridgeCookie).toHaveBeenCalled()
+ expect(sendMagicLink).toHaveBeenCalledWith(
+ 'paid@example.com',
+ expect.objectContaining({ subject: 'Verify your Ghost Guild signup' })
+ )
+ // still no full session cookie
+ expect(setAuthCookie).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/tests/server/api/helcim-payment.test.js b/tests/server/api/helcim-payment.test.js
index 52e2342..1c8d724 100644
--- a/tests/server/api/helcim-payment.test.js
+++ b/tests/server/api/helcim-payment.test.js
@@ -1,14 +1,23 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import { requireAuth } from '../../../server/utils/auth.js'
+import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
+import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
+import Member from '../../../server/models/member.js'
+import Series from '../../../server/models/series.js'
import initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
-vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
+vi.mock('../../../server/utils/auth.js', () => ({
+ requireAuth: vi.fn(),
+ getOptionalMember: vi.fn()
+}))
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
+vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
+vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
+vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } }))
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
vi.stubGlobal('helcimInitializePaymentSchema', {})
@@ -19,6 +28,9 @@ vi.stubGlobal('fetch', mockFetch)
describe('initialize-payment endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
+ getOptionalMember.mockResolvedValue(null)
+ Member.findOne.mockResolvedValue(null)
+ Series.findOne.mockResolvedValue(null)
})
afterEach(() => {
@@ -31,6 +43,11 @@ describe('initialize-payment endpoint', () => {
metadata: { type: 'event_ticket', eventTitle: 'Test Event', eventId: 'evt-1' }
}
globalThis.validateBody.mockResolvedValue(body)
+ loadPublicEvent.mockResolvedValue({
+ _id: 'evt-1',
+ title: 'Test Event',
+ tickets: { enabled: true, public: { available: true, price: 2500 } }
+ })
mockFetch.mockResolvedValue({
ok: true,
@@ -75,6 +92,11 @@ describe('initialize-payment endpoint', () => {
metadata: { type: 'event_ticket', eventTitle: 'Workshop', eventId: 'evt-2' }
}
globalThis.validateBody.mockResolvedValue(body)
+ loadPublicEvent.mockResolvedValue({
+ _id: 'evt-2',
+ title: 'Workshop',
+ tickets: { enabled: true, public: { available: true, price: 1500 } }
+ })
mockFetch.mockResolvedValue({
ok: true,
@@ -92,9 +114,152 @@ describe('initialize-payment endpoint', () => {
expect(result).toEqual({
success: true,
checkoutToken: 'ct-abc',
- secretToken: 'st-xyz'
+ secretToken: 'st-xyz',
+ amount: 1500
})
})
+
+ it('ignores client-supplied amount and re-derives event_ticket price server-side', async () => {
+ const body = {
+ amount: 1, // tampered low value
+ metadata: { type: 'event_ticket', eventId: 'evt-x' }
+ }
+ globalThis.validateBody.mockResolvedValue(body)
+ loadPublicEvent.mockResolvedValue({
+ _id: 'evt-x',
+ title: 'Pricey Workshop',
+ tickets: { enabled: true, public: { available: true, price: 5000 } }
+ })
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: async () => JSON.stringify({ checkoutToken: 'ct-1', secretToken: 'st-1' })
+ })
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/helcim/initialize-payment',
+ body
+ })
+
+ const result = await initPaymentHandler(event)
+
+ // Verify the fetch to Helcim was called with the server-derived amount, not body.amount
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ const [, init] = mockFetch.mock.calls[0]
+ const sentBody = JSON.parse(init.body)
+ expect(sentBody.amount).toBe(5000)
+ expect(sentBody.amount).not.toBe(1)
+ expect(sentBody.paymentType).toBe('purchase')
+ expect(result.amount).toBe(5000)
+ })
+
+ it('returns 400 when event_ticket metadata is missing eventId', async () => {
+ const body = { amount: 25, metadata: { type: 'event_ticket' } }
+ globalThis.validateBody.mockResolvedValue(body)
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/helcim/initialize-payment',
+ body
+ })
+
+ await expect(initPaymentHandler(event)).rejects.toMatchObject({
+ statusCode: 400
+ })
+ })
+
+ it('returns 400 when series_ticket metadata is missing seriesId', async () => {
+ const body = { amount: 50, metadata: { type: 'series_ticket' } }
+ globalThis.validateBody.mockResolvedValue(body)
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/helcim/initialize-payment',
+ body
+ })
+
+ await expect(initPaymentHandler(event)).rejects.toMatchObject({
+ statusCode: 400
+ })
+ })
+
+ it('re-derives series_ticket price via Series.findOne + calculateSeriesTicketPrice', async () => {
+ const body = {
+ amount: 100, // tampered
+ metadata: { type: 'series_ticket', seriesId: 'ser-x' }
+ }
+ globalThis.validateBody.mockResolvedValue(body)
+ Series.findOne.mockResolvedValue({
+ _id: 'ser-x',
+ title: 'Coop Foundations',
+ tickets: { enabled: true, public: { available: true, price: 7500 } }
+ })
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: async () => JSON.stringify({ checkoutToken: 'ct-s', secretToken: 'st-s' })
+ })
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/helcim/initialize-payment',
+ body
+ })
+
+ const result = await initPaymentHandler(event)
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ const [, init] = mockFetch.mock.calls[0]
+ const sentBody = JSON.parse(init.body)
+ expect(sentBody.amount).toBe(7500)
+ expect(sentBody.paymentType).toBe('purchase')
+ expect(result.amount).toBe(7500)
+ expect(Series.findOne).toHaveBeenCalled()
+ })
+
+ it('uses member pricing when metadata.email matches an active member', async () => {
+ const body = {
+ amount: 5000,
+ metadata: { type: 'event_ticket', eventId: 'evt-m', email: '[email protected]' }
+ }
+ globalThis.validateBody.mockResolvedValue(body)
+ Member.findOne.mockResolvedValue({
+ _id: 'm-1',
+ email: '[email protected]',
+ status: 'active',
+ circle: 'community'
+ })
+ loadPublicEvent.mockResolvedValue({
+ _id: 'evt-m',
+ title: 'Member Event',
+ tickets: {
+ enabled: true,
+ member: { available: true, isFree: true },
+ public: { available: true, price: 5000 }
+ }
+ })
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: async () => JSON.stringify({ checkoutToken: 'ct-m', secretToken: 'st-m' })
+ })
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/helcim/initialize-payment',
+ body
+ })
+
+ const result = await initPaymentHandler(event)
+
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+ const [, init] = mockFetch.mock.calls[0]
+ const sentBody = JSON.parse(init.body)
+ expect(sentBody.amount).toBe(0)
+ expect(sentBody.paymentType).toBe('verify')
+ expect(result.amount).toBe(0)
+ })
})
describe('verify-payment endpoint', () => {
diff --git a/tests/server/api/helcim-subscription.test.js b/tests/server/api/helcim-subscription.test.js
index 9a24285..7a41e26 100644
--- a/tests/server/api/helcim-subscription.test.js
+++ b/tests/server/api/helcim-subscription.test.js
@@ -12,7 +12,10 @@ vi.mock('../../../server/models/member.js', () => ({
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
-vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
+vi.mock('../../../server/utils/auth.js', () => ({
+ requireAuth: vi.fn(),
+ getPaymentBridgeMember: vi.fn().mockResolvedValue(null)
+}))
vi.mock('../../../server/utils/slack.ts', () => ({
getSlackService: vi.fn().mockReturnValue(null)
}))
diff --git a/tests/server/api/reconcile-payments-route.test.js b/tests/server/api/reconcile-payments-route.test.js
new file mode 100644
index 0000000..9622153
--- /dev/null
+++ b/tests/server/api/reconcile-payments-route.test.js
@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+import Member from '../../../server/models/member.js'
+import Payment from '../../../server/models/payment.js'
+import { listHelcimCustomerTransactions } from '../../../server/utils/helcim.js'
+import { upsertPaymentFromHelcim } from '../../../server/utils/payments.js'
+import reconcileHandler from '../../../server/api/internal/reconcile-payments.post.js'
+import { createMockEvent } from '../helpers/createMockEvent.js'
+
+vi.mock('../../../server/models/member.js', () => ({
+ default: { find: vi.fn() }
+}))
+vi.mock('../../../server/models/payment.js', () => ({
+ default: { findOne: vi.fn() }
+}))
+vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
+vi.mock('../../../server/utils/helcim.js', () => ({
+ listHelcimCustomerTransactions: vi.fn()
+}))
+vi.mock('../../../server/utils/payments.js', () => ({
+ upsertPaymentFromHelcim: vi.fn()
+}))
+
+// Override useRuntimeConfig from setup.js for these tests
+const RECONCILE_TOKEN = 'test-reconcile-secret-32-characters-long-xx'
+beforeEach(() => {
+ vi.stubGlobal('useRuntimeConfig', () => ({
+ jwtSecret: 'test-jwt-secret',
+ helcimApiToken: 'test-helcim-token',
+ reconcileToken: RECONCILE_TOKEN
+ }))
+})
+
+function leanResolver(value) {
+ return { lean: vi.fn().mockResolvedValue(value) }
+}
+
+describe('POST /api/internal/reconcile-payments', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('rejects requests without the shared-secret token', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments'
+ })
+
+ await expect(reconcileHandler(event)).rejects.toMatchObject({
+ statusCode: 401,
+ statusMessage: 'Unauthorized'
+ })
+ })
+
+ it('rejects requests with the wrong token', async () => {
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments',
+ headers: { 'x-reconcile-token': 'not-the-right-secret' }
+ })
+
+ await expect(reconcileHandler(event)).rejects.toMatchObject({
+ statusCode: 401,
+ statusMessage: 'Unauthorized'
+ })
+ })
+
+ it('rejects when reconcileToken is not configured on the server', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({
+ jwtSecret: 'test-jwt-secret',
+ helcimApiToken: 'test-helcim-token',
+ reconcileToken: ''
+ }))
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments',
+ headers: { 'x-reconcile-token': 'anything' }
+ })
+
+ await expect(reconcileHandler(event)).rejects.toMatchObject({
+ statusCode: 401
+ })
+ })
+
+ it('queries members with helcimCustomerId and returns a summary', async () => {
+ Member.find.mockReturnValue(leanResolver([
+ {
+ _id: 'm1',
+ email: 'a@example.com',
+ helcimCustomerId: 'cust-1',
+ helcimSubscriptionId: 'sub-1',
+ billingCadence: 'monthly'
+ }
+ ]))
+ listHelcimCustomerTransactions.mockResolvedValue([
+ { id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' },
+ { id: 'tx-other', status: 'other', amount: 0, currency: 'CAD' },
+ { id: 'tx-failed', status: 'failed', amount: 10, currency: 'CAD' }
+ ])
+ upsertPaymentFromHelcim.mockResolvedValueOnce({ created: true, payment: { _id: 'p1' } })
+ upsertPaymentFromHelcim.mockResolvedValueOnce({ created: false, payment: { _id: 'p2' } })
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments',
+ headers: { 'x-reconcile-token': RECONCILE_TOKEN }
+ })
+
+ const result = await reconcileHandler(event)
+
+ // Verify the query shape: filter by helcimCustomerId existence + projection
+ expect(Member.find).toHaveBeenCalledWith(
+ { helcimCustomerId: { $exists: true, $ne: null } },
+ expect.objectContaining({
+ helcimCustomerId: 1,
+ helcimSubscriptionId: 1,
+ billingCadence: 1
+ })
+ )
+
+ // upsert called for paid + failed (status='other' is skipped)
+ expect(upsertPaymentFromHelcim).toHaveBeenCalledTimes(2)
+
+ expect(result).toMatchObject({
+ membersScanned: 1,
+ txExamined: 3,
+ created: 1,
+ existed: 1,
+ skipped: 1,
+ memberErrors: 0,
+ apply: true
+ })
+ })
+
+ it('does NOT pass sendConfirmation: true (no duplicate confirmation emails)', async () => {
+ Member.find.mockReturnValue(leanResolver([
+ { _id: 'm1', helcimCustomerId: 'cust-1' }
+ ]))
+ listHelcimCustomerTransactions.mockResolvedValue([
+ { id: 'tx-paid', status: 'paid', amount: 10, currency: 'CAD' }
+ ])
+ upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p1' } })
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments',
+ headers: { 'x-reconcile-token': RECONCILE_TOKEN }
+ })
+ await reconcileHandler(event)
+
+ // The cron must not pass sendConfirmation. Either no opts or sendConfirmation falsy.
+ const opts = upsertPaymentFromHelcim.mock.calls[0][2]
+ if (opts) {
+ expect(opts.sendConfirmation).toBeFalsy()
+ }
+ })
+
+ it('continues iterating when listHelcimCustomerTransactions throws for one member', async () => {
+ Member.find.mockReturnValue(leanResolver([
+ { _id: 'm1', helcimCustomerId: 'cust-1' },
+ { _id: 'm2', helcimCustomerId: 'cust-2' },
+ { _id: 'm3', helcimCustomerId: 'cust-3' }
+ ]))
+ // m1 succeeds first try, m2 fails all 3 retries, m3 succeeds first try.
+ listHelcimCustomerTransactions
+ .mockResolvedValueOnce([{ id: 'tx1', status: 'paid', amount: 5 }])
+ .mockRejectedValueOnce(new Error('helcim 503'))
+ .mockRejectedValueOnce(new Error('helcim 503'))
+ .mockRejectedValueOnce(new Error('helcim 503'))
+ .mockResolvedValueOnce([{ id: 'tx3', status: 'paid', amount: 7 }])
+ upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p' } })
+
+ vi.useFakeTimers()
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments',
+ headers: { 'x-reconcile-token': RECONCILE_TOKEN }
+ })
+ const promise = reconcileHandler(event)
+ // Drain m2's exponential backoff (250 + 500 + 1000ms)
+ await vi.advanceTimersByTimeAsync(2000)
+ const result = await promise
+
+ expect(result.membersScanned).toBe(3)
+ expect(result.memberErrors).toBe(1)
+ expect(result.created).toBe(2) // m1 + m3 succeeded
+ vi.useRealTimers()
+ })
+
+ it('retries transient Helcim errors with exponential backoff (3 attempts)', async () => {
+ vi.useFakeTimers()
+ Member.find.mockReturnValue(leanResolver([
+ { _id: 'm1', helcimCustomerId: 'cust-1' }
+ ]))
+ listHelcimCustomerTransactions
+ .mockRejectedValueOnce(new Error('boom 1'))
+ .mockRejectedValueOnce(new Error('boom 2'))
+ .mockResolvedValueOnce([{ id: 'tx-paid', status: 'paid', amount: 9 }])
+ upsertPaymentFromHelcim.mockResolvedValue({ created: true, payment: { _id: 'p1' } })
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments',
+ headers: { 'x-reconcile-token': RECONCILE_TOKEN }
+ })
+ const promise = reconcileHandler(event)
+ // Advance through the 250ms + 500ms backoff windows
+ await vi.advanceTimersByTimeAsync(250)
+ await vi.advanceTimersByTimeAsync(500)
+ const result = await promise
+
+ expect(listHelcimCustomerTransactions).toHaveBeenCalledTimes(3)
+ expect(result.memberErrors).toBe(0)
+ expect(result.created).toBe(1)
+ vi.useRealTimers()
+ })
+
+ it('counts memberErrors when all 3 retry attempts fail', async () => {
+ vi.useFakeTimers()
+ Member.find.mockReturnValue(leanResolver([
+ { _id: 'm1', helcimCustomerId: 'cust-1' }
+ ]))
+ listHelcimCustomerTransactions.mockRejectedValue(new Error('persistent 503'))
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments',
+ headers: { 'x-reconcile-token': RECONCILE_TOKEN }
+ })
+ const promise = reconcileHandler(event)
+ await vi.advanceTimersByTimeAsync(250)
+ await vi.advanceTimersByTimeAsync(500)
+ await vi.advanceTimersByTimeAsync(1000)
+ const result = await promise
+
+ expect(listHelcimCustomerTransactions).toHaveBeenCalledTimes(3)
+ expect(result.memberErrors).toBe(1)
+ vi.useRealTimers()
+ })
+
+ it('honors ?apply=false dry-run mode (Payment.findOne, no upsert)', async () => {
+ Member.find.mockReturnValue(leanResolver([
+ { _id: 'm1', helcimCustomerId: 'cust-1' }
+ ]))
+ listHelcimCustomerTransactions.mockResolvedValue([
+ { id: 'tx-existing', status: 'paid', amount: 10 },
+ { id: 'tx-new', status: 'paid', amount: 12 }
+ ])
+ Payment.findOne
+ .mockResolvedValueOnce({ _id: 'p-existing' })
+ .mockResolvedValueOnce(null)
+
+ const event = createMockEvent({
+ method: 'POST',
+ path: '/api/internal/reconcile-payments?apply=false',
+ headers: { 'x-reconcile-token': RECONCILE_TOKEN }
+ })
+ const result = await reconcileHandler(event)
+
+ expect(upsertPaymentFromHelcim).not.toHaveBeenCalled()
+ expect(Payment.findOne).toHaveBeenCalledTimes(2)
+ expect(result).toMatchObject({
+ apply: false,
+ created: 1, // would-create
+ existed: 1
+ })
+ })
+})
diff --git a/tests/server/api/series-tickets-purchase.test.js b/tests/server/api/series-tickets-purchase.test.js
new file mode 100644
index 0000000..16483e1
--- /dev/null
+++ b/tests/server/api/series-tickets-purchase.test.js
@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const seriesDir = resolve(import.meta.dirname, '../../../server/api/series/[id]')
+
+describe('series tickets/purchase.post.js — guest account upsert (Fix #8)', () => {
+ const source = readFileSync(resolve(seriesDir, 'tickets/purchase.post.js'), 'utf-8')
+
+ it('uses validateBody with seriesTicketPurchaseSchema', () => {
+ expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
+ })
+
+ it('Case 1 (free) + Case 2 (paid): upserts a guest Member when unauthenticated buyer provides name+email', () => {
+ // Mirror event endpoint upsert pattern; ALWAYS-CREATE-GUEST (no opt-in
+ // checkbox), so guard is `if (!member)` rather than `if (!member && body.createAccount)`.
+ expect(source).toContain('findOneAndUpdate')
+ expect(source).toContain('$setOnInsert')
+ expect(source).toContain('status: "guest"')
+ expect(source).toContain('upsert: true')
+ expect(source).toContain('circle: "community"')
+ expect(source).toContain('contributionAmount: 0')
+ // ALWAYS-CREATE — must NOT gate on a createAccount flag
+ expect(source).not.toContain('body.createAccount')
+ })
+
+ it('Case 3 (idempotency): upsert pattern handles concurrent same-email registrations atomically', () => {
+ // findOneAndUpdate with $setOnInsert + upsert:true is the idempotent pattern;
+ // email has a unique index. No duplicate Member doc created on retry.
+ expect(source).toMatch(/findOneAndUpdate\(\s*\{\s*email:/)
+ expect(source).toContain('upsert: true')
+ expect(source).toContain('new: true')
+ expect(source).toContain('setDefaultsOnInsert: true')
+ })
+
+ it('Case 4 (existing real member): does not auto-login real members entered via public form', () => {
+ // Auto-login only for newly-created accounts and existing guests.
+ // Real members (active/pending_payment) get requiresSignIn: true instead.
+ expect(source).toContain('accountCreated || member.status === "guest"')
+ expect(source).toContain('requiresSignIn = true')
+ })
+
+ it('Case 5 (authenticated guest): sets auth cookie on signedIn:true response', () => {
+ // setAuthCookie fires for both new accounts and returning guests.
+ expect(source).toContain('setAuthCookie(event, member)')
+ expect(source).toContain('signedIn = true')
+ })
+
+ it('Case 6 (missing fields): relies on schema validation to reject missing name/email', () => {
+ // No new validation logic added — existing seriesTicketPurchaseSchema
+ // already requires name+email; validateBody throws 400 if missing.
+ expect(source).toContain('validateBody(event, seriesTicketPurchaseSchema)')
+ })
+
+ it('includes accountCreated, signedIn, and requiresSignIn in response (parity with event endpoint)', () => {
+ expect(source).toContain('accountCreated,')
+ expect(source).toContain('signedIn,')
+ expect(source).toContain('requiresSignIn,')
+ })
+
+ it('still uses hasMemberAccess to gate member pricing (guest/suspended/cancelled treated as non-members)', () => {
+ expect(source).toContain('hasMemberAccess(member)')
+ })
+
+ it('preserves try/catch around requireAuth so unauthenticated callers fall through', () => {
+ // Required for unauth guest-purchase flow to work at all.
+ expect(source).toMatch(/try\s*\{[^}]*requireAuth\(event\)[^}]*\}\s*catch/s)
+ })
+
+ it('does not block purchase when confirmation email fails', () => {
+ const emailCallIndex = source.indexOf('await sendSeriesPassConfirmation')
+ expect(emailCallIndex).toBeGreaterThan(-1)
+ const afterEmail = source.slice(emailCallIndex)
+ const catchBlock = afterEmail.match(/catch\s*\(\w+\)\s*\{[^}]*\}/s)
+ expect(catchBlock).not.toBeNull()
+ expect(catchBlock[0]).toContain('console.error')
+ })
+})
+
+describe('SeriesPassPurchase.vue — client auth refresh (Fix #8)', () => {
+ const source = readFileSync(
+ resolve(import.meta.dirname, '../../../app/components/SeriesPassPurchase.vue'),
+ 'utf-8'
+ )
+
+ it('refreshes client auth state via useAuth().checkMemberStatus() when server reports signedIn', () => {
+ expect(source).toContain('useAuth().checkMemberStatus()')
+ expect(source).toMatch(/purchaseResponse\?\.signedIn/)
+ })
+
+ it('shows a one-line guest-account hint under the form (no checkbox)', () => {
+ // Per ALWAYS-CREATE-GUEST decision: hint only, no UI control.
+ expect(source).toMatch(/free guest account/i)
+ // Make sure no checkbox was added by mistake.
+ expect(source).not.toMatch(/createAccount/)
+ expect(source).not.toMatch(/]*type="checkbox"/i)
+ })
+})
diff --git a/tests/server/api/validation-phase3.test.js b/tests/server/api/validation-phase3.test.js
index e34acde..1d17c02 100644
--- a/tests/server/api/validation-phase3.test.js
+++ b/tests/server/api/validation-phase3.test.js
@@ -12,7 +12,6 @@ import {
waitlistDeleteSchema,
cancelRegistrationSchema,
checkRegistrationSchema,
- eventPaymentSchema,
updateContributionSchema,
seriesTicketPurchaseSchema,
seriesTicketEligibilitySchema,
@@ -312,25 +311,6 @@ describe('waitlistSchema', () => {
})
})
-describe('eventPaymentSchema', () => {
- it('accepts valid payment data', () => {
- const result = eventPaymentSchema.safeParse({
- name: 'Payer',
- email: 'payer@example.com',
- paymentToken: 'tok_abc123'
- })
- expect(result.success).toBe(true)
- })
-
- it('rejects missing paymentToken', () => {
- const result = eventPaymentSchema.safeParse({
- name: 'Payer',
- email: 'payer@example.com'
- })
- expect(result.success).toBe(false)
- })
-})
-
// --- Member schemas ---
describe('updateContributionSchema', () => {
@@ -557,7 +537,6 @@ describe('validateBody migration coverage', () => {
'events/[id]/waitlist.delete.js',
'events/[id]/cancel-registration.post.js',
'events/[id]/check-registration.post.js',
- 'events/[id]/payment.post.js',
'members/update-contribution.post.js',
'series/[id]/tickets/purchase.post.js',
'series/[id]/tickets/check-eligibility.post.js',
diff --git a/tests/server/config/__snapshots__/runtime-config-public.test.js.snap b/tests/server/config/__snapshots__/runtime-config-public.test.js.snap
new file mode 100644
index 0000000..d089ad3
--- /dev/null
+++ b/tests/server/config/__snapshots__/runtime-config-public.test.js.snap
@@ -0,0 +1,11 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`runtimeConfig.public > matches public runtime config snapshot 1`] = `
+[
+ "appUrl",
+ "cloudinaryCloudName",
+ "comingSoon",
+ "helcimAccountId",
+ "helcimPortalUrl",
+]
+`;
diff --git a/tests/server/config/runtime-config-public.test.js b/tests/server/config/runtime-config-public.test.js
new file mode 100644
index 0000000..797f2ab
--- /dev/null
+++ b/tests/server/config/runtime-config-public.test.js
@@ -0,0 +1,36 @@
+import { describe, it, expect, beforeAll } from 'vitest'
+
+// nuxt.config.ts uses the auto-imported defineNuxtConfig() global. Stub it to
+// the identity function so we can dynamically import the config in node and
+// assert against the actual runtimeConfig object the build will use.
+let nuxtConfig
+
+beforeAll(async () => {
+ globalThis.defineNuxtConfig = (config) => config
+ const mod = await import('../../../nuxt.config.ts')
+ nuxtConfig = mod.default
+})
+
+describe('runtimeConfig.public', () => {
+ it('does not expose helcimToken in runtimeConfig.public', () => {
+ expect('helcimToken' in nuxtConfig.runtimeConfig.public).toBe(false)
+ })
+
+ it('does not expose any *ApiToken or *Secret keys in runtimeConfig.public', () => {
+ // Keys that are intentionally public despite matching the pattern.
+ const allowlist = new Set(['helcimAccountId', 'cloudinaryCloudName'])
+ const sensitivePattern = /token|secret|key$/i
+
+ const violations = Object.keys(nuxtConfig.runtimeConfig.public).filter(
+ (key) => sensitivePattern.test(key) && !allowlist.has(key)
+ )
+
+ expect(violations).toEqual([])
+ })
+
+ it('matches public runtime config snapshot', () => {
+ // Snapshot only the sorted key list, not values (values come from env).
+ const sortedKeys = Object.keys(nuxtConfig.runtimeConfig.public).sort()
+ expect(sortedKeys).toMatchSnapshot()
+ })
+})