feat(billing): add update-card API route with rollback + status gate
POST /api/helcim/update-card updates the customer's default card, then
best-effort patches the active subscription payment method. Status-gated
to {active, pending_payment}; verifies the submitted cardToken is
attached to the member's helcimCustomerId via listHelcimCustomerCards.
On subscription PATCH 5xx we revert the customer default to the prior
card token; 4xx (schema rejection — cardToken is not a documented
subscription PATCH field) is tolerated since the customer default is
the authoritative billing driver.
This commit is contained in:
parent
4f9c11a755
commit
101d6a231b
2 changed files with 328 additions and 0 deletions
132
server/api/helcim/update-card.post.js
Normal file
132
server/api/helcim/update-card.post.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// Update the authenticated member's default card in Helcim.
|
||||
// Flow:
|
||||
// 1. requireAuth + status gate (active | pending_payment).
|
||||
// 2. Validate body (cardToken).
|
||||
// 3. Verify cardToken belongs to this customer via listHelcimCustomerCards.
|
||||
// 4. Capture current default card token for possible revert.
|
||||
// 5. Update customer default payment method (authoritative — Helcim bills the customer default).
|
||||
// 6. Update subscription payment method (best-effort belt-and-suspenders).
|
||||
// - On 4xx from step 6 (likely "unknown field" since Helcim's subscription
|
||||
// PATCH schema does not publicly document a cardToken field — see plan
|
||||
// Open Question #3): log and treat as success, since the customer
|
||||
// default already changed and is the real billing driver.
|
||||
// - On 5xx/network from step 6: attempt revert of step 5 and return 500.
|
||||
// 7. logActivity on success.
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
import { validateBody } from '../../utils/validateBody.js'
|
||||
import { helcimUpdateCardSchema } from '../../utils/schemas.js'
|
||||
import {
|
||||
listHelcimCustomerCards,
|
||||
updateHelcimCustomerDefaultPaymentMethod,
|
||||
updateHelcimSubscriptionPaymentMethod
|
||||
} from '../../utils/helcim.js'
|
||||
import { logActivity } from '../../utils/activityLog.js'
|
||||
|
||||
const ALLOWED_STATUSES = new Set(['active', 'pending_payment'])
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const member = await requireAuth(event)
|
||||
|
||||
if (!ALLOWED_STATUSES.has(member.status)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Card updates are only available for active subscriptions'
|
||||
})
|
||||
}
|
||||
|
||||
if (!member.helcimCustomerId || !member.helcimSubscriptionId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'No active subscription on file'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await validateBody(event, helcimUpdateCardSchema)
|
||||
const { cardToken } = body
|
||||
|
||||
// Step 3: verify the submitted token is attached to this member's customer
|
||||
const cardsResponse = await listHelcimCustomerCards(member.helcimCustomerId)
|
||||
const cards = Array.isArray(cardsResponse)
|
||||
? cardsResponse
|
||||
: (cardsResponse?.cards || cardsResponse?.data || [])
|
||||
|
||||
const matchingCard = cards.find((c) => c?.cardToken === cardToken)
|
||||
if (!matchingCard) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Card token does not belong to this customer'
|
||||
})
|
||||
}
|
||||
|
||||
// Step 4: capture prior default card token for possible revert
|
||||
const priorDefault = cards.find((c) => c?.default === true || c?.isDefault === true)
|
||||
const priorCardToken = priorDefault?.cardToken || null
|
||||
|
||||
// Step 5: authoritative change — update customer default
|
||||
try {
|
||||
await updateHelcimCustomerDefaultPaymentMethod(member.helcimCustomerId, cardToken)
|
||||
} catch (error) {
|
||||
console.error('[update-card] Customer default update failed', {
|
||||
memberId: String(member._id),
|
||||
error: error?.statusMessage || error?.message || error
|
||||
})
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to update payment method'
|
||||
})
|
||||
}
|
||||
|
||||
// Step 6: best-effort subscription payment-method update.
|
||||
// Plan Open Question #3: Helcim's subscription PATCH does not publicly
|
||||
// document a cardToken field. We tolerate 4xx (unknown field / schema
|
||||
// rejection) because step 5 — the customer default — is authoritative.
|
||||
// On 5xx/network errors we treat state as untrusted and attempt revert.
|
||||
try {
|
||||
await updateHelcimSubscriptionPaymentMethod(member.helcimSubscriptionId, cardToken)
|
||||
} catch (error) {
|
||||
const status = Number(error?.statusCode) || 0
|
||||
const isClientError = status >= 400 && status < 500
|
||||
|
||||
if (isClientError) {
|
||||
console.warn('[update-card] Subscription PATCH rejected (4xx); treating as best-effort', {
|
||||
memberId: String(member._id),
|
||||
status,
|
||||
message: error?.statusMessage || error?.message
|
||||
})
|
||||
// fall through to success — customer default is the billing driver
|
||||
} else {
|
||||
// 5xx / network / unknown — revert customer default if we can
|
||||
console.error('[update-card] Subscription update failed with server error; attempting revert', {
|
||||
memberId: String(member._id),
|
||||
status,
|
||||
error: error?.statusMessage || error?.message || error
|
||||
})
|
||||
|
||||
if (priorCardToken) {
|
||||
try {
|
||||
await updateHelcimCustomerDefaultPaymentMethod(member.helcimCustomerId, priorCardToken)
|
||||
} catch (revertError) {
|
||||
console.error('[update-card] Revert also failed — INCONSISTENT STATE', {
|
||||
memberId: String(member._id),
|
||||
helcimCustomerId: member.helcimCustomerId,
|
||||
revertError: revertError?.statusMessage || revertError?.message || revertError
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.error('[update-card] No prior default card to revert to', {
|
||||
memberId: String(member._id)
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to update payment method'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: success
|
||||
await logActivity(member._id, 'billing_card_updated', {}, { visibility: 'member' })
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
196
tests/server/api/helcim-update-card.test.js
Normal file
196
tests/server/api/helcim-update-card.test.js
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { requireAuth } from '../../../server/utils/auth.js'
|
||||
import { validateBody } from '../../../server/utils/validateBody.js'
|
||||
import {
|
||||
listHelcimCustomerCards,
|
||||
updateHelcimCustomerDefaultPaymentMethod,
|
||||
updateHelcimSubscriptionPaymentMethod
|
||||
} from '../../../server/utils/helcim.js'
|
||||
import { logActivity } from '../../../server/utils/activityLog.js'
|
||||
import updateCardHandler from '../../../server/api/helcim/update-card.post.js'
|
||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||
|
||||
vi.mock('../../../server/utils/auth.js', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||
vi.mock('../../../server/utils/schemas.js', () => ({ helcimUpdateCardSchema: {} }))
|
||||
vi.mock('../../../server/utils/helcim.js', () => ({
|
||||
listHelcimCustomerCards: vi.fn(),
|
||||
updateHelcimCustomerDefaultPaymentMethod: vi.fn(),
|
||||
updateHelcimSubscriptionPaymentMethod: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../server/utils/activityLog.js', () => ({ logActivity: vi.fn() }))
|
||||
|
||||
const activeMember = () => ({
|
||||
_id: 'member-1',
|
||||
status: 'active',
|
||||
helcimCustomerId: 9876,
|
||||
helcimSubscriptionId: 5555
|
||||
})
|
||||
|
||||
const cardsList = () => ([
|
||||
{ cardToken: 'tok-old', id: 1, default: true },
|
||||
{ cardToken: 'tok-new', id: 2 }
|
||||
])
|
||||
|
||||
const newEvent = () =>
|
||||
createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/update-card',
|
||||
body: { cardToken: 'tok-new' }
|
||||
})
|
||||
|
||||
describe('helcim update-card endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
validateBody.mockResolvedValue({ cardToken: 'tok-new' })
|
||||
})
|
||||
|
||||
it('happy path: updates customer default + subscription, logs activity, returns success', async () => {
|
||||
requireAuth.mockResolvedValue(activeMember())
|
||||
listHelcimCustomerCards.mockResolvedValue(cardsList())
|
||||
updateHelcimCustomerDefaultPaymentMethod.mockResolvedValue({})
|
||||
updateHelcimSubscriptionPaymentMethod.mockResolvedValue({})
|
||||
|
||||
const result = await updateCardHandler(newEvent())
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(1)
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledWith(9876, 'tok-new')
|
||||
expect(updateHelcimSubscriptionPaymentMethod).toHaveBeenCalledWith(5555, 'tok-new')
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
'member-1',
|
||||
'billing_card_updated',
|
||||
{},
|
||||
{ visibility: 'member' }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuth.mockRejectedValue(
|
||||
createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
)
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 401 })
|
||||
|
||||
expect(listHelcimCustomerCards).not.toHaveBeenCalled()
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled()
|
||||
expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when member status is not active/pending_payment', async () => {
|
||||
requireAuth.mockResolvedValue({ ...activeMember(), status: 'cancelled' })
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 403 })
|
||||
|
||||
expect(listHelcimCustomerCards).not.toHaveBeenCalled()
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled()
|
||||
expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when member is missing helcimCustomerId', async () => {
|
||||
requireAuth.mockResolvedValue({ ...activeMember(), helcimCustomerId: null })
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 400 })
|
||||
|
||||
expect(listHelcimCustomerCards).not.toHaveBeenCalled()
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when member is missing helcimSubscriptionId', async () => {
|
||||
requireAuth.mockResolvedValue({ ...activeMember(), helcimSubscriptionId: null })
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 400 })
|
||||
|
||||
expect(listHelcimCustomerCards).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when cardToken is not attached to the customer', async () => {
|
||||
requireAuth.mockResolvedValue(activeMember())
|
||||
listHelcimCustomerCards.mockResolvedValue([
|
||||
{ cardToken: 'tok-old', id: 1, default: true }
|
||||
])
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 403 })
|
||||
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).not.toHaveBeenCalled()
|
||||
expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 500 and does NOT call subscription update when customer default update throws', async () => {
|
||||
requireAuth.mockResolvedValue(activeMember())
|
||||
listHelcimCustomerCards.mockResolvedValue(cardsList())
|
||||
updateHelcimCustomerDefaultPaymentMethod.mockRejectedValue(
|
||||
createError({ statusCode: 500, statusMessage: 'Helcim down' })
|
||||
)
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 500 })
|
||||
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(1)
|
||||
expect(updateHelcimSubscriptionPaymentMethod).not.toHaveBeenCalled()
|
||||
expect(logActivity).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('partial failure with 5xx: reverts customer default with prior token and returns 500', async () => {
|
||||
requireAuth.mockResolvedValue(activeMember())
|
||||
listHelcimCustomerCards.mockResolvedValue(cardsList())
|
||||
updateHelcimCustomerDefaultPaymentMethod.mockResolvedValue({})
|
||||
updateHelcimSubscriptionPaymentMethod.mockRejectedValue(
|
||||
createError({ statusCode: 502, statusMessage: 'Bad gateway' })
|
||||
)
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 500 })
|
||||
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(2)
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenNthCalledWith(1, 9876, 'tok-new')
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenNthCalledWith(2, 9876, 'tok-old')
|
||||
expect(logActivity).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subscription PATCH 4xx is tolerated: returns success without revert', async () => {
|
||||
requireAuth.mockResolvedValue(activeMember())
|
||||
listHelcimCustomerCards.mockResolvedValue(cardsList())
|
||||
updateHelcimCustomerDefaultPaymentMethod.mockResolvedValue({})
|
||||
updateHelcimSubscriptionPaymentMethod.mockRejectedValue(
|
||||
createError({ statusCode: 400, statusMessage: 'Unknown field: cardToken' })
|
||||
)
|
||||
|
||||
const result = await updateCardHandler(newEvent())
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
// customer default was set once, no revert
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(1)
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
'member-1',
|
||||
'billing_card_updated',
|
||||
{},
|
||||
{ visibility: 'member' }
|
||||
)
|
||||
})
|
||||
|
||||
it('revert-also-fails: returns 500 and inconsistency is logged', async () => {
|
||||
requireAuth.mockResolvedValue(activeMember())
|
||||
listHelcimCustomerCards.mockResolvedValue(cardsList())
|
||||
// First call (set new default) succeeds; second call (revert) fails
|
||||
updateHelcimCustomerDefaultPaymentMethod
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(createError({ statusCode: 500, statusMessage: 'still down' }))
|
||||
updateHelcimSubscriptionPaymentMethod.mockRejectedValue(
|
||||
createError({ statusCode: 503, statusMessage: 'Service unavailable' })
|
||||
)
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await expect(updateCardHandler(newEvent())).rejects.toMatchObject({ statusCode: 500 })
|
||||
|
||||
expect(updateHelcimCustomerDefaultPaymentMethod).toHaveBeenCalledTimes(2)
|
||||
|
||||
const loggedInconsistency = errorSpy.mock.calls.some(([msg]) =>
|
||||
typeof msg === 'string' && msg.includes('INCONSISTENT STATE')
|
||||
)
|
||||
expect(loggedInconsistency).toBe(true)
|
||||
expect(logActivity).not.toHaveBeenCalled()
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue