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.
264 lines
9.2 KiB
JavaScript
264 lines
9.2 KiB
JavaScript
// Helcim API helper — centralized fetcher and typed wrappers for Helcim v2 endpoints.
|
|
// Auto-imported by Nitro under server/utils, but server API handlers in this codebase
|
|
// use explicit imports (matching the existing `../../utils/auth.js` pattern).
|
|
|
|
import { randomBytes } from 'node:crypto'
|
|
|
|
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
|
|
|
|
const IDEMPOTENCY_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
|
|
|
/**
|
|
* Generic Helcim API fetcher.
|
|
*
|
|
* Reads the API token from runtime config on every call (do not hoist — Nitro
|
|
* runtime config is not guaranteed to be available at module import time).
|
|
* Throws `createError` with the upstream status on non-2xx responses. Network
|
|
* errors (fetch itself rejecting) propagate naturally so callers can decide
|
|
* how to handle them.
|
|
*
|
|
* @param {string} path - Path relative to the Helcim v2 base (must start with `/`).
|
|
* @param {object} [options]
|
|
* @param {string} [options.method='GET']
|
|
* @param {object} [options.body] - JSON-serializable request body.
|
|
* @param {string} [options.idempotencyKey] - Sent as `idempotency-key` header.
|
|
* @param {string} [options.errorMessage] - statusMessage used when the upstream returns non-OK.
|
|
* @returns {Promise<any>} Parsed JSON response.
|
|
*/
|
|
export async function helcimFetch(path, { method = 'GET', body, idempotencyKey, errorMessage } = {}) {
|
|
const helcimToken = useRuntimeConfig().helcimApiToken
|
|
|
|
if (!helcimToken) {
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'Helcim API token not configured'
|
|
})
|
|
}
|
|
|
|
const headers = {
|
|
accept: 'application/json',
|
|
'api-token': helcimToken
|
|
}
|
|
|
|
if (body !== undefined) {
|
|
headers['content-type'] = 'application/json'
|
|
}
|
|
|
|
if (idempotencyKey) {
|
|
headers['idempotency-key'] = idempotencyKey
|
|
}
|
|
|
|
const response = await fetch(`${HELCIM_API_BASE}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
console.error(`[helcim] ${method} ${path} ${response.status} ${errorText}`)
|
|
throw createError({
|
|
statusCode: response.status,
|
|
statusMessage: errorMessage || 'Helcim API error'
|
|
})
|
|
}
|
|
|
|
const text = await response.text()
|
|
if (!text.trim()) return null
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ---- Customers ----
|
|
|
|
export const getHelcimCustomer = (id) =>
|
|
helcimFetch(`/customers/${id}`, { errorMessage: 'Customer lookup failed' })
|
|
|
|
export const findHelcimCustomerByEmail = (email) =>
|
|
helcimFetch(`/customers?search=${encodeURIComponent(email)}`, { errorMessage: 'Customer search failed' })
|
|
|
|
export const createHelcimCustomer = (payload) =>
|
|
helcimFetch('/customers', { method: 'POST', body: payload, errorMessage: 'Customer creation failed' })
|
|
|
|
export const updateHelcimCustomer = (id, payload) =>
|
|
helcimFetch(`/customers/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Billing update failed' })
|
|
|
|
export const listHelcimCustomerCards = (id) =>
|
|
helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' })
|
|
|
|
/**
|
|
* Set a customer's default payment method by card token.
|
|
*
|
|
* Helcim's "set default card" endpoint keys on the internal numeric card `id`,
|
|
* not the public `cardToken` — so this helper resolves the token via
|
|
* `/customers/{id}/cards` first, then PATCHes the default endpoint.
|
|
*
|
|
* Endpoint: PATCH /customers/{customerId}/cards/{cardId}/default
|
|
* Docs: https://devdocs.helcim.com/reference/setcustomercarddefault
|
|
*
|
|
* @param {string|number} customerId - Helcim customer id.
|
|
* @param {string} cardToken - Card token to mark as default.
|
|
* @returns {Promise<any>} Helcim response.
|
|
*/
|
|
export async function updateHelcimCustomerDefaultPaymentMethod(customerId, cardToken) {
|
|
const cards = await helcimFetch(
|
|
`/customers/${customerId}/cards?cardToken=${encodeURIComponent(cardToken)}`,
|
|
{ errorMessage: 'Card lookup failed' }
|
|
)
|
|
|
|
const list = Array.isArray(cards) ? cards : (cards?.cards || cards?.data || [])
|
|
const match = list.find((c) => c?.cardToken === cardToken)
|
|
if (!match?.id) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: 'Card token not found on customer'
|
|
})
|
|
}
|
|
|
|
return helcimFetch(`/customers/${customerId}/cards/${match.id}/default`, {
|
|
method: 'PATCH',
|
|
errorMessage: 'Default card update failed'
|
|
})
|
|
}
|
|
|
|
// ---- Subscriptions ----
|
|
|
|
export const createHelcimSubscription = (subscription, idempotencyKey) =>
|
|
helcimFetch('/subscriptions', {
|
|
method: 'POST',
|
|
body: { subscriptions: [subscription] },
|
|
idempotencyKey,
|
|
errorMessage: 'Subscription creation failed'
|
|
})
|
|
|
|
export const getHelcimSubscription = (id) =>
|
|
helcimFetch(`/subscriptions/${id}`, { errorMessage: 'Subscription lookup failed' })
|
|
|
|
export const cancelHelcimSubscription = (id) =>
|
|
helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' })
|
|
|
|
export const updateHelcimSubscription = (id, payload) =>
|
|
helcimFetch('/subscriptions', {
|
|
method: 'PATCH',
|
|
body: { subscriptions: [{ id: Number(id), ...payload }] },
|
|
errorMessage: 'Subscription update failed'
|
|
})
|
|
|
|
/**
|
|
* Update the payment method (card token) on an existing subscription.
|
|
*
|
|
* Wraps `updateHelcimSubscription` with the card-token payload shape.
|
|
* Helcim's subscription PATCH schema
|
|
* (https://devdocs.helcim.com/reference/subscription-patch) does not
|
|
* publicly document a cardToken field on the update body — subscriptions
|
|
* normally bill against the customer's default card. We send `cardToken`
|
|
* here as an explicit payment-method binding alongside the
|
|
* `updateHelcimCustomerDefaultPaymentMethod` call; the latter is the
|
|
* authoritative change, this is best-effort belt-and-suspenders.
|
|
*
|
|
* @param {string|number} subscriptionId
|
|
* @param {string} cardToken
|
|
* @returns {Promise<any>}
|
|
*/
|
|
export const updateHelcimSubscriptionPaymentMethod = (subscriptionId, cardToken) =>
|
|
updateHelcimSubscription(subscriptionId, { cardToken })
|
|
|
|
// ---- Transactions ----
|
|
|
|
const TRANSACTION_LIMIT = 50
|
|
|
|
/**
|
|
* List a customer's card transactions, sorted newest-first, capped at 50.
|
|
*
|
|
* Endpoint: GET /card-transactions/?customerCode={code}
|
|
* Docs: https://devdocs.helcim.com/reference/getcardtransactions
|
|
*
|
|
* Helcim returns `status` as `APPROVED` / `DECLINED` and `type` as
|
|
* `purchase` / `refund` / etc. We normalize to one of:
|
|
* 'paid' | 'refunded' | 'failed' | 'other'
|
|
*
|
|
* @param {string} customerCode - Helcim customer code (e.g. "CST1044").
|
|
* @returns {Promise<Array<{id: string, date: string, amount: number, status: string, currency: string}>>}
|
|
*/
|
|
export async function listHelcimCustomerTransactions(customerCode) {
|
|
const path = `/card-transactions/?customerCode=${encodeURIComponent(customerCode)}&limit=${TRANSACTION_LIMIT}`
|
|
const response = await helcimFetch(path, { errorMessage: 'Transaction lookup failed' })
|
|
|
|
const rows = Array.isArray(response)
|
|
? response
|
|
: (response?.transactions || response?.data || [])
|
|
|
|
const filtered = rows.filter((t) => {
|
|
const type = String(t?.type || '').toLowerCase()
|
|
const amount = typeof t?.amount === 'number' ? t.amount : Number(t?.amount) || 0
|
|
return type !== 'verify' && amount > 0
|
|
})
|
|
|
|
const sorted = [...filtered].sort((a, b) => {
|
|
const da = Date.parse(a?.dateCreated || '') || 0
|
|
const db = Date.parse(b?.dateCreated || '') || 0
|
|
return db - da
|
|
}).slice(0, TRANSACTION_LIMIT)
|
|
|
|
return sorted.map(normalizeTransaction)
|
|
}
|
|
|
|
function normalizeTransaction(t) {
|
|
return {
|
|
id: String(t?.transactionId ?? t?.id ?? ''),
|
|
date: t?.dateCreated || '',
|
|
amount: typeof t?.amount === 'number' ? t.amount : Number(t?.amount) || 0,
|
|
status: normalizeTransactionStatus(t?.status, t?.type),
|
|
currency: t?.currency || ''
|
|
}
|
|
}
|
|
|
|
function normalizeTransactionStatus(status, type) {
|
|
const s = String(status || '').toUpperCase()
|
|
const t = String(type || '').toLowerCase()
|
|
if (t === 'refund' || s === 'REFUNDED') return 'refunded'
|
|
if (s === 'APPROVED') return 'paid'
|
|
if (s === 'DECLINED' || s === 'FAILED') return 'failed'
|
|
return 'other'
|
|
}
|
|
|
|
// ---- Payment plans (admin) ----
|
|
|
|
export const listHelcimPlans = () =>
|
|
helcimFetch('/payment-plans', { errorMessage: 'Failed to fetch payment plans' })
|
|
|
|
export const createHelcimPlan = (payload) =>
|
|
helcimFetch('/payment-plans', { method: 'POST', body: payload, errorMessage: 'Payment plan creation failed' })
|
|
|
|
export const listHelcimSubscriptions = () =>
|
|
helcimFetch('/subscriptions', { errorMessage: 'Failed to fetch subscriptions' })
|
|
|
|
// ---- HelcimPay.js ----
|
|
|
|
export const initializeHelcimPaySession = (payload) =>
|
|
helcimFetch('/helcim-pay/initialize', {
|
|
method: 'POST',
|
|
body: payload,
|
|
errorMessage: 'Payment initialization failed'
|
|
})
|
|
|
|
/**
|
|
* Generate a 25-character idempotency key using `crypto.randomBytes`.
|
|
*
|
|
* Each byte is mapped to the 62-character alphabet via modulo. The modulo
|
|
* introduces a slight bias (256 % 62 = 8), but entropy from `randomBytes` —
|
|
* not perfect uniformity — is what matters for an idempotency key.
|
|
*
|
|
* @returns {string} 25-character key.
|
|
*/
|
|
export function generateIdempotencyKey() {
|
|
const bytes = randomBytes(25)
|
|
let key = ''
|
|
for (let i = 0; i < 25; i++) {
|
|
key += IDEMPOTENCY_ALPHABET[bytes[i] % IDEMPOTENCY_ALPHABET.length]
|
|
}
|
|
return key
|
|
}
|