From 783459106fe3d9c8cb6d56c4b374468bbd85eaa3 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 8 Apr 2026 21:37:11 +0100 Subject: [PATCH] refactor(helcim): introduce centralized helcim helper --- server/utils/helcim.js | 280 +++++++++++++++++++++-------------------- 1 file changed, 145 insertions(+), 135 deletions(-) diff --git a/server/utils/helcim.js b/server/utils/helcim.js index c9bc70b..37bc69a 100644 --- a/server/utils/helcim.js +++ b/server/utils/helcim.js @@ -1,140 +1,150 @@ -// Helcim Payment Integration Utilities +// 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). -export const processHelcimPayment = async (paymentData) => { - const { amount, paymentToken, customerData } = paymentData; - - // Check if Helcim is configured - const helcimAccountId = process.env.HELCIM_ACCOUNT_ID; - const helcimApiToken = process.env.HELCIM_API_TOKEN; - - if (!helcimAccountId || !helcimApiToken) { - console.warn('Helcim not configured - skipping payment processing'); - return { - success: false, - message: 'Payment processing not configured', - testMode: true - }; - } - - try { - // In production, you would make API calls to Helcim here - // Example structure: - const response = await fetch('https://api.helcim.com/v2/payment/purchase', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-token': helcimApiToken, - 'account-id': helcimAccountId - }, - body: JSON.stringify({ - amount, - currency: 'CAD', - paymentToken, - customerCode: customerData.email, - contactName: customerData.name, - billingAddress: { - contactName: customerData.name, - email: customerData.email - } - }) - }); - - const result = await response.json(); - - return { - success: result.success || false, - transactionId: result.transactionId, - customerId: result.customerCode, - message: result.message - }; - } catch (error) { - console.error('Helcim payment error:', error); - return { - success: false, - message: error.message || 'Payment processing failed' - }; - } -}; +import { randomBytes } from 'node:crypto' -export const createHelcimSubscription = async (subscriptionData) => { - const { customerId, planId, amount } = subscriptionData; - - const helcimAccountId = process.env.HELCIM_ACCOUNT_ID; - const helcimApiToken = process.env.HELCIM_API_TOKEN; - - if (!helcimAccountId || !helcimApiToken) { - console.warn('Helcim not configured - skipping subscription creation'); - return { - success: false, - message: 'Subscription processing not configured', - testMode: true - }; - } - - try { - // Create recurring payment plan - const response = await fetch('https://api.helcim.com/v2/payment/plan', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'api-token': helcimApiToken, - 'account-id': helcimAccountId - }, - body: JSON.stringify({ - customerCode: customerId, - planName: `Ghost Guild ${planId}`, - amount, - currency: 'CAD', - frequency: 'MONTHLY', - startDate: new Date().toISOString().split('T')[0] - }) - }); - - const result = await response.json(); - - return { - success: result.success || false, - subscriptionId: result.planId, - message: result.message - }; - } catch (error) { - console.error('Helcim subscription error:', error); - return { - success: false, - message: error.message || 'Subscription creation failed' - }; - } -}; +const HELCIM_API_BASE = 'https://api.helcim.com/v2' -export const cancelHelcimSubscription = async (subscriptionId) => { - const helcimApiToken = process.env.HELCIM_API_TOKEN; - - if (!helcimApiToken) { - return { - success: false, - message: 'Subscription management not configured' - }; +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} 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' + }) } - - try { - const response = await fetch(`https://api.helcim.com/v2/payment/plan/${subscriptionId}/cancel`, { - method: 'POST', - headers: { - 'api-token': helcimApiToken - } - }); - - const result = await response.json(); - - return { - success: result.success || false, - message: result.message - }; - } catch (error) { - console.error('Helcim cancellation error:', error); - return { - success: false, - message: error.message || 'Subscription cancellation failed' - }; + + const headers = { + accept: 'application/json', + 'api-token': helcimToken } -}; \ No newline at end of file + + 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 ? 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' + }) + } + + return await response.json() +} + +// ---- 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' }) + +// ---- Subscriptions ---- + +export const createHelcimSubscription = (subscription, idempotencyKey) => + helcimFetch('/subscriptions', { + method: 'POST', + body: { subscriptions: [subscription] }, + idempotencyKey, + errorMessage: 'Subscription creation failed' + }) + +export const cancelHelcimSubscription = (id) => + helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' }) + +export const updateHelcimSubscription = (id, payload) => + helcimFetch(`/subscriptions/${id}`, { method: 'PATCH', body: payload, errorMessage: 'Subscription update failed' }) + +// ---- 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 +} + +/** + * 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' + } +}