From 6888663148920da29bb3dc9d783ead4e8913c6f7 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 16:24:16 +0100 Subject: [PATCH] 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). --- server/utils/helcim.js | 107 ++++++++++++++++ server/utils/schemas.js | 4 + tests/server/utils/helcim.test.js | 194 ++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 tests/server/utils/helcim.test.js diff --git a/server/utils/helcim.js b/server/utils/helcim.js index d891612..11216a4 100644 --- a/server/utils/helcim.js +++ b/server/utils/helcim.js @@ -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} 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} + */ +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 = () => diff --git a/server/utils/schemas.js b/server/utils/schemas.js index dd5de22..4d7ad16 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -79,6 +79,10 @@ export const helcimSubscriptionSchema = z.object({ cadence: z.enum(['monthly', 'annual']).default('monthly') }) +export const helcimUpdateCardSchema = z.object({ + cardToken: z.string().min(1).max(500) +}) + export const helcimUpdateBillingSchema = z.object({ customerId: z.union([z.string().min(1), z.number()]), billingAddress: z.object({ diff --git a/tests/server/utils/helcim.test.js b/tests/server/utils/helcim.test.js new file mode 100644 index 0000000..d863b99 --- /dev/null +++ b/tests/server/utils/helcim.test.js @@ -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' + }) + }) +})