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.
This commit is contained in:
parent
e3410c52a5
commit
0f841912e2
4 changed files with 201 additions and 42 deletions
|
|
@ -25,25 +25,31 @@ export const useMemberPayment = () => {
|
||||||
paymentSuccess.value = false
|
paymentSuccess.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get or create Helcim customer
|
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||||
await getOrCreateCustomer()
|
// 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
|
let cardToken = existing?.cardToken || null
|
||||||
|
|
||||||
|
if (!cardToken) {
|
||||||
await initializeHelcimPay(
|
await initializeHelcimPay(
|
||||||
customerId.value,
|
customerId.value,
|
||||||
customerCode.value,
|
customerCode.value,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 3: Show payment modal and get payment result
|
|
||||||
const paymentResult = await verifyPayment()
|
const paymentResult = await verifyPayment()
|
||||||
console.log('Payment result:', paymentResult)
|
|
||||||
|
|
||||||
if (!paymentResult.success) {
|
if (!paymentResult.success) {
|
||||||
throw new Error('Payment verification failed')
|
throw new Error('Payment verification failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Verify payment on backend
|
|
||||||
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
const verifyResult = await $fetch('/api/helcim/verify-payment', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -56,14 +62,16 @@ export const useMemberPayment = () => {
|
||||||
throw new Error('Payment verification failed on backend')
|
throw new Error('Payment verification failed on backend')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Create subscription with proper contribution tier
|
cardToken = paymentResult.cardToken
|
||||||
|
}
|
||||||
|
|
||||||
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
|
const subscriptionResponse = await $fetch('/api/helcim/subscription', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
customerId: customerId.value,
|
customerId: customerId.value,
|
||||||
customerCode: customerCode.value,
|
customerCode: customerCode.value,
|
||||||
contributionAmount: memberData.value?.contributionAmount ?? 5,
|
contributionAmount: memberData.value?.contributionAmount ?? 5,
|
||||||
cardToken: paymentResult.cardToken,
|
cardToken,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -71,7 +79,6 @@ export const useMemberPayment = () => {
|
||||||
throw new Error('Subscription creation failed')
|
throw new Error('Subscription creation failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Payment successful - refresh member data
|
|
||||||
paymentSuccess.value = true
|
paymentSuccess.value = true
|
||||||
await checkMemberStatus()
|
await checkMemberStatus()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const errorMessage = ref('');
|
||||||
const isProcessing = ref(false);
|
const isProcessing = ref(false);
|
||||||
const customerId = ref('');
|
const customerId = ref('');
|
||||||
const customerCode = ref('');
|
const customerCode = ref('');
|
||||||
|
const hasExistingCard = ref(false);
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
@ -84,13 +85,22 @@ const initialize = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const customer = await $fetch('/api/helcim/get-or-create-customer', {
|
// Skip HelcimPay verify if a card's already on file — Helcim refuses
|
||||||
method: 'POST',
|
// 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;
|
customerId.value = customer.customerId;
|
||||||
customerCode.value = customer.customerCode;
|
customerCode.value = customer.customerCode;
|
||||||
|
hasExistingCard.value = Boolean(existing?.cardToken);
|
||||||
|
|
||||||
|
if (!hasExistingCard.value) {
|
||||||
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
await initializeHelcimPay(customerId.value, customerCode.value, 0);
|
||||||
|
}
|
||||||
step.value = 'ready';
|
step.value = 'ready';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Payment setup init failed:', err);
|
console.error('Payment setup init failed:', err);
|
||||||
|
|
@ -106,6 +116,7 @@ const openModal = async () => {
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!hasExistingCard.value) {
|
||||||
const result = await verifyPayment();
|
const result = await verifyPayment();
|
||||||
if (!result?.success) throw new Error('Payment was not completed.');
|
if (!result?.success) throw new Error('Payment was not completed.');
|
||||||
|
|
||||||
|
|
@ -116,6 +127,7 @@ const openModal = async () => {
|
||||||
customerId: customerId.value,
|
customerId: customerId.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update circle first if it changed — update-contribution only touches tier.
|
// Update circle first if it changed — update-contribution only touches tier.
|
||||||
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {
|
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {
|
||||||
|
|
|
||||||
25
server/api/helcim/existing-card.get.js
Normal file
25
server/api/helcim/existing-card.get.js
Normal file
|
|
@ -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 }
|
||||||
|
})
|
||||||
115
tests/server/api/helcim-existing-card.test.js
Normal file
115
tests/server/api/helcim-existing-card.test.js
Normal file
|
|
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue