feat(launch): security and correctness fixes for 2026-05-01 launch
Day-of-launch deep-dive audit and remediation. 11 issues fixed across security, correctness, and reliability. Tests: 698 → 758 passing (+60), 0 failing, 2 skipped. CRITICAL (security) Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead useHelcim.js deleted. Production token MUST BE ROTATED post-deploy (was previously exposed in window.__NUXT__ payload). Fix #2 — /api/helcim/customer gated with origin check + per-IP/email rate limit + magic-link email verification (replaces unauthenticated setAuthCookie). Adds payment-bridge token for paid-tier signup so users can complete Helcim checkout before email verify. New utils: server/utils/{magicLink,rateLimit}.js. UX: signup success copy now prompts user to check email. Fix #3 — /api/events/[id]/payment deleted (dead code with unauth member-spoof bypass — processHelcimPayment was a permanent stub). Removes processHelcimPayment export and eventPaymentSchema. Fix #4 — /api/helcim/initialize-payment re-derives ticket amount server-side via calculateTicketPrice and calculateSeriesTicketPrice. Adds new series_ticket metadata type (was being shoved through event_ticket with seriesId in metadata.eventId). Fix #5 — /api/helcim/customer upgrades existing status:guest members in place rather than rejecting with 409. Lowercases email at lookup; preserves _id so prior event registrations stay linked. HIGH (correctness / reliability) Fix #6 — Daily reconciliation cron via Netlify scheduled function (@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs, server/api/internal/reconcile-payments.post.js. Shared-secret auth via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff on Helcim transactions API. Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist endpoints) to dodge legacy location validators. Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest Member when caller is unauthenticated, mirrors event-ticket flow byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and client auth refresh on signedIn:true response. Fix #9 — /api/members/cancel-subscription leaves status active per ratified bylaws (was pending_payment). Adds lastCancelledAt audit field on Member model. Indirectly fixes false-positive detectStuckPendingPayment admin alert for cancelled members. Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema (verifyMagicLinkSchema, max 2000 chars). Fix #11 — 8 vitest cases for cancel-subscription handler (was uncovered). Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md. LAUNCH_READINESS.md updated with new test count, 3 deploy-time tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify Netlify scheduled function), and Fixed-2026-04-25 fix log.
This commit is contained in:
parent
0f2f1d1cbf
commit
208638e374
37 changed files with 1980 additions and 340 deletions
|
|
@ -144,6 +144,7 @@
|
||||||
<p class="text-xs text-[--ui-text-muted] text-center">
|
<p class="text-xs text-[--ui-text-muted] text-center">
|
||||||
By registering, you'll be automatically registered for all
|
By registering, you'll be automatically registered for all
|
||||||
{{ seriesInfo.totalEvents }} events in this series.
|
{{ seriesInfo.totalEvents }} events in this series.
|
||||||
|
<span v-if="!isLoggedIn"> We'll create a free guest account so you can access your pass.</span>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,7 +183,7 @@ const props = defineProps({
|
||||||
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
const emit = defineEmits(["purchase-success", "purchase-error"]);
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { initializeTicketPayment, verifyPayment } = useHelcimPay();
|
const { initializeSeriesTicketPayment, verifyPayment } = useHelcimPay();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
@ -264,10 +265,9 @@ const handleSubmit = async () => {
|
||||||
paymentProcessing.value = true;
|
paymentProcessing.value = true;
|
||||||
|
|
||||||
// Initialize Helcim payment for series pass
|
// Initialize Helcim payment for series pass
|
||||||
await initializeTicketPayment(
|
await initializeSeriesTicketPayment(
|
||||||
props.seriesId,
|
props.seriesId,
|
||||||
form.value.email,
|
form.value.email,
|
||||||
passInfo.value.ticket.price,
|
|
||||||
props.seriesInfo.title,
|
props.seriesInfo.title,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -298,6 +298,11 @@ const handleSubmit = async () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refresh client auth state if server signed us in (guest upgrade)
|
||||||
|
if (purchaseResponse?.signedIn) {
|
||||||
|
await useAuth().checkMemberStatus();
|
||||||
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
toast.add({
|
toast.add({
|
||||||
title: "Series Pass Purchased!",
|
title: "Series Pass Purchased!",
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,9 @@
|
||||||
</dl>
|
</dl>
|
||||||
</DashedBox>
|
</DashedBox>
|
||||||
<p class="signup-flow-body" style="margin-top: 16px">
|
<p class="signup-flow-body" style="margin-top: 16px">
|
||||||
We've sent a confirmation email to {{ summary?.email }}. Redirecting
|
Check {{ summary?.email }} for a sign-in link to finish setting up
|
||||||
you to your dashboard...
|
your account. The link expires in 15 minutes.
|
||||||
</p>
|
</p>
|
||||||
<div class="button-row" style="margin-top: 20px">
|
|
||||||
<NuxtLink :to="dashboardHref" class="btn btn-primary">
|
|
||||||
Go to Dashboard Now
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="state === 'error'">
|
<template v-if="state === 'error'">
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ export const useHelcimPay = () => {
|
||||||
let checkoutToken = null;
|
let checkoutToken = null;
|
||||||
let secretToken = null;
|
let secretToken = null;
|
||||||
|
|
||||||
// Initialize HelcimPay.js session
|
// Initialize HelcimPay.js session (membership signup flow)
|
||||||
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch("/api/helcim/initialize-payment", {
|
const response = await $fetch("/api/helcim/initialize-payment", {
|
||||||
|
|
@ -12,6 +12,10 @@ export const useHelcimPay = () => {
|
||||||
customerId,
|
customerId,
|
||||||
customerCode,
|
customerCode,
|
||||||
amount,
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
checkoutToken: response.checkoutToken,
|
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
|
// Show payment modal
|
||||||
const showPaymentModal = () => {
|
const showPaymentModal = () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -272,6 +315,7 @@ export const useHelcimPay = () => {
|
||||||
return {
|
return {
|
||||||
initializeHelcimPay,
|
initializeHelcimPay,
|
||||||
initializeTicketPayment,
|
initializeTicketPayment,
|
||||||
|
initializeSeriesTicketPayment,
|
||||||
verifyPayment,
|
verifyPayment,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -555,10 +555,9 @@ const createSubscription = async (cardToken = null) => {
|
||||||
flowState.value = "success";
|
flowState.value = "success";
|
||||||
successMessage.value = "Your membership is active.";
|
successMessage.value = "Your membership is active.";
|
||||||
|
|
||||||
// Check member status to ensure user is properly authenticated
|
// Sign-in cookie is now issued by the email-verify magic link
|
||||||
await checkMemberStatus();
|
// (see /api/helcim/customer). Don't auto-navigate to a gated page —
|
||||||
|
// the success state instructs the user to check their inbox.
|
||||||
navigateTo("/welcome");
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Subscription creation failed - response not successful");
|
throw new Error("Subscription creation failed - response not successful");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Single source of truth for work remaining before cutover. P0 blocks launch; P1 i
|
||||||
|
|
||||||
## Current state
|
## 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.
|
- 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`).
|
- 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.
|
- 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.
|
- [ ] **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_MONTHLY_PLAN_ID=50302` in production env.
|
||||||
- [ ] Set `NUXT_HELCIM_ANNUAL_PLAN_ID=50303` 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.
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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).
|
- [ ] **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):**
|
**Env vars required in production (reference):**
|
||||||
- `MONGODB_URI`
|
- `MONGODB_URI`
|
||||||
|
|
@ -53,6 +55,32 @@ Applies when the site is connected to Netlify / production hosting. Nothing here
|
||||||
- `BASE_URL`
|
- `BASE_URL`
|
||||||
- `OIDC_COOKIE_SECRET`
|
- `OIDC_COOKIE_SECRET`
|
||||||
- `NUXT_PUBLIC_HELCIM_PORTAL_URL`
|
- `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.
|
- **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.
|
- **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.
|
- **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)
|
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
|
||||||
- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
|
- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
|
||||||
|
|
|
||||||
13
netlify.toml
Normal file
13
netlify.toml
Normal file
|
|
@ -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"
|
||||||
40
netlify/functions/reconcile-payments.mjs
Normal file
40
netlify/functions/reconcile-payments.mjs
Normal file
|
|
@ -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'
|
||||||
|
}
|
||||||
|
|
@ -95,7 +95,7 @@ export default defineNuxtConfig({
|
||||||
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
|
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
|
||||||
jwtSecret: process.env.JWT_SECRET || "",
|
jwtSecret: process.env.JWT_SECRET || "",
|
||||||
resendApiKey: process.env.RESEND_API_KEY || "",
|
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 || "",
|
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
||||||
slackAdminBotToken: process.env.SLACK_ADMIN_BOT_TOKEN || "",
|
slackAdminBotToken: process.env.SLACK_ADMIN_BOT_TOKEN || "",
|
||||||
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
|
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
|
||||||
|
|
@ -106,10 +106,10 @@ export default defineNuxtConfig({
|
||||||
outlineApiKey: process.env.OUTLINE_API_KEY || "",
|
outlineApiKey: process.env.OUTLINE_API_KEY || "",
|
||||||
helcimMonthlyPlanId: process.env.NUXT_HELCIM_MONTHLY_PLAN_ID || "",
|
helcimMonthlyPlanId: process.env.NUXT_HELCIM_MONTHLY_PLAN_ID || "",
|
||||||
helcimAnnualPlanId: process.env.NUXT_HELCIM_ANNUAL_PLAN_ID || "",
|
helcimAnnualPlanId: process.env.NUXT_HELCIM_ANNUAL_PLAN_ID || "",
|
||||||
|
reconcileToken: process.env.NUXT_RECONCILE_TOKEN || "",
|
||||||
|
|
||||||
// Public keys (available on client-side)
|
// Public keys (available on client-side)
|
||||||
public: {
|
public: {
|
||||||
helcimToken: process.env.HELCIM_API_TOKEN || "",
|
|
||||||
helcimAccountId: process.env.NUXT_PUBLIC_HELCIM_ACCOUNT_ID || "",
|
helcimAccountId: process.env.NUXT_PUBLIC_HELCIM_ACCOUNT_ID || "",
|
||||||
cloudinaryCloudName:
|
cloudinaryCloudName:
|
||||||
process.env.NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME || "divzuumlr",
|
process.env.NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME || "divzuumlr",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
// server/api/auth/verify.post.js
|
// server/api/auth/verify.post.js
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
|
import { verifyMagicLinkSchema } from '../../utils/schemas.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const { token } = await validateBody(event, verifyMagicLinkSchema)
|
||||||
const token = body?.token
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Token is required',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -32,7 +32,8 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
eventData.tickets.waitlist.entries.splice(waitlistIndex, 1);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,8 @@ export default defineEventHandler(async (event) => {
|
||||||
notified: false,
|
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
|
// Get position in waitlist
|
||||||
const position = eventData.tickets.waitlist.entries.length;
|
const position = eventData.tickets.waitlist.entries.length;
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,84 @@
|
||||||
// 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 Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { createHelcimCustomer } from '../../utils/helcim.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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
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()
|
await connectDB()
|
||||||
const body = await validateBody(event, helcimCustomerSchema)
|
const body = await validateBody(event, helcimCustomerSchema)
|
||||||
|
|
||||||
// Check if member already exists
|
// --- Per-email rate limit (3 / hour) ---
|
||||||
const existingMember = await Member.findOne({ email: body.email })
|
if (!rateLimit(`signup:email:${body.email}`, { max: 3, windowMs: 3600_000 })) {
|
||||||
if (existingMember) {
|
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({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
statusMessage: 'A member with this email already exists'
|
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({
|
const customerData = await createHelcimCustomer({
|
||||||
customerType: 'PERSON',
|
customerType: 'PERSON',
|
||||||
contactName: body.name,
|
contactName: body.name,
|
||||||
email: body.email
|
email: body.email
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create member in database
|
// If the lookup matched a guest doc, upgrade in place to preserve _id,
|
||||||
const member = await Member.create({
|
// memberNumber (if any), emailHistory, and the event-registration
|
||||||
email: body.email,
|
// 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,
|
name: body.name,
|
||||||
circle: body.circle,
|
circle: body.circle,
|
||||||
contributionAmount: body.contributionAmount,
|
contributionAmount: body.contributionAmount,
|
||||||
|
|
@ -34,16 +86,32 @@ export default defineEventHandler(async (event) => {
|
||||||
status: 'pending_payment',
|
status: 'pending_payment',
|
||||||
agreement: { acceptedAt: new Date() }
|
agreement: { acceptedAt: new Date() }
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setAuthCookie(event, member)
|
// 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:'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
customerId: customerData.id,
|
customerId: customerData.id,
|
||||||
customerCode: customerData.customerCode,
|
customerCode: customerData.customerCode,
|
||||||
|
verificationEmailSent: true,
|
||||||
member: {
|
member: {
|
||||||
id: member._id,
|
id: member._id,
|
||||||
email: member.email,
|
email: normalizedEmail,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
circle: member.circle,
|
circle: member.circle,
|
||||||
contributionAmount: member.contributionAmount,
|
contributionAmount: member.contributionAmount,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,84 @@
|
||||||
// Initialize HelcimPay.js session
|
// 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'
|
import { initializeHelcimPaySession } from '../../utils/helcim.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const body = await validateBody(event, helcimInitializePaymentSchema)
|
const body = await validateBody(event, helcimInitializePaymentSchema)
|
||||||
|
const metaType = body.metadata?.type
|
||||||
|
|
||||||
// Event ticket purchases can be made without authentication
|
const isEventTicket = metaType === 'event_ticket'
|
||||||
const isEventTicket = body.metadata?.type === 'event_ticket'
|
const isSeriesTicket = metaType === 'series_ticket'
|
||||||
if (!isEventTicket) {
|
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)
|
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
|
if (isTicket) {
|
||||||
// For subscriptions or card verification, we do verify
|
// Re-derive price server-side; never trust body.amount for ticket flows.
|
||||||
const paymentType = isEventTicket && amount > 0 ? 'purchase' : 'verify'
|
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 = {
|
const requestBody = {
|
||||||
paymentType,
|
paymentType,
|
||||||
amount: paymentType === 'purchase' ? amount : 0,
|
amount: paymentType === 'purchase' ? amount : 0,
|
||||||
|
|
@ -25,20 +86,15 @@ export default defineEventHandler(async (event) => {
|
||||||
paymentMethod: 'cc'
|
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') {
|
if (body.customerCode && paymentType === 'verify') {
|
||||||
requestBody.customerCode = body.customerCode
|
requestBody.customerCode = body.customerCode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add product/event information for better display in Helcim modal
|
if (description) {
|
||||||
if (body.metadata?.eventTitle) {
|
requestBody.description = description
|
||||||
// Some Helcim accounts don't support invoice numbers in initialization
|
requestBody.notes = description
|
||||||
// Try multiple fields that might display in the modal
|
const orderId = body.metadata?.eventId || body.metadata?.seriesId
|
||||||
requestBody.description = body.metadata.eventTitle
|
if (orderId) requestBody.orderNumber = `${orderId}`
|
||||||
requestBody.notes = body.metadata.eventTitle
|
|
||||||
requestBody.orderNumber = `${body.metadata.eventId}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentData = await initializeHelcimPaySession(requestBody)
|
const paymentData = await initializeHelcimPaySession(requestBody)
|
||||||
|
|
@ -46,7 +102,9 @@ export default defineEventHandler(async (event) => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
checkoutToken: paymentData.checkoutToken,
|
checkoutToken: paymentData.checkoutToken,
|
||||||
secretToken: paymentData.secretToken
|
secretToken: paymentData.secretToken,
|
||||||
|
// Echo derived amount so the client can sanity-check before opening modal.
|
||||||
|
amount
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.statusCode) throw error
|
if (error.statusCode) throw error
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
|
||||||
import Member from '../../models/member.js'
|
import Member from '../../models/member.js'
|
||||||
import { connectDB } from '../../utils/mongoose.js'
|
import { connectDB } from '../../utils/mongoose.js'
|
||||||
import { getSlackService } from '../../utils/slack.ts'
|
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 { createHelcimSubscription, generateIdempotencyKey, listHelcimCustomerTransactions } from '../../utils/helcim.js'
|
||||||
import { sendWelcomeEmail } from '../../utils/resend.js'
|
import { sendWelcomeEmail } from '../../utils/resend.js'
|
||||||
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
import { upsertPaymentFromHelcim } from '../../utils/payments.js'
|
||||||
|
|
@ -81,7 +81,12 @@ async function inviteToSlack(member) {
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
// 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 requireAuth(event)
|
||||||
|
}
|
||||||
await connectDB()
|
await connectDB()
|
||||||
const body = await validateBody(event, helcimSubscriptionSchema)
|
const body = await validateBody(event, helcimSubscriptionSchema)
|
||||||
|
|
||||||
|
|
|
||||||
116
server/api/internal/reconcile-payments.post.js
Normal file
116
server/api/internal/reconcile-payments.post.js
Normal file
|
|
@ -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 <summary>` 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
|
||||||
|
})
|
||||||
|
|
@ -23,16 +23,17 @@ export default defineEventHandler(async (event) => {
|
||||||
// Continue anyway - we'll update the member record
|
// 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(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
member._id,
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
status: 'pending_payment',
|
status: 'active',
|
||||||
contributionAmount: 0,
|
contributionAmount: 0,
|
||||||
helcimSubscriptionId: null,
|
helcimSubscriptionId: null,
|
||||||
paymentMethod: 'none',
|
paymentMethod: 'none',
|
||||||
subscriptionEndDate: new Date(),
|
subscriptionEndDate: new Date(),
|
||||||
|
lastCancelledAt: new Date(),
|
||||||
},
|
},
|
||||||
$unset: { nextBillingDate: 1 },
|
$unset: { nextBillingDate: 1 },
|
||||||
},
|
},
|
||||||
|
|
@ -46,7 +47,7 @@ export default defineEventHandler(async (event) => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Subscription cancelled successfully",
|
message: "Subscription cancelled successfully",
|
||||||
status: 'pending_payment',
|
status: 'active',
|
||||||
contributionAmount: 0,
|
contributionAmount: 0,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Only members with access (active or pending_payment) get member-tier
|
// Only members with access (active or pending_payment) get member-tier
|
||||||
// pricing; guest, suspended, and cancelled are treated as non-members.
|
// pricing; guest, suspended, and cancelled are treated as non-members.
|
||||||
let member = null;
|
let member = null;
|
||||||
|
let accountCreated = false;
|
||||||
try {
|
try {
|
||||||
member = await requireAuth(event);
|
member = await requireAuth(event);
|
||||||
} catch {
|
} 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
|
// Create series registration
|
||||||
const registration = {
|
const registration = {
|
||||||
memberId: member?._id,
|
memberId: member?._id,
|
||||||
|
|
@ -102,6 +126,17 @@ export default defineEventHandler(async (event) => {
|
||||||
series.registrations.push(registration);
|
series.registrations.push(registration);
|
||||||
await completeSeriesTicketPurchase(series, ticketInfo.ticketType);
|
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
|
// Get the newly created registration
|
||||||
const newRegistration =
|
const newRegistration =
|
||||||
series.registrations[series.registrations.length - 1];
|
series.registrations[series.registrations.length - 1];
|
||||||
|
|
@ -172,6 +207,9 @@ export default defineEventHandler(async (event) => {
|
||||||
success: r.success,
|
success: r.success,
|
||||||
reason: r.reason,
|
reason: r.reason,
|
||||||
})),
|
})),
|
||||||
|
accountCreated,
|
||||||
|
signedIn,
|
||||||
|
requiresSignIn,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error purchasing series pass:", error);
|
console.error("Error purchasing series pass:", error);
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ const memberSchema = new mongoose.Schema({
|
||||||
subscriptionStartDate: Date,
|
subscriptionStartDate: Date,
|
||||||
subscriptionEndDate: Date,
|
subscriptionEndDate: Date,
|
||||||
nextBillingDate: Date,
|
nextBillingDate: Date,
|
||||||
|
lastCancelledAt: Date,
|
||||||
slackInvited: { type: Boolean, default: false },
|
slackInvited: { type: Boolean, default: false },
|
||||||
slackInviteStatus: {
|
slackInviteStatus: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* Verify JWT from cookie and return the decoded member.
|
||||||
* Throws 401 if token is missing or invalid.
|
* Throws 401 if token is missing or invalid.
|
||||||
|
|
|
||||||
|
|
@ -262,15 +262,3 @@ export function generateIdempotencyKey() {
|
||||||
}
|
}
|
||||||
return key
|
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
66
server/utils/magicLink.js
Normal file
66
server/utils/magicLink.js
Normal file
|
|
@ -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 <ghostguild@babyghosts.org>',
|
||||||
|
to: email,
|
||||||
|
subject,
|
||||||
|
text
|
||||||
|
})
|
||||||
|
|
||||||
|
logActivity(member._id, 'email_sent', {
|
||||||
|
emailType: 'magic_link',
|
||||||
|
subject,
|
||||||
|
body: text
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sent: true }
|
||||||
|
}
|
||||||
18
server/utils/rateLimit.js
Normal file
18
server/utils/rateLimit.js
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@ export const emailSchema = z.object({
|
||||||
email: z.string().trim().toLowerCase().email()
|
email: z.string().trim().toLowerCase().email()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const verifyMagicLinkSchema = z.object({
|
||||||
|
token: z.string().min(1).max(2000)
|
||||||
|
}).strict()
|
||||||
|
|
||||||
export const memberCreateSchema = z.object({
|
export const memberCreateSchema = z.object({
|
||||||
email: z.string().trim().toLowerCase().email(),
|
email: z.string().trim().toLowerCase().email(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
|
|
@ -62,12 +66,16 @@ export const helcimCustomerSchema = z.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const helcimInitializePaymentSchema = 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(),
|
amount: z.number().min(0).optional(),
|
||||||
customerCode: z.string().max(200).optional(),
|
customerCode: z.string().max(200).optional(),
|
||||||
metadata: z.object({
|
metadata: z.object({
|
||||||
type: z.string().max(100).optional(),
|
type: z.enum(['event_ticket', 'series_ticket', 'subscription', 'card_verify', 'membership_signup']).optional(),
|
||||||
eventTitle: z.string().max(500).optional(),
|
eventTitle: z.string().max(500).optional(),
|
||||||
eventId: z.string().max(200).optional()
|
eventId: z.string().max(200).optional(),
|
||||||
|
seriesId: z.string().max(200).optional(),
|
||||||
|
email: z.string().trim().toLowerCase().email().optional()
|
||||||
}).optional()
|
}).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -131,12 +139,6 @@ export const checkRegistrationSchema = z.object({
|
||||||
email: z.string().trim().toLowerCase().email()
|
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 ---
|
// --- Member schemas ---
|
||||||
|
|
||||||
export const updateContributionSchema = z.object({
|
export const updateContributionSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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', () => ({
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
default: { findById: vi.fn(), findByIdAndUpdate: vi.fn() }
|
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 = {
|
const baseMember = {
|
||||||
_id: 'member-123',
|
_id: 'member-123',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
|
|
@ -44,10 +44,94 @@ describe('auth verify endpoint', () => {
|
||||||
|
|
||||||
await expect(verifyHandler(event)).rejects.toMatchObject({
|
await expect(verifyHandler(event)).rejects.toMatchObject({
|
||||||
statusCode: 400,
|
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 () => {
|
it('rejects invalid JWT with 401', async () => {
|
||||||
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
jwt.verify.mockImplementation(() => { throw new Error('invalid') })
|
||||||
|
|
||||||
|
|
|
||||||
201
tests/server/api/cancel-subscription.test.js
Normal file
201
tests/server/api/cancel-subscription.test.js
Normal file
|
|
@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
49
tests/server/api/event-save-validators.test.js
Normal file
49
tests/server/api/event-save-validators.test.js
Normal file
|
|
@ -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*\)/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
31
tests/server/api/events/payment-deletion.test.js
Normal file
31
tests/server/api/events/payment-deletion.test.js
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
385
tests/server/api/helcim-customer.test.js
Normal file
385
tests/server/api/helcim-customer.test.js
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
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 { 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 initPaymentHandler from '../../../server/api/helcim/initialize-payment.post.js'
|
||||||
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
import verifyPaymentHandler from '../../../server/api/helcim/verify-payment.post.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
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/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||||
|
vi.mock('../../../server/utils/loadEvent.js', () => ({ loadPublicEvent: vi.fn() }))
|
||||||
|
vi.mock('../../../server/models/member.js', () => ({ default: { findOne: vi.fn() } }))
|
||||||
|
vi.mock('../../../server/models/series.js', () => ({ default: { findOne: vi.fn() } }))
|
||||||
|
|
||||||
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
// helcimInitializePaymentSchema is a Nitro auto-import used by validateBody
|
||||||
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
vi.stubGlobal('helcimInitializePaymentSchema', {})
|
||||||
|
|
@ -19,6 +28,9 @@ vi.stubGlobal('fetch', mockFetch)
|
||||||
describe('initialize-payment endpoint', () => {
|
describe('initialize-payment endpoint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
getOptionalMember.mockResolvedValue(null)
|
||||||
|
Member.findOne.mockResolvedValue(null)
|
||||||
|
Series.findOne.mockResolvedValue(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -31,6 +43,11 @@ describe('initialize-payment endpoint', () => {
|
||||||
metadata: { type: 'event_ticket', eventTitle: 'Test Event', eventId: 'evt-1' }
|
metadata: { type: 'event_ticket', eventTitle: 'Test Event', eventId: 'evt-1' }
|
||||||
}
|
}
|
||||||
globalThis.validateBody.mockResolvedValue(body)
|
globalThis.validateBody.mockResolvedValue(body)
|
||||||
|
loadPublicEvent.mockResolvedValue({
|
||||||
|
_id: 'evt-1',
|
||||||
|
title: 'Test Event',
|
||||||
|
tickets: { enabled: true, public: { available: true, price: 2500 } }
|
||||||
|
})
|
||||||
|
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -75,6 +92,11 @@ describe('initialize-payment endpoint', () => {
|
||||||
metadata: { type: 'event_ticket', eventTitle: 'Workshop', eventId: 'evt-2' }
|
metadata: { type: 'event_ticket', eventTitle: 'Workshop', eventId: 'evt-2' }
|
||||||
}
|
}
|
||||||
globalThis.validateBody.mockResolvedValue(body)
|
globalThis.validateBody.mockResolvedValue(body)
|
||||||
|
loadPublicEvent.mockResolvedValue({
|
||||||
|
_id: 'evt-2',
|
||||||
|
title: 'Workshop',
|
||||||
|
tickets: { enabled: true, public: { available: true, price: 1500 } }
|
||||||
|
})
|
||||||
|
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -92,9 +114,152 @@ describe('initialize-payment endpoint', () => {
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
success: true,
|
success: true,
|
||||||
checkoutToken: 'ct-abc',
|
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', () => {
|
describe('verify-payment endpoint', () => {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ vi.mock('../../../server/models/member.js', () => ({
|
||||||
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
|
default: { findOneAndUpdate: vi.fn(), findOne: vi.fn() }
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/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', () => ({
|
vi.mock('../../../server/utils/slack.ts', () => ({
|
||||||
getSlackService: vi.fn().mockReturnValue(null)
|
getSlackService: vi.fn().mockReturnValue(null)
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
269
tests/server/api/reconcile-payments-route.test.js
Normal file
269
tests/server/api/reconcile-payments-route.test.js
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
98
tests/server/api/series-tickets-purchase.test.js
Normal file
98
tests/server/api/series-tickets-purchase.test.js
Normal file
|
|
@ -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(/<input[^>]*type="checkbox"/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
waitlistDeleteSchema,
|
waitlistDeleteSchema,
|
||||||
cancelRegistrationSchema,
|
cancelRegistrationSchema,
|
||||||
checkRegistrationSchema,
|
checkRegistrationSchema,
|
||||||
eventPaymentSchema,
|
|
||||||
updateContributionSchema,
|
updateContributionSchema,
|
||||||
seriesTicketPurchaseSchema,
|
seriesTicketPurchaseSchema,
|
||||||
seriesTicketEligibilitySchema,
|
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 ---
|
// --- Member schemas ---
|
||||||
|
|
||||||
describe('updateContributionSchema', () => {
|
describe('updateContributionSchema', () => {
|
||||||
|
|
@ -557,7 +537,6 @@ describe('validateBody migration coverage', () => {
|
||||||
'events/[id]/waitlist.delete.js',
|
'events/[id]/waitlist.delete.js',
|
||||||
'events/[id]/cancel-registration.post.js',
|
'events/[id]/cancel-registration.post.js',
|
||||||
'events/[id]/check-registration.post.js',
|
'events/[id]/check-registration.post.js',
|
||||||
'events/[id]/payment.post.js',
|
|
||||||
'members/update-contribution.post.js',
|
'members/update-contribution.post.js',
|
||||||
'series/[id]/tickets/purchase.post.js',
|
'series/[id]/tickets/purchase.post.js',
|
||||||
'series/[id]/tickets/check-eligibility.post.js',
|
'series/[id]/tickets/check-eligibility.post.js',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
`;
|
||||||
36
tests/server/config/runtime-config-public.test.js
Normal file
36
tests/server/config/runtime-config-public.test.js
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue