feat(helcim): add transaction list + card update helpers

- listHelcimCustomerTransactions(customerCode): GET /card-transactions/
  with customerCode filter, sorts newest-first, caps at 50, normalizes
  Helcim status (APPROVED/DECLINED) + type (refund) into
  paid/refunded/failed/other.
- updateHelcimCustomerDefaultPaymentMethod(customerId, cardToken):
  resolves cardToken -> cardId via /customers/{id}/cards, then PATCHes
  /customers/{id}/cards/{cardId}/default.
- updateHelcimSubscriptionPaymentMethod(subscriptionId, cardToken):
  wraps updateHelcimSubscription with a cardToken payload.
- helcimUpdateCardSchema: Zod schema { cardToken: string } for the
  upcoming /api/helcim/update-card route.
- Unit tests for all three helpers (success + error paths).
This commit is contained in:
Jennie Robinson Faber 2026-04-19 16:24:16 +01:00
parent b6f5ae8c5e
commit 6888663148
3 changed files with 305 additions and 0 deletions

View file

@ -89,6 +89,41 @@ export const updateHelcimCustomer = (id, payload) =>
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) =>
@ -109,6 +144,78 @@ export const updateHelcimSubscription = (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 = () =>