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:
parent
b6f5ae8c5e
commit
6888663148
3 changed files with 305 additions and 0 deletions
|
|
@ -89,6 +89,41 @@ export const updateHelcimCustomer = (id, payload) =>
|
||||||
export const listHelcimCustomerCards = (id) =>
|
export const listHelcimCustomerCards = (id) =>
|
||||||
helcimFetch(`/customers/${id}/cards`, { errorMessage: 'Card lookup failed' })
|
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 ----
|
// ---- Subscriptions ----
|
||||||
|
|
||||||
export const createHelcimSubscription = (subscription, idempotencyKey) =>
|
export const createHelcimSubscription = (subscription, idempotencyKey) =>
|
||||||
|
|
@ -109,6 +144,78 @@ export const updateHelcimSubscription = (id, payload) =>
|
||||||
errorMessage: 'Subscription update failed'
|
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) ----
|
// ---- Payment plans (admin) ----
|
||||||
|
|
||||||
export const listHelcimPlans = () =>
|
export const listHelcimPlans = () =>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ export const helcimSubscriptionSchema = z.object({
|
||||||
cadence: z.enum(['monthly', 'annual']).default('monthly')
|
cadence: z.enum(['monthly', 'annual']).default('monthly')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const helcimUpdateCardSchema = z.object({
|
||||||
|
cardToken: z.string().min(1).max(500)
|
||||||
|
})
|
||||||
|
|
||||||
export const helcimUpdateBillingSchema = z.object({
|
export const helcimUpdateBillingSchema = z.object({
|
||||||
customerId: z.union([z.string().min(1), z.number()]),
|
customerId: z.union([z.string().min(1), z.number()]),
|
||||||
billingAddress: z.object({
|
billingAddress: z.object({
|
||||||
|
|
|
||||||
194
tests/server/utils/helcim.test.js
Normal file
194
tests/server/utils/helcim.test.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
listHelcimCustomerTransactions,
|
||||||
|
updateHelcimCustomerDefaultPaymentMethod,
|
||||||
|
updateHelcimSubscriptionPaymentMethod
|
||||||
|
} from '../../../server/utils/helcim.js'
|
||||||
|
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', mockFetch)
|
||||||
|
|
||||||
|
function okResponse(payload) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => (payload === null || payload === undefined ? '' : JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errResponse(status = 500, body = 'boom') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
text: async () => body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('listHelcimCustomerTransactions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes, sorts newest-first, and maps statuses', async () => {
|
||||||
|
mockFetch.mockResolvedValue(okResponse([
|
||||||
|
{
|
||||||
|
transactionId: 1,
|
||||||
|
dateCreated: '2024-01-10 10:00:00',
|
||||||
|
amount: 10,
|
||||||
|
currency: 'CAD',
|
||||||
|
status: 'APPROVED',
|
||||||
|
type: 'purchase'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transactionId: 2,
|
||||||
|
dateCreated: '2024-03-15 10:00:00',
|
||||||
|
amount: 25,
|
||||||
|
currency: 'CAD',
|
||||||
|
status: 'APPROVED',
|
||||||
|
type: 'refund'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transactionId: 3,
|
||||||
|
dateCreated: '2024-02-01 10:00:00',
|
||||||
|
amount: 5,
|
||||||
|
currency: 'CAD',
|
||||||
|
status: 'DECLINED',
|
||||||
|
type: 'purchase'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transactionId: 4,
|
||||||
|
dateCreated: '2024-02-15 10:00:00',
|
||||||
|
amount: 1,
|
||||||
|
currency: 'CAD',
|
||||||
|
status: 'UNKNOWN',
|
||||||
|
type: 'purchase'
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
|
||||||
|
const result = await listHelcimCustomerTransactions('CST1044')
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
const url = mockFetch.mock.calls[0][0]
|
||||||
|
expect(url).toContain('/card-transactions/')
|
||||||
|
expect(url).toContain('customerCode=CST1044')
|
||||||
|
expect(url).toContain('limit=50')
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: '2', date: '2024-03-15 10:00:00', amount: 25, status: 'refunded', currency: 'CAD' },
|
||||||
|
{ id: '4', date: '2024-02-15 10:00:00', amount: 1, status: 'other', currency: 'CAD' },
|
||||||
|
{ id: '3', date: '2024-02-01 10:00:00', amount: 5, status: 'failed', currency: 'CAD' },
|
||||||
|
{ id: '1', date: '2024-01-10 10:00:00', amount: 10, status: 'paid', currency: 'CAD' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when Helcim returns non-2xx', async () => {
|
||||||
|
mockFetch.mockResolvedValue(errResponse(500))
|
||||||
|
|
||||||
|
await expect(listHelcimCustomerTransactions('CST1044')).rejects.toMatchObject({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Transaction lookup failed'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateHelcimCustomerDefaultPaymentMethod', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves cardToken to cardId then PATCHes default endpoint', async () => {
|
||||||
|
// First call: list cards filtered by token
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse([
|
||||||
|
{ id: 161462, cardToken: 'tok-abc', cardHolderName: 'Jane' }
|
||||||
|
]))
|
||||||
|
// Second call: PATCH default
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse({ id: 2488717, customerCode: 'CST1200' }))
|
||||||
|
|
||||||
|
const result = await updateHelcimCustomerDefaultPaymentMethod('2488717', 'tok-abc')
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
const firstUrl = mockFetch.mock.calls[0][0]
|
||||||
|
expect(firstUrl).toContain('/customers/2488717/cards')
|
||||||
|
expect(firstUrl).toContain('cardToken=tok-abc')
|
||||||
|
|
||||||
|
const secondUrl = mockFetch.mock.calls[1][0]
|
||||||
|
const secondOpts = mockFetch.mock.calls[1][1]
|
||||||
|
expect(secondUrl).toContain('/customers/2488717/cards/161462/default')
|
||||||
|
expect(secondOpts.method).toBe('PATCH')
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 2488717, customerCode: 'CST1200' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws 404 when cardToken is not attached to the customer', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse([]))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateHelcimCustomerDefaultPaymentMethod('2488717', 'nope')
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Card token not found on customer'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only the lookup should have happened — no PATCH
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates Helcim errors from the PATCH call', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(okResponse([
|
||||||
|
{ id: 161462, cardToken: 'tok-abc' }
|
||||||
|
]))
|
||||||
|
mockFetch.mockResolvedValueOnce(errResponse(500))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateHelcimCustomerDefaultPaymentMethod('2488717', 'tok-abc')
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Default card update failed'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateHelcimSubscriptionPaymentMethod', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
mockFetch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PATCHes /subscriptions with cardToken wrapped in subscriptions array', async () => {
|
||||||
|
mockFetch.mockResolvedValue(okResponse({ data: [{ id: 123456 }] }))
|
||||||
|
|
||||||
|
const result = await updateHelcimSubscriptionPaymentMethod('123456', 'tok-abc')
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0]
|
||||||
|
expect(url).toContain('/subscriptions')
|
||||||
|
expect(opts.method).toBe('PATCH')
|
||||||
|
|
||||||
|
const body = JSON.parse(opts.body)
|
||||||
|
expect(body).toEqual({
|
||||||
|
subscriptions: [{ id: 123456, cardToken: 'tok-abc' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ data: [{ id: 123456 }] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when Helcim returns non-2xx', async () => {
|
||||||
|
mockFetch.mockResolvedValue(errResponse(400))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateHelcimSubscriptionPaymentMethod('123456', 'tok-abc')
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Subscription update failed'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue