From 0f841912e2868a48bb4c7853ada4c60b807ab409 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 26 Apr 2026 17:27:40 +0100 Subject: [PATCH] fix(helcim): skip HelcimPay verify when a card is already on file Helcim refuses paymentType:'verify' for cards already saved on a customer ("A new card must be entered for saving the payment method"), breaking every "Complete Payment" retry after a partial-failed signup. Add GET /api/helcim/existing-card and short-circuit HelcimPay verify in useMemberPayment + payment-setup.vue when a saved card is found, going straight to subscription creation. The two existence-check fetches run in parallel with get-or-create-customer so no extra round-trip latency in the common path. --- app/composables/useMemberPayment.js | 65 +++++----- app/pages/member/payment-setup.vue | 38 ++++-- server/api/helcim/existing-card.get.js | 25 ++++ tests/server/api/helcim-existing-card.test.js | 115 ++++++++++++++++++ 4 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 server/api/helcim/existing-card.get.js create mode 100644 tests/server/api/helcim-existing-card.test.js diff --git a/app/composables/useMemberPayment.js b/app/composables/useMemberPayment.js index 0064b71..23255a7 100644 --- a/app/composables/useMemberPayment.js +++ b/app/composables/useMemberPayment.js @@ -25,45 +25,53 @@ export const useMemberPayment = () => { paymentSuccess.value = false try { - // Step 1: Get or create Helcim customer - await getOrCreateCustomer() + // Skip HelcimPay verify if a card's already on file — Helcim refuses + // to re-save it, breaking retries after a partial-failed signup. + const [, existing] = await Promise.all([ + getOrCreateCustomer(), + $fetch('/api/helcim/existing-card').catch((err) => { + console.warn('[payment] existing-card lookup failed, falling back to verify flow:', err) + return null + }), + ]) - // Step 2: Initialize Helcim payment with $0 for card verification - await initializeHelcimPay( - customerId.value, - customerCode.value, - 0, - ) + let cardToken = existing?.cardToken || null - // Step 3: Show payment modal and get payment result - const paymentResult = await verifyPayment() - console.log('Payment result:', paymentResult) + if (!cardToken) { + await initializeHelcimPay( + customerId.value, + customerCode.value, + 0, + ) - if (!paymentResult.success) { - throw new Error('Payment verification failed') + const paymentResult = await verifyPayment() + + if (!paymentResult.success) { + throw new Error('Payment verification failed') + } + + const verifyResult = await $fetch('/api/helcim/verify-payment', { + method: 'POST', + body: { + cardToken: paymentResult.cardToken, + customerId: customerId.value, + }, + }) + + if (!verifyResult.success) { + throw new Error('Payment verification failed on backend') + } + + cardToken = paymentResult.cardToken } - // Step 4: Verify payment on backend - const verifyResult = await $fetch('/api/helcim/verify-payment', { - method: 'POST', - body: { - cardToken: paymentResult.cardToken, - customerId: customerId.value, - }, - }) - - if (!verifyResult.success) { - throw new Error('Payment verification failed on backend') - } - - // Step 5: Create subscription with proper contribution tier const subscriptionResponse = await $fetch('/api/helcim/subscription', { method: 'POST', body: { customerId: customerId.value, customerCode: customerCode.value, contributionAmount: memberData.value?.contributionAmount ?? 5, - cardToken: paymentResult.cardToken, + cardToken, }, }) @@ -71,7 +79,6 @@ export const useMemberPayment = () => { throw new Error('Subscription creation failed') } - // Step 6: Payment successful - refresh member data paymentSuccess.value = true await checkMemberStatus() diff --git a/app/pages/member/payment-setup.vue b/app/pages/member/payment-setup.vue index 9cc3a29..5efb6f6 100644 --- a/app/pages/member/payment-setup.vue +++ b/app/pages/member/payment-setup.vue @@ -72,6 +72,7 @@ const errorMessage = ref(''); const isProcessing = ref(false); const customerId = ref(''); const customerCode = ref(''); +const hasExistingCard = ref(false); const initialize = async () => { errorMessage.value = ''; @@ -84,13 +85,22 @@ const initialize = async () => { } try { - const customer = await $fetch('/api/helcim/get-or-create-customer', { - method: 'POST', - }); + // Skip HelcimPay verify if a card's already on file — Helcim refuses + // to re-save it, breaking retries after a partial-failed signup. + const [customer, existing] = await Promise.all([ + $fetch('/api/helcim/get-or-create-customer', { method: 'POST' }), + $fetch('/api/helcim/existing-card').catch((err) => { + console.warn('[payment-setup] existing-card lookup failed, falling back to verify flow:', err); + return null; + }), + ]); customerId.value = customer.customerId; customerCode.value = customer.customerCode; + hasExistingCard.value = Boolean(existing?.cardToken); - await initializeHelcimPay(customerId.value, customerCode.value, 0); + if (!hasExistingCard.value) { + await initializeHelcimPay(customerId.value, customerCode.value, 0); + } step.value = 'ready'; } catch (err) { console.error('Payment setup init failed:', err); @@ -106,16 +116,18 @@ const openModal = async () => { errorMessage.value = ''; try { - const result = await verifyPayment(); - if (!result?.success) throw new Error('Payment was not completed.'); + if (!hasExistingCard.value) { + const result = await verifyPayment(); + if (!result?.success) throw new Error('Payment was not completed.'); - await $fetch('/api/helcim/verify-payment', { - method: 'POST', - body: { - cardToken: result.cardToken, - customerId: customerId.value, - }, - }); + await $fetch('/api/helcim/verify-payment', { + method: 'POST', + body: { + cardToken: result.cardToken, + customerId: customerId.value, + }, + }); + } // Update circle first if it changed — update-contribution only touches tier. if (targetCircle.value && targetCircle.value !== memberData.value?.circle) { diff --git a/server/api/helcim/existing-card.get.js b/server/api/helcim/existing-card.get.js new file mode 100644 index 0000000..14405fc --- /dev/null +++ b/server/api/helcim/existing-card.get.js @@ -0,0 +1,25 @@ +// Returns the saved cardToken so callers can skip HelcimPay re-entry. +import { requireAuth } from '../../utils/auth.js' +import { listHelcimCustomerCards } from '../../utils/helcim.js' + +export default defineEventHandler(async (event) => { + const member = await requireAuth(event) + + if (!member.helcimCustomerId) { + return { cardToken: null } + } + + const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId) + const cards = Array.isArray(cardsResponse) + ? cardsResponse + : (cardsResponse?.cards || cardsResponse?.data || []) + + if (!cards.length) { + return { cardToken: null } + } + + const defaultCard = + cards.find((c) => c?.default === true || c?.isDefault === true) || cards[0] + + return { cardToken: defaultCard?.cardToken || null } +}) diff --git a/tests/server/api/helcim-existing-card.test.js b/tests/server/api/helcim-existing-card.test.js new file mode 100644 index 0000000..4dcf2ce --- /dev/null +++ b/tests/server/api/helcim-existing-card.test.js @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { requireAuth } from '../../../server/utils/auth.js' +import { listHelcimCustomerCards } from '../../../server/utils/helcim.js' +import existingCardHandler from '../../../server/api/helcim/existing-card.get.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() })) +vi.mock('../../../server/utils/helcim.js', () => ({ + listHelcimCustomerCards: vi.fn() +})) + +const newEvent = () => + createMockEvent({ method: 'GET', path: '/api/helcim/existing-card' }) + +describe('helcim existing-card endpoint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('requires auth', async () => { + requireAuth.mockRejectedValue( + createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + ) + + await expect(existingCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 401 }) + expect(listHelcimCustomerCards).not.toHaveBeenCalled() + }) + + it('returns { cardToken: null } when member has no helcimCustomerId', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: null }) + + const result = await existingCardHandler(newEvent()) + + expect(result).toEqual({ cardToken: null }) + expect(listHelcimCustomerCards).not.toHaveBeenCalled() + }) + + it('returns { cardToken: null } when customer has no cards', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) + listHelcimCustomerCards.mockResolvedValue([]) + + const result = await existingCardHandler(newEvent()) + + expect(result).toEqual({ cardToken: null }) + expect(listHelcimCustomerCards).toHaveBeenCalledWith(9876) + }) + + it('returns the default card when one is flagged default:true', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) + listHelcimCustomerCards.mockResolvedValue([ + { cardToken: 'tok-old' }, + { cardToken: 'tok-default', default: true } + ]) + + const result = await existingCardHandler(newEvent()) + + expect(result).toEqual({ cardToken: 'tok-default' }) + }) + + it('falls back to first card when no card is flagged default', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) + listHelcimCustomerCards.mockResolvedValue([ + { cardToken: 'tok-first' }, + { cardToken: 'tok-second' } + ]) + + const result = await existingCardHandler(newEvent()) + + expect(result).toEqual({ cardToken: 'tok-first' }) + }) + + it('handles isDefault flag (alternative shape)', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) + listHelcimCustomerCards.mockResolvedValue([ + { cardToken: 'tok-a' }, + { cardToken: 'tok-b', isDefault: true } + ]) + + const result = await existingCardHandler(newEvent()) + + expect(result.cardToken).toBe('tok-b') + }) + + it('unwraps a { cards: [...] } response envelope', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) + listHelcimCustomerCards.mockResolvedValue({ + cards: [{ cardToken: 'tok-only' }] + }) + + const result = await existingCardHandler(newEvent()) + + expect(result.cardToken).toBe('tok-only') + }) + + it('unwraps a { data: [...] } response envelope', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) + listHelcimCustomerCards.mockResolvedValue({ + data: [{ cardToken: 'tok-only' }] + }) + + const result = await existingCardHandler(newEvent()) + + expect(result.cardToken).toBe('tok-only') + }) + + it('returns { cardToken: null } if the resolved card has no cardToken', async () => { + requireAuth.mockResolvedValue({ _id: 'm1', helcimCustomerId: 9876 }) + listHelcimCustomerCards.mockResolvedValue([{ default: true }]) + + const result = await existingCardHandler(newEvent()) + + expect(result).toEqual({ cardToken: null }) + }) +})