// 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} 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} 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 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} */ 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>} */ 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 sorted = [...rows].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 } /** * 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' } }