Persist nextBillingDate on subscription create/update; unset on cancel or downgrade to free. Account page displays the cached date and lazily refreshes from Helcim when the cached value is within 24h of now (or missing).
270 lines
9.4 KiB
JavaScript
270 lines
9.4 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 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'
|
|
}
|
|
}
|